app.js 65 KB

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