|
|
@@ -89,6 +89,7 @@
|
|
|
dom.chatInput = document.getElementById('chat-input');
|
|
|
dom.sendButton = document.getElementById('send-btn');
|
|
|
dom.fileInput = document.getElementById('file-input');
|
|
|
+ dom.expandAllButton = document.getElementById('expand-all-btn');
|
|
|
dom.chatStatus = document.getElementById('chat-status');
|
|
|
dom.sessionIndicator = document.getElementById('session-indicator');
|
|
|
dom.userMenu = document.getElementById('user-menu');
|
|
|
@@ -222,6 +223,15 @@
|
|
|
});
|
|
|
|
|
|
dom.chatForm.addEventListener('submit', handleSubmitMessage);
|
|
|
+ if (dom.expandAllButton) {
|
|
|
+ dom.expandAllButton.addEventListener('click', () => {
|
|
|
+ if (!state.token || !Array.isArray(state.messages) || !state.messages.length) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ state.expandedMessages = new Set(state.messages.map((_, idx) => idx));
|
|
|
+ renderMessages({ preserveScroll: true });
|
|
|
+ });
|
|
|
+ }
|
|
|
|
|
|
dom.chatInput.addEventListener('keydown', (event) => {
|
|
|
if (event.key === 'Enter' && !event.shiftKey && !event.ctrlKey && !event.altKey) {
|
|
|
@@ -985,7 +995,7 @@
|
|
|
if (!dom.chatStatus) {
|
|
|
return;
|
|
|
}
|
|
|
- dom.chatStatus.textContent = message || '';
|
|
|
+ dom.chatStatus.innerHTML = '';
|
|
|
dom.chatStatus.classList.remove('running', 'error');
|
|
|
if (!message) {
|
|
|
return;
|
|
|
@@ -993,6 +1003,16 @@
|
|
|
if (stateClass) {
|
|
|
dom.chatStatus.classList.add(stateClass);
|
|
|
}
|
|
|
+ if (stateClass === 'running') {
|
|
|
+ const spinner = document.createElement('span');
|
|
|
+ spinner.className = 'chat-status-spinner';
|
|
|
+ spinner.setAttribute('aria-hidden', 'true');
|
|
|
+ dom.chatStatus.appendChild(spinner);
|
|
|
+ }
|
|
|
+ const textEl = document.createElement('span');
|
|
|
+ textEl.className = 'chat-status-text';
|
|
|
+ textEl.textContent = message;
|
|
|
+ dom.chatStatus.appendChild(textEl);
|
|
|
}
|
|
|
|
|
|
function setStreaming(active) {
|
|
|
@@ -1165,10 +1185,12 @@
|
|
|
}
|
|
|
}
|
|
|
|
|
|
- function renderMessages() {
|
|
|
+ function renderMessages(options = {}) {
|
|
|
if (!dom.chatMessages) {
|
|
|
return;
|
|
|
}
|
|
|
+ const { preserveScroll = false } = options;
|
|
|
+ const previousScrollTop = preserveScroll ? dom.chatMessages.scrollTop : 0;
|
|
|
dom.chatMessages.innerHTML = '';
|
|
|
if (!state.token) {
|
|
|
const notice = document.createElement('div');
|
|
|
@@ -1217,7 +1239,7 @@
|
|
|
} else {
|
|
|
state.expandedMessages.add(index);
|
|
|
}
|
|
|
- renderMessages();
|
|
|
+ renderMessages({ preserveScroll: true });
|
|
|
});
|
|
|
actions.appendChild(toggleButton);
|
|
|
}
|
|
|
@@ -1249,7 +1271,11 @@
|
|
|
});
|
|
|
|
|
|
updateSearchFeedback();
|
|
|
- scrollToBottom();
|
|
|
+ if (preserveScroll) {
|
|
|
+ dom.chatMessages.scrollTop = previousScrollTop;
|
|
|
+ } else {
|
|
|
+ scrollToBottom();
|
|
|
+ }
|
|
|
}
|
|
|
|
|
|
function renderContent(content, container, query) {
|
|
|
@@ -1297,6 +1323,7 @@
|
|
|
let paragraphBuffer = [];
|
|
|
let listBuffer = [];
|
|
|
let blockquoteBuffer = [];
|
|
|
+ let tableBuffer = null;
|
|
|
let inCodeBlock = false;
|
|
|
let codeLang = '';
|
|
|
let codeBuffer = [];
|
|
|
@@ -1337,7 +1364,66 @@
|
|
|
blockquoteBuffer = [];
|
|
|
};
|
|
|
|
|
|
+ const parseTableRow = (line) => line
|
|
|
+ .trim()
|
|
|
+ .replace(/^\|/, '')
|
|
|
+ .replace(/\|$/, '')
|
|
|
+ .split('|')
|
|
|
+ .map((cell) => cell.trim());
|
|
|
+
|
|
|
+ const flushTable = () => {
|
|
|
+ if (!tableBuffer || tableBuffer.length < 2) {
|
|
|
+ if (tableBuffer && tableBuffer.length) {
|
|
|
+ tableBuffer.forEach((line) => paragraphBuffer.push(line));
|
|
|
+ }
|
|
|
+ tableBuffer = null;
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ const headerCells = parseTableRow(tableBuffer[0]);
|
|
|
+ const dividerCells = parseTableRow(tableBuffer[1]);
|
|
|
+ const isDividerValid = dividerCells.length === headerCells.length
|
|
|
+ && dividerCells.every((cell) => /^:?-{3,}:?$/.test(cell.replace(/\s+/g, '')));
|
|
|
+ if (!isDividerValid) {
|
|
|
+ tableBuffer.forEach((line) => paragraphBuffer.push(line));
|
|
|
+ tableBuffer = null;
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ const table = document.createElement('table');
|
|
|
+ table.className = 'md-table';
|
|
|
+ const thead = document.createElement('thead');
|
|
|
+ const headRow = document.createElement('tr');
|
|
|
+ headerCells.forEach((cell) => {
|
|
|
+ const th = document.createElement('th');
|
|
|
+ appendInlineMarkdown(th, cell);
|
|
|
+ headRow.appendChild(th);
|
|
|
+ });
|
|
|
+ thead.appendChild(headRow);
|
|
|
+ table.appendChild(thead);
|
|
|
+
|
|
|
+ if (tableBuffer.length > 2) {
|
|
|
+ const tbody = document.createElement('tbody');
|
|
|
+ for (let i = 2; i < tableBuffer.length; i += 1) {
|
|
|
+ const rowCells = parseTableRow(tableBuffer[i]);
|
|
|
+ if (rowCells.length === 1 && !rowCells[0]) {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ const row = document.createElement('tr');
|
|
|
+ rowCells.forEach((cell) => {
|
|
|
+ const td = document.createElement('td');
|
|
|
+ appendInlineMarkdown(td, cell);
|
|
|
+ row.appendChild(td);
|
|
|
+ });
|
|
|
+ tbody.appendChild(row);
|
|
|
+ }
|
|
|
+ table.appendChild(tbody);
|
|
|
+ }
|
|
|
+ container.appendChild(table);
|
|
|
+ tableBuffer = null;
|
|
|
+ };
|
|
|
+
|
|
|
const flushCode = () => {
|
|
|
+ const wrapper = document.createElement('div');
|
|
|
+ wrapper.className = 'code-block';
|
|
|
const pre = document.createElement('pre');
|
|
|
const code = document.createElement('code');
|
|
|
if (codeLang) {
|
|
|
@@ -1346,7 +1432,10 @@
|
|
|
}
|
|
|
code.textContent = codeBuffer.join('\n');
|
|
|
pre.appendChild(code);
|
|
|
- container.appendChild(pre);
|
|
|
+ const copyButton = createCopyButton(code);
|
|
|
+ wrapper.appendChild(copyButton);
|
|
|
+ wrapper.appendChild(pre);
|
|
|
+ container.appendChild(wrapper);
|
|
|
codeBuffer = [];
|
|
|
codeLang = '';
|
|
|
inCodeBlock = false;
|
|
|
@@ -1354,7 +1443,8 @@
|
|
|
|
|
|
lines.forEach((rawLine) => {
|
|
|
const line = rawLine;
|
|
|
- const fenceMatch = line.match(/^```([A-Za-z0-9_-]+)?\s*$/);
|
|
|
+ const trimmedLine = rawLine.trim();
|
|
|
+ const fenceMatch = trimmedLine.match(/^```([\w+-]+)?\s*$/);
|
|
|
if (fenceMatch) {
|
|
|
if (inCodeBlock) {
|
|
|
flushCode();
|
|
|
@@ -1374,6 +1464,21 @@
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
+ const isTableRowLine = /^\|.+\|.*$/.test(trimmedLine);
|
|
|
+ if (isTableRowLine) {
|
|
|
+ if (!tableBuffer) {
|
|
|
+ flushParagraph();
|
|
|
+ flushList();
|
|
|
+ flushBlockquote();
|
|
|
+ tableBuffer = [];
|
|
|
+ }
|
|
|
+ tableBuffer.push(trimmedLine);
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ if (tableBuffer) {
|
|
|
+ flushTable();
|
|
|
+ }
|
|
|
+
|
|
|
const listMatch = line.match(/^\s*[-*+]\s+(.*)$/);
|
|
|
if (listMatch) {
|
|
|
flushParagraph();
|
|
|
@@ -1390,10 +1495,11 @@
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
- if (!line.trim()) {
|
|
|
+ if (!trimmedLine.length) {
|
|
|
flushParagraph();
|
|
|
flushList();
|
|
|
flushBlockquote();
|
|
|
+ flushTable();
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
@@ -1418,6 +1524,7 @@
|
|
|
flushParagraph();
|
|
|
flushList();
|
|
|
flushBlockquote();
|
|
|
+ flushTable();
|
|
|
}
|
|
|
|
|
|
function appendInlineMarkdown(parent, text) {
|
|
|
@@ -1501,6 +1608,61 @@
|
|
|
});
|
|
|
}
|
|
|
|
|
|
+ function createCopyButton(codeElement) {
|
|
|
+ const button = document.createElement('button');
|
|
|
+ button.type = 'button';
|
|
|
+ button.className = 'code-copy-btn';
|
|
|
+ button.textContent = '复制';
|
|
|
+ button.title = '复制代码';
|
|
|
+ button.setAttribute('aria-label', '复制代码');
|
|
|
+ button.addEventListener('click', async () => {
|
|
|
+ const text = codeElement && codeElement.textContent ? codeElement.textContent : '';
|
|
|
+ if (!text) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ const defaultLabel = button.textContent;
|
|
|
+ button.disabled = true;
|
|
|
+ try {
|
|
|
+ await copyTextToClipboard(text);
|
|
|
+ button.textContent = '已复制';
|
|
|
+ } catch (err) {
|
|
|
+ console.error('复制失败', err);
|
|
|
+ button.textContent = '复制失败';
|
|
|
+ } finally {
|
|
|
+ setTimeout(() => {
|
|
|
+ button.textContent = defaultLabel;
|
|
|
+ button.disabled = false;
|
|
|
+ }, 1500);
|
|
|
+ }
|
|
|
+ });
|
|
|
+ return button;
|
|
|
+ }
|
|
|
+
|
|
|
+ async function copyTextToClipboard(text) {
|
|
|
+ if (!text) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ if (navigator.clipboard && navigator.clipboard.writeText) {
|
|
|
+ await navigator.clipboard.writeText(text);
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ const textarea = document.createElement('textarea');
|
|
|
+ textarea.value = text;
|
|
|
+ textarea.setAttribute('readonly', 'true');
|
|
|
+ textarea.style.position = 'absolute';
|
|
|
+ textarea.style.left = '-9999px';
|
|
|
+ document.body.appendChild(textarea);
|
|
|
+ textarea.select();
|
|
|
+ try {
|
|
|
+ const success = document.execCommand('copy');
|
|
|
+ if (!success) {
|
|
|
+ throw new Error('复制失败');
|
|
|
+ }
|
|
|
+ } finally {
|
|
|
+ document.body.removeChild(textarea);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
function clearHighlights(root) {
|
|
|
if (!root) {
|
|
|
return;
|