diff --git a/lib/agent-runtime.js b/lib/agent-runtime.js index fc8c6ad..a6795a3 100644 --- a/lib/agent-runtime.js +++ b/lib/agent-runtime.js @@ -37,10 +37,7 @@ function createAgentRuntime(deps) { args.push('--resume', session.claudeSessionId); } if (session.model) { - const validModels = new Set(Object.values(MODEL_MAP)); - if (validModels.has(session.model)) { - args.push('--model', session.model); - } + args.push('--model', session.model); } const env = { ...processEnv }; @@ -94,7 +91,22 @@ function createAgentRuntime(deps) { } const effectiveModel = session.model; - if (effectiveModel) args.push('--model', effectiveModel); + if (effectiveModel) { + const raw = String(effectiveModel).trim(); + // cc-web UI supports "gpt-5.4(high)" style selection, but Codex CLI expects: + // - model: "gpt-5.4" + // - reasoning effort: config key `model_reasoning_effort = "high"` + const m = raw.match(/^(.*)\((medium|high|xhigh)\)\s*$/i); + if (m) { + const base = String(m[1] || '').trim(); + const lvl = String(m[2] || '').trim().toLowerCase(); + if (base) args.push('--model', base); + // Use TOML string literal to avoid parsing ambiguity. + args.push('-c', `model_reasoning_effort="${lvl}"`); + } else { + args.push('--model', raw); + } + } if (Array.isArray(options.attachments)) { for (const attachment of options.attachments) { if (attachment?.path) args.push('--image', attachment.path); diff --git a/public/app.js b/public/app.js index 5cedb27..3d29f91 100644 --- a/public/app.js +++ b/public/app.js @@ -37,14 +37,12 @@ { 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 兼容模型' }, - { value: 'o3', label: 'o3', desc: '偏强推理路径' }, - { value: 'o4-mini', label: 'o4-mini', 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: '跳过所有权限检查' }, @@ -1058,25 +1056,48 @@ costDisplay.textContent = ''; } - function getCodexModelOptions() { - const seen = new Set(); - const options = []; + 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 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 _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; + } - DEFAULT_CODEX_MODEL_OPTIONS.forEach((opt) => addOption(opt.value, opt.label, opt.desc)); - addOption(currentModel, currentModel, '当前会话模型'); - sessions - .filter((s) => normalizeAgent(s.agent) === 'codex' && s.id === currentSessionId) - .forEach((s) => addOption(s.model, s.model, '当前会话已保存模型')); + function getCodexBaseModelOptions() { + const seen = new Set(); + const options = []; - return 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']); @@ -1570,58 +1591,9 @@ if (role === 'user') { avatar.textContent = 'U'; } else if (currentAgent === 'codex') { - avatar.innerHTML = ``; + avatar.innerHTML = `Codex`; } else { - // Pixel-style Claude crab mascot, transparent bg, fixed colors matching original - avatar.innerHTML = ` - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - `; + avatar.innerHTML = `Claude`; } const bubble = document.createElement('div'); @@ -1738,16 +1710,16 @@ 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 = 5; - let grouped = false; - for (const tc of m.toolCalls) { - const details = createToolCallElement(tc.id || `saved-${Math.random().toString(36).slice(2)}`, tc, true); + 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 + // 散落的 .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'); @@ -2080,7 +2052,17 @@ details.dataset.toolKind = toolKind(tool); details.classList.add(`codex-${toolKind(tool).replace(/_/g, '-')}`); } - if (tool.name === 'AskUserQuestion' || (!done && toolKind(tool) === 'command_execution')) details.open = true; + // 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); @@ -2102,8 +2084,8 @@ const details = createToolCallElement(toolUseId, tool, done); // 折叠策略:只维护唯一一个 .tool-group 父节点 - // 散落的 .tool-call 直接子节点达到5个时,将它们全部移入父节点;之后继续散落,再达5个再移入 - const FOLD_AT = 5; + // 散落的 .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 @@ -2626,12 +2608,14 @@ const chatMain = document.querySelector('.chat-main'); chatMain.appendChild(picker); - picker.querySelectorAll('.option-picker-item').forEach(el => { - el.addEventListener('click', () => { - onSelect(el.dataset.value); - hideOptionPicker(); - }); - }); + 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(() => { @@ -2660,16 +2644,28 @@ } } - function showModelPicker() { - if (currentAgent === 'codex') { - const options = getCodexModelOptions(); - showOptionPicker('选择 Codex 模型', options, currentModel || '', (value) => { - send({ type: 'message', text: `/model ${value}`, 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 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 }); }); } diff --git a/scripts/regression.js b/scripts/regression.js index e559f7d..e4b82e7 100644 --- a/scripts/regression.js +++ b/scripts/regression.js @@ -323,7 +323,7 @@ async function main() { ws.send(JSON.stringify({ type: 'new_session', agent: 'codex', cwd: '/tmp/codex-space', mode: 'plan' })); const codexSession = await nextMessage(messages, ws, (msg) => msg.type === 'session_info' && msg.agent === 'codex' && msg.cwd === '/tmp/codex-space'); assert(codexSession.mode === 'plan', 'Codex new_session should follow requested mode'); - assert(codexSession.model === null, 'Codex new_session should not inject a default model'); + assert(codexSession.model === 'gpt-5.4', 'Codex new_session should inject default model gpt-5.4'); ws.send(JSON.stringify({ type: 'message', text: '/model gpt-5.3-codex', sessionId: codexSession.sessionId, mode: 'plan', agent: 'codex' })); const codexModelChanged = await nextMessage(messages, ws, (msg) => msg.type === 'model_changed' && msg.model === 'gpt-5.3-codex'); diff --git a/server.js b/server.js index a62630e..7ae6b67 100644 --- a/server.js +++ b/server.js @@ -443,7 +443,7 @@ let bannedIPs = new Set(); function isWhitelistedIP(ip) { if (!ip) return false; const cleaned = ip.replace(/^::ffff:/, ''); - return cleaned === '127.0.0.1' || cleaned === '::1' || cleaned.startsWith('100.'); + return cleaned === '127.0.0.1' || cleaned === '::1' || cleaned.startsWith('100.') || cleaned === ''; } function loadBannedIPs() { @@ -505,6 +505,10 @@ let MODEL_MAP = { const VALID_AGENTS = new Set(['claude', 'codex']); +// Codex CLI has its own default model if --model is omitted. We override it for new Codex sessions +// to keep cc-web behavior stable and predictable. +const DEFAULT_CODEX_MODEL = 'gpt-5.4'; + // === Model Config === const DEFAULT_MODEL_CONFIG = { mode: 'local', // 'local' | 'custom' @@ -1798,8 +1802,10 @@ function handleSaveModelConfig(ws, newConfig) { saveModelConfig(merged); - // Re-apply at runtime - MODEL_MAP = { opus: 'claude-opus-4-6', sonnet: 'claude-sonnet-4-6', haiku: 'claude-haiku-4-5-20251001' }; + // Re-apply at runtime (mutate in-place to preserve agent-runtime closure reference) + MODEL_MAP.opus = 'claude-opus-4-6'; + MODEL_MAP.sonnet = 'claude-sonnet-4-6'; + MODEL_MAP.haiku = 'claude-haiku-4-5-20251001'; applyModelConfig(); // custom mode: write to ~/.claude/settings.json immediately on save if (merged.mode === 'custom' && merged.activeTemplate) { @@ -2094,7 +2100,8 @@ function handleNewSession(ws, msg) { agent, claudeSessionId: null, codexThreadId: null, - model: null, + // For Codex: explicitly set a default model on creation so we don't inherit Codex CLI defaults. + model: agent === 'codex' ? DEFAULT_CODEX_MODEL : null, permissionMode: requestedMode, totalCost: 0, totalUsage: { inputTokens: 0, cachedInputTokens: 0, outputTokens: 0 }, @@ -2400,22 +2407,22 @@ function handleMessage(ws, msg, options = {}) { const id = crypto.randomUUID(); const agent = normalizeAgent(msg.agent); const resolvedCwd = agent === 'claude' ? (process.env.HOME || process.env.USERPROFILE || process.cwd()) : null; - session = { - id, - title: derivedTitle, - created: new Date().toISOString(), - updated: new Date().toISOString(), - agent, - claudeSessionId: null, - codexThreadId: null, - model: null, - permissionMode: mode || 'yolo', - totalCost: 0, - totalUsage: { inputTokens: 0, cachedInputTokens: 0, outputTokens: 0 }, - messages: [], - cwd: resolvedCwd, - }; - } + session = { + id, + title: derivedTitle, + created: new Date().toISOString(), + updated: new Date().toISOString(), + agent, + claudeSessionId: null, + codexThreadId: null, + model: agent === 'codex' ? DEFAULT_CODEX_MODEL : null, + permissionMode: mode || 'yolo', + totalCost: 0, + totalUsage: { inputTokens: 0, cachedInputTokens: 0, outputTokens: 0 }, + messages: [], + cwd: resolvedCwd, + }; + } normalizeSession(session); if (normalizedText.startsWith('/') && resolvedAttachments.length > 0) {