| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161 |
- (function () {
- 'use strict';
- const state = {
- config: null,
- sessionId: null,
- messages: [],
- expandedMessages: new Set(),
- historyPage: 0,
- historyPageSize: 9999,
- historyTotal: 0,
- historyItems: [],
- model: '',
- outputMode: '流式输出 (Stream)',
- historyCount: 0,
- searchQuery: '',
- streaming: false,
- };
- const dom = {};
- document.addEventListener('DOMContentLoaded', init);
- async function init() {
- cacheDom();
- bindEvents();
- try {
- await loadConfig();
- const querySessionId = getSessionIdFromUrl();
- let loadedFromQuery = false;
- if (querySessionId !== null) {
- try {
- await loadSession(querySessionId, { silent: true, updateUrl: true, replaceUrl: true });
- loadedFromQuery = true;
- } catch (err) {
- console.warn('Failed to load session from URL parameter:', err);
- showToast('指定的会话不存在,已加载最新会话。', 'error');
- }
- }
- if (!loadedFromQuery) {
- await loadLatestSession({ updateUrl: true, replaceUrl: true });
- }
- await loadHistory();
- } catch (err) {
- showToast(err.message || '初始化失败', 'error');
- }
- renderSidebar();
- renderMessages();
- renderHistory();
- }
- function cacheDom() {
- dom.modelSelect = document.getElementById('model-select');
- dom.outputMode = document.getElementById('output-mode');
- dom.searchInput = document.getElementById('search-input');
- dom.searchFeedback = document.getElementById('search-feedback');
- dom.historyRange = document.getElementById('history-range');
- dom.historyRangeLabel = document.getElementById('history-range-label');
- dom.historyRangeValue = document.getElementById('history-range-value');
- dom.historyList = document.getElementById('history-list');
- dom.historyCount = document.getElementById('history-count');
- dom.historyPrev = document.getElementById('history-prev');
- dom.historyNext = document.getElementById('history-next');
- dom.newChatButton = document.getElementById('new-chat-btn');
- dom.chatMessages = document.getElementById('chat-messages');
- dom.chatForm = document.getElementById('chat-form');
- dom.chatInput = document.getElementById('chat-input');
- dom.sendButton = document.getElementById('send-btn');
- dom.fileInput = document.getElementById('file-input');
- dom.chatStatus = document.getElementById('chat-status');
- dom.toast = document.getElementById('toast');
- if (dom.sendButton && !dom.sendButton.dataset.defaultText) {
- dom.sendButton.dataset.defaultText = dom.sendButton.textContent || '发送';
- }
- }
- function bindEvents() {
- dom.modelSelect.addEventListener('change', () => {
- state.model = dom.modelSelect.value;
- });
- dom.outputMode.addEventListener('change', () => {
- state.outputMode = dom.outputMode.value;
- });
- dom.searchInput.addEventListener('input', () => {
- state.searchQuery = dom.searchInput.value.trim();
- state.expandedMessages = new Set();
- renderMessages();
- });
- dom.historyRange.addEventListener('input', () => {
- state.historyCount = Number(dom.historyRange.value || 0);
- updateHistorySlider();
- });
- dom.historyPrev.addEventListener('click', async () => {
- if (state.historyPage > 0) {
- state.historyPage -= 1;
- await loadHistory();
- }
- });
- dom.historyNext.addEventListener('click', async () => {
- const totalPages = Math.ceil(state.historyTotal / state.historyPageSize) || 1;
- if (state.historyPage < totalPages - 1) {
- state.historyPage += 1;
- await loadHistory();
- }
- });
- dom.newChatButton.addEventListener('click', async () => {
- if (state.streaming) {
- showToast('请等待当前回复完成后再新建会话', 'error');
- return;
- }
- try {
- const data = await fetchJSON('/api/session/new', { method: 'POST' });
- state.sessionId = data.session_id;
- state.messages = [];
- state.historyCount = 0;
- state.searchQuery = '';
- dom.searchInput.value = '';
- state.expandedMessages = new Set();
- state.historyPage = 0;
- renderSidebar();
- renderMessages();
- renderHistory();
- updateSessionInUrl(state.sessionId, { replace: false });
- showToast('当前会话已清空。', 'success');
- await loadHistory();
- } catch (err) {
- showToast(err.message || '新建会话失败', 'error');
- }
- });
- dom.chatForm.addEventListener('submit', handleSubmitMessage);
- dom.chatInput.addEventListener('keydown', (event) => {
- if (event.key === 'Enter' && !event.shiftKey && !event.ctrlKey && !event.altKey) {
- event.preventDefault();
- if (typeof dom.chatForm.requestSubmit === 'function') {
- dom.chatForm.requestSubmit();
- } else if (dom.sendButton) {
- dom.sendButton.click();
- }
- }
- });
- window.addEventListener('popstate', handlePopState);
- }
- async function loadConfig() {
- const config = await fetchJSON('/api/config');
- state.config = config;
- const models = Array.isArray(config.models) ? config.models : [];
- state.model = config.default_model || models[0] || '';
- populateSelect(dom.modelSelect, models, state.model);
- populateSelect(dom.outputMode, config.output_modes || [], state.outputMode);
- }
- function populateSelect(selectEl, values, selected) {
- selectEl.innerHTML = '';
- values.forEach((value) => {
- const option = document.createElement('option');
- option.value = value;
- option.textContent = value;
- if (value === selected) {
- option.selected = true;
- }
- selectEl.appendChild(option);
- });
- if (!values.length) {
- const option = document.createElement('option');
- option.value = '';
- option.textContent = '无可用选项';
- selectEl.appendChild(option);
- }
- }
- async function loadLatestSession(options = {}) {
- const { updateUrl = true, replaceUrl = false } = options;
- const data = await fetchJSON('/api/session/latest');
- state.sessionId = typeof data.session_id === 'number' ? data.session_id : 0;
- state.messages = Array.isArray(data.messages) ? data.messages : [];
- state.expandedMessages = new Set();
- state.historyCount = Math.min(state.historyCount, state.messages.length);
- state.searchQuery = '';
- dom.searchInput.value = '';
- state.historyPage = 0;
- renderSidebar();
- renderMessages();
- renderHistory();
- if (updateUrl) {
- updateSessionInUrl(state.sessionId, { replace: replaceUrl });
- }
- }
- async function loadSession(sessionId, options = {}) {
- const { silent = false, updateUrl = true, replaceUrl = false } = options;
- if (state.streaming) {
- if (!silent) {
- showToast('请等待当前回复完成后再切换会话', 'error');
- }
- return false;
- }
- try {
- const data = await fetchJSON(`/api/session/${sessionId}`);
- state.sessionId = data.session_id;
- state.messages = Array.isArray(data.messages) ? data.messages : [];
- state.historyCount = Math.min(state.historyCount, state.messages.length);
- state.expandedMessages = new Set();
- state.searchQuery = '';
- dom.searchInput.value = '';
- renderSidebar();
- renderMessages();
- renderHistory();
- if (updateUrl) {
- updateSessionInUrl(state.sessionId, { replace: replaceUrl });
- }
- return true;
- } catch (err) {
- if (!silent) {
- showToast(err.message || '加载会话失败', 'error');
- }
- throw err;
- }
- }
- async function loadHistory() {
- try {
- const data = await fetchJSON(`/api/history?page=${state.historyPage}&page_size=${state.historyPageSize}`);
- const total = data.total || 0;
- const items = Array.isArray(data.items) ? data.items : [];
- if (state.historyPage > 0 && items.length === 0 && total > 0) {
- const maxPage = Math.max(0, Math.ceil(total / state.historyPageSize) - 1);
- if (state.historyPage > maxPage) {
- state.historyPage = maxPage;
- await loadHistory();
- return;
- }
- }
- state.historyTotal = total;
- state.historyItems = items;
- renderHistory();
- } catch (err) {
- showToast(err.message || '获取历史记录失败', 'error');
- }
- }
- function renderSidebar() {
- if (state.config) {
- populateSelect(dom.modelSelect, state.config.models || [], state.model);
- populateSelect(dom.outputMode, state.config.output_modes || [], state.outputMode);
- }
- updateHistorySlider();
- updateSearchFeedback();
- }
- function updateHistorySlider() {
- const total = state.messages.length;
- dom.historyRange.max = String(total);
- state.historyCount = Math.min(state.historyCount, total);
- dom.historyRange.value = String(state.historyCount);
- dom.historyRangeLabel.textContent = `选择使用的历史消息数量(共${total}条)`;
- dom.historyRangeValue.textContent = `您选择的历史消息数量是: ${state.historyCount}`;
- }
- function updateSearchFeedback() {
- if (!state.searchQuery) {
- dom.searchFeedback.textContent = '无匹配。';
- return;
- }
- const matches = state.messages.filter((msg) => messageMatches(msg.content, state.searchQuery)).length;
- dom.searchFeedback.textContent = `共找到 ${matches} 条匹配。`;
- }
- function setStatus(message, stateClass) {
- if (!dom.chatStatus) {
- return;
- }
- dom.chatStatus.textContent = message || '';
- dom.chatStatus.classList.remove('running', 'error');
- if (!message) {
- return;
- }
- if (stateClass) {
- dom.chatStatus.classList.add(stateClass);
- }
- }
- function setStreaming(active) {
- state.streaming = active;
- if (dom.sendButton) {
- dom.sendButton.disabled = active;
- const defaultText = dom.sendButton.dataset.defaultText || '发送';
- dom.sendButton.textContent = active ? '发送中…' : defaultText;
- }
- if (dom.newChatButton) {
- dom.newChatButton.disabled = active;
- }
- if (active) {
- setStatus('正在生成回复…', 'running');
- }
- }
- function renderHistory() {
- if (dom.historyCount) {
- const total = Number.isFinite(state.historyTotal) ? state.historyTotal : 0;
- dom.historyCount.textContent = `共 ${total} 条`;
- }
- dom.historyList.innerHTML = '';
- if (!state.historyItems.length) {
- const empty = document.createElement('div');
- empty.className = 'sidebar-help';
- empty.textContent = '无记录。';
- dom.historyList.appendChild(empty);
- } else {
- state.historyItems.forEach((item) => {
- const row = document.createElement('div');
- row.className = 'history-row';
- row.dataset.sessionId = String(item.session_id);
- row.setAttribute('role', 'listitem');
- if (item.session_id === state.sessionId) {
- row.classList.add('active');
- }
- const loadLink = document.createElement('a');
- loadLink.className = 'history-title-link';
- loadLink.href = buildSessionUrl(item.session_id);
- const displayTitle = (item.title && item.title.trim()) ? item.title.trim() : `会话 #${item.session_id}`;
- const primary = document.createElement('span');
- primary.className = 'history-title-text';
- primary.textContent = displayTitle;
- loadLink.appendChild(primary);
- const subtitle = document.createElement('span');
- subtitle.className = 'history-subtitle';
- subtitle.textContent = item.filename ? item.filename : `会话 #${item.session_id}`;
- loadLink.appendChild(subtitle);
- loadLink.title = `会话 #${item.session_id} · 点击加载`;
- loadLink.addEventListener('click', async (event) => {
- const isModified = event.metaKey || event.ctrlKey || event.shiftKey || event.altKey || event.button !== 0;
- if (isModified) {
- return;
- }
- event.preventDefault();
- try {
- await loadSession(item.session_id, { replaceUrl: false });
- } catch (err) {
- console.warn('Failed to load session from history list:', err);
- }
- });
- row.appendChild(loadLink);
- const moveButton = document.createElement('button');
- moveButton.className = 'history-icon-button';
- moveButton.type = 'button';
- moveButton.textContent = '📦';
- moveButton.title = '移动到备份文件夹';
- moveButton.addEventListener('click', async (event) => {
- event.stopPropagation();
- try {
- await fetchJSON('/api/history/move', {
- method: 'POST',
- body: { session_id: item.session_id },
- });
- showToast('已移动到备份。', 'success');
- await loadHistory();
- } catch (err) {
- showToast(err.message || '移动失败', 'error');
- }
- });
- row.appendChild(moveButton);
- const deleteButton = document.createElement('button');
- deleteButton.className = 'history-icon-button';
- deleteButton.type = 'button';
- deleteButton.textContent = '❌';
- deleteButton.title = '删除';
- deleteButton.addEventListener('click', async (event) => {
- event.stopPropagation();
- try {
- await fetchJSON(`/api/history/${item.session_id}`, { method: 'DELETE' });
- showToast('已删除。', 'success');
- if (item.session_id === state.sessionId) {
- await loadLatestSession();
- }
- await loadHistory();
- } catch (err) {
- showToast(err.message || '删除失败', 'error');
- }
- });
- row.appendChild(deleteButton);
- dom.historyList.appendChild(row);
- });
- }
- const totalPages = Math.ceil(state.historyTotal / state.historyPageSize) || 1;
- dom.historyPrev.disabled = state.historyPage <= 0;
- dom.historyNext.disabled = state.historyPage >= totalPages - 1;
- }
- function renderMessages() {
- dom.chatMessages.innerHTML = '';
- const total = state.messages.length;
- const searching = Boolean(state.searchQuery);
- state.messages.forEach((message, index) => {
- const wrapper = document.createElement('div');
- wrapper.className = `message ${message.role === 'assistant' ? 'assistant' : 'user'}`;
- wrapper.dataset.index = String(index);
- const header = document.createElement('div');
- header.className = 'message-header';
- header.textContent = message.role === 'assistant' ? 'Assistant' : 'User';
- wrapper.appendChild(header);
- const contentEl = document.createElement('div');
- contentEl.className = 'message-content';
- const expanded = state.expandedMessages.has(index);
- const shouldClamp = !searching && index < total - 1 && !expanded;
- if (shouldClamp) {
- contentEl.classList.add('clamped');
- }
- const query = state.searchQuery && messageMatches(message.content, state.searchQuery)
- ? state.searchQuery
- : '';
- renderContent(message.content, contentEl, query);
- wrapper.appendChild(contentEl);
- const actions = document.createElement('div');
- actions.className = 'message-actions';
- if (!searching && index < total - 1) {
- const toggleButton = document.createElement('button');
- toggleButton.className = 'message-button';
- toggleButton.textContent = expanded ? '<<' : '>>';
- toggleButton.addEventListener('click', () => {
- if (expanded) {
- state.expandedMessages.delete(index);
- } else {
- state.expandedMessages.add(index);
- }
- renderMessages();
- });
- actions.appendChild(toggleButton);
- }
- if (message.role === 'assistant') {
- const exportButton = document.createElement('button');
- exportButton.className = 'message-button';
- exportButton.textContent = '导出';
- exportButton.addEventListener('click', async () => {
- try {
- await fetchJSON('/api/export', {
- method: 'POST',
- body: { content: message.content },
- });
- showToast('已导出到 blog 文件夹。', 'success');
- } catch (err) {
- showToast(err.message || '导出失败', 'error');
- }
- });
- actions.appendChild(exportButton);
- }
- wrapper.appendChild(actions);
- dom.chatMessages.appendChild(wrapper);
- });
- updateSearchFeedback();
- scrollToBottom();
- }
- function renderContent(content, container, query) {
- container.innerHTML = '';
- const highlightQuery = query || '';
- if (typeof content === 'string' || content === null || content === undefined) {
- renderMarkdownContent(container, String(content || ''));
- applyHighlight(container, highlightQuery);
- return;
- }
- if (Array.isArray(content)) {
- content.forEach((part) => {
- if (part && part.type === 'text') {
- const textContainer = document.createElement('div');
- renderMarkdownContent(textContainer, String(part.text || ''));
- container.appendChild(textContainer);
- } else if (part && part.type === 'image_url') {
- const url = part.image_url && part.image_url.url ? part.image_url.url : '';
- const img = document.createElement('img');
- img.src = url;
- img.alt = '上传的图片';
- img.loading = 'lazy';
- container.appendChild(img);
- } else {
- const fallback = document.createElement('pre');
- fallback.textContent = JSON.stringify(part, null, 2);
- container.appendChild(fallback);
- }
- });
- applyHighlight(container, highlightQuery);
- return;
- }
- const pre = document.createElement('pre');
- pre.textContent = typeof content === 'object' ? JSON.stringify(content, null, 2) : String(content || '');
- container.appendChild(pre);
- applyHighlight(container, highlightQuery);
- }
- function renderMarkdownContent(container, text) {
- const normalized = String(text || '').replace(/\r\n/g, '\n');
- const lines = normalized.split('\n');
- let paragraphBuffer = [];
- let listBuffer = [];
- let blockquoteBuffer = [];
- let inCodeBlock = false;
- let codeLang = '';
- let codeBuffer = [];
- const flushParagraph = () => {
- if (!paragraphBuffer.length) {
- return;
- }
- const paragraphText = paragraphBuffer.join('\n');
- const paragraph = document.createElement('p');
- appendInlineMarkdown(paragraph, paragraphText);
- container.appendChild(paragraph);
- paragraphBuffer = [];
- };
- const flushList = () => {
- if (!listBuffer.length) {
- return;
- }
- const list = document.createElement('ul');
- listBuffer.forEach((item) => {
- const li = document.createElement('li');
- appendInlineMarkdown(li, item);
- list.appendChild(li);
- });
- container.appendChild(list);
- listBuffer = [];
- };
- const flushBlockquote = () => {
- if (!blockquoteBuffer.length) {
- return;
- }
- const blockquote = document.createElement('blockquote');
- const textContent = blockquoteBuffer.join('\n');
- appendInlineMarkdown(blockquote, textContent);
- container.appendChild(blockquote);
- blockquoteBuffer = [];
- };
- const flushCode = () => {
- const pre = document.createElement('pre');
- const code = document.createElement('code');
- if (codeLang) {
- code.dataset.lang = codeLang;
- code.className = `language-${codeLang}`;
- }
- code.textContent = codeBuffer.join('\n');
- pre.appendChild(code);
- container.appendChild(pre);
- codeBuffer = [];
- codeLang = '';
- inCodeBlock = false;
- };
- lines.forEach((rawLine) => {
- const line = rawLine;
- const fenceMatch = line.match(/^```([A-Za-z0-9_-]+)?\s*$/);
- if (fenceMatch) {
- if (inCodeBlock) {
- flushCode();
- } else {
- flushParagraph();
- flushList();
- flushBlockquote();
- inCodeBlock = true;
- codeLang = fenceMatch[1] ? fenceMatch[1].toLowerCase() : '';
- codeBuffer = [];
- }
- return;
- }
- if (inCodeBlock) {
- codeBuffer.push(line);
- return;
- }
- const listMatch = line.match(/^\s*[-*+]\s+(.*)$/);
- if (listMatch) {
- flushParagraph();
- flushBlockquote();
- listBuffer.push(listMatch[1]);
- return;
- }
- const blockquoteMatch = line.match(/^>\s?(.*)$/);
- if (blockquoteMatch) {
- flushParagraph();
- flushList();
- blockquoteBuffer.push(blockquoteMatch[1]);
- return;
- }
- if (!line.trim()) {
- flushParagraph();
- flushList();
- flushBlockquote();
- return;
- }
- const headingMatch = line.match(/^(#{1,6})\s+(.*)$/);
- if (headingMatch) {
- flushParagraph();
- flushList();
- flushBlockquote();
- const level = Math.min(headingMatch[1].length, 6);
- const heading = document.createElement(`h${level}`);
- appendInlineMarkdown(heading, headingMatch[2]);
- container.appendChild(heading);
- return;
- }
- paragraphBuffer.push(line);
- });
- if (inCodeBlock) {
- flushCode();
- }
- flushParagraph();
- flushList();
- flushBlockquote();
- }
- function appendInlineMarkdown(parent, text) {
- const pattern = /(!?\[[^\]]*\]\([^\)]+\)|`[^`]*`|\*\*[^*]+\*\*|\*[^*]+\*|~~[^~]+~~)/g;
- let lastIndex = 0;
- let match;
- while ((match = pattern.exec(text)) !== null) {
- if (match.index > lastIndex) {
- appendTextNode(parent, text.slice(lastIndex, match.index));
- }
- appendMarkdownToken(parent, match[0]);
- lastIndex = pattern.lastIndex;
- }
- if (lastIndex < text.length) {
- appendTextNode(parent, text.slice(lastIndex));
- }
- }
- function appendMarkdownToken(parent, token) {
- if (token.startsWith('`') && token.endsWith('`')) {
- const code = document.createElement('code');
- code.textContent = token.slice(1, -1);
- parent.appendChild(code);
- return;
- }
- if (token.startsWith('**') && token.endsWith('**')) {
- const strong = document.createElement('strong');
- appendInlineMarkdown(strong, token.slice(2, -2));
- parent.appendChild(strong);
- return;
- }
- if (token.startsWith('*') && token.endsWith('*')) {
- const em = document.createElement('em');
- appendInlineMarkdown(em, token.slice(1, -1));
- parent.appendChild(em);
- return;
- }
- if (token.startsWith('~~') && token.endsWith('~~')) {
- const del = document.createElement('del');
- appendInlineMarkdown(del, token.slice(2, -2));
- parent.appendChild(del);
- return;
- }
- if (token.startsWith('![')) {
- const match = token.match(/^!\[([^\]]*)\]\(([^\)]+)\)$/);
- if (match) {
- const img = document.createElement('img');
- img.alt = match[1];
- img.src = match[2];
- img.loading = 'lazy';
- parent.appendChild(img);
- return;
- }
- }
- if (token.startsWith('[')) {
- const match = token.match(/^\[([^\]]+)\]\(([^\)]+)\)$/);
- if (match) {
- const anchor = document.createElement('a');
- anchor.href = match[2];
- anchor.target = '_blank';
- anchor.rel = 'noopener noreferrer';
- anchor.textContent = match[1];
- parent.appendChild(anchor);
- return;
- }
- }
- appendTextNode(parent, token);
- }
- function appendTextNode(parent, text) {
- if (!text) {
- return;
- }
- const fragments = String(text).split(/(\n)/);
- fragments.forEach((fragment) => {
- if (fragment === '\n') {
- parent.appendChild(document.createElement('br'));
- } else if (fragment) {
- parent.appendChild(document.createTextNode(fragment));
- }
- });
- }
- function clearHighlights(root) {
- if (!root) {
- return;
- }
- root.querySelectorAll('mark.hl').forEach((mark) => {
- const parent = mark.parentNode;
- if (!parent) {
- return;
- }
- while (mark.firstChild) {
- parent.insertBefore(mark.firstChild, mark);
- }
- parent.removeChild(mark);
- parent.normalize();
- });
- }
- function applyHighlight(root, query) {
- if (!root) {
- return;
- }
- clearHighlights(root);
- if (!query) {
- return;
- }
- const lowerQuery = query.toLowerCase();
- const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, null);
- const matches = [];
- while (walker.nextNode()) {
- const node = walker.currentNode;
- if (!node || !node.nodeValue || !node.nodeValue.trim()) {
- continue;
- }
- const text = node.nodeValue;
- const lowerText = text.toLowerCase();
- let index = lowerText.indexOf(lowerQuery);
- while (index !== -1) {
- matches.push({ node, start: index, end: index + query.length });
- index = lowerText.indexOf(lowerQuery, index + query.length);
- }
- }
- for (let i = matches.length - 1; i >= 0; i -= 1) {
- const { node, start, end } = matches[i];
- if (!node || !node.parentNode) {
- continue;
- }
- const range = document.createRange();
- range.setStart(node, start);
- range.setEnd(node, end);
- const mark = document.createElement('mark');
- mark.className = 'hl';
- range.surroundContents(mark);
- }
- }
- function messageMatches(content, query) {
- if (!query) {
- return false;
- }
- const lower = query.toLowerCase();
- if (typeof content === 'string') {
- return content.toLowerCase().includes(lower);
- }
- if (Array.isArray(content)) {
- return content.some((part) => {
- if (!part || part.type !== 'text') {
- return false;
- }
- return String(part.text || '').toLowerCase().includes(lower);
- });
- }
- try {
- return JSON.stringify(content).toLowerCase().includes(lower);
- } catch (err) {
- return false;
- }
- }
- async function handleSubmitMessage(event) {
- event.preventDefault();
- if (state.streaming) {
- showToast('请等待当前回复完成', 'error');
- return;
- }
- const text = dom.chatInput.value.trim();
- const files = dom.fileInput.files;
- if (!text && (!files || files.length === 0)) {
- showToast('请输入内容或上传文件', 'error');
- return;
- }
- let uploads = [];
- const hasFiles = files && files.length > 0;
- if (hasFiles) {
- try {
- setStatus('正在上传文件…', 'running');
- uploads = await uploadAttachments(files);
- } catch (err) {
- const message = err.message || '文件上传失败';
- setStatus(message, 'error');
- showToast(message, 'error');
- return;
- }
- }
- const { content } = buildUserContent(text, uploads);
- if (!hasContent(content)) {
- setStatus('内容不能为空', 'error');
- showToast('内容不能为空', 'error');
- return;
- }
- setStatus('');
- state.expandedMessages = new Set();
- const userMessage = { role: 'user', content };
- state.messages.push(userMessage);
- renderMessages();
- scrollToBottom();
- dom.chatInput.value = '';
- dom.fileInput.value = '';
- const assistantMessage = { role: 'assistant', content: '' };
- state.messages.push(assistantMessage);
- const assistantIndex = state.messages.length - 1;
- renderMessages();
- scrollToBottom();
- const payload = {
- session_id: state.sessionId ?? 0,
- model: state.model,
- content,
- history_count: state.historyCount,
- stream: state.outputMode === '流式输出 (Stream)',
- };
- setStreaming(true);
- try {
- if (payload.stream) {
- await streamAssistantReply(payload, assistantMessage, assistantIndex);
- } else {
- const data = await fetchJSON('/api/chat', {
- method: 'POST',
- body: payload,
- });
- assistantMessage.content = data.message || '';
- updateMessageContent(assistantIndex, assistantMessage.content);
- showToast('已生成回复', 'success');
- setStatus('');
- }
- } catch (err) {
- state.messages.splice(assistantIndex, 1);
- renderMessages();
- const message = err.message || '发送失败';
- setStatus(message, 'error');
- showToast(message, 'error');
- } finally {
- try {
- state.historyPage = 0;
- await loadHistory();
- } catch (historyErr) {
- console.error('刷新历史记录失败', historyErr);
- } finally {
- updateHistorySlider();
- setStreaming(false);
- }
- }
- }
- function hasContent(content) {
- if (typeof content === 'string') {
- return Boolean(content.trim());
- }
- if (Array.isArray(content)) {
- return content.length > 1 || (content[0] && String(content[0].text || '').trim());
- }
- return Boolean(content);
- }
- async function uploadAttachments(fileList) {
- if (!fileList || fileList.length === 0) {
- return [];
- }
- const formData = new FormData();
- Array.from(fileList).forEach((file) => formData.append('files', file));
- const response = await fetch('/api/upload', {
- method: 'POST',
- body: formData,
- });
- if (!response.ok) {
- throw new Error('文件上传失败');
- }
- return await response.json();
- }
- function buildUserContent(text, uploads) {
- const results = Array.isArray(uploads) ? uploads : [];
- if (!results.length) {
- return { content: text };
- }
- const contentParts = [{ type: 'text', text: text }];
- let additionalPrompt = '';
- results.forEach((item) => {
- if (item.type === 'image' && item.data) {
- contentParts.push({
- type: 'image_url',
- image_url: { url: item.data },
- });
- } else if (item.type === 'file' && item.url) {
- additionalPrompt += `本次提问包含:${item.url} 文件\n`;
- }
- });
- const promptSuffix = additionalPrompt.trim();
- if (contentParts.length > 1) {
- const base = contentParts[0].text || '';
- contentParts[0].text = promptSuffix ? `${base}\n${promptSuffix}`.trim() : base;
- return { content: contentParts };
- }
- let combined = text || '';
- if (promptSuffix) {
- combined = combined ? `${combined}\n${promptSuffix}` : promptSuffix;
- }
- return { content: combined.trim() };
- }
- async function streamAssistantReply(payload, assistantMessage, assistantIndex) {
- const response = await fetch('/api/chat', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify(payload),
- });
- if (!response.ok || !response.body) {
- const errorText = await safeReadText(response);
- throw new Error(errorText || '生成失败');
- }
- const reader = response.body.getReader();
- const decoder = new TextDecoder('utf-8');
- let buffer = '';
- let done = false;
- while (!done) {
- const { value, done: streamDone } = await reader.read();
- done = streamDone;
- if (value) {
- buffer += decoder.decode(value, { stream: !done });
- let newlineIndex = buffer.indexOf('\n');
- while (newlineIndex !== -1) {
- const line = buffer.slice(0, newlineIndex).trim();
- buffer = buffer.slice(newlineIndex + 1);
- if (line) {
- const status = handleStreamLine(line, assistantMessage, assistantIndex);
- if (status === 'end') {
- return;
- }
- }
- newlineIndex = buffer.indexOf('\n');
- }
- }
- }
- setStatus('');
- }
- function handleStreamLine(line, assistantMessage, assistantIndex) {
- let payload;
- try {
- payload = JSON.parse(line);
- } catch (err) {
- return;
- }
- if (payload.type === 'delta') {
- if (typeof assistantMessage.content !== 'string') {
- assistantMessage.content = '';
- }
- assistantMessage.content += payload.text || '';
- updateMessageContent(assistantIndex, assistantMessage.content);
- scrollToBottom();
- return null;
- } else if (payload.type === 'end') {
- showToast('已生成回复', 'success');
- setStatus('');
- return 'end';
- } else if (payload.type === 'error') {
- throw new Error(payload.message || '生成失败');
- }
- }
- function updateMessageContent(index, content) {
- const selector = `.message[data-index="${index}"] .message-content`;
- const node = dom.chatMessages.querySelector(selector);
- if (!node) {
- renderMessages();
- return;
- }
- node.classList.remove('clamped');
- renderContent(content, node, state.searchQuery && messageMatches(content, state.searchQuery) ? state.searchQuery : '');
- }
- function scrollToBottom() {
- dom.chatMessages.scrollTop = dom.chatMessages.scrollHeight;
- }
- function getSessionIdFromUrl() {
- const params = new URLSearchParams(window.location.search);
- const value = params.get('session');
- if (!value) {
- return null;
- }
- const parsed = Number(value);
- return Number.isInteger(parsed) && parsed >= 0 ? parsed : null;
- }
- function buildSessionUrl(sessionId) {
- const current = new URL(window.location.href);
- if (Number.isInteger(sessionId) && sessionId >= 0) {
- current.searchParams.set('session', String(sessionId));
- } else {
- current.searchParams.delete('session');
- }
- current.hash = '';
- const search = current.searchParams.toString();
- return `${current.pathname}${search ? `?${search}` : ''}`;
- }
- function updateSessionInUrl(sessionId, options = {}) {
- if (!window.history || typeof window.history.replaceState !== 'function') {
- return;
- }
- const { replace = false } = options;
- const target = buildSessionUrl(sessionId);
- const stateData = { sessionId };
- if (replace) {
- window.history.replaceState(stateData, '', target);
- } else {
- window.history.pushState(stateData, '', target);
- }
- }
- async function handlePopState(event) {
- if (state.streaming) {
- return;
- }
- const stateSessionId = event.state && Number.isInteger(event.state.sessionId)
- ? event.state.sessionId
- : getSessionIdFromUrl();
- try {
- if (stateSessionId !== null) {
- await loadSession(stateSessionId, { silent: true, updateUrl: false });
- } else {
- await loadLatestSession({ updateUrl: false });
- }
- await loadHistory();
- } catch (err) {
- console.warn('Failed to restore session from history navigation:', err);
- }
- }
- async function fetchJSON(url, options = {}) {
- const opts = { ...options };
- opts.headers = { ...(opts.headers || {}) };
- if (opts.body && !(opts.body instanceof FormData) && typeof opts.body !== 'string') {
- opts.headers['Content-Type'] = 'application/json';
- opts.body = JSON.stringify(opts.body);
- }
- const response = await fetch(url, opts);
- if (!response.ok) {
- const message = await readErrorMessage(response);
- throw new Error(message || '请求失败');
- }
- if (response.status === 204) {
- return {};
- }
- const text = await response.text();
- return text ? JSON.parse(text) : {};
- }
- async function readErrorMessage(response) {
- const text = await safeReadText(response);
- if (!text) {
- return response.statusText;
- }
- try {
- const data = JSON.parse(text);
- return data.detail || data.message || text;
- } catch (err) {
- return text;
- }
- }
- async function safeReadText(response) {
- try {
- return await response.text();
- } catch (err) {
- return '';
- }
- }
- let toastTimer;
- function showToast(message, type = 'success') {
- if (!dom.toast) {
- return;
- }
- dom.toast.textContent = message;
- dom.toast.classList.remove('hidden', 'success', 'error', 'show');
- dom.toast.classList.add(type, 'show');
- clearTimeout(toastTimer);
- toastTimer = setTimeout(() => {
- dom.toast.classList.remove('show');
- toastTimer = setTimeout(() => dom.toast.classList.add('hidden'), 300);
- }, 2500);
- }
- })();
|