From 844281ab4cddaf569f255b961baf969f691d0c07 Mon Sep 17 00:00:00 2001 From: shiyue Date: Mon, 22 Jun 2026 22:18:27 +0800 Subject: [PATCH] Add queued sending for Codex App drafts Also include WebSocket heartbeat handling to keep idle connections healthy. --- public/app.js | 623 ++++++++++++++++++++++++++++++++++++++++++++-- public/index.html | 13 +- public/style.css | 132 +++++++++- server.js | 27 ++ 4 files changed, 766 insertions(+), 29 deletions(-) diff --git a/public/app.js b/public/app.js index dcbc378..f8957d0 100644 --- a/public/app.js +++ b/public/app.js @@ -2,7 +2,7 @@ (function () { 'use strict'; - const ASSET_VERSION = '20260621-cross-reply-collapse-last-section-offset'; + const ASSET_VERSION = '20260622-queued-send'; const WS_URL = `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/ws`; const RENDER_DEBOUNCE = 100; const COMPOSER_SUGGESTION_DEBOUNCE = 120; @@ -196,8 +196,11 @@ let pendingSessionSwitchRequest = null; let skipDeleteConfirm = localStorage.getItem('cc-web-skip-delete-confirm') === '1'; let pendingInitialSessionLoad = false; + let initialSessionListHandled = false; let noteMode = false; let noteDraftSeq = 0; + let queuedMessageSeq = 0; + let queuedMessageDrainTimer = null; let isReloadingMcp = false; let sessionSearchQuery = ''; const collapsedProjectKeys = (() => { @@ -217,6 +220,7 @@ } })(); const pendingNotesByTarget = new Map(); + const queuedMessagesByTarget = new Map(); const userMessageIndex = new Map(); const expandedOldSessionGroups = new Set(); document.documentElement.dataset.dividerTime = showAgentDividerTime ? 'show' : 'hide'; @@ -261,6 +265,7 @@ const msgInput = $('#msg-input'); const inputWrapper = msgInput.closest('.input-wrapper'); const noteModeBtn = $('#note-mode-btn'); + const queueSendBtn = $('#queue-send-btn'); const sendBtn = $('#send-btn'); const abortBtn = $('#abort-btn'); const cmdMenu = $('#cmd-menu'); @@ -301,6 +306,22 @@ return currentSessionId || getDraftNoteKey(agent); } + function getSessionQueueKey(sessionId) { + return sessionId ? `session:${sessionId}` : ''; + } + + function getDraftQueueKey(agent = currentAgent) { + return `queue:${getDraftNoteKey(agent)}`; + } + + function getCurrentQueueKey(agent = currentAgent) { + return currentSessionId ? getSessionQueueKey(currentSessionId) : getDraftQueueKey(agent); + } + + function supportsQueuedSend(agent = currentAgent) { + return isCodexAppAgent(agent); + } + function getNotesForKey(key, create = true) { if (!key) return []; if (!pendingNotesByTarget.has(key)) { @@ -319,6 +340,24 @@ if (!notes || notes.length === 0) pendingNotesByTarget.delete(key); } + function getQueueForKey(key, create = true) { + if (!key) return []; + if (!queuedMessagesByTarget.has(key)) { + if (!create) return []; + queuedMessagesByTarget.set(key, []); + } + return queuedMessagesByTarget.get(key); + } + + function getCurrentQueue(create = true) { + return getQueueForKey(getCurrentQueueKey(), create); + } + + function cleanupQueueKey(key) { + const queue = queuedMessagesByTarget.get(key); + if (!queue || queue.length === 0) queuedMessagesByTarget.delete(key); + } + function migratePendingNotesToSession(sessionId, agent = currentAgent) { if (!sessionId) return; const draftKey = getDraftNoteKey(agent); @@ -329,6 +368,16 @@ pendingNotesByTarget.delete(draftKey); } + function migrateQueuedMessagesToSession(sessionId, agent = currentAgent) { + if (!sessionId) return; + const draftKey = getDraftQueueKey(agent); + const draftQueue = queuedMessagesByTarget.get(draftKey); + if (!draftQueue || draftQueue.length === 0) return; + const sessionQueue = getQueueForKey(getSessionQueueKey(sessionId), true); + sessionQueue.push(...draftQueue); + queuedMessagesByTarget.delete(draftKey); + } + function updateGenerationControls() { const noteActive = !!noteMode; const allowRuntimeInsert = isGenerating && isCodexAppAgent(currentAgent) && !noteActive; @@ -339,6 +388,14 @@ sendBtn.setAttribute('aria-label', sendLabel); sendBtn.hidden = isGenerating ? !(noteActive || allowRuntimeInsert) : false; } + if (queueSendBtn) { + const queueAvailable = noteActive && supportsQueuedSend(); + const queueLabel = isGenerating || currentSessionRunning ? '排队发送' : '排队发送(空闲时将立即发送)'; + queueSendBtn.hidden = !queueAvailable; + queueSendBtn.disabled = !queueAvailable; + queueSendBtn.title = queueLabel; + queueSendBtn.setAttribute('aria-label', queueLabel); + } if (abortBtn) { abortBtn.hidden = !isGenerating; } @@ -398,18 +455,69 @@ editBtn.addEventListener('click', () => beginEditPendingNote(note.id)); deleteBtn.addEventListener('click', () => removePendingNote(note.id)); sendNoteBtn.addEventListener('click', () => sendPendingNote(note.id)); - actions.append(editBtn, deleteBtn, sendNoteBtn); + actions.append(editBtn, deleteBtn); + if (supportsQueuedSend()) { + const queueNoteBtn = createNoteActionButton('queue', '排队', '加入自动发送队列'); + queueNoteBtn.addEventListener('click', () => queuePendingNote(note.id)); + actions.appendChild(queueNoteBtn); + } + actions.appendChild(sendNoteBtn); bubble.append(meta, text, actions); div.append(avatar, bubble); return div; } + function createQueuedMessageElement(message, index, total) { + const div = document.createElement('div'); + div.className = 'pending-note queued-message'; + div.dataset.queueId = message.id; + + const avatar = document.createElement('div'); + avatar.className = 'note-avatar queue-avatar'; + avatar.textContent = 'Q'; + + const bubble = document.createElement('div'); + bubble.className = 'note-bubble queue-bubble'; + + const meta = document.createElement('div'); + meta.className = 'note-meta queue-meta'; + const waitLabel = isGenerating || currentSessionRunning ? '等待本轮结束' : '即将发送'; + meta.textContent = `队列 · 第 ${index + 1}/${total} 条 · ${waitLabel}`; + + const text = document.createElement('div'); + text.className = 'note-text'; + text.textContent = message.text; + + const actions = document.createElement('div'); + actions.className = 'note-actions'; + + const upBtn = createNoteActionButton('move-up', '上移', '上移一位'); + upBtn.disabled = index <= 0; + upBtn.addEventListener('click', () => moveQueuedMessage(message.id, -1)); + + const downBtn = createNoteActionButton('move-down', '下移', '下移一位'); + downBtn.disabled = index >= total - 1; + downBtn.addEventListener('click', () => moveQueuedMessage(message.id, 1)); + + const editBtn = createNoteActionButton('edit', '修改'); + editBtn.addEventListener('click', () => beginEditQueuedMessage(message.id)); + + const deleteBtn = createNoteActionButton('delete', '删除'); + deleteBtn.addEventListener('click', () => removeQueuedMessage(message.id)); + + actions.append(upBtn, downBtn, editBtn, deleteBtn); + bubble.append(meta, text, actions); + div.append(avatar, bubble); + return div; + } + function renderPendingNotes(options = {}) { if (!pendingNotesTray) return; pendingNotesTray.innerHTML = ''; const notes = getCurrentNotes(false); - if (!notes || notes.length === 0) { + const queuedMessages = getCurrentQueue(false); + if ((!notes || notes.length === 0) && (!queuedMessages || queuedMessages.length === 0)) { pendingNotesTray.hidden = true; if (options.updateScrollbar !== false) updateScrollbar(); return; @@ -417,6 +525,9 @@ const frag = document.createDocumentFragment(); notes.forEach((note) => frag.appendChild(createPendingNoteElement(note))); + queuedMessages.forEach((message, index) => { + frag.appendChild(createQueuedMessageElement(message, index, queuedMessages.length)); + }); pendingNotesTray.appendChild(frag); pendingNotesTray.hidden = false; if (options.scrollIntoView !== false && options.scroll !== false) { @@ -454,6 +565,91 @@ return true; } + function getQueuedMessageValidationError(content) { + if (!supportsQueuedSend()) return '排队发送仅支持 Codex App。'; + if (!String(content || '').trim()) return '排队内容不能为空。'; + if (String(content || '').trim().startsWith('/')) return '排队发送暂不支持 slash 指令。'; + return ''; + } + + function addQueuedMessage(text, options = {}) { + const content = String(text || '').trim(); + const validationError = getQueuedMessageValidationError(content); + if (validationError) { + appendError(validationError); + return false; + } + const message = { + id: `queued-${Date.now().toString(36)}-${++queuedMessageSeq}`, + text: content, + createdAt: Date.now(), + }; + getCurrentQueue(true).push(message); + if (options.render !== false) renderPendingNotes(); + scheduleQueuedMessageDrain(); + return true; + } + + function queueMessageFromInput() { + const text = msgInput.value.trim(); + if (!text || isBlockingSessionLoad()) return; + hideCmdMenu(); + hideOptionPicker(); + if (pendingAttachments.length > 0) { + appendError('排队发送暂不支持图片附件,请先移除图片。'); + return; + } + if (addQueuedMessage(text)) { + msgInput.value = ''; + autoResize(); + } + } + + function queuePendingNote(noteId) { + const found = findPendingNote(noteId); + if (!found) return; + const text = String(found.note.text || '').trim(); + const validationError = getQueuedMessageValidationError(text); + if (validationError) { + appendError(validationError); + return; + } + dropPendingNote(noteId); + addQueuedMessage(text, { render: false }); + renderPendingNotes(); + } + + function findQueuedMessage(queueId) { + const key = getCurrentQueueKey(); + const queue = getQueueForKey(key, false); + const index = queue.findIndex((message) => message.id === queueId); + if (index === -1) return null; + return { key, queue, index, message: queue[index] }; + } + + function dropQueuedMessage(queueId) { + const found = findQueuedMessage(queueId); + if (!found) return null; + const [message] = found.queue.splice(found.index, 1); + cleanupQueueKey(found.key); + return message; + } + + function removeQueuedMessage(queueId) { + if (!dropQueuedMessage(queueId)) return; + renderPendingNotes({ scroll: false }); + } + + function moveQueuedMessage(queueId, delta) { + const found = findQueuedMessage(queueId); + if (!found) return; + const nextIndex = found.index + delta; + if (nextIndex < 0 || nextIndex >= found.queue.length) return; + const [message] = found.queue.splice(found.index, 1); + found.queue.splice(nextIndex, 0, message); + renderPendingNotes({ scroll: false }); + } + function removePendingNote(noteId) { if (!dropPendingNote(noteId)) return; renderPendingNotes({ scroll: false }); @@ -522,6 +718,65 @@ }); } + function beginEditQueuedMessage(queueId) { + const found = findQueuedMessage(queueId); + if (!found) return; + const queueEl = pendingNotesTray?.querySelector(`.queued-message[data-queue-id="${queueId}"]`); + const bubble = queueEl?.querySelector('.note-bubble'); + if (!bubble) return; + + bubble.classList.add('editing'); + bubble.innerHTML = ''; + + const meta = document.createElement('div'); + meta.className = 'note-meta queue-meta'; + meta.textContent = '修改排队消息'; + + const editor = document.createElement('textarea'); + editor.className = 'note-edit-input'; + editor.value = found.message.text; + editor.rows = 3; + + const actions = document.createElement('div'); + actions.className = 'note-actions'; + const saveBtn = createNoteActionButton('save', '保存'); + const cancelBtn = createNoteActionButton('cancel', '取消'); + + const save = () => { + const next = editor.value.trim(); + const validationError = getQueuedMessageValidationError(next); + if (validationError) { + appendError(validationError); + editor.focus(); + return; + } + found.message.text = next; + renderPendingNotes({ scroll: false }); + }; + + saveBtn.addEventListener('click', save); + cancelBtn.addEventListener('click', () => renderPendingNotes({ scroll: false })); + editor.addEventListener('input', () => resizeNoteEditor(editor)); + editor.addEventListener('keydown', (e) => { + if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') { + e.preventDefault(); + save(); + } + if (e.key === 'Escape') { + e.preventDefault(); + renderPendingNotes({ scroll: false }); + } + }); + + actions.append(saveBtn, cancelBtn); + bubble.append(meta, editor, actions); + requestAnimationFrame(() => { + resizeNoteEditor(editor); + editor.focus(); + editor.select(); + }); + } + function sendPendingNote(noteId) { if (isGenerating || isBlockingSessionLoad()) { appendError('当前回复还在生成,稍后再发送笔记。'); @@ -535,6 +790,30 @@ submitUserMessage(text); } + function scheduleQueuedMessageDrain() { + if (queuedMessageDrainTimer) return; + queuedMessageDrainTimer = setTimeout(() => { + queuedMessageDrainTimer = null; + drainQueuedMessages(); + }, 0); + } + + function drainQueuedMessages() { + if (!supportsQueuedSend() || isGenerating || currentSessionRunning || isBlockingSessionLoad()) return; + const queue = getCurrentQueue(false); + if (!queue || queue.length === 0) return; + const [message] = queue.splice(0, 1); + cleanupQueueKey(getCurrentQueueKey()); + renderPendingNotes({ scroll: false }); + + const text = String(message?.text || '').trim(); + if (!text) { + scheduleQueuedMessageDrain(); + return; + } + submitUserMessage(text); + } + function normalizeTheme(theme) { return THEME_OPTIONS.some((item) => item.value === theme) ? theme : 'washi'; } @@ -2865,6 +3144,7 @@ localStorage.removeItem(getAgentSessionStorageKey(currentAgent)); } pendingNotesByTarget.delete(session.id); + queuedMessagesByTarget.delete(getSessionQueueKey(session.id)); invalidateSessionCache(session.id); send({ type: 'delete_session', sessionId: session.id }); if (session.id === currentSessionId) { @@ -2945,6 +3225,7 @@ } } updateCwdBadge(); + if (!running) scheduleQueuedMessageDrain(); } function updateAgentScopedUI() { @@ -3047,6 +3328,7 @@ updateSessionIdBadge(); setCurrentAgent(snapshotAgent); migratePendingNotesToSession(snapshot.sessionId, snapshotAgent); + migrateQueuedMessagesToSession(snapshot.sessionId, snapshotAgent); setCurrentSessionRunningState(snapshot.isRunning); setStatsDisplay(snapshot); closeUserOutlinePanel(); @@ -3285,22 +3567,69 @@ // --- marked config --- const PREVIEW_LANGS = new Set(['html', 'svg']); + const MERMAID_LANGS = new Set(['mermaid', 'mmd']); const _previewCodeMap = new Map(); let _previewCodeId = 0; + let _mermaidInitialized = false; + let _mermaidRenderId = 0; + + function normalizeCodeLanguage(language) { + const lang = String(language || '').trim().split(/\s+/)[0].toLowerCase(); + return lang || 'plaintext'; + } + + function highlightCodeBlock(code, lang) { + try { + if (hljs.getLanguage(lang)) { + return hljs.highlight(code, { language: lang }).value; + } + return hljs.highlightAuto(code).value; + } catch { + return escapeHtml(code); + } + } + + function getCodeBlockSource(wrapper) { + if (!wrapper) return ''; + const cid = wrapper.dataset.cid ? Number(wrapper.dataset.cid) : 0; + if (cid && _previewCodeMap.has(cid)) return _previewCodeMap.get(cid); + return wrapper.querySelector('code')?.textContent || ''; + } + + function createStoredCodeId(code) { + const cid = ++_previewCodeId; + _previewCodeMap.set(cid, code); + return cid; + } + + function renderMermaidCodeBlock(code, lang) { + const cid = createStoredCodeId(code); + const highlighted = highlightCodeBlock(code, lang); + return `
+
+ ${escapeHtml(lang)} +
+ + + + +
+
+
+
+ +
+
${highlighted}
+
`; + } const renderer = new marked.Renderer(); renderer.code = function (code, language) { - const lang = (language || 'plaintext').toLowerCase(); - let highlighted; - try { - if (hljs.getLanguage(lang)) { - highlighted = hljs.highlight(code, { language: lang }).value; - } else { - highlighted = hljs.highlightAuto(code).value; - } - } catch { - highlighted = escapeHtml(code); - } + const source = String(code || ''); + const lang = normalizeCodeLanguage(language); + if (MERMAID_LANGS.has(lang)) return renderMermaidCodeBlock(source, lang); + + const highlighted = highlightCodeBlock(source, lang); const canPreview = PREVIEW_LANGS.has(lang); const previewBtn = canPreview ? `` @@ -3308,8 +3637,7 @@ const previewPane = canPreview ? `
` : ''; - const cid = canPreview ? (++_previewCodeId) : 0; - if (canPreview) _previewCodeMap.set(cid, code); + const cid = canPreview ? createStoredCodeId(source) : 0; return `
${escapeHtml(lang)} @@ -3325,8 +3653,7 @@ event?.stopPropagation(); const wrapper = btn?.closest?.('.code-block-wrapper'); if (!wrapper) return; - const cid = wrapper.dataset.cid ? Number(wrapper.dataset.cid) : 0; - const code = (cid && _previewCodeMap.has(cid)) ? _previewCodeMap.get(cid) : wrapper.querySelector('code')?.textContent; + const code = getCodeBlockSource(wrapper); const defaultLabel = btn.dataset.defaultLabel || btn.textContent || 'Copy'; btn.dataset.defaultLabel = defaultLabel; btn.disabled = true; @@ -3361,6 +3688,238 @@ } }; + function ensureMermaidInitialized() { + if (!window.mermaid?.render) throw new Error('Mermaid 加载失败'); + if (!_mermaidInitialized) { + window.mermaid.initialize({ + startOnLoad: false, + securityLevel: 'strict', + theme: 'default', + }); + _mermaidInitialized = true; + } + return window.mermaid; + } + + function setMermaidRenderError(wrapper, message) { + const pane = wrapper?.querySelector?.('.mermaid-render-pane'); + const errorEl = wrapper?.querySelector?.('.mermaid-render-error'); + if (!pane || !errorEl) return; + pane.dataset.renderState = 'error'; + errorEl.hidden = false; + errorEl.textContent = message || 'Mermaid 图形渲染失败'; + } + + async function hydrateMermaidBlock(wrapper, force = false) { + if (!wrapper || (!force && wrapper.dataset.mermaidRendered === '1')) return true; + const pane = wrapper.querySelector('.mermaid-render-pane'); + const target = wrapper.querySelector('.mermaid-render-target'); + const errorEl = wrapper.querySelector('.mermaid-render-error'); + if (!pane || !target) return false; + + const code = getCodeBlockSource(wrapper).trim(); + if (!code) { + setMermaidRenderError(wrapper, 'Mermaid 代码为空'); + return false; + } + + const renderKey = String(++_mermaidRenderId); + wrapper.dataset.mermaidRenderKey = renderKey; + wrapper.dataset.mermaidRendered = '0'; + pane.dataset.renderState = 'pending'; + target.innerHTML = ''; + if (errorEl) { + errorEl.hidden = true; + errorEl.textContent = ''; + } + + try { + const mermaidApi = ensureMermaidInitialized(); + const result = await mermaidApi.render(`ccweb-mermaid-${renderKey}`, code); + if (!wrapper.isConnected || wrapper.dataset.mermaidRenderKey !== renderKey) return false; + target.innerHTML = typeof result === 'string' ? result : result.svg; + if (typeof result?.bindFunctions === 'function') result.bindFunctions(target); + pane.dataset.renderState = 'rendered'; + wrapper.dataset.mermaidRendered = '1'; + return true; + } catch (error) { + if (!wrapper.isConnected || wrapper.dataset.mermaidRenderKey !== renderKey) return false; + setMermaidRenderError(wrapper, error?.message || 'Mermaid 图形渲染失败'); + return false; + } + } + + function hydrateMermaidBlocks(root) { + if (!root) return; + const blocks = root.matches?.('.mermaid-code-block') + ? [root] + : Array.from(root.querySelectorAll('.mermaid-code-block')); + blocks.forEach((block) => { + hydrateMermaidBlock(block); + }); + } + + function hydrateRenderedMarkdown(root) { + hydrateMermaidBlocks(root); + } + + function setRenderedMarkdown(target, text, hydrate = true) { + target.innerHTML = renderMarkdown(text); + if (hydrate) hydrateRenderedMarkdown(target); + } + + window.ccToggleMermaid = function (btn, event) { + event?.preventDefault(); + event?.stopPropagation(); + const wrapper = btn?.closest?.('.mermaid-code-block'); + if (!wrapper) return; + const showCode = wrapper.classList.contains('mermaid-diagram-mode'); + wrapper.classList.toggle('mermaid-diagram-mode', !showCode); + wrapper.classList.toggle('mermaid-source-mode', showCode); + btn.textContent = showCode ? '图' : '代码'; + if (!showCode) hydrateMermaidBlock(wrapper); + }; + + function serializeMermaidSvg(wrapper) { + const svg = wrapper?.querySelector?.('.mermaid-render-target svg'); + if (!svg) return ''; + const clone = svg.cloneNode(true); + if (!clone.getAttribute('xmlns')) clone.setAttribute('xmlns', 'http://www.w3.org/2000/svg'); + if (!clone.getAttribute('width') || !clone.getAttribute('height')) { + const viewBox = clone.getAttribute('viewBox'); + const parts = viewBox ? viewBox.trim().split(/\s+/).map(Number) : []; + if (parts.length === 4 && parts.every(Number.isFinite)) { + clone.setAttribute('width', String(Math.max(1, Math.ceil(parts[2])))); + clone.setAttribute('height', String(Math.max(1, Math.ceil(parts[3])))); + } + } + return new XMLSerializer().serializeToString(clone); + } + + async function getMermaidSvgText(wrapper) { + if (!serializeMermaidSvg(wrapper)) { + await hydrateMermaidBlock(wrapper, true); + } + return serializeMermaidSvg(wrapper); + } + + function svgTextToPngBlob(svgText) { + return new Promise((resolve, reject) => { + const svgBlob = new Blob([svgText], { type: 'image/svg+xml;charset=utf-8' }); + const url = URL.createObjectURL(svgBlob); + const image = new Image(); + image.onload = () => { + try { + const canvas = document.createElement('canvas'); + const width = Math.max(1, image.naturalWidth || image.width || 1200); + const height = Math.max(1, image.naturalHeight || image.height || 800); + canvas.width = width; + canvas.height = height; + const ctx = canvas.getContext('2d'); + ctx.fillStyle = '#ffffff'; + ctx.fillRect(0, 0, width, height); + ctx.drawImage(image, 0, 0, width, height); + canvas.toBlob((blob) => { + URL.revokeObjectURL(url); + if (blob) resolve(blob); + else reject(new Error('PNG 导出失败')); + }, 'image/png'); + } catch (error) { + URL.revokeObjectURL(url); + reject(error); + } + }; + image.onerror = () => { + URL.revokeObjectURL(url); + reject(new Error('SVG 图形读取失败')); + }; + image.src = url; + }); + } + + async function copyBlobToClipboard(blob, type) { + if (!navigator.clipboard?.write || typeof ClipboardItem === 'undefined' || !window.isSecureContext) return false; + await navigator.clipboard.write([new ClipboardItem({ [type]: blob })]); + return true; + } + + function setTemporaryButtonLabel(btn, label, fallback, timeout = 1500) { + if (!btn) return; + const defaultLabel = btn.dataset.defaultLabel || fallback || btn.textContent || ''; + btn.dataset.defaultLabel = defaultLabel; + if (btn._labelResetTimer) clearTimeout(btn._labelResetTimer); + btn.textContent = label; + btn._labelResetTimer = setTimeout(() => { + btn.textContent = btn.dataset.defaultLabel || defaultLabel; + btn._labelResetTimer = null; + }, timeout); + } + + window.ccCopyMermaidDiagram = async function (btn, event) { + event?.preventDefault(); + event?.stopPropagation(); + const wrapper = btn?.closest?.('.mermaid-code-block'); + if (!wrapper) return; + btn.disabled = true; + try { + const svgText = await getMermaidSvgText(wrapper); + if (!svgText) throw new Error('暂无可复制图形'); + let copied = false; + try { + const pngBlob = await svgTextToPngBlob(svgText); + copied = await copyBlobToClipboard(pngBlob, 'image/png'); + } catch {} + if (!copied) { + const svgBlob = new Blob([svgText], { type: 'image/svg+xml;charset=utf-8' }); + try { copied = await copyBlobToClipboard(svgBlob, 'image/svg+xml'); } catch {} + } + if (copied) { + showToast('图形已复制'); + setTemporaryButtonLabel(btn, '已复制', '复制图'); + } else { + await copyTextToClipboard(svgText, '图形 SVG 已复制'); + setTemporaryButtonLabel(btn, '已复制', '复制图'); + } + } catch (error) { + showToast(error?.message || '复制图形失败'); + } finally { + btn.disabled = false; + } + }; + + function downloadBlob(blob, filename) { + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = filename; + link.rel = 'noopener'; + document.body.appendChild(link); + link.click(); + link.remove(); + setTimeout(() => URL.revokeObjectURL(url), 1000); + } + + window.ccDownloadMermaidDiagram = async function (btn, event) { + event?.preventDefault(); + event?.stopPropagation(); + const wrapper = btn?.closest?.('.mermaid-code-block'); + if (!wrapper) return; + btn.disabled = true; + try { + const svgText = await getMermaidSvgText(wrapper); + if (!svgText) throw new Error('暂无可下载图形'); + const cid = wrapper.dataset.cid || Date.now(); + const blob = new Blob([svgText], { type: 'image/svg+xml;charset=utf-8' }); + downloadBlob(blob, `mermaid-${cid}.svg`); + showToast('图形已下载'); + setTemporaryButtonLabel(btn, '已下载', '下载图'); + } catch (error) { + showToast(error?.message || '下载图形失败'); + } finally { + btn.disabled = false; + } + }; + // --- WebSocket --- function isBrowserOnline() { return !('onLine' in navigator) || navigator.onLine; @@ -3449,6 +4008,7 @@ switch (msg.type) { case 'auth_result': if (msg.success) { + const shouldLoadInitialSession = !initialSessionListHandled && !currentSessionId; authToken = msg.token; wsAuthenticated = true; localStorage.setItem('cc-web-token', msg.token); @@ -3461,7 +4021,7 @@ if (msg.mustChangePassword) { showForceChangePassword(); } else { - pendingInitialSessionLoad = true; + pendingInitialSessionLoad = shouldLoadInitialSession; } } else { pendingSessionSwitchRequest = null; @@ -3493,9 +4053,13 @@ } if (pendingInitialSessionLoad) { pendingInitialSessionLoad = false; + initialSessionListHandled = true; syncViewForAgent(currentAgent, { preserveCurrent: false, loadLast: true }); } else if (currentSessionId && !getSessionMeta(currentSessionId)) { + initialSessionListHandled = true; resetChatView(currentAgent); + } else { + initialSessionListHandled = true; } break; @@ -3907,6 +4471,7 @@ toolGroupCount = 0; hasGrouped = false; updateNoteModeUI(); + renderPendingNotes({ scroll: false }); // 不禁用输入框,允许用户继续输入(但无法发送) const welcome = messagesDiv.querySelector('.welcome-msg'); @@ -3980,6 +4545,7 @@ if (sessionId) { migratePendingNotesToSession(sessionId, currentAgent); + migrateQueuedMessagesToSession(sessionId, currentAgent); } pendingText = ''; activeToolCalls.clear(); @@ -3987,6 +4553,7 @@ toolGroupCount = 0; hasGrouped = false; renderPendingNotes(); + scheduleQueuedMessageDrain(); } // --- Rendering --- @@ -4010,7 +4577,7 @@ } else if (pendingText) { let textDiv = bubble.querySelector('.msg-text'); if (!textDiv) { textDiv = bubble; } - textDiv.innerHTML = renderMarkdown(pendingText); + setRenderedMarkdown(textDiv, pendingText); } syncAssistantLastSectionButton(streamEl); scrollToBottom(); @@ -4395,14 +4962,16 @@ const after = content.substring(jsonMatch.index + jsonMatch[0].length); if (before.trim()) { const textDiv = document.createElement('div'); - textDiv.innerHTML = renderMarkdown(before); + setRenderedMarkdown(textDiv, before, false); bubble.appendChild(textDiv); + hydrateRenderedMarkdown(textDiv); } bubble.appendChild(createTodoListElement(parsed)); if (after.trim()) { const textDiv = document.createElement('div'); - textDiv.innerHTML = renderMarkdown(after); + setRenderedMarkdown(textDiv, after, false); bubble.appendChild(textDiv); + hydrateRenderedMarkdown(textDiv); } return; } @@ -4420,7 +4989,7 @@ } } catch (e) {} } - bubble.innerHTML = renderMarkdown(content); + setRenderedMarkdown(bubble, content); return; } @@ -4428,8 +4997,9 @@ content.forEach(block => { if (block.type === 'text') { const textDiv = document.createElement('div'); - textDiv.innerHTML = renderMarkdown(block.text || ''); + setRenderedMarkdown(textDiv, block.text || '', false); bubble.appendChild(textDiv); + hydrateRenderedMarkdown(textDiv); } else if (block.type === 'todo_list') { bubble.appendChild(createTodoListElement(block)); } @@ -4437,7 +5007,7 @@ return; } - bubble.innerHTML = renderMarkdown(String(content)); + setRenderedMarkdown(bubble, String(content)); } function createTodoListElement(block) { @@ -6750,6 +7320,9 @@ } }); sendBtn.addEventListener('click', sendMessage); + if (queueSendBtn) { + queueSendBtn.addEventListener('click', queueMessageFromInput); + } if (noteModeBtn) { noteModeBtn.addEventListener('click', () => { noteMode = !noteMode; diff --git a/public/index.html b/public/index.html index 89166a1..0650692 100644 --- a/public/index.html +++ b/public/index.html @@ -14,7 +14,7 @@ document.documentElement.dataset.dividerTime = dividerTime; })(); - + @@ -126,6 +126,14 @@ +