| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025 |
- (function () {
- 'use strict';
- const TOKEN_KEY = 'chatfast_token';
- const state = {
- config: null,
- sessionId: null,
- sessionNumber: 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: [],
- activeAbortController: null,
- userMenuOpen: false,
- };
- 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.sessionIndicator = document.getElementById('session-indicator');
- dom.userMenu = document.getElementById('user-menu');
- dom.userMenuToggle = document.getElementById('user-menu-toggle');
- dom.userMenuDropdown = document.getElementById('user-menu-dropdown');
- dom.userAvatarInitials = document.getElementById('user-avatar-initials');
- dom.stopButton = document.getElementById('end-chat-btn');
- dom.adminRoleIndicator = document.getElementById('admin-role-indicator');
- 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', () => {
- setUserMenuOpen(false);
- 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', () => {
- setUserMenuOpen(false);
- void openExportPanel();
- });
- }
- if (dom.exportClose) {
- dom.exportClose.addEventListener('click', hideExportPanel);
- }
- setupOverlayDismiss(dom.adminPanel, hideAdminPanel);
- setupOverlayDismiss(dom.exportPanel, 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' });
- updateActiveSession(data.session_id, data.session_number, { updateUrl: true });
- state.messages = [];
- state.historyCount = 0;
- state.searchQuery = '';
- dom.searchInput.value = '';
- state.expandedMessages = new Set();
- state.historyPage = 0;
- renderSidebar();
- renderMessages();
- renderHistory();
- 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);
- if (dom.stopButton) {
- dom.stopButton.addEventListener('click', handleAbortConversation);
- }
- if (dom.userMenuToggle) {
- dom.userMenuToggle.addEventListener('click', (event) => {
- event.preventDefault();
- setUserMenuOpen(!state.userMenuOpen);
- });
- }
- document.addEventListener('click', (event) => {
- if (!state.userMenuOpen || !dom.userMenu) {
- return;
- }
- if (event.target instanceof Node && dom.userMenu.contains(event.target)) {
- return;
- }
- setUserMenuOpen(false);
- });
- document.addEventListener('keydown', (event) => {
- if (event.key === 'Escape') {
- setUserMenuOpen(false);
- hideAdminPanel();
- hideExportPanel();
- }
- });
- }
- function setupOverlayDismiss(overlay, closeHandler) {
- if (!overlay || typeof closeHandler !== 'function') {
- return;
- }
- overlay.addEventListener('click', (event) => {
- if (event.target === overlay) {
- closeHandler();
- }
- });
- }
- function resetChatState() {
- state.sessionId = null;
- state.sessionNumber = null;
- state.messages = [];
- state.expandedMessages = new Set();
- state.historyItems = [];
- state.historyTotal = 0;
- state.historyPage = 0;
- state.historyCount = 0;
- state.myExports = [];
- state.adminUsers = [];
- state.adminExports = [];
- state.activeAbortController = null;
- setUserMenuOpen(false);
- renderSidebar();
- renderMessages();
- renderHistory();
- renderMyExports();
- renderAdminUsers();
- renderAdminExports();
- updateSessionIndicator();
- }
- 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;
- }
- if (dom.userMenu) {
- dom.userMenu.classList.toggle('hidden', !state.token);
- }
- if (dom.userMenuToggle) {
- dom.userMenuToggle.disabled = !state.token;
- }
- if (!state.token) {
- setUserMenuOpen(false);
- }
- if (dom.userAvatarInitials) {
- const initials = state.user ? extractInitials(state.user.username) : '';
- dom.userAvatarInitials.textContent = initials;
- }
- if (dom.adminRoleIndicator) {
- dom.adminRoleIndicator.classList.toggle('hidden', !isAdmin());
- }
- updateSessionIndicator();
- }
- 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();
- setUserMenuOpen(false);
- 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();
- hideAdminPanel();
- hideExportPanel();
- }
- }
- 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');
- updateActiveSession(data.session_id, data.session_number, { updateUrl, replace: replaceUrl });
- 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();
- }
- 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}`);
- updateActiveSession(data.session_id, data.session_number, { updateUrl, replace: replaceUrl });
- 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();
- 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 (dom.stopButton) {
- dom.stopButton.setAttribute('aria-hidden', active ? 'false' : 'true');
- dom.stopButton.title = '提前结束此次对话';
- }
- if (active) {
- setStatus('正在生成回复…', 'running');
- }
- if (!active) {
- if (dom.stopButton) {
- dom.stopButton.classList.add('hidden');
- dom.stopButton.disabled = true;
- }
- state.activeAbortController = null;
- } else if (dom.stopButton) {
- dom.stopButton.classList.remove('hidden');
- dom.stopButton.disabled = false;
- }
- }
- function handleAbortConversation() {
- if (!state.streaming || !state.activeAbortController) {
- return;
- }
- try {
- state.activeAbortController.abort();
- } catch (err) {
- console.warn('Abort failed', err);
- }
- if (dom.stopButton) {
- dom.stopButton.disabled = true;
- }
- }
- 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 sessionNumber = Number.isFinite(Number(item.session_number))
- ? Number(item.session_number)
- : item.session_id;
- const displayTitle = (item.title && item.title.trim())
- ? item.title.trim()
- : `会话 #${sessionNumber}`;
- 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 = `会话 #${sessionNumber}`;
- 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 {
- const isActive = item.session_id === state.sessionId;
- await fetchJSON('/api/history/move', {
- method: 'POST',
- body: { session_id: item.session_id },
- });
- showToast('已移动到备份。', 'success');
- await loadHistory();
- if (isActive) {
- await loadLatestSession({ updateUrl: true, replaceUrl: true });
- }
- } 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)',
- };
- const controller = new AbortController();
- state.activeAbortController = controller;
- setStreaming(true);
- try {
- if (payload.stream) {
- await streamAssistantReply(payload, assistantMessage, assistantIndex, controller);
- } else {
- const data = await fetchJSON('/api/chat', {
- method: 'POST',
- body: payload,
- signal: controller.signal,
- });
- if (Number.isFinite(Number(data.session_id))) {
- updateActiveSession(data.session_id, data.session_number);
- }
- assistantMessage.content = data.message || '';
- updateMessageContent(assistantIndex, assistantMessage.content);
- showToast('已生成回复', 'success');
- setStatus('');
- }
- } catch (err) {
- const aborted = err && (err.name === 'AbortError');
- if (!aborted) {
- state.messages.splice(assistantIndex, 1);
- renderMessages();
- const message = err.message || '发送失败';
- setStatus(message, 'error');
- showToast(message, 'error');
- } else {
- setStatus('对话已提前结束', 'error');
- showToast('当前对话已中止', '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, controller) {
- const response = await fetch('/api/chat', {
- method: 'POST',
- headers: buildAuthHeaders({ 'Content-Type': 'application/json' }),
- body: JSON.stringify(payload),
- signal: controller ? controller.signal : undefined,
- });
- 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 === 'meta') {
- updateActiveSession(payload.session_id, payload.session_number);
- return null;
- }
- 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);
- }
- function extractInitials(name = '') {
- if (!name) {
- return '?';
- }
- const trimmed = name.trim();
- if (!trimmed) {
- return '?';
- }
- return trimmed.slice(0, 2).toUpperCase();
- }
- function setUserMenuOpen(open) {
- const nextState = Boolean(open && state.token);
- state.userMenuOpen = nextState;
- if (dom.userMenuDropdown) {
- dom.userMenuDropdown.classList.toggle('hidden', !nextState);
- }
- if (dom.userMenu) {
- dom.userMenu.classList.toggle('open', nextState);
- }
- if (dom.userMenuToggle) {
- dom.userMenuToggle.setAttribute('aria-expanded', nextState ? 'true' : 'false');
- }
- }
- function updateSessionIndicator() {
- if (!dom.sessionIndicator) {
- return;
- }
- if (!state.token || state.sessionId === null) {
- dom.sessionIndicator.textContent = '';
- dom.sessionIndicator.classList.add('hidden');
- return;
- }
- const displayNumber = Number.isInteger(state.sessionNumber) ? state.sessionNumber : state.sessionId;
- dom.sessionIndicator.textContent = `会话 #${displayNumber}`;
- dom.sessionIndicator.classList.remove('hidden');
- }
- function updateActiveSession(sessionId, sessionNumber, options = {}) {
- const parsedId = Number(sessionId);
- const parsedNumber = Number(sessionNumber);
- const nextId = Number.isFinite(parsedId) ? parsedId : null;
- const previousId = state.sessionId;
- state.sessionId = nextId;
- state.sessionNumber = Number.isFinite(parsedNumber) ? parsedNumber : null;
- updateSessionIndicator();
- if (options.updateUrl === false) {
- return;
- }
- if (previousId === state.sessionId && !options.replace) {
- return;
- }
- updateSessionInUrl(state.sessionId, { replace: Boolean(options.replace) });
- }
- })();
|