diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..73cc904 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,93 @@ +# CC-Web + +Claude Code Web Chat UI - 轻量级 Web 聊天界面,通过 WebSocket 与 Claude Code CLI 交互。 + +## 目录与发布约定 + +- 开发与运行目录固定为:`/home/cc-dan/cc/cc-web` +- 当前提交 GitHub 的脱敏目录为:`/home/cc-dan/cc/cc-web_v1.2.2` +- 脱敏目录命名规则:`/home/cc-dan/cc/cc-web_v<主版本>.<次版本>.<修订版本>` +- 版本发布时,必须保持以下信息一致: + 1. 脱敏目录实际名称(如 `cc-web_v1.2.2`) + 2. 本文件中的“当前提交 GitHub 的脱敏目录”路径 + 3. `/home/cc-dan/cc/项目清单.md` 中 CC-Web 的发布副本路径 + 4. 脱敏目录 `README.md` 的“更新记录”版本号与说明 +- 标准发布流程: + 1. 先在开发目录完成修改并验证 + 2. 将变更同步到脱敏目录并完成脱敏处理 + 3. 如版本升级,先重命名脱敏目录,再同步更新上述 4 处版本信息 + 4. 在脱敏目录提交并上传 GitHub +- GitHub 鉴权使用用户提供的临时 token(有效期 30 天);出于安全考虑,不在仓库文件中保存明文 token + +## 架构 + +- `server.js`: Node.js 后端 (HTTP 静态文件 + WebSocket + Claude 进程管理) +- `public/`: 前端 (原生 HTML/CSS/JS,无构建步骤) +- `sessions/`: JSON 格式对话历史 + 运行时 `-run` 目录 +- `logs/process.log`: 进程生命周期日志 + +## 关键设计 + +- 每条消息 spawn `claude -p --output-format stream-json --verbose --dangerously-skip-permissions` +- 使用 `--resume SESSION_ID` 实现会话续接 +- 用户输入通过 stdin 传入(防注入) +- spawn 时删除 CLAUDECODE 环境变量(避免嵌套检测) +- 密码认证 + token 会话 +- **Detached 进程**: `detached: true` + `proc.unref()`,Claude 进程独立于 Node.js +- **文件 I/O**: stdin/stdout/stderr 走文件(`sessions/{id}-run/`),不走 pipe +- **PID 持久化**: PID 写入 `sessions/{id}-run/pid`,服务重启后通过 `recoverProcesses()` 恢复 +- **systemd KillMode=process**: 服务重启只杀 Node.js,不杀 Claude 子进程 + +## 进程生命周期日志 + +日志文件: `logs/process.log`(JSONL 格式,自动轮转 2MB) + +记录事件: +| 事件 | 说明 | +|------|------| +| `server_start` | 服务启动 | +| `ws_connect` / `ws_disconnect` | WebSocket 连接/断开,含影响的活跃进程列表 | +| `process_spawn` | 进程创建(PID、模式、模型、参数) | +| `process_exit_event` | 进程退出(exitCode、signal) | +| `process_complete` | 进程完成处理(含 wsConnected、wsDisconnectTime、disconnectToDeathGap、stderr 摘要) | +| `pid_monitor_detected_exit` | PID 监控发现进程已退出(服务重启后场景) | +| `user_abort` | 用户主动停止 | +| `process_spawn_fail` | 进程启动失败 | +| `recovery_start/alive/dead` | 启动时恢复进程 | +| `ws_resume_attach` | 客户端重连并挂载到运行中的进程 | +| `heartbeat` | 每 60 秒活跃进程状态快照 | + +关键诊断字段(`process_complete` 事件): +- `wsDisconnectTime`: WS 断开时间 +- `disconnectToDeathGap`: WS 断开到进程结束的时间差 +- `exitCode` / `signal`: 退出码和信号(0=正常,非0/SIGTERM/SIGKILL=异常) +- `stderr`: 错误日志末尾 500 字符 + +查看日志: `cat logs/process.log | jq .` 或 `tail -f logs/process.log | jq .` + +## 端口 + +- 本地监听: 127.0.0.1:8002 +- 外部访问: https://cc.02370237.xyz (Nginx 反向代理) + +## 模型配置系统 + +配置文件: `config/model.json` + +支持两种模式(通过设置面板切换): + +| 模式 | 说明 | +|------|------| +| `local` | 读取 `~/.claude.json` 中的 `env` 字段覆盖 MODEL_MAP(ANTHROPIC_DEFAULT_OPUS/SONNET/HAIKU_MODEL) | +| `custom` | 使用命名模板,每个模板含 apiKey、apiBase、defaultModel、opusModel、sonnetModel、haikuModel | + +关键实现: +- `MODEL_MAP` 改为 `let`,启动时调用 `applyModelConfig()` 应用配置 +- `handleSaveModelConfig()` 保存后立即重新应用 MODEL_MAP +- API Key 脱敏:前4后4,中间 `****` +- 保存时若 API Key 含 `****` 则保留旧值(防止脱敏值覆盖真实密钥) +- WS 消息:`get_model_config` → `model_config`;`save_model_config` → `model_config` + `system_message` + +## /compact 修复记录 + +`pendingCompactRetries` 新增 `reason` 字段(`'normal'` | `'auto'`),区分用户手动 `/compact` 与自动触发,避免补发重复提示消息。 diff --git a/public/app.js b/public/app.js index acac859..f055d42 100644 --- a/public/app.js +++ b/public/app.js @@ -288,6 +288,10 @@ if (typeof _onNotifyTestResult === 'function') _onNotifyTestResult(msg); break; + case 'model_config': + if (typeof _onModelConfig === 'function') _onModelConfig(msg.config); + break; + case 'background_done': // A background task completed (browser was disconnected or viewing another session) showToast(`「${msg.title}」任务完成`, msg.sessionId); @@ -1042,6 +1046,7 @@ // --- Settings Panel --- let _onNotifyConfig = null; let _onNotifyTestResult = null; + let _onModelConfig = null; const settingsBtn = $('#settings-btn'); @@ -1055,8 +1060,9 @@ ]; function showSettingsPanel() { - // Request current config + // Request current configs send({ type: 'get_notify_config' }); + send({ type: 'get_model_config' }); const overlay = document.createElement('div'); overlay.className = 'settings-overlay'; @@ -1070,6 +1076,23 @@ ⚙ 设置 + +
模型配置
+
+ + +
+
+ +
+ +
+
通知设置
@@ -1109,6 +1132,151 @@ overlay.appendChild(panel); document.body.appendChild(overlay); + // === Model Config UI === + const modelModeSelect = panel.querySelector('#model-mode'); + const modelCustomArea = panel.querySelector('#model-custom-area'); + const modelActionsDiv = panel.querySelector('#model-actions'); + const modelSaveBtn = panel.querySelector('#model-save-btn'); + const modelStatusDiv = panel.querySelector('#model-status'); + + let modelCurrentConfig = null; + let modelEditingTemplates = []; + let modelActiveTemplate = ''; + + function showModelStatus(msg, type) { + modelStatusDiv.textContent = msg; + modelStatusDiv.className = 'settings-status ' + (type || ''); + } + + function renderModelCustomArea() { + if (modelModeSelect.value === 'local') { + modelCustomArea.innerHTML = `
读取 ~/.claude.json 中的 ANTHROPIC_DEFAULT_OPUS/SONNET/HAIKU_MODEL 字段覆盖模型名称。
`; + modelActionsDiv.style.display = 'flex'; + } else { + renderModelTemplateEditor(); + modelActionsDiv.style.display = 'flex'; + } + } + + function renderModelTemplateEditor() { + const activeName = modelActiveTemplate; + const tpl = modelEditingTemplates.find(t => t.name === activeName) || null; + const tplOptions = modelEditingTemplates.map(t => + `` + ).join(''); + + modelCustomArea.innerHTML = ` +
+ +
+ + +
+
+ ${tpl ? ` +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ ` : ''} + `; + + panel.querySelector('#model-tpl-select').addEventListener('change', (e) => { + if (e.target.value === '__new__') { + const newName = prompt('输入新模板名称:'); + if (!newName || !newName.trim()) { e.target.value = modelActiveTemplate; return; } + const n = newName.trim(); + 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; + } else { + saveTplFields(); + modelActiveTemplate = e.target.value; + } + renderModelTemplateEditor(); + }); + + const delBtn = panel.querySelector('#model-tpl-del'); + if (delBtn) { + delBtn.addEventListener('click', () => { + if (!modelActiveTemplate) return; + if (!confirm(`确认删除模板「${modelActiveTemplate}」?`)) return; + modelEditingTemplates = modelEditingTemplates.filter(t => t.name !== modelActiveTemplate); + modelActiveTemplate = modelEditingTemplates[0]?.name || ''; + renderModelTemplateEditor(); + }); + } + } + + function saveTplFields() { + 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; + } + + modelModeSelect.addEventListener('change', renderModelCustomArea); + + modelSaveBtn.addEventListener('click', () => { + if (modelModeSelect.value === 'custom') saveTplFields(); + const config = { + mode: modelModeSelect.value, + activeTemplate: modelActiveTemplate, + templates: modelEditingTemplates, + }; + send({ type: 'save_model_config', config }); + showModelStatus('已保存', 'success'); + }); + + _onModelConfig = (config) => { + modelCurrentConfig = config; + modelEditingTemplates = (config.templates || []).map(t => Object.assign({}, t)); + modelActiveTemplate = config.activeTemplate || (modelEditingTemplates[0]?.name || ''); + modelModeSelect.value = config.mode || 'local'; + renderModelCustomArea(); + }; + + // === Notify Config UI === const providerSelect = panel.querySelector('#notify-provider'); const fieldsDiv = panel.querySelector('#notify-fields'); const statusDiv = panel.querySelector('#notify-status'); @@ -1285,6 +1453,7 @@ if (overlay) overlay.remove(); _onNotifyConfig = null; _onNotifyTestResult = null; + _onModelConfig = null; document.removeEventListener('keydown', _settingsEscape); } diff --git a/public/style.css b/public/style.css index 3b5fb83..2361745 100644 --- a/public/style.css +++ b/public/style.css @@ -519,6 +519,33 @@ body { line-height: 1.5; white-space: pre; } +/* HTML preview */ +.code-html-preview { + border-top: 1px solid var(--border-color); + background: var(--bg-secondary); +} +.code-html-preview summary { + padding: 6px 12px; + cursor: pointer; + font-size: 12px; + color: var(--text-secondary); + user-select: none; + list-style: none; +} +.code-html-preview summary::-webkit-details-marker { display: none; } +.code-html-preview summary::before { + content: '▸'; + font-size: 11px; + transition: transform 0.2s; + margin-right: 6px; +} +.code-html-preview[open] summary::before { transform: rotate(90deg); } +.code-html-preview iframe { + width: 100%; + min-height: 180px; + border: 0; + background: #fff; +} /* Tool calls */ .tool-call { diff --git a/server.js b/server.js index cfc59d3..c69ae18 100644 --- a/server.js +++ b/server.js @@ -21,6 +21,7 @@ const PUBLIC_DIR = path.join(__dirname, 'public'); const LOGS_DIR = path.join(__dirname, 'logs'); const NOTIFY_CONFIG_PATH = path.join(__dirname, 'config', 'notify.json'); const AUTH_CONFIG_PATH = path.join(__dirname, 'config', 'auth.json'); +const MODEL_CONFIG_PATH = path.join(__dirname, 'config', 'model.json'); fs.mkdirSync(SESSIONS_DIR, { recursive: true }); fs.mkdirSync(LOGS_DIR, { recursive: true }); @@ -230,7 +231,7 @@ const activeTokens = new Set(); // Pending slash command metadata: sessionId -> { kind: string } const pendingSlashCommands = new Map(); -// Pending compact retry metadata: sessionId -> { text: string, mode: string } +// Pending compact retry metadata: sessionId -> { text: string, mode: string, reason: string } const pendingCompactRetries = new Map(); // Active processes: sessionId -> { pid, ws, fullText, toolCalls, lastCost, tailer } @@ -239,12 +240,101 @@ const activeProcesses = new Map(); // Track which session each ws is viewing: ws -> sessionId const wsSessionMap = new Map(); -const MODEL_MAP = { +// Default fallback MODEL_MAP (overridden by model config at runtime) +let MODEL_MAP = { opus: 'claude-opus-4-6', sonnet: 'claude-sonnet-4-6', haiku: 'claude-haiku-4-5-20251001', }; +// === Model Config === +const DEFAULT_MODEL_CONFIG = { + mode: 'local', // 'local' | 'custom' + templates: [], // array of { name, apiKey, apiBase, defaultModel, opusModel, sonnetModel, haikuModel } + activeTemplate: '', // name of active template (for 'custom' mode) +}; + +function loadModelConfig() { + try { + if (fs.existsSync(MODEL_CONFIG_PATH)) { + return JSON.parse(fs.readFileSync(MODEL_CONFIG_PATH, 'utf8')); + } + } catch {} + return JSON.parse(JSON.stringify(DEFAULT_MODEL_CONFIG)); +} + +function saveModelConfig(config) { + fs.writeFileSync(MODEL_CONFIG_PATH, JSON.stringify(config, null, 2)); +} + +function maskSecret(str) { + if (!str || str.length <= 8) return str ? '****' : ''; + return str.slice(0, 4) + '****' + str.slice(-4); +} + +function getModelConfigMasked() { + const config = loadModelConfig(); + return { + mode: config.mode, + activeTemplate: config.activeTemplate, + templates: (config.templates || []).map(t => ({ + name: t.name, + apiKey: maskSecret(t.apiKey), + apiBase: t.apiBase || '', + defaultModel: t.defaultModel || '', + opusModel: t.opusModel || '', + sonnetModel: t.sonnetModel || '', + haikuModel: t.haikuModel || '', + })), + }; +} + +// Read ~/.claude.json for model name overrides +function loadClaudeJsonModelMap() { + try { + const p = path.join(process.env.HOME || process.env.USERPROFILE || '', '.claude.json'); + if (!fs.existsSync(p)) return null; + const raw = JSON.parse(fs.readFileSync(p, 'utf8')); + const env = raw?.env || {}; + const map = {}; + if (env.ANTHROPIC_DEFAULT_OPUS_MODEL) map.opus = env.ANTHROPIC_DEFAULT_OPUS_MODEL; + if (env.ANTHROPIC_DEFAULT_SONNET_MODEL) map.sonnet = env.ANTHROPIC_DEFAULT_SONNET_MODEL; + if (env.ANTHROPIC_DEFAULT_HAIKU_MODEL) map.haiku = env.ANTHROPIC_DEFAULT_HAIKU_MODEL; + // Fallback: ANTHROPIC_MODEL maps to opus slot + if (!map.opus && env.ANTHROPIC_MODEL) map.opus = env.ANTHROPIC_MODEL; + return Object.keys(map).length > 0 ? map : null; + } catch { + return null; + } +} + +// Apply model config to runtime MODEL_MAP and env +function applyModelConfig() { + const config = loadModelConfig(); + if (config.mode === 'custom' && config.activeTemplate) { + const tpl = (config.templates || []).find(t => t.name === config.activeTemplate); + if (tpl) { + 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 + const localMap = loadClaudeJsonModelMap(); + if (localMap) { + if (localMap.opus) MODEL_MAP.opus = localMap.opus; + if (localMap.sonnet) MODEL_MAP.sonnet = localMap.sonnet; + if (localMap.haiku) MODEL_MAP.haiku = localMap.haiku; + } +} + +// Apply on startup +applyModelConfig(); + const MIME_TYPES = { '.html': 'text/html; charset=utf-8', '.css': 'text/css; charset=utf-8', @@ -468,21 +558,26 @@ function handleProcessComplete(sessionId, exitCode, signal) { // Notify client if (entry.ws) { if (pendingSlash?.kind === 'compact') { - wsSend(entry.ws, { type: 'system_message', message: '上下文压缩完成。已按 Claude Code 原生策略执行 /compact,下次继续在同一会话发送即可。' }); const retry = pendingCompactRetries.get(sessionId); - if (retry?.text) { + if (retry?.reason === 'auto') { + wsSend(entry.ws, { type: 'system_message', message: '上下文压缩完成。已按 Claude Code 原生策略执行 /compact,下次继续在同一会话发送即可。' }); + pendingCompactRetries.delete(sessionId); + } else if (retry?.text) { if (requestTooLarge) { pendingCompactRetries.delete(sessionId); wsSend(entry.ws, { type: 'system_message', message: '已尝试执行 /compact,但仍未成功解除上下文超限。请手动缩小输入范围后重试。' }); } else { wsSend(entry.ws, { type: 'system_message', message: '检测到上一条请求因上下文过大失败,现已自动按压缩计划继续执行。' }); shouldReturnForFollowup = true; + pendingCompactRetries.delete(sessionId); } + } else { + wsSend(entry.ws, { type: 'system_message', message: '上下文压缩完成。已按 Claude Code 原生策略执行 /compact,下次继续在同一会话发送即可。' }); } } if (requestTooLarge && !pendingSlash && session && session.claudeSessionId) { - pendingCompactRetries.set(sessionId, { text: pendingRetry?.text || '', mode: pendingRetry?.mode || session.permissionMode || 'yolo' }); + pendingCompactRetries.set(sessionId, { text: pendingRetry?.text || '', mode: pendingRetry?.mode || session.permissionMode || 'yolo', reason: 'auto' }); wsSend(entry.ws, { type: 'system_message', message: '检测到上下文达到上限,正在按 Claude Code 原版策略自动执行 /compact,然后继续当前任务…' }); shouldReturnForFollowup = true; } @@ -722,6 +817,12 @@ wss.on('connection', (ws) => { case 'change_password': handleChangePassword(ws, msg, authToken); break; + case 'get_model_config': + wsSend(ws, { type: 'model_config', config: getModelConfigMasked() }); + break; + case 'save_model_config': + handleSaveModelConfig(ws, msg.config); + break; default: wsSend(ws, { type: 'error', message: `Unknown type: ${msg.type}` }); } @@ -802,6 +903,44 @@ function handleChangePassword(ws, msg, currentToken) { wsSend(ws, { type: 'password_changed', success: true, token: newToken, message: '密码修改成功' }); } +// === Model Config Handler === +function handleSaveModelConfig(ws, newConfig) { + if (!newConfig || !['local', 'custom'].includes(newConfig.mode)) { + return wsSend(ws, { type: 'error', message: '无效的模型配置' }); + } + const current = loadModelConfig(); + const merged = { + mode: newConfig.mode, + activeTemplate: newConfig.activeTemplate || '', + templates: [], + }; + + // Merge templates: keep existing secrets if masked + const newTemplates = Array.isArray(newConfig.templates) ? newConfig.templates : []; + const oldTemplates = Array.isArray(current.templates) ? current.templates : []; + for (const nt of newTemplates) { + if (!nt.name || !nt.name.trim()) continue; + const old = oldTemplates.find(t => t.name === nt.name); + merged.templates.push({ + name: nt.name.trim(), + apiKey: (nt.apiKey && !nt.apiKey.includes('****')) ? nt.apiKey : (old?.apiKey || ''), + apiBase: nt.apiBase || '', + defaultModel: nt.defaultModel || '', + opusModel: nt.opusModel || '', + sonnetModel: nt.sonnetModel || '', + haikuModel: nt.haikuModel || '', + }); + } + + 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(); + plog('INFO', 'model_config_saved', { mode: merged.mode, activeTemplate: merged.activeTemplate }); + wsSend(ws, { type: 'model_config', config: getModelConfigMasked() }); + wsSend(ws, { type: 'system_message', message: '模型配置已保存' }); +} + // === Slash Command Handler === function handleSlashCommand(ws, text, sessionId) { const parts = text.split(/\s+/); @@ -1096,7 +1235,7 @@ function handleMessage(ws, msg, options = {}) { } if (!hideInHistory && normalizedText !== '/compact' && session.claudeSessionId) { - pendingCompactRetries.set(session.id, { text: normalizedText, mode: session.permissionMode || 'yolo' }); + pendingCompactRetries.set(session.id, { text: normalizedText, mode: session.permissionMode || 'yolo', reason: 'normal' }); } if (session.title === 'New Chat' || session.title === 'Untitled') {