feat: add note mode and workflow config
This commit is contained in:
388
public/app.js
388
public/app.js
@@ -83,6 +83,7 @@
|
||||
let reconnectTimer = null;
|
||||
let pendingText = '';
|
||||
let renderTimer = null;
|
||||
let generatingSessionId = null;
|
||||
let activeToolCalls = new Map();
|
||||
let activeTodoCallTargets = new Map();
|
||||
let toolDomSeq = 0;
|
||||
@@ -109,6 +110,9 @@
|
||||
let pendingNewSessionRequest = null;
|
||||
let skipDeleteConfirm = localStorage.getItem('cc-web-skip-delete-confirm') === '1';
|
||||
let pendingInitialSessionLoad = false;
|
||||
let noteMode = false;
|
||||
let noteDraftSeq = 0;
|
||||
const pendingNotesByTarget = new Map();
|
||||
|
||||
// --- DOM ---
|
||||
const $ = (sel) => document.querySelector(sel);
|
||||
@@ -142,10 +146,12 @@
|
||||
const messagesDiv = $('#messages');
|
||||
const msgInput = $('#msg-input');
|
||||
const inputWrapper = msgInput.closest('.input-wrapper');
|
||||
const noteModeBtn = $('#note-mode-btn');
|
||||
const sendBtn = $('#send-btn');
|
||||
const abortBtn = $('#abort-btn');
|
||||
const cmdMenu = $('#cmd-menu');
|
||||
const modeSelect = $('#mode-select');
|
||||
const defaultMsgInputPlaceholder = msgInput.getAttribute('placeholder') || '输入消息… 输入 / 查看指令';
|
||||
|
||||
// --- Viewport height fix for mobile browsers ---
|
||||
function setVH() {
|
||||
@@ -164,6 +170,238 @@
|
||||
return AGENT_LABELS[agent] ? agent : DEFAULT_AGENT;
|
||||
}
|
||||
|
||||
function getDraftNoteKey(agent = currentAgent) {
|
||||
return `draft:${normalizeAgent(agent)}`;
|
||||
}
|
||||
|
||||
function getCurrentNoteKey(agent = currentAgent) {
|
||||
return currentSessionId || getDraftNoteKey(agent);
|
||||
}
|
||||
|
||||
function getNotesForKey(key, create = true) {
|
||||
if (!key) return [];
|
||||
if (!pendingNotesByTarget.has(key)) {
|
||||
if (!create) return [];
|
||||
pendingNotesByTarget.set(key, []);
|
||||
}
|
||||
return pendingNotesByTarget.get(key);
|
||||
}
|
||||
|
||||
function getCurrentNotes(create = true) {
|
||||
return getNotesForKey(getCurrentNoteKey(), create);
|
||||
}
|
||||
|
||||
function cleanupNoteKey(key) {
|
||||
const notes = pendingNotesByTarget.get(key);
|
||||
if (!notes || notes.length === 0) pendingNotesByTarget.delete(key);
|
||||
}
|
||||
|
||||
function migratePendingNotesToSession(sessionId, agent = currentAgent) {
|
||||
if (!sessionId) return;
|
||||
const draftKey = getDraftNoteKey(agent);
|
||||
const draftNotes = pendingNotesByTarget.get(draftKey);
|
||||
if (!draftNotes || draftNotes.length === 0) return;
|
||||
const sessionNotes = getNotesForKey(sessionId, true);
|
||||
sessionNotes.push(...draftNotes);
|
||||
pendingNotesByTarget.delete(draftKey);
|
||||
}
|
||||
|
||||
function updateNoteModeUI() {
|
||||
const active = !!noteMode;
|
||||
if (noteModeBtn) {
|
||||
noteModeBtn.classList.toggle('active', active);
|
||||
noteModeBtn.setAttribute('aria-pressed', active ? 'true' : 'false');
|
||||
noteModeBtn.title = active ? '关闭笔记模式' : '笔记模式';
|
||||
noteModeBtn.setAttribute('aria-label', active ? '关闭笔记模式' : '笔记模式');
|
||||
}
|
||||
if (inputWrapper) inputWrapper.classList.toggle('note-mode-active', active);
|
||||
if (sendBtn) {
|
||||
sendBtn.classList.toggle('note-send', active);
|
||||
sendBtn.title = active ? '记录笔记' : '发送';
|
||||
sendBtn.hidden = isGenerating ? !active : false;
|
||||
}
|
||||
if (msgInput) {
|
||||
msgInput.placeholder = active ? '记录笔记,稍后从气泡发送…' : defaultMsgInputPlaceholder;
|
||||
}
|
||||
if (active) hideCmdMenu();
|
||||
}
|
||||
|
||||
function createNoteActionButton(action, label, title = label) {
|
||||
const button = document.createElement('button');
|
||||
button.type = 'button';
|
||||
button.className = `note-action ${action}`;
|
||||
button.textContent = label;
|
||||
button.title = title;
|
||||
button.dataset.noteAction = action;
|
||||
return button;
|
||||
}
|
||||
|
||||
function createPendingNoteElement(note) {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'msg note';
|
||||
div.dataset.noteId = note.id;
|
||||
|
||||
const avatar = document.createElement('div');
|
||||
avatar.className = 'msg-avatar note-avatar';
|
||||
avatar.textContent = 'N';
|
||||
|
||||
const bubble = document.createElement('div');
|
||||
bubble.className = 'msg-bubble note-bubble';
|
||||
|
||||
const meta = document.createElement('div');
|
||||
meta.className = 'note-meta';
|
||||
meta.textContent = '笔记 · 待发送';
|
||||
|
||||
const text = document.createElement('div');
|
||||
text.className = 'note-text';
|
||||
text.textContent = note.text;
|
||||
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'note-actions';
|
||||
const editBtn = createNoteActionButton('edit', '修改');
|
||||
const deleteBtn = createNoteActionButton('delete', '删除');
|
||||
const sendNoteBtn = createNoteActionButton('send', '发送', '发送这条笔记');
|
||||
editBtn.addEventListener('click', () => beginEditPendingNote(note.id));
|
||||
deleteBtn.addEventListener('click', () => removePendingNote(note.id));
|
||||
sendNoteBtn.addEventListener('click', () => sendPendingNote(note.id));
|
||||
actions.append(editBtn, deleteBtn, sendNoteBtn);
|
||||
|
||||
bubble.append(meta, text, actions);
|
||||
div.append(avatar, bubble);
|
||||
return div;
|
||||
}
|
||||
|
||||
function renderPendingNotes(options = {}) {
|
||||
messagesDiv.querySelectorAll('.msg.note').forEach((node) => node.remove());
|
||||
const notes = getCurrentNotes(false);
|
||||
if (!notes || notes.length === 0) {
|
||||
updateScrollbar();
|
||||
return;
|
||||
}
|
||||
|
||||
const welcome = messagesDiv.querySelector('.welcome-msg');
|
||||
if (welcome) welcome.remove();
|
||||
|
||||
const frag = document.createDocumentFragment();
|
||||
notes.forEach((note) => frag.appendChild(createPendingNoteElement(note)));
|
||||
messagesDiv.appendChild(frag);
|
||||
if (options.scroll !== false) {
|
||||
scrollToBottom();
|
||||
} else {
|
||||
updateScrollbar();
|
||||
}
|
||||
}
|
||||
|
||||
function findPendingNote(noteId) {
|
||||
const key = getCurrentNoteKey();
|
||||
const notes = getNotesForKey(key, false);
|
||||
const index = notes.findIndex((note) => note.id === noteId);
|
||||
if (index === -1) return null;
|
||||
return { key, notes, index, note: notes[index] };
|
||||
}
|
||||
|
||||
function dropPendingNote(noteId) {
|
||||
const found = findPendingNote(noteId);
|
||||
if (!found) return null;
|
||||
const [note] = found.notes.splice(found.index, 1);
|
||||
cleanupNoteKey(found.key);
|
||||
return note;
|
||||
}
|
||||
|
||||
function addPendingNoteFromInput(text) {
|
||||
const content = String(text || '').trim();
|
||||
if (!content) return false;
|
||||
const note = {
|
||||
id: `note-${Date.now().toString(36)}-${++noteDraftSeq}`,
|
||||
text: content,
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
getCurrentNotes(true).push(note);
|
||||
renderPendingNotes();
|
||||
return true;
|
||||
}
|
||||
|
||||
function removePendingNote(noteId) {
|
||||
if (!dropPendingNote(noteId)) return;
|
||||
renderPendingNotes({ scroll: false });
|
||||
}
|
||||
|
||||
function resizeNoteEditor(editor) {
|
||||
editor.style.height = 'auto';
|
||||
editor.style.height = Math.min(editor.scrollHeight, 180) + 'px';
|
||||
}
|
||||
|
||||
function beginEditPendingNote(noteId) {
|
||||
const found = findPendingNote(noteId);
|
||||
if (!found) return;
|
||||
const noteEl = messagesDiv.querySelector(`.msg.note[data-note-id="${noteId}"]`);
|
||||
const bubble = noteEl?.querySelector('.note-bubble');
|
||||
if (!bubble) return;
|
||||
|
||||
bubble.classList.add('editing');
|
||||
bubble.innerHTML = '';
|
||||
|
||||
const meta = document.createElement('div');
|
||||
meta.className = 'note-meta';
|
||||
meta.textContent = '修改笔记';
|
||||
|
||||
const editor = document.createElement('textarea');
|
||||
editor.className = 'note-edit-input';
|
||||
editor.value = found.note.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();
|
||||
if (!next) {
|
||||
appendError('笔记内容不能为空。');
|
||||
editor.focus();
|
||||
return;
|
||||
}
|
||||
found.note.text = next;
|
||||
renderPendingNotes();
|
||||
};
|
||||
|
||||
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('当前回复还在生成,稍后再发送笔记。');
|
||||
return;
|
||||
}
|
||||
const note = dropPendingNote(noteId);
|
||||
if (!note) return;
|
||||
const text = String(note.text || '').trim();
|
||||
renderPendingNotes({ scroll: false });
|
||||
if (!text) return;
|
||||
submitUserMessage(text);
|
||||
}
|
||||
|
||||
function normalizeTheme(theme) {
|
||||
return THEME_OPTIONS.some((item) => item.value === theme) ? theme : 'washi';
|
||||
}
|
||||
@@ -1182,12 +1420,40 @@
|
||||
if (cached?.promise) return cached.promise;
|
||||
|
||||
const promise = (async () => {
|
||||
await ensureAuthenticatedWs();
|
||||
if (!authToken) {
|
||||
const fetchAttachment = async () => {
|
||||
await ensureAuthenticatedWs();
|
||||
if (!authToken) {
|
||||
throw new Error('登录状态已失效,请刷新页面后重新登录再预览图片。');
|
||||
}
|
||||
return fetch(`/api/attachments/${encodeURIComponent(id)}`, {
|
||||
cache: 'no-store',
|
||||
headers: {
|
||||
Authorization: `Bearer ${authToken}`,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
let response = await fetchAttachment();
|
||||
if (response.status === 401 && localStorage.getItem('cc-web-pw')) {
|
||||
authToken = null;
|
||||
localStorage.removeItem('cc-web-token');
|
||||
response = await fetchAttachment();
|
||||
}
|
||||
if (response.status === 401) {
|
||||
throw new Error('登录状态已失效,请刷新页面后重新登录再预览图片。');
|
||||
}
|
||||
const url = `/api/attachments/${encodeURIComponent(id)}?token=${encodeURIComponent(authToken)}`;
|
||||
attachmentPreviewCache.set(id, { url, objectUrl: false });
|
||||
if (response.status === 404) {
|
||||
throw new Error('图片不存在或已过期');
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new Error('图片预览失败');
|
||||
}
|
||||
const blob = await response.blob();
|
||||
if (!blob || blob.size === 0) {
|
||||
throw new Error('图片预览失败');
|
||||
}
|
||||
const url = URL.createObjectURL(blob);
|
||||
attachmentPreviewCache.set(id, { url, objectUrl: true });
|
||||
return url;
|
||||
})().catch((err) => {
|
||||
attachmentPreviewCache.delete(id);
|
||||
@@ -1296,18 +1562,20 @@
|
||||
getAttachmentPreviewUrl(attachment)
|
||||
.then((url) => {
|
||||
if (!node.isConnected) return;
|
||||
imgEl.src = url;
|
||||
imgEl.onload = () => {
|
||||
if (!node.isConnected) return;
|
||||
imgEl.hidden = false;
|
||||
placeholderEl.hidden = true;
|
||||
node.classList.remove('is-error');
|
||||
node.classList.add('is-loaded');
|
||||
};
|
||||
imgEl.onerror = () => {
|
||||
if (!node.isConnected) return;
|
||||
placeholderEl.textContent = '图片加载失败';
|
||||
node.classList.remove('is-loaded');
|
||||
node.classList.add('is-error');
|
||||
};
|
||||
imgEl.src = url;
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!node.isConnected) return;
|
||||
@@ -1542,6 +1810,7 @@
|
||||
if (getLastSessionForAgent(currentAgent) === session.id) {
|
||||
localStorage.removeItem(getAgentSessionStorageKey(currentAgent));
|
||||
}
|
||||
pendingNotesByTarget.delete(session.id);
|
||||
invalidateSessionCache(session.id);
|
||||
send({ type: 'delete_session', sessionId: session.id });
|
||||
if (session.id === currentSessionId) {
|
||||
@@ -1641,7 +1910,9 @@
|
||||
currentCwd = null;
|
||||
currentModel = currentAgent === 'claude' ? 'opus' : '';
|
||||
isGenerating = false;
|
||||
generatingSessionId = null;
|
||||
pendingText = '';
|
||||
window.pendingContentBlocks = [];
|
||||
pendingAttachments = [];
|
||||
uploadingAttachments = [];
|
||||
activeToolCalls.clear();
|
||||
@@ -1653,20 +1924,24 @@
|
||||
messagesDiv.innerHTML = buildWelcomeMarkup(currentAgent);
|
||||
setStatsDisplay(null);
|
||||
renderPendingAttachments();
|
||||
renderPendingNotes({ scroll: false });
|
||||
highlightActiveSession();
|
||||
}
|
||||
|
||||
function applySessionSnapshot(snapshot, options = {}) {
|
||||
if (!snapshot) return;
|
||||
const snapshotAgent = normalizeAgent(snapshot.agent);
|
||||
if (fileBrowserState && (fileBrowserState.sessionId !== snapshot.sessionId || (snapshot.cwd && fileBrowserState.rootPath !== snapshot.cwd))) {
|
||||
closeFileBrowser();
|
||||
}
|
||||
const preserveStreaming = !!(options.preserveStreaming && isGenerating && snapshot.sessionId === currentSessionId && snapshot.isRunning);
|
||||
if (isGenerating && !preserveStreaming) {
|
||||
isGenerating = false;
|
||||
generatingSessionId = null;
|
||||
sendBtn.hidden = false;
|
||||
abortBtn.hidden = true;
|
||||
pendingText = '';
|
||||
window.pendingContentBlocks = [];
|
||||
activeToolCalls.clear();
|
||||
activeTodoCallTargets.clear();
|
||||
}
|
||||
@@ -1674,7 +1949,8 @@
|
||||
loadedHistorySessionId = snapshot.sessionId;
|
||||
setLastSessionForAgent(snapshot.agent, currentSessionId);
|
||||
chatTitle.textContent = snapshot.title || '新会话';
|
||||
setCurrentAgent(snapshot.agent);
|
||||
setCurrentAgent(snapshotAgent);
|
||||
migratePendingNotesToSession(snapshot.sessionId, snapshotAgent);
|
||||
setCurrentSessionRunningState(snapshot.isRunning);
|
||||
setStatsDisplay(snapshot);
|
||||
currentCwd = snapshot.cwd || null;
|
||||
@@ -1687,6 +1963,8 @@
|
||||
currentModel = snapshot.model || '';
|
||||
if (!preserveStreaming) {
|
||||
renderMessages(snapshot.messages || [], { immediate: !!options.immediate });
|
||||
} else {
|
||||
generatingSessionId = snapshot.sessionId;
|
||||
}
|
||||
highlightActiveSession();
|
||||
renderSessionList();
|
||||
@@ -1980,6 +2258,19 @@
|
||||
}
|
||||
|
||||
// --- Server Message Handler ---
|
||||
function isCurrentSessionEvent(msg) {
|
||||
return !msg.sessionId || msg.sessionId === currentSessionId;
|
||||
}
|
||||
|
||||
function ensureGeneratingForEvent(msg) {
|
||||
if (!isCurrentSessionEvent(msg)) return false;
|
||||
const targetSessionId = msg.sessionId || currentSessionId || null;
|
||||
if (!isGenerating || generatingSessionId !== targetSessionId || !document.getElementById('streaming-msg')) {
|
||||
return startGenerating(targetSessionId);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function handleServerMessage(msg) {
|
||||
switch (msg.type) {
|
||||
case 'auth_result':
|
||||
@@ -2086,13 +2377,13 @@
|
||||
break;
|
||||
|
||||
case 'text_delta':
|
||||
if (!isGenerating) startGenerating();
|
||||
if (!ensureGeneratingForEvent(msg)) break;
|
||||
pendingText += msg.text;
|
||||
scheduleRender();
|
||||
break;
|
||||
|
||||
case 'content_blocks':
|
||||
if (!isGenerating) startGenerating();
|
||||
if (!ensureGeneratingForEvent(msg)) break;
|
||||
if (Array.isArray(msg.blocks)) {
|
||||
if (!window.pendingContentBlocks) window.pendingContentBlocks = [];
|
||||
window.pendingContentBlocks.push(...msg.blocks);
|
||||
@@ -2101,13 +2392,13 @@
|
||||
break;
|
||||
|
||||
case 'tool_start':
|
||||
if (!isGenerating) startGenerating();
|
||||
if (!ensureGeneratingForEvent(msg)) break;
|
||||
activeToolCalls.set(msg.toolUseId, { name: msg.name, input: msg.input, kind: msg.kind || null, meta: msg.meta || null, done: false });
|
||||
appendToolCall(msg.toolUseId, msg.name, msg.input, false, msg.kind || null, msg.meta || null);
|
||||
break;
|
||||
|
||||
case 'tool_update':
|
||||
if (!isGenerating) startGenerating();
|
||||
if (!ensureGeneratingForEvent(msg)) break;
|
||||
if (!activeToolCalls.has(msg.toolUseId)) {
|
||||
activeToolCalls.set(msg.toolUseId, {
|
||||
name: msg.name,
|
||||
@@ -2128,6 +2419,7 @@
|
||||
break;
|
||||
|
||||
case 'tool_end':
|
||||
if (!isCurrentSessionEvent(msg)) break;
|
||||
if (activeToolCalls.has(msg.toolUseId)) {
|
||||
activeToolCalls.get(msg.toolUseId).done = true;
|
||||
if (msg.kind) activeToolCalls.get(msg.toolUseId).kind = msg.kind;
|
||||
@@ -2138,6 +2430,7 @@
|
||||
break;
|
||||
|
||||
case 'cost':
|
||||
if (!isCurrentSessionEvent(msg)) break;
|
||||
costDisplay.textContent = `$${msg.costUsd.toFixed(4)}`;
|
||||
if (currentSessionId) {
|
||||
updateCachedSession(currentSessionId, (snapshot) => { snapshot.totalCost = msg.costUsd; });
|
||||
@@ -2145,6 +2438,7 @@
|
||||
break;
|
||||
|
||||
case 'usage':
|
||||
if (!isCurrentSessionEvent(msg)) break;
|
||||
if (msg.totalUsage) {
|
||||
const cacheText = msg.totalUsage.cachedInputTokens ? ` · cache ${msg.totalUsage.cachedInputTokens}` : '';
|
||||
costDisplay.textContent = `in ${msg.totalUsage.inputTokens} · out ${msg.totalUsage.outputTokens}${cacheText}`;
|
||||
@@ -2155,10 +2449,17 @@
|
||||
break;
|
||||
|
||||
case 'done':
|
||||
if (!isCurrentSessionEvent(msg)) {
|
||||
if (msg.sessionId) {
|
||||
updateCachedSession(msg.sessionId, (snapshot) => { snapshot.isRunning = false; });
|
||||
}
|
||||
break;
|
||||
}
|
||||
finishGenerating(msg.sessionId);
|
||||
break;
|
||||
|
||||
case 'system_message':
|
||||
if (!isCurrentSessionEvent(msg)) break;
|
||||
appendSystemMessage(msg.message);
|
||||
break;
|
||||
|
||||
@@ -2183,10 +2484,11 @@
|
||||
break;
|
||||
|
||||
case 'resume_generating':
|
||||
if (!isCurrentSessionEvent(msg)) break;
|
||||
// Server has an active process for this session — resume streaming
|
||||
setCurrentSessionRunningState(true);
|
||||
if (!isGenerating || !document.getElementById('streaming-msg')) {
|
||||
startGenerating();
|
||||
startGenerating(msg.sessionId || currentSessionId);
|
||||
} else {
|
||||
sendBtn.hidden = true;
|
||||
abortBtn.hidden = false;
|
||||
@@ -2218,6 +2520,7 @@
|
||||
break;
|
||||
|
||||
case 'error':
|
||||
if (!isCurrentSessionEvent(msg)) break;
|
||||
if (msg.code === 'new_session_cwd_missing' && pendingNewSessionRequest?.cwd) {
|
||||
const request = pendingNewSessionRequest;
|
||||
pendingNewSessionRequest = null;
|
||||
@@ -2323,8 +2626,11 @@
|
||||
}
|
||||
|
||||
// --- Generating State ---
|
||||
function startGenerating() {
|
||||
function startGenerating(sessionId = currentSessionId) {
|
||||
const targetSessionId = sessionId || currentSessionId || null;
|
||||
if (targetSessionId && currentSessionId && targetSessionId !== currentSessionId) return false;
|
||||
isGenerating = true;
|
||||
generatingSessionId = targetSessionId;
|
||||
setCurrentSessionRunningState(true);
|
||||
pendingText = '';
|
||||
window.pendingContentBlocks = [];
|
||||
@@ -2334,6 +2640,7 @@
|
||||
hasGrouped = false;
|
||||
sendBtn.hidden = true;
|
||||
abortBtn.hidden = false;
|
||||
updateNoteModeUI();
|
||||
// 不禁用输入框,允许用户继续输入(但无法发送)
|
||||
|
||||
const welcome = messagesDiv.querySelector('.welcome-msg');
|
||||
@@ -2341,6 +2648,7 @@
|
||||
|
||||
const msgEl = createMsgElement('assistant', '');
|
||||
msgEl.id = 'streaming-msg';
|
||||
if (targetSessionId) msgEl.dataset.sessionId = targetSessionId;
|
||||
// 流式消息 bubble 拆为 .msg-text 和 .msg-tools 两个子容器
|
||||
const bubble = msgEl.querySelector('.msg-bubble');
|
||||
bubble.innerHTML = '';
|
||||
@@ -2353,12 +2661,16 @@
|
||||
bubble.appendChild(toolsDiv);
|
||||
messagesDiv.appendChild(msgEl);
|
||||
scrollToBottom();
|
||||
return true;
|
||||
}
|
||||
|
||||
function finishGenerating(sessionId) {
|
||||
if (sessionId && currentSessionId && sessionId !== currentSessionId) return;
|
||||
isGenerating = false;
|
||||
generatingSessionId = null;
|
||||
sendBtn.hidden = false;
|
||||
abortBtn.hidden = true;
|
||||
updateNoteModeUI();
|
||||
setCurrentSessionRunningState(false);
|
||||
msgInput.focus();
|
||||
|
||||
@@ -2400,12 +2712,15 @@
|
||||
streamEl.removeAttribute('id');
|
||||
}
|
||||
|
||||
if (sessionId) currentSessionId = sessionId;
|
||||
if (sessionId) {
|
||||
migratePendingNotesToSession(sessionId, currentAgent);
|
||||
}
|
||||
pendingText = '';
|
||||
activeToolCalls.clear();
|
||||
activeTodoCallTargets.clear();
|
||||
toolGroupCount = 0;
|
||||
hasGrouped = false;
|
||||
renderPendingNotes();
|
||||
}
|
||||
|
||||
// --- Rendering ---
|
||||
@@ -2789,12 +3104,15 @@
|
||||
messagesDiv.innerHTML = '';
|
||||
if (messages.length === 0) {
|
||||
messagesDiv.innerHTML = buildWelcomeMarkup(currentAgent);
|
||||
renderPendingNotes({ scroll: false });
|
||||
scrollToBottom();
|
||||
return;
|
||||
}
|
||||
if (options.immediate) {
|
||||
const frag = document.createDocumentFragment();
|
||||
messages.forEach((message) => frag.appendChild(buildMsgElement(message)));
|
||||
messagesDiv.appendChild(frag);
|
||||
renderPendingNotes({ scroll: false });
|
||||
scrollToBottom();
|
||||
return;
|
||||
}
|
||||
@@ -2816,6 +3134,7 @@
|
||||
const frag0 = document.createDocumentFragment();
|
||||
for (let i = batches[0][0]; i < batches[0][1]; i++) frag0.appendChild(buildMsgElement(messages[i]));
|
||||
messagesDiv.appendChild(frag0);
|
||||
renderPendingNotes({ scroll: false });
|
||||
scrollToBottom();
|
||||
|
||||
// Render remaining batches asynchronously, prepending each
|
||||
@@ -3819,8 +4138,33 @@
|
||||
}
|
||||
|
||||
// --- Send Message ---
|
||||
function submitUserMessage(text, attachments = []) {
|
||||
const welcome = messagesDiv.querySelector('.welcome-msg');
|
||||
if (welcome) welcome.remove();
|
||||
messagesDiv.appendChild(createMsgElement('user', text, attachments));
|
||||
scrollToBottom();
|
||||
|
||||
send({ type: 'message', text, attachments, sessionId: currentSessionId, mode: currentMode, agent: currentAgent });
|
||||
startGenerating();
|
||||
}
|
||||
|
||||
function sendMessage() {
|
||||
const text = msgInput.value.trim();
|
||||
if (noteMode) {
|
||||
if ((!text && pendingAttachments.length === 0) || isBlockingSessionLoad()) return;
|
||||
hideCmdMenu();
|
||||
hideOptionPicker();
|
||||
if (pendingAttachments.length > 0) {
|
||||
appendError('笔记模式暂不支持附带图片,请先移除图片。');
|
||||
return;
|
||||
}
|
||||
if (addPendingNoteFromInput(text)) {
|
||||
msgInput.value = '';
|
||||
autoResize();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if ((!text && pendingAttachments.length === 0) || isGenerating || isBlockingSessionLoad()) return;
|
||||
hideCmdMenu();
|
||||
hideOptionPicker();
|
||||
@@ -3852,18 +4196,12 @@
|
||||
}
|
||||
|
||||
// Regular message
|
||||
const welcome = messagesDiv.querySelector('.welcome-msg');
|
||||
if (welcome) welcome.remove();
|
||||
const attachments = pendingAttachments.map((attachment) => ({ ...attachment }));
|
||||
messagesDiv.appendChild(createMsgElement('user', text, attachments));
|
||||
scrollToBottom();
|
||||
|
||||
send({ type: 'message', text, attachments, sessionId: currentSessionId, mode: currentMode, agent: currentAgent });
|
||||
submitUserMessage(text, attachments);
|
||||
msgInput.value = '';
|
||||
pendingAttachments = [];
|
||||
renderPendingAttachments();
|
||||
autoResize();
|
||||
startGenerating();
|
||||
}
|
||||
|
||||
function autoResize() {
|
||||
@@ -3947,6 +4285,13 @@
|
||||
}
|
||||
});
|
||||
sendBtn.addEventListener('click', sendMessage);
|
||||
if (noteModeBtn) {
|
||||
noteModeBtn.addEventListener('click', () => {
|
||||
noteMode = !noteMode;
|
||||
updateNoteModeUI();
|
||||
msgInput.focus();
|
||||
});
|
||||
}
|
||||
abortBtn.addEventListener('click', () => send({ type: 'abort' }));
|
||||
if (attachBtn && imageUploadInput) {
|
||||
attachBtn.addEventListener('click', () => imageUploadInput.click());
|
||||
@@ -3987,7 +4332,7 @@
|
||||
autoResize();
|
||||
const val = msgInput.value;
|
||||
// Show slash command menu
|
||||
if (val.startsWith('/') && !val.includes('\n')) {
|
||||
if (!noteMode && val.startsWith('/') && !val.includes('\n')) {
|
||||
showCmdMenu(val);
|
||||
} else {
|
||||
hideCmdMenu();
|
||||
@@ -5583,6 +5928,7 @@
|
||||
// --- Init ---
|
||||
applyTheme(currentTheme);
|
||||
setCurrentAgent(currentAgent);
|
||||
updateNoteModeUI();
|
||||
renderSessionList();
|
||||
connect();
|
||||
window.addEventListener('resize', updateCwdBadge);
|
||||
|
||||
@@ -101,6 +101,13 @@
|
||||
</svg>
|
||||
</button>
|
||||
<textarea id="msg-input" rows="1" placeholder="输入消息… 输入 / 查看指令" autocomplete="off"></textarea>
|
||||
<button id="note-mode-btn" class="note-mode-btn" title="笔记模式" aria-label="笔记模式" aria-pressed="false" type="button">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 20h9"></path>
|
||||
<path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z"></path>
|
||||
<path d="m15 5 3 3"></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>
|
||||
|
||||
162
public/style.css
162
public/style.css
@@ -21,6 +21,11 @@
|
||||
--success: #5d8a54; /* 抹茶 matcha */
|
||||
--danger: #c0553a;
|
||||
--info: #5b7ea1; /* 縹色 blue-gray */
|
||||
--note-accent: #5b7ea1;
|
||||
--note-accent-hover: #486b8f;
|
||||
--note-bg: rgba(91, 126, 161, 0.1);
|
||||
--note-bg-strong: rgba(91, 126, 161, 0.16);
|
||||
--note-border: rgba(91, 126, 161, 0.24);
|
||||
--scrollbar-thumb: #c9baa9;
|
||||
--scrollbar-track: transparent;
|
||||
--sidebar-width: 280px;
|
||||
@@ -62,6 +67,11 @@ html[data-theme='coolvibe'] {
|
||||
--success: #2e8a61;
|
||||
--danger: #d65567;
|
||||
--info: #1976a4;
|
||||
--note-accent: #2e8a61;
|
||||
--note-accent-hover: #226547;
|
||||
--note-bg: rgba(46, 138, 97, 0.1);
|
||||
--note-bg-strong: rgba(46, 138, 97, 0.16);
|
||||
--note-border: rgba(46, 138, 97, 0.24);
|
||||
--scrollbar-thumb: #a7c4ce;
|
||||
--font-ui: 'Chivo Mono', ui-monospace, monospace;
|
||||
--page-background:
|
||||
@@ -328,6 +338,11 @@ html[data-theme='editorial'] {
|
||||
--success: #4d7b57;
|
||||
--danger: #c05c42;
|
||||
--info: #4f6f87;
|
||||
--note-accent: #4f6f87;
|
||||
--note-accent-hover: #3d596f;
|
||||
--note-bg: rgba(79, 111, 135, 0.1);
|
||||
--note-bg-strong: rgba(79, 111, 135, 0.16);
|
||||
--note-border: rgba(79, 111, 135, 0.24);
|
||||
--scrollbar-thumb: #bba995;
|
||||
--font-ui: 'Avenir Next', 'Segoe UI', 'PingFang SC', sans-serif;
|
||||
--page-background:
|
||||
@@ -1259,6 +1274,98 @@ body.session-loading-active {
|
||||
border-bottom-left-radius: 4px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.msg.note {
|
||||
align-self: flex-end;
|
||||
flex-direction: row-reverse;
|
||||
max-width: min(680px, 85%);
|
||||
}
|
||||
.msg.note .note-avatar {
|
||||
background: var(--note-accent);
|
||||
color: #fff;
|
||||
}
|
||||
.msg.note .note-bubble {
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.72), transparent),
|
||||
var(--note-bg);
|
||||
border: 1px solid var(--note-border);
|
||||
border-bottom-right-radius: 4px;
|
||||
color: var(--text-primary);
|
||||
box-shadow: 0 10px 22px rgba(45, 31, 20, 0.05);
|
||||
}
|
||||
.note-meta {
|
||||
margin-bottom: 6px;
|
||||
color: var(--note-accent);
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
line-height: 1.2;
|
||||
}
|
||||
.note-text {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.note-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: 6px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
.note-action {
|
||||
appearance: none;
|
||||
min-height: 30px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid var(--note-border);
|
||||
border-radius: 8px;
|
||||
background: rgba(255, 255, 255, 0.58);
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
transition: background 0.15s, border-color 0.15s, color 0.15s, transform 0.1s;
|
||||
}
|
||||
.note-action:hover {
|
||||
background: var(--note-bg-strong);
|
||||
border-color: var(--note-accent);
|
||||
color: var(--note-accent);
|
||||
}
|
||||
.note-action:active {
|
||||
transform: scale(0.96);
|
||||
}
|
||||
.note-action.send,
|
||||
.note-action.save {
|
||||
background: var(--note-accent);
|
||||
border-color: transparent;
|
||||
color: #fff;
|
||||
}
|
||||
.note-action.send:hover,
|
||||
.note-action.save:hover {
|
||||
background: var(--note-accent-hover);
|
||||
color: #fff;
|
||||
}
|
||||
.note-action.delete:hover {
|
||||
background: rgba(192, 85, 58, 0.1);
|
||||
border-color: rgba(192, 85, 58, 0.24);
|
||||
color: var(--danger);
|
||||
}
|
||||
.note-edit-input {
|
||||
width: min(460px, 100%);
|
||||
min-width: 260px;
|
||||
max-width: 100%;
|
||||
border: 1px solid var(--note-border);
|
||||
border-radius: 10px;
|
||||
outline: none;
|
||||
background: rgba(255, 255, 255, 0.76);
|
||||
color: var(--text-primary);
|
||||
font: inherit;
|
||||
line-height: 1.5;
|
||||
padding: 9px 10px;
|
||||
resize: none;
|
||||
}
|
||||
.note-edit-input:focus {
|
||||
border-color: var(--note-accent);
|
||||
box-shadow: 0 0 0 3px var(--note-bg);
|
||||
}
|
||||
|
||||
/* System messages */
|
||||
.msg.system {
|
||||
@@ -1844,6 +1951,10 @@ body.session-loading-active {
|
||||
border-color: var(--info);
|
||||
box-shadow: 0 0 0 3px rgba(91, 126, 161, 0.12);
|
||||
}
|
||||
.input-wrapper.note-mode-active {
|
||||
border-color: var(--note-border);
|
||||
box-shadow: 0 0 0 3px var(--note-bg);
|
||||
}
|
||||
.attach-btn {
|
||||
appearance: none;
|
||||
width: 40px;
|
||||
@@ -1865,7 +1976,42 @@ body.session-loading-active {
|
||||
background: rgba(192, 85, 58, 0.08);
|
||||
border-color: rgba(192, 85, 58, 0.12);
|
||||
}
|
||||
.attach-btn:disabled {
|
||||
.note-mode-btn {
|
||||
appearance: none;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 10px;
|
||||
background: var(--note-accent);
|
||||
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;
|
||||
}
|
||||
.note-mode-btn:hover {
|
||||
background: var(--note-accent-hover);
|
||||
color: #fff;
|
||||
border-color: transparent;
|
||||
}
|
||||
.note-mode-btn:active {
|
||||
transform: scale(0.92);
|
||||
}
|
||||
.note-mode-btn.active {
|
||||
background: var(--note-accent-hover);
|
||||
border-color: transparent;
|
||||
color: #fff;
|
||||
box-shadow: 0 8px 18px var(--note-bg-strong);
|
||||
}
|
||||
.note-mode-btn.active:hover {
|
||||
background: var(--note-accent-hover);
|
||||
color: #fff;
|
||||
}
|
||||
.attach-btn:disabled,
|
||||
.note-mode-btn:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: default;
|
||||
}
|
||||
@@ -1904,6 +2050,13 @@ body.session-loading-active {
|
||||
color: #fff;
|
||||
}
|
||||
.send-btn:hover { background: var(--accent-hover); }
|
||||
.send-btn.note-send {
|
||||
background: var(--note-accent);
|
||||
color: #fff;
|
||||
}
|
||||
.send-btn.note-send:hover {
|
||||
background: var(--note-accent-hover);
|
||||
}
|
||||
.send-btn:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
.abort-btn {
|
||||
background: var(--danger);
|
||||
@@ -2024,8 +2177,13 @@ body.session-loading-active {
|
||||
.msg.assistant .msg-bubble { border-bottom-left-radius: 4px; }
|
||||
.code-block-wrapper pre code { font-size: 12px; }
|
||||
.input-wrapper { padding: 6px 10px; border-radius: 12px; }
|
||||
.attach-btn { width: 38px; height: 38px; border-radius: 11px; }
|
||||
.attach-btn { width: 34px; height: 34px; border-radius: 10px; }
|
||||
.note-mode-btn { width: 34px; height: 34px; border-radius: 10px; }
|
||||
.send-btn, .abort-btn { width: 34px; height: 34px; }
|
||||
.msg.note { max-width: 92%; }
|
||||
.note-actions { gap: 5px; }
|
||||
.note-action { min-height: 28px; padding: 0 8px; }
|
||||
.note-edit-input { min-width: 0; }
|
||||
.new-chat-btn,
|
||||
.new-chat-arrow { min-height: 44px; }
|
||||
.new-chat-arrow { width: 48px; }
|
||||
|
||||
Reference in New Issue
Block a user