Add queued sending for Codex App drafts
Also include WebSocket heartbeat handling to keep idle connections healthy.
This commit is contained in:
623
public/app.js
623
public/app.js
@@ -2,7 +2,7 @@
|
|||||||
(function () {
|
(function () {
|
||||||
'use strict';
|
'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 WS_URL = `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/ws`;
|
||||||
const RENDER_DEBOUNCE = 100;
|
const RENDER_DEBOUNCE = 100;
|
||||||
const COMPOSER_SUGGESTION_DEBOUNCE = 120;
|
const COMPOSER_SUGGESTION_DEBOUNCE = 120;
|
||||||
@@ -196,8 +196,11 @@
|
|||||||
let pendingSessionSwitchRequest = null;
|
let pendingSessionSwitchRequest = null;
|
||||||
let skipDeleteConfirm = localStorage.getItem('cc-web-skip-delete-confirm') === '1';
|
let skipDeleteConfirm = localStorage.getItem('cc-web-skip-delete-confirm') === '1';
|
||||||
let pendingInitialSessionLoad = false;
|
let pendingInitialSessionLoad = false;
|
||||||
|
let initialSessionListHandled = false;
|
||||||
let noteMode = false;
|
let noteMode = false;
|
||||||
let noteDraftSeq = 0;
|
let noteDraftSeq = 0;
|
||||||
|
let queuedMessageSeq = 0;
|
||||||
|
let queuedMessageDrainTimer = null;
|
||||||
let isReloadingMcp = false;
|
let isReloadingMcp = false;
|
||||||
let sessionSearchQuery = '';
|
let sessionSearchQuery = '';
|
||||||
const collapsedProjectKeys = (() => {
|
const collapsedProjectKeys = (() => {
|
||||||
@@ -217,6 +220,7 @@
|
|||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
const pendingNotesByTarget = new Map();
|
const pendingNotesByTarget = new Map();
|
||||||
|
const queuedMessagesByTarget = new Map();
|
||||||
const userMessageIndex = new Map();
|
const userMessageIndex = new Map();
|
||||||
const expandedOldSessionGroups = new Set();
|
const expandedOldSessionGroups = new Set();
|
||||||
document.documentElement.dataset.dividerTime = showAgentDividerTime ? 'show' : 'hide';
|
document.documentElement.dataset.dividerTime = showAgentDividerTime ? 'show' : 'hide';
|
||||||
@@ -261,6 +265,7 @@
|
|||||||
const msgInput = $('#msg-input');
|
const msgInput = $('#msg-input');
|
||||||
const inputWrapper = msgInput.closest('.input-wrapper');
|
const inputWrapper = msgInput.closest('.input-wrapper');
|
||||||
const noteModeBtn = $('#note-mode-btn');
|
const noteModeBtn = $('#note-mode-btn');
|
||||||
|
const queueSendBtn = $('#queue-send-btn');
|
||||||
const sendBtn = $('#send-btn');
|
const sendBtn = $('#send-btn');
|
||||||
const abortBtn = $('#abort-btn');
|
const abortBtn = $('#abort-btn');
|
||||||
const cmdMenu = $('#cmd-menu');
|
const cmdMenu = $('#cmd-menu');
|
||||||
@@ -301,6 +306,22 @@
|
|||||||
return currentSessionId || getDraftNoteKey(agent);
|
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) {
|
function getNotesForKey(key, create = true) {
|
||||||
if (!key) return [];
|
if (!key) return [];
|
||||||
if (!pendingNotesByTarget.has(key)) {
|
if (!pendingNotesByTarget.has(key)) {
|
||||||
@@ -319,6 +340,24 @@
|
|||||||
if (!notes || notes.length === 0) pendingNotesByTarget.delete(key);
|
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) {
|
function migratePendingNotesToSession(sessionId, agent = currentAgent) {
|
||||||
if (!sessionId) return;
|
if (!sessionId) return;
|
||||||
const draftKey = getDraftNoteKey(agent);
|
const draftKey = getDraftNoteKey(agent);
|
||||||
@@ -329,6 +368,16 @@
|
|||||||
pendingNotesByTarget.delete(draftKey);
|
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() {
|
function updateGenerationControls() {
|
||||||
const noteActive = !!noteMode;
|
const noteActive = !!noteMode;
|
||||||
const allowRuntimeInsert = isGenerating && isCodexAppAgent(currentAgent) && !noteActive;
|
const allowRuntimeInsert = isGenerating && isCodexAppAgent(currentAgent) && !noteActive;
|
||||||
@@ -339,6 +388,14 @@
|
|||||||
sendBtn.setAttribute('aria-label', sendLabel);
|
sendBtn.setAttribute('aria-label', sendLabel);
|
||||||
sendBtn.hidden = isGenerating ? !(noteActive || allowRuntimeInsert) : false;
|
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) {
|
if (abortBtn) {
|
||||||
abortBtn.hidden = !isGenerating;
|
abortBtn.hidden = !isGenerating;
|
||||||
}
|
}
|
||||||
@@ -398,18 +455,69 @@
|
|||||||
editBtn.addEventListener('click', () => beginEditPendingNote(note.id));
|
editBtn.addEventListener('click', () => beginEditPendingNote(note.id));
|
||||||
deleteBtn.addEventListener('click', () => removePendingNote(note.id));
|
deleteBtn.addEventListener('click', () => removePendingNote(note.id));
|
||||||
sendNoteBtn.addEventListener('click', () => sendPendingNote(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);
|
bubble.append(meta, text, actions);
|
||||||
div.append(avatar, bubble);
|
div.append(avatar, bubble);
|
||||||
return div;
|
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 = {}) {
|
function renderPendingNotes(options = {}) {
|
||||||
if (!pendingNotesTray) return;
|
if (!pendingNotesTray) return;
|
||||||
pendingNotesTray.innerHTML = '';
|
pendingNotesTray.innerHTML = '';
|
||||||
const notes = getCurrentNotes(false);
|
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;
|
pendingNotesTray.hidden = true;
|
||||||
if (options.updateScrollbar !== false) updateScrollbar();
|
if (options.updateScrollbar !== false) updateScrollbar();
|
||||||
return;
|
return;
|
||||||
@@ -417,6 +525,9 @@
|
|||||||
|
|
||||||
const frag = document.createDocumentFragment();
|
const frag = document.createDocumentFragment();
|
||||||
notes.forEach((note) => frag.appendChild(createPendingNoteElement(note)));
|
notes.forEach((note) => frag.appendChild(createPendingNoteElement(note)));
|
||||||
|
queuedMessages.forEach((message, index) => {
|
||||||
|
frag.appendChild(createQueuedMessageElement(message, index, queuedMessages.length));
|
||||||
|
});
|
||||||
pendingNotesTray.appendChild(frag);
|
pendingNotesTray.appendChild(frag);
|
||||||
pendingNotesTray.hidden = false;
|
pendingNotesTray.hidden = false;
|
||||||
if (options.scrollIntoView !== false && options.scroll !== false) {
|
if (options.scrollIntoView !== false && options.scroll !== false) {
|
||||||
@@ -454,6 +565,91 @@
|
|||||||
return true;
|
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) {
|
function removePendingNote(noteId) {
|
||||||
if (!dropPendingNote(noteId)) return;
|
if (!dropPendingNote(noteId)) return;
|
||||||
renderPendingNotes({ scroll: false });
|
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) {
|
function sendPendingNote(noteId) {
|
||||||
if (isGenerating || isBlockingSessionLoad()) {
|
if (isGenerating || isBlockingSessionLoad()) {
|
||||||
appendError('当前回复还在生成,稍后再发送笔记。');
|
appendError('当前回复还在生成,稍后再发送笔记。');
|
||||||
@@ -535,6 +790,30 @@
|
|||||||
submitUserMessage(text);
|
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) {
|
function normalizeTheme(theme) {
|
||||||
return THEME_OPTIONS.some((item) => item.value === theme) ? theme : 'washi';
|
return THEME_OPTIONS.some((item) => item.value === theme) ? theme : 'washi';
|
||||||
}
|
}
|
||||||
@@ -2865,6 +3144,7 @@
|
|||||||
localStorage.removeItem(getAgentSessionStorageKey(currentAgent));
|
localStorage.removeItem(getAgentSessionStorageKey(currentAgent));
|
||||||
}
|
}
|
||||||
pendingNotesByTarget.delete(session.id);
|
pendingNotesByTarget.delete(session.id);
|
||||||
|
queuedMessagesByTarget.delete(getSessionQueueKey(session.id));
|
||||||
invalidateSessionCache(session.id);
|
invalidateSessionCache(session.id);
|
||||||
send({ type: 'delete_session', sessionId: session.id });
|
send({ type: 'delete_session', sessionId: session.id });
|
||||||
if (session.id === currentSessionId) {
|
if (session.id === currentSessionId) {
|
||||||
@@ -2945,6 +3225,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
updateCwdBadge();
|
updateCwdBadge();
|
||||||
|
if (!running) scheduleQueuedMessageDrain();
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateAgentScopedUI() {
|
function updateAgentScopedUI() {
|
||||||
@@ -3047,6 +3328,7 @@
|
|||||||
updateSessionIdBadge();
|
updateSessionIdBadge();
|
||||||
setCurrentAgent(snapshotAgent);
|
setCurrentAgent(snapshotAgent);
|
||||||
migratePendingNotesToSession(snapshot.sessionId, snapshotAgent);
|
migratePendingNotesToSession(snapshot.sessionId, snapshotAgent);
|
||||||
|
migrateQueuedMessagesToSession(snapshot.sessionId, snapshotAgent);
|
||||||
setCurrentSessionRunningState(snapshot.isRunning);
|
setCurrentSessionRunningState(snapshot.isRunning);
|
||||||
setStatsDisplay(snapshot);
|
setStatsDisplay(snapshot);
|
||||||
closeUserOutlinePanel();
|
closeUserOutlinePanel();
|
||||||
@@ -3285,22 +3567,69 @@
|
|||||||
|
|
||||||
// --- marked config ---
|
// --- marked config ---
|
||||||
const PREVIEW_LANGS = new Set(['html', 'svg']);
|
const PREVIEW_LANGS = new Set(['html', 'svg']);
|
||||||
|
const MERMAID_LANGS = new Set(['mermaid', 'mmd']);
|
||||||
const _previewCodeMap = new Map();
|
const _previewCodeMap = new Map();
|
||||||
let _previewCodeId = 0;
|
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();
|
const renderer = new marked.Renderer();
|
||||||
renderer.code = function (code, language) {
|
renderer.code = function (code, language) {
|
||||||
const lang = (language || 'plaintext').toLowerCase();
|
const source = String(code || '');
|
||||||
let highlighted;
|
const lang = normalizeCodeLanguage(language);
|
||||||
try {
|
if (MERMAID_LANGS.has(lang)) return renderMermaidCodeBlock(source, lang);
|
||||||
if (hljs.getLanguage(lang)) {
|
|
||||||
highlighted = hljs.highlight(code, { language: lang }).value;
|
const highlighted = highlightCodeBlock(source, lang);
|
||||||
} else {
|
|
||||||
highlighted = hljs.highlightAuto(code).value;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
highlighted = escapeHtml(code);
|
|
||||||
}
|
|
||||||
const canPreview = PREVIEW_LANGS.has(lang);
|
const canPreview = PREVIEW_LANGS.has(lang);
|
||||||
const previewBtn = canPreview
|
const previewBtn = canPreview
|
||||||
? `<button class="code-preview-btn" type="button" onclick="ccTogglePreview(this, event)" aria-label="预览代码块">Preview</button>`
|
? `<button class="code-preview-btn" type="button" onclick="ccTogglePreview(this, event)" aria-label="预览代码块">Preview</button>`
|
||||||
@@ -3308,8 +3637,7 @@
|
|||||||
const previewPane = canPreview
|
const previewPane = canPreview
|
||||||
? `<div class="code-preview-pane"><iframe class="code-preview-iframe" sandbox="allow-scripts" loading="lazy"></iframe></div>`
|
? `<div class="code-preview-pane"><iframe class="code-preview-iframe" sandbox="allow-scripts" loading="lazy"></iframe></div>`
|
||||||
: '';
|
: '';
|
||||||
const cid = canPreview ? (++_previewCodeId) : 0;
|
const cid = canPreview ? createStoredCodeId(source) : 0;
|
||||||
if (canPreview) _previewCodeMap.set(cid, code);
|
|
||||||
return `<div class="code-block-wrapper${canPreview ? ' has-preview' : ''}"${canPreview ? ` data-cid="${cid}"` : ''}>
|
return `<div class="code-block-wrapper${canPreview ? ' has-preview' : ''}"${canPreview ? ` data-cid="${cid}"` : ''}>
|
||||||
<div class="code-block-header">
|
<div class="code-block-header">
|
||||||
<span>${escapeHtml(lang)}</span>
|
<span>${escapeHtml(lang)}</span>
|
||||||
@@ -3325,8 +3653,7 @@
|
|||||||
event?.stopPropagation();
|
event?.stopPropagation();
|
||||||
const wrapper = btn?.closest?.('.code-block-wrapper');
|
const wrapper = btn?.closest?.('.code-block-wrapper');
|
||||||
if (!wrapper) return;
|
if (!wrapper) return;
|
||||||
const cid = wrapper.dataset.cid ? Number(wrapper.dataset.cid) : 0;
|
const code = getCodeBlockSource(wrapper);
|
||||||
const code = (cid && _previewCodeMap.has(cid)) ? _previewCodeMap.get(cid) : wrapper.querySelector('code')?.textContent;
|
|
||||||
const defaultLabel = btn.dataset.defaultLabel || btn.textContent || 'Copy';
|
const defaultLabel = btn.dataset.defaultLabel || btn.textContent || 'Copy';
|
||||||
btn.dataset.defaultLabel = defaultLabel;
|
btn.dataset.defaultLabel = defaultLabel;
|
||||||
btn.disabled = true;
|
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 ---
|
// --- WebSocket ---
|
||||||
function isBrowserOnline() {
|
function isBrowserOnline() {
|
||||||
return !('onLine' in navigator) || navigator.onLine;
|
return !('onLine' in navigator) || navigator.onLine;
|
||||||
@@ -3449,6 +4008,7 @@
|
|||||||
switch (msg.type) {
|
switch (msg.type) {
|
||||||
case 'auth_result':
|
case 'auth_result':
|
||||||
if (msg.success) {
|
if (msg.success) {
|
||||||
|
const shouldLoadInitialSession = !initialSessionListHandled && !currentSessionId;
|
||||||
authToken = msg.token;
|
authToken = msg.token;
|
||||||
wsAuthenticated = true;
|
wsAuthenticated = true;
|
||||||
localStorage.setItem('cc-web-token', msg.token);
|
localStorage.setItem('cc-web-token', msg.token);
|
||||||
@@ -3461,7 +4021,7 @@
|
|||||||
if (msg.mustChangePassword) {
|
if (msg.mustChangePassword) {
|
||||||
showForceChangePassword();
|
showForceChangePassword();
|
||||||
} else {
|
} else {
|
||||||
pendingInitialSessionLoad = true;
|
pendingInitialSessionLoad = shouldLoadInitialSession;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
pendingSessionSwitchRequest = null;
|
pendingSessionSwitchRequest = null;
|
||||||
@@ -3493,9 +4053,13 @@
|
|||||||
}
|
}
|
||||||
if (pendingInitialSessionLoad) {
|
if (pendingInitialSessionLoad) {
|
||||||
pendingInitialSessionLoad = false;
|
pendingInitialSessionLoad = false;
|
||||||
|
initialSessionListHandled = true;
|
||||||
syncViewForAgent(currentAgent, { preserveCurrent: false, loadLast: true });
|
syncViewForAgent(currentAgent, { preserveCurrent: false, loadLast: true });
|
||||||
} else if (currentSessionId && !getSessionMeta(currentSessionId)) {
|
} else if (currentSessionId && !getSessionMeta(currentSessionId)) {
|
||||||
|
initialSessionListHandled = true;
|
||||||
resetChatView(currentAgent);
|
resetChatView(currentAgent);
|
||||||
|
} else {
|
||||||
|
initialSessionListHandled = true;
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
@@ -3907,6 +4471,7 @@
|
|||||||
toolGroupCount = 0;
|
toolGroupCount = 0;
|
||||||
hasGrouped = false;
|
hasGrouped = false;
|
||||||
updateNoteModeUI();
|
updateNoteModeUI();
|
||||||
|
renderPendingNotes({ scroll: false });
|
||||||
// 不禁用输入框,允许用户继续输入(但无法发送)
|
// 不禁用输入框,允许用户继续输入(但无法发送)
|
||||||
|
|
||||||
const welcome = messagesDiv.querySelector('.welcome-msg');
|
const welcome = messagesDiv.querySelector('.welcome-msg');
|
||||||
@@ -3980,6 +4545,7 @@
|
|||||||
|
|
||||||
if (sessionId) {
|
if (sessionId) {
|
||||||
migratePendingNotesToSession(sessionId, currentAgent);
|
migratePendingNotesToSession(sessionId, currentAgent);
|
||||||
|
migrateQueuedMessagesToSession(sessionId, currentAgent);
|
||||||
}
|
}
|
||||||
pendingText = '';
|
pendingText = '';
|
||||||
activeToolCalls.clear();
|
activeToolCalls.clear();
|
||||||
@@ -3987,6 +4553,7 @@
|
|||||||
toolGroupCount = 0;
|
toolGroupCount = 0;
|
||||||
hasGrouped = false;
|
hasGrouped = false;
|
||||||
renderPendingNotes();
|
renderPendingNotes();
|
||||||
|
scheduleQueuedMessageDrain();
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Rendering ---
|
// --- Rendering ---
|
||||||
@@ -4010,7 +4577,7 @@
|
|||||||
} else if (pendingText) {
|
} else if (pendingText) {
|
||||||
let textDiv = bubble.querySelector('.msg-text');
|
let textDiv = bubble.querySelector('.msg-text');
|
||||||
if (!textDiv) { textDiv = bubble; }
|
if (!textDiv) { textDiv = bubble; }
|
||||||
textDiv.innerHTML = renderMarkdown(pendingText);
|
setRenderedMarkdown(textDiv, pendingText);
|
||||||
}
|
}
|
||||||
syncAssistantLastSectionButton(streamEl);
|
syncAssistantLastSectionButton(streamEl);
|
||||||
scrollToBottom();
|
scrollToBottom();
|
||||||
@@ -4395,14 +4962,16 @@
|
|||||||
const after = content.substring(jsonMatch.index + jsonMatch[0].length);
|
const after = content.substring(jsonMatch.index + jsonMatch[0].length);
|
||||||
if (before.trim()) {
|
if (before.trim()) {
|
||||||
const textDiv = document.createElement('div');
|
const textDiv = document.createElement('div');
|
||||||
textDiv.innerHTML = renderMarkdown(before);
|
setRenderedMarkdown(textDiv, before, false);
|
||||||
bubble.appendChild(textDiv);
|
bubble.appendChild(textDiv);
|
||||||
|
hydrateRenderedMarkdown(textDiv);
|
||||||
}
|
}
|
||||||
bubble.appendChild(createTodoListElement(parsed));
|
bubble.appendChild(createTodoListElement(parsed));
|
||||||
if (after.trim()) {
|
if (after.trim()) {
|
||||||
const textDiv = document.createElement('div');
|
const textDiv = document.createElement('div');
|
||||||
textDiv.innerHTML = renderMarkdown(after);
|
setRenderedMarkdown(textDiv, after, false);
|
||||||
bubble.appendChild(textDiv);
|
bubble.appendChild(textDiv);
|
||||||
|
hydrateRenderedMarkdown(textDiv);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -4420,7 +4989,7 @@
|
|||||||
}
|
}
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
}
|
}
|
||||||
bubble.innerHTML = renderMarkdown(content);
|
setRenderedMarkdown(bubble, content);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4428,8 +4997,9 @@
|
|||||||
content.forEach(block => {
|
content.forEach(block => {
|
||||||
if (block.type === 'text') {
|
if (block.type === 'text') {
|
||||||
const textDiv = document.createElement('div');
|
const textDiv = document.createElement('div');
|
||||||
textDiv.innerHTML = renderMarkdown(block.text || '');
|
setRenderedMarkdown(textDiv, block.text || '', false);
|
||||||
bubble.appendChild(textDiv);
|
bubble.appendChild(textDiv);
|
||||||
|
hydrateRenderedMarkdown(textDiv);
|
||||||
} else if (block.type === 'todo_list') {
|
} else if (block.type === 'todo_list') {
|
||||||
bubble.appendChild(createTodoListElement(block));
|
bubble.appendChild(createTodoListElement(block));
|
||||||
}
|
}
|
||||||
@@ -4437,7 +5007,7 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
bubble.innerHTML = renderMarkdown(String(content));
|
setRenderedMarkdown(bubble, String(content));
|
||||||
}
|
}
|
||||||
|
|
||||||
function createTodoListElement(block) {
|
function createTodoListElement(block) {
|
||||||
@@ -6750,6 +7320,9 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
sendBtn.addEventListener('click', sendMessage);
|
sendBtn.addEventListener('click', sendMessage);
|
||||||
|
if (queueSendBtn) {
|
||||||
|
queueSendBtn.addEventListener('click', queueMessageFromInput);
|
||||||
|
}
|
||||||
if (noteModeBtn) {
|
if (noteModeBtn) {
|
||||||
noteModeBtn.addEventListener('click', () => {
|
noteModeBtn.addEventListener('click', () => {
|
||||||
noteMode = !noteMode;
|
noteMode = !noteMode;
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
document.documentElement.dataset.dividerTime = dividerTime;
|
document.documentElement.dataset.dividerTime = dividerTime;
|
||||||
})();
|
})();
|
||||||
</script>
|
</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">
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/atom-one-dark.min.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -126,6 +126,14 @@
|
|||||||
<path d="m15 5 3 3"></path>
|
<path d="m15 5 3 3"></path>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</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="发送">
|
<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">
|
<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>
|
<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/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="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>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
132
public/style.css
132
public/style.css
@@ -1044,6 +1044,7 @@ body.session-loading-active {
|
|||||||
.session-search {
|
.session-search {
|
||||||
position: relative;
|
position: relative;
|
||||||
margin-top: 10px;
|
margin-top: 10px;
|
||||||
|
width: 100%;
|
||||||
}
|
}
|
||||||
.session-search::before {
|
.session-search::before {
|
||||||
content: '⌕';
|
content: '⌕';
|
||||||
@@ -1058,6 +1059,9 @@ body.session-loading-active {
|
|||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
.session-search-input {
|
.session-search-input {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
box-sizing: border-box;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 34px;
|
height: 34px;
|
||||||
padding: 0 34px 0 30px;
|
padding: 0 34px 0 30px;
|
||||||
@@ -1073,6 +1077,13 @@ body.session-loading-active {
|
|||||||
.session-search-input::placeholder {
|
.session-search-input::placeholder {
|
||||||
color: var(--text-muted);
|
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 {
|
.session-search-input:focus {
|
||||||
background: var(--bg-primary);
|
background: var(--bg-primary);
|
||||||
border-color: rgba(192, 85, 58, 0.34);
|
border-color: rgba(192, 85, 58, 0.34);
|
||||||
@@ -1083,6 +1094,7 @@ body.session-loading-active {
|
|||||||
right: 6px;
|
right: 6px;
|
||||||
top: 50%;
|
top: 50%;
|
||||||
z-index: 2;
|
z-index: 2;
|
||||||
|
transform: translateY(-50%);
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -2454,9 +2466,24 @@ body.session-loading-active {
|
|||||||
border-color: var(--note-accent);
|
border-color: var(--note-accent);
|
||||||
color: var(--note-accent);
|
color: var(--note-accent);
|
||||||
}
|
}
|
||||||
|
.note-action:disabled {
|
||||||
|
opacity: 0.42;
|
||||||
|
cursor: default;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
.note-action:active {
|
.note-action:active {
|
||||||
transform: scale(0.96);
|
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.send,
|
||||||
.note-action.save {
|
.note-action.save {
|
||||||
background: var(--note-accent);
|
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 .code-preview-pane { display: block; }
|
||||||
.code-block-wrapper.preview-mode pre { display: none; }
|
.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) {
|
@media (pointer: coarse) {
|
||||||
.code-block-header {
|
.code-block-header {
|
||||||
padding: 8px 10px;
|
padding: 8px 10px;
|
||||||
@@ -3305,6 +3389,18 @@ html[data-divider-time='hide'] .msg-bubble .agent-message-divider span {
|
|||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
box-shadow: 0 8px 18px rgba(45, 31, 20, 0.05);
|
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 {
|
.input-wrapper {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -3381,8 +3477,32 @@ html[data-divider-time='hide'] .msg-bubble .agent-message-divider span {
|
|||||||
background: var(--note-accent-hover);
|
background: var(--note-accent-hover);
|
||||||
color: #fff;
|
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,
|
.attach-btn:disabled,
|
||||||
.note-mode-btn:disabled {
|
.note-mode-btn:disabled,
|
||||||
|
.queue-send-btn:disabled {
|
||||||
opacity: 0.45;
|
opacity: 0.45;
|
||||||
cursor: default;
|
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; }
|
.code-block-wrapper pre code { font-size: 12px; }
|
||||||
.input-wrapper { padding: 6px 10px; border-radius: 12px; }
|
.input-wrapper { padding: 6px 10px; border-radius: 12px; }
|
||||||
.attach-btn { width: 34px; height: 34px; border-radius: 10px; }
|
.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; }
|
.send-btn, .abort-btn { width: 34px; height: 34px; }
|
||||||
.pending-notes-tray { max-height: 34vh; margin-bottom: 8px; }
|
.pending-notes-tray { max-height: 34vh; margin-bottom: 8px; }
|
||||||
.pending-note { grid-template-columns: 26px minmax(0, 1fr); gap: 7px; }
|
.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']) .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 {
|
:is(html[data-theme='carbon'], html[data-theme='nocturne'], html[data-theme='cinder']) .send-btn.note-send {
|
||||||
color: var(--bg-primary);
|
color: var(--bg-primary);
|
||||||
}
|
}
|
||||||
@@ -5456,6 +5578,12 @@ html[data-theme='coolvibe'] .settings-back:hover {
|
|||||||
box-shadow: none;
|
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']) .settings-inline-note code,
|
||||||
:is(html[data-theme='carbon'], html[data-theme='nocturne'], html[data-theme='cinder']) .tool-call-code {
|
:is(html[data-theme='carbon'], html[data-theme='nocturne'], html[data-theme='cinder']) .tool-call-code {
|
||||||
background: rgba(0, 0, 0, 0.22);
|
background: rgba(0, 0, 0, 0.22);
|
||||||
|
|||||||
27
server.js
27
server.js
@@ -4942,6 +4942,7 @@ const server = http.createServer((req, res) => {
|
|||||||
|
|
||||||
// === WebSocket Server ===
|
// === WebSocket Server ===
|
||||||
const wss = new WebSocketServer({ noServer: true });
|
const wss = new WebSocketServer({ noServer: true });
|
||||||
|
const WS_HEARTBEAT_INTERVAL_MS = 30000;
|
||||||
|
|
||||||
server.on('upgrade', (req, socket, head) => {
|
server.on('upgrade', (req, socket, head) => {
|
||||||
let pathname = '';
|
let pathname = '';
|
||||||
@@ -4978,8 +4979,14 @@ wss.on('connection', (ws, req) => {
|
|||||||
let authToken = null;
|
let authToken = null;
|
||||||
const wsId = crypto.randomBytes(4).toString('hex'); // short id for log correlation
|
const wsId = crypto.randomBytes(4).toString('hex'); // short id for log correlation
|
||||||
const wsConnectTime = new Date().toISOString();
|
const wsConnectTime = new Date().toISOString();
|
||||||
|
ws.isAlive = true;
|
||||||
|
ws._ccWebId = wsId;
|
||||||
plog('INFO', 'ws_connect', { wsId });
|
plog('INFO', 'ws_connect', { wsId });
|
||||||
|
|
||||||
|
ws.on('pong', () => {
|
||||||
|
ws.isAlive = true;
|
||||||
|
});
|
||||||
|
|
||||||
ws.on('message', (raw) => {
|
ws.on('message', (raw) => {
|
||||||
let msg;
|
let msg;
|
||||||
try {
|
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 ===
|
// === Notify Config Handlers ===
|
||||||
function handleSaveNotifyConfig(ws, newConfig) {
|
function handleSaveNotifyConfig(ws, newConfig) {
|
||||||
if (!newConfig || !newConfig.provider) {
|
if (!newConfig || !newConfig.provider) {
|
||||||
|
|||||||
Reference in New Issue
Block a user