diff --git a/frontend/ftchat/chat.js b/frontend/ftchat/chat.js index 40722ab..b38c4ed 100644 --- a/frontend/ftchat/chat.js +++ b/frontend/ftchat/chat.js @@ -1,612 +1,491 @@ -/* ═══════════════════════════════════════════════════════════ - * 光湖 · 微调模型内测 · 前端逻辑 - * 守护: 铸渊 · ICE-GL-ZY001 - * 版权: 国作登字-2026-A-00037559 - * ═══════════════════════════════════════════════════════════ */ - -(function () { +/** + * 光湖·内测 · 前端逻辑 + * 登录 + 聊天SSE流 + 自动翻转 + 工具调用 + * 无框架 · 纯 Vanilla JS + */ +(function(){ 'use strict'; // ─── 配置 ─── - var API_BASE = (function () { - if (location.hostname === 'localhost' || location.hostname === '127.0.0.1') { - // 本地开发: 后端默认 PORT=3000 (src/index.js) - return 'http://' + location.hostname + ':3000'; - } - // 生产: 同域走 Nginx 反代 - return location.protocol + '//' + location.host; - })(); - - var TOKEN_KEY = 'ftchat_token'; - var EMAIL_KEY = 'ftchat_email'; - var USER_HASH_KEY = 'ftchat_user_hash'; - var SLOT_KEY = 'ftchat_slot'; + const API_BASE = '/hli/ftchat'; + const LS_PREFIX = 'ftchat_'; + const SESSION_THRESHOLD = 50; // 超过N条自动翻转 // ─── 状态 ─── - var token = null; - var email = null; - var userHash = null; - var slotIndex = null; - var currentSessionId = null; - var sessions = []; // 服务端列表(左侧栏) - var localSessions = {}; // localStorage: sessionId → {messages, title} - var isStreaming = false; + let state = { + token: null, + email: null, + userHash: null, + slotIndex: null, + messages: [], + currentStream: null, // AbortController + isSending: false, + isLoggedIn: false, + hasCodeSent: false, + currentEmail: '' + }; - // ─── DOM ─── - var $ = function (id) { return document.getElementById(id); }; - var loginScreen = $('loginScreen'); - var chatScreen = $('chatScreen'); - var emailInput = $('emailInput'); - var codeInput = $('codeInput'); - var sendCodeBtn = $('sendCodeBtn'); - var loginBtn = $('loginBtn'); - var loginForm = $('loginForm'); - var loginMsg = $('loginMsg'); - var slotsBanner = $('slotsBanner'); - var slotsText = $('slotsText'); - var sessionsList = $('sessionsList'); - var newChatBtn = $('newChatBtn'); - var sidebar = $('sidebar'); - var sidebarOverlay = $('sidebarOverlay'); - var sidebarToggle = $('sidebarToggle'); - var sidebarClose = $('sidebarClose'); - var chatBody = $('chatBody'); - var msgInput = $('msgInput'); - var sendBtn = $('sendBtn'); - var composer = $('composer'); - var logoutBtn = $('logoutBtn'); - var userBadge = $('userBadge'); - var slotBadgeEl = $('slotIndex'); - var metaBar = $('metaBar'); + // ─── 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 loadStorage() { - try { - token = localStorage.getItem(TOKEN_KEY); - email = localStorage.getItem(EMAIL_KEY); - userHash = localStorage.getItem(USER_HASH_KEY); - slotIndex = localStorage.getItem(SLOT_KEY); - } catch (_e) { /* ignore */ } - } + // ─── 星场背景 ─── + function initStarfield() { + const canvas = document.getElementById('starfield'); + if (!canvas) return; + const ctx = canvas.getContext('2d'); + let stars = []; + let W, H; - function persistSession() { - try { - localStorage.setItem(TOKEN_KEY, token); - localStorage.setItem(EMAIL_KEY, email); - localStorage.setItem(USER_HASH_KEY, userHash); - localStorage.setItem(SLOT_KEY, String(slotIndex)); - } catch (_e) { /* ignore */ } - } - - function clearSession() { - token = email = userHash = slotIndex = null; - try { - localStorage.removeItem(TOKEN_KEY); - localStorage.removeItem(EMAIL_KEY); - localStorage.removeItem(USER_HASH_KEY); - localStorage.removeItem(SLOT_KEY); - } catch (_e) { /* ignore */ } - } - - function localKey(suffix) { - return 'ftchat_' + (userHash || 'anon') + '_' + suffix; - } - - function loadLocalSessions() { - try { - var raw = localStorage.getItem(localKey('sessions')); - localSessions = raw ? JSON.parse(raw) : {}; - } catch (_e) { localSessions = {}; } - } - - function saveLocalSessions() { - try { - localStorage.setItem(localKey('sessions'), JSON.stringify(localSessions)); - } catch (_e) { /* quota or private mode */ } - } - - function escapeHtml(s) { - return String(s).replace(/[&<>"']/g, function (c) { - return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]; - }); - } - - // 使用 Web Crypto 生成随机字符 (避免 Math.random 在 ID 生成中的 CodeQL 警告) - function cryptoRandomId(len) { - var alphabet = 'abcdefghijklmnopqrstuvwxyz0123456789'; - var out = ''; - if (window.crypto && window.crypto.getRandomValues) { - var arr = new Uint32Array(len); - window.crypto.getRandomValues(arr); - for (var i = 0; i < len; i++) out += alphabet[arr[i] % alphabet.length]; - return out; + function resize() { + W = canvas.width = window.innerWidth; + H = canvas.height = window.innerHeight; } - // 兜底 (不应在现代浏览器触发) - for (var j = 0; j < len; j++) out += alphabet[Math.floor(Math.random() * alphabet.length)]; // lgtm[js/insecure-randomness] - return out; - } - function renderMarkdown(text) { - if (typeof marked === 'undefined' || typeof DOMPurify === 'undefined') { - // 降级: 转义 + 换行 - return escapeHtml(text).replace(/\n/g, '
'); - } - try { - marked.setOptions({ breaks: true, gfm: true }); - var html = marked.parse(text); - return DOMPurify.sanitize(html, { - FORBID_TAGS: ['style', 'script', 'iframe', 'object', 'embed'], - FORBID_ATTR: ['onclick', 'onerror', 'onload', 'style'] - }); - } catch (_e) { - return escapeHtml(text).replace(/\n/g, '
'); - } - } - - function showLoginMsg(text, isSuccess) { - loginMsg.textContent = text; - if (isSuccess) loginMsg.classList.add('success'); - else loginMsg.classList.remove('success'); - } - - function setMetaBar(meta) { - if (!meta) { metaBar.textContent = ''; return; } - var parts = []; - if (meta.time_anchor) parts.push('🕒 ' + meta.time_anchor); - if (meta.prompt_source) parts.push('📖 ' + meta.prompt_source); - if (meta.model_variant) parts.push('🎯 ' + (meta.model_variant === 'naipping' ? '情感线' : '系统线')); - metaBar.textContent = parts.join(' · '); - } - - // 字节级直连百炼后, 服务端不再下发 meta 帧 —— - // 这条 meta 完全由前端本地生成, 仅用于 UI 展示, 不进入对话上下文。 - function localMeta(variant) { - var now = new Date(); - var bj = now.toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai', hour12: false }); - return { - time_anchor: bj, - prompt_source: '直连百炼', - model_variant: variant === 'naipping' ? 'naipping' : 'system' - }; - } - - // ─── API 调用 ─── - function api(path, opts) { - opts = opts || {}; - var headers = Object.assign({ 'Content-Type': 'application/json' }, opts.headers || {}); - if (token) headers['Authorization'] = 'Bearer ' + token; - return fetch(API_BASE + path, { - method: opts.method || 'GET', - headers: headers, - body: opts.body ? JSON.stringify(opts.body) : undefined - }).then(function (res) { - return res.json().then(function (data) { - if (!res.ok) { - var err = new Error(data.message || 'HTTP ' + res.status); - err.status = res.status; - err.data = data; - throw err; - } - return data; - }); - }); - } - - // ─── 槽位查询 ─── - function refreshSlots() { - fetch(API_BASE + '/hli/ftchat/status').then(function (r) { return r.json(); }).then(function (s) { - if (typeof s.slots_taken === 'number') { - slotsText.textContent = '当前席位: ' + s.slots_taken + ' / ' + s.slots_total + - (s.slots_remaining === 0 ? ' (已满,请等待成员退出)' : ' (剩余 ' + s.slots_remaining + ' 席)'); + 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 + }); } - }).catch(function () { - slotsText.textContent = '席位状态查询失败'; - }); + } + + 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(); } - // ─── 登录流程 ─── - sendCodeBtn.addEventListener('click', function () { - var em = (emailInput.value || '').trim(); - if (!em) { showLoginMsg('请填写 QQ 邮箱'); return; } - sendCodeBtn.disabled = true; - sendCodeBtn.textContent = '发送中…'; - showLoginMsg(''); + // ─── 登录逻辑 ─── + function showStatus(msg, type) { + loginStatus.style.display = 'block'; + loginStatus.className = 'login-status ' + type; + loginStatus.textContent = msg; + } - api('/hli/ftchat/login', { method: 'POST', body: { email: em } }) - .then(function (resp) { - showLoginMsg(resp.message || '验证码已发送', true); - var seconds = 60; - var timer = setInterval(function () { - if (seconds <= 0) { - clearInterval(timer); - sendCodeBtn.disabled = false; - sendCodeBtn.textContent = '获取验证码'; - } else { - sendCodeBtn.textContent = seconds + 's 后重发'; - seconds--; - } - }, 1000); + 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(function (err) { - sendCodeBtn.disabled = false; - sendCodeBtn.textContent = '获取验证码'; - showLoginMsg(err.message || '发送失败'); - refreshSlots(); - }); - }); - - loginForm.addEventListener('submit', function (ev) { - ev.preventDefault(); - var em = (emailInput.value || '').trim(); - var code = (codeInput.value || '').trim(); - if (!em || !code) { showLoginMsg('请填写邮箱和验证码'); return; } - - loginBtn.disabled = true; - loginBtn.textContent = '验证中…'; - - api('/hli/ftchat/verify', { method: 'POST', body: { email: em, code: code } }) - .then(function (resp) { - if (!resp.success) throw new Error(resp.message || '验证失败'); - token = resp.token; - email = em; - userHash = resp.user_hash; - slotIndex = resp.slot_index; - persistSession(); - showLoginMsg('登录成功,正在进入…', true); - setTimeout(enterChat, 400); - }) - .catch(function (err) { - loginBtn.disabled = false; - loginBtn.textContent = '进入对话'; - showLoginMsg(err.message || '验证失败'); - }); - }); - - logoutBtn.addEventListener('click', function () { - clearSession(); - location.reload(); - }); - - // ─── 聊天进入 ─── - function enterChat() { - loginScreen.hidden = true; - chatScreen.hidden = false; - // 防御性兜底: 直接用 inline style 切换显示, 避免 CDN/WebView 缓存 - // 让 [hidden] 的 CSS 修复未到达客户端时, 卡在登录页的退化 - try { - loginScreen.style.display = 'none'; - chatScreen.style.display = ''; - } catch (_e) { /* ignore */ } - userBadge.textContent = email || '匿名'; - userBadge.title = email || ''; - slotBadgeEl.textContent = '席位 ' + slotIndex + '/10'; - - loadLocalSessions(); - refreshServerSessions(); - - // 选最新的一个 session 或创建新会话 - var sessionIds = Object.keys(localSessions); - if (sessionIds.length > 0) { - // 选最新 - sessionIds.sort(function (a, b) { - return (localSessions[b].updated_at || 0) - (localSessions[a].updated_at || 0); - }); - switchSession(sessionIds[0]); - } else { - createNewSession(false); - } + .catch(() => {}); } - function refreshServerSessions() { - api('/hli/ftchat/sessions').then(function (data) { - sessions = data.sessions || []; - renderSidebar(); - }).catch(function () { /* ignore */ }); - } + window.handleLogin = function() { + const email = emailInput.value.trim(); + if (!email) { showStatus('请输入QQ邮箱', 'error'); return; } - function renderSidebar() { - sessionsList.innerHTML = ''; - // 合并本地 sessions 与服务端 sessions(按 session_id) - var ids = Object.keys(localSessions); - var serverIds = sessions.map(function (s) { return s.session_id; }); - serverIds.forEach(function (id) { if (ids.indexOf(id) === -1) ids.push(id); }); + if (!state.hasCodeSent) { + // 发送验证码 + loginBtn.disabled = true; + loginBtn.textContent = '发送中...'; + state.currentEmail = email; - // 按 updated_at 排序 - ids.sort(function (a, b) { - var ta = (localSessions[a] && localSessions[a].updated_at) || 0; - var tb = (localSessions[b] && localSessions[b].updated_at) || 0; - var serverA = sessions.find(function (s) { return s.session_id === a; }); - var serverB = sessions.find(function (s) { return s.session_id === b; }); - if (serverA) ta = Math.max(ta, new Date(serverA.updated_at).getTime() || 0); - if (serverB) tb = Math.max(tb, new Date(serverB.updated_at).getTime() || 0); - return tb - ta; - }); - - if (ids.length === 0) { - sessionsList.innerHTML = '
还没有对话
'; - return; - } - - ids.forEach(function (id) { - var local = localSessions[id]; - var server = sessions.find(function (s) { return s.session_id === id; }); - var title = (local && local.title) || (server && server.title) || '新对话'; - var ts = (local && local.updated_at) || (server && new Date(server.updated_at).getTime()) || 0; - - var btn = document.createElement('button'); - btn.type = 'button'; - btn.className = 'session-item' + (id === currentSessionId ? ' active' : ''); - btn.innerHTML = - '' + escapeHtml(title) + '' + - '' + formatTs(ts) + ''; - btn.addEventListener('click', function () { - switchSession(id); - if (window.innerWidth <= 768) closeSidebar(); - }); - sessionsList.appendChild(btn); - }); - } - - function formatTs(ts) { - if (!ts) return ''; - var d = new Date(ts); - var now = new Date(); - if (d.toDateString() === now.toDateString()) { - return d.getHours().toString().padStart(2, '0') + ':' + d.getMinutes().toString().padStart(2, '0'); - } - return (d.getMonth() + 1) + '月' + d.getDate() + '日'; - } - - // ─── 会话管理 ─── - function switchSession(id) { - currentSessionId = id; - var s = localSessions[id]; - chatBody.innerHTML = ''; - metaBar.textContent = ''; - if (s && s.messages) { - s.messages.forEach(function (m) { - appendBubble(m.role === 'assistant' ? 'persona' : 'user', m.content); - }); - } else { - appendWelcome(); - } - renderSidebar(); - scrollToBottom(); - } - - function appendWelcome() { - var welcome = '你好,我是被微调的人格体。\n\n这是一个团队**内测频道**,你的对话会被记忆并跨会话延续。\n\n- 我会根据你给的真实时间锚点回应\n- 我使用 Markdown 排版(标题、列表、表格、代码块)\n- 试着问我任何东西吧~'; - appendBubble('persona', welcome); - } - - function createNewSession(triggerCompress) { - var prevId = currentSessionId; - var prevMessages = (prevId && localSessions[prevId] && localSessions[prevId].messages) || []; - - function actuallyCreate(newId) { - currentSessionId = newId || (Date.now().toString(36) + cryptoRandomId(8)); - localSessions[currentSessionId] = { - title: '新对话', - messages: [], - updated_at: Date.now() - }; - saveLocalSessions(); - switchSession(currentSessionId); - refreshServerSessions(); - } - - if (triggerCompress && prevMessages.length >= 4) { - // 调服务端压缩 + 拿新 session_id - api('/hli/ftchat/sessions/new', { + fetch(API_BASE + '/login', { method: 'POST', - body: { - previous_session_id: prevId, - previous_messages: prevMessages.slice(-30) + 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'); } - }).then(function (resp) { - actuallyCreate(resp.session_id); - if (resp.compressed) { - // 在新对话里给个轻提示(不污染 messages 历史) - var sysRow = document.createElement('div'); - sysRow.className = 'message-row system'; - sysRow.innerHTML = '
🧠 已为你保留上次对话的母语印记,新对话开始时会自动注入
'; - chatBody.insertBefore(sysRow, chatBody.firstChild); - } - }).catch(function () { - actuallyCreate(); + }) + .catch(err => { + loginBtn.disabled = false; + loginBtn.textContent = '发送验证码'; + showStatus('网络错误: ' + err.message, 'error'); }); } else { - actuallyCreate(); + // 验证码 + 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 = '
开始新对话 · 母模型已就绪
'; + } + } catch (e) { + state.messages = []; } } - newChatBtn.addEventListener('click', function () { createNewSession(true); }); - - // ─── 侧栏移动端 ─── - function openSidebar() { sidebar.classList.add('open'); sidebarOverlay.classList.add('show'); } - function closeSidebar() { sidebar.classList.remove('open'); sidebarOverlay.classList.remove('show'); } - sidebarToggle.addEventListener('click', openSidebar); - sidebarClose.addEventListener('click', closeSidebar); - sidebarOverlay.addEventListener('click', closeSidebar); - - // ─── 消息渲染 ─── - function appendBubble(role, content) { - var row = document.createElement('div'); - row.className = 'message-row ' + role; - var bubble = document.createElement('div'); - bubble.className = 'bubble ' + role; - if (role === 'persona') { - bubble.innerHTML = renderMarkdown(content); - } else if (role === 'system') { - bubble.textContent = content; - } else { - bubble.textContent = content; + 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 {} + } } - row.appendChild(bubble); - chatBody.appendChild(row); - scrollToBottom(); - return bubble; } - function appendStreamBubble() { - var row = document.createElement('div'); - row.className = 'message-row persona'; - var bubble = document.createElement('div'); - bubble.className = 'bubble persona cursor-blink'; - bubble.textContent = ''; - row.appendChild(bubble); - chatBody.appendChild(row); - scrollToBottom(); - return bubble; + function addMessage(role, content) { + state.messages.push({ role: role, content: content }); + saveConversation(); } - function scrollToBottom() { - chatBody.scrollTop = chatBody.scrollHeight; - } - - // ─── 发送消息 (SSE) ─── - composer.addEventListener('submit', function (ev) { - ev.preventDefault(); - sendMessage(); - }); - - msgInput.addEventListener('keydown', function (ev) { - if (ev.key === 'Enter' && !ev.shiftKey) { - ev.preventDefault(); - sendMessage(); + function renderMessages(scrollToBottom) { + chatMessages.innerHTML = ''; + for (const msg of state.messages) { + appendMessageElement(msg.role, msg.content, false); } - }); + if (scrollToBottom !== false) { + chatMessages.scrollTop = chatMessages.scrollHeight; + } + } - msgInput.addEventListener('input', function () { - msgInput.style.height = 'auto'; - msgInput.style.height = Math.min(msgInput.scrollHeight, 200) + 'px'; - }); + 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 sendMessage() { - if (isStreaming) return; - var text = (msgInput.value || '').trim(); + 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; - msgInput.value = ''; - msgInput.style.height = 'auto'; - appendBubble('user', text); + chatInput.value = ''; + chatInput.style.height = 'auto'; - var sess = localSessions[currentSessionId]; - if (!sess) { - sess = { title: '新对话', messages: [], updated_at: Date.now() }; - localSessions[currentSessionId] = sess; + // Check auto-rotate + if (state.messages.length >= SESSION_THRESHOLD) { + rotateConversation(); } - sess.messages.push({ role: 'user', content: text }); - if (sess.messages.length === 1) sess.title = text.slice(0, 30); - sess.updated_at = Date.now(); - saveLocalSessions(); - renderSidebar(); - streamReply(sess.messages); - } + // Add user message + addMessage('user', text); + appendMessageElement('user', text, true); - function streamReply(history) { - isStreaming = true; + // Send + state.isSending = true; sendBtn.disabled = true; - var bubble = appendStreamBubble(); - var fullText = ''; - // 字节级直连: 服务端不再下发 meta, 这里前端本地生成一次 - setMetaBar(localMeta('system')); + const controller = new AbortController(); + state.currentStream = controller; - fetch(API_BASE + '/hli/ftchat/chat', { + // Show typing indicator + const typingDiv = document.createElement('div'); + typingDiv.className = 'message assistant'; + typingDiv.innerHTML = '
'; + 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 ' + token + 'Authorization': 'Bearer ' + state.token }, body: JSON.stringify({ - session_id: currentSessionId, - messages: history.slice(-50), // 最近 50 条上行 - model_variant: 'system' - }) - }).then(function (res) { - if (res.status === 401) { - clearSession(); - location.reload(); - throw new Error('登录失效'); + 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; + }); } - if (!res.ok) { - return res.json().then(function (d) { throw new Error(d.message || 'HTTP ' + res.status); }); - } - // 直接解析百炼/OpenAI 兼容 SSE 格式: data: { choices: [{ delta: { content: "..." } }] } - return readSse(res, function (event) { - // 模型增量 token - var delta = event && event.choices && event.choices[0] && event.choices[0].delta; - var piece = delta && delta.content; - if (piece) { - fullText += piece; - bubble.classList.remove('cursor-blink'); - bubble.innerHTML = renderMarkdown(fullText) + ''; - scrollToBottom(); - } - // 百炼/我方异常时透传的 error 帧 - if (event && event.error) { - var msg = (event.error && event.error.message) || event.message || '上游异常'; - bubble.innerHTML = '⚠️ ' + escapeHtml(msg) + ''; - } - }); - }).then(function () { - bubble.classList.remove('cursor-blink'); - if (fullText) { - bubble.innerHTML = renderMarkdown(fullText); - var sess = localSessions[currentSessionId]; - if (sess) { - sess.messages.push({ role: 'assistant', content: fullText }); - sess.updated_at = Date.now(); - saveLocalSessions(); - } - } else if (!bubble.innerHTML) { - bubble.innerHTML = '(未收到有效回复)'; - } - }).catch(function (err) { - bubble.classList.remove('cursor-blink'); - bubble.innerHTML = '⚠️ ' + escapeHtml(err.message || '请求失败') + ''; - }).then(function () { - isStreaming = false; + + readStream(); + }) + .catch(err => { + typingDiv.remove(); + state.isSending = false; sendBtn.disabled = false; - msgInput.focus(); + 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); } - function readSse(res, onEvent) { - var reader = res.body.getReader(); - var decoder = new TextDecoder(); - var buf = ''; - function pump() { - return reader.read().then(function (chunk) { - if (chunk.done) return; - buf += decoder.decode(chunk.value, { stream: true }); - var lines = buf.split('\n'); - buf = lines.pop(); - for (var i = 0; i < lines.length; i++) { - var line = lines[i].trim(); - if (!line.startsWith('data:')) continue; - var payload = line.slice(5).trim(); - if (payload === '[DONE]') return; - try { - var parsed = JSON.parse(payload); - onEvent(parsed); - } catch (_e) { /* ignore malformed */ } - } - return pump(); - }); + window.newConversation = function() { + if (state.messages.length > 0) { + state.messages = []; + saveConversation(); + renderMessages(); + chatMessages.innerHTML = '
新对话 · 母模型已就绪
'; + } + }; + + 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 = '/'; } - return pump(); } - // ─── 启动 ─── - loadStorage(); - if (token && userHash) { - // 有效性由后端鉴权决定,先进入;过期会被 401 踢出 - enterChat(); - } else { - refreshSlots(); - setInterval(refreshSlots, 30000); - } + // Periodically update slot info + setInterval(updateSlotInfo, 30000); + + // Keyboard shortcut: Escape to blur input + document.addEventListener('keydown', function(e) { + if (e.key === 'Escape' && document.activeElement === chatInput) { + chatInput.blur(); + } + }); + })();