|
|
@@ -0,0 +1,1161 @@
|
|
|
+(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);
|
|
|
+ }
|
|
|
+})();
|