2026-05-10 13:12:44 +08:00

613 lines
21 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.

/* ═══════════════════════════════════════════════════════════
* 光湖 · 微调模型内测 · 前端逻辑
* 守护: 铸渊 · ICE-GL-ZY001
* 版权: 国作登字-2026-A-00037559
* ═══════════════════════════════════════════════════════════ */
(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';
// ─── 状态 ───
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;
// ─── 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');
// ─── 工具 ───
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 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 { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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;
}
// 兜底 (不应在现代浏览器触发)
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, '<br>');
}
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, '<br>');
}
}
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 + ' 席)');
}
}).catch(function () {
slotsText.textContent = '席位状态查询失败';
});
}
// ─── 登录流程 ───
sendCodeBtn.addEventListener('click', function () {
var em = (emailInput.value || '').trim();
if (!em) { showLoginMsg('请填写 QQ 邮箱'); return; }
sendCodeBtn.disabled = true;
sendCodeBtn.textContent = '发送中…';
showLoginMsg('');
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);
})
.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);
}
}
function refreshServerSessions() {
api('/hli/ftchat/sessions').then(function (data) {
sessions = data.sessions || [];
renderSidebar();
}).catch(function () { /* ignore */ });
}
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); });
// 按 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 = '<div style="padding: 12px; color: var(--text-muted); font-size: 12px; text-align: center;">还没有对话</div>';
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 =
'<span class="session-item-title">' + escapeHtml(title) + '</span>' +
'<span class="session-item-meta">' + formatTs(ts) + '</span>';
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', {
method: 'POST',
body: {
previous_session_id: prevId,
previous_messages: prevMessages.slice(-30)
}
}).then(function (resp) {
actuallyCreate(resp.session_id);
if (resp.compressed) {
// 在新对话里给个轻提示(不污染 messages 历史)
var sysRow = document.createElement('div');
sysRow.className = 'message-row system';
sysRow.innerHTML = '<div class="bubble system">🧠 已为你保留上次对话的母语印记,新对话开始时会自动注入</div>';
chatBody.insertBefore(sysRow, chatBody.firstChild);
}
}).catch(function () {
actuallyCreate();
});
} else {
actuallyCreate();
}
}
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;
}
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 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();
}
});
msgInput.addEventListener('input', function () {
msgInput.style.height = 'auto';
msgInput.style.height = Math.min(msgInput.scrollHeight, 200) + 'px';
});
function sendMessage() {
if (isStreaming) return;
var text = (msgInput.value || '').trim();
if (!text) return;
msgInput.value = '';
msgInput.style.height = 'auto';
appendBubble('user', text);
var sess = localSessions[currentSessionId];
if (!sess) {
sess = { title: '新对话', messages: [], updated_at: Date.now() };
localSessions[currentSessionId] = sess;
}
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);
}
function streamReply(history) {
isStreaming = true;
sendBtn.disabled = true;
var bubble = appendStreamBubble();
var fullText = '';
// 字节级直连: 服务端不再下发 meta, 这里前端本地生成一次
setMetaBar(localMeta('system'));
fetch(API_BASE + '/hli/ftchat/chat', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + 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('登录失效');
}
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) + '<span class="cursor-blink"></span>';
scrollToBottom();
}
// 百炼/我方异常时透传的 error 帧
if (event && event.error) {
var msg = (event.error && event.error.message) || event.message || '上游异常';
bubble.innerHTML = '<em>⚠️ ' + escapeHtml(msg) + '</em>';
}
});
}).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 = '<em>(未收到有效回复)</em>';
}
}).catch(function (err) {
bubble.classList.remove('cursor-blink');
bubble.innerHTML = '<em>⚠️ ' + escapeHtml(err.message || '请求失败') + '</em>';
}).then(function () {
isStreaming = false;
sendBtn.disabled = false;
msgInput.focus();
});
}
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();
});
}
return pump();
}
// ─── 启动 ───
loadStorage();
if (token && userHash) {
// 有效性由后端鉴权决定,先进入;过期会被 401 踢出
enterChat();
} else {
refreshSlots();
setInterval(refreshSlots, 30000);
}
})();