app.js 81 KB

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