feat: add model config panel and fix /compact duplicate message
- Add model config UI in settings panel (local/custom mode) - local mode: read model names from ~/.claude.json env fields - custom mode: named templates with API key, base URL, model names - Fix /compact duplicate message via reason field in pendingCompactRetries - Add CLAUDE.md with architecture notes
This commit is contained in:
93
CLAUDE.md
Normal file
93
CLAUDE.md
Normal file
@@ -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` 与自动触发,避免补发重复提示消息。
|
||||
171
public/app.js
171
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 @@
|
||||
⚙ 设置
|
||||
<button class="settings-close" title="关闭">×</button>
|
||||
</h3>
|
||||
|
||||
<div class="settings-section-title">模型配置</div>
|
||||
<div class="settings-field">
|
||||
<label>配置模式</label>
|
||||
<select class="settings-select" id="model-mode">
|
||||
<option value="local">读取本地配置文件 (~/.claude.json)</option>
|
||||
<option value="custom">自定义配置</option>
|
||||
</select>
|
||||
</div>
|
||||
<div id="model-custom-area"></div>
|
||||
<div class="settings-actions" id="model-actions" style="display:none">
|
||||
<button class="btn-save" id="model-save-btn">保存模型配置</button>
|
||||
</div>
|
||||
<div class="settings-status" id="model-status"></div>
|
||||
|
||||
<div class="settings-divider"></div>
|
||||
|
||||
<div class="settings-section-title">通知设置</div>
|
||||
<div class="settings-field">
|
||||
<label>通知方式</label>
|
||||
@@ -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 = `<div class="settings-field" style="color:var(--text-secondary);font-size:0.85em">读取 ~/.claude.json 中的 ANTHROPIC_DEFAULT_OPUS/SONNET/HAIKU_MODEL 字段覆盖模型名称。</div>`;
|
||||
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 =>
|
||||
`<option value="${escapeHtml(t.name)}" ${t.name === activeName ? 'selected' : ''}>${escapeHtml(t.name)}</option>`
|
||||
).join('');
|
||||
|
||||
modelCustomArea.innerHTML = `
|
||||
<div class="settings-field">
|
||||
<label>激活模板</label>
|
||||
<div style="display:flex;gap:6px;align-items:center">
|
||||
<select class="settings-select" id="model-tpl-select" style="flex:1">
|
||||
${tplOptions}
|
||||
<option value="__new__">+ 新建模板</option>
|
||||
</select>
|
||||
<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) => {
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
151
server.js
151
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') {
|
||||
|
||||
Reference in New Issue
Block a user