613 lines
21 KiB
JavaScript
613 lines
21 KiB
JavaScript
/* ═══════════════════════════════════════════════════════════
|
||
* 光湖 · 微调模型内测 · 前端逻辑
|
||
* 守护: 铸渊 · 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 { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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);
|
||
}
|
||
})();
|