diff --git a/CLAUDE.md b/CLAUDE.md index 73cc904..f2929c9 100644 --- a/CLAUDE.md +++ b/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 修复记录 diff --git a/README.md b/README.md index 55d047d..7bc5828 100644 --- a/README.md +++ b/README.md @@ -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` 并在压缩后自动重放上一条失败请求继续运行。 diff --git a/public/app.js b/public/app.js index 6785e16..a338b5c 100644 --- a/public/app.js +++ b/public/app.js @@ -1150,7 +1150,7 @@ function renderModelCustomArea() { if (modelModeSelect.value === 'local') { - modelCustomArea.innerHTML = `
读取 ~/.claude.json 中的 ANTHROPIC_DEFAULT_OPUS/SONNET/HAIKU_MODEL 字段覆盖模型名称。
`; + modelCustomArea.innerHTML = `
⚠ 使用自定义模板会覆盖本地 API 配置,请提前做好备份。
`; modelActionsDiv.style.display = 'flex'; } else { renderModelTemplateEditor(); @@ -1191,39 +1191,10 @@ ${tplOptions} + - ${tpl ? ` -
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
- ` : ''} `; 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 = ` +
+

编辑模板: ${escapeHtml(tpl.name)}

+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ `; + 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); diff --git a/server.js b/server.js index c69ae18..2dd72b9 100644 --- a/server.js +++ b/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);