Add queued sending for Codex App drafts

Also include WebSocket heartbeat handling to keep idle connections healthy.
This commit is contained in:
shiyue
2026-06-22 22:18:27 +08:00
parent e15736e302
commit 844281ab4c
4 changed files with 766 additions and 29 deletions

View File

@@ -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 `<div class="code-block-wrapper mermaid-code-block mermaid-diagram-mode" data-cid="${cid}">
<div class="code-block-header">
<span>${escapeHtml(lang)}</span>
<div class="code-block-actions">
<button class="code-preview-btn mermaid-toggle-btn" type="button" onclick="ccToggleMermaid(this, event)" aria-label="切换 Mermaid 图形与代码">代码</button>
<button class="code-preview-btn mermaid-copy-graph-btn" type="button" onclick="ccCopyMermaidDiagram(this, event)" aria-label="复制 Mermaid 图形">复制图</button>
<button class="code-preview-btn mermaid-download-btn" type="button" onclick="ccDownloadMermaidDiagram(this, event)" aria-label="下载 Mermaid 图形">下载图</button>
<button class="code-copy-btn" type="button" onclick="ccCopyCode(this, event)" aria-label="复制 Mermaid 代码">Copy</button>
</div>
</div>
<div class="mermaid-render-pane" data-render-state="pending">
<div class="mermaid-render-target" aria-label="Mermaid 图形"></div>
<div class="mermaid-render-error" role="alert" hidden></div>
</div>
<pre><code class="hljs language-${escapeHtml(lang)}">${highlighted}</code></pre>
</div>`;
}
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
? `<button class="code-preview-btn" type="button" onclick="ccTogglePreview(this, event)" aria-label="预览代码块">Preview</button>`
@@ -3308,8 +3637,7 @@
const previewPane = canPreview
? `<div class="code-preview-pane"><iframe class="code-preview-iframe" sandbox="allow-scripts" loading="lazy"></iframe></div>`
: '';
const cid = canPreview ? (++_previewCodeId) : 0;
if (canPreview) _previewCodeMap.set(cid, code);
const cid = canPreview ? createStoredCodeId(source) : 0;
return `<div class="code-block-wrapper${canPreview ? ' has-preview' : ''}"${canPreview ? ` data-cid="${cid}"` : ''}>
<div class="code-block-header">
<span>${escapeHtml(lang)}</span>
@@ -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;

View File

@@ -14,7 +14,7 @@
document.documentElement.dataset.dividerTime = dividerTime;
})();
</script>
<link rel="stylesheet" href="style.css?v=20260621-cross-reply-collapse-last-section-offset">
<link rel="stylesheet" href="style.css?v=20260622-queued-send">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/atom-one-dark.min.css">
</head>
<body>
@@ -126,6 +126,14 @@
<path d="m15 5 3 3"></path>
</svg>
</button>
<button id="queue-send-btn" class="queue-send-btn" title="排队发送" aria-label="排队发送" type="button" hidden>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round">
<path d="M4 6h10"></path>
<path d="M4 12h8"></path>
<path d="M4 18h10"></path>
<path d="m16 10 4 4-4 4"></path>
</svg>
</button>
<button id="send-btn" class="send-btn" title="发送">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<line x1="22" y1="2" x2="11" y2="13"></line><polygon points="22 2 15 22 11 13 2 9 22 2"></polygon>
@@ -154,6 +162,7 @@
<script src="https://cdnjs.cloudflare.com/ajax/libs/marked/12.0.1/marked.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
<script src="app.js?v=20260621-cross-reply-collapse-last-section-offset"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/mermaid/10.9.1/mermaid.min.js"></script>
<script src="app.js?v=20260622-queued-send"></script>
</body>
</html>

View File

@@ -1044,6 +1044,7 @@ body.session-loading-active {
.session-search {
position: relative;
margin-top: 10px;
width: 100%;
}
.session-search::before {
content: '⌕';
@@ -1058,6 +1059,9 @@ body.session-loading-active {
pointer-events: none;
}
.session-search-input {
-webkit-appearance: none;
appearance: none;
box-sizing: border-box;
width: 100%;
height: 34px;
padding: 0 34px 0 30px;
@@ -1073,6 +1077,13 @@ body.session-loading-active {
.session-search-input::placeholder {
color: var(--text-muted);
}
.session-search-input::-webkit-search-decoration,
.session-search-input::-webkit-search-cancel-button,
.session-search-input::-webkit-search-results-button,
.session-search-input::-webkit-search-results-decoration {
-webkit-appearance: none;
appearance: none;
}
.session-search-input:focus {
background: var(--bg-primary);
border-color: rgba(192, 85, 58, 0.34);
@@ -1083,6 +1094,7 @@ body.session-loading-active {
right: 6px;
top: 50%;
z-index: 2;
transform: translateY(-50%);
display: inline-flex;
align-items: center;
justify-content: center;
@@ -2454,9 +2466,24 @@ body.session-loading-active {
border-color: var(--note-accent);
color: var(--note-accent);
}
.note-action:disabled {
opacity: 0.42;
cursor: default;
pointer-events: none;
}
.note-action:active {
transform: scale(0.96);
}
.note-action.queue {
background: var(--note-bg-strong);
border-color: var(--note-border);
color: var(--note-accent);
}
.note-action.queue:hover {
background: var(--note-accent);
border-color: transparent;
color: #fff;
}
.note-action.send,
.note-action.save {
background: var(--note-accent);
@@ -2723,6 +2750,63 @@ html[data-divider-time='hide'] .msg-bubble .agent-message-divider span {
.code-block-wrapper.preview-mode .code-preview-pane { display: block; }
.code-block-wrapper.preview-mode pre { display: none; }
.mermaid-code-block .code-block-actions {
flex-wrap: wrap;
justify-content: flex-end;
}
.mermaid-code-block.mermaid-diagram-mode pre {
display: none;
}
.mermaid-code-block.mermaid-source-mode .mermaid-render-pane {
display: none;
}
.mermaid-render-pane {
position: relative;
min-height: 120px;
padding: 18px;
overflow: auto;
background: #fff;
border-top: 1px solid var(--border-color);
color: #1f2933;
-webkit-overflow-scrolling: touch;
}
.mermaid-render-pane[data-render-state='pending']::before {
content: '图形渲染中...';
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
padding: 18px;
color: #6b7280;
font-size: 13px;
}
.mermaid-render-pane[data-render-state='rendered']::before,
.mermaid-render-pane[data-render-state='error']::before {
content: none;
}
.mermaid-render-target {
min-width: min-content;
min-height: 80px;
display: flex;
align-items: center;
justify-content: center;
}
.mermaid-render-target svg {
max-width: 100%;
height: auto;
}
.mermaid-render-error {
padding: 12px;
border: 1px solid rgba(220, 53, 69, 0.28);
border-radius: 8px;
background: rgba(220, 53, 69, 0.08);
color: #b42318;
font-size: 13px;
line-height: 1.5;
white-space: pre-wrap;
}
@media (pointer: coarse) {
.code-block-header {
padding: 8px 10px;
@@ -3305,6 +3389,18 @@ html[data-divider-time='hide'] .msg-bubble .agent-message-divider span {
color: var(--text-primary);
box-shadow: 0 8px 18px rgba(45, 31, 20, 0.05);
}
.queued-message .queue-avatar {
background: var(--accent);
}
.queued-message .queue-bubble {
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.72), transparent),
var(--accent-light);
border-color: rgba(192, 85, 58, 0.2);
}
.queued-message .queue-meta {
color: var(--accent);
}
.input-wrapper {
display: flex;
align-items: center;
@@ -3381,8 +3477,32 @@ html[data-divider-time='hide'] .msg-bubble .agent-message-divider span {
background: var(--note-accent-hover);
color: #fff;
}
.queue-send-btn {
appearance: none;
width: 36px;
height: 36px;
border: 1px solid transparent;
border-radius: 10px;
background: var(--info);
color: #fff;
cursor: pointer;
padding: 0;
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: center;
transition: background 0.15s, color 0.15s, border-color 0.15s, transform 0.1s;
}
.queue-send-btn:hover {
background: var(--accent);
color: #fff;
}
.queue-send-btn:active {
transform: scale(0.92);
}
.attach-btn:disabled,
.note-mode-btn:disabled {
.note-mode-btn:disabled,
.queue-send-btn:disabled {
opacity: 0.45;
cursor: default;
}
@@ -3576,7 +3696,8 @@ html[data-divider-time='hide'] .msg-bubble .agent-message-divider span {
.code-block-wrapper pre code { font-size: 12px; }
.input-wrapper { padding: 6px 10px; border-radius: 12px; }
.attach-btn { width: 34px; height: 34px; border-radius: 10px; }
.note-mode-btn { width: 34px; height: 34px; border-radius: 10px; }
.note-mode-btn,
.queue-send-btn { width: 34px; height: 34px; border-radius: 10px; }
.send-btn, .abort-btn { width: 34px; height: 34px; }
.pending-notes-tray { max-height: 34vh; margin-bottom: 8px; }
.pending-note { grid-template-columns: 26px minmax(0, 1fr); gap: 7px; }
@@ -5413,6 +5534,7 @@ html[data-theme='coolvibe'] .settings-back:hover {
}
:is(html[data-theme='carbon'], html[data-theme='nocturne'], html[data-theme='cinder']) .note-mode-btn,
:is(html[data-theme='carbon'], html[data-theme='nocturne'], html[data-theme='cinder']) .queue-send-btn,
:is(html[data-theme='carbon'], html[data-theme='nocturne'], html[data-theme='cinder']) .send-btn.note-send {
color: var(--bg-primary);
}
@@ -5456,6 +5578,12 @@ html[data-theme='coolvibe'] .settings-back:hover {
box-shadow: none;
}
:is(html[data-theme='carbon'], html[data-theme='nocturne'], html[data-theme='cinder']) .queued-message .queue-bubble {
background: var(--accent-light);
border-color: var(--border-color);
color: var(--text-primary);
}
:is(html[data-theme='carbon'], html[data-theme='nocturne'], html[data-theme='cinder']) .settings-inline-note code,
:is(html[data-theme='carbon'], html[data-theme='nocturne'], html[data-theme='cinder']) .tool-call-code {
background: rgba(0, 0, 0, 0.22);

View File

@@ -4942,6 +4942,7 @@ const server = http.createServer((req, res) => {
// === WebSocket Server ===
const wss = new WebSocketServer({ noServer: true });
const WS_HEARTBEAT_INTERVAL_MS = 30000;
server.on('upgrade', (req, socket, head) => {
let pathname = '';
@@ -4978,8 +4979,14 @@ wss.on('connection', (ws, req) => {
let authToken = null;
const wsId = crypto.randomBytes(4).toString('hex'); // short id for log correlation
const wsConnectTime = new Date().toISOString();
ws.isAlive = true;
ws._ccWebId = wsId;
plog('INFO', 'ws_connect', { wsId });
ws.on('pong', () => {
ws.isAlive = true;
});
ws.on('message', (raw) => {
let msg;
try {
@@ -5120,6 +5127,26 @@ wss.on('connection', (ws, req) => {
});
});
// WebSocket 心跳:避免反向代理因空闲连接关闭 /ws。
const wsHeartbeatTimer = setInterval(() => {
for (const client of wss.clients) {
if (client.isAlive === false) {
client.terminate();
continue;
}
client.isAlive = false;
if (client.readyState === 1) {
try {
client.ping();
} catch (err) {
plog('WARN', 'ws_ping_failed', { wsId: client._ccWebId || null, error: err.message });
client.terminate();
}
}
}
}, WS_HEARTBEAT_INTERVAL_MS);
if (typeof wsHeartbeatTimer.unref === 'function') wsHeartbeatTimer.unref();
// === Notify Config Handlers ===
function handleSaveNotifyConfig(ws, newConfig) {
if (!newConfig || !newConfig.provider) {