feat: enhance session UX and codex defaults
This commit is contained in:
142
server.js
142
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,
|
||||
|
||||
Reference in New Issue
Block a user