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 `
${highlighted}
+