| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850 |
- (function () {
- 'use strict';
- const TOKEN_KEY = 'chatfast_token';
- const state = {
- config: null,
- sessionId: null,
- messages: [],
- expandedMessages: new Set(),
- historyPage: 0,
- historyPageSize: 9999,
- historyTotal: 0,
- historyItems: [],
- model: '',
- outputMode: '流式输出 (Stream)',
- historyCount: 0,
- searchQuery: '',
- streaming: false,
- token: null,
- user: null,
- authMode: 'login',
- myExports: [],
- adminUsers: [],
- adminExports: [],
- };
- const dom = {};
- document.addEventListener('DOMContentLoaded', init);
- async function init() {
- cacheDom();
- bindEvents();
- resetChatState();
- state.token = window.localStorage.getItem(TOKEN_KEY);
- if (!state.token) {
- showAuthView('login');
- return;
- }
- try {
- await fetchProfile();
- await bootstrapAfterAuth();
- } catch (err) {
- console.error('Failed to bootstrap with existing session', err);
- handleUnauthorized(false);
- }
- }
- function cacheDom() {
- dom.appShell = document.getElementById('app-shell');
- dom.authView = document.getElementById('auth-view');
- dom.loginForm = document.getElementById('login-form');
- dom.registerForm = document.getElementById('register-form');
- dom.authSwitchers = document.querySelectorAll('[data-auth-mode]');
- dom.logoutButton = document.getElementById('logout-btn');
- dom.userBadge = document.getElementById('user-badge');
- dom.adminButton = document.getElementById('admin-btn');
- dom.exportButton = document.getElementById('export-btn');
- dom.adminPanel = document.getElementById('admin-panel');
- dom.adminClose = document.getElementById('admin-close');
- dom.adminCreateForm = document.getElementById('admin-create-form');
- dom.adminUserList = document.getElementById('admin-user-list');
- dom.adminExportSearch = document.getElementById('admin-export-search');
- dom.adminExportRefresh = document.getElementById('admin-export-refresh');
- dom.adminExportList = document.getElementById('admin-export-list');
- dom.exportPanel = document.getElementById('export-panel');
- dom.exportClose = document.getElementById('export-close');
- dom.exportList = document.getElementById('export-list');
- dom.modelSelect = document.getElementById('model-select');
- dom.outputMode = document.getElementById('output-mode');
- dom.searchInput = document.getElementById('search-input');
- dom.searchFeedback = document.getElementById('search-feedback');
- dom.historyRange = document.getElementById('history-range');
- dom.historyRangeLabel = document.getElementById('history-range-label');
- dom.historyRangeValue = document.getElementById('history-range-value');
- dom.historyList = document.getElementById('history-list');
- dom.historyCount = document.getElementById('history-count');
- dom.historyPrev = document.getElementById('history-prev');
- dom.historyNext = document.getElementById('history-next');
- dom.newChatButton = document.getElementById('new-chat-btn');
- dom.chatMessages = document.getElementById('chat-messages');
- dom.chatForm = document.getElementById('chat-form');
- dom.chatInput = document.getElementById('chat-input');
- dom.sendButton = document.getElementById('send-btn');
- dom.fileInput = document.getElementById('file-input');
- dom.chatStatus = document.getElementById('chat-status');
- dom.toast = document.getElementById('toast');
- if (dom.sendButton && !dom.sendButton.dataset.defaultText) {
- dom.sendButton.dataset.defaultText = dom.sendButton.textContent || '发送';
- }
- }
- function bindEvents() {
- if (dom.loginForm) {
- dom.loginForm.addEventListener('submit', handleLogin);
- }
- if (dom.registerForm) {
- dom.registerForm.addEventListener('submit', handleRegister);
- }
- if (dom.authSwitchers && dom.authSwitchers.length) {
- dom.authSwitchers.forEach((btn) => {
- btn.addEventListener('click', () => {
- const mode = btn.getAttribute('data-auth-mode') || 'login';
- setAuthMode(mode);
- });
- });
- }
- if (dom.logoutButton) {
- dom.logoutButton.addEventListener('click', handleLogout);
- }
- if (dom.adminButton) {
- dom.adminButton.addEventListener('click', () => {
- void openAdminPanel();
- });
- }
- if (dom.adminClose) {
- dom.adminClose.addEventListener('click', hideAdminPanel);
- }
- if (dom.adminCreateForm) {
- dom.adminCreateForm.addEventListener('submit', handleAdminCreate);
- }
- if (dom.adminExportRefresh) {
- dom.adminExportRefresh.addEventListener('click', async (event) => {
- event.preventDefault();
- await loadAdminExports(dom.adminExportSearch.value || '');
- });
- }
- if (dom.adminExportSearch) {
- dom.adminExportSearch.addEventListener('keydown', async (event) => {
- if (event.key === 'Enter') {
- event.preventDefault();
- await loadAdminExports(dom.adminExportSearch.value || '');
- }
- });
- }
- if (dom.exportButton) {
- dom.exportButton.addEventListener('click', () => {
- void openExportPanel();
- });
- }
- if (dom.exportClose) {
- dom.exportClose.addEventListener('click', hideExportPanel);
- }
- dom.modelSelect.addEventListener('change', () => {
- state.model = dom.modelSelect.value;
- });
- dom.outputMode.addEventListener('change', () => {
- state.outputMode = dom.outputMode.value;
- });
- dom.searchInput.addEventListener('input', () => {
- state.searchQuery = dom.searchInput.value.trim();
- state.expandedMessages = new Set();
- renderMessages();
- });
- dom.historyRange.addEventListener('input', () => {
- state.historyCount = Number(dom.historyRange.value || 0);
- updateHistorySlider();
- });
- dom.historyPrev.addEventListener('click', async () => {
- if (state.historyPage > 0) {
- state.historyPage -= 1;
- await loadHistory();
- }
- });
- dom.historyNext.addEventListener('click', async () => {
- const totalPages = Math.ceil(state.historyTotal / state.historyPageSize) || 1;
- if (state.historyPage < totalPages - 1) {
- state.historyPage += 1;
- await loadHistory();
- }
- });
- dom.newChatButton.addEventListener('click', async () => {
- if (!state.token) {
- showToast('请先登录', 'error');
- return;
- }
- if (state.streaming) {
- showToast('请等待当前回复完成后再新建会话', 'error');
- return;
- }
- try {
- const data = await fetchJSON('/api/session/new', { method: 'POST' });
- state.sessionId = data.session_id;
- state.messages = [];
- state.historyCount = 0;
- state.searchQuery = '';
- dom.searchInput.value = '';
- state.expandedMessages = new Set();
- state.historyPage = 0;
- renderSidebar();
- renderMessages();
- renderHistory();
- updateSessionInUrl(state.sessionId, { replace: false });
- showToast('当前会话已清空。', 'success');
- await loadHistory();
- } catch (err) {
- showToast(err.message || '新建会话失败', 'error');
- }
- });
- dom.chatForm.addEventListener('submit', handleSubmitMessage);
- dom.chatInput.addEventListener('keydown', (event) => {
- if (event.key === 'Enter' && !event.shiftKey && !event.ctrlKey && !event.altKey) {
- event.preventDefault();
- if (typeof dom.chatForm.requestSubmit === 'function') {
- dom.chatForm.requestSubmit();
- } else if (dom.sendButton) {
- dom.sendButton.click();
- }
- }
- });
- window.addEventListener('popstate', handlePopState);
- }
- function resetChatState() {
- state.sessionId = null;
- state.messages = [];
- state.expandedMessages = new Set();
- state.historyItems = [];
- state.historyTotal = 0;
- state.historyPage = 0;
- state.historyCount = 0;
- state.myExports = [];
- state.adminUsers = [];
- state.adminExports = [];
- renderSidebar();
- renderMessages();
- renderHistory();
- renderMyExports();
- renderAdminUsers();
- renderAdminExports();
- }
- function showAuthView(mode = 'login') {
- state.authMode = mode;
- if (dom.appShell) {
- dom.appShell.classList.add('hidden');
- }
- if (dom.authView) {
- dom.authView.classList.remove('hidden');
- }
- if (dom.loginForm) {
- dom.loginForm.reset();
- }
- if (dom.registerForm) {
- dom.registerForm.reset();
- }
- setAuthMode(mode);
- }
- function hideAuthView() {
- if (dom.authView) {
- dom.authView.classList.add('hidden');
- }
- if (dom.appShell) {
- dom.appShell.classList.remove('hidden');
- }
- }
- function setAuthMode(mode) {
- state.authMode = mode;
- if (!dom.loginForm || !dom.registerForm) {
- return;
- }
- if (mode === 'register') {
- dom.loginForm.classList.add('hidden');
- dom.registerForm.classList.remove('hidden');
- } else {
- dom.registerForm.classList.add('hidden');
- dom.loginForm.classList.remove('hidden');
- }
- }
- function saveToken(token) {
- state.token = token;
- if (token) {
- window.localStorage.setItem(TOKEN_KEY, token);
- }
- }
- function clearToken() {
- state.token = null;
- window.localStorage.removeItem(TOKEN_KEY);
- }
- async function fetchProfile() {
- const data = await fetchJSON('/api/auth/me');
- state.user = data;
- updateUserUi();
- }
- async function bootstrapAfterAuth() {
- hideAuthView();
- try {
- if (!state.config) {
- await loadConfig();
- }
- const querySessionId = getSessionIdFromUrl();
- let loadedFromQuery = false;
- if (querySessionId !== null) {
- try {
- await loadSession(querySessionId, { silent: true, updateUrl: true, replaceUrl: true });
- loadedFromQuery = true;
- } catch (err) {
- console.warn('Failed to load session from URL parameter:', err);
- showToast('指定的会话不存在,已加载最新会话。', 'error');
- }
- }
- if (!loadedFromQuery) {
- await loadLatestSession({ updateUrl: true, replaceUrl: true });
- }
- await loadHistory();
- await loadMyExports();
- if (isAdmin()) {
- await loadAdminUsers();
- await loadAdminExports(dom.adminExportSearch ? dom.adminExportSearch.value || '' : '');
- } else {
- renderAdminUsers();
- renderAdminExports();
- }
- } catch (err) {
- showToast(err.message || '初始化失败', 'error');
- }
- renderSidebar();
- renderMessages();
- renderHistory();
- }
- function updateUserUi() {
- if (dom.userBadge) {
- if (state.user) {
- const roleText = state.user.role === 'admin' ? '管理员' : '普通用户';
- dom.userBadge.textContent = `${state.user.username} · ${roleText}`;
- } else {
- dom.userBadge.textContent = '';
- }
- }
- if (dom.adminButton) {
- dom.adminButton.classList.toggle('hidden', !isAdmin());
- }
- if (dom.exportButton) {
- dom.exportButton.disabled = !state.token;
- }
- if (dom.logoutButton) {
- dom.logoutButton.disabled = !state.token;
- }
- }
- function isAdmin() {
- return Boolean(state.user && state.user.role === 'admin');
- }
- function handleUnauthorized(showMessage = true) {
- if (!state.token) {
- showAuthView('login');
- return;
- }
- clearToken();
- state.user = null;
- resetChatState();
- showAuthView('login');
- updateUserUi();
- if (showMessage) {
- showToast('登录状态已过期,请重新登录。', 'error');
- }
- }
- async function handleLogin(event) {
- event.preventDefault();
- const formData = new FormData(dom.loginForm);
- const username = String(formData.get('username') || '').trim();
- const password = String(formData.get('password') || '').trim();
- if (!username || !password) {
- showToast('请输入用户名和密码', 'error');
- return;
- }
- try {
- const data = await fetchJSON('/api/auth/login', {
- method: 'POST',
- body: { username, password },
- });
- saveToken(data.token);
- state.user = data.user;
- updateUserUi();
- showToast('登录成功', 'success');
- await bootstrapAfterAuth();
- } catch (err) {
- showToast(err.message || '登录失败', 'error');
- }
- }
- async function handleRegister(event) {
- event.preventDefault();
- const formData = new FormData(dom.registerForm);
- const username = String(formData.get('username') || '').trim();
- const password = String(formData.get('password') || '').trim();
- if (!username || !password) {
- showToast('请输入用户名和密码', 'error');
- return;
- }
- try {
- const data = await fetchJSON('/api/auth/register', {
- method: 'POST',
- body: { username, password },
- });
- saveToken(data.token);
- state.user = data.user;
- updateUserUi();
- showToast('注册并登录成功', 'success');
- await bootstrapAfterAuth();
- } catch (err) {
- showToast(err.message || '注册失败', 'error');
- }
- }
- async function handleLogout(event) {
- event.preventDefault();
- if (!state.token) {
- showAuthView('login');
- return;
- }
- try {
- await fetchJSON('/api/auth/logout', { method: 'POST' });
- } catch (err) {
- console.warn('Logout failed', err);
- } finally {
- clearToken();
- state.user = null;
- resetChatState();
- showAuthView('login');
- updateUserUi();
- }
- }
- async function loadMyExports() {
- if (!state.token) {
- state.myExports = [];
- renderMyExports();
- return;
- }
- try {
- const data = await fetchJSON('/api/exports/me');
- state.myExports = Array.isArray(data.items) ? data.items : [];
- renderMyExports();
- } catch (err) {
- console.warn('Failed to load user exports', err);
- }
- }
- function renderMyExports() {
- if (!dom.exportList) {
- return;
- }
- dom.exportList.innerHTML = '';
- if (!state.token) {
- const empty = document.createElement('p');
- empty.className = 'empty-note';
- empty.textContent = '登录后可查看导出历史。';
- dom.exportList.appendChild(empty);
- return;
- }
- if (!state.myExports.length) {
- const empty = document.createElement('p');
- empty.className = 'empty-note';
- empty.textContent = '暂无导出记录。';
- dom.exportList.appendChild(empty);
- return;
- }
- state.myExports.forEach((item) => {
- const row = document.createElement('div');
- row.className = 'admin-row';
- const info = document.createElement('div');
- info.className = 'admin-row-info';
- const title = document.createElement('strong');
- title.textContent = item.filename || `导出 #${item.id}`;
- info.appendChild(title);
- const meta = document.createElement('span');
- meta.className = 'admin-row-meta';
- meta.textContent = new Date(item.created_at || Date.now()).toLocaleString();
- info.appendChild(meta);
- const preview = document.createElement('div');
- preview.className = 'admin-row-preview';
- preview.textContent = item.content_preview || '';
- info.appendChild(preview);
- row.appendChild(info);
- const actionBox = document.createElement('div');
- actionBox.className = 'admin-row-actions';
- const downloadButton = document.createElement('button');
- downloadButton.type = 'button';
- downloadButton.className = 'secondary-button small';
- downloadButton.textContent = '下载';
- downloadButton.addEventListener('click', () => {
- void downloadExport(item.id, item.filename);
- });
- actionBox.appendChild(downloadButton);
- row.appendChild(actionBox);
- dom.exportList.appendChild(row);
- });
- }
- async function openExportPanel() {
- if (!state.token) {
- showToast('请先登录', 'error');
- return;
- }
- await loadMyExports();
- if (dom.exportPanel) {
- dom.exportPanel.classList.remove('hidden');
- }
- }
- function hideExportPanel() {
- if (dom.exportPanel) {
- dom.exportPanel.classList.add('hidden');
- }
- }
- async function openAdminPanel() {
- if (!isAdmin()) {
- showToast('需要管理员权限', 'error');
- return;
- }
- await Promise.all([
- loadAdminUsers(),
- loadAdminExports(dom.adminExportSearch ? dom.adminExportSearch.value || '' : ''),
- ]);
- if (dom.adminPanel) {
- dom.adminPanel.classList.remove('hidden');
- }
- }
- function hideAdminPanel() {
- if (dom.adminPanel) {
- dom.adminPanel.classList.add('hidden');
- }
- }
- async function loadAdminUsers() {
- if (!isAdmin()) {
- state.adminUsers = [];
- renderAdminUsers();
- return;
- }
- try {
- const data = await fetchJSON('/api/admin/users?page=0&page_size=200');
- state.adminUsers = Array.isArray(data.items) ? data.items : [];
- renderAdminUsers();
- } catch (err) {
- showToast(err.message || '加载用户列表失败', 'error');
- }
- }
- function renderAdminUsers() {
- if (!dom.adminUserList) {
- return;
- }
- dom.adminUserList.innerHTML = '';
- if (!state.adminUsers.length) {
- const empty = document.createElement('p');
- empty.className = 'empty-note';
- empty.textContent = isAdmin() ? '暂无普通用户。' : '无权限查看用户。';
- dom.adminUserList.appendChild(empty);
- return;
- }
- state.adminUsers.forEach((user) => {
- const row = document.createElement('div');
- row.className = 'admin-row';
- const info = document.createElement('div');
- info.className = 'admin-row-info';
- const name = document.createElement('strong');
- name.textContent = user.username;
- info.appendChild(name);
- const meta = document.createElement('span');
- meta.className = 'admin-row-meta';
- const roleLabel = user.role === 'admin' ? '管理员' : '普通用户';
- meta.textContent = `${roleLabel} · ${new Date(user.created_at || Date.now()).toLocaleString()}`;
- info.appendChild(meta);
- row.appendChild(info);
- const actions = document.createElement('div');
- actions.className = 'admin-row-actions';
- const resetButton = document.createElement('button');
- resetButton.type = 'button';
- resetButton.className = 'secondary-button small';
- resetButton.textContent = '重置密码';
- resetButton.disabled = user.role === 'admin';
- resetButton.addEventListener('click', () => {
- void handleAdminReset(user);
- });
- actions.appendChild(resetButton);
- const deleteButton = document.createElement('button');
- deleteButton.type = 'button';
- deleteButton.className = 'secondary-button danger small';
- deleteButton.textContent = '删除';
- deleteButton.disabled = user.role === 'admin';
- deleteButton.addEventListener('click', () => {
- void handleAdminDelete(user);
- });
- actions.appendChild(deleteButton);
- row.appendChild(actions);
- dom.adminUserList.appendChild(row);
- });
- }
- async function handleAdminCreate(event) {
- event.preventDefault();
- if (!isAdmin()) {
- showToast('需要管理员权限', 'error');
- return;
- }
- const formData = new FormData(dom.adminCreateForm);
- const username = String(formData.get('username') || '').trim();
- const password = String(formData.get('password') || '').trim();
- if (!username || !password) {
- showToast('请输入用户名和密码', 'error');
- return;
- }
- try {
- await fetchJSON('/api/admin/users', {
- method: 'POST',
- body: { username, password },
- });
- dom.adminCreateForm.reset();
- showToast('已创建用户', 'success');
- await loadAdminUsers();
- } catch (err) {
- showToast(err.message || '创建用户失败', 'error');
- }
- }
- async function handleAdminReset(user) {
- if (!isAdmin() || !user || user.role === 'admin') {
- return;
- }
- const password = window.prompt(`请输入 ${user.username} 的新密码:`);
- if (!password) {
- return;
- }
- try {
- await fetchJSON(`/api/admin/users/${user.id}`, {
- method: 'PUT',
- body: { password },
- });
- showToast('密码已更新', 'success');
- } catch (err) {
- showToast(err.message || '重置密码失败', 'error');
- }
- }
- async function handleAdminDelete(user) {
- if (!isAdmin() || !user || user.role === 'admin') {
- return;
- }
- const confirmed = window.confirm(`确定要删除 ${user.username} 吗?`);
- if (!confirmed) {
- return;
- }
- try {
- await fetchJSON(`/api/admin/users/${user.id}`, { method: 'DELETE' });
- showToast('已删除用户', 'success');
- await loadAdminUsers();
- } catch (err) {
- showToast(err.message || '删除用户失败', 'error');
- }
- }
- async function loadAdminExports(keyword = '') {
- if (!isAdmin()) {
- state.adminExports = [];
- renderAdminExports();
- return;
- }
- const query = keyword ? `?keyword=${encodeURIComponent(keyword)}` : '';
- try {
- const data = await fetchJSON(`/api/admin/exports${query}`);
- state.adminExports = Array.isArray(data.items) ? data.items : [];
- renderAdminExports();
- } catch (err) {
- showToast(err.message || '获取导出列表失败', 'error');
- }
- }
- function renderAdminExports() {
- if (!dom.adminExportList) {
- return;
- }
- dom.adminExportList.innerHTML = '';
- if (!state.adminExports.length) {
- const empty = document.createElement('p');
- empty.className = 'empty-note';
- empty.textContent = isAdmin() ? '暂无导出记录。' : '无权限查看。';
- dom.adminExportList.appendChild(empty);
- return;
- }
- state.adminExports.forEach((item) => {
- const row = document.createElement('div');
- row.className = 'admin-row';
- const info = document.createElement('div');
- info.className = 'admin-row-info';
- const title = document.createElement('strong');
- title.textContent = item.filename || `导出 #${item.id}`;
- info.appendChild(title);
- const meta = document.createElement('span');
- meta.className = 'admin-row-meta';
- meta.textContent = `${item.username || '未知用户'} · ${new Date(item.created_at || Date.now()).toLocaleString()}`;
- info.appendChild(meta);
- const preview = document.createElement('div');
- preview.className = 'admin-row-preview';
- preview.textContent = item.content_preview || '';
- info.appendChild(preview);
- row.appendChild(info);
- const actions = document.createElement('div');
- actions.className = 'admin-row-actions';
- const downloadButton = document.createElement('button');
- downloadButton.type = 'button';
- downloadButton.className = 'secondary-button small';
- downloadButton.textContent = '下载';
- downloadButton.addEventListener('click', () => {
- void downloadExport(item.id, item.filename);
- });
- actions.appendChild(downloadButton);
- row.appendChild(actions);
- dom.adminExportList.appendChild(row);
- });
- }
- async function downloadExport(exportId, filename) {
- try {
- const response = await fetch(`/api/exports/${exportId}/download`, {
- headers: buildAuthHeaders(),
- });
- if (response.status === 401) {
- handleUnauthorized();
- return;
- }
- if (!response.ok) {
- const message = await readErrorMessage(response);
- throw new Error(message || '下载失败');
- }
- const blob = await response.blob();
- const url = window.URL.createObjectURL(blob);
- const link = document.createElement('a');
- link.href = url;
- link.download = filename || `export-${exportId}.txt`;
- document.body.appendChild(link);
- link.click();
- link.remove();
- window.URL.revokeObjectURL(url);
- showToast('下载完成', 'success');
- } catch (err) {
- showToast(err.message || '下载失败', 'error');
- }
- }
- async function loadConfig() {
- const config = await fetchJSON('/api/config');
- state.config = config;
- const models = Array.isArray(config.models) ? config.models : [];
- state.model = config.default_model || models[0] || '';
- populateSelect(dom.modelSelect, models, state.model);
- populateSelect(dom.outputMode, config.output_modes || [], state.outputMode);
- }
- function populateSelect(selectEl, values, selected) {
- selectEl.innerHTML = '';
- values.forEach((value) => {
- const option = document.createElement('option');
- option.value = value;
- option.textContent = value;
- if (value === selected) {
- option.selected = true;
- }
- selectEl.appendChild(option);
- });
- if (!values.length) {
- const option = document.createElement('option');
- option.value = '';
- option.textContent = '无可用选项';
- selectEl.appendChild(option);
- }
- }
- async function loadLatestSession(options = {}) {
- if (!state.token) {
- return;
- }
- const { updateUrl = true, replaceUrl = false } = options;
- const data = await fetchJSON('/api/session/latest');
- state.sessionId = typeof data.session_id === 'number' ? data.session_id : 0;
- state.messages = Array.isArray(data.messages) ? data.messages : [];
- state.expandedMessages = new Set();
- state.historyCount = Math.min(state.historyCount, state.messages.length);
- state.searchQuery = '';
- dom.searchInput.value = '';
- state.historyPage = 0;
- renderSidebar();
- renderMessages();
- renderHistory();
- if (updateUrl) {
- updateSessionInUrl(state.sessionId, { replace: replaceUrl });
- }
- }
- async function loadSession(sessionId, options = {}) {
- if (!state.token) {
- return false;
- }
- const { silent = false, updateUrl = true, replaceUrl = false } = options;
- if (state.streaming) {
- if (!silent) {
- showToast('请等待当前回复完成后再切换会话', 'error');
- }
- return false;
- }
- try {
- const data = await fetchJSON(`/api/session/${sessionId}`);
- state.sessionId = data.session_id;
- state.messages = Array.isArray(data.messages) ? data.messages : [];
- state.historyCount = Math.min(state.historyCount, state.messages.length);
- state.expandedMessages = new Set();
- state.searchQuery = '';
- dom.searchInput.value = '';
- renderSidebar();
- renderMessages();
- renderHistory();
- if (updateUrl) {
- updateSessionInUrl(state.sessionId, { replace: replaceUrl });
- }
- return true;
- } catch (err) {
- if (!silent) {
- showToast(err.message || '加载会话失败', 'error');
- }
- throw err;
- }
- }
- async function loadHistory() {
- if (!state.token) {
- renderHistory();
- return;
- }
- try {
- const data = await fetchJSON(`/api/history?page=${state.historyPage}&page_size=${state.historyPageSize}`);
- const total = data.total || 0;
- const items = Array.isArray(data.items) ? data.items : [];
- if (state.historyPage > 0 && items.length === 0 && total > 0) {
- const maxPage = Math.max(0, Math.ceil(total / state.historyPageSize) - 1);
- if (state.historyPage > maxPage) {
- state.historyPage = maxPage;
- await loadHistory();
- return;
- }
- }
- state.historyTotal = total;
- state.historyItems = items;
- renderHistory();
- } catch (err) {
- showToast(err.message || '获取历史记录失败', 'error');
- }
- }
- function renderSidebar() {
- if (state.config) {
- populateSelect(dom.modelSelect, state.config.models || [], state.model);
- populateSelect(dom.outputMode, state.config.output_modes || [], state.outputMode);
- }
- updateHistorySlider();
- updateSearchFeedback();
- updateUserUi();
- }
- function updateHistorySlider() {
- const total = state.messages.length;
- if (dom.historyRange) {
- dom.historyRange.max = String(total);
- dom.historyRange.disabled = !state.token;
- state.historyCount = Math.min(state.historyCount, total);
- dom.historyRange.value = String(state.historyCount);
- }
- dom.historyRangeLabel.textContent = `选择使用的历史消息数量(共${total}条)`;
- dom.historyRangeValue.textContent = `您选择的历史消息数量是: ${state.historyCount}`;
- }
- function updateSearchFeedback() {
- if (!state.searchQuery) {
- dom.searchFeedback.textContent = '无匹配。';
- return;
- }
- const matches = state.messages.filter((msg) => messageMatches(msg.content, state.searchQuery)).length;
- dom.searchFeedback.textContent = `共找到 ${matches} 条匹配。`;
- }
- function setStatus(message, stateClass) {
- if (!dom.chatStatus) {
- return;
- }
- dom.chatStatus.textContent = message || '';
- dom.chatStatus.classList.remove('running', 'error');
- if (!message) {
- return;
- }
- if (stateClass) {
- dom.chatStatus.classList.add(stateClass);
- }
- }
- function setStreaming(active) {
- state.streaming = active;
- if (dom.sendButton) {
- dom.sendButton.disabled = active;
- const defaultText = dom.sendButton.dataset.defaultText || '发送';
- dom.sendButton.textContent = active ? '发送中…' : defaultText;
- }
- if (dom.newChatButton) {
- dom.newChatButton.disabled = active;
- }
- if (active) {
- setStatus('正在生成回复…', 'running');
- }
- }
- function renderHistory() {
- if (dom.historyCount) {
- const total = Number.isFinite(state.historyTotal) ? state.historyTotal : 0;
- dom.historyCount.textContent = state.token ? `共 ${total} 条` : '';
- }
- dom.historyList.innerHTML = '';
- if (!state.token) {
- const notice = document.createElement('div');
- notice.className = 'sidebar-help';
- notice.textContent = '请先登录以查看历史记录。';
- dom.historyList.appendChild(notice);
- if (dom.historyPrev) {
- dom.historyPrev.disabled = true;
- }
- if (dom.historyNext) {
- dom.historyNext.disabled = true;
- }
- return;
- }
- if (!state.historyItems.length) {
- const empty = document.createElement('div');
- empty.className = 'sidebar-help';
- empty.textContent = '无记录。';
- dom.historyList.appendChild(empty);
- } else {
- state.historyItems.forEach((item) => {
- const row = document.createElement('div');
- row.className = 'history-row';
- row.dataset.sessionId = String(item.session_id);
- row.setAttribute('role', 'listitem');
- if (item.session_id === state.sessionId) {
- row.classList.add('active');
- }
- const loadLink = document.createElement('a');
- loadLink.className = 'history-title-link';
- loadLink.href = buildSessionUrl(item.session_id);
- const displayTitle = (item.title && item.title.trim()) ? item.title.trim() : `会话 #${item.session_id}`;
- const primary = document.createElement('span');
- primary.className = 'history-title-text';
- primary.textContent = displayTitle;
- loadLink.appendChild(primary);
- const subtitle = document.createElement('span');
- subtitle.className = 'history-subtitle';
- subtitle.textContent = item.filename ? item.filename : `会话 #${item.session_id}`;
- loadLink.appendChild(subtitle);
- loadLink.title = `会话 #${item.session_id} · 点击加载`;
- loadLink.addEventListener('click', async (event) => {
- const isModified = event.metaKey || event.ctrlKey || event.shiftKey || event.altKey || event.button !== 0;
- if (isModified) {
- return;
- }
- event.preventDefault();
- try {
- await loadSession(item.session_id, { replaceUrl: false });
- } catch (err) {
- console.warn('Failed to load session from history list:', err);
- }
- });
- row.appendChild(loadLink);
- const moveButton = document.createElement('button');
- moveButton.className = 'history-icon-button';
- moveButton.type = 'button';
- moveButton.textContent = '📦';
- moveButton.title = '移动到备份文件夹';
- moveButton.addEventListener('click', async (event) => {
- event.stopPropagation();
- try {
- await fetchJSON('/api/history/move', {
- method: 'POST',
- body: { session_id: item.session_id },
- });
- showToast('已移动到备份。', 'success');
- await loadHistory();
- } catch (err) {
- showToast(err.message || '移动失败', 'error');
- }
- });
- row.appendChild(moveButton);
- const deleteButton = document.createElement('button');
- deleteButton.className = 'history-icon-button';
- deleteButton.type = 'button';
- deleteButton.textContent = '❌';
- deleteButton.title = '删除';
- deleteButton.addEventListener('click', async (event) => {
- event.stopPropagation();
- try {
- await fetchJSON(`/api/history/${item.session_id}`, { method: 'DELETE' });
- showToast('已删除。', 'success');
- if (item.session_id === state.sessionId) {
- await loadLatestSession();
- }
- await loadHistory();
- } catch (err) {
- showToast(err.message || '删除失败', 'error');
- }
- });
- row.appendChild(deleteButton);
- dom.historyList.appendChild(row);
- });
- }
- const totalPages = Math.ceil(state.historyTotal / state.historyPageSize) || 1;
- if (dom.historyPrev) {
- dom.historyPrev.disabled = state.historyPage <= 0;
- }
- if (dom.historyNext) {
- dom.historyNext.disabled = state.historyPage >= totalPages - 1;
- }
- }
- function renderMessages() {
- if (!dom.chatMessages) {
- return;
- }
- dom.chatMessages.innerHTML = '';
- if (!state.token) {
- const notice = document.createElement('div');
- notice.className = 'message notice';
- notice.textContent = '请先登录以开始聊天。';
- dom.chatMessages.appendChild(notice);
- return;
- }
- const total = state.messages.length;
- const searching = Boolean(state.searchQuery);
- state.messages.forEach((message, index) => {
- const wrapper = document.createElement('div');
- wrapper.className = `message ${message.role === 'assistant' ? 'assistant' : 'user'}`;
- wrapper.dataset.index = String(index);
- const header = document.createElement('div');
- header.className = 'message-header';
- header.textContent = message.role === 'assistant' ? 'Assistant' : 'User';
- wrapper.appendChild(header);
- const contentEl = document.createElement('div');
- contentEl.className = 'message-content';
- const expanded = state.expandedMessages.has(index);
- const shouldClamp = !searching && index < total - 1 && !expanded;
- if (shouldClamp) {
- contentEl.classList.add('clamped');
- }
- const query = state.searchQuery && messageMatches(message.content, state.searchQuery)
- ? state.searchQuery
- : '';
- renderContent(message.content, contentEl, query);
- wrapper.appendChild(contentEl);
- const actions = document.createElement('div');
- actions.className = 'message-actions';
- if (!searching && index < total - 1) {
- const toggleButton = document.createElement('button');
- toggleButton.className = 'message-button';
- toggleButton.textContent = expanded ? '<<' : '>>';
- toggleButton.addEventListener('click', () => {
- if (expanded) {
- state.expandedMessages.delete(index);
- } else {
- state.expandedMessages.add(index);
- }
- renderMessages();
- });
- actions.appendChild(toggleButton);
- }
- if (message.role === 'assistant') {
- const exportButton = document.createElement('button');
- exportButton.className = 'message-button';
- exportButton.textContent = '导出';
- exportButton.addEventListener('click', async () => {
- try {
- const data = await fetchJSON('/api/export', {
- method: 'POST',
- body: { content: message.content, session_id: state.sessionId },
- });
- if (data && data.export) {
- state.myExports = [data.export, ...state.myExports];
- renderMyExports();
- }
- showToast('已导出并保存到数据库。', 'success');
- } catch (err) {
- showToast(err.message || '导出失败', 'error');
- }
- });
- actions.appendChild(exportButton);
- }
- wrapper.appendChild(actions);
- dom.chatMessages.appendChild(wrapper);
- });
- updateSearchFeedback();
- scrollToBottom();
- }
- function renderContent(content, container, query) {
- container.innerHTML = '';
- const highlightQuery = query || '';
- if (typeof content === 'string' || content === null || content === undefined) {
- renderMarkdownContent(container, String(content || ''));
- applyHighlight(container, highlightQuery);
- return;
- }
- if (Array.isArray(content)) {
- content.forEach((part) => {
- if (part && part.type === 'text') {
- const textContainer = document.createElement('div');
- renderMarkdownContent(textContainer, String(part.text || ''));
- container.appendChild(textContainer);
- } else if (part && part.type === 'image_url') {
- const url = part.image_url && part.image_url.url ? part.image_url.url : '';
- const img = document.createElement('img');
- img.src = url;
- img.alt = '上传的图片';
- img.loading = 'lazy';
- container.appendChild(img);
- } else {
- const fallback = document.createElement('pre');
- fallback.textContent = JSON.stringify(part, null, 2);
- container.appendChild(fallback);
- }
- });
- applyHighlight(container, highlightQuery);
- return;
- }
- const pre = document.createElement('pre');
- pre.textContent = typeof content === 'object' ? JSON.stringify(content, null, 2) : String(content || '');
- container.appendChild(pre);
- applyHighlight(container, highlightQuery);
- }
- function renderMarkdownContent(container, text) {
- const normalized = String(text || '').replace(/\r\n/g, '\n');
- const lines = normalized.split('\n');
- let paragraphBuffer = [];
- let listBuffer = [];
- let blockquoteBuffer = [];
- let inCodeBlock = false;
- let codeLang = '';
- let codeBuffer = [];
- const flushParagraph = () => {
- if (!paragraphBuffer.length) {
- return;
- }
- const paragraphText = paragraphBuffer.join('\n');
- const paragraph = document.createElement('p');
- appendInlineMarkdown(paragraph, paragraphText);
- container.appendChild(paragraph);
- paragraphBuffer = [];
- };
- const flushList = () => {
- if (!listBuffer.length) {
- return;
- }
- const list = document.createElement('ul');
- listBuffer.forEach((item) => {
- const li = document.createElement('li');
- appendInlineMarkdown(li, item);
- list.appendChild(li);
- });
- container.appendChild(list);
- listBuffer = [];
- };
- const flushBlockquote = () => {
- if (!blockquoteBuffer.length) {
- return;
- }
- const blockquote = document.createElement('blockquote');
- const textContent = blockquoteBuffer.join('\n');
- appendInlineMarkdown(blockquote, textContent);
- container.appendChild(blockquote);
- blockquoteBuffer = [];
- };
- const flushCode = () => {
- const pre = document.createElement('pre');
- const code = document.createElement('code');
- if (codeLang) {
- code.dataset.lang = codeLang;
- code.className = `language-${codeLang}`;
- }
- code.textContent = codeBuffer.join('\n');
- pre.appendChild(code);
- container.appendChild(pre);
- codeBuffer = [];
- codeLang = '';
- inCodeBlock = false;
- };
- lines.forEach((rawLine) => {
- const line = rawLine;
- const fenceMatch = line.match(/^```([A-Za-z0-9_-]+)?\s*$/);
- if (fenceMatch) {
- if (inCodeBlock) {
- flushCode();
- } else {
- flushParagraph();
- flushList();
- flushBlockquote();
- inCodeBlock = true;
- codeLang = fenceMatch[1] ? fenceMatch[1].toLowerCase() : '';
- codeBuffer = [];
- }
- return;
- }
- if (inCodeBlock) {
- codeBuffer.push(line);
- return;
- }
- const listMatch = line.match(/^\s*[-*+]\s+(.*)$/);
- if (listMatch) {
- flushParagraph();
- flushBlockquote();
- listBuffer.push(listMatch[1]);
- return;
- }
- const blockquoteMatch = line.match(/^>\s?(.*)$/);
- if (blockquoteMatch) {
- flushParagraph();
- flushList();
- blockquoteBuffer.push(blockquoteMatch[1]);
- return;
- }
- if (!line.trim()) {
- flushParagraph();
- flushList();
- flushBlockquote();
- return;
- }
- const headingMatch = line.match(/^(#{1,6})\s+(.*)$/);
- if (headingMatch) {
- flushParagraph();
- flushList();
- flushBlockquote();
- const level = Math.min(headingMatch[1].length, 6);
- const heading = document.createElement(`h${level}`);
- appendInlineMarkdown(heading, headingMatch[2]);
- container.appendChild(heading);
- return;
- }
- paragraphBuffer.push(line);
- });
- if (inCodeBlock) {
- flushCode();
- }
- flushParagraph();
- flushList();
- flushBlockquote();
- }
- function appendInlineMarkdown(parent, text) {
- const pattern = /(!?\[[^\]]*\]\([^\)]+\)|`[^`]*`|\*\*[^*]+\*\*|\*[^*]+\*|~~[^~]+~~)/g;
- let lastIndex = 0;
- let match;
- while ((match = pattern.exec(text)) !== null) {
- if (match.index > lastIndex) {
- appendTextNode(parent, text.slice(lastIndex, match.index));
- }
- appendMarkdownToken(parent, match[0]);
- lastIndex = pattern.lastIndex;
- }
- if (lastIndex < text.length) {
- appendTextNode(parent, text.slice(lastIndex));
- }
- }
- function appendMarkdownToken(parent, token) {
- if (token.startsWith('`') && token.endsWith('`')) {
- const code = document.createElement('code');
- code.textContent = token.slice(1, -1);
- parent.appendChild(code);
- return;
- }
- if (token.startsWith('**') && token.endsWith('**')) {
- const strong = document.createElement('strong');
- appendInlineMarkdown(strong, token.slice(2, -2));
- parent.appendChild(strong);
- return;
- }
- if (token.startsWith('*') && token.endsWith('*')) {
- const em = document.createElement('em');
- appendInlineMarkdown(em, token.slice(1, -1));
- parent.appendChild(em);
- return;
- }
- if (token.startsWith('~~') && token.endsWith('~~')) {
- const del = document.createElement('del');
- appendInlineMarkdown(del, token.slice(2, -2));
- parent.appendChild(del);
- return;
- }
- if (token.startsWith('![')) {
- const match = token.match(/^!\[([^\]]*)\]\(([^\)]+)\)$/);
- if (match) {
- const img = document.createElement('img');
- img.alt = match[1];
- img.src = match[2];
- img.loading = 'lazy';
- parent.appendChild(img);
- return;
- }
- }
- if (token.startsWith('[')) {
- const match = token.match(/^\[([^\]]+)\]\(([^\)]+)\)$/);
- if (match) {
- const anchor = document.createElement('a');
- anchor.href = match[2];
- anchor.target = '_blank';
- anchor.rel = 'noopener noreferrer';
- anchor.textContent = match[1];
- parent.appendChild(anchor);
- return;
- }
- }
- appendTextNode(parent, token);
- }
- function appendTextNode(parent, text) {
- if (!text) {
- return;
- }
- const fragments = String(text).split(/(\n)/);
- fragments.forEach((fragment) => {
- if (fragment === '\n') {
- parent.appendChild(document.createElement('br'));
- } else if (fragment) {
- parent.appendChild(document.createTextNode(fragment));
- }
- });
- }
- function clearHighlights(root) {
- if (!root) {
- return;
- }
- root.querySelectorAll('mark.hl').forEach((mark) => {
- const parent = mark.parentNode;
- if (!parent) {
- return;
- }
- while (mark.firstChild) {
- parent.insertBefore(mark.firstChild, mark);
- }
- parent.removeChild(mark);
- parent.normalize();
- });
- }
- function applyHighlight(root, query) {
- if (!root) {
- return;
- }
- clearHighlights(root);
- if (!query) {
- return;
- }
- const lowerQuery = query.toLowerCase();
- const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, null);
- const matches = [];
- while (walker.nextNode()) {
- const node = walker.currentNode;
- if (!node || !node.nodeValue || !node.nodeValue.trim()) {
- continue;
- }
- const text = node.nodeValue;
- const lowerText = text.toLowerCase();
- let index = lowerText.indexOf(lowerQuery);
- while (index !== -1) {
- matches.push({ node, start: index, end: index + query.length });
- index = lowerText.indexOf(lowerQuery, index + query.length);
- }
- }
- for (let i = matches.length - 1; i >= 0; i -= 1) {
- const { node, start, end } = matches[i];
- if (!node || !node.parentNode) {
- continue;
- }
- const range = document.createRange();
- range.setStart(node, start);
- range.setEnd(node, end);
- const mark = document.createElement('mark');
- mark.className = 'hl';
- range.surroundContents(mark);
- }
- }
- function messageMatches(content, query) {
- if (!query) {
- return false;
- }
- const lower = query.toLowerCase();
- if (typeof content === 'string') {
- return content.toLowerCase().includes(lower);
- }
- if (Array.isArray(content)) {
- return content.some((part) => {
- if (!part || part.type !== 'text') {
- return false;
- }
- return String(part.text || '').toLowerCase().includes(lower);
- });
- }
- try {
- return JSON.stringify(content).toLowerCase().includes(lower);
- } catch (err) {
- return false;
- }
- }
- async function handleSubmitMessage(event) {
- event.preventDefault();
- if (!state.token) {
- showToast('请先登录后再聊天', 'error');
- return;
- }
- if (state.streaming) {
- showToast('请等待当前回复完成', 'error');
- return;
- }
- const text = dom.chatInput.value.trim();
- const files = dom.fileInput.files;
- if (!text && (!files || files.length === 0)) {
- showToast('请输入内容或上传文件', 'error');
- return;
- }
- let uploads = [];
- const hasFiles = files && files.length > 0;
- if (hasFiles) {
- try {
- setStatus('正在上传文件…', 'running');
- uploads = await uploadAttachments(files);
- } catch (err) {
- const message = err.message || '文件上传失败';
- setStatus(message, 'error');
- showToast(message, 'error');
- return;
- }
- }
- const { content } = buildUserContent(text, uploads);
- if (!hasContent(content)) {
- setStatus('内容不能为空', 'error');
- showToast('内容不能为空', 'error');
- return;
- }
- setStatus('');
- state.expandedMessages = new Set();
- const userMessage = { role: 'user', content };
- state.messages.push(userMessage);
- renderMessages();
- scrollToBottom();
- dom.chatInput.value = '';
- dom.fileInput.value = '';
- const assistantMessage = { role: 'assistant', content: '' };
- state.messages.push(assistantMessage);
- const assistantIndex = state.messages.length - 1;
- renderMessages();
- scrollToBottom();
- const payload = {
- session_id: state.sessionId ?? 0,
- model: state.model,
- content,
- history_count: state.historyCount,
- stream: state.outputMode === '流式输出 (Stream)',
- };
- setStreaming(true);
- try {
- if (payload.stream) {
- await streamAssistantReply(payload, assistantMessage, assistantIndex);
- } else {
- const data = await fetchJSON('/api/chat', {
- method: 'POST',
- body: payload,
- });
- assistantMessage.content = data.message || '';
- updateMessageContent(assistantIndex, assistantMessage.content);
- showToast('已生成回复', 'success');
- setStatus('');
- }
- } catch (err) {
- state.messages.splice(assistantIndex, 1);
- renderMessages();
- const message = err.message || '发送失败';
- setStatus(message, 'error');
- showToast(message, 'error');
- } finally {
- try {
- state.historyPage = 0;
- await loadHistory();
- } catch (historyErr) {
- console.error('刷新历史记录失败', historyErr);
- } finally {
- updateHistorySlider();
- setStreaming(false);
- }
- }
- }
- function hasContent(content) {
- if (typeof content === 'string') {
- return Boolean(content.trim());
- }
- if (Array.isArray(content)) {
- return content.length > 1 || (content[0] && String(content[0].text || '').trim());
- }
- return Boolean(content);
- }
- async function uploadAttachments(fileList) {
- if (!fileList || fileList.length === 0) {
- return [];
- }
- const formData = new FormData();
- Array.from(fileList).forEach((file) => formData.append('files', file));
- const response = await fetch('/api/upload', {
- method: 'POST',
- headers: buildAuthHeaders(),
- body: formData,
- });
- if (!response.ok) {
- throw new Error('文件上传失败');
- }
- return await response.json();
- }
- function buildUserContent(text, uploads) {
- const results = Array.isArray(uploads) ? uploads : [];
- if (!results.length) {
- return { content: text };
- }
- const contentParts = [{ type: 'text', text: text }];
- let additionalPrompt = '';
- results.forEach((item) => {
- if (item.type === 'image' && item.data) {
- contentParts.push({
- type: 'image_url',
- image_url: { url: item.data },
- });
- } else if (item.type === 'file' && item.url) {
- additionalPrompt += `本次提问包含:${item.url} 文件\n`;
- }
- });
- const promptSuffix = additionalPrompt.trim();
- if (contentParts.length > 1) {
- const base = contentParts[0].text || '';
- contentParts[0].text = promptSuffix ? `${base}\n${promptSuffix}`.trim() : base;
- return { content: contentParts };
- }
- let combined = text || '';
- if (promptSuffix) {
- combined = combined ? `${combined}\n${promptSuffix}` : promptSuffix;
- }
- return { content: combined.trim() };
- }
- async function streamAssistantReply(payload, assistantMessage, assistantIndex) {
- const response = await fetch('/api/chat', {
- method: 'POST',
- headers: buildAuthHeaders({ 'Content-Type': 'application/json' }),
- body: JSON.stringify(payload),
- });
- if (response.status === 401) {
- handleUnauthorized();
- throw new Error('未授权');
- }
- if (!response.ok || !response.body) {
- const errorText = await safeReadText(response);
- throw new Error(errorText || '生成失败');
- }
- const reader = response.body.getReader();
- const decoder = new TextDecoder('utf-8');
- let buffer = '';
- let done = false;
- while (!done) {
- const { value, done: streamDone } = await reader.read();
- done = streamDone;
- if (value) {
- buffer += decoder.decode(value, { stream: !done });
- let newlineIndex = buffer.indexOf('\n');
- while (newlineIndex !== -1) {
- const line = buffer.slice(0, newlineIndex).trim();
- buffer = buffer.slice(newlineIndex + 1);
- if (line) {
- const status = handleStreamLine(line, assistantMessage, assistantIndex);
- if (status === 'end') {
- return;
- }
- }
- newlineIndex = buffer.indexOf('\n');
- }
- }
- }
- setStatus('');
- }
- function handleStreamLine(line, assistantMessage, assistantIndex) {
- let payload;
- try {
- payload = JSON.parse(line);
- } catch (err) {
- return;
- }
- if (payload.type === 'delta') {
- if (typeof assistantMessage.content !== 'string') {
- assistantMessage.content = '';
- }
- assistantMessage.content += payload.text || '';
- updateMessageContent(assistantIndex, assistantMessage.content);
- scrollToBottom();
- return null;
- } else if (payload.type === 'end') {
- showToast('已生成回复', 'success');
- setStatus('');
- return 'end';
- } else if (payload.type === 'error') {
- throw new Error(payload.message || '生成失败');
- }
- }
- function updateMessageContent(index, content) {
- const selector = `.message[data-index="${index}"] .message-content`;
- const node = dom.chatMessages.querySelector(selector);
- if (!node) {
- renderMessages();
- return;
- }
- node.classList.remove('clamped');
- renderContent(content, node, state.searchQuery && messageMatches(content, state.searchQuery) ? state.searchQuery : '');
- }
- function scrollToBottom() {
- if (!dom.chatMessages) {
- return;
- }
- dom.chatMessages.scrollTop = dom.chatMessages.scrollHeight;
- }
- function getSessionIdFromUrl() {
- const params = new URLSearchParams(window.location.search);
- const value = params.get('session');
- if (!value) {
- return null;
- }
- const parsed = Number(value);
- return Number.isInteger(parsed) && parsed >= 0 ? parsed : null;
- }
- function buildSessionUrl(sessionId) {
- const current = new URL(window.location.href);
- if (Number.isInteger(sessionId) && sessionId >= 0) {
- current.searchParams.set('session', String(sessionId));
- } else {
- current.searchParams.delete('session');
- }
- current.hash = '';
- const search = current.searchParams.toString();
- return `${current.pathname}${search ? `?${search}` : ''}`;
- }
- function updateSessionInUrl(sessionId, options = {}) {
- if (!window.history || typeof window.history.replaceState !== 'function') {
- return;
- }
- const { replace = false } = options;
- const target = buildSessionUrl(sessionId);
- const stateData = { sessionId };
- if (replace) {
- window.history.replaceState(stateData, '', target);
- } else {
- window.history.pushState(stateData, '', target);
- }
- }
- async function handlePopState(event) {
- if (!state.token) {
- return;
- }
- if (state.streaming) {
- return;
- }
- const stateSessionId = event.state && Number.isInteger(event.state.sessionId)
- ? event.state.sessionId
- : getSessionIdFromUrl();
- try {
- if (stateSessionId !== null) {
- await loadSession(stateSessionId, { silent: true, updateUrl: false });
- } else {
- await loadLatestSession({ updateUrl: false });
- }
- await loadHistory();
- } catch (err) {
- console.warn('Failed to restore session from history navigation:', err);
- }
- }
- function buildAuthHeaders(baseHeaders = {}) {
- const headers = { ...(baseHeaders || {}) };
- if (state.token) {
- headers.Authorization = `Bearer ${state.token}`;
- }
- return headers;
- }
- async function fetchJSON(url, options = {}) {
- const opts = { ...options };
- opts.headers = buildAuthHeaders(opts.headers || {});
- if (opts.body && !(opts.body instanceof FormData) && typeof opts.body !== 'string') {
- opts.headers['Content-Type'] = 'application/json';
- opts.body = JSON.stringify(opts.body);
- }
- const response = await fetch(url, opts);
- if (response.status === 401) {
- handleUnauthorized();
- const message = await readErrorMessage(response);
- throw new Error(message || '未授权');
- }
- if (!response.ok) {
- const message = await readErrorMessage(response);
- throw new Error(message || '请求失败');
- }
- if (response.status === 204) {
- return {};
- }
- const text = await response.text();
- return text ? JSON.parse(text) : {};
- }
- async function readErrorMessage(response) {
- const text = await safeReadText(response);
- if (!text) {
- return response.statusText;
- }
- try {
- const data = JSON.parse(text);
- return data.detail || data.message || text;
- } catch (err) {
- return text;
- }
- }
- async function safeReadText(response) {
- try {
- return await response.text();
- } catch (err) {
- return '';
- }
- }
- let toastTimer;
- function showToast(message, type = 'success') {
- if (!dom.toast) {
- return;
- }
- dom.toast.textContent = message;
- dom.toast.classList.remove('hidden', 'success', 'error', 'show');
- dom.toast.classList.add(type, 'show');
- clearTimeout(toastTimer);
- toastTimer = setTimeout(() => {
- dom.toast.classList.remove('show');
- toastTimer = setTimeout(() => dom.toast.classList.add('hidden'), 300);
- }, 2500);
- }
- })();
|