492 lines
14 KiB
JavaScript
492 lines
14 KiB
JavaScript
/**
|
|
* 光湖·内测 · 前端逻辑
|
|
* 登录 + 聊天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();
|
|
}
|
|
});
|
|
|
|
})();
|