From 62ab6f358d4f7bad402b0822afa38a2071376402 Mon Sep 17 00:00:00 2001 From: shiyue Date: Fri, 15 May 2026 18:35:38 +0800 Subject: [PATCH] feat: enhance session UX and codex defaults --- lib/agent-runtime.js | 5 +- public/app.js | 238 +++++++++++++++++++++++++++++++++++++++--- public/style.css | 207 +++++++++++++++++++++++++++++++++--- scripts/regression.js | 17 ++- server.js | 142 ++++++++++++++++++++++--- 5 files changed, 564 insertions(+), 45 deletions(-) diff --git a/lib/agent-runtime.js b/lib/agent-runtime.js index 1b72bd3..cf42df0 100644 --- a/lib/agent-runtime.js +++ b/lib/agent-runtime.js @@ -6,6 +6,7 @@ function createAgentRuntime(deps) { MODEL_MAP, loadModelConfig, applyCustomTemplateToSettings, + getDefaultCodexModel, loadCodexConfig, prepareCodexCustomRuntime, wsSend, @@ -96,13 +97,13 @@ function createAgentRuntime(deps) { break; } - const effectiveModel = session.model; + const effectiveModel = session.model || getDefaultCodexModel(); 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); + const m = raw.match(/^(.*)\((low|medium|high|xhigh)\)\s*$/i); if (m) { const base = String(m[1] || '').trim(); const lvl = String(m[2] || '').trim().toLowerCase(); diff --git a/public/app.js b/public/app.js index 4695690..dcc8604 100644 --- a/public/app.js +++ b/public/app.js @@ -99,6 +99,8 @@ let sidebarSwipe = null; let pendingAttachments = []; let uploadingAttachments = []; + let attachmentPreviewModal = null; + const attachmentPreviewCache = new Map(); let loginPasswordValue = ''; // store login password for force-change flow let currentCwd = null; let currentSessionRunning = false; @@ -1119,6 +1121,7 @@ }, }); } catch {} + clearAttachmentPreviewCache(id); } function ensureAuthenticatedWs() { @@ -1160,14 +1163,184 @@ }); } - function renderAttachmentLabels(attachments, options = {}) { + function clearAttachmentPreviewCache(id) { + const entry = attachmentPreviewCache.get(id); + if (entry?.url && entry.objectUrl) URL.revokeObjectURL(entry.url); + attachmentPreviewCache.delete(id); + } + + async function getAttachmentPreviewUrl(attachment) { + const id = String(attachment?.id || '').trim(); + if (!id) throw new Error('图片附件缺少 ID'); + if (attachment.storageState === 'expired') { + throw new Error('图片已过期'); + } + if (attachment.previewUrl) return attachment.previewUrl; + + const cached = attachmentPreviewCache.get(id); + if (cached?.url) return cached.url; + if (cached?.promise) return cached.promise; + + const promise = (async () => { + await ensureAuthenticatedWs(); + if (!authToken) { + throw new Error('登录状态已失效,请刷新页面后重新登录再预览图片。'); + } + const url = `/api/attachments/${encodeURIComponent(id)}?token=${encodeURIComponent(authToken)}`; + attachmentPreviewCache.set(id, { url, objectUrl: false }); + return url; + })().catch((err) => { + attachmentPreviewCache.delete(id); + throw err; + }); + + attachmentPreviewCache.set(id, { promise }); + return promise; + } + + function closeAttachmentPreviewModal() { + if (!attachmentPreviewModal) return; + const { overlay, escapeHandler } = attachmentPreviewModal; + if (escapeHandler) document.removeEventListener('keydown', escapeHandler); + if (overlay && overlay.parentNode) overlay.parentNode.removeChild(overlay); + attachmentPreviewModal = null; + } + + async function openAttachmentPreviewModal(attachment) { + if (!attachment || attachment.storageState === 'expired') { + showToast('图片已过期,无法预览'); + return; + } + + closeAttachmentPreviewModal(); + + const overlay = document.createElement('div'); + overlay.className = 'modal-overlay attachment-preview-overlay'; + overlay.innerHTML = ` + + `; + + document.body.appendChild(overlay); + const stageEl = overlay.querySelector('.attachment-preview-stage'); + const imgEl = overlay.querySelector('.attachment-preview-image'); + const placeholderEl = overlay.querySelector('.attachment-preview-placeholder'); + const closeBtn = overlay.querySelector('.modal-close-btn'); + + const finishClose = () => closeAttachmentPreviewModal(); + attachmentPreviewModal = { + overlay, + escapeHandler: null, + }; + + attachmentPreviewModal.escapeHandler = (e) => { + if (e.key === 'Escape') finishClose(); + }; + document.addEventListener('keydown', attachmentPreviewModal.escapeHandler); + + closeBtn.addEventListener('click', finishClose); + overlay.addEventListener('click', (e) => { + if (e.target === overlay) finishClose(); + }); + + try { + const url = await getAttachmentPreviewUrl(attachment); + if (!attachmentPreviewModal || attachmentPreviewModal.overlay !== overlay) return; + imgEl.onload = () => { + if (!attachmentPreviewModal || attachmentPreviewModal.overlay !== overlay) return; + imgEl.hidden = false; + placeholderEl.hidden = true; + stageEl.classList.remove('is-loading'); + stageEl.classList.add('is-ready'); + }; + imgEl.onerror = () => { + if (!attachmentPreviewModal || attachmentPreviewModal.overlay !== overlay) return; + placeholderEl.textContent = '图片加载失败'; + stageEl.classList.remove('is-loading'); + stageEl.classList.add('is-error'); + }; + imgEl.src = url; + } catch (err) { + if (!attachmentPreviewModal || attachmentPreviewModal.overlay !== overlay) return; + placeholderEl.textContent = err.message || '图片预览失败'; + stageEl.classList.remove('is-loading'); + stageEl.classList.add('is-error'); + } + } + + function hydrateAttachmentPreviews(root, attachments = []) { + if (!root) return; + const attachmentMap = new Map((Array.isArray(attachments) ? attachments : []).map((attachment) => [attachment.id, attachment])); + root.querySelectorAll('[data-attachment-id]').forEach((node) => { + const attachment = attachmentMap.get(node.dataset.attachmentId); + if (!attachment) return; + const imgEl = node.querySelector('.msg-attachment-thumb-image'); + const placeholderEl = node.querySelector('.msg-attachment-thumb-placeholder'); + const isExpired = attachment.storageState === 'expired'; + + if (!isExpired) { + getAttachmentPreviewUrl(attachment) + .then((url) => { + if (!node.isConnected) return; + imgEl.src = url; + imgEl.onload = () => { + if (!node.isConnected) return; + imgEl.hidden = false; + placeholderEl.hidden = true; + node.classList.add('is-loaded'); + }; + imgEl.onerror = () => { + if (!node.isConnected) return; + placeholderEl.textContent = '图片加载失败'; + node.classList.add('is-error'); + }; + }) + .catch((err) => { + if (!node.isConnected) return; + placeholderEl.textContent = err.message || '图片加载失败'; + node.classList.add('is-error'); + }); + } + + node.addEventListener('click', () => openAttachmentPreviewModal(attachment)); + }); + } + + function renderAttachmentPreviews(attachments, options = {}) { if (!Array.isArray(attachments) || attachments.length === 0) return ''; - const labels = attachments.map((attachment) => { - const stateSuffix = attachment.storageState === 'expired' ? '(已过期)' : ''; + const items = attachments.map((attachment) => { + const state = attachment.storageState || 'available'; const name = escapeHtml(attachment.filename || 'image'); - return `图片: ${name}${stateSuffix}`; + const size = formatFileSize(attachment.size || 0); + const isExpired = state === 'expired'; + return ` + + `; }).join(''); - return `
${labels}
`; + return `
${items}
`; } function renderPendingAttachments() { @@ -1262,7 +1435,9 @@ try { const results = await Promise.allSettled(files.map(async (file) => { const optimized = await compressImageFile(file); - return uploadImageFile(optimized); + const uploaded = await uploadImageFile(optimized); + uploaded.previewUrl = URL.createObjectURL(file); + return uploaded; })); const errors = []; for (const result of results) { @@ -2299,15 +2474,16 @@ bubble.appendChild(textNode); } if (attachments.length > 0) { - bubble.insertAdjacentHTML('beforeend', renderAttachmentLabels(attachments)); + bubble.insertAdjacentHTML('beforeend', renderAttachmentPreviews(attachments)); } } else { renderAssistantContent(bubble, content); if (attachments.length > 0) { - bubble.insertAdjacentHTML('beforeend', renderAttachmentLabels(attachments)); + bubble.insertAdjacentHTML('beforeend', renderAttachmentPreviews(attachments)); } } + hydrateAttachmentPreviews(bubble, attachments); div.appendChild(avatar); div.appendChild(bubble); return div; @@ -3258,7 +3434,10 @@ header.title = group.cwd || group.name; header.innerHTML = ` ${escapeHtml(group.name)} - ${group.sessions.length} + + ${group.sessions.length} + + `; groupEl.appendChild(header); @@ -3266,6 +3445,11 @@ groupEl.appendChild(createSessionListItem(s)); } + header.querySelector('.session-project-create').addEventListener('click', (e) => { + e.stopPropagation(); + quickCreateProjectSession(group.cwd || '', { agent: currentAgent, mode: currentMode }); + }); + sessionList.appendChild(groupEl); } @@ -3604,7 +3788,8 @@ showOptionPicker('选择 Codex 模型', baseOptions, current.base || '', (baseValue) => { const base = String(baseValue || '').trim(); const thinkingOptions = [ - { value: '', label: '无 (默认)', desc: '不附加 (medium/high/xhigh) 后缀' }, + { value: '', label: '无 (默认)', desc: '不附加 (low/medium/high/xhigh) 后缀' }, + { value: 'low', label: 'low', desc: '较轻 thinking' }, { value: 'medium', label: 'medium', desc: '中等 thinking' }, { value: 'high', label: 'high', desc: '更强 thinking' }, { value: 'xhigh', label: 'xhigh', desc: '最强 thinking' }, @@ -4994,6 +5179,21 @@ try { localStorage.setItem(RECENT_CWD_KEY, JSON.stringify(list)); } catch {} } + function requestNewSession(options = {}) { + const cwd = options.cwd || null; + const rawCwd = options.rawCwd !== undefined ? options.rawCwd : (cwd || ''); + const agent = normalizeAgent(options.agent || currentAgent); + const mode = ['default', 'plan', 'yolo'].includes(options.mode) ? options.mode : currentMode; + pendingNewSessionRequest = { + cwd, + rawCwd, + agent, + mode, + }; + if (cwd) saveRecentCwd(cwd); + send({ type: 'new_session', cwd, agent, mode }); + } + // --- New Session Modal --- let _onCwdSuggestions = null; @@ -5119,15 +5319,13 @@ function createSession() { const cwd = getEffectiveCwd(); const rawCwd = cwdInput.value.trim(); - pendingNewSessionRequest = { + close(); + requestNewSession({ cwd, rawCwd, agent: targetAgent, mode: requestedMode, - }; - close(); - if (cwd) saveRecentCwd(cwd); - send({ type: 'new_session', cwd, agent: targetAgent, mode: requestedMode }); + }); } pickDirBtn.addEventListener('click', () => { @@ -5167,6 +5365,16 @@ cwdInput.focus(); } + function quickCreateProjectSession(cwd, options = {}) { + const targetCwd = String(cwd || '').trim(); + requestNewSession({ + cwd: targetCwd || null, + rawCwd: targetCwd, + agent: options.agent || currentAgent, + mode: options.mode || currentMode, + }); + } + // --- Import Native Session Modal --- let _onNativeSessions = null; diff --git a/public/style.css b/public/style.css index 54caba2..6ee4f3b 100644 --- a/public/style.css +++ b/public/style.css @@ -669,6 +669,13 @@ body.session-loading-active { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + flex: 1; +} +.session-project-header-actions { + display: inline-flex; + align-items: center; + gap: 6px; + flex-shrink: 0; } .session-project-count { display: inline-flex; @@ -683,6 +690,30 @@ body.session-loading-active { font-size: 10px; letter-spacing: 0; } +.session-project-create { + width: 22px; + height: 22px; + padding: 0; + border: 1px solid rgba(221, 208, 192, 0.95); + border-radius: 999px; + background: rgba(255, 249, 242, 0.96); + color: var(--text-secondary); + font-size: 14px; + font-weight: 700; + line-height: 1; + cursor: pointer; + transition: background 0.16s, color 0.16s, transform 0.16s, border-color 0.16s; +} +.session-project-create:hover { + background: var(--accent-light); + border-color: rgba(192, 85, 58, 0.24); + color: var(--accent); + transform: translateY(-1px); +} +.session-project-create:focus-visible { + outline: 2px solid rgba(192, 85, 58, 0.22); + outline-offset: 2px; +} .session-item { display: flex; align-items: center; @@ -1098,32 +1129,130 @@ body.session-loading-active { max-width: 100%; } .msg-attachments { - display: flex; - flex-wrap: wrap; - gap: 8px; + display: grid; + grid-template-columns: repeat(auto-fit, minmax(150px, 220px)); + gap: 10px; margin-top: 10px; } -.msg-attachment-label { - display: inline-flex; +.msg-attachment-card { + display: flex; + flex-direction: column; + align-items: stretch; + gap: 8px; + width: 100%; + min-width: 0; + padding: 8px; + border: 1px solid rgba(221, 208, 192, 0.92); + border-radius: 14px; + background: rgba(255, 255, 255, 0.82); + color: var(--text-primary); + text-align: left; + cursor: pointer; + box-shadow: 0 8px 20px rgba(45, 31, 20, 0.05); + transition: transform 0.16s, box-shadow 0.16s, border-color 0.16s, background 0.16s; +} +.msg-attachment-card:hover { + transform: translateY(-1px); + border-color: rgba(192, 85, 58, 0.22); + box-shadow: 0 12px 24px rgba(45, 31, 20, 0.08); +} +.msg-attachment-card:focus-visible { + outline: 2px solid rgba(192, 85, 58, 0.24); + outline-offset: 2px; +} +.msg-attachment-card:disabled { + cursor: default; + opacity: 0.88; + transform: none; +} +.msg-attachment-card.is-expired { + background: rgba(249, 242, 233, 0.9); +} +.msg-attachment-thumb { + position: relative; + display: block; + width: 100%; + min-height: 132px; + overflow: hidden; + border-radius: 11px; + background: + linear-gradient(180deg, rgba(245, 238, 229, 0.92), rgba(232, 222, 211, 0.9)); +} +.msg-attachment-thumb-placeholder, +.attachment-preview-placeholder { + position: absolute; + inset: 0; + display: flex; align-items: center; - max-width: 100%; - padding: 5px 9px; - border-radius: 999px; - background: rgba(91, 126, 161, 0.12); + justify-content: center; + padding: 12px; + color: var(--text-muted); + font-size: 12px; + font-weight: 700; + letter-spacing: 0.04em; +} +.msg-attachment-thumb-image { + display: block; + width: 100%; + height: 100%; + object-fit: cover; + opacity: 0; + transition: opacity 0.18s ease; +} +.msg-attachment-card.is-loaded .msg-attachment-thumb-image { + opacity: 1; +} +.msg-attachment-meta { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} +.msg-attachment-name, +.msg-attachment-note { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.msg-attachment-name { + font-size: 12px; + font-weight: 700; +} +.msg-attachment-note { color: var(--text-secondary); font-size: 11px; - font-weight: 600; - line-height: 1.35; +} +.msg.user .msg-attachment-card { + background: rgba(255, 255, 255, 0.14); + border-color: rgba(255, 255, 255, 0.2); + color: #fff; + box-shadow: none; +} +.msg.user .msg-attachment-card:hover { + border-color: rgba(255, 255, 255, 0.34); +} +.msg.user .msg-attachment-thumb { + background: rgba(255, 255, 255, 0.08); +} +.msg.user .msg-attachment-thumb-placeholder, +.msg.user .msg-attachment-note { + color: rgba(255, 255, 255, 0.78); +} +.msg.user .msg-attachment-name { + color: #fff; +} +.msg.user .msg-attachment-card.is-expired { + background: rgba(255, 255, 255, 0.1); +} +.msg-attachment-card.is-error .msg-attachment-thumb-placeholder { + color: var(--danger); } .msg.user .msg-bubble { background: var(--bg-bubble-user); color: #fff; border-bottom-right-radius: 4px; } -.msg.user .msg-attachment-label { - background: rgba(255, 255, 255, 0.16); - color: rgba(255, 255, 255, 0.92); -} .msg.assistant .msg-bubble { background: var(--bg-bubble-assistant); border: 1px solid var(--border-color); @@ -2517,6 +2646,10 @@ html[data-theme='coolvibe'] .settings-back:hover { .modal-panel-wide { max-width: 600px; } +.attachment-preview-panel { + width: min(92vw, 1040px); + max-width: min(92vw, 1040px); +} .modal-header { display: flex; align-items: center; @@ -2546,6 +2679,50 @@ html[data-theme='coolvibe'] .settings-back:hover { overflow-y: auto; flex: 1; } +.attachment-preview-body { + display: flex; + flex-direction: column; + gap: 14px; + padding: 18px 20px 20px; +} +.attachment-preview-stage { + position: relative; + min-height: 320px; + max-height: 72vh; + border-radius: 16px; + overflow: hidden; + background: + linear-gradient(180deg, rgba(245, 238, 229, 0.96), rgba(232, 222, 211, 0.92)); + border: 1px solid rgba(221, 208, 192, 0.92); +} +.attachment-preview-stage.is-ready { + background: #111; +} +.attachment-preview-stage.is-error { + border-color: rgba(192, 85, 58, 0.24); +} +.attachment-preview-image { + display: block; + width: 100%; + height: 100%; + max-height: 72vh; + object-fit: contain; + background: #111; +} +.attachment-preview-meta { + display: flex; + flex-direction: column; + gap: 4px; +} +.attachment-preview-name { + color: var(--text-primary); + font-size: 15px; + font-weight: 700; +} +.attachment-preview-desc { + color: var(--text-secondary); + font-size: 12px; +} .modal-footer { display: flex; justify-content: flex-end; diff --git a/scripts/regression.js b/scripts/regression.js index 8955082..78be2ab 100644 --- a/scripts/regression.js +++ b/scripts/regression.js @@ -304,6 +304,20 @@ function createFakeCodexHistory(homeDir) { return { threadId, rolloutPath, stateDb, logsDb }; } +function createFakeCodexConfig(homeDir, { model = 'gpt-5.5', reasoningEffort = 'xhigh' } = {}) { + const codexDir = path.join(homeDir, '.codex'); + mkdirp(codexDir); + fs.writeFileSync(path.join(codexDir, 'config.toml'), [ + 'model_provider = "test"', + `model = "${model}"`, + `model_reasoning_effort = "${reasoningEffort}"`, + '', + '[projects."/tmp/project-b"]', + 'trust_level = "trusted"', + '', + ].join('\n')); +} + async function main() { const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'cc-web-regression-')); const configDir = path.join(tempRoot, 'config'); @@ -325,6 +339,7 @@ async function main() { }, null, 2)); createFakeClaudeHistory(homeDir); + createFakeCodexConfig(homeDir); const codexFixture = createFakeCodexHistory(homeDir); const port = await getFreePort(); @@ -392,7 +407,7 @@ async function main() { ws.send(JSON.stringify({ type: 'new_session', agent: 'codex', cwd: codexInitCwd, mode: 'plan' })); const codexSession = await nextMessage(messages, ws, (msg) => msg.type === 'session_info' && msg.agent === 'codex' && msg.cwd === codexInitCwd); assert(codexSession.mode === 'plan', 'Codex new_session should follow requested mode'); - assert(codexSession.model === 'gpt-5.4', 'Codex new_session should inject default model gpt-5.4'); + assert(codexSession.model === 'gpt-5.5(xhigh)', 'Codex new_session should read default model from ~/.codex/config.toml'); ws.send(JSON.stringify({ type: 'list_cwd_suggestions' })); const cwdSuggestions = await nextMessage(messages, ws, (msg) => msg.type === 'cwd_suggestions'); diff --git a/server.js b/server.js index aa5fa8d..72284bd 100644 --- a/server.js +++ b/server.js @@ -528,9 +528,16 @@ 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'; +// Codex 默认模型优先读取 ~/.codex/config.toml,缺失时再回退到旧默认值。 +const FALLBACK_CODEX_MODEL = 'gpt-5.4'; +const CODEX_REASONING_LEVELS = new Set(['low', 'medium', 'high', 'xhigh']); + +function getLocalCodexConfigTomlPath() { + const codexHome = String(process.env.CODEX_HOME || '').trim(); + if (codexHome) return path.join(codexHome, 'config.toml'); + const homeDir = process.env.HOME || process.env.USERPROFILE || ''; + return path.join(homeDir, '.codex', 'config.toml'); +} // === Model Config === const DEFAULT_MODEL_CONFIG = { @@ -547,6 +554,91 @@ const DEFAULT_CODEX_CONFIG = { supportsSearch: false, }; +function stripTomlInlineComment(value) { + const raw = String(value || ''); + let inDoubleQuote = false; + let inSingleQuote = false; + let escaped = false; + for (let i = 0; i < raw.length; i += 1) { + const ch = raw[i]; + if (inDoubleQuote) { + if (escaped) { + escaped = false; + continue; + } + if (ch === '\\') { + escaped = true; + continue; + } + if (ch === '"') inDoubleQuote = false; + continue; + } + if (inSingleQuote) { + if (ch === '\'') inSingleQuote = false; + continue; + } + if (ch === '"') { + inDoubleQuote = true; + continue; + } + if (ch === '\'') { + inSingleQuote = true; + continue; + } + if (ch === '#') return raw.slice(0, i).trim(); + } + return raw.trim(); +} + +function parseTomlStringValue(value) { + const raw = stripTomlInlineComment(value); + if (!raw) return ''; + if ((raw.startsWith('"') && raw.endsWith('"')) || (raw.startsWith('\'') && raw.endsWith('\''))) { + if (raw.startsWith('"')) { + try { + return JSON.parse(raw); + } catch {} + } + return raw.slice(1, -1); + } + return raw; +} + +function loadLocalCodexTomlConfig() { + try { + const configPath = getLocalCodexConfigTomlPath(); + if (!configPath || !fs.existsSync(configPath)) { + return { model: '', reasoningEffort: '' }; + } + const text = fs.readFileSync(configPath, 'utf8'); + const parsed = { model: '', reasoningEffort: '' }; + for (const line of text.split(/\r?\n/)) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) continue; + if (trimmed.startsWith('[')) break; + const eqIndex = trimmed.indexOf('='); + if (eqIndex <= 0) continue; + const key = trimmed.slice(0, eqIndex).trim(); + const value = trimmed.slice(eqIndex + 1).trim(); + if (key === 'model') parsed.model = parseTomlStringValue(value); + if (key === 'model_reasoning_effort') parsed.reasoningEffort = parseTomlStringValue(value).toLowerCase(); + } + return parsed; + } catch { + return { model: '', reasoningEffort: '' }; + } +} + +function getDefaultCodexModel() { + const localConfig = loadLocalCodexTomlConfig(); + const model = String(localConfig.model || '').trim() || FALLBACK_CODEX_MODEL; + const reasoningEffort = String(localConfig.reasoningEffort || '').trim().toLowerCase(); + if (CODEX_REASONING_LEVELS.has(reasoningEffort)) { + return `${model}(${reasoningEffort})`; + } + return model; +} + function loadModelConfig() { try { if (fs.existsSync(MODEL_CONFIG_PATH)) { @@ -1404,7 +1496,10 @@ function modelShortName(fullModel) { } function sessionModelLabel(session) { - if (!session?.model) return null; + if (!session) return null; + if (!session.model) { + return isClaudeSession(session) ? null : getDefaultCodexModel(); + } return isClaudeSession(session) ? (modelShortName(session.model) || session.model) : session.model; } @@ -1985,8 +2080,8 @@ const server = http.createServer((req, res) => { return; } - if (req.method === 'DELETE' && url.pathname.startsWith('/api/attachments/')) { - const token = extractBearerToken(req); + if (url.pathname.startsWith('/api/attachments/')) { + const token = extractBearerToken(req) || String(url.searchParams.get('token') || ''); if (!token || !activeTokens.has(token)) { return jsonResponse(res, 401, { ok: false, message: 'Not authenticated' }); } @@ -1994,8 +2089,30 @@ const server = http.createServer((req, res) => { if (!id) { return jsonResponse(res, 400, { ok: false, message: '缺少附件 ID' }); } - removeAttachmentById(id); - return jsonResponse(res, 200, { ok: true }); + if (req.method === 'GET') { + const meta = loadAttachmentMeta(id); + const state = currentAttachmentState(meta); + if (state !== 'available' || !meta?.path || !fs.existsSync(meta.path)) { + return jsonResponse(res, 404, { ok: false, message: '附件不存在或已过期' }); + } + try { + const stat = fs.statSync(meta.path); + res.writeHead(200, { + 'Content-Type': meta.mime || 'application/octet-stream', + 'Content-Length': stat.size, + 'Content-Disposition': `inline; filename="${(meta.filename || 'image').replace(/"/g, '\\"')}"`, + 'Cache-Control': 'private, no-store, max-age=0', + }); + fs.createReadStream(meta.path).pipe(res); + } catch (err) { + return jsonResponse(res, 500, { ok: false, message: `读取附件失败: ${err.message}` }); + } + return; + } + if (req.method === 'DELETE') { + removeAttachmentById(id); + return jsonResponse(res, 200, { ok: true }); + } } if (req.method === 'GET' && url.pathname === '/api/fs/list') { @@ -2480,7 +2597,7 @@ function handleSlashCommand(ws, text, sessionId, fallbackAgent) { const modelInput = parts[1]; if (agent === 'codex') { if (!modelInput) { - const current = session?.model || '配置默认模型'; + const current = session?.model || getDefaultCodexModel(); wsSend(ws, { type: 'system_message', message: `当前 Codex 模型: ${current}\n用法: /model <模型名>` }); } else { if (session) { @@ -2638,9 +2755,9 @@ function handleNewSession(ws, msg) { agent, claudeSessionId: null, codexThreadId: null, - // For Codex: explicitly set a default model on creation so we don't inherit Codex CLI defaults. + // For Codex: 在会话创建时写入 ~/.codex/config.toml 中的默认模型,避免 UI 与运行时脱节。 // For Claude: default to opus (1M) so --model is always passed to CLI. - model: agent === 'codex' ? DEFAULT_CODEX_MODEL : MODEL_MAP.opus, + model: agent === 'codex' ? getDefaultCodexModel() : MODEL_MAP.opus, permissionMode: requestedMode, totalCost: 0, totalUsage: { inputTokens: 0, cachedInputTokens: 0, outputTokens: 0 }, @@ -2954,7 +3071,7 @@ function handleMessage(ws, msg, options = {}) { agent, claudeSessionId: null, codexThreadId: null, - model: agent === 'codex' ? DEFAULT_CODEX_MODEL : null, + model: agent === 'codex' ? getDefaultCodexModel() : null, permissionMode: mode || 'yolo', totalCost: 0, totalUsage: { inputTokens: 0, cachedInputTokens: 0, outputTokens: 0 }, @@ -3179,6 +3296,7 @@ const { MODEL_MAP, loadModelConfig, applyCustomTemplateToSettings, + getDefaultCodexModel, loadCodexConfig, prepareCodexCustomRuntime, wsSend,