// === CC-Web Frontend === (function () { 'use strict'; const WS_URL = `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/ws`; const RENDER_DEBOUNCE = 100; const SLASH_COMMANDS = [ { cmd: '/clear', desc: '清除当前会话' }, { cmd: '/model', desc: '查看/切换模型' }, { cmd: '/mode', desc: '查看/切换权限模式' }, { cmd: '/cost', desc: '查看会话费用' }, { cmd: '/compact', desc: '压缩上下文' }, { cmd: '/init', desc: '生成/更新 CLAUDE.md' }, { cmd: '/help', desc: '显示帮助' }, ]; const MODE_LABELS = { default: '默认', plan: 'Plan', yolo: 'YOLO', }; const AGENT_LABELS = { claude: 'Claude', codex: 'Codex', }; 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 MODEL_OPTIONS = [ { value: 'opus', label: 'Opus', desc: '最强大,适合复杂任务' }, { value: 'sonnet', label: 'Sonnet', desc: '平衡性能与速度' }, { 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'], }, ]; // --- State --- let ws = null; 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 pendingText = ''; let renderTimer = null; let activeToolCalls = new Map(); 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 codexConfigCache = null; let loadedHistorySessionId = null; let activeSessionLoad = null; let sidebarSwipe = null; let pendingAttachments = []; let uploadingAttachments = []; let loginPasswordValue = ''; // store login password for force-change flow let currentCwd = null; let currentSessionRunning = false; let skipDeleteConfirm = localStorage.getItem('cc-web-skip-delete-confirm') === '1'; let pendingInitialSessionLoad = false; // --- 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 sessionList = $('#session-list'); const chatTitle = $('#chat-title'); const chatAgentBtn = $('#chat-agent-btn'); const chatAgentMenu = $('#chat-agent-menu'); const chatRuntimeState = $('#chat-runtime-state'); const chatCwd = $('#chat-cwd'); const costDisplay = $('#cost-display'); const attachmentTray = $('#attachment-tray'); const imageUploadInput = $('#image-upload-input'); const attachBtn = $('#attach-btn'); const messagesDiv = $('#messages'); const msgInput = $('#msg-input'); const inputWrapper = msgInput.closest('.input-wrapper'); const sendBtn = $('#send-btn'); const abortBtn = $('#abort-btn'); const cmdMenu = $('#cmd-menu'); const modeSelect = $('#mode-select'); // --- 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 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 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 buildThemeEntryHtml() { return `
外观
`; } 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 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 = {}) { return { sessionId: payload.sessionId, messages: cloneMessages(payload.messages || []), title: payload.title || '新会话', mode: payload.mode || 'yolo', model: payload.model || '', agent: normalizeAgent(payload.agent), hasUnread: !!payload.hasUnread, cwd: payload.cwd || null, totalCost: typeof payload.totalCost === 'number' ? payload.totalCost : 0, totalUsage: payload.totalUsage ? deepClone(payload.totalUsage) : null, updated: payload.updated || null, isRunning: !!payload.isRunning, 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 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) { 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.isRunning = !!meta.isRunning; } 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 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 {} } 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 renderAttachmentLabels(attachments, options = {}) { if (!Array.isArray(attachments) || attachments.length === 0) return ''; const labels = attachments.map((attachment) => { const stateSuffix = attachment.storageState === 'expired' ? '(已过期)' : ''; const name = escapeHtml(attachment.filename || 'image'); return `图片: ${name}${stateSuffix}`; }).join(''); return `
${labels}
`; } 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); return uploadImageFile(optimized); })); 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 shouldOverlayRuntimeBadge() { return window.matchMedia('(max-width: 768px), (pointer: coarse)').matches; } 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; } else { chatCwd.textContent = ''; chatCwd.title = ''; } chatCwd.hidden = !currentCwd || (currentSessionRunning && shouldOverlayRuntimeBadge()); } function setCurrentSessionRunningState(isRunning) { const running = !!isRunning; currentSessionRunning = running; if (chatRuntimeState) { chatRuntimeState.hidden = !running; chatRuntimeState.textContent = running ? '运行中' : ''; } updateCwdBadge(); } 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 = currentAgent === 'codex' ? '导入本地 Codex 会话' : '导入本地 Claude 会话'; } } function setCurrentAgent(agent) { currentAgent = normalizeAgent(agent); localStorage.setItem('cc-web-agent', currentAgent); currentMode = localStorage.getItem(getAgentModeStorageKey(currentAgent)) || 'yolo'; modeSelect.value = currentMode; updateAgentScopedUI(); } 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); currentSessionId = null; loadedHistorySessionId = null; clearSessionLoading(); setCurrentSessionRunningState(false); currentCwd = null; currentModel = currentAgent === 'claude' ? 'opus' : ''; isGenerating = false; pendingText = ''; pendingAttachments = []; uploadingAttachments = []; activeToolCalls.clear(); sendBtn.hidden = false; abortBtn.hidden = true; chatTitle.textContent = '新会话'; updateCwdBadge(); messagesDiv.innerHTML = buildWelcomeMarkup(currentAgent); setStatsDisplay(null); renderPendingAttachments(); highlightActiveSession(); } function applySessionSnapshot(snapshot, options = {}) { if (!snapshot) return; const preserveStreaming = !!(options.preserveStreaming && isGenerating && snapshot.sessionId === currentSessionId && snapshot.isRunning); if (isGenerating && !preserveStreaming) { isGenerating = false; sendBtn.hidden = false; abortBtn.hidden = true; pendingText = ''; activeToolCalls.clear(); } currentSessionId = snapshot.sessionId; loadedHistorySessionId = snapshot.sessionId; setLastSessionForAgent(snapshot.agent, currentSessionId); chatTitle.textContent = snapshot.title || '新会话'; setCurrentAgent(snapshot.agent); setCurrentSessionRunningState(snapshot.isRunning); setStatsDisplay(snapshot); 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 }); } 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); 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 setSessionLoading(sessionId, options = {}) { const loading = !!sessionId; const blocking = options.blocking !== false; activeSessionLoad = loading ? { sessionId, blocking, snapshot: null } : null; const showOverlay = !!(loading && blocking); document.body.classList.toggle('session-loading-active', showOverlay); sessionLoadingOverlay.hidden = !showOverlay; sessionLoadingOverlay.setAttribute('aria-hidden', showOverlay ? 'false' : 'true'); 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 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) return; if (!force && sessionId === currentSessionId && !activeSessionLoad) return; renderEpoch++; loadedHistorySessionId = null; setSessionLoading(sessionId, { blocking, label: options.label }); send({ type: 'load_session', sessionId }); } function showCachedSession(sessionId) { const snapshot = buildCachedSessionSnapshot(sessionId); if (!snapshot) return false; if (currentSessionId && currentSessionId !== sessionId) { send({ type: 'detach_view' }); } clearSessionLoading(); touchSessionCache(sessionId); applySessionSnapshot(snapshot, { immediate: true, suppressUnreadToast: true }); return true; } function openSession(sessionId, options = {}) { if (!sessionId) return; 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 (currentAgent === 'codex' && 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) => normalizeAgent(s.agent) === 'codex' && s.id === currentSessionId) .forEach((s) => addBaseOption(s.model, s.model, '当前会话已保存模型')); return options; } // --- marked config --- const PREVIEW_LANGS = new Set(['html', 'svg']); const _previewCodeMap = new Map(); let _previewCodeId = 0; const renderer = new marked.Renderer(); renderer.code = function (code, language) { const lang = (language || 'plaintext').toLowerCase(); let highlighted; try { if (hljs.getLanguage(lang)) { highlighted = hljs.highlight(code, { language: lang }).value; } else { highlighted = hljs.highlightAuto(code).value; } } catch { highlighted = escapeHtml(code); } const canPreview = PREVIEW_LANGS.has(lang); const previewBtn = canPreview ? `` : ''; const previewPane = canPreview ? `
` : ''; const cid = canPreview ? (++_previewCodeId) : 0; if (canPreview) _previewCodeMap.set(cid, code); return `
${escapeHtml(lang)}
${previewBtn}
${previewPane}
${highlighted}
`; }; marked.setOptions({ renderer, breaks: true, gfm: true }); window.ccCopyCode = function (btn) { const wrapper = btn.closest('.code-block-wrapper'); const cid = wrapper.dataset.cid ? Number(wrapper.dataset.cid) : 0; const code = (cid && _previewCodeMap.has(cid)) ? _previewCodeMap.get(cid) : wrapper.querySelector('code').textContent; navigator.clipboard.writeText(code).then(() => { btn.textContent = 'Copied!'; setTimeout(() => btn.textContent = 'Copy', 1500); }); }; window.ccTogglePreview = function (btn) { 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'; } }; // --- WebSocket --- function connect() { if (ws && ws.readyState <= 1) return; ws = new WebSocket(WS_URL); ws.onopen = () => { reconnectAttempts = 0; if (authToken) send({ type: 'auth', token: authToken }); }; ws.onmessage = (e) => { let msg; try { msg = JSON.parse(e.data); } catch { return; } handleServerMessage(msg); }; ws.onclose = () => { clearSessionLoading(); scheduleReconnect(); }; ws.onerror = () => {}; } function send(data) { if (ws && ws.readyState === 1) ws.send(JSON.stringify(data)); } function scheduleReconnect() { if (reconnectTimer) return; const delay = Math.min(1000 * Math.pow(2, reconnectAttempts), 30000); reconnectAttempts++; reconnectTimer = setTimeout(() => { reconnectTimer = null; connect(); }, delay); } // --- Server Message Handler --- function handleServerMessage(msg) { switch (msg.type) { case 'auth_result': if (msg.success) { authToken = msg.token; localStorage.setItem('cc-web-token', msg.token); document.dispatchEvent(new CustomEvent('cc-web-auth-restored')); loginOverlay.hidden = true; app.hidden = false; send({ type: 'get_codex_config' }); // Check if must change password if (msg.mustChangePassword) { showForceChangePassword(); } else { pendingInitialSessionLoad = true; } } else { authToken = null; 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 = msg.sessions || []; reconcileSessionCacheWithSessions(); renderSessionList(); if (currentSessionId) { setCurrentSessionRunningState(!!getSessionMeta(currentSessionId)?.isRunning); } if (pendingInitialSessionLoad) { pendingInitialSessionLoad = false; syncViewForAgent(currentAgent, { preserveCurrent: false, loadLast: true }); } else if (currentSessionId && !getSessionMeta(currentSessionId)) { resetChatView(currentAgent); } break; case 'session_info': const snapshot = normalizeSessionSnapshot(msg); if (activeSessionLoad?.sessionId === msg.sessionId) { activeSessionLoad.snapshot = snapshot; } applySessionSnapshot(snapshot, { immediate: isBlockingSessionLoad(msg.sessionId), suppressUnreadToast: false, preserveStreaming: msg.sessionId === currentSessionId && msg.isRunning, }); if (!msg.historyPending) { if (activeSessionLoad?.sessionId === msg.sessionId) { 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, }); if (!msg.remaining) { finalizeLoadedSession(msg.sessionId); } } 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 'text_delta': if (!isGenerating) startGenerating(); pendingText += msg.text; scheduleRender(); break; case 'tool_start': if (!isGenerating) startGenerating(); 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_end': 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); break; case 'cost': costDisplay.textContent = `$${msg.costUsd.toFixed(4)}`; if (currentSessionId) { updateCachedSession(currentSessionId, (snapshot) => { snapshot.totalCost = msg.costUsd; }); } break; case 'usage': 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': finishGenerating(msg.sessionId); break; case 'system_message': appendSystemMessage(msg.message); 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': // Server has an active process for this session — resume streaming setCurrentSessionRunningState(true); if (!isGenerating || !document.getElementById('streaming-msg')) { startGenerating(); } else { sendBtn.hidden = true; abortBtn.hidden = false; toolGroupCount = 0; hasGrouped = false; activeToolCalls.clear(); const toolsDiv = document.querySelector('#streaming-msg .msg-tools'); if (toolsDiv) toolsDiv.innerHTML = ''; } pendingText = msg.text || ''; flushRender(); if (msg.toolCalls && msg.toolCalls.length > 0) { for (const tc of msg.toolCalls) { 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.done && tc.result) { updateToolCall(tc.id, tc.result); } } } break; case 'error': appendError(msg.message); 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.paths || []); break; case 'update_info': if (typeof window._ccOnUpdateInfo === 'function') window._ccOnUpdateInfo(msg); break; } } // --- Generating State --- function startGenerating() { isGenerating = true; setCurrentSessionRunningState(true); pendingText = ''; activeToolCalls.clear(); toolGroupCount = 0; hasGrouped = false; sendBtn.hidden = true; abortBtn.hidden = false; // 不禁用输入框,允许用户继续输入(但无法发送) const welcome = messagesDiv.querySelector('.welcome-msg'); if (welcome) welcome.remove(); const msgEl = createMsgElement('assistant', ''); msgEl.id = 'streaming-msg'; // 流式消息 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); messagesDiv.appendChild(msgEl); scrollToBottom(); } function finishGenerating(sessionId) { isGenerating = false; sendBtn.hidden = false; abortBtn.hidden = true; setCurrentSessionRunningState(false); msgInput.focus(); if (pendingText) flushRender(); 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(c => c.classList.contains('tool-call')); 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 (sessionId) currentSessionId = sessionId; pendingText = ''; activeToolCalls.clear(); toolGroupCount = 0; hasGrouped = false; } // --- 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; let textDiv = bubble.querySelector('.msg-text'); if (!textDiv) { textDiv = bubble; } textDiv.innerHTML = renderMarkdown(pendingText); scrollToBottom(); } function renderMarkdown(text) { if (!text) return '
'; try { return marked.parse(text); } catch { return escapeHtml(text); } } function createMsgElement(role, content, attachments = []) { const div = document.createElement('div'); div.className = `msg ${role}${role === 'assistant' ? ' agent-' + currentAgent : ''}`; if (role === 'system') { const bubble = document.createElement('div'); bubble.className = 'msg-bubble'; bubble.textContent = content; div.appendChild(bubble); return div; } const avatar = document.createElement('div'); avatar.className = 'msg-avatar'; if (role === 'user') { avatar.textContent = 'U'; } else if (currentAgent === 'codex') { avatar.innerHTML = `Codex`; } else { avatar.innerHTML = `Claude`; } const bubble = document.createElement('div'); bubble.className = 'msg-bubble'; 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); } if (attachments.length > 0) { bubble.insertAdjacentHTML('beforeend', renderAttachmentLabels(attachments)); } } else { bubble.innerHTML = content ? renderMarkdown(content) : ''; if (attachments.length > 0) { bubble.insertAdjacentHTML('beforeend', renderAttachmentLabels(attachments)); } } div.appendChild(avatar); div.appendChild(bubble); return div; } let renderEpoch = 0; function toolKind(tool) { return tool?.kind || tool?.meta?.kind || ''; } function toolTitle(tool) { if (tool?.meta?.title) return tool.meta.title; return tool?.name || 'Tool'; } function toolSubtitle(tool) { 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 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 buildMsgElement(m) { const el = createMsgElement(m.role, m.content, m.attachments || []); if (m.role === 'assistant' && m.toolCalls && m.toolCalls.length > 0) { const bubble = el.querySelector('.msg-bubble'); const FOLD_AT = 3; let grouped = false; for (const tc of m.toolCalls) { const details = createToolCallElement(tc.id || `saved-${Math.random().toString(36).slice(2)}`, tc, true); // 散落的 .tool-call 达到 FOLD_AT 个时,移入唯一 .tool-group const loose = Array.from(bubble.children).filter(c => c.classList.contains('tool-call')); if (loose.length >= FOLD_AT) { let group = bubble.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); bubble.insertBefore(group, bubble.firstChild); grouped = true; } const inner = group.querySelector('.tool-group-inner'); loose.forEach(c => inner.appendChild(c)); _refreshGroupSummary(group); } bubble.appendChild(details); } // 结束时若出现过父目录,收尾散落项 if (grouped) { const loose = Array.from(bubble.children).filter(c => c.classList.contains('tool-call')); if (loose.length > 0) { const group = bubble.querySelector(':scope > .tool-group'); if (group) { const inner = group.querySelector('.tool-group-inner'); loose.forEach(c => inner.appendChild(c)); _refreshGroupSummary(group); } } } } return el; } function renderMessages(messages, options = {}) { renderEpoch++; const epoch = renderEpoch; messagesDiv.innerHTML = ''; if (messages.length === 0) { messagesDiv.innerHTML = buildWelcomeMarkup(currentAgent); return; } if (options.immediate) { const frag = document.createDocumentFragment(); messages.forEach((message) => frag.appendChild(buildMsgElement(message))); messagesDiv.appendChild(frag); 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])); messagesDiv.appendChild(frag0); 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])); messagesDiv.insertBefore(frag, messagesDiv.firstChild); // 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 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) => frag.appendChild(buildMsgElement(m))); if (!preserveScroll) { messagesDiv.insertBefore(frag, messagesDiv.firstChild); if (!skipScrollbar) updateScrollbar(); return; } const prevHeight = messagesDiv.scrollHeight; const prevScrollTop = messagesDiv.scrollTop; messagesDiv.insertBefore(frag, messagesDiv.firstChild); 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 (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 details = document.createElement('details'); details.className = 'tool-call'; details.id = `tool-${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); const kind = toolKind(tool); if (tool.name === 'AskUserQuestion') { details.open = true; } else if (agent !== 'codex' && !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 appendToolCall(toolUseId, name, input, done, kind = null, meta = null) { const streamEl = document.getElementById('streaming-msg'); if (!streamEl) return; const bubble = streamEl.querySelector('.msg-bubble'); if (!bubble) return; let toolsDiv = bubble.querySelector('.msg-tools'); if (!toolsDiv) { toolsDiv = bubble; } const tool = { id: toolUseId, name, input, kind, meta, done }; const details = createToolCallElement(toolUseId, tool, done); // 折叠策略:只维护唯一一个 .tool-group 父节点 // 散落的 .tool-call 直接子节点达到3个时,将它们全部移入父节点;之后继续散落,再达3个再移入 const FOLD_AT = 3; const looseBefore = Array.from(toolsDiv.children).filter(c => c.classList.contains('tool-call')); 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); scrollToBottom(); } 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 updateToolCall(toolUseId, result) { const el = document.getElementById(`tool-${toolUseId}`); if (!el) return; const tool = activeToolCalls.get(toolUseId) || { id: toolUseId, name: el.dataset.toolName || '', kind: el.dataset.toolKind || null, done: true, }; tool.done = true; if (result !== undefined) tool.result = result; const summary = el.querySelector('summary'); if (summary) applyToolSummary(summary, tool, true); if (tool.name === 'AskUserQuestion') return; const nextContent = buildToolContentElement(tool); const content = el.querySelector('.tool-call-content'); if (content) content.replaceWith(nextContent); } function getDeleteConfirmMessage(agent) { const normalized = normalizeAgent(agent); if (normalized === 'codex') { return '删除本会话将同步删去本地 Codex rollout 历史与线程记录,不可恢复。确认删除?'; } return '删除本会话将同步删去本地 Claude 中的会话历史,不可恢复。确认删除?'; } 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) { const welcome = messagesDiv.querySelector('.welcome-msg'); if (welcome) welcome.remove(); messagesDiv.appendChild(createMsgElement('system', message)); scrollToBottom(); } function appendError(message) { const div = document.createElement('div'); div.className = 'msg system'; div.innerHTML = `
⚠ ${escapeHtml(message)}
`; messagesDiv.appendChild(div); scrollToBottom(); } function scrollToBottom() { requestAnimationFrame(() => { messagesDiv.scrollTop = messagesDiv.scrollHeight; 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 = ''; const visibleSessions = getVisibleSessions(); if (visibleSessions.length === 0) { const empty = document.createElement('div'); empty.className = 'session-list-empty'; empty.textContent = `暂无 ${AGENT_LABELS[currentAgent]} 会话,点击“新会话”开始。`; sessionList.appendChild(empty); return; } for (const s of visibleSessions) { const item = document.createElement('div'); item.className = `session-item${s.id === currentSessionId ? ' active' : ''}`; item.dataset.id = s.id; item.innerHTML = `
${escapeHtml(s.title || 'Untitled')} ${s.isRunning ? '运行中' : ''}
${s.hasUnread ? '' : ''} ${timeAgo(s.updated)}
`; item.addEventListener('click', (e) => { const target = e.target; if (target.classList.contains('delete')) { e.stopPropagation(); const doDelete = () => { if (getLastSessionForAgent(currentAgent) === s.id) { localStorage.removeItem(getAgentSessionStorageKey(currentAgent)); } invalidateSessionCache(s.id); send({ type: 'delete_session', sessionId: s.id }); if (s.id === currentSessionId) { resetChatView(currentAgent); } }; if (skipDeleteConfirm) { doDelete(); } else { showDeleteConfirm(s.agent, doDelete); } return; } if (target.classList.contains('edit')) { e.stopPropagation(); startEditSessionTitle(item, s); return; } openSession(s.id); }); sessionList.appendChild(item); } } 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); }); } // --- Header title editing (contenteditable) --- chatTitle.addEventListener('click', () => { if (!currentSessionId || chatTitle.contentEditable === 'true') return; const originalText = chatTitle.textContent; chatTitle.contentEditable = 'true'; chatTitle.style.background = '#fff'; 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(); } }); }); // --- 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(); } } // --- Slash Command Menu --- function showCmdMenu(filter) { const filtered = SLASH_COMMANDS.filter(c => c.cmd.startsWith(filter) || c.desc.includes(filter.slice(1)) ); // Exact match first (fixes /mode vs /model ambiguity) filtered.sort((a, b) => (b.cmd === filter ? 1 : 0) - (a.cmd === filter ? 1 : 0)); if (filtered.length === 0) { hideCmdMenu(); return; } cmdMenuIndex = 0; cmdMenu.innerHTML = filtered.map((c, i) => `
${c.cmd} ${c.desc}
` ).join(''); cmdMenu.hidden = false; // Click handlers cmdMenu.querySelectorAll('.cmd-item').forEach(el => { el.addEventListener('click', () => { const cmd = el.dataset.cmd; if (cmd === '/model') { hideCmdMenu(); msgInput.value = ''; showModelPicker(); return; } if (cmd === '/mode') { hideCmdMenu(); msgInput.value = ''; showModePicker(); return; } msgInput.value = cmd + ' '; hideCmdMenu(); msgInput.focus(); }); }); } function hideCmdMenu() { cmdMenu.hidden = true; cmdMenuIndex = -1; } 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'); } function selectCmdMenuItem() { const items = cmdMenu.querySelectorAll('.cmd-item'); if (cmdMenuIndex >= 0 && items[cmdMenuIndex]) { const cmd = items[cmdMenuIndex].dataset.cmd; if (cmd === '/model') { hideCmdMenu(); msgInput.value = ''; showModelPicker(); return; } if (cmd === '/mode') { hideCmdMenu(); msgInput.value = ''; showModePicker(); return; } msgInput.value = cmd + ' '; hideCmdMenu(); msgInput.focus(); } } // --- 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 (currentAgent === 'codex') { const current = _splitCodexThinkingModel(currentModel || ''); const baseOptions = getCodexBaseModelOptions(); showOptionPicker('选择 Codex 模型', baseOptions, current.base || '', (baseValue) => { const base = String(baseValue || '').trim(); const thinkingOptions = [ { value: '', label: '无 (默认)', desc: '不附加 (medium/high/xhigh) 后缀' }, { 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 sendMessage() { const text = msgInput.value.trim(); if ((!text && pendingAttachments.length === 0) || isGenerating || isBlockingSessionLoad()) return; hideCmdMenu(); hideOptionPicker(); // 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 welcome = messagesDiv.querySelector('.welcome-msg'); if (welcome) welcome.remove(); const attachments = pendingAttachments.map((attachment) => ({ ...attachment })); messagesDiv.appendChild(createMsgElement('user', text, attachments)); scrollToBottom(); send({ type: 'message', text, attachments, sessionId: currentSessionId, mode: currentMode, agent: currentAgent }); msgInput.value = ''; pendingAttachments = []; renderPendingAttachments(); autoResize(); startGenerating(); } 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 }); }); }); } // 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 (currentAgent === 'codex') { 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(); } }); sendBtn.addEventListener('click', sendMessage); 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 }); } if (currentMode === 'default') { appendSystemMessage('⚠ 由于项目设计与 CLI 原生逻辑不同,默认模式的授权申请功能暂未实现,建议搭配 Plan 或 YOLO 模式使用。'); } }); msgInput.addEventListener('input', () => { autoResize(); const val = msgInput.value; // Show slash command menu if (val.startsWith('/') && !val.includes('\n')) { showCmdMenu(val); } else { hideCmdMenu(); } }); 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(); return; } } 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 (!cmdMenu.contains(e.target) && e.target !== msgInput) { hideCmdMenu(); } }); // --- 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}」任务完成`, 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 运行配置
${buildThemeEntryHtml()}
${buildNotifyEntryHtml(null)}
系统
`; overlay.appendChild(panel); document.body.appendChild(overlay); const themePageBtn = panel.querySelector('[data-open-theme-page]'); if (themePageBtn) themePageBtn.addEventListener('click', openThemeSubpage); 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 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 _onUpdateInfo = null; function showCodexStatus(msg, type) { codexStatus.textContent = msg; codexStatus.className = 'settings-status ' + (type || ''); } 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 || ''); renderCodexProfileArea(); }; codexModeSelect.addEventListener('change', renderCodexProfileArea); 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, }; 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 (currentAgent === 'codex') { 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 配置
${buildThemeEntryHtml()}
${buildNotifyEntryHtml(null)}
系统
`; overlay.appendChild(panel); document.body.appendChild(overlay); const themePageBtn = panel.querySelector('[data-open-theme-page]'); if (themePageBtn) themePageBtn.addEventListener('click', openThemeSubpage); 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(''); }; send({ type: 'list_cwd_suggestions' }); }); function close() { 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(); }); overlay.querySelector('#ns-create-btn').addEventListener('click', () => { const cwd = cwdInput.value.trim() || null; close(); send({ type: 'new_session', cwd, agent: targetAgent, mode: currentMode }); }); cwdInput.focus(); } // --- Import Native Session Modal --- let _onNativeSessions = null; function showImportSessionModal() { if (currentAgent !== 'claude') return; 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(); }); _onNativeSessions = (groups) => { const body = overlay.querySelector('#is-body'); if (!body) return; if (!groups || groups.length === 0) { body.innerHTML = `${buildAgentContextCard('claude', '从 Claude 原生历史导入', '读取 ~/.claude/projects/ 下的会话文件,恢复对话文本与工具调用,并保留 Claude 侧续接上下文。')}`; return; } body.innerHTML = buildAgentContextCard('claude', '从 Claude 原生历史导入', '读取 ~/.claude/projects/ 下的会话文件,恢复对话文本与工具调用,并保留 Claude 侧续接上下文。'); for (const group of groups) { 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); } }; send({ type: 'list_native_sessions' }); } function showImportCodexSessionModal() { if (currentAgent !== 'codex') return; 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(); }); _onCodexSessions = (items) => { const body = overlay.querySelector('#ics-body'); if (!body) return; if (!items || items.length === 0) { body.innerHTML = `${buildAgentContextCard('codex', '从 Codex rollout 历史导入', '读取 ~/.codex/sessions/ 下的 rollout 文件,恢复用户消息、助手输出、函数调用和 token 统计。')}`; return; } body.innerHTML = buildAgentContextCard('codex', '从 Codex rollout 历史导入', '读取 ~/.codex/sessions/ 下的 rollout 文件,恢复用户消息、助手输出、函数调用和 token 统计。'); items.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); } 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('已导入过此 Codex 会话,重新导入将覆盖已有内容。确认继续?') : confirm('将解析本地 Codex rollout 历史并导入当前 Web 视图。确认继续?'); if (!confirmed) return; close(); send({ type: 'import_codex_session', threadId: sess.threadId, rolloutPath: sess.rolloutPath }); }); item.appendChild(info); item.appendChild(btn); body.appendChild(item); }); }; send({ type: 'list_codex_sessions' }); } // --- 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); renderSessionList(); connect(); window.addEventListener('resize', updateCwdBadge); // Register Service Worker for mobile push notifications if ('serviceWorker' in navigator) { navigator.serviceWorker.register('/sw.js').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 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; } })();