492 lines
14 KiB
JavaScript
Raw Normal View History

/**
* 光湖·内测 · 前端逻辑
* 登录 + 聊天SSE流 + 自动翻转 + 工具调用
* 无框架 · Vanilla JS
*/
(function(){
'use strict';
// ─── 配置 ───
const API_BASE = '/hli/ftchat';
const LS_PREFIX = 'ftchat_';
const SESSION_THRESHOLD = 50; // 超过N条自动翻转
// ─── 状态 ───
let state = {
token: null,
email: null,
userHash: null,
slotIndex: null,
messages: [],
currentStream: null, // AbortController
isSending: false,
isLoggedIn: false,
hasCodeSent: false,
currentEmail: ''
};
// ─── DOM 引用 ───
const $ = (id) => document.getElementById(id);
const loginScreen = $('loginScreen');
const chatScreen = $('chatScreen');
const loginForm = $('loginForm');
const emailInput = $('emailInput');
const codeInput = $('codeInput');
const codeGroup = $('codeGroup');
const loginBtn = $('loginBtn');
const loginStatus = $('loginStatus');
const slotInfo = $('slotInfo');
const chatMessages = $('chatMessages');
const chatInput = $('chatInput');
const sendBtn = $('sendBtn');
const userEmail = $('userEmail');
// ─── 星场背景 ───
function initStarfield() {
const canvas = document.getElementById('starfield');
if (!canvas) return;
const ctx = canvas.getContext('2d');
let stars = [];
let W, H;
function resize() {
W = canvas.width = window.innerWidth;
H = canvas.height = window.innerHeight;
}
function createStars() {
stars = [];
const count = Math.min(Math.floor(W * H / 8000), 120);
for (let i = 0; i < count; i++) {
stars.push({
x: Math.random() * W,
y: Math.random() * H,
r: Math.random() * 1.5 + 0.5,
a: Math.random() * 0.8 + 0.2,
speed: Math.random() * 0.005 + 0.002
});
}
}
function draw() {
ctx.clearRect(0, 0, W, H);
const t = Date.now();
for (const s of stars) {
const twinkle = Math.sin(t * s.speed) * 0.3 + 0.7;
ctx.beginPath();
ctx.arc(s.x, s.y, s.r, 0, Math.PI * 2);
ctx.fillStyle = 'rgba(200, 200, 255, ' + (s.a * twinkle) + ')';
ctx.fill();
}
requestAnimationFrame(draw);
}
window.addEventListener('resize', () => { resize(); createStars(); });
resize();
createStars();
draw();
}
// ─── 登录逻辑 ───
function showStatus(msg, type) {
loginStatus.style.display = 'block';
loginStatus.className = 'login-status ' + type;
loginStatus.textContent = msg;
}
function updateSlotInfo() {
fetch(API_BASE + '/status')
.then(r => r.json())
.then(d => {
if (d.slots_remaining !== undefined) {
slotInfo.textContent = '剩余内测席位: ' + d.slots_remaining + ' / ' + d.slots_total;
}
})
.catch(() => {});
}
window.handleLogin = function() {
const email = emailInput.value.trim();
if (!email) { showStatus('请输入QQ邮箱', 'error'); return; }
if (!state.hasCodeSent) {
// 发送验证码
loginBtn.disabled = true;
loginBtn.textContent = '发送中...';
state.currentEmail = email;
fetch(API_BASE + '/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: email })
})
.then(r => r.json())
.then(d => {
loginBtn.disabled = false;
if (d.success) {
state.hasCodeSent = true;
codeGroup.style.display = 'block';
codeInput.focus();
loginBtn.textContent = '登录';
showStatus('验证码已发送(开发模式: 请查看服务器日志)', 'info');
emailInput.readOnly = true;
} else {
loginBtn.textContent = '发送验证码';
showStatus(d.message || '发送失败', 'error');
}
})
.catch(err => {
loginBtn.disabled = false;
loginBtn.textContent = '发送验证码';
showStatus('网络错误: ' + err.message, 'error');
});
} else {
// 验证码
const code = codeInput.value.trim();
if (!code || code.length !== 6) { showStatus('请输入6位验证码', 'error'); return; }
loginBtn.disabled = true;
loginBtn.textContent = '验证中...';
fetch(API_BASE + '/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: state.currentEmail, code: code })
})
.then(r => r.json())
.then(d => {
loginBtn.disabled = false;
if (d.success) {
onLoginSuccess(d);
} else {
loginBtn.textContent = '登录';
showStatus(d.message || '验证失败', 'error');
codeInput.value = '';
codeInput.focus();
}
})
.catch(err => {
loginBtn.disabled = false;
loginBtn.textContent = '登录';
showStatus('网络错误: ' + err.message, 'error');
});
}
};
function onLoginSuccess(data) {
state.token = data.token;
state.userHash = data.user_hash;
state.email = state.currentEmail || data.email;
state.slotIndex = data.slot_index;
state.isLoggedIn = true;
localStorage.setItem(LS_PREFIX + 'token', data.token);
localStorage.setItem(LS_PREFIX + 'email', state.email);
localStorage.setItem(LS_PREFIX + 'hash', data.user_hash);
userEmail.textContent = state.email;
loginScreen.classList.remove('active');
chatScreen.classList.add('active');
loadConversation();
chatInput.focus();
}
function logout() {
state.token = null;
state.isLoggedIn = false;
state.hasCodeSent = false;
state.messages = [];
state.currentEmail = '';
state.email = null;
localStorage.removeItem(LS_PREFIX + 'token');
localStorage.removeItem(LS_PREFIX + 'email');
localStorage.removeItem(LS_PREFIX + 'hash');
emailInput.value = '';
emailInput.readOnly = false;
codeInput.value = '';
codeGroup.style.display = 'none';
loginBtn.textContent = '发送验证码';
loginBtn.disabled = false;
loginStatus.style.display = 'none';
chatScreen.classList.remove('active');
loginScreen.classList.add('active');
}
window.logout = logout;
function restoreSession() {
const token = localStorage.getItem(LS_PREFIX + 'token');
const email = localStorage.getItem(LS_PREFIX + 'email');
const hash = localStorage.getItem(LS_PREFIX + 'hash');
if (token && email && hash) {
state.token = token;
state.email = email;
state.userHash = hash;
state.isLoggedIn = true;
userEmail.textContent = email;
loginScreen.classList.remove('active');
chatScreen.classList.add('active');
loadConversation();
chatInput.focus();
return true;
}
return false;
}
// ─── 聊天逻辑 ───
function getStorageKey() {
return LS_PREFIX + state.userHash + '_messages';
}
function loadConversation() {
try {
const saved = localStorage.getItem(getStorageKey());
if (saved) {
state.messages = JSON.parse(saved);
renderMessages();
} else {
state.messages = [];
chatMessages.innerHTML = '<div class="message system-note"><div class="message-content">开始新对话 · 母模型已就绪</div></div>';
}
} catch (e) {
state.messages = [];
}
}
function saveConversation() {
try {
localStorage.setItem(getStorageKey(), JSON.stringify(state.messages));
} catch (e) {
// Storage full - trim history
if (e.name === 'QuotaExceededError') {
state.messages = state.messages.slice(-100);
try { localStorage.setItem(getStorageKey(), JSON.stringify(state.messages)); } catch {}
}
}
}
function addMessage(role, content) {
state.messages.push({ role: role, content: content });
saveConversation();
}
function renderMessages(scrollToBottom) {
chatMessages.innerHTML = '';
for (const msg of state.messages) {
appendMessageElement(msg.role, msg.content, false);
}
if (scrollToBottom !== false) {
chatMessages.scrollTop = chatMessages.scrollHeight;
}
}
function appendMessageElement(role, content, isNew) {
const div = document.createElement('div');
div.className = 'message ' + role;
const contentDiv = document.createElement('div');
contentDiv.className = 'message-content';
contentDiv.textContent = content;
div.appendChild(contentDiv);
chatMessages.appendChild(div);
if (isNew) chatMessages.scrollTop = chatMessages.scrollHeight;
}
function appendStreamingMessage(role) {
const div = document.createElement('div');
div.className = 'message ' + role;
const contentDiv = document.createElement('div');
contentDiv.className = 'message-content cursor-blink';
contentDiv.textContent = '';
div.appendChild(contentDiv);
chatMessages.appendChild(div);
chatMessages.scrollTop = chatMessages.scrollHeight;
return contentDiv;
}
// ─── 发送消息 ───
window.sendMessage = function() {
if (state.isSending) return;
const text = chatInput.value.trim();
if (!text) return;
chatInput.value = '';
chatInput.style.height = 'auto';
// Check auto-rotate
if (state.messages.length >= SESSION_THRESHOLD) {
rotateConversation();
}
// Add user message
addMessage('user', text);
appendMessageElement('user', text, true);
// Send
state.isSending = true;
sendBtn.disabled = true;
const controller = new AbortController();
state.currentStream = controller;
// Show typing indicator
const typingDiv = document.createElement('div');
typingDiv.className = 'message assistant';
typingDiv.innerHTML = '<div class="typing-indicator"><span></span><span></span><span></span></div>';
chatMessages.appendChild(typingDiv);
chatMessages.scrollTop = chatMessages.scrollHeight;
// Format messages for API - NO system prompt
const apiMessages = state.messages.map(m => ({ role: m.role, content: m.content }));
fetch(API_BASE + '/chat', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + state.token
},
body: JSON.stringify({
messages: apiMessages,
max_tokens: 1024,
temperature: 0.7
}),
signal: controller.signal
})
.then(response => {
// Remove typing indicator
typingDiv.remove();
const contentDiv = appendStreamingMessage('assistant');
let fullContent = '';
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
function readStream() {
reader.read().then(({ done, value }) => {
if (done) {
contentDiv.classList.remove('cursor-blink');
state.isSending = false;
sendBtn.disabled = false;
state.currentStream = null;
if (fullContent) {
addMessage('assistant', fullContent);
}
return;
}
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) continue;
try {
const parsed = JSON.parse(trimmed);
if (parsed.delta) {
fullContent += parsed.delta;
contentDiv.textContent = fullContent;
chatMessages.scrollTop = chatMessages.scrollHeight;
}
if (parsed.error) {
fullContent += '\n[错误: ' + parsed.error + ']';
contentDiv.textContent = fullContent;
}
} catch (e) {
// Non-JSON line (e.g. [DONE])
if (trimmed === 'data: [DONE]') {
// Stream complete
}
}
}
readStream();
}).catch(err => {
if (err.name !== 'AbortError') {
contentDiv.textContent = '请求中断: ' + err.message;
}
contentDiv.classList.remove('cursor-blink');
state.isSending = false;
sendBtn.disabled = false;
state.currentStream = null;
});
}
readStream();
})
.catch(err => {
typingDiv.remove();
state.isSending = false;
sendBtn.disabled = false;
state.currentStream = null;
if (err.name !== 'AbortError') {
const errorDiv = appendStreamingMessage('assistant');
errorDiv.textContent = '连接失败: ' + err.message;
errorDiv.classList.remove('cursor-blink');
}
});
};
function rotateConversation() {
// Save current and start new
state.messages = [];
saveConversation();
renderMessages();
addMessage('system', '[对话已自动翻转,继续当前上下文]');
appendMessageElement('system-note', '—— 对话已自动翻转,继续当前上下文 ——', true);
}
window.newConversation = function() {
if (state.messages.length > 0) {
state.messages = [];
saveConversation();
renderMessages();
chatMessages.innerHTML = '<div class="message system-note"><div class="message-content">新对话 · 母模型已就绪</div></div>';
}
};
window.handleInputKeydown = function(e) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
sendMessage();
}
};
// Auto-resize textarea
chatInput.addEventListener('input', function() {
this.style.height = 'auto';
this.style.height = Math.min(this.scrollHeight, 120) + 'px';
});
// ─── 初始化 ───
initStarfield();
updateSlotInfo();
if (!restoreSession()) {
// Try to check if token in URL params
const params = new URLSearchParams(window.location.search);
const tokenParam = params.get('token');
if (tokenParam) {
localStorage.setItem(LS_PREFIX + 'token', tokenParam);
// Force reload to pick up the token
window.location.href = '/';
}
}
// Periodically update slot info
setInterval(updateSlotInfo, 30000);
chatInput.addEventListener('keydown', function(e) {
if (e.key === 'Escape' && document.activeElement === chatInput) {
chatInput.blur();
}
});
})();