feat: model config system with settings.json integration
- Add local/custom mode switching for API configuration - Custom mode patches ~/.claude/settings.json env section before spawn - Strip ANTHROPIC_* from child process env for clean config isolation - Add spawn-time model name validation against MODEL_MAP - Move template config fields to modal dialog for cleaner UI - Show warning when custom templates may overwrite local API config
This commit is contained in:
16
CLAUDE.md
16
CLAUDE.md
@@ -5,10 +5,10 @@ Claude Code Web Chat UI - 轻量级 Web 聊天界面,通过 WebSocket 与 Clau
|
||||
## 目录与发布约定
|
||||
|
||||
- 开发与运行目录固定为:`/home/cc-dan/cc/cc-web`
|
||||
- 当前提交 GitHub 的脱敏目录为:`/home/cc-dan/cc/cc-web_v1.2.2`
|
||||
- 当前提交 GitHub 的脱敏目录为:`/home/cc-dan/cc/cc-web_v1.2.3`
|
||||
- 脱敏目录命名规则:`/home/cc-dan/cc/cc-web_v<主版本>.<次版本>.<修订版本>`
|
||||
- 版本发布时,必须保持以下信息一致:
|
||||
1. 脱敏目录实际名称(如 `cc-web_v1.2.2`)
|
||||
1. 脱敏目录实际名称(如 `cc-web_v1.2.3`)
|
||||
2. 本文件中的“当前提交 GitHub 的脱敏目录”路径
|
||||
3. `/home/cc-dan/cc/项目清单.md` 中 CC-Web 的发布副本路径
|
||||
4. 脱敏目录 `README.md` 的“更新记录”版本号与说明
|
||||
@@ -78,14 +78,18 @@ Claude Code Web Chat UI - 轻量级 Web 聊天界面,通过 WebSocket 与 Clau
|
||||
|
||||
| 模式 | 说明 |
|
||||
|------|------|
|
||||
| `local` | 读取 `~/.claude.json` 中的 `env` 字段覆盖 MODEL_MAP(ANTHROPIC_DEFAULT_OPUS/SONNET/HAIKU_MODEL) |
|
||||
| `custom` | 使用命名模板,每个模板含 apiKey、apiBase、defaultModel、opusModel、sonnetModel、haikuModel |
|
||||
| `local` | 依赖 `~/.claude/settings.json` 已有配置(由 cc-switch-web 等工具管理),仅从中读取模型名称更新 MODEL_MAP |
|
||||
| `custom` | 使用命名模板,spawn 前将模板凭据写入 `~/.claude/settings.json` 的 `env` 字段 |
|
||||
|
||||
关键实现:
|
||||
- `MODEL_MAP` 改为 `let`,启动时调用 `applyModelConfig()` 应用配置
|
||||
- `handleSaveModelConfig()` 保存后立即重新应用 MODEL_MAP
|
||||
- **配置优先级**:Claude CLI 读取 `~/.claude/settings.json` > 环境变量 > `~/.claude.json`
|
||||
- `applyModelConfig()` 仅更新 MODEL_MAP 模型名称映射(不写环境变量)
|
||||
- **spawn 环境隔离**:子进程 env 中删除所有 `ANTHROPIC_*` 变量,让 CLI 直接读取 `~/.claude/settings.json`
|
||||
- **custom 模式 spawn**:将模板的 apiKey/apiBase/model 写入 `~/.claude/settings.json` 的 `env` 字段,保留非 API 相关字段(如 `API_TIMEOUT_MS`)
|
||||
- **模型验证**:spawn 时检查 `session.model` 是否在当前 `MODEL_MAP` 值中,无效则不传 `--model` 参数
|
||||
- API Key 脱敏:前4后4,中间 `****`
|
||||
- 保存时若 API Key 含 `****` 则保留旧值(防止脱敏值覆盖真实密钥)
|
||||
- **前端**:模板配置字段通过弹窗编辑(点击"编辑"按钮),local 模式显示覆盖警告
|
||||
- WS 消息:`get_model_config` → `model_config`;`save_model_config` → `model_config` + `system_message`
|
||||
|
||||
## /compact 修复记录
|
||||
|
||||
@@ -244,6 +244,11 @@ node server.js
|
||||
|
||||
## 更新记录
|
||||
|
||||
- **v1.2.3**
|
||||
- 新增模型配置系统:支持 local(读取本地配置)和 custom(自定义 API 模板)两种模式切换。
|
||||
- custom 模式通过写入 `~/.claude/settings.json` 实现 API 凭据注入,兼容 cc-switch-web 等配置管理工具。
|
||||
- 模板配置改为弹窗编辑,界面更简洁;切换至 custom 模式时显示覆盖警告。
|
||||
- spawn 时增加模型名称校验,防止无效模型参数导致进程静默失败。
|
||||
- **v1.2.2**
|
||||
- 对齐 Claude Code 原生上下文压缩策略:`/compact` 改为真实下发到 CLI 执行,不再使用本地会话伪重置。
|
||||
- 补齐超限自动恢复链路:当出现 `Request too large (max 20MB)` 时,自动执行 `/compact` 并在压缩后自动重放上一条失败请求继续运行。
|
||||
|
||||
130
public/app.js
130
public/app.js
@@ -1150,7 +1150,7 @@
|
||||
|
||||
function renderModelCustomArea() {
|
||||
if (modelModeSelect.value === 'local') {
|
||||
modelCustomArea.innerHTML = `<div class="settings-field" style="color:var(--text-secondary);font-size:0.85em">读取 ~/.claude.json 中的 ANTHROPIC_DEFAULT_OPUS/SONNET/HAIKU_MODEL 字段覆盖模型名称。</div>`;
|
||||
modelCustomArea.innerHTML = `<div class="settings-field" style="color:var(--text-warning, #e8a838);font-size:0.85em">⚠ 使用自定义模板会覆盖本地 API 配置,请提前做好备份。</div>`;
|
||||
modelActionsDiv.style.display = 'flex';
|
||||
} else {
|
||||
renderModelTemplateEditor();
|
||||
@@ -1191,39 +1191,10 @@
|
||||
${tplOptions}
|
||||
<option value="__new__">+ 新建模板</option>
|
||||
</select>
|
||||
<button class="btn-test" id="model-tpl-edit" style="padding:4px 10px">编辑</button>
|
||||
<button class="btn-test" id="model-tpl-del" title="删除" style="padding:4px 8px">删除</button>
|
||||
</div>
|
||||
</div>
|
||||
${tpl ? `
|
||||
<div class="settings-field">
|
||||
<label>模板名称</label>
|
||||
<input type="text" id="model-tpl-name" placeholder="模板名称" value="${escapeHtml(tpl.name)}">
|
||||
</div>
|
||||
<div class="settings-field">
|
||||
<label>API Key</label>
|
||||
<input type="text" id="model-tpl-apikey" placeholder="sk-ant-..." value="${escapeHtml(tpl.apiKey || '')}">
|
||||
</div>
|
||||
<div class="settings-field">
|
||||
<label>API Base URL</label>
|
||||
<input type="text" id="model-tpl-apibase" placeholder="https://api.anthropic.com" value="${escapeHtml(tpl.apiBase || '')}">
|
||||
</div>
|
||||
<div class="settings-field">
|
||||
<label>默认模型 (ANTHROPIC_MODEL)</label>
|
||||
<input type="text" id="model-tpl-default" placeholder="claude-opus-4-6" value="${escapeHtml(tpl.defaultModel || '')}">
|
||||
</div>
|
||||
<div class="settings-field">
|
||||
<label>Opus 模型名</label>
|
||||
<input type="text" id="model-tpl-opus" placeholder="claude-opus-4-6" value="${escapeHtml(tpl.opusModel || '')}">
|
||||
</div>
|
||||
<div class="settings-field">
|
||||
<label>Sonnet 模型名</label>
|
||||
<input type="text" id="model-tpl-sonnet" placeholder="claude-sonnet-4-6" value="${escapeHtml(tpl.sonnetModel || '')}">
|
||||
</div>
|
||||
<div class="settings-field">
|
||||
<label>Haiku 模型名</label>
|
||||
<input type="text" id="model-tpl-haiku" placeholder="claude-haiku-4-5-20251001" value="${escapeHtml(tpl.haikuModel || '')}">
|
||||
</div>
|
||||
` : ''}
|
||||
`;
|
||||
|
||||
panel.querySelector('#model-tpl-select').addEventListener('change', (e) => {
|
||||
@@ -1234,11 +1205,16 @@
|
||||
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 {
|
||||
saveTplFields();
|
||||
modelActiveTemplate = e.target.value;
|
||||
renderModelTemplateEditor();
|
||||
}
|
||||
renderModelTemplateEditor();
|
||||
});
|
||||
|
||||
panel.querySelector('#model-tpl-edit').addEventListener('click', () => {
|
||||
openTplEditModal();
|
||||
});
|
||||
|
||||
const delBtn = panel.querySelector('#model-tpl-del');
|
||||
@@ -1253,24 +1229,80 @@
|
||||
}
|
||||
}
|
||||
|
||||
function saveTplFields() {
|
||||
function openTplEditModal() {
|
||||
const tpl = modelEditingTemplates.find(t => t.name === modelActiveTemplate);
|
||||
if (!tpl) return;
|
||||
const nameEl = panel.querySelector('#model-tpl-name');
|
||||
const apikeyEl = panel.querySelector('#model-tpl-apikey');
|
||||
const apibaseEl = panel.querySelector('#model-tpl-apibase');
|
||||
const defaultEl = panel.querySelector('#model-tpl-default');
|
||||
const opusEl = panel.querySelector('#model-tpl-opus');
|
||||
const sonnetEl = panel.querySelector('#model-tpl-sonnet');
|
||||
const haikuEl = panel.querySelector('#model-tpl-haiku');
|
||||
if (nameEl && nameEl.value.trim()) tpl.name = nameEl.value.trim();
|
||||
if (apikeyEl) tpl.apiKey = apikeyEl.value.trim();
|
||||
if (apibaseEl) tpl.apiBase = apibaseEl.value.trim();
|
||||
if (defaultEl) tpl.defaultModel = defaultEl.value.trim();
|
||||
if (opusEl) tpl.opusModel = opusEl.value.trim();
|
||||
if (sonnetEl) tpl.sonnetModel = sonnetEl.value.trim();
|
||||
if (haikuEl) tpl.haikuModel = haikuEl.value.trim();
|
||||
modelActiveTemplate = tpl.name;
|
||||
|
||||
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 = `
|
||||
<div class="settings-header">
|
||||
<h3>编辑模板: ${escapeHtml(tpl.name)}</h3>
|
||||
<button class="settings-close" id="tpl-modal-close">×</button>
|
||||
</div>
|
||||
<div class="settings-field">
|
||||
<label>模板名称</label>
|
||||
<input type="text" id="tpl-ed-name" value="${escapeHtml(tpl.name)}">
|
||||
</div>
|
||||
<div class="settings-field">
|
||||
<label>API Key</label>
|
||||
<input type="text" id="tpl-ed-apikey" placeholder="sk-ant-..." value="${escapeHtml(tpl.apiKey || '')}">
|
||||
</div>
|
||||
<div class="settings-field">
|
||||
<label>API Base URL</label>
|
||||
<input type="text" id="tpl-ed-apibase" placeholder="https://api.anthropic.com" value="${escapeHtml(tpl.apiBase || '')}">
|
||||
</div>
|
||||
<div class="settings-field">
|
||||
<label>默认模型 (ANTHROPIC_MODEL)</label>
|
||||
<input type="text" id="tpl-ed-default" placeholder="claude-opus-4-6" value="${escapeHtml(tpl.defaultModel || '')}">
|
||||
</div>
|
||||
<div class="settings-field">
|
||||
<label>Opus 模型名</label>
|
||||
<input type="text" id="tpl-ed-opus" placeholder="claude-opus-4-6" value="${escapeHtml(tpl.opusModel || '')}">
|
||||
</div>
|
||||
<div class="settings-field">
|
||||
<label>Sonnet 模型名</label>
|
||||
<input type="text" id="tpl-ed-sonnet" placeholder="claude-sonnet-4-6" value="${escapeHtml(tpl.sonnetModel || '')}">
|
||||
</div>
|
||||
<div class="settings-field">
|
||||
<label>Haiku 模型名</label>
|
||||
<input type="text" id="tpl-ed-haiku" placeholder="claude-haiku-4-5-20251001" value="${escapeHtml(tpl.haikuModel || '')}">
|
||||
</div>
|
||||
<div class="settings-actions">
|
||||
<button class="btn-save" id="tpl-ed-ok">确定</button>
|
||||
</div>
|
||||
`;
|
||||
modalOverlay.appendChild(modal);
|
||||
document.body.appendChild(modalOverlay);
|
||||
|
||||
const closeModal = () => { document.body.removeChild(modalOverlay); };
|
||||
modal.querySelector('#tpl-modal-close').addEventListener('click', closeModal);
|
||||
modalOverlay.addEventListener('click', (e) => { if (e.target === modalOverlay) closeModal(); });
|
||||
|
||||
modal.querySelector('#tpl-ed-ok').addEventListener('click', () => {
|
||||
const newName = modal.querySelector('#tpl-ed-name').value.trim();
|
||||
if (newName && newName !== tpl.name) {
|
||||
if (modelEditingTemplates.find(t => t.name === newName && t !== tpl)) { alert('模板名称已存在'); return; }
|
||||
tpl.name = newName;
|
||||
modelActiveTemplate = newName;
|
||||
}
|
||||
tpl.apiKey = modal.querySelector('#tpl-ed-apikey').value.trim();
|
||||
tpl.apiBase = modal.querySelector('#tpl-ed-apibase').value.trim();
|
||||
tpl.defaultModel = modal.querySelector('#tpl-ed-default').value.trim();
|
||||
tpl.opusModel = modal.querySelector('#tpl-ed-opus').value.trim();
|
||||
tpl.sonnetModel = modal.querySelector('#tpl-ed-sonnet').value.trim();
|
||||
tpl.haikuModel = modal.querySelector('#tpl-ed-haiku').value.trim();
|
||||
closeModal();
|
||||
renderModelTemplateEditor();
|
||||
});
|
||||
}
|
||||
|
||||
function saveTplFields() {
|
||||
// Fields are now saved via modal, no inline fields to read
|
||||
}
|
||||
|
||||
modelModeSelect.addEventListener('change', renderModelCustomArea);
|
||||
|
||||
47
server.js
47
server.js
@@ -308,7 +308,7 @@ function loadClaudeJsonModelMap() {
|
||||
}
|
||||
}
|
||||
|
||||
// Apply model config to runtime MODEL_MAP and env
|
||||
// Apply model config to runtime MODEL_MAP only (env vars are injected per-spawn, not here)
|
||||
function applyModelConfig() {
|
||||
const config = loadModelConfig();
|
||||
if (config.mode === 'custom' && config.activeTemplate) {
|
||||
@@ -317,13 +317,10 @@ function applyModelConfig() {
|
||||
if (tpl.opusModel) MODEL_MAP.opus = tpl.opusModel;
|
||||
if (tpl.sonnetModel) MODEL_MAP.sonnet = tpl.sonnetModel;
|
||||
if (tpl.haikuModel) MODEL_MAP.haiku = tpl.haikuModel;
|
||||
if (tpl.apiBase) process.env.ANTHROPIC_BASE_URL = tpl.apiBase;
|
||||
if (tpl.apiKey) process.env.ANTHROPIC_API_KEY = tpl.apiKey;
|
||||
if (tpl.defaultModel) process.env.ANTHROPIC_MODEL = tpl.defaultModel;
|
||||
return;
|
||||
}
|
||||
}
|
||||
// mode === 'local': read from ~/.claude.json
|
||||
// mode === 'local': read model names from ~/.claude.json
|
||||
const localMap = loadClaudeJsonModelMap();
|
||||
if (localMap) {
|
||||
if (localMap.opus) MODEL_MAP.opus = localMap.opus;
|
||||
@@ -933,6 +930,7 @@ 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' };
|
||||
applyModelConfig();
|
||||
@@ -1277,13 +1275,50 @@ function handleMessage(ws, msg, options = {}) {
|
||||
args.push('--resume', session.claudeSessionId);
|
||||
}
|
||||
if (session.model) {
|
||||
args.push('--model', session.model);
|
||||
// Only pass --model if it's a known valid model name in MODEL_MAP
|
||||
const validModels = new Set(Object.values(MODEL_MAP));
|
||||
if (validModels.has(session.model)) {
|
||||
args.push('--model', session.model);
|
||||
}
|
||||
}
|
||||
|
||||
const env = { ...process.env };
|
||||
delete env.CLAUDECODE;
|
||||
delete env.CLAUDE_CODE;
|
||||
delete env.CC_WEB_PASSWORD;
|
||||
// Strip all ANTHROPIC_* from env — claude CLI reads ~/.claude/settings.json which takes priority
|
||||
for (const k of Object.keys(env)) {
|
||||
if (k.startsWith('ANTHROPIC_')) delete env[k];
|
||||
}
|
||||
// custom mode: patch ~/.claude/settings.json env section with template credentials
|
||||
{
|
||||
const modelCfg = loadModelConfig();
|
||||
if (modelCfg.mode === 'custom' && modelCfg.activeTemplate) {
|
||||
const tpl = (modelCfg.templates || []).find(t => t.name === modelCfg.activeTemplate);
|
||||
if (tpl) {
|
||||
const CLAUDE_SETTINGS_PATH = path.join(process.env.HOME || process.env.USERPROFILE || '', '.claude', 'settings.json');
|
||||
let settings = {};
|
||||
try { settings = JSON.parse(fs.readFileSync(CLAUDE_SETTINGS_PATH, 'utf8')); } catch {}
|
||||
const API_KEYS = ['ANTHROPIC_AUTH_TOKEN','ANTHROPIC_API_KEY','ANTHROPIC_BASE_URL','ANTHROPIC_MODEL',
|
||||
'ANTHROPIC_DEFAULT_OPUS_MODEL','ANTHROPIC_DEFAULT_SONNET_MODEL','ANTHROPIC_DEFAULT_HAIKU_MODEL'];
|
||||
const existingEnv = settings.env || {};
|
||||
// Remove old API-related keys, keep non-API keys
|
||||
const cleanedEnv = {};
|
||||
for (const [k, v] of Object.entries(existingEnv)) {
|
||||
if (!API_KEYS.includes(k)) cleanedEnv[k] = v;
|
||||
}
|
||||
// Inject template values
|
||||
if (tpl.apiKey) { cleanedEnv.ANTHROPIC_AUTH_TOKEN = tpl.apiKey; cleanedEnv.ANTHROPIC_API_KEY = tpl.apiKey; }
|
||||
if (tpl.apiBase) cleanedEnv.ANTHROPIC_BASE_URL = tpl.apiBase;
|
||||
if (tpl.defaultModel) cleanedEnv.ANTHROPIC_MODEL = tpl.defaultModel;
|
||||
if (tpl.opusModel) cleanedEnv.ANTHROPIC_DEFAULT_OPUS_MODEL = tpl.opusModel;
|
||||
if (tpl.sonnetModel) cleanedEnv.ANTHROPIC_DEFAULT_SONNET_MODEL = tpl.sonnetModel;
|
||||
if (tpl.haikuModel) cleanedEnv.ANTHROPIC_DEFAULT_HAIKU_MODEL = tpl.haikuModel;
|
||||
settings.env = cleanedEnv;
|
||||
try { fs.writeFileSync(CLAUDE_SETTINGS_PATH, JSON.stringify(settings, null, 2)); } catch {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// === Detached process with file-based I/O ===
|
||||
const dir = runDir(currentSessionId);
|
||||
|
||||
Reference in New Issue
Block a user