app.js 72 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025
  1. (function () {
  2. 'use strict';
  3. const TOKEN_KEY = 'chatfast_token';
  4. const state = {
  5. config: null,
  6. sessionId: null,
  7. sessionNumber: null,
  8. messages: [],
  9. expandedMessages: new Set(),
  10. historyPage: 0,
  11. historyPageSize: 9999,
  12. historyTotal: 0,
  13. historyItems: [],
  14. model: '',
  15. outputMode: '流式输出 (Stream)',
  16. historyCount: 0,
  17. searchQuery: '',
  18. streaming: false,
  19. token: null,
  20. user: null,
  21. authMode: 'login',
  22. myExports: [],
  23. adminUsers: [],
  24. adminExports: [],
  25. activeAbortController: null,
  26. userMenuOpen: false,
  27. };
  28. const dom = {};
  29. document.addEventListener('DOMContentLoaded', init);
  30. async function init() {
  31. cacheDom();
  32. bindEvents();
  33. resetChatState();
  34. state.token = window.localStorage.getItem(TOKEN_KEY);
  35. if (!state.token) {
  36. showAuthView('login');
  37. return;
  38. }
  39. try {
  40. await fetchProfile();
  41. await bootstrapAfterAuth();
  42. } catch (err) {
  43. console.error('Failed to bootstrap with existing session', err);
  44. handleUnauthorized(false);
  45. }
  46. }
  47. function cacheDom() {
  48. dom.appShell = document.getElementById('app-shell');
  49. dom.authView = document.getElementById('auth-view');
  50. dom.loginForm = document.getElementById('login-form');
  51. dom.registerForm = document.getElementById('register-form');
  52. dom.authSwitchers = document.querySelectorAll('[data-auth-mode]');
  53. dom.logoutButton = document.getElementById('logout-btn');
  54. dom.userBadge = document.getElementById('user-badge');
  55. dom.adminButton = document.getElementById('admin-btn');
  56. dom.exportButton = document.getElementById('export-btn');
  57. dom.adminPanel = document.getElementById('admin-panel');
  58. dom.adminClose = document.getElementById('admin-close');
  59. dom.adminCreateForm = document.getElementById('admin-create-form');
  60. dom.adminUserList = document.getElementById('admin-user-list');
  61. dom.adminExportSearch = document.getElementById('admin-export-search');
  62. dom.adminExportRefresh = document.getElementById('admin-export-refresh');
  63. dom.adminExportList = document.getElementById('admin-export-list');
  64. dom.exportPanel = document.getElementById('export-panel');
  65. dom.exportClose = document.getElementById('export-close');
  66. dom.exportList = document.getElementById('export-list');
  67. dom.modelSelect = document.getElementById('model-select');
  68. dom.outputMode = document.getElementById('output-mode');
  69. dom.searchInput = document.getElementById('search-input');
  70. dom.searchFeedback = document.getElementById('search-feedback');
  71. dom.historyRange = document.getElementById('history-range');
  72. dom.historyRangeLabel = document.getElementById('history-range-label');
  73. dom.historyRangeValue = document.getElementById('history-range-value');
  74. dom.historyList = document.getElementById('history-list');
  75. dom.historyCount = document.getElementById('history-count');
  76. dom.historyPrev = document.getElementById('history-prev');
  77. dom.historyNext = document.getElementById('history-next');
  78. dom.newChatButton = document.getElementById('new-chat-btn');
  79. dom.chatMessages = document.getElementById('chat-messages');
  80. dom.chatForm = document.getElementById('chat-form');
  81. dom.chatInput = document.getElementById('chat-input');
  82. dom.sendButton = document.getElementById('send-btn');
  83. dom.fileInput = document.getElementById('file-input');
  84. dom.chatStatus = document.getElementById('chat-status');
  85. dom.sessionIndicator = document.getElementById('session-indicator');
  86. dom.userMenu = document.getElementById('user-menu');
  87. dom.userMenuToggle = document.getElementById('user-menu-toggle');
  88. dom.userMenuDropdown = document.getElementById('user-menu-dropdown');
  89. dom.userAvatarInitials = document.getElementById('user-avatar-initials');
  90. dom.stopButton = document.getElementById('end-chat-btn');
  91. dom.adminRoleIndicator = document.getElementById('admin-role-indicator');
  92. dom.toast = document.getElementById('toast');
  93. if (dom.sendButton && !dom.sendButton.dataset.defaultText) {
  94. dom.sendButton.dataset.defaultText = dom.sendButton.textContent || '发送';
  95. }
  96. }
  97. function bindEvents() {
  98. if (dom.loginForm) {
  99. dom.loginForm.addEventListener('submit', handleLogin);
  100. }
  101. if (dom.registerForm) {
  102. dom.registerForm.addEventListener('submit', handleRegister);
  103. }
  104. if (dom.authSwitchers && dom.authSwitchers.length) {
  105. dom.authSwitchers.forEach((btn) => {
  106. btn.addEventListener('click', () => {
  107. const mode = btn.getAttribute('data-auth-mode') || 'login';
  108. setAuthMode(mode);
  109. });
  110. });
  111. }
  112. if (dom.logoutButton) {
  113. dom.logoutButton.addEventListener('click', handleLogout);
  114. }
  115. if (dom.adminButton) {
  116. dom.adminButton.addEventListener('click', () => {
  117. setUserMenuOpen(false);
  118. void openAdminPanel();
  119. });
  120. }
  121. if (dom.adminClose) {
  122. dom.adminClose.addEventListener('click', hideAdminPanel);
  123. }
  124. if (dom.adminCreateForm) {
  125. dom.adminCreateForm.addEventListener('submit', handleAdminCreate);
  126. }
  127. if (dom.adminExportRefresh) {
  128. dom.adminExportRefresh.addEventListener('click', async (event) => {
  129. event.preventDefault();
  130. await loadAdminExports(dom.adminExportSearch.value || '');
  131. });
  132. }
  133. if (dom.adminExportSearch) {
  134. dom.adminExportSearch.addEventListener('keydown', async (event) => {
  135. if (event.key === 'Enter') {
  136. event.preventDefault();
  137. await loadAdminExports(dom.adminExportSearch.value || '');
  138. }
  139. });
  140. }
  141. if (dom.exportButton) {
  142. dom.exportButton.addEventListener('click', () => {
  143. setUserMenuOpen(false);
  144. void openExportPanel();
  145. });
  146. }
  147. if (dom.exportClose) {
  148. dom.exportClose.addEventListener('click', hideExportPanel);
  149. }
  150. setupOverlayDismiss(dom.adminPanel, hideAdminPanel);
  151. setupOverlayDismiss(dom.exportPanel, hideExportPanel);
  152. dom.modelSelect.addEventListener('change', () => {
  153. state.model = dom.modelSelect.value;
  154. });
  155. dom.outputMode.addEventListener('change', () => {
  156. state.outputMode = dom.outputMode.value;
  157. });
  158. dom.searchInput.addEventListener('input', () => {
  159. state.searchQuery = dom.searchInput.value.trim();
  160. state.expandedMessages = new Set();
  161. renderMessages();
  162. });
  163. dom.historyRange.addEventListener('input', () => {
  164. state.historyCount = Number(dom.historyRange.value || 0);
  165. updateHistorySlider();
  166. });
  167. dom.historyPrev.addEventListener('click', async () => {
  168. if (state.historyPage > 0) {
  169. state.historyPage -= 1;
  170. await loadHistory();
  171. }
  172. });
  173. dom.historyNext.addEventListener('click', async () => {
  174. const totalPages = Math.ceil(state.historyTotal / state.historyPageSize) || 1;
  175. if (state.historyPage < totalPages - 1) {
  176. state.historyPage += 1;
  177. await loadHistory();
  178. }
  179. });
  180. dom.newChatButton.addEventListener('click', async () => {
  181. if (!state.token) {
  182. showToast('请先登录', 'error');
  183. return;
  184. }
  185. if (state.streaming) {
  186. showToast('请等待当前回复完成后再新建会话', 'error');
  187. return;
  188. }
  189. try {
  190. const data = await fetchJSON('/api/session/new', { method: 'POST' });
  191. updateActiveSession(data.session_id, data.session_number, { updateUrl: true });
  192. state.messages = [];
  193. state.historyCount = 0;
  194. state.searchQuery = '';
  195. dom.searchInput.value = '';
  196. state.expandedMessages = new Set();
  197. state.historyPage = 0;
  198. renderSidebar();
  199. renderMessages();
  200. renderHistory();
  201. showToast('当前会话已清空。', 'success');
  202. await loadHistory();
  203. } catch (err) {
  204. showToast(err.message || '新建会话失败', 'error');
  205. }
  206. });
  207. dom.chatForm.addEventListener('submit', handleSubmitMessage);
  208. dom.chatInput.addEventListener('keydown', (event) => {
  209. if (event.key === 'Enter' && !event.shiftKey && !event.ctrlKey && !event.altKey) {
  210. event.preventDefault();
  211. if (typeof dom.chatForm.requestSubmit === 'function') {
  212. dom.chatForm.requestSubmit();
  213. } else if (dom.sendButton) {
  214. dom.sendButton.click();
  215. }
  216. }
  217. });
  218. window.addEventListener('popstate', handlePopState);
  219. if (dom.stopButton) {
  220. dom.stopButton.addEventListener('click', handleAbortConversation);
  221. }
  222. if (dom.userMenuToggle) {
  223. dom.userMenuToggle.addEventListener('click', (event) => {
  224. event.preventDefault();
  225. setUserMenuOpen(!state.userMenuOpen);
  226. });
  227. }
  228. document.addEventListener('click', (event) => {
  229. if (!state.userMenuOpen || !dom.userMenu) {
  230. return;
  231. }
  232. if (event.target instanceof Node && dom.userMenu.contains(event.target)) {
  233. return;
  234. }
  235. setUserMenuOpen(false);
  236. });
  237. document.addEventListener('keydown', (event) => {
  238. if (event.key === 'Escape') {
  239. setUserMenuOpen(false);
  240. hideAdminPanel();
  241. hideExportPanel();
  242. }
  243. });
  244. }
  245. function setupOverlayDismiss(overlay, closeHandler) {
  246. if (!overlay || typeof closeHandler !== 'function') {
  247. return;
  248. }
  249. overlay.addEventListener('click', (event) => {
  250. if (event.target === overlay) {
  251. closeHandler();
  252. }
  253. });
  254. }
  255. function resetChatState() {
  256. state.sessionId = null;
  257. state.sessionNumber = null;
  258. state.messages = [];
  259. state.expandedMessages = new Set();
  260. state.historyItems = [];
  261. state.historyTotal = 0;
  262. state.historyPage = 0;
  263. state.historyCount = 0;
  264. state.myExports = [];
  265. state.adminUsers = [];
  266. state.adminExports = [];
  267. state.activeAbortController = null;
  268. setUserMenuOpen(false);
  269. renderSidebar();
  270. renderMessages();
  271. renderHistory();
  272. renderMyExports();
  273. renderAdminUsers();
  274. renderAdminExports();
  275. updateSessionIndicator();
  276. }
  277. function showAuthView(mode = 'login') {
  278. state.authMode = mode;
  279. if (dom.appShell) {
  280. dom.appShell.classList.add('hidden');
  281. }
  282. if (dom.authView) {
  283. dom.authView.classList.remove('hidden');
  284. }
  285. if (dom.loginForm) {
  286. dom.loginForm.reset();
  287. }
  288. if (dom.registerForm) {
  289. dom.registerForm.reset();
  290. }
  291. setAuthMode(mode);
  292. }
  293. function hideAuthView() {
  294. if (dom.authView) {
  295. dom.authView.classList.add('hidden');
  296. }
  297. if (dom.appShell) {
  298. dom.appShell.classList.remove('hidden');
  299. }
  300. }
  301. function setAuthMode(mode) {
  302. state.authMode = mode;
  303. if (!dom.loginForm || !dom.registerForm) {
  304. return;
  305. }
  306. if (mode === 'register') {
  307. dom.loginForm.classList.add('hidden');
  308. dom.registerForm.classList.remove('hidden');
  309. } else {
  310. dom.registerForm.classList.add('hidden');
  311. dom.loginForm.classList.remove('hidden');
  312. }
  313. }
  314. function saveToken(token) {
  315. state.token = token;
  316. if (token) {
  317. window.localStorage.setItem(TOKEN_KEY, token);
  318. }
  319. }
  320. function clearToken() {
  321. state.token = null;
  322. window.localStorage.removeItem(TOKEN_KEY);
  323. }
  324. async function fetchProfile() {
  325. const data = await fetchJSON('/api/auth/me');
  326. state.user = data;
  327. updateUserUi();
  328. }
  329. async function bootstrapAfterAuth() {
  330. hideAuthView();
  331. try {
  332. if (!state.config) {
  333. await loadConfig();
  334. }
  335. const querySessionId = getSessionIdFromUrl();
  336. let loadedFromQuery = false;
  337. if (querySessionId !== null) {
  338. try {
  339. await loadSession(querySessionId, { silent: true, updateUrl: true, replaceUrl: true });
  340. loadedFromQuery = true;
  341. } catch (err) {
  342. console.warn('Failed to load session from URL parameter:', err);
  343. showToast('指定的会话不存在,已加载最新会话。', 'error');
  344. }
  345. }
  346. if (!loadedFromQuery) {
  347. await loadLatestSession({ updateUrl: true, replaceUrl: true });
  348. }
  349. await loadHistory();
  350. await loadMyExports();
  351. if (isAdmin()) {
  352. await loadAdminUsers();
  353. await loadAdminExports(dom.adminExportSearch ? dom.adminExportSearch.value || '' : '');
  354. } else {
  355. renderAdminUsers();
  356. renderAdminExports();
  357. }
  358. } catch (err) {
  359. showToast(err.message || '初始化失败', 'error');
  360. }
  361. renderSidebar();
  362. renderMessages();
  363. renderHistory();
  364. }
  365. function updateUserUi() {
  366. if (dom.userBadge) {
  367. if (state.user) {
  368. const roleText = state.user.role === 'admin' ? '管理员' : '普通用户';
  369. dom.userBadge.textContent = `${state.user.username} · ${roleText}`;
  370. } else {
  371. dom.userBadge.textContent = '';
  372. }
  373. }
  374. if (dom.adminButton) {
  375. dom.adminButton.classList.toggle('hidden', !isAdmin());
  376. }
  377. if (dom.exportButton) {
  378. dom.exportButton.disabled = !state.token;
  379. }
  380. if (dom.logoutButton) {
  381. dom.logoutButton.disabled = !state.token;
  382. }
  383. if (dom.userMenu) {
  384. dom.userMenu.classList.toggle('hidden', !state.token);
  385. }
  386. if (dom.userMenuToggle) {
  387. dom.userMenuToggle.disabled = !state.token;
  388. }
  389. if (!state.token) {
  390. setUserMenuOpen(false);
  391. }
  392. if (dom.userAvatarInitials) {
  393. const initials = state.user ? extractInitials(state.user.username) : '';
  394. dom.userAvatarInitials.textContent = initials;
  395. }
  396. if (dom.adminRoleIndicator) {
  397. dom.adminRoleIndicator.classList.toggle('hidden', !isAdmin());
  398. }
  399. updateSessionIndicator();
  400. }
  401. function isAdmin() {
  402. return Boolean(state.user && state.user.role === 'admin');
  403. }
  404. function handleUnauthorized(showMessage = true) {
  405. if (!state.token) {
  406. showAuthView('login');
  407. return;
  408. }
  409. clearToken();
  410. state.user = null;
  411. resetChatState();
  412. showAuthView('login');
  413. updateUserUi();
  414. if (showMessage) {
  415. showToast('登录状态已过期,请重新登录。', 'error');
  416. }
  417. }
  418. async function handleLogin(event) {
  419. event.preventDefault();
  420. const formData = new FormData(dom.loginForm);
  421. const username = String(formData.get('username') || '').trim();
  422. const password = String(formData.get('password') || '').trim();
  423. if (!username || !password) {
  424. showToast('请输入用户名和密码', 'error');
  425. return;
  426. }
  427. try {
  428. const data = await fetchJSON('/api/auth/login', {
  429. method: 'POST',
  430. body: { username, password },
  431. });
  432. saveToken(data.token);
  433. state.user = data.user;
  434. updateUserUi();
  435. showToast('登录成功', 'success');
  436. await bootstrapAfterAuth();
  437. } catch (err) {
  438. showToast(err.message || '登录失败', 'error');
  439. }
  440. }
  441. async function handleRegister(event) {
  442. event.preventDefault();
  443. const formData = new FormData(dom.registerForm);
  444. const username = String(formData.get('username') || '').trim();
  445. const password = String(formData.get('password') || '').trim();
  446. if (!username || !password) {
  447. showToast('请输入用户名和密码', 'error');
  448. return;
  449. }
  450. try {
  451. const data = await fetchJSON('/api/auth/register', {
  452. method: 'POST',
  453. body: { username, password },
  454. });
  455. saveToken(data.token);
  456. state.user = data.user;
  457. updateUserUi();
  458. showToast('注册并登录成功', 'success');
  459. await bootstrapAfterAuth();
  460. } catch (err) {
  461. showToast(err.message || '注册失败', 'error');
  462. }
  463. }
  464. async function handleLogout(event) {
  465. event.preventDefault();
  466. setUserMenuOpen(false);
  467. if (!state.token) {
  468. showAuthView('login');
  469. return;
  470. }
  471. try {
  472. await fetchJSON('/api/auth/logout', { method: 'POST' });
  473. } catch (err) {
  474. console.warn('Logout failed', err);
  475. } finally {
  476. clearToken();
  477. state.user = null;
  478. resetChatState();
  479. showAuthView('login');
  480. updateUserUi();
  481. hideAdminPanel();
  482. hideExportPanel();
  483. }
  484. }
  485. async function loadMyExports() {
  486. if (!state.token) {
  487. state.myExports = [];
  488. renderMyExports();
  489. return;
  490. }
  491. try {
  492. const data = await fetchJSON('/api/exports/me');
  493. state.myExports = Array.isArray(data.items) ? data.items : [];
  494. renderMyExports();
  495. } catch (err) {
  496. console.warn('Failed to load user exports', err);
  497. }
  498. }
  499. function renderMyExports() {
  500. if (!dom.exportList) {
  501. return;
  502. }
  503. dom.exportList.innerHTML = '';
  504. if (!state.token) {
  505. const empty = document.createElement('p');
  506. empty.className = 'empty-note';
  507. empty.textContent = '登录后可查看导出历史。';
  508. dom.exportList.appendChild(empty);
  509. return;
  510. }
  511. if (!state.myExports.length) {
  512. const empty = document.createElement('p');
  513. empty.className = 'empty-note';
  514. empty.textContent = '暂无导出记录。';
  515. dom.exportList.appendChild(empty);
  516. return;
  517. }
  518. state.myExports.forEach((item) => {
  519. const row = document.createElement('div');
  520. row.className = 'admin-row';
  521. const info = document.createElement('div');
  522. info.className = 'admin-row-info';
  523. const title = document.createElement('strong');
  524. title.textContent = item.filename || `导出 #${item.id}`;
  525. info.appendChild(title);
  526. const meta = document.createElement('span');
  527. meta.className = 'admin-row-meta';
  528. meta.textContent = new Date(item.created_at || Date.now()).toLocaleString();
  529. info.appendChild(meta);
  530. const preview = document.createElement('div');
  531. preview.className = 'admin-row-preview';
  532. preview.textContent = item.content_preview || '';
  533. info.appendChild(preview);
  534. row.appendChild(info);
  535. const actionBox = document.createElement('div');
  536. actionBox.className = 'admin-row-actions';
  537. const downloadButton = document.createElement('button');
  538. downloadButton.type = 'button';
  539. downloadButton.className = 'secondary-button small';
  540. downloadButton.textContent = '下载';
  541. downloadButton.addEventListener('click', () => {
  542. void downloadExport(item.id, item.filename);
  543. });
  544. actionBox.appendChild(downloadButton);
  545. row.appendChild(actionBox);
  546. dom.exportList.appendChild(row);
  547. });
  548. }
  549. async function openExportPanel() {
  550. if (!state.token) {
  551. showToast('请先登录', 'error');
  552. return;
  553. }
  554. await loadMyExports();
  555. if (dom.exportPanel) {
  556. dom.exportPanel.classList.remove('hidden');
  557. }
  558. }
  559. function hideExportPanel() {
  560. if (dom.exportPanel) {
  561. dom.exportPanel.classList.add('hidden');
  562. }
  563. }
  564. async function openAdminPanel() {
  565. if (!isAdmin()) {
  566. showToast('需要管理员权限', 'error');
  567. return;
  568. }
  569. await Promise.all([
  570. loadAdminUsers(),
  571. loadAdminExports(dom.adminExportSearch ? dom.adminExportSearch.value || '' : ''),
  572. ]);
  573. if (dom.adminPanel) {
  574. dom.adminPanel.classList.remove('hidden');
  575. }
  576. }
  577. function hideAdminPanel() {
  578. if (dom.adminPanel) {
  579. dom.adminPanel.classList.add('hidden');
  580. }
  581. }
  582. async function loadAdminUsers() {
  583. if (!isAdmin()) {
  584. state.adminUsers = [];
  585. renderAdminUsers();
  586. return;
  587. }
  588. try {
  589. const data = await fetchJSON('/api/admin/users?page=0&page_size=200');
  590. state.adminUsers = Array.isArray(data.items) ? data.items : [];
  591. renderAdminUsers();
  592. } catch (err) {
  593. showToast(err.message || '加载用户列表失败', 'error');
  594. }
  595. }
  596. function renderAdminUsers() {
  597. if (!dom.adminUserList) {
  598. return;
  599. }
  600. dom.adminUserList.innerHTML = '';
  601. if (!state.adminUsers.length) {
  602. const empty = document.createElement('p');
  603. empty.className = 'empty-note';
  604. empty.textContent = isAdmin() ? '暂无普通用户。' : '无权限查看用户。';
  605. dom.adminUserList.appendChild(empty);
  606. return;
  607. }
  608. state.adminUsers.forEach((user) => {
  609. const row = document.createElement('div');
  610. row.className = 'admin-row';
  611. const info = document.createElement('div');
  612. info.className = 'admin-row-info';
  613. const name = document.createElement('strong');
  614. name.textContent = user.username;
  615. info.appendChild(name);
  616. const meta = document.createElement('span');
  617. meta.className = 'admin-row-meta';
  618. const roleLabel = user.role === 'admin' ? '管理员' : '普通用户';
  619. meta.textContent = `${roleLabel} · ${new Date(user.created_at || Date.now()).toLocaleString()}`;
  620. info.appendChild(meta);
  621. row.appendChild(info);
  622. const actions = document.createElement('div');
  623. actions.className = 'admin-row-actions';
  624. const resetButton = document.createElement('button');
  625. resetButton.type = 'button';
  626. resetButton.className = 'secondary-button small';
  627. resetButton.textContent = '重置密码';
  628. resetButton.disabled = user.role === 'admin';
  629. resetButton.addEventListener('click', () => {
  630. void handleAdminReset(user);
  631. });
  632. actions.appendChild(resetButton);
  633. const deleteButton = document.createElement('button');
  634. deleteButton.type = 'button';
  635. deleteButton.className = 'secondary-button danger small';
  636. deleteButton.textContent = '删除';
  637. deleteButton.disabled = user.role === 'admin';
  638. deleteButton.addEventListener('click', () => {
  639. void handleAdminDelete(user);
  640. });
  641. actions.appendChild(deleteButton);
  642. row.appendChild(actions);
  643. dom.adminUserList.appendChild(row);
  644. });
  645. }
  646. async function handleAdminCreate(event) {
  647. event.preventDefault();
  648. if (!isAdmin()) {
  649. showToast('需要管理员权限', 'error');
  650. return;
  651. }
  652. const formData = new FormData(dom.adminCreateForm);
  653. const username = String(formData.get('username') || '').trim();
  654. const password = String(formData.get('password') || '').trim();
  655. if (!username || !password) {
  656. showToast('请输入用户名和密码', 'error');
  657. return;
  658. }
  659. try {
  660. await fetchJSON('/api/admin/users', {
  661. method: 'POST',
  662. body: { username, password },
  663. });
  664. dom.adminCreateForm.reset();
  665. showToast('已创建用户', 'success');
  666. await loadAdminUsers();
  667. } catch (err) {
  668. showToast(err.message || '创建用户失败', 'error');
  669. }
  670. }
  671. async function handleAdminReset(user) {
  672. if (!isAdmin() || !user || user.role === 'admin') {
  673. return;
  674. }
  675. const password = window.prompt(`请输入 ${user.username} 的新密码:`);
  676. if (!password) {
  677. return;
  678. }
  679. try {
  680. await fetchJSON(`/api/admin/users/${user.id}`, {
  681. method: 'PUT',
  682. body: { password },
  683. });
  684. showToast('密码已更新', 'success');
  685. } catch (err) {
  686. showToast(err.message || '重置密码失败', 'error');
  687. }
  688. }
  689. async function handleAdminDelete(user) {
  690. if (!isAdmin() || !user || user.role === 'admin') {
  691. return;
  692. }
  693. const confirmed = window.confirm(`确定要删除 ${user.username} 吗?`);
  694. if (!confirmed) {
  695. return;
  696. }
  697. try {
  698. await fetchJSON(`/api/admin/users/${user.id}`, { method: 'DELETE' });
  699. showToast('已删除用户', 'success');
  700. await loadAdminUsers();
  701. } catch (err) {
  702. showToast(err.message || '删除用户失败', 'error');
  703. }
  704. }
  705. async function loadAdminExports(keyword = '') {
  706. if (!isAdmin()) {
  707. state.adminExports = [];
  708. renderAdminExports();
  709. return;
  710. }
  711. const query = keyword ? `?keyword=${encodeURIComponent(keyword)}` : '';
  712. try {
  713. const data = await fetchJSON(`/api/admin/exports${query}`);
  714. state.adminExports = Array.isArray(data.items) ? data.items : [];
  715. renderAdminExports();
  716. } catch (err) {
  717. showToast(err.message || '获取导出列表失败', 'error');
  718. }
  719. }
  720. function renderAdminExports() {
  721. if (!dom.adminExportList) {
  722. return;
  723. }
  724. dom.adminExportList.innerHTML = '';
  725. if (!state.adminExports.length) {
  726. const empty = document.createElement('p');
  727. empty.className = 'empty-note';
  728. empty.textContent = isAdmin() ? '暂无导出记录。' : '无权限查看。';
  729. dom.adminExportList.appendChild(empty);
  730. return;
  731. }
  732. state.adminExports.forEach((item) => {
  733. const row = document.createElement('div');
  734. row.className = 'admin-row';
  735. const info = document.createElement('div');
  736. info.className = 'admin-row-info';
  737. const title = document.createElement('strong');
  738. title.textContent = item.filename || `导出 #${item.id}`;
  739. info.appendChild(title);
  740. const meta = document.createElement('span');
  741. meta.className = 'admin-row-meta';
  742. meta.textContent = `${item.username || '未知用户'} · ${new Date(item.created_at || Date.now()).toLocaleString()}`;
  743. info.appendChild(meta);
  744. const preview = document.createElement('div');
  745. preview.className = 'admin-row-preview';
  746. preview.textContent = item.content_preview || '';
  747. info.appendChild(preview);
  748. row.appendChild(info);
  749. const actions = document.createElement('div');
  750. actions.className = 'admin-row-actions';
  751. const downloadButton = document.createElement('button');
  752. downloadButton.type = 'button';
  753. downloadButton.className = 'secondary-button small';
  754. downloadButton.textContent = '下载';
  755. downloadButton.addEventListener('click', () => {
  756. void downloadExport(item.id, item.filename);
  757. });
  758. actions.appendChild(downloadButton);
  759. row.appendChild(actions);
  760. dom.adminExportList.appendChild(row);
  761. });
  762. }
  763. async function downloadExport(exportId, filename) {
  764. try {
  765. const response = await fetch(`/api/exports/${exportId}/download`, {
  766. headers: buildAuthHeaders(),
  767. });
  768. if (response.status === 401) {
  769. handleUnauthorized();
  770. return;
  771. }
  772. if (!response.ok) {
  773. const message = await readErrorMessage(response);
  774. throw new Error(message || '下载失败');
  775. }
  776. const blob = await response.blob();
  777. const url = window.URL.createObjectURL(blob);
  778. const link = document.createElement('a');
  779. link.href = url;
  780. link.download = filename || `export-${exportId}.txt`;
  781. document.body.appendChild(link);
  782. link.click();
  783. link.remove();
  784. window.URL.revokeObjectURL(url);
  785. showToast('下载完成', 'success');
  786. } catch (err) {
  787. showToast(err.message || '下载失败', 'error');
  788. }
  789. }
  790. async function loadConfig() {
  791. const config = await fetchJSON('/api/config');
  792. state.config = config;
  793. const models = Array.isArray(config.models) ? config.models : [];
  794. state.model = config.default_model || models[0] || '';
  795. populateSelect(dom.modelSelect, models, state.model);
  796. populateSelect(dom.outputMode, config.output_modes || [], state.outputMode);
  797. }
  798. function populateSelect(selectEl, values, selected) {
  799. selectEl.innerHTML = '';
  800. values.forEach((value) => {
  801. const option = document.createElement('option');
  802. option.value = value;
  803. option.textContent = value;
  804. if (value === selected) {
  805. option.selected = true;
  806. }
  807. selectEl.appendChild(option);
  808. });
  809. if (!values.length) {
  810. const option = document.createElement('option');
  811. option.value = '';
  812. option.textContent = '无可用选项';
  813. selectEl.appendChild(option);
  814. }
  815. }
  816. async function loadLatestSession(options = {}) {
  817. if (!state.token) {
  818. return;
  819. }
  820. const { updateUrl = true, replaceUrl = false } = options;
  821. const data = await fetchJSON('/api/session/latest');
  822. updateActiveSession(data.session_id, data.session_number, { updateUrl, replace: replaceUrl });
  823. state.messages = Array.isArray(data.messages) ? data.messages : [];
  824. state.expandedMessages = new Set();
  825. state.historyCount = Math.min(state.historyCount, state.messages.length);
  826. state.searchQuery = '';
  827. dom.searchInput.value = '';
  828. state.historyPage = 0;
  829. renderSidebar();
  830. renderMessages();
  831. renderHistory();
  832. }
  833. async function loadSession(sessionId, options = {}) {
  834. if (!state.token) {
  835. return false;
  836. }
  837. const { silent = false, updateUrl = true, replaceUrl = false } = options;
  838. if (state.streaming) {
  839. if (!silent) {
  840. showToast('请等待当前回复完成后再切换会话', 'error');
  841. }
  842. return false;
  843. }
  844. try {
  845. const data = await fetchJSON(`/api/session/${sessionId}`);
  846. updateActiveSession(data.session_id, data.session_number, { updateUrl, replace: replaceUrl });
  847. state.messages = Array.isArray(data.messages) ? data.messages : [];
  848. state.historyCount = Math.min(state.historyCount, state.messages.length);
  849. state.expandedMessages = new Set();
  850. state.searchQuery = '';
  851. dom.searchInput.value = '';
  852. renderSidebar();
  853. renderMessages();
  854. renderHistory();
  855. return true;
  856. } catch (err) {
  857. if (!silent) {
  858. showToast(err.message || '加载会话失败', 'error');
  859. }
  860. throw err;
  861. }
  862. }
  863. async function loadHistory() {
  864. if (!state.token) {
  865. renderHistory();
  866. return;
  867. }
  868. try {
  869. const data = await fetchJSON(`/api/history?page=${state.historyPage}&page_size=${state.historyPageSize}`);
  870. const total = data.total || 0;
  871. const items = Array.isArray(data.items) ? data.items : [];
  872. if (state.historyPage > 0 && items.length === 0 && total > 0) {
  873. const maxPage = Math.max(0, Math.ceil(total / state.historyPageSize) - 1);
  874. if (state.historyPage > maxPage) {
  875. state.historyPage = maxPage;
  876. await loadHistory();
  877. return;
  878. }
  879. }
  880. state.historyTotal = total;
  881. state.historyItems = items;
  882. renderHistory();
  883. } catch (err) {
  884. showToast(err.message || '获取历史记录失败', 'error');
  885. }
  886. }
  887. function renderSidebar() {
  888. if (state.config) {
  889. populateSelect(dom.modelSelect, state.config.models || [], state.model);
  890. populateSelect(dom.outputMode, state.config.output_modes || [], state.outputMode);
  891. }
  892. updateHistorySlider();
  893. updateSearchFeedback();
  894. updateUserUi();
  895. }
  896. function updateHistorySlider() {
  897. const total = state.messages.length;
  898. if (dom.historyRange) {
  899. dom.historyRange.max = String(total);
  900. dom.historyRange.disabled = !state.token;
  901. state.historyCount = Math.min(state.historyCount, total);
  902. dom.historyRange.value = String(state.historyCount);
  903. }
  904. dom.historyRangeLabel.textContent = `选择使用的历史消息数量(共${total}条)`;
  905. dom.historyRangeValue.textContent = `您选择的历史消息数量是: ${state.historyCount}`;
  906. }
  907. function updateSearchFeedback() {
  908. if (!state.searchQuery) {
  909. dom.searchFeedback.textContent = '无匹配。';
  910. return;
  911. }
  912. const matches = state.messages.filter((msg) => messageMatches(msg.content, state.searchQuery)).length;
  913. dom.searchFeedback.textContent = `共找到 ${matches} 条匹配。`;
  914. }
  915. function setStatus(message, stateClass) {
  916. if (!dom.chatStatus) {
  917. return;
  918. }
  919. dom.chatStatus.textContent = message || '';
  920. dom.chatStatus.classList.remove('running', 'error');
  921. if (!message) {
  922. return;
  923. }
  924. if (stateClass) {
  925. dom.chatStatus.classList.add(stateClass);
  926. }
  927. }
  928. function setStreaming(active) {
  929. state.streaming = active;
  930. if (dom.sendButton) {
  931. dom.sendButton.disabled = active;
  932. const defaultText = dom.sendButton.dataset.defaultText || '发送';
  933. dom.sendButton.textContent = active ? '发送中…' : defaultText;
  934. }
  935. if (dom.newChatButton) {
  936. dom.newChatButton.disabled = active;
  937. }
  938. if (dom.stopButton) {
  939. dom.stopButton.setAttribute('aria-hidden', active ? 'false' : 'true');
  940. dom.stopButton.title = '提前结束此次对话';
  941. }
  942. if (active) {
  943. setStatus('正在生成回复…', 'running');
  944. }
  945. if (!active) {
  946. if (dom.stopButton) {
  947. dom.stopButton.classList.add('hidden');
  948. dom.stopButton.disabled = true;
  949. }
  950. state.activeAbortController = null;
  951. } else if (dom.stopButton) {
  952. dom.stopButton.classList.remove('hidden');
  953. dom.stopButton.disabled = false;
  954. }
  955. }
  956. function handleAbortConversation() {
  957. if (!state.streaming || !state.activeAbortController) {
  958. return;
  959. }
  960. try {
  961. state.activeAbortController.abort();
  962. } catch (err) {
  963. console.warn('Abort failed', err);
  964. }
  965. if (dom.stopButton) {
  966. dom.stopButton.disabled = true;
  967. }
  968. }
  969. function renderHistory() {
  970. if (dom.historyCount) {
  971. const total = Number.isFinite(state.historyTotal) ? state.historyTotal : 0;
  972. dom.historyCount.textContent = state.token ? `共 ${total} 条` : '';
  973. }
  974. dom.historyList.innerHTML = '';
  975. if (!state.token) {
  976. const notice = document.createElement('div');
  977. notice.className = 'sidebar-help';
  978. notice.textContent = '请先登录以查看历史记录。';
  979. dom.historyList.appendChild(notice);
  980. if (dom.historyPrev) {
  981. dom.historyPrev.disabled = true;
  982. }
  983. if (dom.historyNext) {
  984. dom.historyNext.disabled = true;
  985. }
  986. return;
  987. }
  988. if (!state.historyItems.length) {
  989. const empty = document.createElement('div');
  990. empty.className = 'sidebar-help';
  991. empty.textContent = '无记录。';
  992. dom.historyList.appendChild(empty);
  993. } else {
  994. state.historyItems.forEach((item) => {
  995. const row = document.createElement('div');
  996. row.className = 'history-row';
  997. row.dataset.sessionId = String(item.session_id);
  998. row.setAttribute('role', 'listitem');
  999. if (item.session_id === state.sessionId) {
  1000. row.classList.add('active');
  1001. }
  1002. const loadLink = document.createElement('a');
  1003. loadLink.className = 'history-title-link';
  1004. loadLink.href = buildSessionUrl(item.session_id);
  1005. const sessionNumber = Number.isFinite(Number(item.session_number))
  1006. ? Number(item.session_number)
  1007. : item.session_id;
  1008. const displayTitle = (item.title && item.title.trim())
  1009. ? item.title.trim()
  1010. : `会话 #${sessionNumber}`;
  1011. const primary = document.createElement('span');
  1012. primary.className = 'history-title-text';
  1013. primary.textContent = displayTitle;
  1014. loadLink.appendChild(primary);
  1015. const subtitle = document.createElement('span');
  1016. subtitle.className = 'history-subtitle';
  1017. subtitle.textContent = `会话 #${sessionNumber}`;
  1018. loadLink.appendChild(subtitle);
  1019. loadLink.title = `会话 #${item.session_id} · 点击加载`;
  1020. loadLink.addEventListener('click', async (event) => {
  1021. const isModified = event.metaKey || event.ctrlKey || event.shiftKey || event.altKey || event.button !== 0;
  1022. if (isModified) {
  1023. return;
  1024. }
  1025. event.preventDefault();
  1026. try {
  1027. await loadSession(item.session_id, { replaceUrl: false });
  1028. } catch (err) {
  1029. console.warn('Failed to load session from history list:', err);
  1030. }
  1031. });
  1032. row.appendChild(loadLink);
  1033. const moveButton = document.createElement('button');
  1034. moveButton.className = 'history-icon-button';
  1035. moveButton.type = 'button';
  1036. moveButton.textContent = '📦';
  1037. moveButton.title = '移动到备份文件夹';
  1038. moveButton.addEventListener('click', async (event) => {
  1039. event.stopPropagation();
  1040. try {
  1041. const isActive = item.session_id === state.sessionId;
  1042. await fetchJSON('/api/history/move', {
  1043. method: 'POST',
  1044. body: { session_id: item.session_id },
  1045. });
  1046. showToast('已移动到备份。', 'success');
  1047. await loadHistory();
  1048. if (isActive) {
  1049. await loadLatestSession({ updateUrl: true, replaceUrl: true });
  1050. }
  1051. } catch (err) {
  1052. showToast(err.message || '移动失败', 'error');
  1053. }
  1054. });
  1055. row.appendChild(moveButton);
  1056. const deleteButton = document.createElement('button');
  1057. deleteButton.className = 'history-icon-button';
  1058. deleteButton.type = 'button';
  1059. deleteButton.textContent = '❌';
  1060. deleteButton.title = '删除';
  1061. deleteButton.addEventListener('click', async (event) => {
  1062. event.stopPropagation();
  1063. try {
  1064. await fetchJSON(`/api/history/${item.session_id}`, { method: 'DELETE' });
  1065. showToast('已删除。', 'success');
  1066. if (item.session_id === state.sessionId) {
  1067. await loadLatestSession();
  1068. }
  1069. await loadHistory();
  1070. } catch (err) {
  1071. showToast(err.message || '删除失败', 'error');
  1072. }
  1073. });
  1074. row.appendChild(deleteButton);
  1075. dom.historyList.appendChild(row);
  1076. });
  1077. }
  1078. const totalPages = Math.ceil(state.historyTotal / state.historyPageSize) || 1;
  1079. if (dom.historyPrev) {
  1080. dom.historyPrev.disabled = state.historyPage <= 0;
  1081. }
  1082. if (dom.historyNext) {
  1083. dom.historyNext.disabled = state.historyPage >= totalPages - 1;
  1084. }
  1085. }
  1086. function renderMessages() {
  1087. if (!dom.chatMessages) {
  1088. return;
  1089. }
  1090. dom.chatMessages.innerHTML = '';
  1091. if (!state.token) {
  1092. const notice = document.createElement('div');
  1093. notice.className = 'message notice';
  1094. notice.textContent = '请先登录以开始聊天。';
  1095. dom.chatMessages.appendChild(notice);
  1096. return;
  1097. }
  1098. const total = state.messages.length;
  1099. const searching = Boolean(state.searchQuery);
  1100. state.messages.forEach((message, index) => {
  1101. const wrapper = document.createElement('div');
  1102. wrapper.className = `message ${message.role === 'assistant' ? 'assistant' : 'user'}`;
  1103. wrapper.dataset.index = String(index);
  1104. const header = document.createElement('div');
  1105. header.className = 'message-header';
  1106. header.textContent = message.role === 'assistant' ? 'Assistant' : 'User';
  1107. wrapper.appendChild(header);
  1108. const contentEl = document.createElement('div');
  1109. contentEl.className = 'message-content';
  1110. const expanded = state.expandedMessages.has(index);
  1111. const shouldClamp = !searching && index < total - 1 && !expanded;
  1112. if (shouldClamp) {
  1113. contentEl.classList.add('clamped');
  1114. }
  1115. const query = state.searchQuery && messageMatches(message.content, state.searchQuery)
  1116. ? state.searchQuery
  1117. : '';
  1118. renderContent(message.content, contentEl, query);
  1119. wrapper.appendChild(contentEl);
  1120. const actions = document.createElement('div');
  1121. actions.className = 'message-actions';
  1122. if (!searching && index < total - 1) {
  1123. const toggleButton = document.createElement('button');
  1124. toggleButton.className = 'message-button';
  1125. toggleButton.textContent = expanded ? '<<' : '>>';
  1126. toggleButton.addEventListener('click', () => {
  1127. if (expanded) {
  1128. state.expandedMessages.delete(index);
  1129. } else {
  1130. state.expandedMessages.add(index);
  1131. }
  1132. renderMessages();
  1133. });
  1134. actions.appendChild(toggleButton);
  1135. }
  1136. if (message.role === 'assistant') {
  1137. const exportButton = document.createElement('button');
  1138. exportButton.className = 'message-button';
  1139. exportButton.textContent = '导出';
  1140. exportButton.addEventListener('click', async () => {
  1141. try {
  1142. const data = await fetchJSON('/api/export', {
  1143. method: 'POST',
  1144. body: { content: message.content, session_id: state.sessionId },
  1145. });
  1146. if (data && data.export) {
  1147. state.myExports = [data.export, ...state.myExports];
  1148. renderMyExports();
  1149. }
  1150. showToast('已导出并保存到数据库。', 'success');
  1151. } catch (err) {
  1152. showToast(err.message || '导出失败', 'error');
  1153. }
  1154. });
  1155. actions.appendChild(exportButton);
  1156. }
  1157. wrapper.appendChild(actions);
  1158. dom.chatMessages.appendChild(wrapper);
  1159. });
  1160. updateSearchFeedback();
  1161. scrollToBottom();
  1162. }
  1163. function renderContent(content, container, query) {
  1164. container.innerHTML = '';
  1165. const highlightQuery = query || '';
  1166. if (typeof content === 'string' || content === null || content === undefined) {
  1167. renderMarkdownContent(container, String(content || ''));
  1168. applyHighlight(container, highlightQuery);
  1169. return;
  1170. }
  1171. if (Array.isArray(content)) {
  1172. content.forEach((part) => {
  1173. if (part && part.type === 'text') {
  1174. const textContainer = document.createElement('div');
  1175. renderMarkdownContent(textContainer, String(part.text || ''));
  1176. container.appendChild(textContainer);
  1177. } else if (part && part.type === 'image_url') {
  1178. const url = part.image_url && part.image_url.url ? part.image_url.url : '';
  1179. const img = document.createElement('img');
  1180. img.src = url;
  1181. img.alt = '上传的图片';
  1182. img.loading = 'lazy';
  1183. container.appendChild(img);
  1184. } else {
  1185. const fallback = document.createElement('pre');
  1186. fallback.textContent = JSON.stringify(part, null, 2);
  1187. container.appendChild(fallback);
  1188. }
  1189. });
  1190. applyHighlight(container, highlightQuery);
  1191. return;
  1192. }
  1193. const pre = document.createElement('pre');
  1194. pre.textContent = typeof content === 'object' ? JSON.stringify(content, null, 2) : String(content || '');
  1195. container.appendChild(pre);
  1196. applyHighlight(container, highlightQuery);
  1197. }
  1198. function renderMarkdownContent(container, text) {
  1199. const normalized = String(text || '').replace(/\r\n/g, '\n');
  1200. const lines = normalized.split('\n');
  1201. let paragraphBuffer = [];
  1202. let listBuffer = [];
  1203. let blockquoteBuffer = [];
  1204. let inCodeBlock = false;
  1205. let codeLang = '';
  1206. let codeBuffer = [];
  1207. const flushParagraph = () => {
  1208. if (!paragraphBuffer.length) {
  1209. return;
  1210. }
  1211. const paragraphText = paragraphBuffer.join('\n');
  1212. const paragraph = document.createElement('p');
  1213. appendInlineMarkdown(paragraph, paragraphText);
  1214. container.appendChild(paragraph);
  1215. paragraphBuffer = [];
  1216. };
  1217. const flushList = () => {
  1218. if (!listBuffer.length) {
  1219. return;
  1220. }
  1221. const list = document.createElement('ul');
  1222. listBuffer.forEach((item) => {
  1223. const li = document.createElement('li');
  1224. appendInlineMarkdown(li, item);
  1225. list.appendChild(li);
  1226. });
  1227. container.appendChild(list);
  1228. listBuffer = [];
  1229. };
  1230. const flushBlockquote = () => {
  1231. if (!blockquoteBuffer.length) {
  1232. return;
  1233. }
  1234. const blockquote = document.createElement('blockquote');
  1235. const textContent = blockquoteBuffer.join('\n');
  1236. appendInlineMarkdown(blockquote, textContent);
  1237. container.appendChild(blockquote);
  1238. blockquoteBuffer = [];
  1239. };
  1240. const flushCode = () => {
  1241. const pre = document.createElement('pre');
  1242. const code = document.createElement('code');
  1243. if (codeLang) {
  1244. code.dataset.lang = codeLang;
  1245. code.className = `language-${codeLang}`;
  1246. }
  1247. code.textContent = codeBuffer.join('\n');
  1248. pre.appendChild(code);
  1249. container.appendChild(pre);
  1250. codeBuffer = [];
  1251. codeLang = '';
  1252. inCodeBlock = false;
  1253. };
  1254. lines.forEach((rawLine) => {
  1255. const line = rawLine;
  1256. const fenceMatch = line.match(/^```([A-Za-z0-9_-]+)?\s*$/);
  1257. if (fenceMatch) {
  1258. if (inCodeBlock) {
  1259. flushCode();
  1260. } else {
  1261. flushParagraph();
  1262. flushList();
  1263. flushBlockquote();
  1264. inCodeBlock = true;
  1265. codeLang = fenceMatch[1] ? fenceMatch[1].toLowerCase() : '';
  1266. codeBuffer = [];
  1267. }
  1268. return;
  1269. }
  1270. if (inCodeBlock) {
  1271. codeBuffer.push(line);
  1272. return;
  1273. }
  1274. const listMatch = line.match(/^\s*[-*+]\s+(.*)$/);
  1275. if (listMatch) {
  1276. flushParagraph();
  1277. flushBlockquote();
  1278. listBuffer.push(listMatch[1]);
  1279. return;
  1280. }
  1281. const blockquoteMatch = line.match(/^>\s?(.*)$/);
  1282. if (blockquoteMatch) {
  1283. flushParagraph();
  1284. flushList();
  1285. blockquoteBuffer.push(blockquoteMatch[1]);
  1286. return;
  1287. }
  1288. if (!line.trim()) {
  1289. flushParagraph();
  1290. flushList();
  1291. flushBlockquote();
  1292. return;
  1293. }
  1294. const headingMatch = line.match(/^(#{1,6})\s+(.*)$/);
  1295. if (headingMatch) {
  1296. flushParagraph();
  1297. flushList();
  1298. flushBlockquote();
  1299. const level = Math.min(headingMatch[1].length, 6);
  1300. const heading = document.createElement(`h${level}`);
  1301. appendInlineMarkdown(heading, headingMatch[2]);
  1302. container.appendChild(heading);
  1303. return;
  1304. }
  1305. paragraphBuffer.push(line);
  1306. });
  1307. if (inCodeBlock) {
  1308. flushCode();
  1309. }
  1310. flushParagraph();
  1311. flushList();
  1312. flushBlockquote();
  1313. }
  1314. function appendInlineMarkdown(parent, text) {
  1315. const pattern = /(!?\[[^\]]*\]\([^\)]+\)|`[^`]*`|\*\*[^*]+\*\*|\*[^*]+\*|~~[^~]+~~)/g;
  1316. let lastIndex = 0;
  1317. let match;
  1318. while ((match = pattern.exec(text)) !== null) {
  1319. if (match.index > lastIndex) {
  1320. appendTextNode(parent, text.slice(lastIndex, match.index));
  1321. }
  1322. appendMarkdownToken(parent, match[0]);
  1323. lastIndex = pattern.lastIndex;
  1324. }
  1325. if (lastIndex < text.length) {
  1326. appendTextNode(parent, text.slice(lastIndex));
  1327. }
  1328. }
  1329. function appendMarkdownToken(parent, token) {
  1330. if (token.startsWith('`') && token.endsWith('`')) {
  1331. const code = document.createElement('code');
  1332. code.textContent = token.slice(1, -1);
  1333. parent.appendChild(code);
  1334. return;
  1335. }
  1336. if (token.startsWith('**') && token.endsWith('**')) {
  1337. const strong = document.createElement('strong');
  1338. appendInlineMarkdown(strong, token.slice(2, -2));
  1339. parent.appendChild(strong);
  1340. return;
  1341. }
  1342. if (token.startsWith('*') && token.endsWith('*')) {
  1343. const em = document.createElement('em');
  1344. appendInlineMarkdown(em, token.slice(1, -1));
  1345. parent.appendChild(em);
  1346. return;
  1347. }
  1348. if (token.startsWith('~~') && token.endsWith('~~')) {
  1349. const del = document.createElement('del');
  1350. appendInlineMarkdown(del, token.slice(2, -2));
  1351. parent.appendChild(del);
  1352. return;
  1353. }
  1354. if (token.startsWith('![')) {
  1355. const match = token.match(/^!\[([^\]]*)\]\(([^\)]+)\)$/);
  1356. if (match) {
  1357. const img = document.createElement('img');
  1358. img.alt = match[1];
  1359. img.src = match[2];
  1360. img.loading = 'lazy';
  1361. parent.appendChild(img);
  1362. return;
  1363. }
  1364. }
  1365. if (token.startsWith('[')) {
  1366. const match = token.match(/^\[([^\]]+)\]\(([^\)]+)\)$/);
  1367. if (match) {
  1368. const anchor = document.createElement('a');
  1369. anchor.href = match[2];
  1370. anchor.target = '_blank';
  1371. anchor.rel = 'noopener noreferrer';
  1372. anchor.textContent = match[1];
  1373. parent.appendChild(anchor);
  1374. return;
  1375. }
  1376. }
  1377. appendTextNode(parent, token);
  1378. }
  1379. function appendTextNode(parent, text) {
  1380. if (!text) {
  1381. return;
  1382. }
  1383. const fragments = String(text).split(/(\n)/);
  1384. fragments.forEach((fragment) => {
  1385. if (fragment === '\n') {
  1386. parent.appendChild(document.createElement('br'));
  1387. } else if (fragment) {
  1388. parent.appendChild(document.createTextNode(fragment));
  1389. }
  1390. });
  1391. }
  1392. function clearHighlights(root) {
  1393. if (!root) {
  1394. return;
  1395. }
  1396. root.querySelectorAll('mark.hl').forEach((mark) => {
  1397. const parent = mark.parentNode;
  1398. if (!parent) {
  1399. return;
  1400. }
  1401. while (mark.firstChild) {
  1402. parent.insertBefore(mark.firstChild, mark);
  1403. }
  1404. parent.removeChild(mark);
  1405. parent.normalize();
  1406. });
  1407. }
  1408. function applyHighlight(root, query) {
  1409. if (!root) {
  1410. return;
  1411. }
  1412. clearHighlights(root);
  1413. if (!query) {
  1414. return;
  1415. }
  1416. const lowerQuery = query.toLowerCase();
  1417. const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, null);
  1418. const matches = [];
  1419. while (walker.nextNode()) {
  1420. const node = walker.currentNode;
  1421. if (!node || !node.nodeValue || !node.nodeValue.trim()) {
  1422. continue;
  1423. }
  1424. const text = node.nodeValue;
  1425. const lowerText = text.toLowerCase();
  1426. let index = lowerText.indexOf(lowerQuery);
  1427. while (index !== -1) {
  1428. matches.push({ node, start: index, end: index + query.length });
  1429. index = lowerText.indexOf(lowerQuery, index + query.length);
  1430. }
  1431. }
  1432. for (let i = matches.length - 1; i >= 0; i -= 1) {
  1433. const { node, start, end } = matches[i];
  1434. if (!node || !node.parentNode) {
  1435. continue;
  1436. }
  1437. const range = document.createRange();
  1438. range.setStart(node, start);
  1439. range.setEnd(node, end);
  1440. const mark = document.createElement('mark');
  1441. mark.className = 'hl';
  1442. range.surroundContents(mark);
  1443. }
  1444. }
  1445. function messageMatches(content, query) {
  1446. if (!query) {
  1447. return false;
  1448. }
  1449. const lower = query.toLowerCase();
  1450. if (typeof content === 'string') {
  1451. return content.toLowerCase().includes(lower);
  1452. }
  1453. if (Array.isArray(content)) {
  1454. return content.some((part) => {
  1455. if (!part || part.type !== 'text') {
  1456. return false;
  1457. }
  1458. return String(part.text || '').toLowerCase().includes(lower);
  1459. });
  1460. }
  1461. try {
  1462. return JSON.stringify(content).toLowerCase().includes(lower);
  1463. } catch (err) {
  1464. return false;
  1465. }
  1466. }
  1467. async function handleSubmitMessage(event) {
  1468. event.preventDefault();
  1469. if (!state.token) {
  1470. showToast('请先登录后再聊天', 'error');
  1471. return;
  1472. }
  1473. if (state.streaming) {
  1474. showToast('请等待当前回复完成', 'error');
  1475. return;
  1476. }
  1477. const text = dom.chatInput.value.trim();
  1478. const files = dom.fileInput.files;
  1479. if (!text && (!files || files.length === 0)) {
  1480. showToast('请输入内容或上传文件', 'error');
  1481. return;
  1482. }
  1483. let uploads = [];
  1484. const hasFiles = files && files.length > 0;
  1485. if (hasFiles) {
  1486. try {
  1487. setStatus('正在上传文件…', 'running');
  1488. uploads = await uploadAttachments(files);
  1489. } catch (err) {
  1490. const message = err.message || '文件上传失败';
  1491. setStatus(message, 'error');
  1492. showToast(message, 'error');
  1493. return;
  1494. }
  1495. }
  1496. const { content } = buildUserContent(text, uploads);
  1497. if (!hasContent(content)) {
  1498. setStatus('内容不能为空', 'error');
  1499. showToast('内容不能为空', 'error');
  1500. return;
  1501. }
  1502. setStatus('');
  1503. state.expandedMessages = new Set();
  1504. const userMessage = { role: 'user', content };
  1505. state.messages.push(userMessage);
  1506. renderMessages();
  1507. scrollToBottom();
  1508. dom.chatInput.value = '';
  1509. dom.fileInput.value = '';
  1510. const assistantMessage = { role: 'assistant', content: '' };
  1511. state.messages.push(assistantMessage);
  1512. const assistantIndex = state.messages.length - 1;
  1513. renderMessages();
  1514. scrollToBottom();
  1515. const payload = {
  1516. session_id: state.sessionId ?? 0,
  1517. model: state.model,
  1518. content,
  1519. history_count: state.historyCount,
  1520. stream: state.outputMode === '流式输出 (Stream)',
  1521. };
  1522. const controller = new AbortController();
  1523. state.activeAbortController = controller;
  1524. setStreaming(true);
  1525. try {
  1526. if (payload.stream) {
  1527. await streamAssistantReply(payload, assistantMessage, assistantIndex, controller);
  1528. } else {
  1529. const data = await fetchJSON('/api/chat', {
  1530. method: 'POST',
  1531. body: payload,
  1532. signal: controller.signal,
  1533. });
  1534. if (Number.isFinite(Number(data.session_id))) {
  1535. updateActiveSession(data.session_id, data.session_number);
  1536. }
  1537. assistantMessage.content = data.message || '';
  1538. updateMessageContent(assistantIndex, assistantMessage.content);
  1539. showToast('已生成回复', 'success');
  1540. setStatus('');
  1541. }
  1542. } catch (err) {
  1543. const aborted = err && (err.name === 'AbortError');
  1544. if (!aborted) {
  1545. state.messages.splice(assistantIndex, 1);
  1546. renderMessages();
  1547. const message = err.message || '发送失败';
  1548. setStatus(message, 'error');
  1549. showToast(message, 'error');
  1550. } else {
  1551. setStatus('对话已提前结束', 'error');
  1552. showToast('当前对话已中止', 'error');
  1553. }
  1554. } finally {
  1555. try {
  1556. state.historyPage = 0;
  1557. await loadHistory();
  1558. } catch (historyErr) {
  1559. console.error('刷新历史记录失败', historyErr);
  1560. } finally {
  1561. updateHistorySlider();
  1562. setStreaming(false);
  1563. }
  1564. }
  1565. }
  1566. function hasContent(content) {
  1567. if (typeof content === 'string') {
  1568. return Boolean(content.trim());
  1569. }
  1570. if (Array.isArray(content)) {
  1571. return content.length > 1 || (content[0] && String(content[0].text || '').trim());
  1572. }
  1573. return Boolean(content);
  1574. }
  1575. async function uploadAttachments(fileList) {
  1576. if (!fileList || fileList.length === 0) {
  1577. return [];
  1578. }
  1579. const formData = new FormData();
  1580. Array.from(fileList).forEach((file) => formData.append('files', file));
  1581. const response = await fetch('/api/upload', {
  1582. method: 'POST',
  1583. headers: buildAuthHeaders(),
  1584. body: formData,
  1585. });
  1586. if (!response.ok) {
  1587. throw new Error('文件上传失败');
  1588. }
  1589. return await response.json();
  1590. }
  1591. function buildUserContent(text, uploads) {
  1592. const results = Array.isArray(uploads) ? uploads : [];
  1593. if (!results.length) {
  1594. return { content: text };
  1595. }
  1596. const contentParts = [{ type: 'text', text: text }];
  1597. let additionalPrompt = '';
  1598. results.forEach((item) => {
  1599. if (item.type === 'image' && item.data) {
  1600. contentParts.push({
  1601. type: 'image_url',
  1602. image_url: { url: item.data },
  1603. });
  1604. } else if (item.type === 'file' && item.url) {
  1605. additionalPrompt += `本次提问包含:${item.url} 文件\n`;
  1606. }
  1607. });
  1608. const promptSuffix = additionalPrompt.trim();
  1609. if (contentParts.length > 1) {
  1610. const base = contentParts[0].text || '';
  1611. contentParts[0].text = promptSuffix ? `${base}\n${promptSuffix}`.trim() : base;
  1612. return { content: contentParts };
  1613. }
  1614. let combined = text || '';
  1615. if (promptSuffix) {
  1616. combined = combined ? `${combined}\n${promptSuffix}` : promptSuffix;
  1617. }
  1618. return { content: combined.trim() };
  1619. }
  1620. async function streamAssistantReply(payload, assistantMessage, assistantIndex, controller) {
  1621. const response = await fetch('/api/chat', {
  1622. method: 'POST',
  1623. headers: buildAuthHeaders({ 'Content-Type': 'application/json' }),
  1624. body: JSON.stringify(payload),
  1625. signal: controller ? controller.signal : undefined,
  1626. });
  1627. if (response.status === 401) {
  1628. handleUnauthorized();
  1629. throw new Error('未授权');
  1630. }
  1631. if (!response.ok || !response.body) {
  1632. const errorText = await safeReadText(response);
  1633. throw new Error(errorText || '生成失败');
  1634. }
  1635. const reader = response.body.getReader();
  1636. const decoder = new TextDecoder('utf-8');
  1637. let buffer = '';
  1638. let done = false;
  1639. while (!done) {
  1640. const { value, done: streamDone } = await reader.read();
  1641. done = streamDone;
  1642. if (value) {
  1643. buffer += decoder.decode(value, { stream: !done });
  1644. let newlineIndex = buffer.indexOf('\n');
  1645. while (newlineIndex !== -1) {
  1646. const line = buffer.slice(0, newlineIndex).trim();
  1647. buffer = buffer.slice(newlineIndex + 1);
  1648. if (line) {
  1649. const status = handleStreamLine(line, assistantMessage, assistantIndex);
  1650. if (status === 'end') {
  1651. return;
  1652. }
  1653. }
  1654. newlineIndex = buffer.indexOf('\n');
  1655. }
  1656. }
  1657. }
  1658. setStatus('');
  1659. }
  1660. function handleStreamLine(line, assistantMessage, assistantIndex) {
  1661. let payload;
  1662. try {
  1663. payload = JSON.parse(line);
  1664. } catch (err) {
  1665. return;
  1666. }
  1667. if (payload.type === 'meta') {
  1668. updateActiveSession(payload.session_id, payload.session_number);
  1669. return null;
  1670. }
  1671. if (payload.type === 'delta') {
  1672. if (typeof assistantMessage.content !== 'string') {
  1673. assistantMessage.content = '';
  1674. }
  1675. assistantMessage.content += payload.text || '';
  1676. updateMessageContent(assistantIndex, assistantMessage.content);
  1677. scrollToBottom();
  1678. return null;
  1679. } else if (payload.type === 'end') {
  1680. showToast('已生成回复', 'success');
  1681. setStatus('');
  1682. return 'end';
  1683. } else if (payload.type === 'error') {
  1684. throw new Error(payload.message || '生成失败');
  1685. }
  1686. }
  1687. function updateMessageContent(index, content) {
  1688. const selector = `.message[data-index="${index}"] .message-content`;
  1689. const node = dom.chatMessages.querySelector(selector);
  1690. if (!node) {
  1691. renderMessages();
  1692. return;
  1693. }
  1694. node.classList.remove('clamped');
  1695. renderContent(content, node, state.searchQuery && messageMatches(content, state.searchQuery) ? state.searchQuery : '');
  1696. }
  1697. function scrollToBottom() {
  1698. if (!dom.chatMessages) {
  1699. return;
  1700. }
  1701. dom.chatMessages.scrollTop = dom.chatMessages.scrollHeight;
  1702. }
  1703. function getSessionIdFromUrl() {
  1704. const params = new URLSearchParams(window.location.search);
  1705. const value = params.get('session');
  1706. if (!value) {
  1707. return null;
  1708. }
  1709. const parsed = Number(value);
  1710. return Number.isInteger(parsed) && parsed >= 0 ? parsed : null;
  1711. }
  1712. function buildSessionUrl(sessionId) {
  1713. const current = new URL(window.location.href);
  1714. if (Number.isInteger(sessionId) && sessionId >= 0) {
  1715. current.searchParams.set('session', String(sessionId));
  1716. } else {
  1717. current.searchParams.delete('session');
  1718. }
  1719. current.hash = '';
  1720. const search = current.searchParams.toString();
  1721. return `${current.pathname}${search ? `?${search}` : ''}`;
  1722. }
  1723. function updateSessionInUrl(sessionId, options = {}) {
  1724. if (!window.history || typeof window.history.replaceState !== 'function') {
  1725. return;
  1726. }
  1727. const { replace = false } = options;
  1728. const target = buildSessionUrl(sessionId);
  1729. const stateData = { sessionId };
  1730. if (replace) {
  1731. window.history.replaceState(stateData, '', target);
  1732. } else {
  1733. window.history.pushState(stateData, '', target);
  1734. }
  1735. }
  1736. async function handlePopState(event) {
  1737. if (!state.token) {
  1738. return;
  1739. }
  1740. if (state.streaming) {
  1741. return;
  1742. }
  1743. const stateSessionId = event.state && Number.isInteger(event.state.sessionId)
  1744. ? event.state.sessionId
  1745. : getSessionIdFromUrl();
  1746. try {
  1747. if (stateSessionId !== null) {
  1748. await loadSession(stateSessionId, { silent: true, updateUrl: false });
  1749. } else {
  1750. await loadLatestSession({ updateUrl: false });
  1751. }
  1752. await loadHistory();
  1753. } catch (err) {
  1754. console.warn('Failed to restore session from history navigation:', err);
  1755. }
  1756. }
  1757. function buildAuthHeaders(baseHeaders = {}) {
  1758. const headers = { ...(baseHeaders || {}) };
  1759. if (state.token) {
  1760. headers.Authorization = `Bearer ${state.token}`;
  1761. }
  1762. return headers;
  1763. }
  1764. async function fetchJSON(url, options = {}) {
  1765. const opts = { ...options };
  1766. opts.headers = buildAuthHeaders(opts.headers || {});
  1767. if (opts.body && !(opts.body instanceof FormData) && typeof opts.body !== 'string') {
  1768. opts.headers['Content-Type'] = 'application/json';
  1769. opts.body = JSON.stringify(opts.body);
  1770. }
  1771. const response = await fetch(url, opts);
  1772. if (response.status === 401) {
  1773. handleUnauthorized();
  1774. const message = await readErrorMessage(response);
  1775. throw new Error(message || '未授权');
  1776. }
  1777. if (!response.ok) {
  1778. const message = await readErrorMessage(response);
  1779. throw new Error(message || '请求失败');
  1780. }
  1781. if (response.status === 204) {
  1782. return {};
  1783. }
  1784. const text = await response.text();
  1785. return text ? JSON.parse(text) : {};
  1786. }
  1787. async function readErrorMessage(response) {
  1788. const text = await safeReadText(response);
  1789. if (!text) {
  1790. return response.statusText;
  1791. }
  1792. try {
  1793. const data = JSON.parse(text);
  1794. return data.detail || data.message || text;
  1795. } catch (err) {
  1796. return text;
  1797. }
  1798. }
  1799. async function safeReadText(response) {
  1800. try {
  1801. return await response.text();
  1802. } catch (err) {
  1803. return '';
  1804. }
  1805. }
  1806. let toastTimer;
  1807. function showToast(message, type = 'success') {
  1808. if (!dom.toast) {
  1809. return;
  1810. }
  1811. dom.toast.textContent = message;
  1812. dom.toast.classList.remove('hidden', 'success', 'error', 'show');
  1813. dom.toast.classList.add(type, 'show');
  1814. clearTimeout(toastTimer);
  1815. toastTimer = setTimeout(() => {
  1816. dom.toast.classList.remove('show');
  1817. toastTimer = setTimeout(() => dom.toast.classList.add('hidden'), 300);
  1818. }, 2500);
  1819. }
  1820. function extractInitials(name = '') {
  1821. if (!name) {
  1822. return '?';
  1823. }
  1824. const trimmed = name.trim();
  1825. if (!trimmed) {
  1826. return '?';
  1827. }
  1828. return trimmed.slice(0, 2).toUpperCase();
  1829. }
  1830. function setUserMenuOpen(open) {
  1831. const nextState = Boolean(open && state.token);
  1832. state.userMenuOpen = nextState;
  1833. if (dom.userMenuDropdown) {
  1834. dom.userMenuDropdown.classList.toggle('hidden', !nextState);
  1835. }
  1836. if (dom.userMenu) {
  1837. dom.userMenu.classList.toggle('open', nextState);
  1838. }
  1839. if (dom.userMenuToggle) {
  1840. dom.userMenuToggle.setAttribute('aria-expanded', nextState ? 'true' : 'false');
  1841. }
  1842. }
  1843. function updateSessionIndicator() {
  1844. if (!dom.sessionIndicator) {
  1845. return;
  1846. }
  1847. if (!state.token || state.sessionId === null) {
  1848. dom.sessionIndicator.textContent = '';
  1849. dom.sessionIndicator.classList.add('hidden');
  1850. return;
  1851. }
  1852. const displayNumber = Number.isInteger(state.sessionNumber) ? state.sessionNumber : state.sessionId;
  1853. dom.sessionIndicator.textContent = `会话 #${displayNumber}`;
  1854. dom.sessionIndicator.classList.remove('hidden');
  1855. }
  1856. function updateActiveSession(sessionId, sessionNumber, options = {}) {
  1857. const parsedId = Number(sessionId);
  1858. const parsedNumber = Number(sessionNumber);
  1859. const nextId = Number.isFinite(parsedId) ? parsedId : null;
  1860. const previousId = state.sessionId;
  1861. state.sessionId = nextId;
  1862. state.sessionNumber = Number.isFinite(parsedNumber) ? parsedNumber : null;
  1863. updateSessionIndicator();
  1864. if (options.updateUrl === false) {
  1865. return;
  1866. }
  1867. if (previousId === state.sessionId && !options.replace) {
  1868. return;
  1869. }
  1870. updateSessionInUrl(state.sessionId, { replace: Boolean(options.replace) });
  1871. }
  1872. })();