diff --git a/.codex/skills/cc-web-centos7-release/SKILL.md b/.codex/skills/cc-web-centos7-release/SKILL.md new file mode 100644 index 0000000..8dd4d86 --- /dev/null +++ b/.codex/skills/cc-web-centos7-release/SKILL.md @@ -0,0 +1,90 @@ +--- +name: cc-web-centos7-release +description: Rebuild and verify the cc-web CentOS 7 compatible Bun baseline single executable release package. Use when the user asks to 打包, 重新打包, build single-exe, CentOS 7 发布包, dist-exe/cc-web-bun-linux-x64-baseline.tar.gz, or asks why Bun/BUN_BIN was needed for this project. +--- + +# cc-web CentOS 7 Release + +## Core Rules + +- Build the CentOS 7 release with `scripts/build-single-exe.js`. +- The default target must stay `bun-linux-x64-baseline`. +- The release archive is `dist-exe/cc-web-bun-linux-x64-baseline.tar.gz`. +- Do not switch this project back to Docker for CentOS 7 compatibility. +- Do not install NodeSource Node.js 22 on CentOS 7. CentOS 7 has glibc 2.17, while current NodeSource Node.js 22 packages require newer glibc/libstdc++ symbols. +- Do not bundle Claude/Codex CLI into the release package. cc-web must call host CLIs at runtime through `CLAUDE_PATH`, `CODEX_PATH`, or `PATH`. + +## Build Workflow + +1. Check whether `bun` is available: + +```bash +command -v bun +``` + +2. If `bun` is not in `PATH`, reuse an existing local Bun binary before downloading anything: + +```bash +find /home /tmp -type f -name bun -perm -111 2>/dev/null | head -50 +``` + +Prefer a baseline binary path like: + +```text +/tmp/ccweb-bun.*/node_modules/@oven/bun-linux-x64-baseline/bin/bun +``` + +3. Build with `BUN_BIN` when using a local/temporary Bun: + +```bash +BUN_BIN=/tmp/ccweb-bun.rhfNgd/node_modules/@oven/bun-linux-x64-baseline/bin/bun npm run build:single-exe +``` + +If `bun` is already in `PATH`, this is enough: + +```bash +npm run build:single-exe +``` + +4. After adding or changing `.codex/skills`, `.agents/skills`, public assets, runtime assets, or server code, rebuild again. The build copies runtime assets into `dist-exe/bun-linux-x64-baseline/` before creating the tarball. + +## Verification + +Run lightweight checks before committing: + +```bash +node --check server.js +node --check lib/codex-app-runtime.js +node --check scripts/mock-codex-app-server.js +node --check scripts/mock-codex.js +node --check scripts/regression.js +node --check public/app.js +./dist-exe/bun-linux-x64-baseline/cc-web --ccweb-mcp-server +tar -tzf dist-exe/cc-web-bun-linux-x64-baseline.tar.gz | head +``` + +For the MCP smoke test, send one JSON-RPC `initialize` request on stdin and expect a valid JSON response. Do not leave the process running. + +## CentOS 7 Run Command + +On the target machine, unpack the archive and run the binary from the release directory: + +```bash +tar -xzf cc-web-bun-linux-x64-baseline.tar.gz +cd bun-linux-x64-baseline +chmod +x cc-web +PORT=8002 CC_WEB_PASSWORD='请改成强密码' ./cc-web +``` + +For background execution without PM2: + +```bash +nohup env PORT=8002 CC_WEB_PASSWORD='请改成强密码' ./cc-web > logs/cc-web.out 2>&1 & +``` + +For host CLI paths: + +```bash +export CLAUDE_PATH=/usr/local/bin/claude +export CODEX_PATH=/usr/local/bin/codex +``` diff --git a/.codex/skills/cc-web-centos7-release/agents/openai.yaml b/.codex/skills/cc-web-centos7-release/agents/openai.yaml new file mode 100644 index 0000000..e9d8a73 --- /dev/null +++ b/.codex/skills/cc-web-centos7-release/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "CC Web CentOS7 Release" + short_description: "固化 cc-web CentOS 7 单文件打包流程" + default_prompt: "Use $cc-web-centos7-release to rebuild the CentOS 7 Bun baseline single-exe release package." diff --git a/dist-exe/cc-web-bun-linux-x64-baseline.tar.gz b/dist-exe/cc-web-bun-linux-x64-baseline.tar.gz index d7c1b6a..6d2ffce 100644 Binary files a/dist-exe/cc-web-bun-linux-x64-baseline.tar.gz and b/dist-exe/cc-web-bun-linux-x64-baseline.tar.gz differ diff --git a/lib/codex-app-runtime.js b/lib/codex-app-runtime.js index 3b8175b..ddaef99 100644 --- a/lib/codex-app-runtime.js +++ b/lib/codex-app-runtime.js @@ -131,6 +131,28 @@ function createCodexAppRuntime(deps = {}) { return true; } + function codexAppErrorMessage(value) { + if (!value) return ''; + if (typeof value === 'string') return value; + if (typeof value !== 'object') return String(value); + + const parts = []; + const directMessage = value.message || value.title || value.detail || value.reason; + if (directMessage) parts.push(String(directMessage)); + + const error = value.error && typeof value.error === 'object' ? value.error : null; + if (error) { + if (error.message) parts.push(String(error.message)); + if (error.code) parts.push(String(error.code)); + if (error.type) parts.push(String(error.type)); + } + + if (value.code) parts.push(String(value.code)); + if (value.type) parts.push(String(value.type)); + if (parts.length > 0) return [...new Set(parts)].join(' '); + return safeStringifyPreview(value, 2000, { maxDepth: 3, maxArray: 10, maxKeys: 20 }); + } + function sendRuntime(entry, sessionId, payload) { wsSend(entry.ws, { ...payload, sessionId }); } @@ -652,7 +674,7 @@ function createCodexAppRuntime(deps = {}) { if (params.turn?.id) entry.turnId = params.turn.id; entry.turnStatus = params.turn?.status || 'completed'; if (params.turn?.status === 'failed') { - entry.lastError = params.turn?.error?.message || 'Codex App 任务失败'; + entry.lastError = codexAppErrorMessage(params.turn?.error) || 'Codex App 任务失败'; } return { done: true }; } @@ -662,14 +684,16 @@ function createCodexAppRuntime(deps = {}) { case 'guardianWarning': case 'configWarning': case 'deprecationNotice': { - const message = params.message || params.title || ''; + const message = method === 'error' + ? codexAppErrorMessage(params) + : (params.message || params.title || ''); if (message) { if (method === 'error') entry.lastError = message; if (method === 'error' || shouldShowRuntimeNotice(method, message)) { sendRuntime(entry, sessionId, { type: 'system_message', message }); } } - return { done: false }; + return { done: method === 'error' }; } default: diff --git a/public/app.js b/public/app.js index 4f5ed20..a37df2c 100644 --- a/public/app.js +++ b/public/app.js @@ -2,7 +2,7 @@ (function () { 'use strict'; - const ASSET_VERSION = '20260624-icon-refresh'; + const ASSET_VERSION = '20260625-branch-bubble'; const WS_URL = `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/ws`; const RENDER_DEBOUNCE = 100; const COMPOSER_SUGGESTION_DEBOUNCE = 120; @@ -11,10 +11,13 @@ const CROSS_CONVERSATION_REPLY_COLLAPSE_STORAGE_KEY = 'cc-web-collapsed-cross-replies'; const CROSS_CONVERSATION_REPLY_COLLAPSE_LIMIT = 500; const ASSISTANT_LAST_SECTION_BUTTON_CLASS = 'msg-last-section-btn'; + const ASSISTANT_BRANCH_BUTTON_CLASS = 'msg-branch-btn'; const ASSISTANT_LAST_SECTION_FOCUS_CLASS = 'msg-last-section-focus'; const ASSISTANT_LAST_SECTION_SCROLL_OFFSET = 72; const ASSISTANT_LAST_SECTION_SKIP_SELECTOR = [ `.${ASSISTANT_LAST_SECTION_BUTTON_CLASS}`, + `.${ASSISTANT_BRANCH_BUTTON_CLASS}`, + '.msg-action-row', '.msg-tools', '.tool-call', '.tool-group', @@ -190,6 +193,7 @@ const attachmentPreviewCache = new Map(); let loginPasswordValue = ''; // store login password for force-change flow let currentCwd = null; + let currentSessionMessageCount = 0; let currentSessionRunning = false; let fileBrowserState = null; let directoryPickerState = null; @@ -1406,17 +1410,74 @@ return button; } + function getMessageActionRow(bubble) { + if (!bubble) return null; + let row = bubble.querySelector(':scope > .msg-action-row'); + if (!row) { + row = document.createElement('div'); + row.className = 'msg-action-row'; + bubble.appendChild(row); + } + return row; + } + function syncAssistantLastSectionButton(messageEl) { if (!messageEl?.classList?.contains('assistant')) return; const bubble = messageEl.querySelector(':scope > .msg-bubble'); if (!bubble) return; - let button = bubble.querySelector(`:scope > .${ASSISTANT_LAST_SECTION_BUTTON_CLASS}`); + let button = bubble.querySelector(`:scope .${ASSISTANT_LAST_SECTION_BUTTON_CLASS}`); const hasTarget = !!getAssistantLastSectionTarget(bubble); if (!button && !hasTarget) return; if (!button) button = createAssistantLastSectionButton(); button.hidden = !hasTarget; button.disabled = !hasTarget; - bubble.appendChild(button); + getMessageActionRow(bubble)?.appendChild(button); + } + + function createAssistantBranchButton(messageIndex) { + const button = document.createElement('button'); + button.type = 'button'; + button.className = ASSISTANT_BRANCH_BUTTON_CLASS; + button.title = '从这里分支新会话'; + button.setAttribute('aria-label', '从这里分支新会话'); + button.dataset.messageIndex = String(messageIndex); + button.innerHTML = ` + + `; + button.addEventListener('click', (event) => { + event.preventDefault(); + event.stopPropagation(); + const index = Number.parseInt(button.dataset.messageIndex || '', 10); + branchFromAssistantMessage(index); + }); + return button; + } + + function syncAssistantBranchButton(messageEl, messageIndex) { + if (!messageEl?.classList?.contains('assistant')) return; + if (!currentSessionId || !Number.isFinite(messageIndex) || messageIndex < 0) return; + const bubble = messageEl.querySelector(':scope > .msg-bubble'); + if (!bubble) return; + let button = bubble.querySelector(`:scope .${ASSISTANT_BRANCH_BUTTON_CLASS}`); + if (!button) button = createAssistantBranchButton(messageIndex); + button.dataset.messageIndex = String(messageIndex); + getMessageActionRow(bubble)?.appendChild(button); + } + + function markSessionMessageElement(messageEl, messageIndex) { + if (!messageEl || !Number.isFinite(messageIndex) || messageIndex < 0) return; + messageEl.dataset.sessionMessage = 'true'; + messageEl.dataset.messageIndex = String(messageIndex); + if (messageEl.classList.contains('assistant')) { + syncAssistantBranchButton(messageEl, messageIndex); + } } function updateSessionIdBadge() { @@ -1534,10 +1595,17 @@ function normalizeSessionSnapshot(payload, options = {}) { const sessionId = payload.sessionId || payload.id || ''; + const messages = cloneMessages(payload.messages || []); + const historyTotal = Number.isFinite(Number(payload.historyTotal)) + ? Math.max(0, Number(payload.historyTotal)) + : messages.length; + const historyBaseIndex = Number.isFinite(Number(payload.historyBaseIndex)) + ? Math.max(0, Number(payload.historyBaseIndex)) + : Math.max(0, historyTotal - messages.length); return { sessionId, id: sessionId, - messages: cloneMessages(payload.messages || []), + messages, title: payload.title || '新会话', mode: payload.mode || 'yolo', model: payload.model || '', @@ -1558,6 +1626,8 @@ waitingReplyCount: Number(payload.waitingReplyCount || 0), failedReplyCount: Number(payload.failedReplyCount || 0), pendingReplies: Array.isArray(payload.pendingReplies) ? deepClone(payload.pendingReplies) : [], + historyTotal, + historyBaseIndex, historyPending: !!payload.historyPending, complete: options.complete !== undefined ? !!options.complete : !payload.historyPending, }; @@ -3332,6 +3402,7 @@ closeFileBrowser(); currentSessionId = null; loadedHistorySessionId = null; + currentSessionMessageCount = 0; clearSessionLoading(); setCurrentSessionRunningState(false); currentCwd = null; @@ -3375,6 +3446,11 @@ } currentSessionId = snapshot.sessionId; loadedHistorySessionId = snapshot.sessionId; + currentSessionMessageCount = Math.max( + snapshot.historyTotal || 0, + snapshot.historyBaseIndex + (snapshot.messages || []).length, + (snapshot.messages || []).length, + ); setLastSessionForAgent(snapshot.agent, currentSessionId); chatTitle.textContent = snapshot.title || '新会话'; updateSessionIdBadge(); @@ -3393,7 +3469,10 @@ } currentModel = snapshot.model || ''; if (!preserveStreaming) { - renderMessages(snapshot.messages || [], { immediate: !!options.immediate }); + renderMessages(snapshot.messages || [], { + immediate: !!options.immediate, + baseIndex: snapshot.historyBaseIndex || 0, + }); if (snapshot.isRunning && snapshot.sessionId === currentSessionId) { startGenerating(snapshot.sessionId); } @@ -4231,6 +4310,7 @@ prependHistoryMessages(msg.messages || [], { preserveScroll: !blocking, skipScrollbar: blocking, + baseIndex: Number.isFinite(Number(msg.historyBaseIndex)) ? Number(msg.historyBaseIndex) : 0, }); if (!msg.remaining) { finalizeLoadedSession(msg.sessionId); @@ -4266,11 +4346,13 @@ } } if (msg.sessionId === currentSessionId && msg.message) { + const messageIndex = currentSessionMessageCount; + currentSessionMessageCount += 1; collectClosedCollabAgentIds([msg.message]).forEach((id) => closedCollabAgentIds.add(id)); const welcome = messagesDiv.querySelector('.welcome-msg'); if (welcome) welcome.remove(); const shouldFollow = !(currentSessionRunning || isGenerating) || isNearBottom(); - messagesDiv.appendChild(buildMsgElement(msg.message)); + messagesDiv.appendChild(buildMsgElement(msg.message, messageIndex)); followOutputIfNeeded(shouldFollow); setCurrentSessionRunningState(!!getSessionMeta(currentSessionId)?.isRunning); } @@ -4494,6 +4576,10 @@ cwd: request.cwd, agent: request.agent, mode: request.mode, + model: request.model, + title: request.title, + branchSourceSessionId: request.branchSourceSessionId, + branchMessageIndex: request.branchMessageIndex, createCwd: true, requestId: request.requestId, }); @@ -4503,6 +4589,10 @@ agent: request.agent, cwd: request.rawCwd || request.cwd, mode: request.mode, + model: request.model, + title: request.title, + branchSourceSessionId: request.branchSourceSessionId, + branchMessageIndex: request.branchMessageIndex, }); }, }); @@ -4632,6 +4722,10 @@ function finishGenerating(sessionId) { if (sessionId && currentSessionId && sessionId !== currentSessionId) return; + const hasPersistedAssistantMessage = !!( + pendingText + || (Array.isArray(window.pendingContentBlocks) && window.pendingContentBlocks.length > 0) + ); isGenerating = false; generatingSessionId = null; updateNoteModeUI(); @@ -4674,6 +4768,11 @@ } } streamEl.removeAttribute('id'); + if (hasPersistedAssistantMessage && currentSessionId) { + const messageIndex = currentSessionMessageCount; + currentSessionMessageCount += 1; + markSessionMessageElement(streamEl, messageIndex); + } syncAssistantLastSectionButton(streamEl); } @@ -5769,7 +5868,7 @@ return fallbackMessages.length > 0 ? fallbackMessages[fallbackMessages.length - 1] : null; } - function buildMsgElement(m) { + function buildMsgElement(m, messageIndex = null) { const el = createMsgElement(m.role, m.content, m.attachments || [], m); if (m.role === 'assistant' && m.toolCalls && m.toolCalls.length > 0) { const bubble = el.querySelector('.msg-bubble'); @@ -5820,12 +5919,16 @@ } } } + if (Number.isFinite(messageIndex)) { + markSessionMessageElement(el, messageIndex); + } return el; } function renderMessages(messages, options = {}) { renderEpoch++; const epoch = renderEpoch; + const baseIndex = Number.isFinite(Number(options.baseIndex)) ? Number(options.baseIndex) : 0; closedCollabAgentIds = collectClosedCollabAgentIds(messages); messagesDiv.innerHTML = ''; clearUserMessageIndex(); @@ -5838,7 +5941,7 @@ } if (options.immediate) { const frag = document.createDocumentFragment(); - messages.forEach((message) => frag.appendChild(buildMsgElement(message))); + messages.forEach((message, index) => frag.appendChild(buildMsgElement(message, baseIndex + index))); messagesDiv.appendChild(frag); updateUserOutlinePanel(); renderPendingNotes({ scroll: false }); @@ -5861,7 +5964,7 @@ // Render first batch immediately const frag0 = document.createDocumentFragment(); - for (let i = batches[0][0]; i < batches[0][1]; i++) frag0.appendChild(buildMsgElement(messages[i])); + for (let i = batches[0][0]; i < batches[0][1]; i++) frag0.appendChild(buildMsgElement(messages[i], baseIndex + i)); messagesDiv.appendChild(frag0); updateUserOutlinePanel(); renderPendingNotes({ scroll: false }); @@ -5878,7 +5981,7 @@ const prevHeight = messagesDiv.scrollHeight; const prevScrollTop = messagesDiv.scrollTop; const frag = document.createDocumentFragment(); - for (let i = start; i < end; i++) frag.appendChild(buildMsgElement(messages[i])); + for (let i = start; i < end; i++) frag.appendChild(buildMsgElement(messages[i], baseIndex + i)); messagesDiv.insertBefore(frag, messagesDiv.firstChild); updateUserOutlinePanel(); // Compensate scrollTop so visible area stays unchanged @@ -5890,13 +5993,14 @@ function prependHistoryMessages(messages, options = {}) { if (!Array.isArray(messages) || messages.length === 0) return; + const baseIndex = Number.isFinite(Number(options.baseIndex)) ? Number(options.baseIndex) : 0; collectClosedCollabAgentIds(messages).forEach((id) => closedCollabAgentIds.add(id)); const preserveScroll = options.preserveScroll !== false; const skipScrollbar = options.skipScrollbar === true; const welcome = messagesDiv.querySelector('.welcome-msg'); if (welcome) welcome.remove(); const frag = document.createDocumentFragment(); - messages.forEach((m) => frag.appendChild(buildMsgElement(m))); + messages.forEach((m, index) => frag.appendChild(buildMsgElement(m, baseIndex + index))); if (!preserveScroll) { messagesDiv.insertBefore(frag, messagesDiv.firstChild); updateUserOutlinePanel(); @@ -7210,6 +7314,11 @@ const messageId = createLocalId('user'); const element = createMsgElement('user', text, attachments, { messageId }); messagesDiv.appendChild(element); + if (currentSessionId) { + const messageIndex = currentSessionMessageCount; + currentSessionMessageCount += 1; + markSessionMessageElement(element, messageIndex); + } registerUserMessage(messageId, element, text); updateUserOutlinePanel(); scrollToBottom(); @@ -7259,6 +7368,11 @@ } else { messagesDiv.appendChild(element); } + if (currentSessionId) { + const messageIndex = currentSessionMessageCount; + currentSessionMessageCount += 1; + markSessionMessageElement(element, messageIndex); + } registerUserMessage(messageId, element, text); updateUserOutlinePanel(); if (shouldFollow) { @@ -7886,6 +8000,31 @@
+ + +