// === CC-Web Frontend === (function () { 'use strict'; const ASSET_VERSION = '20260629-ccweb-prompt-dark-theme'; const WS_URL = `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/ws`; const RENDER_DEBOUNCE = 100; const COMPOSER_SUGGESTION_DEBOUNCE = 120; const DIVIDER_TIME_STORAGE_KEY = 'cc-web-show-divider-time'; const CCWEB_PROMPT_VIEW_MODE_STORAGE_KEY = 'cc-web-ccweb-prompt-view-mode'; const PROJECT_COLLAPSE_STORAGE_KEY = 'cc-web-collapsed-projects'; const CROSS_CONVERSATION_REPLY_COLLAPSE_STORAGE_KEY = 'cc-web-collapsed-cross-replies'; const CROSS_CONVERSATION_REPLY_COLLAPSE_LIMIT = 500; const ASSISTANT_LAST_SECTION_BUTTON_CLASS = 'msg-last-section-btn'; const ASSISTANT_BRANCH_BUTTON_CLASS = 'msg-branch-btn'; const ASSISTANT_LAST_SECTION_FOCUS_CLASS = 'msg-last-section-focus'; const ASSISTANT_LAST_SECTION_SCROLL_OFFSET = 72; const ASSISTANT_LAST_SECTION_SKIP_SELECTOR = [ `.${ASSISTANT_LAST_SECTION_BUTTON_CLASS}`, `.${ASSISTANT_BRANCH_BUTTON_CLASS}`, '.msg-action-row', '.msg-tools', '.tool-call', '.tool-group', '.msg-attachments', '.msg-attachment-card', '.cross-conversation-meta', '.agent-message-divider', ].join(','); const ASSISTANT_LAST_SECTION_SCOPE_SELECTOR = [ 'p', 'li', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'td', 'th', 'pre', 'code', '.msg-text', ].join(','); const SLASH_COMMANDS = [ { cmd: '/clear', desc: '清除当前会话' }, { cmd: '/model', desc: '查看/切换模型' }, { cmd: '/mode', desc: '查看/切换权限模式' }, { cmd: '/cost', desc: '查看会话费用' }, { cmd: '/compact', desc: '压缩上下文' }, { cmd: '/goal', desc: '设置/查看 Codex App 持久目标' }, { cmd: '/init', desc: '生成/更新 Agent 指南文件' }, { cmd: '/help', desc: '显示帮助' }, ]; const MODE_LABELS = { default: '默认', plan: 'Plan', yolo: 'YOLO', }; const AGENT_LABELS = { claude: 'Claude', codex: 'Codex', codexapp: 'Codex App', }; const DEFAULT_AGENT = 'claude'; const SESSION_CACHE_LIMIT = 4; const SESSION_CACHE_MAX_WEIGHT = 1_500_000; const SIDEBAR_SWIPE_TRIGGER = 72; const SIDEBAR_SWIPE_MAX_VERTICAL_DRIFT = 42; const OLD_SESSION_GROUP_INITIAL_VISIBLE = 3; const SESSION_GROUP_COMPACT_VISIBLE_LIMIT = 8; const OLD_SESSION_COLLAPSE_DAYS = 7; const OLD_SESSION_COLLAPSE_MS = OLD_SESSION_COLLAPSE_DAYS * 24 * 60 * 60 * 1000; const SESSION_LOAD_OVERLAY_TIMEOUT_MS = 12_000; const MODEL_OPTIONS = [ { value: 'opus', label: 'Opus', desc: '最强大,1M 上下文' }, { value: 'sonnet', label: 'Sonnet', desc: '平衡性能,1M 上下文' }, { value: 'haiku', label: 'Haiku', desc: '最快速,适合简单任务' }, ]; const DEFAULT_CODEX_MODEL_OPTIONS = [ { value: 'gpt-5.4', label: 'GPT-5.4', desc: '当前主力 Codex 模型' }, { value: 'gpt-5.3-codex', label: 'GPT-5.3 Codex', desc: '偏工程执行场景' }, { value: 'gpt-5.2-codex', label: 'GPT-5.2 Codex', desc: '兼容旧路由与旧配置' }, { value: 'gpt-5.2', label: 'GPT-5.2', desc: '通用 OpenAI 兼容模型' }, ]; const MODE_PICKER_OPTIONS = [ { value: 'yolo', label: 'YOLO', desc: '跳过所有权限检查' }, { value: 'plan', label: 'Plan', desc: '执行前需确认计划' }, { value: 'default', label: '默认', desc: '标准权限审批' }, ]; const THEME_OPTIONS = [ { value: 'washi', label: 'Washi Warm', desc: '暖纸色与朱砂点缀,保留当前熟悉的 CC-Web 气质。', swatches: ['#faf6f0', '#f2ebe2', '#c0553a', '#5d8a54'], }, { value: 'coolvibe', label: 'CoolVibe Light', desc: '保留 CoolVibe 的青色科技感,但改成更干净的浅色工作台。', swatches: ['#f7fbfc', '#eef7f9', '#0891b2', '#ffffff'], }, { value: 'editorial', label: 'Editorial Sand', desc: '更明亮的留白和更克制的棕色强调,像编辑台一样安静。', swatches: ['#f6f1e8', '#efe8dc', '#8b5e3c', '#2f4b45'], }, { value: 'sage', label: 'Sage Console', desc: '清透鼠尾草绿与石墨文字,适合长时间工作流。', swatches: ['#f5f8f2', '#e6efdf', '#2f6f64', '#557ba3'], }, { value: 'ink', label: 'Ink Focus', desc: '浅灰纸面配靛蓝重点,信息密度更高也更冷静。', swatches: ['#f6f7fb', '#e8edf5', '#3f5fb5', '#3f8f73'], }, { value: 'dawn', label: 'Dawn Studio', desc: '晨光米白配珊瑚红,保留温度但比暖纸更轻。', swatches: ['#fff7f2', '#f3e8e1', '#b5524d', '#4f8a6b'], }, { value: 'carbon', label: 'Carbon Mint', desc: '石墨黑底配薄荷绿重点,夜间使用更稳。', swatches: ['#0f1314', '#202829', '#67d8b2', '#9fb7ff'], }, { value: 'nocturne', label: 'Nocturne Teal', desc: '深海青黑配电光蓝,适合高专注会话。', swatches: ['#081417', '#142b31', '#5ecdf5', '#f0c36a'], }, { value: 'cinder', label: 'Cinder Rose', desc: '炭黑底配低饱和玫瑰色,暗色里保留一点温度。', swatches: ['#151112', '#2a2022', '#e68193', '#67c587'], }, ]; // --- State --- let ws = null; let wsAuthenticated = false; let authToken = localStorage.getItem('cc-web-token'); let currentSessionId = null; let sessions = []; let sessionCache = new Map(); let isGenerating = false; let reconnectAttempts = 0; let reconnectTimer = null; let isPageUnloading = false; let pendingText = ''; let renderTimer = null; let generatingSessionId = null; let activeToolCalls = new Map(); let activeTodoCallTargets = new Map(); let closedCollabAgentIds = new Set(); let toolDomSeq = 0; let toolGroupCount = 0; // 当前 .msg-tools 直接子节点数(含已有父目录) let hasGrouped = false; // 本次输出是否已触发过折叠 let cmdMenuIndex = -1; let currentMode = 'yolo'; let currentModel = 'opus'; let currentAgent = AGENT_LABELS[localStorage.getItem('cc-web-agent')] ? localStorage.getItem('cc-web-agent') : DEFAULT_AGENT; let currentTheme = (document.documentElement.dataset.theme || localStorage.getItem('cc-web-theme') || 'washi'); let showAgentDividerTime = localStorage.getItem(DIVIDER_TIME_STORAGE_KEY) !== '0'; let codexConfigCache = null; let loadedHistorySessionId = null; let activeSessionLoad = null; let sessionLoadOverlayTimer = null; let sidebarSwipe = null; let activeComposerToken = null; let composerSuggestionTimer = null; let composerRequestSeq = 0; let latestComposerRequestId = ''; let pendingAttachments = []; let uploadingAttachments = []; let attachmentPreviewModal = null; const attachmentPreviewCache = new Map(); let loginPasswordValue = ''; // store login password for force-change flow let currentCwd = null; let currentSessionMessageCount = 0; let currentSessionRunning = false; let fileBrowserState = null; let directoryPickerState = null; let codexAppUserInputModal = null; let codexAppApprovalModal = null; let pendingNewSessionRequest = null; let pendingSessionSwitchRequest = null; let sessionSwitchRequestSeq = 0; 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; const mcpStartupToastKeys = new Map(); let sessionSearchQuery = ''; const collapsedProjectKeys = (() => { try { const parsed = JSON.parse(localStorage.getItem(PROJECT_COLLAPSE_STORAGE_KEY) || '[]'); return new Set(Array.isArray(parsed) ? parsed.filter(Boolean) : []); } catch { return new Set(); } })(); const collapsedCrossConversationReplyKeys = (() => { try { const parsed = JSON.parse(localStorage.getItem(CROSS_CONVERSATION_REPLY_COLLAPSE_STORAGE_KEY) || '[]'); return new Set(Array.isArray(parsed) ? parsed.filter(Boolean) : []); } catch { return new Set(); } })(); const pendingNotesByTarget = new Map(); const queuedMessagesByTarget = new Map(); const userMessageIndex = new Map(); const expandedOldSessionGroups = new Set(); document.documentElement.dataset.dividerTime = showAgentDividerTime ? 'show' : 'hide'; // --- DOM --- const $ = (sel) => document.querySelector(sel); const loginOverlay = $('#login-overlay'); const loginForm = $('#login-form'); const loginPassword = $('#login-password'); const loginError = $('#login-error'); const rememberPw = $('#remember-pw'); const app = $('#app'); const sessionLoadingOverlay = $('#session-loading-overlay'); const sessionLoadingLabel = $('#session-loading-label'); const sidebar = $('#sidebar'); const sidebarOverlay = $('#sidebar-overlay'); const menuBtn = $('#menu-btn'); const chatMain = document.querySelector('.chat-main'); const newChatSplit = sidebar.querySelector('.new-chat-split'); const newChatBtn = $('#new-chat-btn'); const newChatArrow = $('#new-chat-arrow'); const newChatDropdown = $('#new-chat-dropdown'); const importSessionBtn = $('#import-session-btn'); const sessionSearchInput = $('#session-search-input'); const sessionSearchClear = $('#session-search-clear'); const sessionList = $('#session-list'); const chatTitle = $('#chat-title'); const chatSessionIdBtn = $('#chat-session-id-btn'); const chatAgentBtn = $('#chat-agent-btn'); const chatAgentMenu = $('#chat-agent-menu'); const chatRuntimeState = $('#chat-runtime-state'); const chatCwd = $('#chat-cwd'); const userOutlineBtn = $('#user-outline-btn'); const userOutlinePanel = $('#user-outline-panel'); const ccwebPromptOutlineBtn = $('#ccweb-prompt-outline-btn'); const ccwebPromptOutlinePanel = $('#ccweb-prompt-outline-panel'); const reloadMcpBtn = $('#reload-mcp-btn'); const costDisplay = $('#cost-display'); const attachmentTray = $('#attachment-tray'); const pendingNotesTray = $('#pending-notes-tray'); const imageUploadInput = $('#image-upload-input'); const attachBtn = $('#attach-btn'); const messagesDiv = $('#messages'); 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'); const modeSelect = $('#mode-select'); const defaultMsgInputPlaceholder = msgInput.getAttribute('placeholder') || '输入消息… 输入 / 查看指令'; // --- Viewport height fix for mobile browsers --- function setVH() { document.documentElement.style.setProperty('--vh', `${window.innerHeight * 0.01}px`); } setVH(); window.addEventListener('resize', setVH); window.addEventListener('orientationchange', () => setTimeout(setVH, 100)); function buildWelcomeMarkup(agent) { const label = AGENT_LABELS[agent] || AGENT_LABELS.claude; return `

欢迎使用 CC-Web

开始与 ${label} 对话

`; } function normalizeAgent(agent) { return AGENT_LABELS[agent] ? agent : DEFAULT_AGENT; } function isCodexLikeAgent(agent) { const normalized = normalizeAgent(agent); return normalized === 'codex' || normalized === 'codexapp'; } function isCodexAppAgent(agent) { return normalizeAgent(agent) === 'codexapp'; } function getDraftNoteKey(agent = currentAgent) { return `draft:${normalizeAgent(agent)}`; } function getCurrentNoteKey(agent = currentAgent) { 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)) { 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 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); const draftNotes = pendingNotesByTarget.get(draftKey); if (!draftNotes || draftNotes.length === 0) return; const sessionNotes = getNotesForKey(sessionId, true); sessionNotes.push(...draftNotes); 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; const sendLabel = noteActive ? '记录笔记' : (allowRuntimeInsert ? '插入' : '发送'); if (sendBtn) { sendBtn.classList.toggle('note-send', noteActive); sendBtn.title = sendLabel; 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; } } 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 (msgInput) { msgInput.placeholder = active ? '记录笔记,稍后从气泡发送…' : defaultMsgInputPlaceholder; } if (active) hideCmdMenu(); updateGenerationControls(); } 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 = 'pending-note'; div.dataset.noteId = note.id; const avatar = document.createElement('div'); avatar.className = 'note-avatar'; avatar.textContent = 'N'; const bubble = document.createElement('div'); bubble.className = '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); 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); const queuedMessages = getCurrentQueue(false); renderPendingCcwebPrompts({ scroll: false, updateScrollbar: false }); if ((!notes || notes.length === 0) && (!queuedMessages || queuedMessages.length === 0)) { pendingNotesTray.hidden = true; if (options.updateScrollbar !== false) updateScrollbar(); return; } 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) { pendingNotesTray.scrollTop = pendingNotesTray.scrollHeight; } if (options.updateScrollbar !== false) updateScrollbar(); } function collectCurrentPendingCcwebPrompts() { if (!currentSessionId) return []; const prompts = []; const seen = new Set(); const entry = sessionCache.get(currentSessionId); const messages = Array.isArray(entry?.snapshot?.messages) ? entry.snapshot.messages : []; messages.forEach((message) => { const prompt = message?.ccwebPrompt; if (!prompt?.id || seen.has(prompt.id) || (prompt.status || 'pending') !== 'pending') return; seen.add(prompt.id); prompts.push(prompt); }); messagesDiv?.querySelectorAll?.('.ccweb-prompt-card[data-status="pending"]').forEach((card) => { const promptId = card.dataset.promptId || ''; if (!promptId || seen.has(promptId)) return; seen.add(promptId); prompts.push({ id: promptId, title: card.querySelector('.ccweb-prompt-title')?.textContent || '需要用户确认', questions: Array.from(card.querySelectorAll('.ccweb-prompt-question')).map((questionEl) => ({ id: questionEl.dataset.questionId || '', })), }); }); return prompts; } function scrollToCcwebPrompt(promptId) { if (!promptId || !messagesDiv) return false; const card = messagesDiv.querySelector(`.ccweb-prompt-card[data-prompt-id="${cssEscape(promptId)}"]`); if (!card) { showToast('未找到待提交表单', '可能在未加载的历史消息中'); return false; } const target = card.closest('.msg') || card; const containerRect = messagesDiv.getBoundingClientRect(); const targetRect = target.getBoundingClientRect(); const targetTop = messagesDiv.scrollTop + targetRect.top - containerRect.top - 72; messagesDiv.scrollTo({ top: Math.max(0, targetTop), behavior: 'smooth' }); card.classList.remove('ccweb-prompt-focus'); requestAnimationFrame(() => { card.classList.add('ccweb-prompt-focus'); window.setTimeout(() => card.classList.remove('ccweb-prompt-focus'), 1400); }); updateScrollbar(); return true; } function dismissCcwebPrompt(promptId) { if (!promptId || !currentSessionId) return; send({ type: 'ccweb_prompt_user_dismiss', sessionId: currentSessionId, promptId, }); } function createPendingCcwebPromptElement(prompt) { const item = document.createElement('div'); item.className = 'pending-ccweb-prompt'; item.dataset.promptId = prompt.id || ''; const badge = document.createElement('span'); badge.className = 'pending-ccweb-prompt-badge'; badge.setAttribute('aria-label', '未提交'); const title = document.createElement('div'); title.className = 'pending-ccweb-prompt-title'; const questionCount = Array.isArray(prompt.questions) ? prompt.questions.length : 0; title.textContent = `${prompt.title || '需要用户确认'} · ${questionCount || 1} 题`; const action = document.createElement('button'); action.type = 'button'; action.className = 'pending-ccweb-prompt-action'; action.textContent = '定位'; action.addEventListener('click', () => { closeCcwebPromptOutlinePanel(); scrollToCcwebPrompt(prompt.id); }); const dismiss = document.createElement('button'); dismiss.type = 'button'; dismiss.className = 'pending-ccweb-prompt-dismiss'; dismiss.textContent = '忽略'; dismiss.title = '忽略并删除这个未提交表单'; dismiss.addEventListener('click', () => { dismiss.disabled = true; dismiss.textContent = '删除中'; dismissCcwebPrompt(prompt.id); }); item.append(badge, title, action, dismiss); return item; } function renderPendingCcwebPrompts(options = {}) { if (!ccwebPromptOutlineBtn || !ccwebPromptOutlinePanel) return; const prompts = collectCurrentPendingCcwebPrompts(); const anchor = ccwebPromptOutlineBtn.closest('.ccweb-prompt-outline-anchor'); if (prompts.length === 0) { if (anchor) anchor.hidden = true; closeCcwebPromptOutlinePanel(); delete ccwebPromptOutlineBtn.dataset.count; ccwebPromptOutlinePanel.innerHTML = ''; if (options.updateScrollbar !== false) updateScrollbar(); return; } if (anchor) anchor.hidden = false; ccwebPromptOutlineBtn.disabled = false; ccwebPromptOutlineBtn.dataset.count = String(prompts.length); ccwebPromptOutlinePanel.replaceChildren(...prompts.map((prompt) => createPendingCcwebPromptElement(prompt))); if (options.updateScrollbar !== false) updateScrollbar(); } function closeCcwebPromptOutlinePanel() { if (!ccwebPromptOutlinePanel || !ccwebPromptOutlineBtn) return; ccwebPromptOutlinePanel.hidden = true; ccwebPromptOutlineBtn.setAttribute('aria-expanded', 'false'); } function toggleCcwebPromptOutlinePanel() { if (!ccwebPromptOutlinePanel || !ccwebPromptOutlineBtn) return; if (ccwebPromptOutlinePanel.hidden) { renderPendingCcwebPrompts({ scroll: false, updateScrollbar: false }); const anchor = ccwebPromptOutlineBtn.closest('.ccweb-prompt-outline-anchor'); if (anchor?.hidden || !ccwebPromptOutlinePanel.children.length) return; closeUserOutlinePanel(); ccwebPromptOutlinePanel.hidden = false; ccwebPromptOutlineBtn.setAttribute('aria-expanded', 'true'); } else { closeCcwebPromptOutlinePanel(); } } 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 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 }); } 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 = pendingNotesTray?.querySelector(`.pending-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 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('当前回复还在生成,稍后再发送笔记。'); return; } const note = dropPendingNote(noteId); if (!note) return; const text = String(note.text || '').trim(); renderPendingNotes({ scroll: false }); if (!text) return; 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'; } function getThemeOption(theme) { return THEME_OPTIONS.find((item) => item.value === normalizeTheme(theme)) || THEME_OPTIONS[0]; } function refreshThemeSummaries() { const label = getThemeOption(currentTheme).label; document.querySelectorAll('[data-theme-summary]').forEach((node) => { node.textContent = label; }); } function applyTheme(theme) { currentTheme = normalizeTheme(theme); document.documentElement.dataset.theme = currentTheme; localStorage.setItem('cc-web-theme', currentTheme); refreshThemeSummaries(); } function getDividerTimeSummary() { return showAgentDividerTime ? '显示时间' : '不显示时间'; } function refreshDividerTimeControls(root = document) { root.querySelectorAll('[data-divider-time-summary]').forEach((node) => { node.textContent = getDividerTimeSummary(); }); root.querySelectorAll('[data-divider-time-toggle]').forEach((node) => { node.checked = showAgentDividerTime; }); } function applyDividerTimePreference(visible) { showAgentDividerTime = !!visible; document.documentElement.dataset.dividerTime = showAgentDividerTime ? 'show' : 'hide'; localStorage.setItem(DIVIDER_TIME_STORAGE_KEY, showAgentDividerTime ? '1' : '0'); refreshDividerTimeControls(); } function buildThemePickerHtml(options = {}) { const { showSectionTitle = true } = options; return ` ${showSectionTitle ? '
界面主题
' : ''}
${THEME_OPTIONS.map((theme) => ` `).join('')}
`; } function mountThemePicker(panel) { panel.querySelectorAll('[data-theme-value]').forEach((button) => { button.addEventListener('click', () => { applyTheme(button.dataset.themeValue); panel.querySelectorAll('[data-theme-value]').forEach((item) => { item.classList.toggle('active', item.dataset.themeValue === currentTheme); }); }); }); } function buildAppearanceSettingsHtml() { return `
外观
`; } function mountAppearanceSettings(panel) { const themePageBtn = panel.querySelector('[data-open-theme-page]'); if (themePageBtn) themePageBtn.addEventListener('click', openThemeSubpage); const dividerTimeToggle = panel.querySelector('[data-divider-time-toggle]'); if (dividerTimeToggle) { dividerTimeToggle.checked = showAgentDividerTime; dividerTimeToggle.addEventListener('change', () => { applyDividerTimePreference(dividerTimeToggle.checked); }); } refreshDividerTimeControls(panel); } function buildNotifyEntryHtml(config) { const provider = config?.provider || 'off'; const providerLabel = PROVIDER_OPTIONS.find(o => o.value === provider)?.label || '关闭'; const summaryOn = config?.summary?.enabled ? '摘要已启用' : '摘要关闭'; const meta = provider === 'off' ? '未启用' : `${providerLabel} · ${summaryOn}`; return `
通知
`; } function openNotifySubpage() { send({ type: 'get_notify_config' }); const overlay = document.createElement('div'); overlay.className = 'settings-overlay settings-subpage-overlay'; overlay.style.zIndex = '10001'; const panel = document.createElement('div'); panel.className = 'settings-panel settings-subpage-panel'; panel.innerHTML = `
Notification

通知设置

`; overlay.appendChild(panel); document.body.appendChild(overlay); const providerSelect = panel.querySelector('#notify-provider'); const fieldsDiv = panel.querySelector('#notify-fields'); const summaryArea = panel.querySelector('#notify-summary-area'); const statusDiv = panel.querySelector('#notify-status'); const testBtn = panel.querySelector('#notify-test-btn'); const saveBtn = panel.querySelector('#notify-save-btn'); let currentNotifyConfig = null; function renderFields(provider) { renderNotifyFields(fieldsDiv, currentNotifyConfig, provider); if (summaryArea) { summaryArea.innerHTML = buildSummarySettingsHtml(currentNotifyConfig); bindSummarySettingsEvents(panel); } } function collectConfig() { return collectNotifyConfigFromPanel(panel, currentNotifyConfig, providerSelect.value); } function showStatus(msg, type) { statusDiv.textContent = msg; statusDiv.className = 'settings-status ' + (type || ''); } function refreshParentSummary(config) { const provider = config?.provider || 'off'; const providerLabel = PROVIDER_OPTIONS.find(o => o.value === provider)?.label || '关闭'; const summaryOn = config?.summary?.enabled ? '摘要已启用' : '摘要关闭'; const meta = provider === 'off' ? '未启用' : `${providerLabel} · ${summaryOn}`; document.querySelectorAll('[data-notify-summary]').forEach(el => { el.textContent = meta; }); } const savedOnNotifyConfig = _onNotifyConfig; _onNotifyConfig = (config) => { currentNotifyConfig = config; providerSelect.value = config.provider || 'off'; renderFields(config.provider || 'off'); if (savedOnNotifyConfig) savedOnNotifyConfig(config); }; const savedOnNotifyTestResult = _onNotifyTestResult; _onNotifyTestResult = (msg) => { showStatus(msg.message, msg.success ? 'success' : 'error'); if (savedOnNotifyTestResult) savedOnNotifyTestResult(msg); }; providerSelect.addEventListener('change', () => renderFields(providerSelect.value)); testBtn.addEventListener('click', () => { const config = collectConfig(); send({ type: 'save_notify_config', config }); showStatus('正在发送测试消息...', ''); send({ type: 'test_notify' }); }); saveBtn.addEventListener('click', () => { const config = collectConfig(); send({ type: 'save_notify_config', config }); refreshParentSummary(config); showStatus('已保存', 'success'); }); const closeSubpage = () => { _onNotifyConfig = savedOnNotifyConfig; _onNotifyTestResult = savedOnNotifyTestResult; if (overlay.parentNode) overlay.parentNode.removeChild(overlay); }; panel.querySelector('.settings-back').addEventListener('click', closeSubpage); overlay.addEventListener('click', (e) => { if (e.target === overlay) closeSubpage(); }); } function openThemeSubpage() { const overlay = document.createElement('div'); overlay.className = 'settings-overlay settings-subpage-overlay'; overlay.style.zIndex = '10001'; const panel = document.createElement('div'); panel.className = 'settings-panel settings-subpage-panel'; panel.innerHTML = `
Appearance

界面主题

${buildThemePickerHtml({ showSectionTitle: false })} `; overlay.appendChild(panel); document.body.appendChild(overlay); mountThemePicker(panel); refreshThemeSummaries(); const closeSubpage = () => { if (overlay.parentNode) overlay.parentNode.removeChild(overlay); }; panel.querySelector('.settings-back').addEventListener('click', closeSubpage); panel.querySelector('.settings-close').addEventListener('click', closeSubpage); overlay.addEventListener('click', (e) => { if (e.target === overlay) closeSubpage(); }); } function getAgentSessionStorageKey(agent) { return `cc-web-session-${normalizeAgent(agent)}`; } function getAgentModeStorageKey(agent) { return `cc-web-mode-${normalizeAgent(agent)}`; } function getLastSessionForAgent(agent) { return localStorage.getItem(getAgentSessionStorageKey(agent)); } function setLastSessionForAgent(agent, sessionId) { localStorage.setItem(getAgentSessionStorageKey(agent), sessionId); localStorage.setItem('cc-web-session', sessionId); } function getSessionMeta(sessionId) { return sessions.find((s) => s.id === sessionId) || null; } function compareSessionUpdatedDesc(a, b) { return new Date(b?.updated || 0) - new Date(a?.updated || 0); } function compareSessionPinnedDesc(a, b) { const pinnedDiff = new Date(b?.pinnedAt || 0) - new Date(a?.pinnedAt || 0); return pinnedDiff || compareSessionUpdatedDesc(a, b); } function shortSessionId(sessionId) { const value = String(sessionId || ''); return value ? value.slice(0, 8) : ''; } function shortChildAgentId(threadId) { const value = String(threadId || ''); if (!value) return ''; if (value.length <= 13) return value; return `${value.slice(0, 8)}…${value.slice(-4)}`; } function shortMessagePreview(text, maxLength = 60) { const value = String(text || '').replace(/\s+/g, ' ').trim(); if (!value) return '空消息'; return value.length > maxLength ? `${value.slice(0, maxLength - 1)}…` : value; } function getCrossConversationReplyCollapseKey(meta = {}) { const source = meta?.crossConversation || {}; const messageId = source.replyToRequestId || source.messageId || meta.messageId || meta.id || [source.sourceSessionId, meta.timestamp || source.processedAt || source.sentAt].filter(Boolean).join(':'); if (!messageId) return ''; return `${currentSessionId || 'unknown'}:${messageId}`; } function persistCrossConversationReplyCollapseState() { try { const keys = Array.from(collapsedCrossConversationReplyKeys).slice(-CROSS_CONVERSATION_REPLY_COLLAPSE_LIMIT); localStorage.setItem(CROSS_CONVERSATION_REPLY_COLLAPSE_STORAGE_KEY, JSON.stringify(keys)); } catch {} } function setCrossConversationReplyCollapsed(key, collapsed) { if (!key) return; if (collapsed) { collapsedCrossConversationReplyKeys.delete(key); collapsedCrossConversationReplyKeys.add(key); } else { collapsedCrossConversationReplyKeys.delete(key); } persistCrossConversationReplyCollapseState(); } function isCrossConversationReplyCollapsed(key) { return !!key && collapsedCrossConversationReplyKeys.has(key); } function formatCrossConversationReplyTime(value) { if (!value) return ''; const date = new Date(value); if (Number.isNaN(date.getTime())) return ''; return date.toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', }); } function createLocalId(prefix = 'local') { if (window.crypto?.randomUUID) return `${prefix}-${window.crypto.randomUUID()}`; return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; } function clearUserMessageIndex() { userMessageIndex.clear(); } function registerUserMessage(messageId, element, content) { if (!messageId || !element) return; userMessageIndex.set(messageId, { id: messageId, element, content: String(content || ''), }); } function buildUserOutlineItems() { const seen = new Set(); return Array.from(messagesDiv.querySelectorAll('.msg.user[data-message-id]')).map((element) => { const id = String(element.dataset.messageId || '').trim(); if (!id || seen.has(id)) return null; seen.add(id); const indexed = userMessageIndex.get(id); const content = indexed?.content || element.querySelector('.msg-text')?.textContent || ''; return { id, targetMessageId: element.id || '', label: shortMessagePreview(content, 64), }; }).filter((entry) => entry && entry.targetMessageId); } function updateUserOutlinePanel() { if (!userOutlinePanel || !userOutlineBtn) return; const items = buildUserOutlineItems(); if (items.length === 0) { userOutlinePanel.innerHTML = '
暂无用户消息
'; userOutlineBtn.disabled = true; } else { userOutlinePanel.innerHTML = items.map((item, index) => ` `).join(''); userOutlineBtn.disabled = false; } } function closeUserOutlinePanel() { if (!userOutlinePanel || !userOutlineBtn) return; userOutlinePanel.hidden = true; userOutlineBtn.setAttribute('aria-expanded', 'false'); } function toggleUserOutlinePanel() { if (!userOutlinePanel || !userOutlineBtn) return; if (userOutlinePanel.hidden) { updateUserOutlinePanel(); closeCcwebPromptOutlinePanel(); userOutlinePanel.hidden = false; userOutlineBtn.setAttribute('aria-expanded', 'true'); } else { closeUserOutlinePanel(); } } function scrollToMessage(anchorId) { if (!anchorId) return; const target = document.getElementById(anchorId); if (!target || !messagesDiv.contains(target)) return; const containerRect = messagesDiv.getBoundingClientRect(); const targetRect = target.getBoundingClientRect(); const targetTop = messagesDiv.scrollTop + targetRect.top - containerRect.top - 12; messagesDiv.scrollTo({ top: Math.max(0, targetTop), behavior: 'smooth' }); } function isAssistantLastSectionTextNode(node, root) { if (!node || node.nodeType !== Node.TEXT_NODE || !node.nodeValue?.trim()) return false; const parent = node.parentElement; if (!parent || !root.contains(parent)) return false; if (parent.closest(ASSISTANT_LAST_SECTION_SKIP_SELECTOR)) return false; const tag = parent.tagName?.toLowerCase(); return !['button', 'script', 'style', 'textarea', 'input', 'select', 'option'].includes(tag); } function collectAssistantTextNodes(root) { const nodes = []; if (!root) return nodes; const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, { acceptNode(node) { return isAssistantLastSectionTextNode(node, root) ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT; }, }); let node = walker.nextNode(); while (node) { nodes.push(node); node = walker.nextNode(); } return nodes; } function findLastAssistantTextScope(bubble) { const nodes = collectAssistantTextNodes(bubble); const lastNode = nodes[nodes.length - 1]; if (!lastNode) return null; return lastNode.parentElement?.closest(ASSISTANT_LAST_SECTION_SCOPE_SELECTOR) || bubble; } function collectAssistantTextScopes(root) { if (!root) return []; const seen = new Set(); return Array.from(root.querySelectorAll(ASSISTANT_LAST_SECTION_SCOPE_SELECTOR)).filter((scope) => { if (!scope || seen.has(scope)) return false; seen.add(scope); if (scope.closest(ASSISTANT_LAST_SECTION_SKIP_SELECTOR)) return false; return collectAssistantTextNodes(scope).length > 0; }); } function findFirstAssistantTextScopeAfterDivider(bubble) { const dividers = Array.from(bubble?.querySelectorAll?.('.agent-message-divider') || []); const lastDivider = dividers[dividers.length - 1]; if (!lastDivider) return null; return collectAssistantTextScopes(bubble).find((scope) => ( lastDivider.compareDocumentPosition(scope) & Node.DOCUMENT_POSITION_FOLLOWING )) || null; } function findFirstNonWhitespaceIndex(text, start = 0) { for (let i = Math.max(0, start); i < text.length; i += 1) { if (!/\s/.test(text[i])) return i; } return -1; } function mapTextIndexToNode(entries, index) { for (const entry of entries) { if (index >= entry.start && index < entry.end) { return { node: entry.node, offset: index - entry.start }; } } const last = entries[entries.length - 1]; return last ? { node: last.node, offset: last.node.nodeValue.length } : null; } function getAssistantTextScopeStartTarget(scope) { if (!scope) return null; const nodes = collectAssistantTextNodes(scope); if (nodes.length === 0) return null; const entries = []; let text = ''; nodes.forEach((node) => { const value = node.nodeValue || ''; const start = text.length; text += value; entries.push({ node, start, end: text.length }); }); const startIndex = findFirstNonWhitespaceIndex(text, 0); if (startIndex < 0) return null; const mapped = mapTextIndexToNode(entries, startIndex); return mapped ? { ...mapped, scope } : null; } function getAssistantLastSectionTarget(bubble) { const scope = findFirstAssistantTextScopeAfterDivider(bubble) || findLastAssistantTextScope(bubble); return getAssistantTextScopeStartTarget(scope); } function getRangeRectFromTextPosition(node, offset) { if (!node) return null; const range = document.createRange(); const safeOffset = Math.min(Math.max(0, offset), node.nodeValue.length); range.setStart(node, safeOffset); range.setEnd(node, Math.min(node.nodeValue.length, safeOffset + 1)); const rect = Array.from(range.getClientRects()).find(item => item.width || item.height) || null; range.detach?.(); return rect; } function scrollAssistantBubbleToLastSection(bubble) { const target = getAssistantLastSectionTarget(bubble); if (!target) return false; const rect = getRangeRectFromTextPosition(target.node, target.offset) || target.scope.getBoundingClientRect(); if (!rect) return false; const containerRect = messagesDiv.getBoundingClientRect(); const targetTop = messagesDiv.scrollTop + rect.top - containerRect.top - ASSISTANT_LAST_SECTION_SCROLL_OFFSET; messagesDiv.scrollTo({ top: Math.max(0, targetTop), behavior: 'smooth' }); target.scope.classList.remove(ASSISTANT_LAST_SECTION_FOCUS_CLASS); requestAnimationFrame(() => { target.scope.classList.add(ASSISTANT_LAST_SECTION_FOCUS_CLASS); window.setTimeout(() => target.scope.classList.remove(ASSISTANT_LAST_SECTION_FOCUS_CLASS), 1100); }); updateScrollbar(); return true; } function createAssistantLastSectionButton() { const button = document.createElement('button'); button.type = 'button'; button.className = ASSISTANT_LAST_SECTION_BUTTON_CLASS; button.title = '定位到本条回复最后一段'; button.setAttribute('aria-label', '定位到本条回复最后一段'); button.innerHTML = ` `; button.addEventListener('click', (event) => { event.preventDefault(); event.stopPropagation(); const bubble = button.closest('.msg-bubble'); scrollAssistantBubbleToLastSection(bubble); }); return button; } function getMessageActionRow(bubble) { if (!bubble) return null; let row = bubble.querySelector(':scope > .msg-action-row'); if (!row) { row = document.createElement('div'); row.className = 'msg-action-row'; bubble.appendChild(row); } return row; } function syncAssistantLastSectionButton(messageEl) { if (!messageEl?.classList?.contains('assistant')) return; const bubble = messageEl.querySelector(':scope > .msg-bubble'); if (!bubble) return; let button = bubble.querySelector(`:scope .${ASSISTANT_LAST_SECTION_BUTTON_CLASS}`); const hasTarget = !!getAssistantLastSectionTarget(bubble); if (!button && !hasTarget) return; if (!button) button = createAssistantLastSectionButton(); button.hidden = !hasTarget; button.disabled = !hasTarget; getMessageActionRow(bubble)?.appendChild(button); } function createAssistantBranchButton(messageIndex) { const button = document.createElement('button'); button.type = 'button'; button.className = ASSISTANT_BRANCH_BUTTON_CLASS; button.title = '从这里分支新会话'; button.setAttribute('aria-label', '从这里分支新会话'); button.dataset.messageIndex = String(messageIndex); button.innerHTML = ` `; button.addEventListener('click', (event) => { event.preventDefault(); event.stopPropagation(); const index = Number.parseInt(button.dataset.messageIndex || '', 10); branchFromAssistantMessage(index); }); return button; } function syncAssistantBranchButton(messageEl, messageIndex) { if (!messageEl?.classList?.contains('assistant')) return; if (!currentSessionId || !Number.isFinite(messageIndex) || messageIndex < 0) return; const bubble = messageEl.querySelector(':scope > .msg-bubble'); if (!bubble) return; let button = bubble.querySelector(`:scope .${ASSISTANT_BRANCH_BUTTON_CLASS}`); if (!button) button = createAssistantBranchButton(messageIndex); button.dataset.messageIndex = String(messageIndex); getMessageActionRow(bubble)?.appendChild(button); } function markSessionMessageElement(messageEl, messageIndex) { if (!messageEl || !Number.isFinite(messageIndex) || messageIndex < 0) return; messageEl.dataset.sessionMessage = 'true'; messageEl.dataset.messageIndex = String(messageIndex); if (messageEl.classList.contains('assistant')) { syncAssistantBranchButton(messageEl, messageIndex); } } function updateSessionIdBadge() { if (!chatSessionIdBtn) return; if (!currentSessionId) { chatSessionIdBtn.hidden = true; chatSessionIdBtn.textContent = 'ID'; chatSessionIdBtn.title = '复制当前会话 ID'; return; } chatSessionIdBtn.hidden = false; chatSessionIdBtn.textContent = `ID ${shortSessionId(currentSessionId)}`; chatSessionIdBtn.title = `复制当前会话 ID\n${currentSessionId}`; chatSessionIdBtn.setAttribute('aria-label', `复制当前会话 ID ${currentSessionId}`); } function shouldPreferTextareaCopy() { const ua = navigator.userAgent || ''; return /iPad|iPhone|iPod/.test(ua) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1); } function copyTextWithTextarea(value) { const textarea = document.createElement('textarea'); textarea.value = value; textarea.setAttribute('readonly', ''); textarea.setAttribute('aria-hidden', 'true'); textarea.style.position = 'fixed'; textarea.style.top = '0'; textarea.style.left = '0'; textarea.style.width = '1px'; textarea.style.height = '1px'; textarea.style.padding = '0'; textarea.style.border = '0'; textarea.style.opacity = '0'; textarea.style.pointerEvents = 'none'; textarea.style.fontSize = '16px'; document.body.appendChild(textarea); const activeElement = document.activeElement; try { try { textarea.focus({ preventScroll: true }); } catch { textarea.focus(); } textarea.select(); try { textarea.setSelectionRange(0, value.length); } catch {} if (!document.execCommand('copy')) throw new Error('copy_failed'); } finally { textarea.remove(); if (activeElement && typeof activeElement.focus === 'function') { try { activeElement.focus({ preventScroll: true }); } catch {} } } } async function copyTextToClipboard(text, successText = '已复制') { const value = String(text || ''); if (!value) return false; try { let copied = false; const preferTextarea = shouldPreferTextareaCopy(); if (preferTextarea) { try { copyTextWithTextarea(value); copied = true; } catch {} } if (!copied && navigator.clipboard?.writeText && window.isSecureContext) { try { await navigator.clipboard.writeText(value); copied = true; } catch {} } if (!copied) copyTextWithTextarea(value); showToast(successText); return true; } catch { showToast('复制失败'); return false; } } function deepClone(value) { if (value === null || value === undefined) return value; return JSON.parse(JSON.stringify(value)); } function cloneMessages(messages) { return Array.isArray(messages) ? deepClone(messages) : []; } function estimateSessionMessageWeight(message) { const content = typeof message?.content === 'string' ? message.content.length : JSON.stringify(message?.content || '').length; const toolCalls = Array.isArray(message?.toolCalls) ? JSON.stringify(message.toolCalls).length : 0; return content + toolCalls + 64; } function estimateSessionSnapshotWeight(snapshot) { const base = JSON.stringify({ title: snapshot.title || '', mode: snapshot.mode || '', model: snapshot.model || '', agent: snapshot.agent || '', cwd: snapshot.cwd || '', updated: snapshot.updated || '', }).length; return base + (snapshot.messages || []).reduce((sum, message) => sum + estimateSessionMessageWeight(message), 0); } function normalizeSessionSnapshot(payload, options = {}) { const sessionId = payload.sessionId || payload.id || ''; const messages = cloneMessages(payload.messages || []); const historyTotal = Number.isFinite(Number(payload.historyTotal)) ? Math.max(0, Number(payload.historyTotal)) : messages.length; const historyBaseIndex = Number.isFinite(Number(payload.historyBaseIndex)) ? Math.max(0, Number(payload.historyBaseIndex)) : Math.max(0, historyTotal - messages.length); return { sessionId, id: sessionId, messages, title: payload.title || '新会话', mode: payload.mode || 'yolo', model: payload.model || '', agent: normalizeAgent(payload.agent), pinnedAt: payload.pinnedAt || null, hasUnread: !!payload.hasUnread, cwd: payload.cwd || null, projectName: payload.projectName || '', oversized: !!payload.oversized, fileBytes: Number(payload.fileBytes || 0), totalCost: typeof payload.totalCost === 'number' ? payload.totalCost : 0, totalUsage: payload.totalUsage ? deepClone(payload.totalUsage) : null, updated: payload.updated || null, isRunning: !!payload.isRunning, waitingOnChildren: !!payload.waitingOnChildren, pendingReplyCount: Number(payload.pendingReplyCount || 0), readyReplyCount: Number(payload.readyReplyCount || 0), waitingReplyCount: Number(payload.waitingReplyCount || 0), failedReplyCount: Number(payload.failedReplyCount || 0), pendingReplies: Array.isArray(payload.pendingReplies) ? deepClone(payload.pendingReplies) : [], historyTotal, historyBaseIndex, historyPending: !!payload.historyPending, complete: options.complete !== undefined ? !!options.complete : !payload.historyPending, }; } function touchSessionCache(sessionId) { const entry = sessionCache.get(sessionId); if (entry) entry.lastUsed = Date.now(); } function invalidateSessionCache(sessionId) { if (!sessionId) return; sessionCache.delete(sessionId); } function pruneSessionCache() { let totalWeight = 0; for (const entry of sessionCache.values()) totalWeight += entry.weight || 0; while (sessionCache.size > SESSION_CACHE_LIMIT || totalWeight > SESSION_CACHE_MAX_WEIGHT) { let oldestId = null; let oldestTs = Infinity; for (const [sessionId, entry] of sessionCache) { if ((entry.lastUsed || 0) < oldestTs) { oldestTs = entry.lastUsed || 0; oldestId = sessionId; } } if (!oldestId) break; totalWeight -= sessionCache.get(oldestId)?.weight || 0; sessionCache.delete(oldestId); } } function cacheSessionSnapshot(snapshot) { if (!snapshot?.sessionId || !snapshot.complete) return; const cachedSnapshot = deepClone(snapshot); const weight = estimateSessionSnapshotWeight(cachedSnapshot); if (weight > SESSION_CACHE_MAX_WEIGHT) { invalidateSessionCache(cachedSnapshot.sessionId); return; } const meta = getSessionMeta(cachedSnapshot.sessionId); sessionCache.set(cachedSnapshot.sessionId, { snapshot: cachedSnapshot, version: cachedSnapshot.updated || null, meta: meta ? deepClone(meta) : null, weight, lastUsed: Date.now(), }); pruneSessionCache(); } function updateCachedSession(sessionId, updater) { const entry = sessionCache.get(sessionId); if (!entry) return; const nextSnapshot = deepClone(entry.snapshot); updater(nextSnapshot); entry.snapshot = nextSnapshot; entry.weight = estimateSessionSnapshotWeight(nextSnapshot); entry.lastUsed = Date.now(); if (nextSnapshot.updated) entry.version = nextSnapshot.updated; pruneSessionCache(); } function reconcileSessionCacheWithSessions() { const knownIds = new Set(sessions.map((session) => session.id)); for (const [sessionId, entry] of sessionCache) { if (!knownIds.has(sessionId)) { sessionCache.delete(sessionId); continue; } const meta = getSessionMeta(sessionId); entry.meta = meta ? deepClone(meta) : null; } } function mergeSessionListSnapshot(snapshot) { if (!snapshot?.sessionId) return; const nextMeta = { id: snapshot.sessionId, sessionId: snapshot.sessionId, cwd: snapshot.cwd || '', projectName: snapshot.cwd ? getPathLeaf(snapshot.cwd) : snapshot.projectName || '', title: snapshot.title || '新会话', agent: normalizeAgent(snapshot.agent), updated: snapshot.updated || new Date().toISOString(), pinnedAt: snapshot.pinnedAt || null, hasUnread: !!snapshot.hasUnread, isRunning: !!snapshot.isRunning, waitingOnChildren: !!snapshot.waitingOnChildren, pendingReplyCount: Number(snapshot.pendingReplyCount || 0), readyReplyCount: Number(snapshot.readyReplyCount || 0), waitingReplyCount: Number(snapshot.waitingReplyCount || 0), failedReplyCount: Number(snapshot.failedReplyCount || 0), oversized: !!snapshot.oversized, fileBytes: Number(snapshot.fileBytes || 0), }; let found = false; sessions = sessions.map((session) => { if (session.id !== snapshot.sessionId) return session; found = true; return { ...session, ...nextMeta, cwd: nextMeta.cwd || session.cwd || '', projectName: nextMeta.cwd ? getPathLeaf(nextMeta.cwd) : session.projectName || nextMeta.projectName || '', title: nextMeta.title || session.title, }; }); if (!found) { sessions = [nextMeta, ...sessions].sort(compareSessionUpdatedDesc); } } function getSessionCacheDisposition(sessionId) { const entry = sessionCache.get(sessionId); const meta = getSessionMeta(sessionId); if (!entry?.snapshot?.complete || !meta) return 'miss'; if (entry.version === (meta.updated || null) && !meta.hasUnread && !meta.isRunning && !meta.waitingOnChildren) { return 'strong'; } return 'weak'; } function buildCachedSessionSnapshot(sessionId) { const entry = sessionCache.get(sessionId); if (!entry?.snapshot) return null; const snapshot = deepClone(entry.snapshot); const meta = getSessionMeta(sessionId) || entry.meta; if (meta) { snapshot.title = meta.title || snapshot.title; snapshot.agent = normalizeAgent(meta.agent || snapshot.agent); snapshot.hasUnread = !!meta.hasUnread; snapshot.updated = meta.updated || snapshot.updated; snapshot.pinnedAt = meta.pinnedAt || null; snapshot.isRunning = !!meta.isRunning; snapshot.waitingOnChildren = !!meta.waitingOnChildren; snapshot.pendingReplyCount = Number(meta.pendingReplyCount || 0); snapshot.readyReplyCount = Number(meta.readyReplyCount || 0); snapshot.waitingReplyCount = Number(meta.waitingReplyCount || 0); snapshot.failedReplyCount = Number(meta.failedReplyCount || 0); } return snapshot; } function formatFileSize(bytes) { const size = Number(bytes) || 0; if (size < 1024) return `${size}B`; if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)}KB`; return `${(size / (1024 * 1024)).toFixed(1)}MB`; } function normalizeBrowserPath(input) { return String(input || '') .replace(/\\/g, '/') .split('/') .filter((part) => part && part !== '.') .join('/'); } function getPathLeaf(input) { const normalized = String(input || '').replace(/\\/g, '/').replace(/\/+$/, ''); if (!normalized) return ''; const parts = normalized.split('/'); return parts[parts.length - 1] || normalized; } function getBrowserParentPath(currentPath) { const normalized = normalizeBrowserPath(currentPath); if (!normalized) return ''; const parts = normalized.split('/'); parts.pop(); return parts.join('/'); } function getBrowserDisplayPath(rootPath, currentPath) { const root = String(rootPath || '').replace(/\\/g, '/').replace(/\/+$/, ''); const current = normalizeBrowserPath(currentPath); return current ? `${root}/${current}` : root; } function normalizeAbsoluteDisplayPath(input) { return String(input || '').trim().replace(/\\/g, '/').replace(/\/+$/, ''); } function isAbsoluteDisplayPath(input) { const value = normalizeAbsoluteDisplayPath(input); return value.startsWith('/') || /^[A-Za-z]:\//.test(value); } function getWorkspaceRelativePath(input) { const filePath = normalizeAbsoluteDisplayPath(input); if (!filePath) return ''; if (!isAbsoluteDisplayPath(filePath)) return normalizeBrowserPath(filePath); const rootPath = normalizeAbsoluteDisplayPath(currentCwd); if (!rootPath) return ''; if (filePath === rootPath) return ''; if (!filePath.startsWith(`${rootPath}/`)) return ''; return normalizeBrowserPath(filePath.slice(rootPath.length + 1)); } function parseLocalFileLinkHref(rawHref) { const raw = String(rawHref || '').trim(); if (!raw || raw.startsWith('#')) return null; let decoded = raw; try { decoded = decodeURI(raw); } catch {} if (/^file:\/\//i.test(decoded)) { try { decoded = decodeURI(new URL(decoded).pathname || decoded); } catch {} } else if (/^[A-Za-z][A-Za-z0-9+.-]*:/.test(decoded) && !/^[A-Za-z]:[\\/]/.test(decoded)) { return null; } let line = 0; let hasLineSuffix = false; const hashMatch = decoded.match(/#L?(\d+)(?:\b|$)/i); if (hashMatch) { line = Number(hashMatch[1]) || 0; decoded = decoded.slice(0, hashMatch.index); } decoded = decoded.replace(/\\/g, '/'); const lineMatch = decoded.match(/^(.+?):(\d+)(?::\d+)?$/); if (lineMatch) { decoded = lineMatch[1]; line = Number(lineMatch[2]) || line; hasLineSuffix = true; } const filePath = normalizeAbsoluteDisplayPath(decoded); if (!filePath) return null; if (!isAbsoluteDisplayPath(filePath) && !/^\.{1,2}\//.test(filePath) && !filePath.includes('/') && !hasLineSuffix) return null; return { filePath, line }; } function formatLocalFileLinkText(link, fallbackText) { const rawText = String(fallbackText || '').trim(); const baseText = rawText && rawText !== link.filePath ? rawText : (getPathLeaf(link.filePath) || link.filePath); if (!link.line || new RegExp(`:${link.line}$`).test(baseText)) return baseText; return `${baseText}:${link.line}`; } async function fetchAuthJson(url, options = {}) { await ensureAuthenticatedWs(); const response = await fetch(url, { ...options, headers: { ...(options.headers || {}), Authorization: `Bearer ${authToken}`, }, }); const rawText = await response.text(); let data = null; try { data = rawText ? JSON.parse(rawText) : null; } catch { data = null; } if (response.status === 401) { throw new Error('登录状态已失效,请刷新页面后重新登录。'); } if (!response.ok || data?.ok === false) { throw new Error(data?.message || `请求失败 (${response.status})`); } return data || {}; } function updateReloadMcpButtonUI() { if (!reloadMcpBtn) return; const visible = !!currentSessionId && isCodexAppAgent(currentAgent); reloadMcpBtn.hidden = !visible; reloadMcpBtn.disabled = !visible || isReloadingMcp; reloadMcpBtn.textContent = isReloadingMcp ? '重载中' : '重载 MCP'; reloadMcpBtn.setAttribute('aria-busy', isReloadingMcp ? 'true' : 'false'); } function normalizeMcpStartupStatusPayload(payload) { if (!payload || typeof payload !== 'object') return null; if (payload.mcpStatus && typeof payload.mcpStatus === 'object') return payload.mcpStatus; if (payload.status && typeof payload.status === 'object') return payload.status; return payload; } function mcpStartupStatusToastText(status) { const summary = normalizeMcpStartupStatusPayload(status); if (!summary) return '已请求重载,等待状态'; const server = String(summary.server || summary.name || 'ccweb').trim() || 'ccweb'; const state = String(summary.status || 'unknown').trim().toLowerCase(); const message = String(summary.message || '').trim(); if (state === 'ready') return `${server} MCP 已启动`; if (state === 'failed') return `${server} MCP 启动失败${message ? `:${message}` : ''}`; if (state === 'cancelled' || state === 'canceled') return `${server} MCP 启动已取消${message ? `:${message}` : ''}`; if (state === 'starting') return `${server} MCP 正在启动`; if (state === 'pending' || state === 'unknown') return '已请求重载,等待状态'; return `${server} MCP 状态:${state}`; } function rememberMcpStartupStatus(sessionId, status) { const summary = normalizeMcpStartupStatusPayload(status); if (!sessionId || !summary) return; updateCachedSession(sessionId, (snapshot) => { snapshot.codexAppMcpStartupStatus = deepClone(summary); }); } function showMcpStartupStatusToast(status, sessionId = currentSessionId, options = {}) { const summary = normalizeMcpStartupStatusPayload(status); const text = mcpStartupStatusToastText(summary); const server = String(summary?.server || summary?.name || 'ccweb').trim() || 'ccweb'; const state = String(summary?.status || 'pending').trim().toLowerCase() || 'pending'; if (state === 'ready' && !options.notifyReady) return; if ((state === 'starting' || state === 'pending' || state === 'unknown') && !options.notifyPending) return; const stamp = state === 'failed' || state === 'cancelled' || state === 'canceled' ? String(summary?.message || text || '') : ''; const cacheKey = sessionId || currentSessionId || 'global'; const nextKey = `${server}|${state}|${stamp}`; if (mcpStartupToastKeys.get(cacheKey) === nextKey) return; mcpStartupToastKeys.set(cacheKey, nextKey); showToast(text, sessionId); } async function reloadCurrentMcpServers() { if (!currentSessionId || !isCodexAppAgent(currentAgent) || isReloadingMcp) return; isReloadingMcp = true; updateReloadMcpButtonUI(); try { const data = await fetchAuthJson(`/api/sessions/${encodeURIComponent(currentSessionId)}/reload-mcp`, { method: 'POST', }); rememberMcpStartupStatus(currentSessionId, data.mcpStatus); showMcpStartupStatusToast(data.mcpStatus || { status: 'pending' }, currentSessionId, { notifyReady: true, notifyPending: true, }); } catch (err) { showToast(err?.message || '重载 MCP 失败'); } finally { isReloadingMcp = false; updateReloadMcpButtonUI(); } } function closeCodexAppUserInputModal(sendCancel = false) { if (!codexAppUserInputModal) return; const { overlay, escapeHandler, requestId, sessionId } = codexAppUserInputModal; if (escapeHandler) document.removeEventListener('keydown', escapeHandler); if (overlay && overlay.parentNode) overlay.parentNode.removeChild(overlay); codexAppUserInputModal = null; if (sendCancel && requestId) { send({ type: 'codex_app_user_input_response', action: 'cancel', sessionId, requestId, answers: {}, }); } } function codexAppApprovalPayloadText(payload) { if (payload === null || payload === undefined) return ''; if (typeof payload === 'string') return payload; try { return JSON.stringify(payload, null, 2); } catch { return String(payload); } } function closeCodexAppApprovalModal(sendCancel = false) { if (!codexAppApprovalModal) return; const { overlay, escapeHandler, requestId, sessionId } = codexAppApprovalModal; if (escapeHandler) document.removeEventListener('keydown', escapeHandler); if (overlay && overlay.parentNode) overlay.parentNode.removeChild(overlay); codexAppApprovalModal = null; if (sendCancel && requestId) { send({ type: 'codex_app_approval_response', action: 'cancel', sessionId, requestId, }); } } function submitCodexAppApproval(action) { if (!codexAppApprovalModal) return; const { requestId, sessionId } = codexAppApprovalModal; send({ type: 'codex_app_approval_response', action, sessionId, requestId, }); closeCodexAppApprovalModal(false); } function showCodexAppApprovalModal(msg) { closeCodexAppApprovalModal(true); const payloadText = codexAppApprovalPayloadText(msg.payload); const overlay = document.createElement('div'); overlay.className = 'modal-overlay codex-approval-overlay'; overlay.innerHTML = ` `; document.body.appendChild(overlay); const escapeHandler = (e) => { if (e.key === 'Escape') closeCodexAppApprovalModal(true); }; document.addEventListener('keydown', escapeHandler); codexAppApprovalModal = { overlay, requestId: msg.requestId || '', sessionId: msg.sessionId || '', escapeHandler, }; overlay.querySelectorAll('[data-codex-approval-cancel]').forEach((button) => { button.addEventListener('click', () => closeCodexAppApprovalModal(true)); }); overlay.addEventListener('click', (e) => { if (e.target === overlay) closeCodexAppApprovalModal(true); }); overlay.querySelectorAll('[data-codex-approval-action]').forEach((button) => { button.addEventListener('click', () => submitCodexAppApproval(button.dataset.codexApprovalAction || 'cancel')); }); overlay.querySelector('[data-codex-approval-action="approve"]')?.focus(); } function cssEscape(value) { if (window.CSS && typeof window.CSS.escape === 'function') return window.CSS.escape(String(value || '')); return String(value || '').replace(/["\\]/g, '\\$&'); } function collectCodexAppUserInputAnswers(panel, questions) { const answers = {}; for (const question of questions) { const id = String(question?.id || '').trim(); if (!id) continue; const escapedId = cssEscape(id); const checked = panel.querySelector(`input[name="codex-ui-${escapedId}"]:checked`); const values = []; if (checked) { if (checked.value === '__other__') { const input = panel.querySelector(`[data-codex-ui-other="${escapedId}"]`); const text = String(input?.value || '').trim(); if (text) values.push(text); } else { values.push(checked.value); } } else { const input = panel.querySelector(`[data-codex-ui-other="${escapedId}"]`); const text = String(input?.value || '').trim(); if (text) values.push(text); } answers[id] = { answers: values }; } return answers; } function renderCodexAppQuestion(question, index) { const id = String(question?.id || `q${index}`); const options = Array.isArray(question?.options) ? question.options : []; const hasOther = !!question?.isOther || options.length === 0; const inputType = question?.isSecret ? 'password' : 'text'; const optionHtml = options.map((option, optionIndex) => { const value = String(option?.label || `选项 ${optionIndex + 1}`); return ` `; }).join(''); const otherHtml = hasOther ? ` ` : ''; return `
${escapeHtml(question?.header || `问题 ${index + 1}`)}
${escapeHtml(question?.question || '请选择一个答案。')}
${optionHtml} ${otherHtml}
`; } function showCodexAppUserInputModal(msg) { closeCodexAppUserInputModal(true); const questions = Array.isArray(msg.questions) ? msg.questions : []; const overlay = document.createElement('div'); overlay.className = 'modal-overlay codex-user-input-overlay'; overlay.innerHTML = ` `; document.body.appendChild(overlay); const panel = overlay.querySelector('.codex-user-input-panel'); const escapeHandler = (e) => { if (e.key === 'Escape') closeCodexAppUserInputModal(true); }; document.addEventListener('keydown', escapeHandler); codexAppUserInputModal = { overlay, requestId: msg.requestId || '', sessionId: msg.sessionId || '', escapeHandler, }; overlay.querySelectorAll('[data-codex-ui-cancel]').forEach((button) => { button.addEventListener('click', () => closeCodexAppUserInputModal(true)); }); overlay.addEventListener('click', (e) => { if (e.target === overlay) closeCodexAppUserInputModal(true); }); overlay.querySelector('[data-codex-ui-submit]')?.addEventListener('click', () => { send({ type: 'codex_app_user_input_response', action: 'submit', sessionId: msg.sessionId, requestId: msg.requestId, answers: collectCodexAppUserInputAnswers(panel, questions), }); closeCodexAppUserInputModal(false); }); panel.querySelectorAll('.codex-user-input-text').forEach((input) => { input.addEventListener('focus', () => { const radio = input.closest('.codex-user-input-option')?.querySelector('input[type="radio"]'); if (radio) radio.checked = true; }); }); panel.querySelector('input, button')?.focus(); } function ccwebPromptStatusLabel(status) { if (status === 'submitted') return '已提交'; if (status === 'cancelled') return '已取消'; return '待回答'; } function ccwebPromptRecommendedOption(question) { const options = Array.isArray(question?.options) ? question.options : []; return options.find((option) => option?.recommended) || options[0] || null; } function getCcwebPromptViewMode() { return localStorage.getItem(CCWEB_PROMPT_VIEW_MODE_STORAGE_KEY) === 'tabs' ? 'tabs' : 'cards'; } function setCcwebPromptActiveQuestion(card, index) { if (!card) return; const questions = Array.from(card.querySelectorAll('.ccweb-prompt-question')); if (questions.length === 0) return; const activeIndex = Math.min(Math.max(Number(index) || 0, 0), questions.length - 1); card.dataset.activeQuestionIndex = String(activeIndex); questions.forEach((questionEl, questionIndex) => { const isActive = questionIndex === activeIndex; questionEl.classList.toggle('is-active', isActive); questionEl.setAttribute('aria-hidden', isActive ? 'false' : 'true'); }); card.querySelectorAll('.ccweb-prompt-tab').forEach((tab, tabIndex) => { const isActive = tabIndex === activeIndex; tab.classList.toggle('is-active', isActive); tab.setAttribute('aria-selected', isActive ? 'true' : 'false'); tab.tabIndex = isActive ? 0 : -1; }); const counter = card.querySelector('.ccweb-prompt-tab-counter'); if (counter) counter.textContent = `问题 ${activeIndex + 1} / ${questions.length}`; const prev = card.querySelector('[data-ccweb-prompt-prev]'); const next = card.querySelector('[data-ccweb-prompt-next]'); if (prev) prev.disabled = activeIndex <= 0; if (next) next.disabled = activeIndex >= questions.length - 1; } function setCcwebPromptViewMode(card, mode) { if (!card) return; const normalized = mode === 'tabs' ? 'tabs' : 'cards'; card.dataset.viewMode = normalized; card.querySelectorAll('.ccweb-prompt-view-btn').forEach((button) => { const isActive = button.dataset.viewMode === normalized; button.classList.toggle('is-active', isActive); button.setAttribute('aria-pressed', isActive ? 'true' : 'false'); }); if (normalized === 'tabs') { setCcwebPromptActiveQuestion(card, Number(card.dataset.activeQuestionIndex || 0)); } else { card.querySelectorAll('.ccweb-prompt-question').forEach((questionEl) => { questionEl.setAttribute('aria-hidden', 'false'); }); } } function createCcwebPromptViewControls(card, questions) { const switcher = document.createElement('div'); switcher.className = 'ccweb-prompt-view-switcher'; switcher.setAttribute('aria-label', '表单显示方式'); [ { mode: 'cards', label: '▦', title: '卡片视图' }, { mode: 'tabs', label: '▤', title: '页签视图' }, ].forEach((item) => { const button = document.createElement('button'); button.type = 'button'; button.className = 'ccweb-prompt-view-btn'; button.dataset.viewMode = item.mode; button.textContent = item.label; button.title = item.title; button.setAttribute('aria-label', item.title); button.addEventListener('click', () => { localStorage.setItem(CCWEB_PROMPT_VIEW_MODE_STORAGE_KEY, item.mode); setCcwebPromptViewMode(card, item.mode); }); switcher.appendChild(button); }); return switcher; } function createCcwebPromptTabs(card, questions) { const controls = document.createElement('div'); controls.className = 'ccweb-prompt-view-controls'; const tabs = document.createElement('div'); tabs.className = 'ccweb-prompt-tabs'; tabs.setAttribute('role', 'tablist'); questions.forEach((question, index) => { const tab = document.createElement('button'); tab.type = 'button'; tab.className = 'ccweb-prompt-tab'; tab.setAttribute('role', 'tab'); tab.textContent = question.title || `问题 ${index + 1}`; tab.addEventListener('click', () => setCcwebPromptActiveQuestion(card, index)); tabs.appendChild(tab); }); controls.append(tabs); return controls; } function createCcwebPromptTabNav(card, questions) { const nav = document.createElement('div'); nav.className = 'ccweb-prompt-tab-nav'; if (!Array.isArray(questions) || questions.length <= 1) return nav; const prev = document.createElement('button'); prev.type = 'button'; prev.className = 'ccweb-prompt-tab-nav-btn'; prev.dataset.ccwebPromptPrev = '1'; prev.textContent = '上一个'; prev.addEventListener('click', () => setCcwebPromptActiveQuestion(card, Number(card.dataset.activeQuestionIndex || 0) - 1)); const counter = document.createElement('span'); counter.className = 'ccweb-prompt-tab-counter'; const next = document.createElement('button'); next.type = 'button'; next.className = 'ccweb-prompt-tab-nav-btn'; next.dataset.ccwebPromptNext = '1'; next.textContent = '下一个'; next.addEventListener('click', () => setCcwebPromptActiveQuestion(card, Number(card.dataset.activeQuestionIndex || 0) + 1)); nav.append(prev, counter, next); return nav; } function setCcwebPromptError(card, message) { const error = card.querySelector('.ccweb-prompt-error'); if (!error) return; error.textContent = message || ''; error.hidden = !message; } function updateCcwebPromptAnswerFromSelection(questionEl, question) { const textarea = questionEl.querySelector('.ccweb-prompt-answer'); if (!textarea) return; const selectedIds = Array.from(questionEl.querySelectorAll('.ccweb-prompt-option.is-selected')) .map((button) => button.dataset.optionId || '') .filter(Boolean); const selectedOptions = (question.options || []).filter((option) => selectedIds.includes(option.id)); const answerText = selectedOptions.map((option) => option.answerText || option.label || '').filter(Boolean).join('\n'); if (answerText) textarea.value = answerText; } function selectCcwebPromptOption(questionEl, question, optionId) { const buttons = Array.from(questionEl.querySelectorAll('.ccweb-prompt-option')); if (question.selectionMode === 'multi') { buttons.forEach((button) => { if (button.dataset.optionId === optionId) button.classList.toggle('is-selected'); }); } else { buttons.forEach((button) => { button.classList.toggle('is-selected', button.dataset.optionId === optionId); }); } updateCcwebPromptAnswerFromSelection(questionEl, question); } function createCcwebPromptQuestionElement(question, index, prompt) { const questionEl = document.createElement('section'); questionEl.className = 'ccweb-prompt-question'; questionEl.dataset.questionId = question.id || `question_${index + 1}`; const head = document.createElement('div'); head.className = 'ccweb-prompt-question-head'; const title = document.createElement('div'); title.className = 'ccweb-prompt-question-title'; title.textContent = question.title || `问题 ${index + 1}`; head.appendChild(title); if (question.required !== false && prompt.status !== 'submitted') { const required = document.createElement('span'); required.className = 'ccweb-prompt-required'; required.textContent = '必答'; head.appendChild(required); } questionEl.appendChild(head); if (question.question) { const body = document.createElement('div'); body.className = 'ccweb-prompt-question-body'; body.textContent = question.question; questionEl.appendChild(body); } if (prompt.status === 'submitted') { const answer = prompt.answers?.[question.id] || {}; if (Array.isArray(answer.selectedOptionLabels) && answer.selectedOptionLabels.length > 0) { const selected = document.createElement('div'); selected.className = 'ccweb-prompt-selected-readonly'; selected.textContent = `选择:${answer.selectedOptionLabels.join(',')}`; questionEl.appendChild(selected); } const answerText = document.createElement('div'); answerText.className = 'ccweb-prompt-answer-readonly'; answerText.textContent = answer.answerText || '(未填写答案)'; questionEl.appendChild(answerText); return questionEl; } const options = Array.isArray(question.options) ? question.options : []; if (options.length > 0 && question.selectionMode !== 'none') { const optionList = document.createElement('div'); optionList.className = 'ccweb-prompt-options'; options.forEach((option) => { const button = document.createElement('button'); button.type = 'button'; button.className = 'ccweb-prompt-option'; button.dataset.optionId = option.id || ''; const label = document.createElement('span'); label.className = 'ccweb-prompt-option-label'; label.textContent = option.label || option.id || '选项'; button.appendChild(label); if (option.recommended) { const badge = document.createElement('span'); badge.className = 'ccweb-prompt-option-badge'; badge.textContent = '推荐'; button.appendChild(badge); } if (option.description) { const desc = document.createElement('span'); desc.className = 'ccweb-prompt-option-desc'; desc.textContent = option.description; button.appendChild(desc); } button.addEventListener('click', () => selectCcwebPromptOption(questionEl, question, option.id)); optionList.appendChild(button); }); questionEl.appendChild(optionList); } const answer = document.createElement('textarea'); answer.className = 'ccweb-prompt-answer'; answer.rows = 4; answer.placeholder = question.answerPlaceholder || '填写你的答案...'; answer.value = question.defaultAnswer || ''; questionEl.appendChild(answer); const recommended = ccwebPromptRecommendedOption(question); if (recommended?.recommended) { selectCcwebPromptOption(questionEl, question, recommended.id); } return questionEl; } function collectCcwebPromptAnswers(card, prompt) { const answers = {}; for (const question of prompt.questions || []) { const escapedId = cssEscape(question.id || ''); const questionEl = card.querySelector(`.ccweb-prompt-question[data-question-id="${escapedId}"]`); if (!questionEl) continue; const selectedOptionIds = Array.from(questionEl.querySelectorAll('.ccweb-prompt-option.is-selected')) .map((button) => button.dataset.optionId || '') .filter(Boolean); const answerText = String(questionEl.querySelector('.ccweb-prompt-answer')?.value || '').trim(); if (question.required !== false && !answerText) { return { ok: false, message: `请填写「${question.title || question.id}」的答案。` }; } answers[question.id] = { selectedOptionIds, answerText }; } return { ok: true, answers }; } function createCcwebPromptElement(prompt, meta = {}) { const card = document.createElement('section'); const promptStatus = prompt?.status || 'pending'; const questions = Array.isArray(prompt?.questions) ? prompt.questions : []; card.className = 'ccweb-prompt-card'; card.dataset.promptId = prompt?.id || ''; card.dataset.status = promptStatus; card.dataset.viewMode = 'cards'; const header = document.createElement('div'); header.className = 'ccweb-prompt-header'; const titleWrap = document.createElement('div'); titleWrap.className = 'ccweb-prompt-title-wrap'; const title = document.createElement('div'); title.className = 'ccweb-prompt-title'; title.textContent = prompt?.title || '需要用户确认'; titleWrap.appendChild(title); header.appendChild(titleWrap); const headerActions = document.createElement('div'); headerActions.className = 'ccweb-prompt-header-actions'; const status = document.createElement('span'); status.className = 'ccweb-prompt-status'; status.textContent = promptStatus === 'pending' ? '●' : ccwebPromptStatusLabel(prompt?.status || 'pending'); status.title = ccwebPromptStatusLabel(prompt?.status || 'pending'); status.setAttribute('aria-label', ccwebPromptStatusLabel(prompt?.status || 'pending')); headerActions.appendChild(status); if (promptStatus === 'pending' && questions.length > 1) { headerActions.appendChild(createCcwebPromptViewControls(card, questions)); } header.appendChild(headerActions); card.appendChild(header); if (prompt?.description) { const desc = document.createElement('div'); desc.className = 'ccweb-prompt-desc'; desc.textContent = prompt.description; card.appendChild(desc); } if (promptStatus === 'pending' && questions.length > 1) { card.appendChild(createCcwebPromptTabs(card, questions)); } const questionsWrap = document.createElement('div'); questionsWrap.className = 'ccweb-prompt-questions'; questions.forEach((question, index) => { questionsWrap.appendChild(createCcwebPromptQuestionElement(question, index, prompt)); }); card.appendChild(questionsWrap); const error = document.createElement('div'); error.className = 'ccweb-prompt-error'; error.hidden = true; card.appendChild(error); if ((prompt?.status || 'pending') === 'pending') { const footer = document.createElement('div'); footer.className = 'ccweb-prompt-footer'; if (questions.length > 1) { footer.appendChild(createCcwebPromptTabNav(card, questions)); } const footerActions = document.createElement('div'); footerActions.className = 'ccweb-prompt-footer-actions'; const fillRecommended = document.createElement('button'); fillRecommended.type = 'button'; fillRecommended.className = 'ccweb-prompt-secondary'; fillRecommended.textContent = '填入推荐'; fillRecommended.addEventListener('click', () => { questions.forEach((question) => { const option = ccwebPromptRecommendedOption(question); if (!option) return; const questionEl = card.querySelector(`.ccweb-prompt-question[data-question-id="${cssEscape(question.id || '')}"]`); if (questionEl) selectCcwebPromptOption(questionEl, question, option.id); }); }); footerActions.appendChild(fillRecommended); const submit = document.createElement('button'); submit.type = 'button'; submit.className = 'ccweb-prompt-submit'; submit.textContent = '提交全部'; submit.addEventListener('click', () => { const collected = collectCcwebPromptAnswers(card, prompt); if (!collected.ok) { setCcwebPromptError(card, collected.message); return; } setCcwebPromptError(card, ''); submit.disabled = true; submit.textContent = '提交中'; send({ type: 'ccweb_prompt_user_response', sessionId: meta.sessionId || currentSessionId, promptId: prompt.id, answers: collected.answers, }); }); footerActions.appendChild(submit); footer.appendChild(footerActions); card.appendChild(footer); } if (promptStatus === 'pending' && questions.length > 1) { setCcwebPromptActiveQuestion(card, 0); setCcwebPromptViewMode(card, getCcwebPromptViewMode()); } return card; } function updateCcwebPromptMessageInSnapshot(snapshot, prompt) { if (!snapshot || !Array.isArray(snapshot.messages) || !prompt?.id) return; for (const message of snapshot.messages) { if (message?.ccwebPrompt?.id === prompt.id) { message.ccwebPrompt = deepClone(prompt); } } } function removeCcwebPromptMessageFromSnapshot(snapshot, promptId) { if (!snapshot || !Array.isArray(snapshot.messages) || !promptId) return; snapshot.messages = snapshot.messages.filter((message) => message?.ccwebPrompt?.id !== promptId); } function removeCcwebPromptMessageFromDom(promptId) { if (!promptId) return 0; let removed = 0; document.querySelectorAll(`.ccweb-prompt-card[data-prompt-id="${cssEscape(promptId)}"]`).forEach((card) => { const messageEl = card.closest('.msg'); if (messageEl?.parentNode) { messageEl.remove(); } else { card.remove(); } removed += 1; }); if (removed > 0) { updateUserOutlinePanel(); updateScrollbar(); } return removed; } function applyCcwebPromptUserUpdate(msg) { if (msg.sessionId && msg.prompt) { updateCachedSession(msg.sessionId, (snapshot) => updateCcwebPromptMessageInSnapshot(snapshot, msg.prompt)); } if (msg.sessionId !== currentSessionId || !msg.prompt?.id) return; document.querySelectorAll(`.ccweb-prompt-card[data-prompt-id="${cssEscape(msg.prompt.id)}"]`).forEach((card) => { card.replaceWith(createCcwebPromptElement(msg.prompt, { sessionId: msg.sessionId })); }); renderPendingCcwebPrompts({ scroll: false }); } function applyCcwebPromptUserRemove(msg) { const promptId = msg.promptId || msg.prompt?.id || ''; if (msg.sessionId && promptId) { updateCachedSession(msg.sessionId, (snapshot) => removeCcwebPromptMessageFromSnapshot(snapshot, promptId)); } if (msg.sessionId !== currentSessionId || !promptId) return; removeCcwebPromptMessageFromDom(promptId); renderPendingCcwebPrompts({ scroll: false }); } function closeDirectoryPicker() { if (!directoryPickerState) return; const { overlay, escapeHandler } = directoryPickerState; if (escapeHandler) document.removeEventListener('keydown', escapeHandler); if (overlay && overlay.parentNode) overlay.parentNode.removeChild(overlay); directoryPickerState = null; } function setDirectoryPickerStatus(message, type = '') { if (!directoryPickerState?.statusEl) return; directoryPickerState.statusEl.textContent = message || ''; directoryPickerState.statusEl.dataset.state = type || ''; } function updateDirectoryPickerPathBar() { if (!directoryPickerState?.pathEl) return; const displayPath = directoryPickerState.currentPath || directoryPickerState.defaultPath || ''; directoryPickerState.pathEl.textContent = displayPath; directoryPickerState.pathEl.title = displayPath; directoryPickerState.upBtn.disabled = !directoryPickerState.parentPath; directoryPickerState.chooseBtn.disabled = !displayPath; } function renderDirectoryPickerEntries(entries) { if (!directoryPickerState?.listEl) return; const safeEntries = Array.isArray(entries) ? entries : []; if (safeEntries.length === 0) { directoryPickerState.listEl.innerHTML = ''; return; } directoryPickerState.listEl.innerHTML = safeEntries.map((entry) => { const metaParts = [entry.symlink ? '链接目录' : '目录']; if (entry.updatedAt) metaParts.push(timeAgo(entry.updatedAt)); return ` `; }).join(''); directoryPickerState.listEl.querySelectorAll('.file-browser-item').forEach((button) => { button.addEventListener('click', () => { const targetPath = button.dataset.path || ''; if (targetPath) loadDirectoryPickerDirectory(targetPath); }); }); } async function loadDirectoryPickerDirectory(targetPath, options = {}) { if (!directoryPickerState) return; const state = directoryPickerState; const requestId = ++state.requestId; state.listEl.innerHTML = ''; setDirectoryPickerStatus('正在读取目录…'); try { const data = await fetchAuthJson(`/api/fs/directories?path=${encodeURIComponent(targetPath || '')}`); if (!directoryPickerState || state !== directoryPickerState || requestId !== state.requestId) return; state.currentPath = data.currentPath || state.currentPath; state.parentPath = data.parentPath || ''; state.defaultPath = data.defaultPath || state.defaultPath; updateDirectoryPickerPathBar(); renderDirectoryPickerEntries(data.entries || []); const statusParts = []; if (data.truncated) statusParts.push(`目录较大,仅显示前 ${data.entryLimit} 项`); statusParts.push(`当前共 ${Array.isArray(data.entries) ? data.entries.length : 0} 个子目录`); setDirectoryPickerStatus(statusParts.join(' · ')); } catch (err) { if (!directoryPickerState || state !== directoryPickerState || requestId !== state.requestId) return; if (options.allowFallback !== false && targetPath) { loadDirectoryPickerDirectory('', { allowFallback: false }); return; } state.listEl.innerHTML = ``; setDirectoryPickerStatus(err.message || '目录读取失败', 'error'); } } function showDirectoryPicker(options = {}) { closeDirectoryPicker(); const overlay = document.createElement('div'); overlay.className = 'modal-overlay'; overlay.id = 'directory-picker-overlay'; overlay.innerHTML = ` `; document.body.appendChild(overlay); directoryPickerState = { overlay, pathEl: overlay.querySelector('[data-picker-path]'), statusEl: overlay.querySelector('[data-picker-status]'), listEl: overlay.querySelector('[data-picker-list]'), upBtn: overlay.querySelector('[data-picker-up]'), refreshBtn: overlay.querySelector('[data-picker-refresh]'), chooseBtn: overlay.querySelector('[data-picker-choose]'), currentPath: '', parentPath: '', defaultPath: '', requestId: 0, onChoose: typeof options.onChoose === 'function' ? options.onChoose : null, escapeHandler: null, }; directoryPickerState.escapeHandler = (e) => { if (e.key === 'Escape') closeDirectoryPicker(); }; document.addEventListener('keydown', directoryPickerState.escapeHandler); const closeButtons = overlay.querySelectorAll('[data-picker-close], [data-picker-cancel]'); closeButtons.forEach((button) => button.addEventListener('click', closeDirectoryPicker)); overlay.addEventListener('click', (e) => { if (e.target === overlay) closeDirectoryPicker(); }); directoryPickerState.upBtn.addEventListener('click', () => { if (directoryPickerState?.parentPath) loadDirectoryPickerDirectory(directoryPickerState.parentPath, { allowFallback: false }); }); directoryPickerState.refreshBtn.addEventListener('click', () => { loadDirectoryPickerDirectory(directoryPickerState?.currentPath || '', { allowFallback: false }); }); directoryPickerState.chooseBtn.addEventListener('click', () => { const selectedPath = directoryPickerState?.currentPath || directoryPickerState?.defaultPath || ''; const onChoose = directoryPickerState?.onChoose; closeDirectoryPicker(); if (selectedPath && typeof onChoose === 'function') onChoose(selectedPath); }); updateDirectoryPickerPathBar(); loadDirectoryPickerDirectory(String(options.initialPath || '').trim(), { allowFallback: true }); } function closeFileBrowser() { if (!fileBrowserState) return; const { overlay, escapeHandler } = fileBrowserState; if (escapeHandler) document.removeEventListener('keydown', escapeHandler); if (overlay && overlay.parentNode) overlay.parentNode.removeChild(overlay); fileBrowserState = null; } function setFileBrowserStatus(message, type = '') { if (!fileBrowserState?.statusEl) return; fileBrowserState.statusEl.textContent = message || ''; fileBrowserState.statusEl.dataset.state = type || ''; } function setFileBrowserPreviewMode(active) { if (!fileBrowserState?.panel) return; fileBrowserState.panel.classList.toggle('preview-active', !!active); } function syncFileBrowserSelection() { if (!fileBrowserState?.listEl) return; fileBrowserState.listEl.querySelectorAll('.file-browser-item').forEach((button) => { button.classList.toggle( 'active', button.dataset.kind === 'file' && button.dataset.path === fileBrowserState.selectedFilePath ); }); } function renderFileBrowserPreviewEmpty(title, message) { if (!fileBrowserState) return; fileBrowserState.previewTitleEl.textContent = title || '文件预览'; fileBrowserState.previewMetaEl.textContent = message || '选择一个文本文件查看内容'; fileBrowserState.previewEmptyEl.textContent = message || '选择一个文本文件查看内容'; fileBrowserState.previewEmptyEl.hidden = false; fileBrowserState.previewCodeEl.hidden = true; fileBrowserState.previewCodeEl.textContent = ''; } function renderFileBrowserPreviewLoading(name) { if (!fileBrowserState) return; fileBrowserState.previewTitleEl.textContent = name || '文件预览'; fileBrowserState.previewMetaEl.textContent = '正在读取文件内容…'; fileBrowserState.previewEmptyEl.textContent = '正在读取文件内容…'; fileBrowserState.previewEmptyEl.hidden = false; fileBrowserState.previewCodeEl.hidden = true; fileBrowserState.previewCodeEl.textContent = ''; } function updateFileBrowserPathBar() { if (!fileBrowserState) return; const displayPath = getBrowserDisplayPath(fileBrowserState.rootPath, fileBrowserState.currentPath); fileBrowserState.pathEl.textContent = displayPath; fileBrowserState.pathEl.title = displayPath; fileBrowserState.upBtn.disabled = !fileBrowserState.currentPath; } function renderFileBrowserDirectory(entries) { if (!fileBrowserState) return; const state = fileBrowserState; const safeEntries = Array.isArray(entries) ? entries : []; if (safeEntries.length === 0) { state.listEl.innerHTML = ''; return; } state.listEl.innerHTML = safeEntries.map((entry) => { const metaParts = []; if (entry.kind === 'directory') { metaParts.push(entry.symlink ? '链接目录' : '目录'); } else { metaParts.push(entry.previewableHint ? '文本' : '文件'); if (entry.size >= 0) metaParts.push(formatFileSize(entry.size)); } if (entry.updatedAt) metaParts.push(timeAgo(entry.updatedAt)); return ` `; }).join(''); state.listEl.querySelectorAll('.file-browser-item').forEach((button) => { button.addEventListener('click', () => { const itemPath = normalizeBrowserPath(button.dataset.path || ''); if (button.dataset.kind === 'directory') { loadFileBrowserDirectory(itemPath); return; } openFileBrowserFile(itemPath); }); }); syncFileBrowserSelection(); } async function loadFileBrowserDirectory(targetPath, options = {}) { if (!fileBrowserState) return; const state = fileBrowserState; const normalizedPath = normalizeBrowserPath(targetPath); const previousPath = state.currentPath; const requestId = ++state.directoryRequestId; state.currentPath = normalizedPath; updateFileBrowserPathBar(); state.listEl.innerHTML = ''; setFileBrowserStatus('正在读取目录…'); if (!options.preservePreview) { state.selectedFilePath = ''; syncFileBrowserSelection(); renderFileBrowserPreviewEmpty('文件预览', '选择一个文本文件查看内容'); setFileBrowserPreviewMode(false); } try { const data = await fetchAuthJson(`/api/fs/list?sessionId=${encodeURIComponent(state.sessionId)}&path=${encodeURIComponent(normalizedPath)}`); if (!fileBrowserState || state !== fileBrowserState || requestId !== state.directoryRequestId) return; state.rootPath = data.rootPath || state.rootPath; state.currentPath = normalizeBrowserPath(data.currentPath || ''); updateFileBrowserPathBar(); renderFileBrowserDirectory(data.entries || []); const statusParts = []; if (data.truncated) statusParts.push(`目录较大,仅显示前 ${data.entryLimit} 项`); statusParts.push(`当前共 ${Array.isArray(data.entries) ? data.entries.length : 0} 项`); setFileBrowserStatus(statusParts.join(' · ')); } catch (err) { if (!fileBrowserState || state !== fileBrowserState || requestId !== state.directoryRequestId) return; state.currentPath = previousPath; updateFileBrowserPathBar(); state.listEl.innerHTML = ``; setFileBrowserStatus(err.message || '目录读取失败', 'error'); } } function scrollFileBrowserPreviewToLine(lineNumber) { const line = Number(lineNumber || 0); const codeEl = fileBrowserState?.previewCodeEl; if (!codeEl || line <= 0) return; requestAnimationFrame(() => { const styles = window.getComputedStyle(codeEl); const lineHeight = parseFloat(styles.lineHeight) || (parseFloat(styles.fontSize) * 1.4) || 18; codeEl.scrollTop = Math.max(0, (line - 4) * lineHeight); }); } async function openFileBrowserFile(targetPath, options = {}) { if (!fileBrowserState) return; const state = fileBrowserState; const normalizedPath = normalizeBrowserPath(targetPath); const targetLine = Math.max(0, Number(options.line || 0) || 0); const requestId = ++state.previewRequestId; state.selectedFilePath = normalizedPath; syncFileBrowserSelection(); renderFileBrowserPreviewLoading(normalizedPath.split('/').pop() || '文件预览'); setFileBrowserPreviewMode(true); try { const data = await fetchAuthJson(`/api/fs/read?sessionId=${encodeURIComponent(state.sessionId)}&path=${encodeURIComponent(normalizedPath)}`); if (!fileBrowserState || state !== fileBrowserState || requestId !== state.previewRequestId) return; state.selectedFilePath = normalizeBrowserPath(data.path || normalizedPath); syncFileBrowserSelection(); state.previewTitleEl.textContent = data.name || '文件预览'; const metaParts = [formatFileSize(data.size || 0)]; if (data.updatedAt) metaParts.push(timeAgo(data.updatedAt)); if (data.truncated) metaParts.push(`仅显示前 ${formatFileSize(data.previewBytes || 0)}`); if (targetLine) metaParts.push(`第 ${targetLine} 行`); state.previewMetaEl.textContent = metaParts.join(' · '); state.previewEmptyEl.hidden = true; state.previewCodeEl.hidden = false; state.previewCodeEl.textContent = data.content || ''; scrollFileBrowserPreviewToLine(targetLine); setFileBrowserStatus(`已打开 ${data.name || '文件'}${targetLine ? `:${targetLine}` : ''}`); } catch (err) { if (!fileBrowserState || state !== fileBrowserState || requestId !== state.previewRequestId) return; state.selectedFilePath = ''; syncFileBrowserSelection(); renderFileBrowserPreviewEmpty('无法预览', err.message || '当前文件无法打开'); setFileBrowserStatus(err.message || '当前文件无法打开', 'error'); } } function showFileBrowser() { if (!currentSessionId) { showToast('请先打开一个会话'); return; } if (!currentCwd) { showToast('当前会话没有可浏览的工作目录'); return; } if (fileBrowserState && fileBrowserState.sessionId === currentSessionId) return; closeFileBrowser(); const overlay = document.createElement('div'); overlay.className = 'modal-overlay'; overlay.id = 'file-browser-overlay'; overlay.innerHTML = ` `; document.body.appendChild(overlay); const state = { sessionId: currentSessionId, rootPath: currentCwd, currentPath: '', selectedFilePath: '', directoryRequestId: 0, previewRequestId: 0, overlay, panel: overlay.querySelector('.file-browser-panel'), pathEl: overlay.querySelector('[data-browser-path]'), statusEl: overlay.querySelector('[data-browser-status]'), listEl: overlay.querySelector('[data-browser-list]'), previewTitleEl: overlay.querySelector('[data-browser-preview-title]'), previewMetaEl: overlay.querySelector('[data-browser-preview-meta]'), previewEmptyEl: overlay.querySelector('[data-browser-preview-empty]'), previewCodeEl: overlay.querySelector('[data-browser-preview-content]'), upBtn: overlay.querySelector('[data-browser-up]'), refreshBtn: overlay.querySelector('[data-browser-refresh]'), mobileBackBtn: overlay.querySelector('[data-browser-back]'), escapeHandler: null, }; fileBrowserState = state; state.escapeHandler = (e) => { if (e.key === 'Escape') closeFileBrowser(); }; document.addEventListener('keydown', state.escapeHandler); overlay.querySelector('[data-browser-close]').addEventListener('click', closeFileBrowser); overlay.addEventListener('click', (e) => { if (e.target === overlay) closeFileBrowser(); }); state.upBtn.addEventListener('click', () => { loadFileBrowserDirectory(getBrowserParentPath(state.currentPath)); }); state.refreshBtn.addEventListener('click', () => { loadFileBrowserDirectory(state.currentPath, { preservePreview: !!state.selectedFilePath }); }); state.mobileBackBtn.addEventListener('click', () => { setFileBrowserPreviewMode(false); }); updateFileBrowserPathBar(); renderFileBrowserPreviewEmpty('文件预览', '选择一个文本文件查看内容'); loadFileBrowserDirectory(''); } function syncAttachmentActions() { const uploading = uploadingAttachments.length > 0; if (attachBtn) attachBtn.disabled = uploading; } function replaceFileExtension(filename, ext) { const base = String(filename || 'image').replace(/\.[^/.]+$/, ''); return `${base}${ext}`; } function loadImageFromFile(file) { return new Promise((resolve, reject) => { const url = URL.createObjectURL(file); const img = new Image(); img.onload = () => { URL.revokeObjectURL(url); resolve(img); }; img.onerror = () => { URL.revokeObjectURL(url); reject(new Error('读取图片失败')); }; img.src = url; }); } async function compressImageFile(file) { if (!file || !/^image\/(png|jpeg|webp)$/i.test(file.type || '')) return file; const img = await loadImageFromFile(file); const maxDimension = 2000; const maxOriginalBytes = 2 * 1024 * 1024; const largestSide = Math.max(img.naturalWidth || img.width, img.naturalHeight || img.height); if (file.size <= maxOriginalBytes && largestSide <= maxDimension) { return file; } const scale = Math.min(1, maxDimension / largestSide); const width = Math.max(1, Math.round((img.naturalWidth || img.width) * scale)); const height = Math.max(1, Math.round((img.naturalHeight || img.height) * scale)); const canvas = document.createElement('canvas'); canvas.width = width; canvas.height = height; const ctx = canvas.getContext('2d', { alpha: true }); if (!ctx) return file; ctx.drawImage(img, 0, 0, width, height); const targetType = 'image/webp'; const qualities = [0.9, 0.84, 0.78, 0.72]; let bestBlob = null; for (const quality of qualities) { const blob = await new Promise((resolve) => canvas.toBlob(resolve, targetType, quality)); if (!blob) continue; if (!bestBlob || blob.size < bestBlob.size) bestBlob = blob; if (blob.size <= Math.max(maxOriginalBytes, file.size * 0.72)) break; } if (!bestBlob || bestBlob.size >= file.size) return file; return new File([bestBlob], replaceFileExtension(file.name || 'image', '.webp'), { type: bestBlob.type, lastModified: Date.now(), }); } async function deleteUploadedAttachment(id) { if (!id) return; try { await ensureAuthenticatedWs(); await fetch(`/api/attachments/${encodeURIComponent(id)}`, { method: 'DELETE', headers: { Authorization: `Bearer ${authToken}`, }, }); } catch {} clearAttachmentPreviewCache(id); } function ensureAuthenticatedWs() { return new Promise((resolve, reject) => { if (ws && ws.readyState === 1 && authToken) { resolve(authToken); return; } const savedPassword = localStorage.getItem('cc-web-pw'); if (!savedPassword) { reject(new Error('登录状态已失效,请刷新页面后重新登录再上传图片。')); return; } const timeout = setTimeout(() => { reject(new Error('登录状态恢复超时,请刷新页面后重试。')); }, 8000); const cleanup = () => { clearTimeout(timeout); document.removeEventListener('cc-web-auth-restored', onRestored); document.removeEventListener('cc-web-auth-failed', onFailed); }; const onRestored = () => { cleanup(); resolve(authToken); }; const onFailed = () => { cleanup(); reject(new Error('登录状态已失效,请刷新页面后重新登录再上传图片。')); }; document.addEventListener('cc-web-auth-restored', onRestored); document.addEventListener('cc-web-auth-failed', onFailed); if (!ws || ws.readyState > 1) { connect(); } else if (ws.readyState === 1) { send({ type: 'auth', password: savedPassword }); } }); } function clearAttachmentPreviewCache(id) { const entry = attachmentPreviewCache.get(id); if (entry?.url && entry.objectUrl) URL.revokeObjectURL(entry.url); attachmentPreviewCache.delete(id); } async function getAttachmentPreviewUrl(attachment) { const id = String(attachment?.id || '').trim(); if (!id) throw new Error('图片附件缺少 ID'); if (attachment.storageState === 'expired') { throw new Error('图片已过期'); } if (attachment.previewUrl) return attachment.previewUrl; const cached = attachmentPreviewCache.get(id); if (cached?.url) return cached.url; if (cached?.promise) return cached.promise; const promise = (async () => { 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('登录状态已失效,请刷新页面后重新登录再预览图片。'); } 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); throw err; }); attachmentPreviewCache.set(id, { promise }); return promise; } function closeAttachmentPreviewModal() { if (!attachmentPreviewModal) return; const { overlay, escapeHandler } = attachmentPreviewModal; if (escapeHandler) document.removeEventListener('keydown', escapeHandler); if (overlay && overlay.parentNode) overlay.parentNode.removeChild(overlay); attachmentPreviewModal = null; } async function openAttachmentPreviewModal(attachment) { if (!attachment || attachment.storageState === 'expired') { showToast('图片已过期,无法预览'); return; } closeAttachmentPreviewModal(); const overlay = document.createElement('div'); overlay.className = 'modal-overlay attachment-preview-overlay'; overlay.innerHTML = ` `; document.body.appendChild(overlay); const stageEl = overlay.querySelector('.attachment-preview-stage'); const imgEl = overlay.querySelector('.attachment-preview-image'); const placeholderEl = overlay.querySelector('.attachment-preview-placeholder'); const closeBtn = overlay.querySelector('.modal-close-btn'); const finishClose = () => closeAttachmentPreviewModal(); attachmentPreviewModal = { overlay, escapeHandler: null, }; attachmentPreviewModal.escapeHandler = (e) => { if (e.key === 'Escape') finishClose(); }; document.addEventListener('keydown', attachmentPreviewModal.escapeHandler); closeBtn.addEventListener('click', finishClose); overlay.addEventListener('click', (e) => { if (e.target === overlay) finishClose(); }); try { const url = await getAttachmentPreviewUrl(attachment); if (!attachmentPreviewModal || attachmentPreviewModal.overlay !== overlay) return; imgEl.onload = () => { if (!attachmentPreviewModal || attachmentPreviewModal.overlay !== overlay) return; imgEl.hidden = false; placeholderEl.hidden = true; stageEl.classList.remove('is-loading'); stageEl.classList.add('is-ready'); }; imgEl.onerror = () => { if (!attachmentPreviewModal || attachmentPreviewModal.overlay !== overlay) return; placeholderEl.textContent = '图片加载失败'; stageEl.classList.remove('is-loading'); stageEl.classList.add('is-error'); }; imgEl.src = url; } catch (err) { if (!attachmentPreviewModal || attachmentPreviewModal.overlay !== overlay) return; placeholderEl.textContent = err.message || '图片预览失败'; stageEl.classList.remove('is-loading'); stageEl.classList.add('is-error'); } } function hydrateAttachmentPreviews(root, attachments = []) { if (!root) return; const attachmentMap = new Map((Array.isArray(attachments) ? attachments : []).map((attachment) => [attachment.id, attachment])); root.querySelectorAll('[data-attachment-id]').forEach((node) => { const attachment = attachmentMap.get(node.dataset.attachmentId); if (!attachment) return; const imgEl = node.querySelector('.msg-attachment-thumb-image'); const placeholderEl = node.querySelector('.msg-attachment-thumb-placeholder'); const isExpired = attachment.storageState === 'expired'; if (!isExpired) { getAttachmentPreviewUrl(attachment) .then((url) => { if (!node.isConnected) return; 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; placeholderEl.textContent = err.message || '图片加载失败'; node.classList.add('is-error'); }); } node.addEventListener('click', () => openAttachmentPreviewModal(attachment)); }); } function renderAttachmentPreviews(attachments, options = {}) { if (!Array.isArray(attachments) || attachments.length === 0) return ''; const items = attachments.map((attachment) => { const state = attachment.storageState || 'available'; const name = escapeHtml(attachment.filename || 'image'); const size = formatFileSize(attachment.size || 0); const isExpired = state === 'expired'; return ` `; }).join(''); return `
${items}
`; } function renderPendingAttachments() { if (!attachmentTray) return; if (!pendingAttachments.length && !uploadingAttachments.length) { attachmentTray.hidden = true; attachmentTray.innerHTML = ''; syncAttachmentActions(); return; } attachmentTray.hidden = false; const uploadingHtml = uploadingAttachments.map((attachment) => `
${escapeHtml(attachment.filename || 'image')} 上传中 · ${formatFileSize(attachment.size)}
`).join(''); const readyHtml = pendingAttachments.map((attachment, index) => `
${escapeHtml(attachment.filename || 'image')} ${formatFileSize(attachment.size)} · 将随下一条消息发送
`).join(''); const noteHtml = [ uploadingAttachments.length > 0 ? '
图片上传中,此时发送不会包含尚未完成的图片。
' : '', ].join(''); attachmentTray.innerHTML = `${uploadingHtml}${readyHtml}${noteHtml}`; attachmentTray.querySelectorAll('.attachment-chip-remove').forEach((btn) => { btn.addEventListener('click', (e) => { e.stopPropagation(); const index = Number(btn.dataset.index); const [removed] = pendingAttachments.splice(index, 1); renderPendingAttachments(); deleteUploadedAttachment(removed?.id); }); }); syncAttachmentActions(); } async function uploadImageFile(file) { await ensureAuthenticatedWs(); const headers = { 'Authorization': `Bearer ${authToken}`, 'Content-Type': file.type || 'application/octet-stream', 'X-Filename': encodeURIComponent(file.name || 'image'), }; const response = await fetch('/api/attachments', { method: 'POST', headers, body: file, }); const rawText = await response.text(); let data = null; try { data = rawText ? JSON.parse(rawText) : null; } catch { data = null; } if (response.status === 401) { throw new Error('登录状态已失效,请刷新页面后重新登录再上传图片。'); } if (response.status === 413) { throw new Error('图片大小超过当前上传限制,请压缩到 10MB 以内后重试。'); } if (!response.ok || !data?.ok) { throw new Error(data?.message || `上传失败 (${response.status})`); } return data.attachment; } async function handleSelectedImageFiles(fileList) { const files = Array.from(fileList || []).filter((file) => file && /^image\//.test(file.type || '')); if (!files.length) return; if (pendingAttachments.length + files.length > 4) { appendError('单条消息最多附带 4 张图片。'); return; } const batch = files.map((file, index) => ({ id: `${Date.now()}-${index}-${Math.random().toString(36).slice(2, 8)}`, filename: file.name || 'image', size: file.size || 0, })); uploadingAttachments.push(...batch); renderPendingAttachments(); try { const results = await Promise.allSettled(files.map(async (file) => { const optimized = await compressImageFile(file); const uploaded = await uploadImageFile(optimized); uploaded.previewUrl = URL.createObjectURL(file); return uploaded; })); const errors = []; for (const result of results) { if (result.status === 'fulfilled') { pendingAttachments.push(result.value); } else { errors.push(result.reason?.message || '图片上传失败'); } } if (errors.length > 0) { appendError(errors[0]); } } catch (err) { appendError(err.message || '图片上传失败'); } finally { uploadingAttachments = uploadingAttachments.filter((item) => !batch.some((entry) => entry.id === item.id)); renderPendingAttachments(); if (imageUploadInput) imageUploadInput.value = ''; } } function getVisibleSessions() { return sessions.filter((s) => normalizeAgent(s.agent) === currentAgent); } function normalizeSessionSearchQuery(query) { return String(query || '').trim().toLowerCase(); } function syncSessionSearchUi() { if (!sessionSearchInput) return; if (sessionSearchInput.value !== sessionSearchQuery) { sessionSearchInput.value = sessionSearchQuery; } const hasQuery = !!normalizeSessionSearchQuery(sessionSearchQuery); sessionSearchInput.classList.toggle('has-value', hasQuery); if (sessionSearchClear) { sessionSearchClear.hidden = !hasQuery; sessionSearchClear.disabled = !hasQuery; } } function getSessionSearchText(session) { const cwd = getSessionEffectiveCwd(session); return [ session?.title, getSessionProjectName(session), cwd, session?.id, shortSessionId(session?.id), ].filter(Boolean).join('\n').toLowerCase(); } function sessionMatchesSearch(session, normalizedQuery) { if (!normalizedQuery) return true; return getSessionSearchText(session).includes(normalizedQuery); } function getProjectCollapseKey(group) { const rawKey = group?.cwd || group?.name || ''; return `${normalizeAgent(currentAgent)}:${rawKey}`; } function persistCollapsedProjectKeys() { try { localStorage.setItem(PROJECT_COLLAPSE_STORAGE_KEY, JSON.stringify([...collapsedProjectKeys])); } catch {} } function setProjectCollapsed(groupKey, collapsed) { if (!groupKey) return; if (collapsed) { collapsedProjectKeys.add(groupKey); } else { collapsedProjectKeys.delete(groupKey); } persistCollapsedProjectKeys(); renderSessionList(); } function getSessionCwdFromCache(sessionId) { if (!sessionId) return ''; const cachedCwd = sessionCache.get(sessionId)?.snapshot?.cwd; if (cachedCwd) return cachedCwd; if (sessionId === currentSessionId && currentCwd) return currentCwd; return ''; } function getSessionEffectiveCwd(session) { return session?.cwd || getSessionCwdFromCache(session?.id) || ''; } function getSessionProjectName(session) { if (session?.projectName) return session.projectName; const cwd = String(getSessionEffectiveCwd(session)).replace(/\\/g, '/').replace(/\/+$/, ''); return cwd ? (getPathLeaf(cwd) || cwd) : ''; } function groupSessionsByProject(sessionItems) { const groups = []; const groupMap = new Map(); const ungroupedSessions = []; for (const session of sessionItems) { const projectName = getSessionProjectName(session); if (!projectName) { ungroupedSessions.push(session); continue; } if (!groupMap.has(projectName)) { const cwd = getSessionEffectiveCwd(session); const group = { name: projectName, cwd, sessions: [], latestUpdated: session.updated || '', }; groupMap.set(projectName, group); groups.push(group); } const group = groupMap.get(projectName); group.sessions.push(session); if (new Date(session.updated || 0) > new Date(group.latestUpdated || 0)) { group.latestUpdated = session.updated || group.latestUpdated; group.cwd = getSessionEffectiveCwd(session) || group.cwd; } } for (const group of groups) { group.sessions.sort(compareSessionUpdatedDesc); } ungroupedSessions.sort(compareSessionUpdatedDesc); return { groups: groups.sort((a, b) => new Date(b.latestUpdated || 0) - new Date(a.latestUpdated || 0)), ungroupedSessions, }; } function splitPinnedSessions(sessionItems) { const pinnedSessions = []; const regularSessions = []; for (const session of sessionItems) { if (session.pinnedAt) { pinnedSessions.push(session); } else { regularSessions.push(session); } } pinnedSessions.sort(compareSessionPinnedDesc); regularSessions.sort(compareSessionUpdatedDesc); return { pinnedSessions, regularSessions }; } function isOlderThanOldSessionWindow(session, nowMs = Date.now()) { const updatedMs = new Date(session?.updated || 0).getTime(); return Number.isFinite(updatedMs) && updatedMs > 0 && nowMs - updatedMs > OLD_SESSION_COLLAPSE_MS; } function getProjectOldSessionCollapseKey(group) { return `project:${getProjectCollapseKey(group)}`; } function getUngroupedOldSessionCollapseKey() { return `${normalizeAgent(currentAgent)}:ungrouped`; } function expandOldSessionGroup(collapseKey) { if (!collapseKey) return; expandedOldSessionGroups.add(collapseKey); } function shouldAlwaysShowOldSession(session) { return session.id === currentSessionId || session.isRunning || session.hasUnread || session.waitingOnChildren; } function splitCollapsedSessions(sessionItems, collapseKey) { const isExpanded = collapseKey && expandedOldSessionGroups.has(collapseKey); const nowMs = Date.now(); const shouldCompactByCount = sessionItems.length > SESSION_GROUP_COMPACT_VISIBLE_LIMIT; const visibleSessions = []; const hiddenSessions = []; sessionItems.forEach((session) => { const isOldSession = isOlderThanOldSessionWindow(session, nowMs); const shouldHideByAge = isOldSession && visibleSessions.length >= OLD_SESSION_GROUP_INITIAL_VISIBLE; const shouldHideByCount = shouldCompactByCount && visibleSessions.length >= SESSION_GROUP_COMPACT_VISIBLE_LIMIT; const shouldHideSession = ( !isExpanded && !shouldAlwaysShowOldSession(session) && (shouldHideByAge || shouldHideByCount) ); if (shouldHideSession) { hiddenSessions.push(session); } else { visibleSessions.push(session); } }); return { visibleSessions, hiddenSessions }; } function createOldSessionLoadMoreButton(hiddenCount, collapseKey, projectName = '') { const button = document.createElement('button'); button.type = 'button'; button.className = 'session-list-load-more'; button.setAttribute('aria-label', `加载更多${projectName ? ` ${projectName}` : ''}会话,还有 ${hiddenCount} 条`); button.innerHTML = ` 加载更多 还有 ${hiddenCount} 条 `; button.addEventListener('click', () => { expandOldSessionGroup(collapseKey); renderSessionList(); }); return button; } function applySessionPinnedState(sessionId, pinnedAt) { sessions = sessions.map((session) => ( session.id === sessionId ? { ...session, pinnedAt: pinnedAt || null } : session )); updateCachedSession(sessionId, (snapshot) => { snapshot.pinnedAt = pinnedAt || null; }); renderSessionList(); } function toggleSessionPinned(session) { if (!session?.id) return; const nextPinned = !session.pinnedAt; const pinnedAt = nextPinned ? new Date().toISOString() : null; applySessionPinnedState(session.id, pinnedAt); send({ type: 'set_session_pinned', sessionId: session.id, pinned: nextPinned }); } function setSessionActionMenuOpen(item, open) { if (!item) return; item.classList.toggle('menu-open', open); item.querySelector('.session-item-btn.more')?.setAttribute('aria-expanded', open ? 'true' : 'false'); } function closeSessionActionMenus(exceptItem = null) { document.querySelectorAll('.session-item.menu-open').forEach((item) => { if (item !== exceptItem) setSessionActionMenuOpen(item, false); }); } function createSessionListItem(session) { const item = document.createElement('div'); const isPinned = !!session.pinnedAt; const waitingOnChildren = !!session.waitingOnChildren; const readyReplyCount = Number(session.readyReplyCount || 0); const waitingLabel = readyReplyCount > 0 ? `子对话已返回 ${readyReplyCount}` : `等待子对话 ${Number(session.pendingReplyCount || 0) || ''}`.trim(); item.className = `session-item${session.id === currentSessionId ? ' active' : ''}${isPinned ? ' pinned' : ''}${waitingOnChildren ? ' waiting-children' : ''}`; item.dataset.id = session.id; const sessionCwd = getSessionEffectiveCwd(session); if (sessionCwd) item.title = sessionCwd; item.innerHTML = `
${escapeHtml(session.title || 'Untitled')} ${isPinned ? '' : ''} ${session.isRunning ? '运行中' : ''} ${!session.isRunning && waitingOnChildren ? `${escapeHtml(waitingLabel)}` : ''}
${session.hasUnread ? '' : ''} ${timeAgo(session.updated)}
`; item.addEventListener('click', (e) => { const target = e.target instanceof Element ? e.target.closest('.session-item-btn, .session-item-menu-btn') : null; if (target?.classList.contains('more')) { e.stopPropagation(); const nextOpen = !item.classList.contains('menu-open'); closeSessionActionMenus(item); setSessionActionMenuOpen(item, nextOpen); return; } if (target?.classList.contains('copy-id')) { e.stopPropagation(); closeSessionActionMenus(); copyTextToClipboard(session.id, '会话 ID 已复制'); return; } if (target?.classList.contains('pin')) { e.stopPropagation(); closeSessionActionMenus(); toggleSessionPinned(session); return; } if (target?.classList.contains('delete')) { e.stopPropagation(); closeSessionActionMenus(); const doDelete = () => { if (getLastSessionForAgent(currentAgent) === session.id) { 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) { resetChatView(currentAgent); } }; if (skipDeleteConfirm) { doDelete(); } else { showDeleteConfirm(session.agent, doDelete); } return; } if (target?.classList.contains('edit')) { e.stopPropagation(); closeSessionActionMenus(); startEditSessionTitle(item, session); return; } if (e.target instanceof Element && e.target.closest('.session-item-more')) { e.stopPropagation(); return; } closeSessionActionMenus(); if (isMobileInputMode()) closeSidebar(); openSession(session.id); }); return item; } function updateCwdBadge() { if (!chatCwd) return; if (currentCwd) { const parts = currentCwd.replace(/\/+$/, '').split('/'); const short = parts.slice(-2).join('/') || currentCwd; chatCwd.textContent = '~/' + short; chatCwd.title = `${currentCwd}\n点击浏览目录和文件`; chatCwd.setAttribute('aria-label', `浏览工作目录 ${currentCwd}`); } else { chatCwd.textContent = ''; chatCwd.title = ''; chatCwd.removeAttribute('aria-label'); } chatCwd.disabled = !currentCwd; chatCwd.hidden = !currentCwd; } function currentSessionWaitState() { const meta = currentSessionId ? getSessionMeta(currentSessionId) : null; const cached = currentSessionId ? sessionCache.get(currentSessionId)?.snapshot : null; const source = meta || cached || {}; return { waitingOnChildren: !!source.waitingOnChildren, pendingReplyCount: Number(source.pendingReplyCount || 0), readyReplyCount: Number(source.readyReplyCount || 0), }; } function setCurrentSessionRunningState(isRunning) { const running = !!isRunning; currentSessionRunning = running; if (chatRuntimeState) { const waitState = currentSessionWaitState(); if (running) { chatRuntimeState.hidden = false; chatRuntimeState.classList.remove('waiting'); chatRuntimeState.textContent = '运行中'; } else if (waitState.waitingOnChildren) { chatRuntimeState.hidden = false; chatRuntimeState.classList.add('waiting'); chatRuntimeState.textContent = waitState.readyReplyCount > 0 ? `子对话已返回 ${waitState.readyReplyCount}` : `等待子对话 ${waitState.pendingReplyCount || ''}`.trim(); } else { chatRuntimeState.hidden = true; chatRuntimeState.classList.remove('waiting'); chatRuntimeState.textContent = ''; } } updateCwdBadge(); if (!running) scheduleQueuedMessageDrain(); } function updateAgentScopedUI() { if (chatAgentBtn) { chatAgentBtn.textContent = AGENT_LABELS[currentAgent]; chatAgentBtn.setAttribute('aria-expanded', chatAgentMenu && !chatAgentMenu.hidden ? 'true' : 'false'); } if (chatAgentMenu) { chatAgentMenu.querySelectorAll('.chat-agent-option').forEach((btn) => { const active = btn.dataset.agent === currentAgent; btn.classList.toggle('active', active); btn.setAttribute('aria-pressed', active ? 'true' : 'false'); }); } if (importSessionBtn) { importSessionBtn.textContent = isCodexLikeAgent(currentAgent) ? `导入本地 ${AGENT_LABELS[currentAgent]} 会话` : '导入本地 Claude 会话'; importSessionBtn.disabled = false; } updateReloadMcpButtonUI(); } function setCurrentAgent(agent) { currentAgent = normalizeAgent(agent); localStorage.setItem('cc-web-agent', currentAgent); currentMode = localStorage.getItem(getAgentModeStorageKey(currentAgent)) || 'yolo'; modeSelect.value = currentMode; updateAgentScopedUI(); updateGenerationControls(); } function closeAgentMenu() { if (!chatAgentMenu) return; chatAgentMenu.hidden = true; if (chatAgentBtn) chatAgentBtn.setAttribute('aria-expanded', 'false'); } function toggleAgentMenu() { if (!chatAgentMenu || !chatAgentBtn) return; const willOpen = chatAgentMenu.hidden; chatAgentMenu.hidden = !willOpen; chatAgentBtn.setAttribute('aria-expanded', willOpen ? 'true' : 'false'); } function resetChatView(agent) { setCurrentAgent(agent); closeUserOutlinePanel(); closeCcwebPromptOutlinePanel(); closeFileBrowser(); currentSessionId = null; loadedHistorySessionId = null; currentSessionMessageCount = 0; clearSessionLoading(); setCurrentSessionRunningState(false); currentCwd = null; currentModel = isCodexLikeAgent(currentAgent) ? '' : 'opus'; isGenerating = false; generatingSessionId = null; pendingText = ''; window.pendingContentBlocks = []; pendingAttachments = []; uploadingAttachments = []; activeToolCalls.clear(); activeTodoCallTargets.clear(); closedCollabAgentIds = new Set(); updateGenerationControls(); chatTitle.textContent = '新会话'; updateSessionIdBadge(); updateCwdBadge(); updateReloadMcpButtonUI(); 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; updateGenerationControls(); pendingText = ''; window.pendingContentBlocks = []; activeToolCalls.clear(); activeTodoCallTargets.clear(); } currentSessionId = snapshot.sessionId; loadedHistorySessionId = snapshot.sessionId; currentSessionMessageCount = Math.max( snapshot.historyTotal || 0, snapshot.historyBaseIndex + (snapshot.messages || []).length, (snapshot.messages || []).length, ); setLastSessionForAgent(snapshot.agent, currentSessionId); chatTitle.textContent = snapshot.title || '新会话'; updateSessionIdBadge(); setCurrentAgent(snapshotAgent); migratePendingNotesToSession(snapshot.sessionId, snapshotAgent); migrateQueuedMessagesToSession(snapshot.sessionId, snapshotAgent); setCurrentSessionRunningState(snapshot.isRunning); setStatsDisplay(snapshot); closeUserOutlinePanel(); closeCcwebPromptOutlinePanel(); currentCwd = snapshot.cwd || null; updateCwdBadge(); if (snapshot.mode && MODE_LABELS[snapshot.mode]) { currentMode = snapshot.mode; modeSelect.value = currentMode; localStorage.setItem(getAgentModeStorageKey(currentAgent), currentMode); } currentModel = snapshot.model || ''; if (!preserveStreaming) { renderMessages(snapshot.messages || [], { immediate: !!options.immediate, baseIndex: snapshot.historyBaseIndex || 0, }); if (snapshot.isRunning && snapshot.sessionId === currentSessionId) { startGenerating(snapshot.sessionId); } } else { generatingSessionId = snapshot.sessionId; } highlightActiveSession(); renderSessionList(); if (!options.skipCloseSidebar) closeSidebar(); if (snapshot.hasUnread && !options.suppressUnreadToast) { showToast('后台任务已完成', snapshot.sessionId); } } function syncViewForAgent(agent, options = {}) { const targetAgent = normalizeAgent(agent); const { preserveCurrent = true, loadLast = true } = options; setCurrentAgent(targetAgent); closeUserOutlinePanel(); closeCcwebPromptOutlinePanel(); renderSessionList(); const currentMeta = currentSessionId ? getSessionMeta(currentSessionId) : null; if (preserveCurrent && currentMeta && normalizeAgent(currentMeta.agent) === targetAgent) { highlightActiveSession(); return; } if (currentSessionId && (!currentMeta || normalizeAgent(currentMeta.agent) !== targetAgent)) { send({ type: 'detach_view' }); } resetChatView(targetAgent); if (!loadLast) return; const lastSessionId = getLastSessionForAgent(targetAgent); const lastMeta = lastSessionId ? getSessionMeta(lastSessionId) : null; if (lastMeta && normalizeAgent(lastMeta.agent) === targetAgent) { openSession(lastSessionId); } } function getSessionLoadLabel(sessionId) { const meta = sessionId ? getSessionMeta(sessionId) : null; const title = meta?.title ? `“${meta.title}”` : '所选会话'; return `正在载入 ${title} 的完整消息记录…`; } function clearSessionLoadOverlayTimer() { if (!sessionLoadOverlayTimer) return; clearTimeout(sessionLoadOverlayTimer); sessionLoadOverlayTimer = null; } function releaseSessionLoadingOverlay({ keepActiveLoad = true, allowRetry = false } = {}) { clearSessionLoadOverlayTimer(); document.body.classList.remove('session-loading-active'); sessionLoadingOverlay.hidden = true; sessionLoadingOverlay.setAttribute('aria-hidden', 'true'); msgInput.disabled = false; modeSelect.disabled = false; sendBtn.disabled = false; abortBtn.disabled = false; if (keepActiveLoad && activeSessionLoad) { if (allowRetry) activeSessionLoad.overlayReleased = true; } } function setSessionLoading(sessionId, options = {}) { clearSessionLoadOverlayTimer(); const loading = !!sessionId; const blocking = options.blocking !== false; const requestId = loading ? (options.requestId || createSessionSwitchRequestId(sessionId)) : ''; activeSessionLoad = loading ? { sessionId, blocking, snapshot: null, requestId, overlayReleased: false } : null; const showOverlay = !!(loading && blocking); if (showOverlay) { document.body.classList.add('session-loading-active'); sessionLoadingOverlay.hidden = false; sessionLoadingOverlay.setAttribute('aria-hidden', 'false'); sessionLoadOverlayTimer = setTimeout(() => { sessionLoadOverlayTimer = null; if (!activeSessionLoad || activeSessionLoad.sessionId !== sessionId || activeSessionLoad.requestId !== requestId) return; releaseSessionLoadingOverlay({ keepActiveLoad: true, allowRetry: true }); }, SESSION_LOAD_OVERLAY_TIMEOUT_MS); } else { releaseSessionLoadingOverlay({ keepActiveLoad: loading }); } sessionLoadingLabel.textContent = loading ? (options.label || getSessionLoadLabel(sessionId)) : '正在整理消息与上下文…'; msgInput.disabled = showOverlay; modeSelect.disabled = showOverlay; sendBtn.disabled = showOverlay; abortBtn.disabled = showOverlay; if (showOverlay && document.activeElement instanceof HTMLElement) { document.activeElement.blur(); } } function clearSessionLoading(sessionId) { if (sessionId && activeSessionLoad && activeSessionLoad.sessionId !== sessionId) return; setSessionLoading(null, { blocking: false }); } function createSessionSwitchRequestId(sessionId) { sessionSwitchRequestSeq += 1; return `session-load-${Date.now()}-${sessionSwitchRequestSeq}-${String(sessionId || '').slice(0, 8)}`; } function isBlockingSessionLoad(sessionId) { return !!(activeSessionLoad && activeSessionLoad.blocking && (!sessionId || activeSessionLoad.sessionId === sessionId)); } function finishSessionSwitch(sessionId) { if (isBlockingSessionLoad(sessionId)) { scrollToBottom(); requestAnimationFrame(() => clearSessionLoading(sessionId)); return; } clearSessionLoading(sessionId); } function finalizeLoadedSession(sessionId) { if (activeSessionLoad?.sessionId === sessionId && activeSessionLoad.snapshot) { activeSessionLoad.snapshot.complete = true; cacheSessionSnapshot(activeSessionLoad.snapshot); } finishSessionSwitch(sessionId); } function beginSessionSwitch(sessionId, options = {}) { if (!sessionId) return; const blocking = options.blocking !== false; const force = options.force === true; if (!force && activeSessionLoad?.sessionId === sessionId && !activeSessionLoad.overlayReleased) return; if (!force && sessionId === currentSessionId && !activeSessionLoad) return; renderEpoch++; loadedHistorySessionId = null; setSessionLoading(sessionId, { blocking, label: options.label }); requestSessionLoad(sessionId, { blocking, label: options.label }); } function requestSessionLoad(sessionId, options = {}) { if (!sessionId) return; pendingSessionSwitchRequest = { sessionId, blocking: options.blocking !== false, label: options.label || '', requestId: options.requestId || activeSessionLoad?.requestId || createSessionSwitchRequestId(sessionId), }; if (ws && ws.readyState === 1 && wsAuthenticated) { flushPendingSessionSwitch(); return; } if (!ws || ws.readyState > 1) connect(); } function flushPendingSessionSwitch() { if (!pendingSessionSwitchRequest) return; if (!ws || ws.readyState !== 1 || !wsAuthenticated) return; const request = pendingSessionSwitchRequest; pendingSessionSwitchRequest = null; if (!activeSessionLoad) { setSessionLoading(request.sessionId, { blocking: request.blocking, label: request.label || undefined, requestId: request.requestId, }); } ws.send(JSON.stringify({ type: 'load_session', sessionId: request.sessionId, requestId: request.requestId })); } function showCachedSession(sessionId) { const snapshot = buildCachedSessionSnapshot(sessionId); if (!snapshot) return false; if (currentSessionId && currentSessionId !== sessionId) { send({ type: 'detach_view' }); } closeUserOutlinePanel(); closeCcwebPromptOutlinePanel(); clearSessionLoading(); touchSessionCache(sessionId); applySessionSnapshot(snapshot, { immediate: true, suppressUnreadToast: true }); return true; } function openSession(sessionId, options = {}) { if (!sessionId) return; closeUserOutlinePanel(); closeCcwebPromptOutlinePanel(); if (options.forceSync) { beginSessionSwitch(sessionId, { blocking: options.blocking !== false, force: true, label: options.label }); return; } if (!options.force && sessionId === currentSessionId && !activeSessionLoad) return; const disposition = getSessionCacheDisposition(sessionId); if (disposition === 'strong') { showCachedSession(sessionId); return; } if (disposition === 'weak' && showCachedSession(sessionId)) { beginSessionSwitch(sessionId, { blocking: false, force: true, label: options.label }); return; } beginSessionSwitch(sessionId, { blocking: options.blocking !== false, force: options.force === true, label: options.label }); } function setStatsDisplay(msg) { if (isCodexLikeAgent(currentAgent) && msg && msg.totalUsage) { const usage = msg.totalUsage; if ((usage.inputTokens || 0) > 0 || (usage.outputTokens || 0) > 0) { const cacheText = usage.cachedInputTokens ? ` · cache ${usage.cachedInputTokens}` : ''; costDisplay.textContent = `in ${usage.inputTokens} · out ${usage.outputTokens}${cacheText}`; return; } } if (msg && typeof msg.totalCost === 'number' && msg.totalCost > 0) { costDisplay.textContent = `$${msg.totalCost.toFixed(4)}`; return; } costDisplay.textContent = ''; } function _splitCodexThinkingModel(model) { const raw = String(model || '').trim(); if (!raw) return { base: '', level: '' }; const m = raw.match(/^(.*)\(([^()]+)\)\s*$/); if (!m) return { base: raw, level: '' }; return { base: (m[1] || '').trim(), level: (m[2] || '').trim().toLowerCase() }; } function _isCodexModelAtLeast52(model) { const { base } = _splitCodexThinkingModel(model); // Accept only GPT-5.2+ (hide/remove older and other families from picker). const m = String(base || '').trim().match(/^gpt-5\.(\d+)(?:-.+)?$/i); if (!m) return false; const minor = Number(m[1] || 0); return Number.isFinite(minor) && minor >= 2; } function getCodexBaseModelOptions() { const seen = new Set(); const options = []; function addOption(value, label, desc) { const v = (value || '').trim(); if (!v || seen.has(v)) return; seen.add(v); options.push({ value: v, label: label || v, desc: desc || 'Codex 模型' }); } function addBaseOption(value, label, desc) { if (!_isCodexModelAtLeast52(value)) return; const { base } = _splitCodexThinkingModel(value); addOption(base, label || base, desc); } DEFAULT_CODEX_MODEL_OPTIONS.forEach((opt) => addBaseOption(opt.value, opt.label, opt.desc)); addBaseOption(currentModel, currentModel, '当前会话模型'); sessions .filter((s) => isCodexLikeAgent(s.agent) && s.id === currentSessionId) .forEach((s) => addBaseOption(s.model, s.model, '当前会话已保存模型')); return options; } // --- marked config --- const PREVIEW_LANGS = new Set(['html', 'svg']); const MERMAID_LANGS = new Set(['mermaid', 'mmd']); const _previewCodeMap = new Map(); let _previewCodeId = 0; let _mermaidInitialized = false; let _mermaidRenderId = 0; function normalizeCodeLanguage(language) { const lang = String(language || '').trim().split(/\s+/)[0].toLowerCase(); return lang || 'plaintext'; } function highlightCodeBlock(code, lang) { try { if (hljs.getLanguage(lang)) { return hljs.highlight(code, { language: lang }).value; } return hljs.highlightAuto(code).value; } catch { return escapeHtml(code); } } function getCodeBlockSource(wrapper) { if (!wrapper) return ''; const cid = wrapper.dataset.cid ? Number(wrapper.dataset.cid) : 0; if (cid && _previewCodeMap.has(cid)) return _previewCodeMap.get(cid); return wrapper.querySelector('code')?.textContent || ''; } function createStoredCodeId(code) { const cid = ++_previewCodeId; _previewCodeMap.set(cid, code); return cid; } function renderMermaidCodeBlock(code, lang) { const cid = createStoredCodeId(code); const highlighted = highlightCodeBlock(code, lang); return `
${escapeHtml(lang)}
${highlighted}
`; } const renderer = new marked.Renderer(); renderer.code = function (code, language) { const source = String(code || ''); const lang = normalizeCodeLanguage(language); if (MERMAID_LANGS.has(lang)) return renderMermaidCodeBlock(source, lang); const highlighted = highlightCodeBlock(source, lang); const canPreview = PREVIEW_LANGS.has(lang); const previewBtn = canPreview ? `` : ''; const previewPane = canPreview ? `
` : ''; const cid = canPreview ? createStoredCodeId(source) : 0; return `
${escapeHtml(lang)}
${previewBtn}
${previewPane}
${highlighted}
`; }; marked.setOptions({ renderer, breaks: true, gfm: true }); window.ccCopyCode = async function (btn, event) { event?.preventDefault(); event?.stopPropagation(); const wrapper = btn?.closest?.('.code-block-wrapper'); if (!wrapper) return; const code = getCodeBlockSource(wrapper); const defaultLabel = btn.dataset.defaultLabel || btn.textContent || 'Copy'; btn.dataset.defaultLabel = defaultLabel; btn.disabled = true; const copied = await copyTextToClipboard(code, '代码已复制'); btn.disabled = false; if (!copied) return; if (btn._copyResetTimer) clearTimeout(btn._copyResetTimer); btn.textContent = 'Copied!'; btn._copyResetTimer = setTimeout(() => { btn.textContent = btn.dataset.defaultLabel || 'Copy'; btn._copyResetTimer = null; }, 1500); }; window.ccTogglePreview = function (btn, event) { event?.preventDefault(); event?.stopPropagation(); const wrapper = btn.closest('.code-block-wrapper'); const inPreview = wrapper.classList.contains('preview-mode'); if (inPreview) { wrapper.classList.remove('preview-mode'); btn.textContent = 'Preview'; } else { const iframe = wrapper.querySelector('.code-preview-iframe'); if (iframe && !iframe.dataset.loaded) { const cid = wrapper.dataset.cid ? Number(wrapper.dataset.cid) : 0; iframe.srcdoc = (cid && _previewCodeMap.has(cid)) ? _previewCodeMap.get(cid) : ''; iframe.dataset.loaded = '1'; } wrapper.classList.add('preview-mode'); btn.textContent = 'Source'; } }; 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 handleLocalFileLinkClick(event) { event.preventDefault(); event.stopPropagation(); const link = event.currentTarget; const filePath = link?.dataset?.filePath || ''; const line = Number(link?.dataset?.fileLine || 0) || 0; const relativePath = getWorkspaceRelativePath(filePath); if (!relativePath) { showToast(currentCwd ? '文件不在当前会话工作目录内' : '当前会话没有可浏览的工作目录'); return; } showFileBrowser(); if (!fileBrowserState) return; loadFileBrowserDirectory(getBrowserParentPath(relativePath), { preservePreview: true }); openFileBrowserFile(relativePath, { line }); } function hydrateLocalFileLinks(root) { if (!root) return; const links = root.matches?.('a') ? [root] : Array.from(root.querySelectorAll('a')); links.forEach((link) => { if (link.dataset.localFileHydrated === '1') return; const parsed = parseLocalFileLinkHref(link.getAttribute('href') || ''); if (!parsed) return; link.dataset.localFileHydrated = '1'; link.dataset.localFileLink = 'true'; link.dataset.filePath = parsed.filePath; link.dataset.fileLine = parsed.line ? String(parsed.line) : ''; link.classList.add('local-file-link'); link.title = parsed.line ? `${parsed.filePath}:${parsed.line}` : parsed.filePath; link.textContent = formatLocalFileLinkText(parsed, link.textContent); link.addEventListener('click', handleLocalFileLinkClick); }); } function hydrateRenderedMarkdown(root) { hydrateLocalFileLinks(root); hydrateMermaidBlocks(root); } function setRenderedMarkdown(target, text, hydrate = true) { target.innerHTML = renderMarkdown(text); if (hydrate) hydrateRenderedMarkdown(target); } window.ccToggleMermaid = function (btn, event) { event?.preventDefault(); event?.stopPropagation(); const wrapper = btn?.closest?.('.mermaid-code-block'); if (!wrapper) return; const showCode = wrapper.classList.contains('mermaid-diagram-mode'); wrapper.classList.toggle('mermaid-diagram-mode', !showCode); wrapper.classList.toggle('mermaid-source-mode', showCode); btn.textContent = showCode ? '图' : '代码'; if (!showCode) hydrateMermaidBlock(wrapper); }; function serializeMermaidSvg(wrapper) { const svg = wrapper?.querySelector?.('.mermaid-render-target svg'); if (!svg) return ''; const clone = svg.cloneNode(true); if (!clone.getAttribute('xmlns')) clone.setAttribute('xmlns', 'http://www.w3.org/2000/svg'); if (!clone.getAttribute('width') || !clone.getAttribute('height')) { const viewBox = clone.getAttribute('viewBox'); const parts = viewBox ? viewBox.trim().split(/\s+/).map(Number) : []; if (parts.length === 4 && parts.every(Number.isFinite)) { clone.setAttribute('width', String(Math.max(1, Math.ceil(parts[2])))); clone.setAttribute('height', String(Math.max(1, Math.ceil(parts[3])))); } } return new XMLSerializer().serializeToString(clone); } async function getMermaidSvgText(wrapper) { if (!serializeMermaidSvg(wrapper)) { await hydrateMermaidBlock(wrapper, true); } return serializeMermaidSvg(wrapper); } function svgTextToPngBlob(svgText) { return new Promise((resolve, reject) => { const svgBlob = new Blob([svgText], { type: 'image/svg+xml;charset=utf-8' }); const url = URL.createObjectURL(svgBlob); const image = new Image(); image.onload = () => { try { const canvas = document.createElement('canvas'); const width = Math.max(1, image.naturalWidth || image.width || 1200); const height = Math.max(1, image.naturalHeight || image.height || 800); canvas.width = width; canvas.height = height; const ctx = canvas.getContext('2d'); ctx.fillStyle = '#ffffff'; ctx.fillRect(0, 0, width, height); ctx.drawImage(image, 0, 0, width, height); canvas.toBlob((blob) => { URL.revokeObjectURL(url); if (blob) resolve(blob); else reject(new Error('PNG 导出失败')); }, 'image/png'); } catch (error) { URL.revokeObjectURL(url); reject(error); } }; image.onerror = () => { URL.revokeObjectURL(url); reject(new Error('SVG 图形读取失败')); }; image.src = url; }); } async function copyBlobToClipboard(blob, type) { if (!navigator.clipboard?.write || typeof ClipboardItem === 'undefined' || !window.isSecureContext) return false; await navigator.clipboard.write([new ClipboardItem({ [type]: blob })]); return true; } function setTemporaryButtonLabel(btn, label, fallback, timeout = 1500) { if (!btn) return; const defaultLabel = btn.dataset.defaultLabel || fallback || btn.textContent || ''; btn.dataset.defaultLabel = defaultLabel; if (btn._labelResetTimer) clearTimeout(btn._labelResetTimer); btn.textContent = label; btn._labelResetTimer = setTimeout(() => { btn.textContent = btn.dataset.defaultLabel || defaultLabel; btn._labelResetTimer = null; }, timeout); } window.ccCopyMermaidDiagram = async function (btn, event) { event?.preventDefault(); event?.stopPropagation(); const wrapper = btn?.closest?.('.mermaid-code-block'); if (!wrapper) return; btn.disabled = true; try { const svgText = await getMermaidSvgText(wrapper); if (!svgText) throw new Error('暂无可复制图形'); let copied = false; try { const pngBlob = await svgTextToPngBlob(svgText); copied = await copyBlobToClipboard(pngBlob, 'image/png'); } catch {} if (!copied) { const svgBlob = new Blob([svgText], { type: 'image/svg+xml;charset=utf-8' }); try { copied = await copyBlobToClipboard(svgBlob, 'image/svg+xml'); } catch {} } if (copied) { showToast('图形已复制'); setTemporaryButtonLabel(btn, '已复制', '复制图'); } else { await copyTextToClipboard(svgText, '图形 SVG 已复制'); setTemporaryButtonLabel(btn, '已复制', '复制图'); } } catch (error) { showToast(error?.message || '复制图形失败'); } finally { btn.disabled = false; } }; function downloadBlob(blob, filename) { const url = URL.createObjectURL(blob); const link = document.createElement('a'); link.href = url; link.download = filename; link.rel = 'noopener'; document.body.appendChild(link); link.click(); link.remove(); setTimeout(() => URL.revokeObjectURL(url), 1000); } window.ccDownloadMermaidDiagram = async function (btn, event) { event?.preventDefault(); event?.stopPropagation(); const wrapper = btn?.closest?.('.mermaid-code-block'); if (!wrapper) return; btn.disabled = true; try { const svgText = await getMermaidSvgText(wrapper); if (!svgText) throw new Error('暂无可下载图形'); const cid = wrapper.dataset.cid || Date.now(); const blob = new Blob([svgText], { type: 'image/svg+xml;charset=utf-8' }); downloadBlob(blob, `mermaid-${cid}.svg`); showToast('图形已下载'); setTemporaryButtonLabel(btn, '已下载', '下载图'); } catch (error) { showToast(error?.message || '下载图形失败'); } finally { btn.disabled = false; } }; // --- WebSocket --- function isBrowserOnline() { return !('onLine' in navigator) || navigator.onLine; } function canConnectWs() { return !isPageUnloading && isBrowserOnline(); } function clearReconnectTimer() { if (!reconnectTimer) return; clearTimeout(reconnectTimer); reconnectTimer = null; } function connect() { if (!canConnectWs()) return; if (ws && ws.readyState <= 1) return; clearReconnectTimer(); const socket = new WebSocket(WS_URL); ws = socket; wsAuthenticated = false; socket.onopen = () => { if (ws !== socket) return; reconnectAttempts = 0; clearReconnectTimer(); if (authToken) send({ type: 'auth', token: authToken }); }; socket.onmessage = (e) => { if (ws !== socket) return; let msg; try { msg = JSON.parse(e.data); } catch { return; } try { handleServerMessage(msg); } catch (err) { console.error('[cc-web] failed to handle server message', err, msg); if (activeSessionLoad) { releaseSessionLoadingOverlay({ keepActiveLoad: true, allowRetry: true }); } } }; socket.onclose = () => { if (ws !== socket) return; ws = null; wsAuthenticated = false; if (activeSessionLoad?.sessionId && !isPageUnloading) { pendingSessionSwitchRequest = { sessionId: activeSessionLoad.sessionId, blocking: activeSessionLoad.blocking, label: sessionLoadingLabel?.textContent || '', requestId: activeSessionLoad.requestId || createSessionSwitchRequestId(activeSessionLoad.sessionId), }; } clearSessionLoading(); scheduleReconnect(); }; socket.onerror = () => {}; } function send(data) { if (ws && ws.readyState === 1) ws.send(JSON.stringify(data)); } function scheduleReconnect() { if (!canConnectWs()) return; if (reconnectTimer) return; const delay = Math.min(1000 * Math.pow(2, reconnectAttempts), 30000); reconnectAttempts++; reconnectTimer = setTimeout(() => { reconnectTimer = null; connect(); }, delay); } // --- 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': if (msg.success) { const shouldLoadInitialSession = !initialSessionListHandled && !currentSessionId; authToken = msg.token; wsAuthenticated = true; localStorage.setItem('cc-web-token', msg.token); document.dispatchEvent(new CustomEvent('cc-web-auth-restored')); loginOverlay.hidden = true; app.hidden = false; flushPendingSessionSwitch(); send({ type: 'get_codex_config' }); // Check if must change password if (msg.mustChangePassword) { showForceChangePassword(); } else { pendingInitialSessionLoad = shouldLoadInitialSession; } } else { pendingSessionSwitchRequest = null; clearSessionLoading(); authToken = null; wsAuthenticated = false; localStorage.removeItem('cc-web-token'); document.dispatchEvent(new CustomEvent('cc-web-auth-failed')); loginOverlay.hidden = false; app.hidden = true; if (msg.banned) { loginError.textContent = '该 IP 已被永久封禁'; loginError.hidden = false; loginPassword.disabled = true; loginForm.querySelector('button[type="submit"]').disabled = true; } else { loginError.textContent = '密码错误'; loginError.hidden = false; } } break; case 'session_list': sessions = Array.isArray(msg.sessions) ? msg.sessions.map(normalizeSessionSnapshot) : []; reconcileSessionCacheWithSessions(); renderSessionList(); if (currentSessionId) { setCurrentSessionRunningState(!!getSessionMeta(currentSessionId)?.isRunning); } if (pendingInitialSessionLoad) { pendingInitialSessionLoad = false; initialSessionListHandled = true; syncViewForAgent(currentAgent, { preserveCurrent: false, loadLast: true }); } else if (currentSessionId && !getSessionMeta(currentSessionId)) { initialSessionListHandled = true; resetChatView(currentAgent); } else { initialSessionListHandled = true; } break; case 'session_info': const snapshot = normalizeSessionSnapshot(msg); const activeLoad = activeSessionLoad; const pendingNewSession = pendingNewSessionRequest; const messageRequestId = String(msg.requestId || ''); const looksLikeCreatedSession = Array.isArray(msg.messages) && msg.messages.length === 0 && !msg.historyPending && !msg.isRunning; const matchesPendingNewSessionFallback = !!(pendingNewSession && !messageRequestId && looksLikeCreatedSession && snapshot.sessionId && snapshot.sessionId !== currentSessionId && snapshot.agent === pendingNewSession.agent && (!pendingNewSession.cwd || snapshot.cwd === pendingNewSession.cwd) && (!pendingNewSession.mode || snapshot.mode === pendingNewSession.mode)); const matchesActiveLoad = !!(activeLoad?.sessionId === msg.sessionId && (!activeLoad.requestId || activeLoad.requestId === messageRequestId)); const matchesPendingNewSession = !!(pendingNewSession && ((!pendingNewSession.requestId || pendingNewSession.requestId === messageRequestId) || matchesPendingNewSessionFallback)); const canSwitchToSessionInfo = matchesActiveLoad || matchesPendingNewSession || msg.sessionId === currentSessionId || (!currentSessionId && !activeLoad && !pendingNewSession) || (!messageRequestId && !activeLoad && !pendingNewSession); mergeSessionListSnapshot(snapshot); if (matchesActiveLoad) { activeSessionLoad.snapshot = snapshot; } if (!canSwitchToSessionInfo) { if (!msg.historyPending) cacheSessionSnapshot(snapshot); renderSessionList(); break; } if (matchesPendingNewSession) { pendingNewSessionRequest = null; if (!matchesActiveLoad) clearSessionLoading(); } applySessionSnapshot(snapshot, { immediate: isBlockingSessionLoad(msg.sessionId), suppressUnreadToast: false, preserveStreaming: msg.sessionId === currentSessionId && msg.isRunning, }); if (msg.sessionId === currentSessionId) { setCurrentSessionRunningState(!!msg.isRunning); } if (!msg.historyPending) { if (matchesActiveLoad) { finalizeLoadedSession(msg.sessionId); } else { cacheSessionSnapshot(snapshot); finishSessionSwitch(msg.sessionId); } } break; case 'session_history_chunk': if (msg.sessionId === currentSessionId && loadedHistorySessionId === msg.sessionId) { const blocking = isBlockingSessionLoad(msg.sessionId); if (activeSessionLoad?.sessionId === msg.sessionId && activeSessionLoad.snapshot) { activeSessionLoad.snapshot.messages = cloneMessages(msg.messages || []).concat(activeSessionLoad.snapshot.messages); } prependHistoryMessages(msg.messages || [], { preserveScroll: !blocking, skipScrollbar: blocking, baseIndex: Number.isFinite(Number(msg.historyBaseIndex)) ? Number(msg.historyBaseIndex) : 0, }); if (!msg.remaining) { finalizeLoadedSession(msg.sessionId); } } break; case 'session_message': if (msg.sessionId && msg.message) { const isCrossConversationReply = !!msg.message.crossConversation?.replyToRequestId; updateCachedSession(msg.sessionId, (snapshot) => { snapshot.messages = Array.isArray(snapshot.messages) ? snapshot.messages : []; snapshot.messages.push(deepClone(msg.message)); snapshot.updated = msg.message.timestamp || new Date().toISOString(); if (isCrossConversationReply) { snapshot.readyReplyCount = Math.max(0, Number(snapshot.readyReplyCount || 0) - 1); snapshot.pendingReplyCount = Math.max(0, Number(snapshot.pendingReplyCount || 0) - 1); snapshot.waitingOnChildren = Number(snapshot.pendingReplyCount || 0) > 0; } }); if (isCrossConversationReply) { sessions = sessions.map((session) => { if (session.id !== msg.sessionId) return session; const pendingReplyCount = Math.max(0, Number(session.pendingReplyCount || 0) - 1); const readyReplyCount = Math.max(0, Number(session.readyReplyCount || 0) - 1); return { ...session, readyReplyCount, pendingReplyCount, waitingOnChildren: pendingReplyCount > 0, }; }); } } if (msg.sessionId === currentSessionId && msg.message) { const messageIndex = currentSessionMessageCount; currentSessionMessageCount += 1; collectClosedCollabAgentIds([msg.message]).forEach((id) => closedCollabAgentIds.add(id)); const welcome = messagesDiv.querySelector('.welcome-msg'); if (welcome) welcome.remove(); const shouldFollow = !(currentSessionRunning || isGenerating) || isNearBottom(); messagesDiv.appendChild(buildMsgElement(msg.message, messageIndex)); followOutputIfNeeded(shouldFollow); setCurrentSessionRunningState(!!getSessionMeta(currentSessionId)?.isRunning); renderPendingCcwebPrompts({ scroll: false }); } renderSessionList(); break; case 'session_renamed': sessions = sessions.map((session) => session.id === msg.sessionId ? { ...session, title: msg.title } : session); updateCachedSession(msg.sessionId, (snapshot) => { snapshot.title = msg.title; }); if (msg.sessionId === currentSessionId) { chatTitle.textContent = msg.title; } renderSessionList(); break; case 'session_pinned': applySessionPinnedState(msg.sessionId, msg.pinnedAt || null); break; case 'text_delta': if (!ensureGeneratingForEvent(msg)) break; pendingText += msg.text; scheduleRender(); break; case 'content_blocks': if (!ensureGeneratingForEvent(msg)) break; if (Array.isArray(msg.blocks)) { if (!window.pendingContentBlocks) window.pendingContentBlocks = []; window.pendingContentBlocks.push(...msg.blocks); scheduleRender(); } break; case 'tool_start': if (!ensureGeneratingForEvent(msg)) break; if (isEmptyReasoningTool({ name: msg.name, input: msg.input, kind: msg.kind || null, meta: msg.meta || null, done: false })) 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 (!ensureGeneratingForEvent(msg)) break; if (!activeToolCalls.has(msg.toolUseId)) { activeToolCalls.set(msg.toolUseId, { name: msg.name, input: msg.input, kind: msg.kind || null, meta: msg.meta || null, result: msg.result, done: false, }); appendToolCall(msg.toolUseId, msg.name, msg.input, false, msg.kind || null, msg.meta || null, msg.result); } activeToolCalls.get(msg.toolUseId).done = false; if (msg.name) activeToolCalls.get(msg.toolUseId).name = msg.name; if (msg.input !== undefined) activeToolCalls.get(msg.toolUseId).input = msg.input; if (msg.kind) activeToolCalls.get(msg.toolUseId).kind = msg.kind; if (msg.meta) activeToolCalls.get(msg.toolUseId).meta = msg.meta; activeToolCalls.get(msg.toolUseId).result = msg.result; updateToolCall(msg.toolUseId, msg.result, false); break; case 'tool_end': if (!isCurrentSessionEvent(msg)) break; if (!activeToolCalls.has(msg.toolUseId) && !isEmptyReasoningTool({ name: msg.name, input: msg.input, result: msg.result, kind: msg.kind || null, meta: msg.meta || null, done: true })) { activeToolCalls.set(msg.toolUseId, { name: msg.name, input: msg.input, kind: msg.kind || null, meta: msg.meta || null, result: msg.result, done: true, }); appendToolCall(msg.toolUseId, msg.name, msg.input, true, msg.kind || null, msg.meta || null, msg.result); } if (activeToolCalls.has(msg.toolUseId)) { activeToolCalls.get(msg.toolUseId).done = true; if (msg.kind) activeToolCalls.get(msg.toolUseId).kind = msg.kind; if (msg.meta) activeToolCalls.get(msg.toolUseId).meta = msg.meta; activeToolCalls.get(msg.toolUseId).result = msg.result; } updateToolCall(msg.toolUseId, msg.result, true); break; case 'cost': if (!isCurrentSessionEvent(msg)) break; costDisplay.textContent = `$${msg.costUsd.toFixed(4)}`; if (currentSessionId) { updateCachedSession(currentSessionId, (snapshot) => { snapshot.totalCost = msg.costUsd; }); } 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}`; if (currentSessionId) { updateCachedSession(currentSessionId, (snapshot) => { snapshot.totalUsage = deepClone(msg.totalUsage); }); } } 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, { tone: msg.tone, transient: msg.transient, autoDismissMs: msg.autoDismissMs, }); break; case 'codex_app_steer_status': if (!isCurrentSessionEvent(msg)) break; updateCodexAppSteerMessage(msg.clientMessageId, msg.status, msg.message); break; case 'codex_app_user_input_request': if (msg.sessionId && msg.sessionId !== currentSessionId) { showToast('Codex App 需要输入', msg.sessionId); } showCodexAppUserInputModal(msg); break; case 'codex_app_approval_request': if (msg.sessionId && msg.sessionId !== currentSessionId) { showToast('Codex App 需要审批', msg.sessionId); } showCodexAppApprovalModal(msg); break; case 'ccweb_mcp_child_agent_update': applyCcwebMcpChildAgentUpdate(msg); break; case 'ccweb_prompt_user_update': applyCcwebPromptUserUpdate(msg); break; case 'ccweb_prompt_user_remove': applyCcwebPromptUserRemove(msg); break; case 'mcp_startup_status': rememberMcpStartupStatus(msg.sessionId, msg.mcpStatus || msg.status); showMcpStartupStatusToast(msg.mcpStatus || msg.status, msg.sessionId); break; case 'mode_changed': if (msg.mode && MODE_LABELS[msg.mode]) { currentMode = msg.mode; modeSelect.value = currentMode; localStorage.setItem(getAgentModeStorageKey(currentAgent), currentMode); if (currentSessionId) { updateCachedSession(currentSessionId, (snapshot) => { snapshot.mode = msg.mode; }); } } break; case 'model_changed': if (msg.model) { currentModel = msg.model; if (currentSessionId) { updateCachedSession(currentSessionId, (snapshot) => { snapshot.model = msg.model; }); } } 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(msg.sessionId || currentSessionId); } else { updateGenerationControls(); toolGroupCount = 0; hasGrouped = false; activeToolCalls.clear(); activeTodoCallTargets.clear(); const toolsDiv = document.querySelector('#streaming-msg .msg-tools'); if (toolsDiv) toolsDiv.innerHTML = ''; } pendingText = msg.text || ''; flushRender(); if (msg.toolCalls && msg.toolCalls.length > 0) { const mergedCollabTool = mergeCollabAgentTools(msg.toolCalls); const resumeToolCalls = [ ...(mergedCollabTool ? [mergedCollabTool] : []), ...msg.toolCalls.filter((tc) => toolKind(tc) !== 'collab_agent_tool_call'), ]; for (const tc of resumeToolCalls) { activeToolCalls.set(tc.id, { name: tc.name, input: tc.input, result: tc.result, kind: tc.kind || null, meta: tc.meta || null, done: tc.done, }); appendToolCall(tc.id, tc.name, tc.input, tc.done, tc.kind || null, tc.meta || null); if (tc.result !== undefined && tc.result !== null) { updateToolCall(tc.id, tc.result, !!tc.done); } } } break; case 'error': if (!isCurrentSessionEvent(msg)) break; if (msg.code === 'new_session_cwd_missing' && pendingNewSessionRequest?.cwd) { const request = pendingNewSessionRequest; pendingNewSessionRequest = null; showSimpleConfirm({ title: '目录不存在', message: `${msg.cwd || request.cwd}\n\n要先创建这个目录再进入新会话吗?`, confirmText: '创建目录', cancelText: '返回修改', onConfirm: () => { pendingNewSessionRequest = { ...request }; send({ type: 'new_session', cwd: request.cwd, agent: request.agent, mode: request.mode, model: request.model, title: request.title, branchSourceSessionId: request.branchSourceSessionId, branchMessageIndex: request.branchMessageIndex, createCwd: true, requestId: request.requestId, }); }, onCancel: () => { showNewSessionModal({ agent: request.agent, cwd: request.rawCwd || request.cwd, mode: request.mode, model: request.model, title: request.title, branchSourceSessionId: request.branchSourceSessionId, branchMessageIndex: request.branchMessageIndex, }); }, }); clearSessionLoading(); if (!isGenerating && currentSessionId) { setCurrentSessionRunningState(!!getSessionMeta(currentSessionId)?.isRunning); } break; } if (pendingNewSessionRequest) pendingNewSessionRequest = null; appendError(msg.message, { transient: msg.transient, autoDismissMs: msg.autoDismissMs, }); clearSessionLoading(); if (!isGenerating && currentSessionId) { setCurrentSessionRunningState(!!getSessionMeta(currentSessionId)?.isRunning); } if (isGenerating) finishGenerating(); break; case 'notify_config': if (typeof _onNotifyConfig === 'function') _onNotifyConfig(msg.config); // Update summary in parent settings panel if visible if (msg.config) { const provider = msg.config.provider || 'off'; const providerLabel = PROVIDER_OPTIONS.find(o => o.value === provider)?.label || '关闭'; const summaryOn = msg.config.summary?.enabled ? '摘要已启用' : '摘要关闭'; const meta = provider === 'off' ? '未启用' : `${providerLabel} · ${summaryOn}`; document.querySelectorAll('[data-notify-summary]').forEach(el => { el.textContent = meta; }); } break; case 'notify_test_result': if (typeof _onNotifyTestResult === 'function') _onNotifyTestResult(msg); break; case 'model_config': if (typeof _onModelConfig === 'function') _onModelConfig(msg.config); break; case 'codex_config': codexConfigCache = msg.config || null; if (typeof _onCodexConfig === 'function') _onCodexConfig(msg.config); break; case 'fetch_models_result': if (typeof _onFetchModelsResult === 'function') _onFetchModelsResult(msg); break; case 'background_done': // A background task completed (browser was disconnected or viewing another session) showToast(`「${msg.title}」任务完成`, msg.sessionId); showBrowserNotification(msg.title); if (msg.sessionId === currentSessionId) { // Reload current session to show completed response openSession(msg.sessionId, { forceSync: true, blocking: false }); } else { send({ type: 'list_sessions' }); } break; case 'password_changed': handlePasswordChanged(msg); break; case 'native_sessions': if (typeof _onNativeSessions === 'function') _onNativeSessions(msg.groups || []); break; case 'codex_sessions': if (typeof _onCodexSessions === 'function') _onCodexSessions(msg.sessions || []); break; case 'cwd_suggestions': if (typeof _onCwdSuggestions === 'function') _onCwdSuggestions(msg); break; case 'composer_suggestions': handleComposerSuggestions(msg); break; case 'update_info': if (typeof window._ccOnUpdateInfo === 'function') window._ccOnUpdateInfo(msg); break; } } // --- Generating State --- 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 = []; activeToolCalls.clear(); activeTodoCallTargets.clear(); toolGroupCount = 0; hasGrouped = false; updateNoteModeUI(); renderPendingNotes({ scroll: false }); // 不禁用输入框,允许用户继续输入(但无法发送) const welcome = messagesDiv.querySelector('.welcome-msg'); if (welcome) welcome.remove(); 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 = ''; const textDiv = document.createElement('div'); textDiv.className = 'msg-text'; textDiv.innerHTML = '
'; const toolsDiv = document.createElement('div'); toolsDiv.className = 'msg-tools'; bubble.appendChild(textDiv); bubble.appendChild(toolsDiv); syncAssistantLastSectionButton(msgEl); messagesDiv.appendChild(msgEl); scrollToBottom(); return true; } function finishGenerating(sessionId) { if (sessionId && currentSessionId && sessionId !== currentSessionId) return; const hasPersistedAssistantMessage = !!( pendingText || (Array.isArray(window.pendingContentBlocks) && window.pendingContentBlocks.length > 0) ); isGenerating = false; generatingSessionId = null; updateNoteModeUI(); setCurrentSessionRunningState(false); msgInput.focus(); if (pendingText || (window.pendingContentBlocks && window.pendingContentBlocks.length > 0)) { flushRender(); } window.pendingContentBlocks = []; const typing = document.querySelector('.typing-indicator'); if (typing) typing.remove(); const streamEl = document.getElementById('streaming-msg'); if (streamEl) { // 若本轮出现过父目录,把末尾散落的 .tool-call 也一并收入同一父节点 if (hasGrouped) { const toolsDiv = streamEl.querySelector('.msg-tools'); if (toolsDiv) { const loose = Array.from(toolsDiv.children).filter(isGroupableToolCall); if (loose.length > 0) { let group = toolsDiv.querySelector(':scope > .tool-group'); if (!group) { group = document.createElement('details'); group.className = 'tool-group'; const gs = document.createElement('summary'); gs.className = 'tool-group-summary'; group.appendChild(gs); const inner = document.createElement('div'); inner.className = 'tool-group-inner'; group.appendChild(inner); toolsDiv.insertBefore(group, toolsDiv.firstChild); } const inner = group.querySelector('.tool-group-inner'); loose.forEach(c => inner.appendChild(c)); _refreshGroupSummary(group); } } } streamEl.removeAttribute('id'); if (hasPersistedAssistantMessage && currentSessionId) { const messageIndex = currentSessionMessageCount; currentSessionMessageCount += 1; markSessionMessageElement(streamEl, messageIndex); } syncAssistantLastSectionButton(streamEl); } if (sessionId) { migratePendingNotesToSession(sessionId, currentAgent); migrateQueuedMessagesToSession(sessionId, currentAgent); } pendingText = ''; activeToolCalls.clear(); activeTodoCallTargets.clear(); toolGroupCount = 0; hasGrouped = false; renderPendingNotes(); scheduleQueuedMessageDrain(); } // --- Rendering --- function scheduleRender() { if (renderTimer) return; renderTimer = setTimeout(() => { renderTimer = null; flushRender(); }, RENDER_DEBOUNCE); } function flushRender() { const streamEl = document.getElementById('streaming-msg'); if (!streamEl) return; const bubble = streamEl.querySelector('.msg-bubble'); if (!bubble) return; const shouldFollow = isNearBottom(); if (window.pendingContentBlocks && window.pendingContentBlocks.length > 0) { bubble.innerHTML = ''; renderAssistantContent(bubble, window.pendingContentBlocks); } else if (pendingText) { let textDiv = bubble.querySelector('.msg-text'); if (!textDiv) { textDiv = bubble; } setRenderedMarkdown(textDiv, pendingText); } syncAssistantLastSectionButton(streamEl); followOutputIfNeeded(shouldFollow); } function renderMarkdown(text) { if (!text) return '
'; try { return marked.parse(text); } catch { return escapeHtml(text); } } function codexAppSteerStatusLabel(status) { if (status === 'inserted') return '已插入'; if (status === 'failed') return '插入失败'; return '引导中...'; } function setCodexAppSteerStatusElement(element, status, message) { if (!element) return false; const normalized = ['pending', 'inserted', 'failed'].includes(status) ? status : 'pending'; element.classList.add('codex-steer-message'); element.classList.toggle('codex-steer-pending', normalized === 'pending'); element.classList.toggle('codex-steer-inserted', normalized === 'inserted'); element.classList.toggle('codex-steer-failed', normalized === 'failed'); const bubble = element.querySelector('.msg-bubble'); if (!bubble) return false; let statusEl = bubble.querySelector('.codex-steer-status'); if (!statusEl) { statusEl = document.createElement('div'); statusEl.className = 'codex-steer-status'; bubble.appendChild(statusEl); } statusEl.dataset.status = normalized; statusEl.textContent = message || codexAppSteerStatusLabel(normalized); return true; } function updateCodexAppSteerMessage(clientMessageId, status, message) { const id = String(clientMessageId || '').trim(); if (!id) return false; const indexed = userMessageIndex.get(id); const element = indexed?.element || messagesDiv.querySelector(`[data-message-id="${cssEscape(id)}"]`); return setCodexAppSteerStatusElement(element, status, message); } function scheduleTransientMessageRemoval(element, timeoutMs) { const ttl = Number(timeoutMs); if (!element || !Number.isFinite(ttl) || ttl <= 0) return; window.setTimeout(() => { if (!element || !element.isConnected) return; element.classList.add('is-dismissing'); window.setTimeout(() => { if (!element || !element.isConnected) return; element.remove(); updateScrollbar(); }, 220); }, ttl); } function normalizeMentionList(value) { return Array.isArray(value) ? value.filter((item) => item && typeof item === 'object') : []; } function escapeHtmlAttr(value) { return String(value || '') .replace(/&/g, '&') .replace(/"/g, '"') .replace(//g, '>'); } function shortPreviewText(text, maxLength = 140) { const normalized = String(text || '').trim().replace(/\s+/g, ' '); if (!normalized) return ''; return normalized.length > maxLength ? `${normalized.slice(0, Math.max(0, maxLength - 3))}...` : normalized; } function mentionDependencyStateLabel(state) { return state === 'configured' ? '已配置' : state === 'declared' ? '未配置' : ''; } function mentionDependencyLabel(dep) { const name = dep?.value || dep?.name || dep?.server || ''; const state = mentionDependencyStateLabel(dep?.state); return state ? `${name} · ${state}` : name; } function buildMentionTooltip(mention) { const lines = []; const title = mention.title || mention.name || mention.label || ''; const description = shortPreviewText(mention.description || ''); if (title) lines.push(title); if (description) lines.push(description); if (mention.defaultPromptPreview) lines.push(`默认提示: ${shortPreviewText(mention.defaultPromptPreview, 180)}`); const dependencies = Array.isArray(mention.dependencies) ? mention.dependencies : []; for (const dep of dependencies.slice(0, 4)) { const label = mentionDependencyLabel(dep); if (label) lines.push(`MCP: ${label}`); } return lines.join('\n'); } function createMentionChip(mention) { const chip = document.createElement('div'); const kind = String(mention.kind || '').trim() || 'mention'; chip.className = `msg-mention-chip kind-${kind}`; if (mention.brandColor) chip.style.setProperty('--mention-accent', mention.brandColor); const tooltip = buildMentionTooltip(mention); if (tooltip) chip.title = tooltip; if (kind === 'skill') { const badge = document.createElement('span'); badge.className = 'msg-mention-badge'; if (mention.iconSmall && /^https?:\/\//i.test(String(mention.iconSmall))) { const img = document.createElement('img'); img.src = mention.iconSmall; img.alt = mention.title || mention.name || 'skill'; img.loading = 'lazy'; badge.appendChild(img); } else { badge.textContent = 'Skill'; } chip.appendChild(badge); } const body = document.createElement('div'); body.className = 'msg-mention-body'; const title = document.createElement('div'); title.className = 'msg-mention-title'; if (kind === 'skill') { title.textContent = mention.title || mention.name || mention.label || ''; } else if (kind === 'prompt') { title.textContent = mention.title || mention.label || mention.name || ''; } else { title.textContent = mention.label || mention.name || ''; } body.appendChild(title); const descriptionText = mention.description || (kind === 'prompt' ? 'Prompt 模板' : ''); if (descriptionText) { const desc = document.createElement('div'); desc.className = 'msg-mention-desc'; desc.textContent = shortPreviewText(descriptionText, kind === 'skill' ? 92 : 72); body.appendChild(desc); } const dependencies = Array.isArray(mention.dependencies) ? mention.dependencies : []; if (dependencies.length > 0) { const meta = document.createElement('div'); meta.className = 'msg-mention-meta'; dependencies.slice(0, 2).forEach((dep) => { const pill = document.createElement('span'); pill.className = `msg-mention-pill state-${dep.state || 'declared'}`; pill.textContent = mentionDependencyLabel(dep); meta.appendChild(pill); }); body.appendChild(meta); } chip.appendChild(body); return chip; } function renderComposerMentionsStrip(meta) { const mentions = normalizeMentionList(meta?.composerMentions); if (mentions.length === 0) return null; const wrap = document.createElement('div'); wrap.className = 'msg-mentions'; mentions.forEach((mention) => wrap.appendChild(createMentionChip(mention))); return wrap; } function createMsgElement(role, content, attachments = [], meta = {}) { const div = document.createElement('div'); const isCrossConversation = !!meta.crossConversation; const isCrossConversationReply = isCrossConversation && !!(meta.crossConversation.reply || meta.crossConversation.replyToRequestId); const canCollapseCrossConversationReply = role === 'assistant' && isCrossConversationReply; const resolvedMessageId = meta?.messageId || meta?.id || createLocalId('user'); div.className = `msg ${role}${role === 'assistant' ? ' agent-' + currentAgent : ''}${isCrossConversation ? ' cross-conversation' : ''}${isCrossConversationReply ? ' cross-conversation-reply' : ''}`; if (role === 'user') { div.id = `hapi-message-${resolvedMessageId}`; div.dataset.messageId = resolvedMessageId; } if (role === 'system') { const tone = String(meta.tone || 'neutral').trim() || 'neutral'; const bubble = document.createElement('div'); bubble.className = 'msg-bubble'; bubble.dataset.tone = tone; const text = document.createElement('span'); text.className = 'system-message-text'; text.textContent = content; bubble.appendChild(text); const transient = !!meta.transient; if (transient) { div.classList.add('transient'); } const closeBtn = document.createElement('button'); closeBtn.type = 'button'; closeBtn.className = 'system-message-close'; closeBtn.title = '关闭提示'; closeBtn.setAttribute('aria-label', '关闭提示'); closeBtn.textContent = '×'; closeBtn.addEventListener('click', (event) => { event.stopPropagation(); div.remove(); updateScrollbar(); }); bubble.appendChild(closeBtn); div.appendChild(bubble); if (transient) { scheduleTransientMessageRemoval(div, meta.autoDismissMs || 6000); } return div; } const avatar = document.createElement('div'); avatar.className = 'msg-avatar'; if (isCrossConversation) { avatar.textContent = isCrossConversationReply ? '↩' : '↗'; } else if (role === 'user') { avatar.textContent = 'U'; } else if (isCodexLikeAgent(currentAgent)) { avatar.innerHTML = `${isCodexAppAgent(currentAgent) ? 'Codex App' : 'Codex'}`; } else { avatar.innerHTML = `Claude`; } const bubble = document.createElement('div'); bubble.className = 'msg-bubble'; let crossConversationReplyCollapseKey = ''; let crossConversationReplyToggle = null; let crossConversationReplyBody = null; const applyCrossConversationReplyCollapseState = (collapsed) => { if (!crossConversationReplyToggle || !crossConversationReplyBody) return; div.classList.toggle('cross-conversation-collapsed', collapsed); bubble.dataset.collapsed = collapsed ? 'true' : 'false'; crossConversationReplyBody.hidden = collapsed; crossConversationReplyToggle.textContent = collapsed ? '展开' : '收起'; crossConversationReplyToggle.title = collapsed ? '展开返回消息' : '收起返回消息'; crossConversationReplyToggle.setAttribute('aria-label', collapsed ? '展开返回消息' : '收起返回消息'); crossConversationReplyToggle.setAttribute('aria-expanded', String(!collapsed)); updateScrollbar(); }; if (canCollapseCrossConversationReply) { crossConversationReplyCollapseKey = getCrossConversationReplyCollapseKey(meta); if (crossConversationReplyCollapseKey) { div.dataset.crossConversationReplyKey = crossConversationReplyCollapseKey; } } if (isCrossConversation) { const source = meta.crossConversation || {}; const sourceTitle = source.sourceTitle || '未命名对话'; const sourceId = source.sourceSessionId || ''; const sourceMeta = document.createElement('div'); sourceMeta.className = 'cross-conversation-meta'; const label = document.createElement('span'); label.className = 'cross-conversation-label'; label.textContent = isCrossConversationReply ? `来自「${sourceTitle}」的回复` : `来自「${sourceTitle}」的对话`; sourceMeta.appendChild(label); if (sourceId) { const copyBtn = document.createElement('button'); copyBtn.type = 'button'; copyBtn.className = 'cross-conversation-id-btn'; copyBtn.textContent = `ID ${shortSessionId(sourceId)}`; copyBtn.title = `复制来源会话 ID\n${sourceId}`; copyBtn.addEventListener('click', (event) => { event.stopPropagation(); copyTextToClipboard(sourceId, '来源会话 ID 已复制'); }); sourceMeta.appendChild(copyBtn); } if (canCollapseCrossConversationReply) { const replyTimeText = formatCrossConversationReplyTime(meta.timestamp || source.processedAt || source.sentAt); if (replyTimeText) { const time = document.createElement('span'); time.className = 'cross-conversation-time'; time.textContent = replyTimeText; sourceMeta.appendChild(time); } crossConversationReplyToggle = document.createElement('button'); crossConversationReplyToggle.type = 'button'; crossConversationReplyToggle.className = 'cross-conversation-collapse-btn'; crossConversationReplyToggle.addEventListener('click', (event) => { event.stopPropagation(); const collapsed = !div.classList.contains('cross-conversation-collapsed'); setCrossConversationReplyCollapsed(crossConversationReplyCollapseKey, collapsed); applyCrossConversationReplyCollapseState(collapsed); }); sourceMeta.appendChild(crossConversationReplyToggle); } bubble.appendChild(sourceMeta); } if (role === 'user') { if (content) { const textNode = document.createElement('div'); textNode.className = 'msg-text'; textNode.style.whiteSpace = 'pre-wrap'; textNode.textContent = content; bubble.appendChild(textNode); const copyBtn = document.createElement('button'); copyBtn.type = 'button'; copyBtn.className = 'msg-copy-btn'; copyBtn.title = '复制用户消息'; copyBtn.setAttribute('aria-label', '复制用户消息'); copyBtn.innerHTML = ` `; copyBtn.addEventListener('click', (event) => { event.stopPropagation(); copyTextToClipboard(content, '用户消息已复制'); }); bubble.appendChild(copyBtn); } if (attachments.length > 0) { bubble.insertAdjacentHTML('beforeend', renderAttachmentPreviews(attachments)); } const mentionsStrip = renderComposerMentionsStrip(meta); if (mentionsStrip) bubble.appendChild(mentionsStrip); } else { const assistantContentTarget = canCollapseCrossConversationReply ? document.createElement('div') : bubble; if (canCollapseCrossConversationReply) { assistantContentTarget.className = 'cross-conversation-reply-body'; crossConversationReplyBody = assistantContentTarget; } renderAssistantContent(assistantContentTarget, content); if (attachments.length > 0) { assistantContentTarget.insertAdjacentHTML('beforeend', renderAttachmentPreviews(attachments)); } if (canCollapseCrossConversationReply) { bubble.appendChild(assistantContentTarget); } } hydrateAttachmentPreviews(bubble, attachments); div.appendChild(avatar); div.appendChild(bubble); if (role === 'assistant') { syncAssistantLastSectionButton(div); } if (canCollapseCrossConversationReply) { applyCrossConversationReplyCollapseState(isCrossConversationReplyCollapsed(crossConversationReplyCollapseKey)); } if (role === 'user' && meta.codexAppSteerStatus) { setCodexAppSteerStatusElement(div, meta.codexAppSteerStatus, meta.codexAppSteerMessage); } if (role === 'user') { registerUserMessage(resolvedMessageId, div, content); } return div; } function renderAssistantContent(bubble, content) { if (!content) return; if (typeof content === 'string') { // 检测并提取 JSON 格式的 todo_list(可能在代码块中) const jsonMatch = content.match(/```json\s*(\{[\s\S]*?"type"\s*:\s*"todo_list"[\s\S]*?\})\s*```/); if (jsonMatch) { try { const parsed = JSON.parse(jsonMatch[1]); if (parsed.type === 'todo_list') { const before = content.substring(0, jsonMatch.index); const after = content.substring(jsonMatch.index + jsonMatch[0].length); if (before.trim()) { const textDiv = document.createElement('div'); setRenderedMarkdown(textDiv, before, false); bubble.appendChild(textDiv); hydrateRenderedMarkdown(textDiv); } bubble.appendChild(createTodoListElement(parsed)); if (after.trim()) { const textDiv = document.createElement('div'); setRenderedMarkdown(textDiv, after, false); bubble.appendChild(textDiv); hydrateRenderedMarkdown(textDiv); } return; } } catch (e) {} } // 尝试直接解析 JSON const trimmed = content.trim(); if (trimmed.startsWith('{') && trimmed.includes('"type"') && trimmed.includes('"todo_list"')) { try { const parsed = JSON.parse(trimmed); if (parsed.type === 'todo_list') { bubble.appendChild(createTodoListElement(parsed)); return; } } catch (e) {} } setRenderedMarkdown(bubble, content); return; } if (Array.isArray(content)) { content.forEach(block => { if (block.type === 'text') { const textDiv = document.createElement('div'); setRenderedMarkdown(textDiv, block.text || '', false); bubble.appendChild(textDiv); hydrateRenderedMarkdown(textDiv); } else if (block.type === 'todo_list') { bubble.appendChild(createTodoListElement(block)); } }); return; } setRenderedMarkdown(bubble, String(content)); } function createTodoListElement(block) { const container = document.createElement('div'); container.className = 'todo-list-container'; container.dataset.todoId = block.id || ''; const list = document.createElement('ul'); list.className = 'todo-list'; if (Array.isArray(block.items)) { block.items.forEach((item, index) => { const li = document.createElement('li'); li.className = 'todo-item' + (item.completed ? ' completed' : ''); const checkbox = document.createElement('input'); checkbox.type = 'checkbox'; checkbox.className = 'todo-checkbox'; checkbox.checked = item.completed || false; checkbox.dataset.index = index; const label = document.createElement('span'); label.className = 'todo-text'; label.textContent = item.text || ''; li.appendChild(checkbox); li.appendChild(label); list.appendChild(li); }); } container.appendChild(list); return container; } let renderEpoch = 0; function toolKind(tool) { return tool?.kind || tool?.meta?.kind || ''; } function normalizeDisplayPath(filePath) { const rawPath = typeof filePath === 'string' ? filePath.trim() : ''; if (!rawPath) return ''; const normalizedPath = rawPath.replace(/\\/g, '/'); const normalizedCwd = typeof currentCwd === 'string' ? currentCwd.trim().replace(/\\/g, '/') : ''; if (normalizedCwd && normalizedPath.startsWith(`${normalizedCwd}/`)) { return normalizedPath.slice(normalizedCwd.length + 1); } return normalizedPath; } function getFileChangeDisplay(tool) { const changes = Array.isArray(tool?.input?.changes) ? tool.input.changes : []; const primaryChange = changes[0] || null; const rawPath = tool?.meta?.subtitle || primaryChange?.path || tool?.input?.path || tool?.input?.file_path || ''; const filePath = normalizeDisplayPath(rawPath); const rawKind = primaryChange?.kind || tool?.input?.kind || ''; let action = '修改'; if (rawKind === 'create' || rawKind === 'new') { action = '创建'; } else if (rawKind === 'delete' || rawKind === 'remove') { action = '删除'; } else if (rawKind === 'update' || rawKind === 'edit') { action = '更新'; } else if (tool?.input?.new_string && tool?.input?.old_string) { action = '更新'; } return { action, filePath, changeCount: changes.length }; } function toolTitle(tool) { if (toolKind(tool) === 'file_change') { const { action, filePath, changeCount } = getFileChangeDisplay(tool); if (filePath && changeCount > 1) return `${action} ${filePath} 等 ${changeCount} 个文件`; if (filePath) return `${action} ${filePath}`; if (changeCount > 1) return `修改 ${changeCount} 个文件`; return tool?.meta?.title || 'File Change'; } if (tool?.meta?.title) return tool.meta.title; return tool?.name || 'Tool'; } function toolSubtitle(tool) { if (toolKind(tool) === 'file_change') { return ''; } if (tool?.meta?.subtitle) return tool.meta.subtitle; if (toolKind(tool) === 'command_execution') { return tool?.input?.command || ''; } return ''; } function stringifyToolValue(value) { if (typeof value === 'string') return value; if (value === null || value === undefined) return ''; try { return JSON.stringify(value, null, 2); } catch { return String(value); } } function reasoningPartText(parts) { if (!Array.isArray(parts)) return ''; return parts.map((part) => { if (typeof part === 'string') return part; if (typeof part?.text === 'string') return part.text; return ''; }).filter(Boolean).join('\n'); } function isEmptyReasoningTool(tool) { if (toolKind(tool) !== 'reasoning') return false; const resultText = stringifyToolValue(tool?.result).trim(); const input = tool?.input || {}; const inputText = [ reasoningPartText(input.content), reasoningPartText(input.summary), ].filter(Boolean).join('\n').trim(); return !resultText && !inputText; } function toolStateLabel(tool, done) { if (!done) return 'Running'; if (toolKind(tool) === 'command_execution' && typeof tool?.meta?.exitCode === 'number') { return `Exit ${tool.meta.exitCode}`; } return 'Done'; } function toolStateClass(tool, done) { if (!done) return 'running'; if (toolKind(tool) === 'command_execution' && typeof tool?.meta?.exitCode === 'number' && tool.meta.exitCode !== 0) { return 'error'; } return 'done'; } function applyToolSummary(summary, tool, done) { summary.innerHTML = ''; const icon = document.createElement('span'); icon.className = `tool-call-icon ${done ? 'done' : 'running'}`; const main = document.createElement('span'); main.className = 'tool-call-summary-main'; const label = document.createElement('span'); label.className = 'tool-call-label'; label.textContent = toolTitle(tool); main.appendChild(label); const subtitleText = toolSubtitle(tool); if (subtitleText) { const subtitle = document.createElement('span'); subtitle.className = 'tool-call-subtitle'; subtitle.textContent = subtitleText; main.appendChild(subtitle); } const state = document.createElement('span'); state.className = `tool-call-state ${toolStateClass(tool, done)}`; state.textContent = toolStateLabel(tool, done); summary.appendChild(icon); summary.appendChild(main); summary.appendChild(state); } function buildStructuredToolSection(labelText, bodyText) { const section = document.createElement('div'); section.className = 'tool-call-section'; const label = document.createElement('div'); label.className = 'tool-call-section-label'; label.textContent = labelText; const pre = document.createElement('pre'); pre.className = 'tool-call-code'; pre.textContent = bodyText; section.appendChild(label); section.appendChild(pre); return section; } function parseMaybeJsonObject(value) { if (value && typeof value === 'object') return value; if (typeof value !== 'string') return null; const trimmed = value.trim(); if (!trimmed || (!trimmed.startsWith('{') && !trimmed.startsWith('['))) return null; try { const parsed = JSON.parse(trimmed); return parsed && typeof parsed === 'object' ? parsed : null; } catch { return null; } } function summarizePrompt(prompt) { const text = typeof prompt === 'string' ? prompt.trim().replace(/\s+/g, ' ') : ''; if (!text) return ''; return text.length > 140 ? `${text.slice(0, 140)}…` : text; } function normalizeCollabAgentAction(value) { const raw = String(value || '').trim(); if (!raw) return ''; const name = raw.split(/[./]/).filter(Boolean).pop() || raw; const normalized = name.toLowerCase().replace(/[\s_-]/g, ''); if (normalized === 'spawn' || normalized === 'spawnagent') return 'spawn_agent'; if (normalized === 'wait' || normalized === 'waitagent') return 'wait_agent'; if (normalized === 'close' || normalized === 'closeagent') return 'close_agent'; if (normalized === 'sendinput' || normalized === 'sendmessage') return 'send_input'; if (normalized === 'resume' || normalized === 'resumeagent') return 'resume_agent'; return normalized; } function getCollabAgentAction(tool, data = null) { const value = data?.tool || tool?.name || tool?.meta?.title || ''; return normalizeCollabAgentAction(value); } function normalizeCollabAgentData(tool) { const inputData = effectiveObject(tool?.input); const resultData = effectiveObject(tool?.result); const merged = { ...inputData, ...resultData, agentsStates: resultData.agentsStates || resultData.agents_states || inputData.agentsStates || inputData.agents_states || {}, receiverThreadIds: resultData.receiverThreadIds || resultData.receiver_thread_ids || resultData.targets || inputData.receiverThreadIds || inputData.receiver_thread_ids || inputData.targets || [], prompt: inputData.prompt || resultData.prompt || '', tool: inputData.tool || resultData.tool || tool?.name || '', status: resultData.status || inputData.status || tool?.meta?.status || null, }; return merged; } function effectiveObject(value) { const parsed = parseMaybeJsonObject(value); if (parsed && !Array.isArray(parsed)) return parsed; if (value && typeof value === 'object' && !Array.isArray(value)) return value; return {}; } function collabAgentStateEntries(data) { const states = data?.agentsStates; if (!states || typeof states !== 'object') return []; return Object.entries(states).map(([id, value], index) => { const state = value && typeof value === 'object' ? value : { status: value }; const label = String(state.label || state.title || state.nickname || state.name || `子代理 ${index + 1}`); const role = String(state.role || state.agent || state.agentType || '').trim(); let status = String(state.status || state.state || 'pending').trim() || 'pending'; if (state.closedAt && collabStateTone(status) !== 'closed') status = 'closed'; const detail = String(state.candidateResult || state.finalMessage || state.summary || state.message || state.lastMessage || state.step || state.description || '').trim(); return { id, label, role, status, detail }; }); } function getCollabAgentIdsFromTool(tool) { const data = normalizeCollabAgentData(tool); const ids = new Set(); for (const entry of collabAgentStateEntries(data)) { if (entry.id) ids.add(entry.id); } if (Array.isArray(data.receiverThreadIds)) { data.receiverThreadIds.forEach((id) => { if (id) ids.add(String(id)); }); } const directIds = [ data.threadId, data.thread_id, data.agentId, data.agent_id, data.childThreadId, data.child_thread_id, data.childAgentId, data.child_agent_id, data.targetThreadId, data.target_thread_id, data.target, ]; directIds.forEach((id) => { if (id) ids.add(String(id)); }); const directArrays = [ data.threadIds, data.thread_ids, data.childThreadIds, data.child_thread_ids, data.agentIds, data.agent_ids, data.targets, ]; directArrays.forEach((value) => { if (!Array.isArray(value)) return; value.forEach((id) => { if (id) ids.add(String(id)); }); }); return Array.from(ids); } function getClosedCollabAgentIdsFromTool(tool) { if (toolKind(tool) !== 'collab_agent_tool_call') return []; const data = normalizeCollabAgentData(tool); const ids = new Set(); const action = getCollabAgentAction(tool, data); const allIds = getCollabAgentIdsFromTool(tool); if (action === 'close_agent' || collabStateTone(data.status) === 'closed') { allIds.forEach((id) => ids.add(id)); } collabAgentStateEntries(data).forEach((entry) => { if (entry.id && collabStateTone(entry.status) === 'closed') ids.add(entry.id); }); return Array.from(ids); } function collectClosedCollabAgentIds(messages) { const ids = new Set(); (Array.isArray(messages) ? messages : []).forEach((message) => { (Array.isArray(message?.toolCalls) ? message.toolCalls : []).forEach((tool) => { getClosedCollabAgentIdsFromTool(tool).forEach((id) => ids.add(id)); }); }); return ids; } function rememberClosedCollabAgentIdsFromTool(tool) { getClosedCollabAgentIdsFromTool(tool).forEach((id) => closedCollabAgentIds.add(id)); } function isGenericCollabAgentLabel(label, id) { const value = String(label || '').trim(); if (!value) return true; if (/^子代理\s*\d+$/i.test(value)) return true; return !!id && value === String(id); } function mergeCollabAgentTools(tools, options = {}) { const list = Array.isArray(tools) ? tools.filter((tool) => toolKind(tool) === 'collab_agent_tool_call') : []; if (list.length === 0) return null; const states = {}; const receiverThreadIds = []; const knownClosedIds = options.closedAgentIds instanceof Set ? options.closedAgentIds : closedCollabAgentIds; const localClosedIds = new Set(knownClosedIds || []); let toolName = '子代'; let prompt = ''; let status = ''; let done = false; list.forEach((tool, toolIndex) => { const data = normalizeCollabAgentData(tool); const action = getCollabAgentAction(tool, data); const isCloseAction = action === 'close_agent'; const displayAction = String(data.tool || tool.name || '').trim(); if (displayAction && !['wait_agent', 'close_agent'].includes(action)) toolName = displayAction; if (!prompt && data.prompt) prompt = data.prompt; if (isCloseAction) { getCollabAgentIdsFromTool(tool).forEach((id) => localClosedIds.add(id)); } const dataStatus = isCloseAction ? 'closed' : data.status; if (dataStatus) status = dataStatus; done = done || !!tool.done; collabAgentStateEntries(data).forEach((entry) => { if (!entry.id) return; states[entry.id] = { ...(states[entry.id] || {}), ...entry, status: isCloseAction || localClosedIds.has(entry.id) ? 'closed' : entry.status, }; if (collabStateTone(states[entry.id].status) === 'closed') localClosedIds.add(entry.id); }); getCollabAgentIdsFromTool(tool).forEach((id) => { if (!receiverThreadIds.includes(id)) receiverThreadIds.push(id); states[id] = { ...(states[id] || {}), label: states[id]?.label || `子代理 ${receiverThreadIds.length}`, status: isCloseAction || localClosedIds.has(id) ? 'closed' : (data.status || states[id]?.status || (tool.done ? 'completed' : 'running')), }; }); if (receiverThreadIds.length === 0 && list.length === 1) { const fallbackId = tool.id || `tool-${toolIndex + 1}`; receiverThreadIds.push(fallbackId); states[fallbackId] = { label: '子代理', status: isCloseAction ? 'closed' : (data.status || (tool.done ? 'completed' : 'running')), }; } }); receiverThreadIds.forEach((id, index) => { states[id] = { ...(states[id] || {}), label: states[id]?.label || `子代理 ${index + 1}`, status: localClosedIds.has(id) ? 'closed' : (states[id]?.status || 'pending'), }; }); const allClosed = receiverThreadIds.length > 0 && receiverThreadIds.every((id) => collabStateTone(states[id]?.status) === 'closed'); const mergedStatus = allClosed ? 'closed' : (status || (done ? 'completed' : 'running')); return { id: list[0].id || 'collab-agent-merged', name: list[0].name || 'ccweb_mcp_child_agent', kind: 'collab_agent_tool_call', done, input: { tool: toolName, prompt, status: mergedStatus, receiverThreadIds, agentsStates: states, }, }; } function collabStateTone(statusText) { const normalized = String(statusText || '').toLowerCase(); if (!normalized) return 'pending'; if (/(closed|close)/.test(normalized)) return 'closed'; if (/(returned|done|completed|success|finished|idle)/.test(normalized)) return 'done'; if (/(fail|error|cancel|aborted|rejected)/.test(normalized)) return 'error'; if (/(running|working|active|inprogress|in_progress|executing)/.test(normalized)) return 'running'; return 'pending'; } function collabStateLabel(statusText) { const normalized = String(statusText || '').trim(); if (!normalized) return '等待中'; const lower = normalized.toLowerCase(); if (/(closed|close)/.test(lower)) return '已关闭'; if (/(returned)/.test(lower)) return '已返回'; if (/(done|completed|success|finished)/.test(lower)) return '已返回'; if (/(fail|error|rejected)/.test(lower)) return '失败'; if (/(cancel|aborted)/.test(lower)) return '已取消'; if (/(running|working|active|inprogress|in_progress|executing)/.test(lower)) return '进行中'; if (/(idle|pending|queued|waiting)/.test(lower)) return '等待中'; return normalized; } function createCollabAgentToolElement(tool) { const data = normalizeCollabAgentData(tool); const wrapper = document.createElement('div'); wrapper.className = 'tool-call-content collab-agent-content'; const stack = document.createElement('div'); stack.className = 'collab-agent-stack'; const stateEntries = collabAgentStateEntries(data); const threadCount = Array.isArray(data.receiverThreadIds) ? data.receiverThreadIds.length : 0; const agentCount = stateEntries.length; const totalCount = agentCount || threadCount || 0; const promptText = summarizePrompt(data.prompt); const header = document.createElement('div'); header.className = 'collab-agent-header'; const titleWrap = document.createElement('div'); titleWrap.className = 'collab-agent-title-wrap'; const kicker = document.createElement('div'); kicker.className = 'collab-agent-kicker'; kicker.textContent = '子代'; titleWrap.appendChild(kicker); const title = document.createElement('div'); title.className = 'collab-agent-title'; title.textContent = `${totalCount || 0} 个`; titleWrap.appendChild(title); const meta = document.createElement('div'); meta.className = 'collab-agent-meta'; meta.textContent = ''; if (promptText) meta.title = promptText; if (promptText) titleWrap.appendChild(meta); header.appendChild(titleWrap); const headerActions = document.createElement('div'); headerActions.className = 'collab-agent-actions'; const statusChip = document.createElement('span'); const overallTone = collabStateTone(data.status || (tool.done ? 'completed' : 'running')); statusChip.className = `collab-agent-overall-status ${overallTone}`; statusChip.textContent = collabStateLabel(data.status || (tool.done ? 'completed' : 'running')); headerActions.appendChild(statusChip); header.appendChild(headerActions); stack.appendChild(header); if (stateEntries.length > 0) { const list = document.createElement('div'); list.className = 'collab-agent-list'; stateEntries.forEach((entry, index) => { const tone = collabStateTone(entry.status); const item = document.createElement('div'); item.className = 'collab-agent-item'; item.title = [ entry.label || `子代理 ${index + 1}`, entry.role ? `角色: ${entry.role}` : '', entry.detail ? `结果: ${entry.detail}` : '', entry.id ? `ID: ${entry.id}` : '', ].filter(Boolean).join('\n'); if (entry.id) { item.setAttribute('role', 'button'); item.tabIndex = 0; item.addEventListener('click', () => { copyTextToClipboard(entry.id, '子代理线程 ID 已复制'); }); item.addEventListener('keydown', (event) => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); copyTextToClipboard(entry.id, '子代理线程 ID 已复制'); } }); } const row = document.createElement('div'); row.className = 'collab-agent-item-row'; const label = document.createElement('div'); label.className = 'collab-agent-item-label'; label.textContent = !isGenericCollabAgentLabel(entry.label, entry.id) ? entry.label : `ID ${shortChildAgentId(entry.id || '')}`; row.appendChild(label); const chip = document.createElement('span'); chip.className = `collab-agent-item-status ${tone}`; chip.textContent = collabStateLabel(entry.status); row.appendChild(chip); if (entry.id && tone !== 'closed') { const closeBtn = document.createElement('button'); closeBtn.type = 'button'; closeBtn.className = 'collab-agent-close-btn'; closeBtn.textContent = '关闭'; closeBtn.title = `关闭子代理\n${entry.id}`; closeBtn.addEventListener('click', (event) => { event.stopPropagation(); closeBtn.disabled = true; closeBtn.textContent = '关闭中'; send({ type: 'ccweb_mcp_child_agent_close', sessionId: currentSessionId, threadId: entry.id, }); }); row.appendChild(closeBtn); } item.appendChild(row); if (entry.id || entry.role) { const footer = document.createElement('div'); footer.className = 'collab-agent-item-footer'; footer.textContent = entry.role || ''; if (!footer.textContent) footer.hidden = true; item.appendChild(footer); } list.appendChild(item); }); stack.appendChild(list); } if (stateEntries.length === 0 && Array.isArray(data.receiverThreadIds) && data.receiverThreadIds.length > 0) { const threads = document.createElement('div'); threads.className = 'collab-agent-threads'; data.receiverThreadIds.forEach((threadId) => { const btn = document.createElement('button'); btn.type = 'button'; btn.className = 'collab-agent-thread-chip'; btn.textContent = `ID ${shortChildAgentId(threadId)}`; btn.title = `复制子代理线程 ID\n${threadId}`; btn.addEventListener('click', () => { copyTextToClipboard(threadId, '子代理线程 ID 已复制'); }); threads.appendChild(btn); }); stack.appendChild(threads); } if (!promptText && stateEntries.length === 0 && (!Array.isArray(data.receiverThreadIds) || data.receiverThreadIds.length === 0)) { const empty = document.createElement('div'); empty.className = 'tool-call-empty'; empty.textContent = tool.done ? '子代理调用已结束,未返回结构化状态。' : '等待子代理状态…'; stack.appendChild(empty); } wrapper.appendChild(stack); return wrapper; } function isGroupableToolCall(node) { return !!(node?.classList?.contains('tool-call') && node.dataset.toolKind !== 'todo_list' && node.dataset.toolKind !== 'collab_agent_tool_call'); } function rememberToolCallTarget(toolUseId, tool, element) { if (!element) return; const entry = activeToolCalls.get(toolUseId); if (entry) { entry.domElement = element; } if (toolKind(tool) === 'todo_list' && tool?.input?.id) { activeTodoCallTargets.set(tool.input.id, element); } } function getLatestAssistantToolScope() { const streamEl = document.getElementById('streaming-msg'); if (streamEl) return streamEl; const agentSelector = `.msg.assistant.agent-${normalizeAgent(currentAgent)}`; const assistantMessages = messagesDiv.querySelectorAll(agentSelector); if (assistantMessages.length > 0) { return assistantMessages[assistantMessages.length - 1]; } const fallbackMessages = messagesDiv.querySelectorAll('.msg.assistant'); return fallbackMessages.length > 0 ? fallbackMessages[fallbackMessages.length - 1] : null; } function buildMsgElement(m, messageIndex = null) { const el = createMsgElement(m.role, m.content, m.attachments || [], m); if (m.ccwebPrompt) { const bubble = el.querySelector('.msg-bubble'); if (bubble) bubble.appendChild(createCcwebPromptElement(m.ccwebPrompt, { sessionId: currentSessionId })); } if (m.role === 'assistant' && m.toolCalls && m.toolCalls.length > 0) { const bubble = el.querySelector('.msg-bubble'); const toolMount = bubble.querySelector(':scope > .cross-conversation-reply-body') || bubble; const FOLD_AT = 3; let grouped = false; const mergedCollabTool = mergeCollabAgentTools(m.toolCalls); const renderToolCalls = [ ...(mergedCollabTool ? [mergedCollabTool] : []), ...m.toolCalls.filter((tc) => toolKind(tc) !== 'collab_agent_tool_call'), ]; for (const tc of renderToolCalls) { if (isEmptyReasoningTool(tc)) continue; const details = createToolCallElement(tc.id || `saved-${Math.random().toString(36).slice(2)}`, tc, true); // 散落的 .tool-call 达到 FOLD_AT 个时,移入唯一 .tool-group const loose = Array.from(toolMount.children).filter(isGroupableToolCall); if (loose.length >= FOLD_AT) { let group = toolMount.querySelector(':scope > .tool-group'); if (!group) { group = document.createElement('details'); group.className = 'tool-group'; const gs = document.createElement('summary'); gs.className = 'tool-group-summary'; group.appendChild(gs); const inner = document.createElement('div'); inner.className = 'tool-group-inner'; group.appendChild(inner); toolMount.insertBefore(group, toolMount.firstChild); grouped = true; } const inner = group.querySelector('.tool-group-inner'); loose.forEach(c => inner.appendChild(c)); _refreshGroupSummary(group); } toolMount.appendChild(details); } // 结束时若出现过父目录,收尾散落项 if (grouped) { const loose = Array.from(toolMount.children).filter(isGroupableToolCall); if (loose.length > 0) { const group = toolMount.querySelector(':scope > .tool-group'); if (group) { const inner = group.querySelector('.tool-group-inner'); loose.forEach(c => inner.appendChild(c)); _refreshGroupSummary(group); } } } } if (Number.isFinite(messageIndex)) { markSessionMessageElement(el, messageIndex); } return el; } function renderMessages(messages, options = {}) { renderEpoch++; const epoch = renderEpoch; const baseIndex = Number.isFinite(Number(options.baseIndex)) ? Number(options.baseIndex) : 0; closedCollabAgentIds = collectClosedCollabAgentIds(messages); messagesDiv.innerHTML = ''; clearUserMessageIndex(); if (messages.length === 0) { messagesDiv.innerHTML = buildWelcomeMarkup(currentAgent); updateUserOutlinePanel(); renderPendingNotes({ scroll: false }); scrollToBottom(); return; } if (options.immediate) { const frag = document.createDocumentFragment(); messages.forEach((message, index) => frag.appendChild(buildMsgElement(message, baseIndex + index))); messagesDiv.appendChild(frag); updateUserOutlinePanel(); renderPendingNotes({ scroll: false }); scrollToBottom(); return; } // Batch render: last 10 first, then next 20, then the rest const batches = []; const len = messages.length; if (len <= 10) { batches.push([0, len]); } else if (len <= 30) { batches.push([len - 10, len]); batches.push([0, len - 10]); } else { batches.push([len - 10, len]); batches.push([len - 30, len - 10]); batches.push([0, len - 30]); } // Render first batch immediately const frag0 = document.createDocumentFragment(); for (let i = batches[0][0]; i < batches[0][1]; i++) frag0.appendChild(buildMsgElement(messages[i], baseIndex + i)); messagesDiv.appendChild(frag0); updateUserOutlinePanel(); renderPendingNotes({ scroll: false }); scrollToBottom(); // Render remaining batches asynchronously, prepending each // Use scrollHeight delta to keep current view position stable after prepend let delay = 0; for (let b = 1; b < batches.length; b++) { const [start, end] = batches[b]; delay += 16; setTimeout(() => { if (renderEpoch !== epoch) return; // session switched, abort stale render const prevHeight = messagesDiv.scrollHeight; const prevScrollTop = messagesDiv.scrollTop; const frag = document.createDocumentFragment(); for (let i = start; i < end; i++) frag.appendChild(buildMsgElement(messages[i], baseIndex + i)); messagesDiv.insertBefore(frag, messagesDiv.firstChild); updateUserOutlinePanel(); // Compensate scrollTop so visible area stays unchanged messagesDiv.scrollTop = prevScrollTop + (messagesDiv.scrollHeight - prevHeight); updateScrollbar(); }, delay); } } function prependHistoryMessages(messages, options = {}) { if (!Array.isArray(messages) || messages.length === 0) return; const baseIndex = Number.isFinite(Number(options.baseIndex)) ? Number(options.baseIndex) : 0; collectClosedCollabAgentIds(messages).forEach((id) => closedCollabAgentIds.add(id)); const preserveScroll = options.preserveScroll !== false; const skipScrollbar = options.skipScrollbar === true; const welcome = messagesDiv.querySelector('.welcome-msg'); if (welcome) welcome.remove(); const frag = document.createDocumentFragment(); messages.forEach((m, index) => frag.appendChild(buildMsgElement(m, baseIndex + index))); if (!preserveScroll) { messagesDiv.insertBefore(frag, messagesDiv.firstChild); updateUserOutlinePanel(); if (!skipScrollbar) updateScrollbar(); return; } const prevHeight = messagesDiv.scrollHeight; const prevScrollTop = messagesDiv.scrollTop; messagesDiv.insertBefore(frag, messagesDiv.firstChild); updateUserOutlinePanel(); messagesDiv.scrollTop = prevScrollTop + (messagesDiv.scrollHeight - prevHeight); if (!skipScrollbar) updateScrollbar(); } function normalizeAskUserInput(input) { if (input === null || input === undefined) return null; if (typeof input === 'string') { const trimmed = input.trim(); if (!trimmed) return null; try { return JSON.parse(trimmed); } catch { return null; } } return input; } function extractAskUserQuestions(input) { const parsed = normalizeAskUserInput(input); if (!parsed || !Array.isArray(parsed.questions)) return []; return parsed.questions; } function appendAskOptionToInput(question, option) { const header = (question?.header || '').trim() || '问题'; const line = `【${header}】${option?.label || ''}`; const current = msgInput.value.trim(); msgInput.value = current ? `${current}\n${line}` : line; autoResize(); msgInput.focus(); } function createAskUserQuestionView(questions) { const wrapper = document.createElement('div'); wrapper.className = 'ask-user-question'; questions.forEach((q, idx) => { const card = document.createElement('div'); card.className = 'ask-question-card'; const header = document.createElement('div'); header.className = 'ask-question-header'; header.textContent = `${idx + 1}. ${q.header || '问题'}`; card.appendChild(header); const body = document.createElement('div'); body.className = 'ask-question-text'; body.textContent = q.question || ''; card.appendChild(body); if (Array.isArray(q.options) && q.options.length > 0) { const hasDesc = q.options.some(o => o.description); // 左右分栏容器 const layout = document.createElement('div'); layout.className = 'ask-options-layout' + (hasDesc ? ' has-preview' : ''); const opts = document.createElement('div'); opts.className = 'ask-question-options'; // 右侧预览区(仅在有 description 时创建) const preview = hasDesc ? document.createElement('div') : null; if (preview) { preview.className = 'ask-option-preview'; // 默认显示第一项 preview.textContent = q.options[0].description || ''; } // 当前选中项(移动端 tap-to-preview 状态) let selectedOpt = null; let selectedBtn = null; q.options.forEach((opt, i) => { const item = document.createElement('button'); item.type = 'button'; item.className = 'ask-option-item'; const title = document.createElement('div'); title.className = 'ask-option-label'; title.textContent = `${i + 1}. ${opt.label || ''}`; item.appendChild(title); // 桌面:hover 切换预览 if (preview) { item.addEventListener('mouseenter', () => { preview.textContent = opt.description || ''; }); } item.addEventListener('click', (e) => { const isTouch = item.dataset.touchActivated === '1'; item.dataset.touchActivated = ''; if (isTouch) { // 移动端:第一次 tap = 选中预览,不发送 if (selectedBtn !== item) { if (selectedBtn) selectedBtn.classList.remove('ask-option-selected'); selectedBtn = item; selectedOpt = opt; item.classList.add('ask-option-selected'); if (preview) preview.textContent = opt.description || ''; return; } // 第二次 tap 同一项 = 发送 } // 桌面直接发送 appendAskOptionToInput(q, opt); }); item.addEventListener('touchstart', () => { item.dataset.touchActivated = '1'; }, { passive: true }); opts.appendChild(item); }); layout.appendChild(opts); if (preview) { layout.appendChild(preview); // 预览区最小高度 = 左侧选项列表总高度(渲染后同步) requestAnimationFrame(() => { preview.style.minHeight = opts.offsetHeight + 'px'; }); } // 移动端确认按钮 if (hasDesc) { const confirmBtn = document.createElement('button'); confirmBtn.type = 'button'; confirmBtn.className = 'ask-confirm-btn'; confirmBtn.textContent = '确认选择'; confirmBtn.addEventListener('click', () => { if (selectedOpt) { appendAskOptionToInput(q, selectedOpt); } else if (q.options.length > 0) { appendAskOptionToInput(q, q.options[0]); } }); layout.appendChild(confirmBtn); } card.appendChild(layout); } wrapper.appendChild(card); }); return wrapper; } function buildToolContentElement(name, input) { const tool = typeof name === 'object' && name !== null ? name : { name, input }; const effectiveName = tool.name || name; const effectiveInput = tool.input !== undefined ? tool.input : input; const effectiveResult = tool.result; const kind = toolKind(tool); if (kind === 'todo_list') { let todoData = effectiveInput; // 如果有 result 且是字符串,尝试解析 if (effectiveResult && typeof effectiveResult === 'string') { try { todoData = JSON.parse(effectiveResult); } catch (e) { } } else { } const wrapper = document.createElement('div'); wrapper.className = 'tool-call-content todo-list-content'; wrapper.appendChild(createTodoListElement(todoData && typeof todoData === 'object' ? todoData : { items: [] })); return wrapper; } if (kind === 'collab_agent_tool_call') { return createCollabAgentToolElement(tool); } if (effectiveName === 'AskUserQuestion') { const questions = extractAskUserQuestions(effectiveInput); if (questions.length > 0) { return createAskUserQuestionView(questions); } } if (kind === 'command_execution') { const wrapper = document.createElement('div'); wrapper.className = 'tool-call-content command'; const stack = document.createElement('div'); stack.className = 'tool-call-structured'; const commandText = effectiveInput?.command || tool?.meta?.subtitle || ''; if (commandText) stack.appendChild(buildStructuredToolSection('Command', commandText)); if (effectiveResult) { stack.appendChild(buildStructuredToolSection('Output', stringifyToolValue(effectiveResult))); } else if (!tool.done) { const empty = document.createElement('div'); empty.className = 'tool-call-empty'; empty.textContent = '等待命令输出…'; stack.appendChild(empty); } wrapper.appendChild(stack); return wrapper; } if (kind === 'reasoning') { const content = document.createElement('div'); content.className = 'tool-call-content reasoning'; const text = stringifyToolValue(effectiveResult || effectiveInput); content.innerHTML = text ? renderMarkdown(text) : '
暂无推理内容
'; return content; } if (kind === 'file_change' || kind === 'mcp_tool_call') { const wrapper = document.createElement('div'); wrapper.className = `tool-call-content ${kind === 'file_change' ? 'file-change' : ''}`.trim(); const stack = document.createElement('div'); stack.className = 'tool-call-structured'; if (tool?.meta?.subtitle) { stack.appendChild(buildStructuredToolSection(kind === 'file_change' ? 'Target' : 'Tool', tool.meta.subtitle)); } const payloadText = stringifyToolValue(effectiveResult || effectiveInput); if (payloadText) { stack.appendChild(buildStructuredToolSection('Payload', payloadText)); } wrapper.appendChild(stack); return wrapper; } const inputStr = stringifyToolValue(effectiveResult || effectiveInput); const content = document.createElement('div'); content.className = 'tool-call-content'; content.textContent = inputStr; return content; } function createToolCallElement(toolUseId, tool, done) { const kind = toolKind(tool); if (kind === 'collab_agent_tool_call') { const wrapper = document.createElement('div'); wrapper.className = 'tool-call ccweb-mcp-child-agent-tool-call collab-agent-inline'; wrapper.id = `tool-node-${++toolDomSeq}`; wrapper.dataset.toolUseId = toolUseId ? String(toolUseId) : ''; wrapper.dataset.toolName = tool.name || ''; wrapper.dataset.toolKind = kind; wrapper.dataset.childIds = getCollabAgentIdsFromTool(tool).join(','); wrapper.appendChild(buildToolContentElement({ ...tool, done })); return wrapper; } const details = document.createElement('details'); details.className = 'tool-call'; details.id = `tool-node-${++toolDomSeq}`; details.dataset.toolUseId = toolUseId ? String(toolUseId) : ''; details.dataset.toolName = tool.name || ''; if (toolKind(tool)) { details.dataset.toolKind = toolKind(tool); details.classList.add(`codex-${toolKind(tool).replace(/_/g, '-')}`); } // Default expansion policy: // - Always open AskUserQuestion (it is an actionable UI). // - For non-Codex sessions, auto-open in-flight command execution so users can watch output. // - For Codex sessions, keep everything collapsed by default (less noise), including in-flight commands. const agent = normalizeAgent(currentAgent); if (tool.name === 'AskUserQuestion') { details.open = true; } else if (!isCodexLikeAgent(agent) && !done && kind === 'command_execution') { details.open = true; } const summary = document.createElement('summary'); applyToolSummary(summary, tool, done); details.appendChild(summary); details.appendChild(buildToolContentElement({ ...tool, done })); return details; } function upsertCollabAgentToolCall(toolsDiv, toolUseId, tool, done) { if (!toolsDiv || !tool) return null; const existing = toolsDiv.querySelector(':scope > .ccweb-mcp-child-agent-tool-call[data-collab-merged="true"]') || toolsDiv.querySelector(':scope > .tool-group .ccweb-mcp-child-agent-tool-call[data-collab-merged="true"]'); const nextTool = { ...tool, id: toolUseId, done }; rememberClosedCollabAgentIdsFromTool(nextTool); const map = existing?.__collabTools instanceof Map ? existing.__collabTools : new Map(); map.set(toolUseId || nextTool.id || `collab-${map.size + 1}`, nextTool); const merged = mergeCollabAgentTools(Array.from(map.values())); if (!merged) return existing; if (existing) { existing.__collabTools = map; existing.dataset.toolUseId = merged.id || existing.dataset.toolUseId || ''; existing.dataset.childIds = getCollabAgentIdsFromTool(merged).join(','); existing.replaceChildren(buildToolContentElement(merged)); removeDuplicateCollabAgentNodes(toolsDiv); return existing; } const el = createToolCallElement(merged.id, merged, !!merged.done); el.dataset.collabMerged = 'true'; el.dataset.childIds = getCollabAgentIdsFromTool(merged).join(','); el.__collabTools = map; toolsDiv.appendChild(el); removeDuplicateCollabAgentNodes(toolsDiv); return el; } function removeDuplicateCollabAgentNodes(scope) { if (!scope) return; const seen = new Set(); const nodes = Array.from(scope.querySelectorAll('.ccweb-mcp-child-agent-tool-call')); nodes.forEach((node) => { const ids = String(node.dataset.childIds || '').split(',').filter(Boolean); const duplicate = ids.length > 0 && ids.some((id) => seen.has(id)); ids.forEach((id) => seen.add(id)); if (duplicate) node.remove(); }); } function appendToolCall(toolUseId, name, input, done, kind = null, meta = null, result = undefined) { const streamEl = document.getElementById('streaming-msg'); if (!streamEl) return; const bubble = streamEl.querySelector('.msg-bubble'); if (!bubble) return; const shouldFollow = isNearBottom(); let toolsDiv = bubble.querySelector('.msg-tools'); if (!toolsDiv) { toolsDiv = bubble; } const tool = { id: toolUseId, name, input, kind, meta, done }; if (result !== undefined) tool.result = result; if (isEmptyReasoningTool(tool)) return; if (toolKind(tool) === 'collab_agent_tool_call') { const el = upsertCollabAgentToolCall(toolsDiv, toolUseId, tool, done); if (el) rememberToolCallTarget(toolUseId, tool, el); followOutputIfNeeded(shouldFollow); return; } // 如果是 todo_list,检查是否已存在相同 id 的 todo_list if (kind === 'todo_list' && input?.id) { const existingTodo = findTodoToolCallByTodoId(toolsDiv, input.id); if (existingTodo) { const details = createToolCallElement(toolUseId, tool, done); existingTodo.replaceWith(details); rememberToolCallTarget(toolUseId, tool, details); followOutputIfNeeded(shouldFollow); return; } } const details = createToolCallElement(toolUseId, tool, done); // 折叠策略:只维护唯一一个 .tool-group 父节点 // 散落的 .tool-call 直接子节点达到3个时,将它们全部移入父节点;之后继续散落,再达3个再移入 const FOLD_AT = 3; const looseBefore = Array.from(toolsDiv.children).filter(isGroupableToolCall); if (looseBefore.length >= FOLD_AT) { // 确保存在唯一的 .tool-group let group = toolsDiv.querySelector(':scope > .tool-group'); if (!group) { group = document.createElement('details'); group.className = 'tool-group'; const gs = document.createElement('summary'); gs.className = 'tool-group-summary'; group.appendChild(gs); const inner = document.createElement('div'); inner.className = 'tool-group-inner'; group.appendChild(inner); toolsDiv.insertBefore(group, toolsDiv.firstChild); hasGrouped = true; } const inner = group.querySelector('.tool-group-inner'); looseBefore.forEach(c => inner.appendChild(c)); _refreshGroupSummary(group); } toolsDiv.appendChild(details); rememberToolCallTarget(toolUseId, tool, details); followOutputIfNeeded(shouldFollow); } function _refreshGroupSummary(group) { const inner = group.querySelector('.tool-group-inner'); const count = inner ? inner.childElementCount : 0; const summary = group.querySelector('.tool-group-summary'); if (summary) summary.textContent = `展开 ${count} 个工具调用`; } function findLatestToolCallElement(root, matcher) { if (!root || typeof matcher !== 'function') return null; const allTools = root.querySelectorAll('.tool-call'); for (let i = allTools.length - 1; i >= 0; i--) { const el = allTools[i]; if (matcher(el)) return el; } return null; } function findTodoToolCallByTodoId(root, todoId) { if (!todoId) return null; return findLatestToolCallElement(root, (el) => { if (el.dataset.toolKind !== 'todo_list') return false; const currentTodoId = el.querySelector('.todo-list-container')?.dataset.todoId; return currentTodoId === todoId; }); } function updateToolCall(toolUseId, result, done = true) { const tool = activeToolCalls.get(toolUseId) || null; const toolUseIdText = toolUseId ? String(toolUseId) : ''; const scope = getLatestAssistantToolScope(); if (toolKind(tool) === 'collab_agent_tool_call') { const toolsDiv = scope?.querySelector?.('.msg-tools') || scope?.querySelector?.('.msg-bubble') || scope; const nextTool = { ...tool, result, done }; const el = upsertCollabAgentToolCall(toolsDiv, toolUseId, nextTool, done); if (el) rememberToolCallTarget(toolUseId, nextTool, el); return; } let el = tool?.domElement && tool.domElement.isConnected ? tool.domElement : null; if (!el) { if (tool?.kind === 'todo_list' && tool?.input?.id) { const markedTodo = activeTodoCallTargets.get(tool.input.id); if (markedTodo && markedTodo.isConnected) { el = markedTodo; } } } if (!el) { el = findLatestToolCallElement(scope, (candidate) => candidate.dataset.toolUseId === toolUseIdText); } if (!el) { el = findLatestToolCallElement(messagesDiv, (candidate) => candidate.dataset.toolUseId === toolUseIdText); } if (!el && tool?.kind === 'todo_list' && tool?.input?.id) { el = findTodoToolCallByTodoId(scope, tool.input.id); } if (!el) { return; } const nextTool = tool || { id: toolUseId, name: el.dataset.toolName || '', kind: el.dataset.toolKind || null, done, }; nextTool.done = done; if (result !== undefined) nextTool.result = result; if (isEmptyReasoningTool(nextTool)) { activeToolCalls.delete(toolUseId); el.remove(); return; } rememberToolCallTarget(toolUseId, nextTool, el); const summary = el.querySelector('summary'); if (summary) applyToolSummary(summary, nextTool, done); if (nextTool.name === 'AskUserQuestion') return; const nextContent = buildToolContentElement(nextTool); const content = Array.from(el.children).find((child) => child.tagName !== 'SUMMARY') || null; if (content) { content.replaceWith(nextContent); } else { el.appendChild(nextContent); } } function applyCcwebMcpChildAgentUpdate(msg) { const tool = msg?.tool; const toolUseId = msg?.toolUseId || tool?.id; if (!toolUseId || !tool) return; const isCurrentSessionUpdate = msg.sessionId === currentSessionId; if (isCurrentSessionUpdate) { if (msg?.child?.threadId && collabStateTone(msg.child.status) === 'closed') { closedCollabAgentIds.add(String(msg.child.threadId)); } rememberClosedCollabAgentIdsFromTool(tool); } updateCachedSession(msg.sessionId, (snapshot) => { const messages = Array.isArray(snapshot.messages) ? snapshot.messages : []; for (let i = messages.length - 1; i >= 0; i -= 1) { const calls = Array.isArray(messages[i]?.toolCalls) ? messages[i].toolCalls : []; const target = calls.find((item) => item.id === toolUseId); if (!target) continue; target.name = tool.name || target.name; target.kind = tool.kind || target.kind; target.input = tool.input !== undefined ? tool.input : target.input; target.result = tool.result !== undefined ? tool.result : target.result; target.meta = tool.meta || target.meta || null; target.done = tool.done !== undefined ? !!tool.done : target.done; snapshot.updated = new Date().toISOString(); break; } }); if (!isCurrentSessionUpdate) return; activeToolCalls.set(toolUseId, { id: toolUseId, name: tool.name, input: tool.input, result: tool.result, kind: tool.kind || null, meta: tool.meta || null, done: !!tool.done, }); updateToolCall(toolUseId, tool.result, !!tool.done); } function getDeleteConfirmMessage(agent) { const normalized = normalizeAgent(agent); if (normalized === 'codex') { return '删除本会话将同步删去本地 Codex rollout 历史与线程记录,不可恢复。确认删除?'; } if (normalized === 'codexapp') { return '删除本会话只会删除 cc-web 中的 Codex App 会话记录,不会清理本地 Codex App 线程历史。确认删除?'; } return '删除本会话将同步删去本地 Claude 中的会话历史,不可恢复。确认删除?'; } function showSimpleConfirm(options = {}) { const overlay = document.createElement('div'); overlay.className = 'settings-overlay'; overlay.style.zIndex = '10002'; const box = document.createElement('div'); box.className = 'settings-panel'; const title = options.title ? `
${escapeHtml(options.title)}
` : ''; const confirmText = options.confirmText || '确认'; const cancelText = options.cancelText || '取消'; box.innerHTML = ` ${title}
${escapeHtml(options.message || '')}
`; overlay.appendChild(box); document.body.appendChild(overlay); const close = () => { if (overlay.parentNode) overlay.parentNode.removeChild(overlay); }; box.querySelector('#simple-confirm-ok').addEventListener('click', () => { close(); if (typeof options.onConfirm === 'function') options.onConfirm(); }); box.querySelector('#simple-confirm-cancel').addEventListener('click', () => { close(); if (typeof options.onCancel === 'function') options.onCancel(); }); overlay.addEventListener('click', (e) => { if (e.target === overlay) { close(); if (typeof options.onCancel === 'function') options.onCancel(); } }); } function showDeleteConfirm(agent, onConfirm) { const overlay = document.createElement('div'); overlay.className = 'settings-overlay'; overlay.style.zIndex = '10002'; const box = document.createElement('div'); box.className = 'settings-panel'; box.innerHTML = `
${escapeHtml(getDeleteConfirmMessage(agent))}
`; overlay.appendChild(box); document.body.appendChild(overlay); const close = () => document.body.removeChild(overlay); box.querySelector('#del-confirm-ok').addEventListener('click', () => { close(); onConfirm(); }); box.querySelector('#del-confirm-skip').addEventListener('click', () => { skipDeleteConfirm = true; localStorage.setItem('cc-web-skip-delete-confirm', '1'); close(); onConfirm(); }); box.querySelector('#del-confirm-cancel').addEventListener('click', close); overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); }); } function appendSystemMessage(message, options = {}) { const welcome = messagesDiv.querySelector('.welcome-msg'); if (welcome) welcome.remove(); messagesDiv.appendChild(createMsgElement('system', message, [], options)); if (options.preserveScroll !== true) { scrollToBottom(); } } function appendError(message, options = {}) { appendSystemMessage(`⚠ ${message}`, { tone: 'danger', transient: options.transient !== false, autoDismissMs: options.autoDismissMs || 7000, preserveScroll: options.preserveScroll !== false, }); } function scrollToBottom() { requestAnimationFrame(() => { messagesDiv.scrollTop = messagesDiv.scrollHeight; updateScrollbar(); }); } function isNearBottom(threshold = 96) { const distance = messagesDiv.scrollHeight - messagesDiv.clientHeight - messagesDiv.scrollTop; return distance <= threshold; } function scrollToBottomIfNear(threshold = 96) { if (!isNearBottom(threshold)) return false; scrollToBottom(); return true; } function followOutputIfNeeded(shouldFollow) { if (shouldFollow) { scrollToBottom(); } else { updateScrollbar(); } } // --- Custom Scrollbar --- const scrollbarEl = document.getElementById('custom-scrollbar'); const thumbEl = document.getElementById('custom-scrollbar-thumb'); function updateScrollbar() { if (!scrollbarEl || !thumbEl) return; const { scrollTop, scrollHeight, clientHeight } = messagesDiv; if (scrollHeight <= clientHeight) { thumbEl.style.display = 'none'; return; } thumbEl.style.display = ''; const trackH = scrollbarEl.clientHeight; const thumbH = Math.max(30, trackH * clientHeight / scrollHeight); const thumbTop = (scrollTop / (scrollHeight - clientHeight)) * (trackH - thumbH); thumbEl.style.height = thumbH + 'px'; thumbEl.style.top = thumbTop + 'px'; } messagesDiv.addEventListener('scroll', () => { updateScrollbar(); // 移动端:滚动时短暂显示滑块,停止后淡出 scrollbarEl.classList.add('scrolling'); clearTimeout(scrollbarEl._hideTimer); scrollbarEl._hideTimer = setTimeout(() => { if (!isDragging) scrollbarEl.classList.remove('scrolling'); }, 1200); }, { passive: true }); new ResizeObserver(updateScrollbar).observe(messagesDiv); // Drag logic let dragStartY = 0, dragStartScrollTop = 0, isDragging = false; function onDragStart(e) { isDragging = true; dragStartY = e.type === 'touchstart' ? e.touches[0].clientY : e.clientY; dragStartScrollTop = messagesDiv.scrollTop; thumbEl.classList.add('dragging'); scrollbarEl.classList.add('active'); e.preventDefault(); } function onDragMove(e) { if (!isDragging) return; const clientY = e.type === 'touchmove' ? e.touches[0].clientY : e.clientY; const dy = clientY - dragStartY; const { scrollHeight, clientHeight } = messagesDiv; const trackH = scrollbarEl.clientHeight; const thumbH = Math.max(30, trackH * clientHeight / scrollHeight); const ratio = (scrollHeight - clientHeight) / (trackH - thumbH); messagesDiv.scrollTop = dragStartScrollTop + dy * ratio; e.preventDefault(); } function onDragEnd() { if (!isDragging) return; isDragging = false; thumbEl.classList.remove('dragging'); scrollbarEl.classList.remove('active'); } thumbEl.addEventListener('mousedown', onDragStart); thumbEl.addEventListener('touchstart', onDragStart, { passive: false }); document.addEventListener('mousemove', onDragMove); document.addEventListener('touchmove', onDragMove, { passive: false }); document.addEventListener('mouseup', onDragEnd); document.addEventListener('touchend', onDragEnd); updateScrollbar(); function renderSessionList() { sessionList.innerHTML = ''; syncSessionSearchUi(); const allVisibleSessions = getVisibleSessions(); const normalizedSearchQuery = normalizeSessionSearchQuery(sessionSearchQuery); const isSearchingSessions = !!normalizedSearchQuery; const visibleSessions = isSearchingSessions ? allVisibleSessions.filter((session) => sessionMatchesSearch(session, normalizedSearchQuery)) : allVisibleSessions; if (allVisibleSessions.length === 0) { const empty = document.createElement('div'); empty.className = 'session-list-empty'; empty.textContent = `暂无 ${AGENT_LABELS[currentAgent]} 会话,点击“新会话”开始。`; sessionList.appendChild(empty); return; } if (visibleSessions.length === 0) { const empty = document.createElement('div'); empty.className = 'session-list-empty'; empty.textContent = '没有匹配的会话或项目。'; sessionList.appendChild(empty); return; } const { pinnedSessions, regularSessions } = splitPinnedSessions(visibleSessions); const { groups: projectGroups, ungroupedSessions } = groupSessionsByProject(regularSessions); if (pinnedSessions.length > 0) { const pinnedGroupEl = document.createElement('section'); pinnedGroupEl.className = 'session-project-group session-pinned-group'; const pinnedHeader = document.createElement('div'); pinnedHeader.className = 'session-project-header session-pinned-header'; pinnedHeader.innerHTML = ` 置顶 ${pinnedSessions.length} `; pinnedGroupEl.appendChild(pinnedHeader); for (const session of pinnedSessions) { pinnedGroupEl.appendChild(createSessionListItem(session)); } sessionList.appendChild(pinnedGroupEl); } projectGroups.forEach((group, groupIndex) => { const groupKey = getProjectCollapseKey(group); const oldSessionCollapseKey = getProjectOldSessionCollapseKey(group); const { visibleSessions: visibleGroupSessions, hiddenSessions: hiddenGroupSessions } = isSearchingSessions ? { visibleSessions: group.sessions, hiddenSessions: [] } : splitCollapsedSessions(group.sessions, oldSessionCollapseKey); const isCollapsed = !isSearchingSessions && collapsedProjectKeys.has(groupKey); const hasActiveSession = group.sessions.some((session) => session.id === currentSessionId); const hasUnreadSession = group.sessions.some((session) => session.hasUnread); const hasRunningSession = group.sessions.some((session) => session.isRunning); const hasWaitingSession = group.sessions.some((session) => session.waitingOnChildren); const groupBodyId = `session-project-body-${groupIndex}`; const groupEl = document.createElement('section'); groupEl.className = `session-project-group${isCollapsed ? ' collapsed' : ''}${hasActiveSession ? ' has-active-session' : ''}${hasUnreadSession ? ' has-unread-session' : ''}${hasRunningSession ? ' has-running-session' : ''}${hasWaitingSession ? ' has-waiting-session' : ''}`; const header = document.createElement('div'); header.className = 'session-project-header'; header.title = group.cwd || group.name; header.innerHTML = ` ${group.sessions.length} `; groupEl.appendChild(header); const groupBody = document.createElement('div'); groupBody.id = groupBodyId; groupBody.className = 'session-project-sessions'; groupBody.hidden = isCollapsed; for (const s of visibleGroupSessions) { groupBody.appendChild(createSessionListItem(s)); } if (hiddenGroupSessions.length > 0) { groupBody.appendChild(createOldSessionLoadMoreButton(hiddenGroupSessions.length, oldSessionCollapseKey, group.name)); } groupEl.appendChild(groupBody); header.querySelector('.session-project-toggle').addEventListener('click', () => { setProjectCollapsed(groupKey, !isCollapsed); }); header.querySelector('.session-project-create').addEventListener('click', (e) => { e.stopPropagation(); quickCreateProjectSession(group.cwd || '', { agent: currentAgent, mode: currentMode }); }); sessionList.appendChild(groupEl); }); const ungroupedCollapseKey = getUngroupedOldSessionCollapseKey(); const { visibleSessions: visibleUngroupedSessions, hiddenSessions: hiddenUngroupedSessions } = isSearchingSessions ? { visibleSessions: ungroupedSessions, hiddenSessions: [] } : splitCollapsedSessions(ungroupedSessions, ungroupedCollapseKey); for (const s of visibleUngroupedSessions) { sessionList.appendChild(createSessionListItem(s)); } if (hiddenUngroupedSessions.length > 0) { sessionList.appendChild(createOldSessionLoadMoreButton(hiddenUngroupedSessions.length, ungroupedCollapseKey)); } } function startEditSessionTitle(itemEl, session) { const titleEl = itemEl.querySelector('.session-item-title'); const currentTitle = session.title || ''; const input = document.createElement('input'); input.className = 'session-item-edit-input'; input.value = currentTitle; input.maxLength = 100; titleEl.replaceWith(input); input.focus(); input.select(); // Hide actions during edit const actions = itemEl.querySelector('.session-item-actions'); const time = itemEl.querySelector('.session-item-time'); if (actions) actions.style.display = 'none'; if (time) time.style.display = 'none'; function save() { const newTitle = input.value.trim() || currentTitle; if (newTitle !== currentTitle) { send({ type: 'rename_session', sessionId: session.id, title: newTitle }); } // Restore const span = document.createElement('span'); span.className = 'session-item-title'; span.textContent = newTitle; input.replaceWith(span); if (actions) actions.style.display = ''; if (time) time.style.display = ''; } input.addEventListener('blur', save); input.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); input.blur(); } if (e.key === 'Escape') { input.value = currentTitle; input.blur(); } }); } function highlightActiveSession() { document.querySelectorAll('.session-item').forEach((el) => { el.classList.toggle('active', el.dataset.id === currentSessionId); }); } if (chatSessionIdBtn) { chatSessionIdBtn.addEventListener('click', () => { copyTextToClipboard(currentSessionId, '当前会话 ID 已复制'); }); } // --- Header title editing (contenteditable) --- chatTitle.addEventListener('click', () => { if (!currentSessionId || chatTitle.contentEditable === 'true') return; const originalText = chatTitle.textContent; chatTitle.contentEditable = 'true'; chatTitle.style.background = 'var(--surface-strong)'; chatTitle.style.outline = '1px solid var(--accent)'; chatTitle.style.borderRadius = '6px'; chatTitle.style.padding = '2px 8px'; chatTitle.style.minWidth = '96px'; chatTitle.style.whiteSpace = 'normal'; chatTitle.style.overflow = 'visible'; chatTitle.style.textOverflow = 'clip'; chatTitle.focus(); // Select all text const range = document.createRange(); range.selectNodeContents(chatTitle); const sel = window.getSelection(); sel.removeAllRanges(); sel.addRange(range); function finish(save) { chatTitle.contentEditable = 'false'; chatTitle.style.background = ''; chatTitle.style.outline = ''; chatTitle.style.borderRadius = ''; chatTitle.style.padding = ''; chatTitle.style.minWidth = ''; chatTitle.style.whiteSpace = ''; chatTitle.style.overflow = ''; chatTitle.style.textOverflow = ''; const newTitle = chatTitle.textContent.trim() || originalText; chatTitle.textContent = newTitle; if (save && newTitle !== originalText && currentSessionId) { send({ type: 'rename_session', sessionId: currentSessionId, title: newTitle }); } } chatTitle.addEventListener('blur', () => finish(true), { once: true }); chatTitle.addEventListener('keydown', function handler(e) { if (e.key === 'Enter') { e.preventDefault(); chatTitle.removeEventListener('keydown', handler); chatTitle.blur(); } if (e.key === 'Escape') { chatTitle.textContent = originalText; chatTitle.removeEventListener('keydown', handler); chatTitle.blur(); } }); }); if (chatCwd) { chatCwd.addEventListener('click', () => { if (!currentCwd) return; showFileBrowser(); }); } // --- Sidebar --- function openSidebar() { sidebar.classList.add('open'); sidebarOverlay.hidden = false; } function closeSidebar() { sidebar.classList.remove('open'); sidebarOverlay.hidden = true; } function canOpenSidebarBySwipe(target) { if (!window.matchMedia('(max-width: 768px), (pointer: coarse)').matches) return false; if (sidebar.classList.contains('open')) return false; if (sessionLoadingOverlay && !sessionLoadingOverlay.hidden) return false; if (!chatMain || !target || !chatMain.contains(target)) return false; if (!app.hidden && target && target.closest('input, textarea, select, button, .modal-panel, .settings-panel, .option-picker, .cmd-menu')) { return false; } return true; } function canCloseSidebarBySwipe(target) { if (!window.matchMedia('(max-width: 768px), (pointer: coarse)').matches) return false; if (!sidebar.classList.contains('open')) return false; if (!target) return false; return sidebar.contains(target) || target === sidebarOverlay; } function handleSidebarSwipeStart(e) { if (!e.touches || e.touches.length !== 1) return; const touch = e.touches[0]; if (canCloseSidebarBySwipe(e.target)) { sidebarSwipe = { startX: touch.clientX, startY: touch.clientY, active: true, mode: 'close', }; return; } if (!canOpenSidebarBySwipe(e.target)) { sidebarSwipe = null; return; } sidebarSwipe = { startX: touch.clientX, startY: touch.clientY, active: true, mode: 'open', }; } function handleSidebarSwipeMove(e) { if (!sidebarSwipe?.active || !e.touches || e.touches.length !== 1) return; const touch = e.touches[0]; const deltaX = touch.clientX - sidebarSwipe.startX; const deltaY = touch.clientY - sidebarSwipe.startY; if (Math.abs(deltaY) > SIDEBAR_SWIPE_MAX_VERTICAL_DRIFT && Math.abs(deltaY) > Math.abs(deltaX)) { sidebarSwipe = null; return; } const horizontalIntent = sidebarSwipe.mode === 'open' ? deltaX > 12 : deltaX < -12; if (horizontalIntent && Math.abs(deltaY) < SIDEBAR_SWIPE_MAX_VERTICAL_DRIFT) { e.preventDefault(); } } function handleSidebarSwipeEnd(e) { if (!sidebarSwipe?.active) return; const touch = e.changedTouches && e.changedTouches[0]; const endX = touch ? touch.clientX : sidebarSwipe.startX; const endY = touch ? touch.clientY : sidebarSwipe.startY; const deltaX = endX - sidebarSwipe.startX; const deltaY = endY - sidebarSwipe.startY; const shouldOpen = sidebarSwipe.mode === 'open' && deltaX >= SIDEBAR_SWIPE_TRIGGER && Math.abs(deltaY) <= SIDEBAR_SWIPE_MAX_VERTICAL_DRIFT; const shouldClose = sidebarSwipe.mode === 'close' && deltaX <= -SIDEBAR_SWIPE_TRIGGER && Math.abs(deltaY) <= SIDEBAR_SWIPE_MAX_VERTICAL_DRIFT; sidebarSwipe = null; if (shouldOpen) { openSidebar(); } else if (shouldClose) { closeSidebar(); } } // --- Composer Modifier Menu --- function getLocalSlashSuggestions(query) { const normalized = String(query || '').replace(/^\//, '').toLowerCase(); const filtered = SLASH_COMMANDS .filter((item) => { const cmd = item.cmd.toLowerCase(); const desc = item.desc.toLowerCase(); return !normalized || cmd.includes(normalized) || desc.includes(normalized); }) .sort((a, b) => (b.cmd === `/${normalized}` ? 1 : 0) - (a.cmd === `/${normalized}` ? 1 : 0)); return filtered.map((item) => ({ kind: 'command', name: item.cmd, label: item.cmd, description: item.desc, insertion: `${item.cmd} `, appendSpace: false, })); } function findActiveComposerToken() { if (!msgInput) return null; const value = msgInput.value || ''; const cursor = typeof msgInput.selectionStart === 'number' ? msgInput.selectionStart : value.length; const before = value.slice(0, cursor); if (!before.includes('\n') && value.startsWith('/') && cursor > 0) { const nextWhitespace = value.search(/\s/); const end = nextWhitespace >= 0 ? Math.min(cursor, nextWhitespace) : cursor; if (cursor <= end || nextWhitespace < 0) { return { trigger: '/', query: value.slice(1, cursor), start: 0, end: cursor }; } } const lineStart = Math.max(before.lastIndexOf('\n'), before.lastIndexOf('\r')) + 1; const line = before.slice(lineStart); const match = line.match(/(^|\s)([@$])([^\s]*)$/); if (!match) return null; const prefixLength = match[1] ? match[1].length : 0; const start = lineStart + match.index + prefixLength; return { trigger: match[2], query: match[3] || '', start, end: cursor, }; } function showCmdMenu(token, items) { const safeItems = Array.isArray(items) ? items : []; if (!token || safeItems.length === 0) { hideCmdMenu(); return; } activeComposerToken = token; cmdMenuIndex = 0; cmdMenu.innerHTML = safeItems.map((item, i) => { const kindLabel = item.kind === 'skill' ? 'Skill' : item.kind === 'prompt' ? 'Prompt' : item.kind === 'file' ? (item.itemType === 'directory' ? 'Dir' : 'File') : item.kind === 'mcp' ? 'MCP' : 'Cmd'; return `
${kindLabel} ${escapeHtml(item.label || item.name || item.insertion || '')} ${escapeHtml(item.description || item.title || '')}
`; }).join(''); cmdMenu._items = safeItems; cmdMenu.hidden = false; cmdMenu.querySelectorAll('.cmd-item').forEach((el) => { el.addEventListener('click', () => { const index = Number.parseInt(el.dataset.index || '-1', 10); selectComposerItemByIndex(index); }); }); } function requestComposerSuggestions() { const token = findActiveComposerToken(); if (!token || noteMode) { hideCmdMenu(); return; } if (token.trigger === '/') { showCmdMenu(token, getLocalSlashSuggestions(token.query)); } clearTimeout(composerSuggestionTimer); composerSuggestionTimer = setTimeout(() => { const liveToken = findActiveComposerToken(); if (!liveToken || liveToken.trigger !== token.trigger || liveToken.start !== token.start) { hideCmdMenu(); return; } const requestId = `composer-${Date.now()}-${++composerRequestSeq}`; latestComposerRequestId = requestId; activeComposerToken = liveToken; send({ type: 'composer_suggestions', requestId, trigger: liveToken.trigger, query: liveToken.query, sessionId: currentSessionId, agent: currentAgent, }); }, COMPOSER_SUGGESTION_DEBOUNCE); } function handleComposerSuggestions(msg) { if (!msg || msg.requestId !== latestComposerRequestId) return; const token = findActiveComposerToken(); if (!token || token.trigger !== msg.trigger) { hideCmdMenu(); return; } showCmdMenu(token, msg.items || []); } function hideCmdMenu() { cmdMenu.hidden = true; cmdMenuIndex = -1; cmdMenu._items = []; activeComposerToken = null; } function navigateCmdMenu(direction) { const items = cmdMenu.querySelectorAll('.cmd-item'); if (items.length === 0) return; items[cmdMenuIndex]?.classList.remove('active'); cmdMenuIndex = (cmdMenuIndex + direction + items.length) % items.length; items[cmdMenuIndex]?.classList.add('active'); items[cmdMenuIndex]?.scrollIntoView({ block: 'nearest' }); } function selectComposerItemByIndex(index) { const items = Array.isArray(cmdMenu._items) ? cmdMenu._items : []; const item = items[index]; if (!item) return; if (item.kind === 'command') { const cmd = item.name || item.label || ''; if (cmd === '/model') { hideCmdMenu(); msgInput.value = ''; showModelPicker(); return; } if (cmd === '/mode') { hideCmdMenu(); msgInput.value = ''; showModePicker(); return; } msgInput.value = item.insertion || `${cmd} `; hideCmdMenu(); msgInput.focus(); autoResize(); return; } const token = activeComposerToken || findActiveComposerToken(); if (!token) return; const value = msgInput.value || ''; const insertion = String(item.insertion || item.label || item.name || ''); const appendSpace = item.appendSpace !== false; const suffix = appendSpace ? ' ' : ''; msgInput.value = value.slice(0, token.start) + insertion + suffix + value.slice(token.end); const nextCursor = token.start + insertion.length + suffix.length; msgInput.setSelectionRange(nextCursor, nextCursor); hideCmdMenu(); msgInput.focus(); autoResize(); if (!appendSpace) requestComposerSuggestions(); } function selectCmdMenuItem() { if (cmdMenuIndex >= 0) selectComposerItemByIndex(cmdMenuIndex); } // --- Option Picker (generic) --- function showOptionPicker(title, options, currentValue, onSelect) { hideOptionPicker(); const picker = document.createElement('div'); picker.className = 'option-picker'; picker.id = 'option-picker'; picker.innerHTML = `
${escapeHtml(title)}
${options.map(opt => `
${escapeHtml(opt.label)}
${escapeHtml(opt.desc)}
${opt.value === currentValue ? '' : ''}
`).join('')} `; const chatMain = document.querySelector('.chat-main'); chatMain.appendChild(picker); picker.querySelectorAll('.option-picker-item').forEach(el => { el.addEventListener('click', () => { // Close current picker first so onSelect can safely open a nested picker. const v = el.dataset.value; hideOptionPicker(); onSelect(v); }); }); // Close on outside click (delayed to avoid immediate close) setTimeout(() => { document.addEventListener('click', _pickerOutsideClick); }, 0); document.addEventListener('keydown', _pickerEscape); } function hideOptionPicker() { const picker = document.getElementById('option-picker'); if (picker) picker.remove(); document.removeEventListener('click', _pickerOutsideClick); document.removeEventListener('keydown', _pickerEscape); } function _pickerOutsideClick(e) { const picker = document.getElementById('option-picker'); if (picker && !picker.contains(e.target)) { hideOptionPicker(); } } function _pickerEscape(e) { if (e.key === 'Escape') { hideOptionPicker(); } } function showModelPicker() { if (isCodexLikeAgent(currentAgent)) { const current = _splitCodexThinkingModel(currentModel || ''); const baseOptions = getCodexBaseModelOptions(); showOptionPicker(`选择 ${isCodexAppAgent(currentAgent) ? 'Codex App' : 'Codex'} 模型`, baseOptions, current.base || '', (baseValue) => { const base = String(baseValue || '').trim(); const thinkingOptions = [ { value: '', label: '无 (默认)', desc: '不附加 (low/medium/high/xhigh) 后缀' }, { value: 'low', label: 'low', desc: '较轻 thinking' }, { value: 'medium', label: 'medium', desc: '中等 thinking' }, { value: 'high', label: 'high', desc: '更强 thinking' }, { value: 'xhigh', label: 'xhigh', desc: '最强 thinking' }, ]; showOptionPicker('选择 Thinking 强度', thinkingOptions, current.level || '', (lvl) => { const level = String(lvl || '').trim().toLowerCase(); const full = level ? `${base}(${level})` : base; send({ type: 'message', text: `/model ${full}`, sessionId: currentSessionId, mode: currentMode, agent: currentAgent }); }); }); return; } showOptionPicker('选择模型', MODEL_OPTIONS, currentModel, (value) => { send({ type: 'message', text: `/model ${value}`, sessionId: currentSessionId, mode: currentMode, agent: currentAgent }); }); } function showModePicker() { showOptionPicker('选择权限模式', MODE_PICKER_OPTIONS, currentMode, (value) => { currentMode = value; modeSelect.value = currentMode; localStorage.setItem(getAgentModeStorageKey(currentAgent), currentMode); if (currentSessionId) { send({ type: 'set_mode', sessionId: currentSessionId, mode: currentMode }); } }); } // --- Send Message --- function submitUserMessage(text, attachments = []) { const welcome = messagesDiv.querySelector('.welcome-msg'); if (welcome) welcome.remove(); const messageId = createLocalId('user'); const element = createMsgElement('user', text, attachments, { messageId }); messagesDiv.appendChild(element); if (currentSessionId) { const messageIndex = currentSessionMessageCount; currentSessionMessageCount += 1; markSessionMessageElement(element, messageIndex); } registerUserMessage(messageId, element, text); updateUserOutlinePanel(); 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; } const runtimeInsert = isGenerating && isCodexAppAgent(currentAgent); if ((!text && pendingAttachments.length === 0) || isBlockingSessionLoad()) return; if (isGenerating && !runtimeInsert) return; hideCmdMenu(); hideOptionPicker(); if (runtimeInsert) { if (pendingAttachments.length > 0) { appendError('Codex App 运行中插入暂不支持图片附件,请先移除图片。'); return; } if (text.startsWith('/')) { appendError('Codex App 运行中暂不支持 slash 指令插入。'); return; } const messageId = createLocalId('user'); const element = createMsgElement('user', text, [], { messageId, codexAppSteerStatus: 'pending' }); const streamEl = document.getElementById('streaming-msg'); const shouldFollow = isNearBottom(); if (streamEl && streamEl.parentNode === messagesDiv) { messagesDiv.insertBefore(element, streamEl); } else { messagesDiv.appendChild(element); } if (currentSessionId) { const messageIndex = currentSessionMessageCount; currentSessionMessageCount += 1; markSessionMessageElement(element, messageIndex); } registerUserMessage(messageId, element, text); updateUserOutlinePanel(); if (shouldFollow) { scrollToBottom(); } else { updateScrollbar(); } send({ type: 'message', text, sessionId: currentSessionId, mode: currentMode, agent: currentAgent, clientMessageId: messageId }); msgInput.value = ''; autoResize(); return; } // Slash commands: don't show as user bubble if (text.startsWith('/')) { if (pendingAttachments.length > 0) { appendError('命令消息暂不支持附带图片,请先移除图片或发送普通消息。'); return; } // /model without argument → show interactive picker if (text === '/model' || text === '/model ') { showModelPicker(); msgInput.value = ''; autoResize(); return; } // /mode without argument → show interactive picker if (text === '/mode' || text === '/mode ') { showModePicker(); msgInput.value = ''; autoResize(); return; } send({ type: 'message', text, sessionId: currentSessionId, mode: currentMode, agent: currentAgent }); msgInput.value = ''; autoResize(); return; } // Regular message const attachments = pendingAttachments.map((attachment) => ({ ...attachment })); submitUserMessage(text, attachments); msgInput.value = ''; pendingAttachments = []; renderPendingAttachments(); autoResize(); } function autoResize() { msgInput.style.height = 'auto'; const max = parseInt(getComputedStyle(document.documentElement).getPropertyValue('--input-max-height')) || 200; msgInput.style.height = Math.min(msgInput.scrollHeight, max) + 'px'; } function isMobileInputMode() { return window.matchMedia('(max-width: 768px), (pointer: coarse)').matches; } // --- Event Listeners --- loginForm.addEventListener('submit', (e) => { e.preventDefault(); const pw = loginPassword.value; if (!pw) return; loginError.hidden = true; loginPasswordValue = pw; // Remember password if (rememberPw.checked) { localStorage.setItem('cc-web-pw', pw); } else { localStorage.removeItem('cc-web-pw'); } send({ type: 'auth', password: pw }); // Request notification permission on first user interaction requestNotificationPermission(); }); menuBtn.addEventListener('click', () => { sidebar.classList.contains('open') ? closeSidebar() : openSidebar(); }); sidebarOverlay.addEventListener('click', closeSidebar); document.addEventListener('touchstart', handleSidebarSwipeStart, { passive: true }); document.addEventListener('touchmove', handleSidebarSwipeMove, { passive: false }); document.addEventListener('touchend', handleSidebarSwipeEnd, { passive: true }); document.addEventListener('touchcancel', () => { sidebarSwipe = null; }, { passive: true }); if (chatAgentBtn && chatAgentMenu) { chatAgentBtn.addEventListener('click', (e) => { e.stopPropagation(); toggleAgentMenu(); }); chatAgentMenu.querySelectorAll('.chat-agent-option').forEach((btn) => { btn.addEventListener('click', (e) => { e.stopPropagation(); closeAgentMenu(); const targetAgent = normalizeAgent(btn.dataset.agent); if (targetAgent === currentAgent) return; syncViewForAgent(targetAgent, { preserveCurrent: false, loadLast: true }); }); }); } if (userOutlineBtn && userOutlinePanel) { userOutlineBtn.addEventListener('click', (e) => { e.stopPropagation(); toggleUserOutlinePanel(); }); userOutlinePanel.addEventListener('click', (e) => { const target = e.target instanceof HTMLElement ? e.target.closest('.user-outline-item') : null; if (!target) return; const anchorId = target.getAttribute('data-target') || ''; closeUserOutlinePanel(); scrollToMessage(anchorId); }); } if (ccwebPromptOutlineBtn && ccwebPromptOutlinePanel) { ccwebPromptOutlineBtn.addEventListener('click', (e) => { e.stopPropagation(); toggleCcwebPromptOutlinePanel(); }); ccwebPromptOutlinePanel.addEventListener('click', (e) => { e.stopPropagation(); }); } if (reloadMcpBtn) { reloadMcpBtn.addEventListener('click', (e) => { e.stopPropagation(); reloadCurrentMcpServers(); }); } if (sessionSearchInput) { sessionSearchInput.addEventListener('input', () => { sessionSearchQuery = sessionSearchInput.value; renderSessionList(); }); sessionSearchInput.addEventListener('keydown', (e) => { if (e.key === 'Escape' && normalizeSessionSearchQuery(sessionSearchQuery)) { e.stopPropagation(); sessionSearchQuery = ''; renderSessionList(); sessionSearchInput.focus(); } }); } if (sessionSearchClear) { sessionSearchClear.addEventListener('click', () => { sessionSearchQuery = ''; renderSessionList(); sessionSearchInput?.focus(); }); } // Split new-chat button newChatBtn.addEventListener('click', () => showNewSessionModal()); newChatArrow.addEventListener('click', (e) => { e.stopPropagation(); newChatDropdown.hidden = !newChatDropdown.hidden; }); importSessionBtn.addEventListener('click', () => { newChatDropdown.hidden = true; if (isCodexLikeAgent(currentAgent)) { showImportCodexSessionModal(); } else { showImportSessionModal(); } }); document.addEventListener('click', (e) => { if (!newChatDropdown.hidden && !newChatDropdown.contains(e.target) && e.target !== newChatArrow) { newChatDropdown.hidden = true; } if (chatAgentMenu && !chatAgentMenu.hidden && !chatAgentMenu.contains(e.target) && e.target !== chatAgentBtn) { closeAgentMenu(); } if (userOutlinePanel && !userOutlinePanel.hidden && !userOutlinePanel.contains(e.target) && e.target !== userOutlineBtn) { closeUserOutlinePanel(); } if (ccwebPromptOutlinePanel && !ccwebPromptOutlinePanel.hidden && !ccwebPromptOutlinePanel.contains(e.target) && e.target !== ccwebPromptOutlineBtn) { closeCcwebPromptOutlinePanel(); } }); sendBtn.addEventListener('click', sendMessage); if (queueSendBtn) { queueSendBtn.addEventListener('click', queueMessageFromInput); } if (noteModeBtn) { noteModeBtn.addEventListener('click', () => { noteMode = !noteMode; updateNoteModeUI(); msgInput.focus(); }); } abortBtn.addEventListener('click', () => send({ type: 'abort' })); if (attachBtn && imageUploadInput) { attachBtn.addEventListener('click', () => imageUploadInput.click()); imageUploadInput.addEventListener('change', () => { handleSelectedImageFiles(imageUploadInput.files); }); } if (inputWrapper) { inputWrapper.addEventListener('dragover', (e) => { if (!e.dataTransfer?.types?.includes('Files')) return; e.preventDefault(); inputWrapper.classList.add('drag-active'); }); inputWrapper.addEventListener('dragleave', (e) => { if (e.target === inputWrapper) inputWrapper.classList.remove('drag-active'); }); inputWrapper.addEventListener('drop', (e) => { e.preventDefault(); inputWrapper.classList.remove('drag-active'); handleSelectedImageFiles(e.dataTransfer?.files); }); } // Mode selector modeSelect.value = currentMode; modeSelect.addEventListener('change', () => { currentMode = modeSelect.value; localStorage.setItem(getAgentModeStorageKey(currentAgent), currentMode); if (currentSessionId) { send({ type: 'set_mode', sessionId: currentSessionId, mode: currentMode }); } }); msgInput.addEventListener('input', () => { autoResize(); requestComposerSuggestions(); }); msgInput.addEventListener('keydown', (e) => { // Command menu navigation if (!cmdMenu.hidden) { if (e.key === 'ArrowDown') { e.preventDefault(); navigateCmdMenu(1); return; } if (e.key === 'ArrowUp') { e.preventDefault(); navigateCmdMenu(-1); return; } if (e.key === 'Tab') { e.preventDefault(); selectCmdMenuItem(); return; } if (e.key === 'Escape') { hideCmdMenu(); closeSessionActionMenus(); return; } } if (e.key === 'Escape') { closeSessionActionMenus(); } if (e.key === 'Enter' && !e.shiftKey) { if (isMobileInputMode()) { if (!cmdMenu.hidden) { e.preventDefault(); selectCmdMenuItem(); } return; } e.preventDefault(); if (!cmdMenu.hidden) { // If menu is open and user presses Enter, select the item selectCmdMenuItem(); } else { sendMessage(); } } }); msgInput.addEventListener('paste', (e) => { const items = Array.from(e.clipboardData?.items || []); const files = items .filter((item) => item.kind === 'file' && /^image\//.test(item.type || '')) .map((item) => item.getAsFile()) .filter(Boolean); if (files.length > 0) { e.preventDefault(); handleSelectedImageFiles(files); } }); // Close cmd menu on outside click document.addEventListener('click', (e) => { if (!(e.target instanceof Element) || !e.target.closest('.session-item-more')) { closeSessionActionMenus(); } if (!cmdMenu.contains(e.target) && e.target !== msgInput) { hideCmdMenu(); } }); document.addEventListener('keydown', (e) => { if (e.key === 'Escape') closeSessionActionMenus(); }); // --- Toast Notification --- function showToast(text, sessionId) { const toast = document.createElement('div'); toast.className = 'toast-notification'; toast.textContent = text; if (sessionId) { toast.style.cursor = 'pointer'; toast.addEventListener('click', () => { openSession(sessionId); toast.remove(); }); } document.body.appendChild(toast); setTimeout(() => toast.classList.add('show'), 10); setTimeout(() => { toast.classList.remove('show'); setTimeout(() => toast.remove(), 300); }, 5000); } // --- Browser Notification (via Service Worker for mobile) --- function showBrowserNotification(title) { if (!('Notification' in window) || Notification.permission !== 'granted') return; if ('serviceWorker' in navigator) { navigator.serviceWorker.ready.then((reg) => { reg.showNotification('CC-Web', { body: `「${title}」任务完成`, icon: '/icon-192.png', tag: 'cc-web-task', renotify: true, }); }).catch(() => {}); } } function requestNotificationPermission() { if ('Notification' in window && Notification.permission === 'default') { Notification.requestPermission(); } } // --- Settings Panel --- let _onNotifyConfig = null; let _onNotifyTestResult = null; let _onModelConfig = null; let _onCodexConfig = null; let _onFetchModelsResult = null; let _onCodexSessions = null; const settingsBtn = $('#settings-btn'); const PROVIDER_OPTIONS = [ { value: 'off', label: '关闭' }, { value: 'pushplus', label: 'PushPlus' }, { value: 'telegram', label: 'Telegram' }, { value: 'serverchan', label: 'Server酱' }, { value: 'feishu', label: '飞书机器人' }, { value: 'qqbot', label: 'QQ(Qmsg)' }, ]; function buildNotifyFieldsHtml(config, provider) { if (provider === 'pushplus') { return `
`; } if (provider === 'telegram') { return `
`; } if (provider === 'serverchan') { return `
`; } if (provider === 'feishu') { return `
`; } if (provider === 'qqbot') { return `
`; } return ''; } function buildAgentContextCard(agent, title, copy) { const label = AGENT_LABELS[normalizeAgent(agent)] || AGENT_LABELS.claude; return `
${escapeHtml(label)} Space
${escapeHtml(title)}
${escapeHtml(copy)}
`; } function renderNotifyFields(fieldsDiv, config, provider) { fieldsDiv.innerHTML = buildNotifyFieldsHtml(config, provider); } function collectNotifyConfigFromPanel(panel, currentConfig, provider) { const pp = panel.querySelector('#notify-pushplus-token'); const tgBot = panel.querySelector('#notify-tg-bottoken'); const tgChat = panel.querySelector('#notify-tg-chatid'); const sc = panel.querySelector('#notify-sc-sendkey'); const feishuWh = panel.querySelector('#notify-feishu-webhook'); const qmsgKey = panel.querySelector('#notify-qmsg-key'); // Summary config const summaryEnabled = panel.querySelector('#notify-summary-enabled'); const summaryTrigger = panel.querySelector('#notify-summary-trigger'); const summarySource = panel.querySelector('#notify-summary-source'); const summaryApiBase = panel.querySelector('#notify-summary-apibase'); const summaryApiKey = panel.querySelector('#notify-summary-apikey'); const summaryModel = panel.querySelector('#notify-summary-model'); const cs = currentConfig?.summary || {}; return { provider, pushplus: { token: pp ? pp.value.trim() : (currentConfig?.pushplus?.token || '') }, telegram: { botToken: tgBot ? tgBot.value.trim() : (currentConfig?.telegram?.botToken || ''), chatId: tgChat ? tgChat.value.trim() : (currentConfig?.telegram?.chatId || ''), }, serverchan: { sendKey: sc ? sc.value.trim() : (currentConfig?.serverchan?.sendKey || '') }, feishu: { webhook: feishuWh ? feishuWh.value.trim() : (currentConfig?.feishu?.webhook || '') }, qqbot: { qmsgKey: qmsgKey ? qmsgKey.value.trim() : (currentConfig?.qqbot?.qmsgKey || '') }, summary: { enabled: summaryEnabled ? summaryEnabled.checked : !!cs.enabled, trigger: summaryTrigger ? summaryTrigger.value : (cs.trigger || 'background'), apiSource: summarySource ? summarySource.value : (cs.apiSource || 'claude'), apiBase: summaryApiBase ? summaryApiBase.value.trim() : (cs.apiBase || ''), apiKey: summaryApiKey ? summaryApiKey.value.trim() : (cs.apiKey || ''), model: summaryModel ? summaryModel.value.trim() : (cs.model || ''), }, }; } function buildSummarySettingsHtml(config) { const s = config?.summary || {}; const enabled = !!s.enabled; const trigger = s.trigger || 'background'; const src = s.apiSource || 'claude'; const customVisible = src === 'custom' ? '' : 'display:none'; return `
通知摘要
`; } function bindSummarySettingsEvents(panel) { const enabledCb = panel.querySelector('#notify-summary-enabled'); const optionsDiv = panel.querySelector('#notify-summary-options'); const sourceSelect = panel.querySelector('#notify-summary-source'); const customDiv = panel.querySelector('#notify-summary-custom'); if (!enabledCb || !optionsDiv || !sourceSelect || !customDiv) return; enabledCb.addEventListener('change', () => { optionsDiv.style.display = enabledCb.checked ? '' : 'none'; }); sourceSelect.addEventListener('change', () => { customDiv.style.display = sourceSelect.value === 'custom' ? '' : 'none'; }); } function openPasswordModal() { const pwOverlay = document.createElement('div'); pwOverlay.className = 'settings-overlay'; pwOverlay.style.zIndex = '10001'; const pwModal = document.createElement('div'); pwModal.className = 'settings-panel'; pwModal.style.maxWidth = '400px'; pwModal.innerHTML = `

修改密码

至少 8 位,包含大写/小写/数字/特殊字符中的 2 种
`; pwOverlay.appendChild(pwModal); document.body.appendChild(pwOverlay); const currentPwIn = pwModal.querySelector('#pw-modal-current'); const newPwIn = pwModal.querySelector('#pw-modal-new'); const confirmPwIn = pwModal.querySelector('#pw-modal-confirm'); const hint = pwModal.querySelector('#pw-modal-hint'); const submitBtn = pwModal.querySelector('#pw-modal-submit'); const status = pwModal.querySelector('#pw-modal-status'); function checkPw() { const newPw = newPwIn.value; const confirmPw = confirmPwIn.value; const currentPw = currentPwIn.value; if (!newPw) { hint.textContent = '至少 8 位,包含大写/小写/数字/特殊字符中的 2 种'; hint.className = 'password-hint'; submitBtn.disabled = true; return; } const result = clientValidatePassword(newPw); if (!result.valid) { hint.textContent = result.message; hint.className = 'password-hint error'; submitBtn.disabled = true; return; } hint.textContent = '密码强度符合要求'; hint.className = 'password-hint success'; submitBtn.disabled = !currentPw || !confirmPw || confirmPw !== newPw; } currentPwIn.addEventListener('input', checkPw); newPwIn.addEventListener('input', checkPw); confirmPwIn.addEventListener('input', checkPw); const closePwModal = () => { document.body.removeChild(pwOverlay); }; pwModal.querySelector('#pw-modal-close').addEventListener('click', closePwModal); pwOverlay.addEventListener('click', (e) => { if (e.target === pwOverlay) closePwModal(); }); submitBtn.addEventListener('click', () => { const currentPw = currentPwIn.value; const newPw = newPwIn.value; const confirmPw = confirmPwIn.value; if (newPw !== confirmPw) { status.textContent = '两次密码不一致'; status.className = 'settings-status error'; return; } submitBtn.disabled = true; status.textContent = '正在修改...'; status.className = 'settings-status'; _onPasswordChanged = (result) => { if (result.success) { status.textContent = result.message || '密码修改成功'; status.className = 'settings-status success'; setTimeout(closePwModal, 1200); } else { status.textContent = result.message || '修改失败'; status.className = 'settings-status error'; submitBtn.disabled = false; } }; send({ type: 'change_password', currentPassword: currentPw, newPassword: newPw }); }); currentPwIn.focus(); } function showCodexSettingsPanel() { send({ type: 'get_codex_config' }); send({ type: 'get_notify_config' }); const overlay = document.createElement('div'); overlay.className = 'settings-overlay'; overlay.id = 'settings-overlay'; const panel = document.createElement('div'); panel.className = 'settings-panel'; panel.innerHTML = `

⚙ Codex 设置

Codex 运行配置
容量失败重试
仅对没有产生文本或工具调用的 Codex 容量/过载失败生效,避免重复执行已有副作用的任务。
${buildAppearanceSettingsHtml()}
${buildNotifyEntryHtml(null)}
系统
`; overlay.appendChild(panel); document.body.appendChild(overlay); mountAppearanceSettings(panel); const notifyPageBtn = panel.querySelector('[data-open-notify-page]'); if (notifyPageBtn) notifyPageBtn.addEventListener('click', openNotifySubpage); const closeBtn = panel.querySelector('.settings-close'); const codexModeSelect = panel.querySelector('#codex-mode'); const codexProfileArea = panel.querySelector('#codex-profile-area'); const codexRetryModeSelect = panel.querySelector('#codex-retry-mode'); const codexRetryIntervalInput = panel.querySelector('#codex-retry-interval'); const codexRetryAttemptsInput = panel.querySelector('#codex-retry-attempts'); const codexRetryAttemptsField = panel.querySelector('#codex-retry-attempts-field'); const codexRetryNote = panel.querySelector('#codex-retry-note'); const codexStatus = panel.querySelector('#codex-status'); const codexSaveBtn = panel.querySelector('#codex-save-btn'); const pwOpenModalBtn = panel.querySelector('#pw-open-modal-btn'); const checkUpdateBtn = panel.querySelector('#check-update-btn'); const updateStatusEl = panel.querySelector('#update-status'); let currentCodexConfig = null; let codexEditingProfiles = []; let codexActiveProfile = ''; let codexRetryConfig = { mode: 'limited', intervalSeconds: 2, maxAttempts: 3 }; let _onUpdateInfo = null; function showCodexStatus(msg, type) { codexStatus.textContent = msg; codexStatus.className = 'settings-status ' + (type || ''); } function normalizeCodexRetryConfig(raw = {}) { const mode = ['off', 'limited', 'forever'].includes(raw.mode) ? raw.mode : 'limited'; const intervalSeconds = Math.max(1, Math.min(3600, Number.parseInt(String(raw.intervalSeconds || ''), 10) || 2)); const maxAttempts = Math.max(1, Math.min(1000, Number.parseInt(String(raw.maxAttempts || ''), 10) || 3)); return { mode, intervalSeconds, maxAttempts }; } function syncCodexRetryInputs() { const mode = codexRetryModeSelect.value; const disabled = mode === 'off'; codexRetryIntervalInput.disabled = disabled; codexRetryAttemptsInput.disabled = disabled || mode === 'forever'; codexRetryAttemptsField.classList.toggle('settings-field-disabled', disabled || mode === 'forever'); codexRetryNote.textContent = mode === 'off' ? '已关闭自动重试;容量/过载失败会直接显示错误。' : mode === 'forever' ? '会一直按固定间隔重试;仍只在没有文本或工具调用时触发。' : '按指定次数重试;仍只在没有文本或工具调用时触发,避免重复执行副作用。'; } function setCodexRetryConfig(config) { codexRetryConfig = normalizeCodexRetryConfig(config); codexRetryModeSelect.value = codexRetryConfig.mode; codexRetryIntervalInput.value = String(codexRetryConfig.intervalSeconds); codexRetryAttemptsInput.value = String(codexRetryConfig.maxAttempts); syncCodexRetryInputs(); } function readCodexRetryConfig() { return normalizeCodexRetryConfig({ mode: codexRetryModeSelect.value, intervalSeconds: codexRetryIntervalInput.value, maxAttempts: codexRetryAttemptsInput.value, }); } function renderCodexProfileArea() { const mode = codexModeSelect.value; if (mode === 'local') { codexProfileArea.innerHTML = `
当前将直接复用本机 codex 的登录态与 ~/.codex/config.toml。这适合你已经在终端里正常使用 Codex 的场景。
`; return; } if (codexEditingProfiles.length === 0) { codexProfileArea.innerHTML = `
自定义模式适合接 OpenAI 兼容服务,例如你提到的第三方 API 入口。这里仅覆盖 API KeyAPI Base URL,不会让配置页随意改模型 ID。
`; panel.querySelector('#codex-profile-add-first').addEventListener('click', () => openCodexProfileModal()); return; } const options = codexEditingProfiles.map((profile) => `` ).join(''); const currentProfile = codexEditingProfiles.find((profile) => profile.name === codexActiveProfile) || codexEditingProfiles[0]; if (currentProfile && !codexActiveProfile) codexActiveProfile = currentProfile.name; const summaryBase = currentProfile?.apiBase ? escapeHtml(currentProfile.apiBase) : '未设置 API Base URL'; codexProfileArea.innerHTML = `
自定义模式会为 cc-web 生成独立的 Codex 运行配置,只覆盖当前激活 Profile 的 API KeyAPI Base URL,不去碰你平时终端里用的全局登录态。
当前 Profile:${escapeHtml(currentProfile?.name || '未选择')}
API Base URL:${summaryBase}
`; panel.querySelector('#codex-profile-select').addEventListener('change', (e) => { if (e.target.value === '__new__') { openCodexProfileModal(); return; } codexActiveProfile = e.target.value; renderCodexProfileArea(); }); panel.querySelector('#codex-profile-edit').addEventListener('click', () => { openCodexProfileModal(codexActiveProfile); }); panel.querySelector('#codex-profile-del').addEventListener('click', () => { if (!codexActiveProfile) return; if (!confirm(`确认删除 Codex Profile「${codexActiveProfile}」?`)) return; codexEditingProfiles = codexEditingProfiles.filter((profile) => profile.name !== codexActiveProfile); codexActiveProfile = codexEditingProfiles[0]?.name || ''; renderCodexProfileArea(); }); } function openCodexProfileModal(profileName = '') { const current = profileName ? codexEditingProfiles.find((profile) => profile.name === profileName) : null; const draft = current || { name: '', apiKey: '', apiBase: '' }; const modalOverlay = document.createElement('div'); modalOverlay.className = 'settings-overlay'; modalOverlay.style.zIndex = '10001'; const modal = document.createElement('div'); modal.className = 'settings-panel'; modal.style.maxWidth = '460px'; modal.innerHTML = `

${current ? `编辑 Profile: ${escapeHtml(current.name)}` : '新建 Codex Profile'}

这里不开放模型 ID 编辑。Codex 仍使用上方“默认模型”以及会话内的模型切换逻辑,只把 API 入口和密钥切换到当前 Profile。
`; modalOverlay.appendChild(modal); document.body.appendChild(modalOverlay); const closeModal = () => document.body.removeChild(modalOverlay); modal.querySelector('#codex-profile-modal-close').addEventListener('click', closeModal); modalOverlay.addEventListener('click', (e) => { if (e.target === modalOverlay) closeModal(); }); modal.querySelector('#codex-profile-ok').addEventListener('click', () => { const name = modal.querySelector('#codex-profile-name').value.trim(); const apiKey = modal.querySelector('#codex-profile-apikey').value.trim(); const apiBase = modal.querySelector('#codex-profile-apibase').value.trim(); if (!name) { alert('请填写 Profile 名称'); return; } if (!apiKey) { alert('请填写 API Key'); return; } if (!apiBase) { alert('请填写 API Base URL'); return; } const existing = codexEditingProfiles.find((profile) => profile.name === name); if (existing && existing !== current) { alert('Profile 名称已存在'); return; } if (current) { current.name = name; current.apiKey = apiKey; current.apiBase = apiBase; } else { codexEditingProfiles.push({ name, apiKey, apiBase }); } codexActiveProfile = name; closeModal(); renderCodexProfileArea(); }); } _onCodexConfig = (config) => { currentCodexConfig = config || {}; codexModeSelect.value = currentCodexConfig.mode || 'local'; codexEditingProfiles = (currentCodexConfig.profiles || []).map((profile) => ({ ...profile })); codexActiveProfile = currentCodexConfig.activeProfile || (codexEditingProfiles[0]?.name || ''); setCodexRetryConfig(currentCodexConfig.retry || codexRetryConfig); renderCodexProfileArea(); }; codexModeSelect.addEventListener('change', renderCodexProfileArea); codexRetryModeSelect.addEventListener('change', syncCodexRetryInputs); setCodexRetryConfig(codexRetryConfig); codexSaveBtn.addEventListener('click', () => { if (codexModeSelect.value === 'custom' && codexEditingProfiles.length === 0) { showCodexStatus('自定义模式至少需要一个 Codex Profile', 'error'); return; } const config = { mode: codexModeSelect.value, activeProfile: codexActiveProfile, profiles: codexEditingProfiles, enableSearch: false, retry: readCodexRetryConfig(), }; send({ type: 'save_codex_config', config }); showCodexStatus('已保存', 'success'); }); pwOpenModalBtn.addEventListener('click', openPasswordModal); checkUpdateBtn.addEventListener('click', () => { updateStatusEl.textContent = '正在检查...'; updateStatusEl.className = 'settings-status'; _onUpdateInfo = (info) => { _onUpdateInfo = null; if (info.error) { updateStatusEl.textContent = '检查失败: ' + info.error; updateStatusEl.className = 'settings-status error'; return; } if (info.hasUpdate) { updateStatusEl.innerHTML = `有新版本 v${escapeHtml(info.latestVersion)}(当前 v${escapeHtml(info.localVersion)}) 查看更新`; updateStatusEl.className = 'settings-status success'; } else { updateStatusEl.textContent = `已是最新版本 v${info.localVersion}`; updateStatusEl.className = 'settings-status success'; } }; send({ type: 'check_update' }); }); window._ccOnUpdateInfo = (info) => { if (_onUpdateInfo) _onUpdateInfo(info); }; closeBtn.addEventListener('click', hideSettingsPanel); overlay.addEventListener('click', (e) => { if (e.target === overlay) hideSettingsPanel(); }); document.addEventListener('keydown', _settingsEscape); } function showSettingsPanel() { if (isCodexLikeAgent(currentAgent)) { showCodexSettingsPanel(); return; } // Request current configs (notify config is loaded on demand inside subpage) send({ type: 'get_model_config' }); send({ type: 'get_notify_config' }); const overlay = document.createElement('div'); overlay.className = 'settings-overlay'; overlay.id = 'settings-overlay'; const panel = document.createElement('div'); panel.className = 'settings-panel'; panel.innerHTML = `

⚙ Claude 设置

Claude 配置
${buildAppearanceSettingsHtml()}
${buildNotifyEntryHtml(null)}
系统
`; overlay.appendChild(panel); document.body.appendChild(overlay); mountAppearanceSettings(panel); const notifyPageBtn2 = panel.querySelector('[data-open-notify-page]'); if (notifyPageBtn2) notifyPageBtn2.addEventListener('click', openNotifySubpage); // === Model Config UI === const modelModeSelect = panel.querySelector('#model-mode'); const modelCustomArea = panel.querySelector('#model-custom-area'); const modelActionsDiv = panel.querySelector('#model-actions'); const modelSaveBtn = panel.querySelector('#model-save-btn'); const modelStatusDiv = panel.querySelector('#model-status'); let modelCurrentConfig = null; let modelEditingTemplates = []; let modelActiveTemplate = ''; function showModelStatus(msg, type) { modelStatusDiv.textContent = msg; modelStatusDiv.className = 'settings-status ' + (type || ''); } function renderModelCustomArea() { if (modelModeSelect.value === 'local') { modelCustomArea.innerHTML = `
⚠ 使用自定义模板会覆盖本地 API 配置,请提前做好备份。
`; modelActionsDiv.style.display = 'flex'; } else { renderModelTemplateEditor(); modelActionsDiv.style.display = 'flex'; } } function renderModelTemplateEditor() { const activeName = modelActiveTemplate; const tpl = modelEditingTemplates.find(t => t.name === activeName) || null; const tplOptions = modelEditingTemplates.map(t => `` ).join(''); if (modelEditingTemplates.length === 0) { modelCustomArea.innerHTML = `
尚无模板,点击下方按钮新建。
`; panel.querySelector('#model-tpl-add-first').addEventListener('click', () => { const newName = prompt('输入新模板名称:'); if (!newName || !newName.trim()) return; const n = newName.trim(); modelEditingTemplates.push({ name: n, apiKey: '', apiBase: '', defaultModel: '', opusModel: '', sonnetModel: '', haikuModel: '' }); modelActiveTemplate = n; renderModelTemplateEditor(); }); return; } modelCustomArea.innerHTML = `
`; panel.querySelector('#model-tpl-select').addEventListener('change', (e) => { if (e.target.value === '__new__') { const newName = prompt('输入新模板名称:'); if (!newName || !newName.trim()) { e.target.value = modelActiveTemplate; return; } const n = newName.trim(); if (modelEditingTemplates.find(t => t.name === n)) { alert('模板名称已存在'); e.target.value = modelActiveTemplate; return; } modelEditingTemplates.push({ name: n, apiKey: '', apiBase: '', defaultModel: '', opusModel: '', sonnetModel: '', haikuModel: '' }); modelActiveTemplate = n; renderModelTemplateEditor(); openTplEditModal(); } else { modelActiveTemplate = e.target.value; renderModelTemplateEditor(); } }); panel.querySelector('#model-tpl-edit').addEventListener('click', () => { openTplEditModal(); }); const delBtn = panel.querySelector('#model-tpl-del'); if (delBtn) { delBtn.addEventListener('click', () => { if (!modelActiveTemplate) return; if (!confirm(`确认删除模板「${modelActiveTemplate}」?`)) return; modelEditingTemplates = modelEditingTemplates.filter(t => t.name !== modelActiveTemplate); modelActiveTemplate = modelEditingTemplates[0]?.name || ''; renderModelTemplateEditor(); }); } } function openTplEditModal() { const tpl = modelEditingTemplates.find(t => t.name === modelActiveTemplate); if (!tpl) return; const modalOverlay = document.createElement('div'); modalOverlay.className = 'settings-overlay'; modalOverlay.style.zIndex = '10001'; const modal = document.createElement('div'); modal.className = 'settings-panel'; modal.style.maxWidth = '460px'; modal.innerHTML = `

编辑模板: ${escapeHtml(tpl.name)}

`; modalOverlay.appendChild(modal); document.body.appendChild(modalOverlay); // Custom endpoint checkbox toggle const customEndpointCb = modal.querySelector('#tpl-ed-custom-endpoint'); const endpointInput = modal.querySelector('#tpl-ed-models-endpoint'); customEndpointCb.addEventListener('change', () => { endpointInput.style.display = customEndpointCb.checked ? '' : 'none'; }); // Fetch models const fetchBtn = modal.querySelector('#tpl-ed-fetch-models'); const fetchStatus = modal.querySelector('#tpl-ed-fetch-status'); const datalist = modal.querySelector('#tpl-dl-models'); fetchBtn.addEventListener('click', () => { const apiBase = modal.querySelector('#tpl-ed-apibase').value.trim(); const apiKey = modal.querySelector('#tpl-ed-apikey').value.trim(); if (!apiBase || !apiKey) { fetchStatus.textContent = '请先填写 API Base 和 API Key'; fetchStatus.style.color = 'var(--text-error, #e85d5d)'; return; } const modelsEndpoint = customEndpointCb.checked ? endpointInput.value.trim() : ''; fetchBtn.disabled = true; fetchStatus.textContent = '正在获取...'; fetchStatus.style.color = 'var(--text-secondary)'; _onFetchModelsResult = (result) => { _onFetchModelsResult = null; fetchBtn.disabled = false; if (result.success) { datalist.innerHTML = result.models.map(m => ``) .join(''); const quickPickPaths = merged.slice(0, 6); cwdPicks.innerHTML = quickPickPaths.map((pathValue) => ` `).join(''); cwdPicks.querySelectorAll('.modal-quick-pick').forEach((button) => { button.addEventListener('click', () => { const pathValue = button.dataset.path || ''; if (!pathValue) return; cwdInput.value = pathValue; cwdInput.focus(); }); }); const fallbackPath = suggestionState.defaultPath || ''; cwdTip.textContent = fallbackPath ? `留空时默认使用 ${fallbackPath}` : '可手动输入路径,也可以点按钮选择目录'; } function requestCwdSuggestions() { if (suggestionsRequested) return; suggestionsRequested = true; _onCwdSuggestions = (payload) => { suggestionState = { defaultPath: String(payload?.defaultPath || '').trim(), paths: Array.isArray(payload?.paths) ? payload.paths : [], }; if (!cwdInput.value.trim()) { cwdInput.value = recentCwds[0] || suggestionState.defaultPath || ''; } renderCwdOptions(); }; send({ type: 'list_cwd_suggestions' }); } renderCwdOptions(); requestCwdSuggestions(); function createSession() { const cwd = getEffectiveCwd(); const rawCwd = cwdInput.value.trim(); close(); requestNewSession({ cwd, rawCwd, agent: targetAgent, mode: requestedMode, model: options.model || '', title: options.title || '', branchSourceSessionId: options.branchSourceSessionId || '', branchMessageIndex: options.branchMessageIndex, }); } pickDirBtn.addEventListener('click', () => { showDirectoryPicker({ title: '选择工作目录', initialPath: getEffectiveCwd() || '', onChoose: (selectedPath) => { cwdInput.value = selectedPath; cwdInput.focus(); }, }); }); cwdInput.addEventListener('focus', () => { if (!suggestionState.defaultPath && (!suggestionState.paths || suggestionState.paths.length === 0)) { requestCwdSuggestions(); } }); cwdInput.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); createSession(); } }); function close() { closeDirectoryPicker(); overlay.remove(); _onCwdSuggestions = null; } overlay.querySelector('#ns-close-btn').addEventListener('click', close); overlay.querySelector('#ns-cancel-btn').addEventListener('click', close); overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); }); createBtn.addEventListener('click', createSession); cwdInput.focus(); } function quickCreateProjectSession(cwd, options = {}) { const targetCwd = String(cwd || '').trim(); requestNewSession({ cwd: targetCwd || null, rawCwd: targetCwd, agent: options.agent || currentAgent, mode: options.mode || currentMode, }); } // --- Import Native Session Modal --- let _onNativeSessions = null; function appendImportVisibilityToggle(body, options) { const hiddenCount = Number(options?.hiddenCount || 0); if (!body || hiddenCount <= 0) return; const row = document.createElement('label'); row.className = 'import-filter-row'; row.innerHTML = ` 显示已导入会话 已隐藏 ${hiddenCount} 个 cc-web 已存在的会话 `; const input = row.querySelector('input'); input.addEventListener('change', () => options.onToggle(!!input.checked)); body.appendChild(row); } function showImportSessionModal() { if (currentAgent !== 'claude') return; let nativeGroups = []; let showImported = false; const overlay = document.createElement('div'); overlay.className = 'modal-overlay'; overlay.id = 'import-session-overlay'; overlay.innerHTML = ` `; document.body.appendChild(overlay); function close() { overlay.remove(); _onNativeSessions = null; } overlay.querySelector('#is-close-btn').addEventListener('click', close); overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); }); function renderNativeSessions() { const body = overlay.querySelector('#is-body'); if (!body) return; if (!nativeGroups || nativeGroups.length === 0) { body.innerHTML = `${buildAgentContextCard('claude', '从 Claude 原生历史导入', '读取 ~/.claude/projects/ 下的会话文件,恢复对话文本与工具调用,并保留 Claude 侧续接上下文。')}`; return; } body.innerHTML = buildAgentContextCard('claude', '从 Claude 原生历史导入', '读取 ~/.claude/projects/ 下的会话文件,恢复对话文本与工具调用,并保留 Claude 侧续接上下文。'); const hiddenCount = nativeGroups.reduce((sum, group) => ( sum + (Array.isArray(group.sessions) ? group.sessions.filter((sess) => sess.alreadyImported).length : 0) ), 0); appendImportVisibilityToggle(body, { hiddenCount, showImported, onToggle: (next) => { showImported = next; renderNativeSessions(); }, }); const visibleGroups = nativeGroups .map((group) => ({ ...group, sessions: showImported ? (group.sessions || []) : (group.sessions || []).filter((sess) => !sess.alreadyImported), })) .filter((group) => group.sessions.length > 0); if (visibleGroups.length === 0) { body.insertAdjacentHTML('beforeend', ``); return; } for (const group of visibleGroups) { const groupEl = document.createElement('div'); groupEl.className = 'import-group'; // Convert slug dir to readable path let readablePath = group.dir.replace(/-/g, '/'); if (!readablePath.startsWith('/')) readablePath = '/' + readablePath; readablePath = readablePath.replace(/\/+/g, '/'); const groupTitle = document.createElement('div'); groupTitle.className = 'import-group-title'; groupTitle.textContent = readablePath; groupEl.appendChild(groupTitle); for (const sess of group.sessions) { const item = document.createElement('div'); item.className = 'import-item'; const info = document.createElement('div'); info.className = 'import-item-info'; const titleEl = document.createElement('div'); titleEl.className = 'import-item-title'; titleEl.textContent = sess.title; const meta = document.createElement('div'); meta.className = 'import-item-meta'; const cwdText = sess.cwd ? sess.cwd : ''; const timeText = sess.updatedAt ? timeAgo(sess.updatedAt) : ''; meta.textContent = [cwdText, timeText].filter(Boolean).join(' · '); info.appendChild(titleEl); info.appendChild(meta); const btn = document.createElement('button'); btn.className = 'import-item-btn'; btn.textContent = sess.alreadyImported ? '重新导入' : '导入'; btn.addEventListener('click', () => { if (sess.alreadyImported) { if (!confirm('已导入过此会话,重新导入将覆盖已有内容。确认继续?')) return; } else { if (!confirm('由于 cc-web 与本地 CLI 的逻辑不同,导入会话需要解析后方可展示,导入后将覆盖已有内容。确认继续?')) return; } close(); send({ type: 'import_native_session', sessionId: sess.sessionId, projectDir: group.dir }); }); item.appendChild(info); item.appendChild(btn); groupEl.appendChild(item); } body.appendChild(groupEl); } } _onNativeSessions = (groups) => { nativeGroups = Array.isArray(groups) ? groups : []; renderNativeSessions(); }; send({ type: 'list_native_sessions' }); } function showImportCodexSessionModal() { if (!isCodexLikeAgent(currentAgent)) return; const importAgent = currentAgent; let codexItems = []; let showImported = false; const label = AGENT_LABELS[importAgent] || 'Codex'; const contextTitle = importAgent === 'codexapp' ? '从 Codex App rollout 历史导入' : '从 Codex rollout 历史导入'; const contextCopy = importAgent === 'codexapp' ? '读取 ~/.codex/sessions/ 下的 rollout 文件,恢复对话文本、工具调用和 token 统计,并绑定 Codex App 线程用于后续续接。' : '读取 ~/.codex/sessions/ 下的 rollout 文件,恢复用户消息、助手输出、函数调用和 token 统计。'; const overlay = document.createElement('div'); overlay.className = 'modal-overlay'; overlay.id = 'import-codex-session-overlay'; overlay.innerHTML = ` `; document.body.appendChild(overlay); function close() { overlay.remove(); _onCodexSessions = null; } overlay.querySelector('#ics-close-btn').addEventListener('click', close); overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); }); function renderCodexSessions() { const body = overlay.querySelector('#ics-body'); if (!body) return; if (!codexItems || codexItems.length === 0) { body.innerHTML = `${buildAgentContextCard(importAgent, contextTitle, contextCopy)}`; return; } body.innerHTML = buildAgentContextCard(importAgent, contextTitle, contextCopy); const hiddenCount = codexItems.filter((sess) => sess.alreadyImported).length; appendImportVisibilityToggle(body, { hiddenCount, showImported, onToggle: (next) => { showImported = next; renderCodexSessions(); }, }); const visibleItems = showImported ? codexItems : codexItems.filter((sess) => !sess.alreadyImported); if (visibleItems.length === 0) { body.insertAdjacentHTML('beforeend', ``); return; } visibleItems.forEach((sess) => { const item = document.createElement('div'); item.className = 'import-item'; const info = document.createElement('div'); info.className = 'import-item-info'; const titleEl = document.createElement('div'); titleEl.className = 'import-item-title'; titleEl.textContent = sess.title || sess.threadId; const meta = document.createElement('div'); meta.className = 'import-item-meta'; meta.textContent = [ sess.cwd || '', sess.source ? `source:${sess.source}` : '', sess.updatedAt ? timeAgo(sess.updatedAt) : '', ].filter(Boolean).join(' · '); const tags = document.createElement('div'); tags.className = 'import-item-tags'; if (sess.cliVersion) { const ver = document.createElement('span'); ver.className = 'import-item-tag'; ver.textContent = `CLI ${sess.cliVersion}`; tags.appendChild(ver); } if (sess.source) { const source = document.createElement('span'); source.className = 'import-item-tag'; source.textContent = sess.source; tags.appendChild(source); } if ((sess.duplicateCount || 0) > 1) { const merged = document.createElement('span'); merged.className = 'import-item-tag'; merged.textContent = `合并 ${sess.duplicateCount} 条`; tags.appendChild(merged); } info.appendChild(titleEl); info.appendChild(meta); if (tags.children.length > 0) info.appendChild(tags); const btn = document.createElement('button'); btn.className = 'import-item-btn'; btn.textContent = sess.alreadyImported ? '重新导入' : '导入'; btn.addEventListener('click', () => { const confirmed = sess.alreadyImported ? confirm(`已导入过此 ${label} 会话,重新导入将覆盖已有内容。确认继续?`) : confirm(`将解析本地 ${label} rollout 历史并导入当前 Web 视图。确认继续?`); if (!confirmed) return; close(); send({ type: 'import_codex_session', agent: importAgent, threadId: sess.threadId, rolloutPath: sess.rolloutPath }); }); item.appendChild(info); item.appendChild(btn); body.appendChild(item); }); } _onCodexSessions = (items) => { codexItems = Array.isArray(items) ? items : []; renderCodexSessions(); }; send({ type: 'list_codex_sessions', agent: importAgent }); } // --- Helpers --- function escapeHtml(str) { if (!str) return ''; return String(str).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); } function timeAgo(dateStr) { if (!dateStr) return ''; const diff = Date.now() - new Date(dateStr).getTime(); const mins = Math.floor(diff / 60000); if (mins < 1) return '刚刚'; if (mins < 60) return `${mins}分钟前`; const hours = Math.floor(mins / 60); if (hours < 24) return `${hours}小时前`; const days = Math.floor(hours / 24); if (days < 30) return `${days}天前`; return new Date(dateStr).toLocaleDateString('zh-CN'); } // --- Init --- applyTheme(currentTheme); setCurrentAgent(currentAgent); updateNoteModeUI(); renderSessionList(); connect(); window.addEventListener('resize', updateCwdBadge); window.addEventListener('online', () => { reconnectAttempts = 0; connect(); }); window.addEventListener('offline', clearReconnectTimer); window.addEventListener('pagehide', () => { isPageUnloading = true; clearReconnectTimer(); if (ws && ws.readyState <= 1) { ws.close(1001, 'pagehide'); } }); window.addEventListener('pageshow', () => { isPageUnloading = false; if (!ws || ws.readyState > 1) { reconnectAttempts = 0; connect(); } }); // Register Service Worker for mobile push notifications if ('serviceWorker' in navigator) { navigator.serviceWorker.register(`/sw.js?v=${ASSET_VERSION}`).catch(() => {}); } // Restore remembered password const savedPw = localStorage.getItem('cc-web-pw'); if (savedPw) { loginPassword.value = savedPw; rememberPw.checked = true; } // Visibility change: re-sync state when user returns to tab (critical for mobile) document.addEventListener('visibilitychange', () => { if (document.visibilityState !== 'visible') return; if (!ws || ws.readyState > 1) { // WS is dead, force reconnect reconnectAttempts = 0; connect(); } else if (ws.readyState === 1 && currentSessionId) { // Preserve active streaming UI when returning to foreground. if (isGenerating || currentSessionRunning) { send({ type: 'load_session', sessionId: currentSessionId }); } else { beginSessionSwitch(currentSessionId, { blocking: false, force: true }); } } }); if (!authToken) { loginOverlay.hidden = false; app.hidden = true; } else { loginOverlay.hidden = true; app.hidden = false; } })();