- 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
1517 lines
52 KiB
JavaScript
1517 lines
52 KiB
JavaScript
const http = require('http');
|
||
const fs = require('fs');
|
||
const path = require('path');
|
||
const crypto = require('crypto');
|
||
const { spawn } = require('child_process');
|
||
const { WebSocketServer } = require('ws');
|
||
|
||
// Load .env
|
||
const envPath = path.join(__dirname, '.env');
|
||
if (fs.existsSync(envPath)) {
|
||
for (const line of fs.readFileSync(envPath, 'utf8').split('\n')) {
|
||
const m = line.match(/^([^#=]+)=(.*)$/);
|
||
if (m && !process.env[m[1].trim()]) process.env[m[1].trim()] = m[2].trim();
|
||
}
|
||
}
|
||
|
||
const PORT = parseInt(process.env.PORT) || 8002;
|
||
const CLAUDE_PATH = process.env.CLAUDE_PATH || 'claude';
|
||
const SESSIONS_DIR = path.join(__dirname, 'sessions');
|
||
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 });
|
||
fs.mkdirSync(path.dirname(NOTIFY_CONFIG_PATH), { recursive: true });
|
||
|
||
// === Process Lifecycle Logger ===
|
||
const LOG_FILE = path.join(LOGS_DIR, 'process.log');
|
||
const LOG_MAX_SIZE = 2 * 1024 * 1024; // 2MB per file
|
||
|
||
function plog(level, event, data = {}) {
|
||
const entry = {
|
||
ts: new Date().toISOString(),
|
||
level,
|
||
event,
|
||
...data,
|
||
};
|
||
const line = JSON.stringify(entry) + '\n';
|
||
try {
|
||
// Simple rotation: if file > 2MB, rename to .old and start fresh
|
||
try {
|
||
const stat = fs.statSync(LOG_FILE);
|
||
if (stat.size > LOG_MAX_SIZE) {
|
||
const oldFile = LOG_FILE.replace('.log', '.old.log');
|
||
try { fs.unlinkSync(oldFile); } catch {}
|
||
fs.renameSync(LOG_FILE, oldFile);
|
||
}
|
||
} catch {}
|
||
fs.appendFileSync(LOG_FILE, line);
|
||
} catch {}
|
||
}
|
||
|
||
// === Notification System ===
|
||
function loadNotifyConfig() {
|
||
try {
|
||
if (fs.existsSync(NOTIFY_CONFIG_PATH)) {
|
||
return JSON.parse(fs.readFileSync(NOTIFY_CONFIG_PATH, 'utf8'));
|
||
}
|
||
} catch {}
|
||
// First run: migrate from .env PUSHPLUS_TOKEN
|
||
const token = process.env.PUSHPLUS_TOKEN || '';
|
||
const config = {
|
||
provider: token ? 'pushplus' : 'off',
|
||
pushplus: { token },
|
||
telegram: { botToken: '', chatId: '' },
|
||
serverchan: { sendKey: '' },
|
||
feishu: { webhook: '' },
|
||
qqbot: { qmsgKey: '' },
|
||
};
|
||
saveNotifyConfig(config);
|
||
return config;
|
||
}
|
||
|
||
function saveNotifyConfig(config) {
|
||
fs.writeFileSync(NOTIFY_CONFIG_PATH, JSON.stringify(config, null, 2));
|
||
}
|
||
|
||
function maskToken(str) {
|
||
if (!str || str.length <= 8) return str ? '****' : '';
|
||
return str.slice(0, 4) + '****' + str.slice(-4);
|
||
}
|
||
|
||
function getNotifyConfigMasked() {
|
||
const config = loadNotifyConfig();
|
||
return {
|
||
provider: config.provider,
|
||
pushplus: { token: maskToken(config.pushplus?.token) },
|
||
telegram: { botToken: maskToken(config.telegram?.botToken), chatId: config.telegram?.chatId || '' },
|
||
serverchan: { sendKey: maskToken(config.serverchan?.sendKey) },
|
||
feishu: { webhook: maskToken(config.feishu?.webhook) },
|
||
qqbot: { qmsgKey: maskToken(config.qqbot?.qmsgKey) },
|
||
};
|
||
}
|
||
|
||
function sendNotification(title, content) {
|
||
const config = loadNotifyConfig();
|
||
if (!config.provider || config.provider === 'off') return Promise.resolve({ ok: true, skipped: true });
|
||
const https = require('https');
|
||
|
||
return new Promise((resolve) => {
|
||
let url, data;
|
||
let isFormData = false;
|
||
switch (config.provider) {
|
||
case 'pushplus': {
|
||
if (!config.pushplus?.token) return resolve({ ok: false, error: 'PushPlus token 未配置' });
|
||
url = 'https://www.pushplus.plus/send';
|
||
data = JSON.stringify({ token: config.pushplus.token, title, content, template: 'txt' });
|
||
break;
|
||
}
|
||
case 'telegram': {
|
||
if (!config.telegram?.botToken || !config.telegram?.chatId) return resolve({ ok: false, error: 'Telegram botToken 或 chatId 未配置' });
|
||
url = `https://api.telegram.org/bot${config.telegram.botToken}/sendMessage`;
|
||
data = JSON.stringify({ chat_id: config.telegram.chatId, text: `${title}\n\n${content}` });
|
||
break;
|
||
}
|
||
case 'serverchan': {
|
||
if (!config.serverchan?.sendKey) return resolve({ ok: false, error: 'Server酱 sendKey 未配置' });
|
||
url = `https://sctapi.ftqq.com/${config.serverchan.sendKey}.send`;
|
||
data = JSON.stringify({ title, desp: content });
|
||
break;
|
||
}
|
||
case 'feishu': {
|
||
if (!config.feishu?.webhook) return resolve({ ok: false, error: '飞书 Webhook 未配置' });
|
||
url = config.feishu.webhook;
|
||
data = JSON.stringify({ msg_type: 'text', content: { text: `${title}\n\n${content}` } });
|
||
break;
|
||
}
|
||
case 'qqbot': {
|
||
if (!config.qqbot?.qmsgKey) return resolve({ ok: false, error: 'Qmsg Key 未配置' });
|
||
url = `https://qmsg.zendee.cn/send/${config.qqbot.qmsgKey}`;
|
||
data = `msg=${encodeURIComponent(`${title}\n\n${content}`)}`;
|
||
isFormData = true;
|
||
break;
|
||
}
|
||
default:
|
||
return resolve({ ok: false, error: `未知通知方式: ${config.provider}` });
|
||
}
|
||
|
||
const parsed = new URL(url);
|
||
const contentType = isFormData ? 'application/x-www-form-urlencoded' : 'application/json';
|
||
const reqOptions = {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': contentType, 'Content-Length': Buffer.byteLength(data) },
|
||
};
|
||
const req = https.request(parsed, reqOptions, (res) => {
|
||
let body = '';
|
||
res.on('data', (c) => body += c);
|
||
res.on('end', () => {
|
||
plog('INFO', 'notify_response', { provider: config.provider, status: res.statusCode, body: body.slice(0, 200) });
|
||
resolve({ ok: res.statusCode >= 200 && res.statusCode < 300, status: res.statusCode, body: body.slice(0, 200) });
|
||
});
|
||
});
|
||
req.on('error', (e) => {
|
||
plog('WARN', 'notify_error', { provider: config.provider, error: e.message });
|
||
resolve({ ok: false, error: e.message });
|
||
});
|
||
req.write(data);
|
||
req.end();
|
||
});
|
||
}
|
||
|
||
// Load config on startup (ensures migration)
|
||
loadNotifyConfig();
|
||
|
||
// === Auth Config ===
|
||
function generateRandomPassword(length = 12) {
|
||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
||
let result = '';
|
||
const bytes = crypto.randomBytes(length);
|
||
for (let i = 0; i < length; i++) {
|
||
result += chars[bytes[i] % chars.length];
|
||
}
|
||
return result;
|
||
}
|
||
|
||
function loadAuthConfig() {
|
||
// Priority 1: config/auth.json exists with password
|
||
try {
|
||
if (fs.existsSync(AUTH_CONFIG_PATH)) {
|
||
const config = JSON.parse(fs.readFileSync(AUTH_CONFIG_PATH, 'utf8'));
|
||
if (config.password) return config;
|
||
}
|
||
} catch {}
|
||
|
||
// Priority 2: .env has CC_WEB_PASSWORD → migrate
|
||
const envPw = process.env.CC_WEB_PASSWORD;
|
||
if (envPw && envPw !== 'changeme') {
|
||
const config = { password: envPw, mustChange: false };
|
||
saveAuthConfig(config);
|
||
return config;
|
||
}
|
||
|
||
// Priority 3: Generate random password
|
||
const pw = generateRandomPassword(12);
|
||
const config = { password: pw, mustChange: true };
|
||
saveAuthConfig(config);
|
||
console.log('========================================');
|
||
console.log(' 自动生成初始密码: ' + pw);
|
||
console.log(' 首次登录后将要求修改密码');
|
||
console.log('========================================');
|
||
return config;
|
||
}
|
||
|
||
function saveAuthConfig(config) {
|
||
fs.writeFileSync(AUTH_CONFIG_PATH, JSON.stringify(config, null, 2));
|
||
}
|
||
|
||
function validatePasswordStrength(pw) {
|
||
if (!pw || pw.length < 8) {
|
||
return { valid: false, message: '密码长度至少 8 位' };
|
||
}
|
||
let types = 0;
|
||
if (/[a-z]/.test(pw)) types++;
|
||
if (/[A-Z]/.test(pw)) types++;
|
||
if (/[0-9]/.test(pw)) types++;
|
||
if (/[^a-zA-Z0-9]/.test(pw)) types++;
|
||
if (types < 2) {
|
||
return { valid: false, message: '密码需包含至少 2 种字符类型(大写/小写/数字/特殊字符)' };
|
||
}
|
||
return { valid: true, message: '' };
|
||
}
|
||
|
||
let authConfig = loadAuthConfig();
|
||
let PASSWORD = authConfig.password;
|
||
|
||
const activeTokens = new Set();
|
||
|
||
// Pending slash command metadata: sessionId -> { kind: string }
|
||
const pendingSlashCommands = new Map();
|
||
|
||
// Pending compact retry metadata: sessionId -> { text: string, mode: string, reason: string }
|
||
const pendingCompactRetries = new Map();
|
||
|
||
// Active processes: sessionId -> { pid, ws, fullText, toolCalls, lastCost, tailer }
|
||
const activeProcesses = new Map();
|
||
|
||
// Track which session each ws is viewing: ws -> sessionId
|
||
const wsSessionMap = new 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 only (env vars are injected per-spawn, not here)
|
||
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;
|
||
return;
|
||
}
|
||
}
|
||
// mode === 'local': read model names 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',
|
||
'.js': 'text/javascript; charset=utf-8',
|
||
'.json': 'application/json',
|
||
'.png': 'image/png',
|
||
'.svg': 'image/svg+xml',
|
||
'.ico': 'image/x-icon',
|
||
};
|
||
|
||
// === Utility Functions ===
|
||
|
||
function wsSend(ws, data) {
|
||
if (ws && ws.readyState === 1) ws.send(JSON.stringify(data));
|
||
}
|
||
|
||
function sanitizeId(id) {
|
||
return String(id).replace(/[^a-zA-Z0-9\-]/g, '');
|
||
}
|
||
|
||
function sessionPath(id) {
|
||
return path.join(SESSIONS_DIR, `${sanitizeId(id)}.json`);
|
||
}
|
||
|
||
function runDir(sessionId) {
|
||
return path.join(SESSIONS_DIR, `${sanitizeId(sessionId)}-run`);
|
||
}
|
||
|
||
function loadSession(id) {
|
||
try {
|
||
return JSON.parse(fs.readFileSync(sessionPath(id), 'utf8'));
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
function saveSession(session) {
|
||
fs.writeFileSync(sessionPath(session.id), JSON.stringify(session, null, 2));
|
||
}
|
||
|
||
function modelShortName(fullModel) {
|
||
if (!fullModel) return null;
|
||
const entry = Object.entries(MODEL_MAP).find(([, v]) => v === fullModel);
|
||
return entry ? entry[0] : null;
|
||
}
|
||
|
||
const IS_WIN = process.platform === 'win32';
|
||
|
||
function isProcessRunning(pid) {
|
||
try {
|
||
process.kill(pid, 0);
|
||
return true;
|
||
} catch {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
function killProcess(pid, force = false) {
|
||
try {
|
||
if (IS_WIN) {
|
||
const args = ['/T', '/PID', String(pid)];
|
||
if (force) args.unshift('/F');
|
||
spawn('taskkill', args, { windowsHide: true, stdio: 'ignore' });
|
||
} else {
|
||
process.kill(pid, force ? 'SIGKILL' : 'SIGTERM');
|
||
}
|
||
} catch {}
|
||
}
|
||
|
||
function cleanRunDir(sessionId) {
|
||
const dir = runDir(sessionId);
|
||
try {
|
||
if (fs.existsSync(dir)) fs.rmSync(dir, { recursive: true });
|
||
} catch {}
|
||
}
|
||
|
||
function sendSessionList(ws) {
|
||
try {
|
||
const files = fs.readdirSync(SESSIONS_DIR).filter(f => f.endsWith('.json'));
|
||
const sessions = [];
|
||
for (const f of files) {
|
||
try {
|
||
const s = JSON.parse(fs.readFileSync(path.join(SESSIONS_DIR, f), 'utf8'));
|
||
sessions.push({ id: s.id, title: s.title || 'Untitled', updated: s.updated, hasUnread: !!s.hasUnread });
|
||
} catch {}
|
||
}
|
||
sessions.sort((a, b) => new Date(b.updated) - new Date(a.updated));
|
||
wsSend(ws, { type: 'session_list', sessions });
|
||
} catch {
|
||
wsSend(ws, { type: 'session_list', sessions: [] });
|
||
}
|
||
}
|
||
|
||
// === File Tailer ===
|
||
// Tails a file and calls onLine for each new complete line.
|
||
class FileTailer {
|
||
constructor(filePath, onLine) {
|
||
this.filePath = filePath;
|
||
this.onLine = onLine;
|
||
this.offset = 0;
|
||
this.buffer = '';
|
||
this.watcher = null;
|
||
this.interval = null;
|
||
this.stopped = false;
|
||
}
|
||
|
||
start() {
|
||
this.readNew();
|
||
try {
|
||
this.watcher = fs.watch(this.filePath, () => {
|
||
if (!this.stopped) this.readNew();
|
||
});
|
||
this.watcher.on('error', () => {});
|
||
} catch {}
|
||
// Backup poll every 500ms (fs.watch not always reliable on all systems)
|
||
this.interval = setInterval(() => {
|
||
if (!this.stopped) this.readNew();
|
||
}, 500);
|
||
}
|
||
|
||
readNew() {
|
||
try {
|
||
const stat = fs.statSync(this.filePath);
|
||
if (stat.size <= this.offset) return;
|
||
const buf = Buffer.alloc(stat.size - this.offset);
|
||
const fd = fs.openSync(this.filePath, 'r');
|
||
fs.readSync(fd, buf, 0, buf.length, this.offset);
|
||
fs.closeSync(fd);
|
||
this.offset = stat.size;
|
||
this.buffer += buf.toString();
|
||
const lines = this.buffer.split('\n');
|
||
this.buffer = lines.pop();
|
||
for (const line of lines) {
|
||
if (line.trim()) this.onLine(line);
|
||
}
|
||
} catch {}
|
||
}
|
||
|
||
stop() {
|
||
this.stopped = true;
|
||
if (this.watcher) { this.watcher.close(); this.watcher = null; }
|
||
if (this.interval) { clearInterval(this.interval); this.interval = null; }
|
||
}
|
||
}
|
||
|
||
// === Process Lifecycle ===
|
||
|
||
function handleProcessComplete(sessionId, exitCode, signal) {
|
||
const entry = activeProcesses.get(sessionId);
|
||
if (!entry) return;
|
||
|
||
const completeTime = new Date().toISOString();
|
||
const wsConnected = !!entry.ws;
|
||
const disconnectGap = entry.wsDisconnectTime
|
||
? ((new Date(completeTime) - new Date(entry.wsDisconnectTime)) / 1000).toFixed(1) + 's'
|
||
: null;
|
||
|
||
const pendingRetry = pendingCompactRetries.get(sessionId) || null;
|
||
let requestTooLarge = false;
|
||
|
||
// Read stderr for error clues
|
||
let stderrSnippet = '';
|
||
try {
|
||
const errPath = path.join(runDir(sessionId), 'error.log');
|
||
if (fs.existsSync(errPath)) {
|
||
const content = fs.readFileSync(errPath, 'utf8').trim();
|
||
if (content) stderrSnippet = content.slice(-500);
|
||
}
|
||
} catch {}
|
||
|
||
requestTooLarge = /Request too large \(max 20MB\)/i.test(entry.fullText || '') || /Request too large \(max 20MB\)/i.test(stderrSnippet || '');
|
||
|
||
plog(exitCode === 0 || exitCode === null ? 'INFO' : 'WARN', 'process_complete', {
|
||
sessionId: sessionId.slice(0, 8),
|
||
pid: entry.pid,
|
||
exitCode,
|
||
signal,
|
||
wsConnected,
|
||
wsDisconnectTime: entry.wsDisconnectTime || null,
|
||
disconnectToDeathGap: disconnectGap,
|
||
responseLen: (entry.fullText || '').length,
|
||
toolCallCount: (entry.toolCalls || []).length,
|
||
cost: entry.lastCost,
|
||
stderr: stderrSnippet || null,
|
||
requestTooLarge,
|
||
});
|
||
|
||
// Final read
|
||
if (entry.tailer) {
|
||
entry.tailer.readNew();
|
||
entry.tailer.stop();
|
||
}
|
||
|
||
const pendingSlash = pendingSlashCommands.get(sessionId) || null;
|
||
if (pendingSlash) pendingSlashCommands.delete(sessionId);
|
||
|
||
// Save result to session
|
||
const session = loadSession(sessionId);
|
||
if (session && entry.fullText) {
|
||
session.messages.push({
|
||
role: 'assistant',
|
||
content: entry.fullText,
|
||
toolCalls: entry.toolCalls || [],
|
||
timestamp: new Date().toISOString(),
|
||
});
|
||
session.updated = new Date().toISOString();
|
||
if (!entry.ws) session.hasUnread = true;
|
||
saveSession(session);
|
||
}
|
||
|
||
if (pendingSlash?.kind === 'compact' && session) {
|
||
if (entry.lastCost) {
|
||
session.totalCost = Math.max(0, (session.totalCost || 0) - entry.lastCost);
|
||
}
|
||
session.updated = new Date().toISOString();
|
||
saveSession(session);
|
||
}
|
||
|
||
let shouldReturnForFollowup = false;
|
||
|
||
// Notify client
|
||
if (entry.ws) {
|
||
if (pendingSlash?.kind === 'compact') {
|
||
const retry = pendingCompactRetries.get(sessionId);
|
||
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', reason: 'auto' });
|
||
wsSend(entry.ws, { type: 'system_message', message: '检测到上下文达到上限,正在按 Claude Code 原版策略自动执行 /compact,然后继续当前任务…' });
|
||
shouldReturnForFollowup = true;
|
||
}
|
||
|
||
wsSend(entry.ws, { type: 'done', sessionId, costUsd: entry.lastCost || null });
|
||
sendSessionList(entry.ws);
|
||
} else {
|
||
// Process completed while browser was disconnected — notify all connected clients
|
||
const session = loadSession(sessionId);
|
||
const title = session?.title || 'Untitled';
|
||
for (const client of wss.clients) {
|
||
if (client.readyState === 1) {
|
||
wsSend(client, {
|
||
type: 'background_done',
|
||
sessionId,
|
||
title,
|
||
costUsd: entry.lastCost || null,
|
||
responseLen: (entry.fullText || '').length,
|
||
});
|
||
}
|
||
}
|
||
// Push notification
|
||
const cost = entry.lastCost ? `$${entry.lastCost.toFixed(4)}` : '';
|
||
const respLen = (entry.fullText || '').length;
|
||
sendNotification(
|
||
`CC-Web 任务完成`,
|
||
`会话: ${title}\n字数: ${respLen}\n费用: ${cost}`
|
||
);
|
||
}
|
||
|
||
activeProcesses.delete(sessionId);
|
||
cleanRunDir(sessionId);
|
||
pendingSlashCommands.delete(sessionId);
|
||
|
||
if (!shouldReturnForFollowup && !requestTooLarge && pendingRetry && pendingRetry.text === (entry.fullText || '').trim()) {
|
||
pendingCompactRetries.delete(sessionId);
|
||
}
|
||
|
||
if (shouldReturnForFollowup && entry.ws && entry.ws.readyState === 1 && session) {
|
||
if (pendingSlash?.kind === 'compact') {
|
||
const retry = pendingCompactRetries.get(sessionId);
|
||
if (retry?.text) {
|
||
pendingCompactRetries.delete(sessionId);
|
||
handleMessage(entry.ws, { text: retry.text, sessionId, mode: retry.mode || session.permissionMode || 'yolo' });
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (requestTooLarge && !pendingSlash && session.claudeSessionId) {
|
||
pendingSlashCommands.set(sessionId, { kind: 'compact' });
|
||
handleMessage(entry.ws, { text: '/compact', sessionId, mode: session.permissionMode || 'yolo' }, { hideInHistory: true });
|
||
return;
|
||
}
|
||
}
|
||
}
|
||
|
||
// Global PID monitor: detect process completion (especially after server restart)
|
||
setInterval(() => {
|
||
for (const [sessionId, entry] of activeProcesses) {
|
||
if (entry.pid && !isProcessRunning(entry.pid)) {
|
||
plog('INFO', 'pid_monitor_detected_exit', {
|
||
sessionId: sessionId.slice(0, 8),
|
||
pid: entry.pid,
|
||
wsConnected: !!entry.ws,
|
||
});
|
||
handleProcessComplete(sessionId, null, 'unknown (detected by monitor)');
|
||
}
|
||
}
|
||
}, 2000);
|
||
|
||
// Recover processes that were running before server restart
|
||
function recoverProcesses() {
|
||
try {
|
||
const entries = fs.readdirSync(SESSIONS_DIR).filter(f => f.endsWith('-run') && fs.statSync(path.join(SESSIONS_DIR, f)).isDirectory());
|
||
if (entries.length === 0) return;
|
||
plog('INFO', 'recovery_start', { runDirs: entries.length });
|
||
for (const dirName of entries) {
|
||
const sessionId = dirName.replace('-run', '');
|
||
const dir = path.join(SESSIONS_DIR, dirName);
|
||
const pidPath = path.join(dir, 'pid');
|
||
const outputPath = path.join(dir, 'output.jsonl');
|
||
|
||
if (!fs.existsSync(pidPath)) {
|
||
try { fs.rmSync(dir, { recursive: true }); } catch {}
|
||
continue;
|
||
}
|
||
|
||
const pid = parseInt(fs.readFileSync(pidPath, 'utf8'));
|
||
|
||
if (isProcessRunning(pid)) {
|
||
console.log(`[recovery] Re-attaching to session ${sessionId} (PID ${pid})`);
|
||
plog('INFO', 'recovery_alive', { sessionId: sessionId.slice(0, 8), pid });
|
||
const entry = { pid, ws: null, fullText: '', toolCalls: [], lastCost: null, tailer: null };
|
||
activeProcesses.set(sessionId, entry);
|
||
|
||
if (fs.existsSync(outputPath)) {
|
||
entry.tailer = new FileTailer(outputPath, (line) => {
|
||
try {
|
||
const event = JSON.parse(line);
|
||
processClaudeEvent(entry, event, sessionId);
|
||
} catch {}
|
||
});
|
||
entry.tailer.start();
|
||
}
|
||
} else {
|
||
// Process finished while server was down — read all output and save
|
||
console.log(`[recovery] Processing completed output for session ${sessionId}`);
|
||
plog('INFO', 'recovery_dead', { sessionId: sessionId.slice(0, 8), pid });
|
||
if (fs.existsSync(outputPath)) {
|
||
const tempEntry = { pid: 0, ws: null, fullText: '', toolCalls: [], lastCost: null, tailer: null };
|
||
const content = fs.readFileSync(outputPath, 'utf8');
|
||
for (const line of content.split('\n')) {
|
||
if (!line.trim()) continue;
|
||
try {
|
||
const event = JSON.parse(line);
|
||
processClaudeEvent(tempEntry, event, sessionId);
|
||
} catch {}
|
||
}
|
||
const session = loadSession(sessionId);
|
||
if (session && tempEntry.fullText) {
|
||
session.messages.push({
|
||
role: 'assistant',
|
||
content: tempEntry.fullText,
|
||
toolCalls: tempEntry.toolCalls || [],
|
||
timestamp: new Date().toISOString(),
|
||
});
|
||
session.updated = new Date().toISOString();
|
||
saveSession(session);
|
||
}
|
||
}
|
||
try { fs.rmSync(dir, { recursive: true }); } catch {}
|
||
}
|
||
}
|
||
} catch (err) {
|
||
console.error('[recovery] Error:', err.message);
|
||
}
|
||
}
|
||
|
||
// === HTTP Static File Server ===
|
||
const server = http.createServer((req, res) => {
|
||
const url = new URL(req.url, `http://${req.headers.host}`);
|
||
let filePath = path.join(PUBLIC_DIR, url.pathname === '/' ? 'index.html' : url.pathname);
|
||
filePath = path.resolve(filePath);
|
||
|
||
if (!filePath.startsWith(PUBLIC_DIR)) {
|
||
res.writeHead(403);
|
||
return res.end('Forbidden');
|
||
}
|
||
|
||
fs.readFile(filePath, (err, data) => {
|
||
if (err) {
|
||
res.writeHead(404);
|
||
return res.end('Not Found');
|
||
}
|
||
const ext = path.extname(filePath);
|
||
res.writeHead(200, {
|
||
'Content-Type': MIME_TYPES[ext] || 'application/octet-stream',
|
||
'Cache-Control': 'no-cache',
|
||
});
|
||
res.end(data);
|
||
});
|
||
});
|
||
|
||
// === WebSocket Server ===
|
||
const wss = new WebSocketServer({ server });
|
||
|
||
wss.on('connection', (ws) => {
|
||
let authenticated = false;
|
||
let authToken = null;
|
||
const wsId = crypto.randomBytes(4).toString('hex'); // short id for log correlation
|
||
const wsConnectTime = new Date().toISOString();
|
||
plog('INFO', 'ws_connect', { wsId });
|
||
|
||
ws.on('message', (raw) => {
|
||
let msg;
|
||
try {
|
||
msg = JSON.parse(raw);
|
||
} catch {
|
||
return wsSend(ws, { type: 'error', message: 'Invalid JSON' });
|
||
}
|
||
|
||
if (msg.type === 'auth') {
|
||
if (msg.password === PASSWORD || (msg.token && activeTokens.has(msg.token))) {
|
||
authToken = msg.token && activeTokens.has(msg.token) ? msg.token : crypto.randomBytes(32).toString('hex');
|
||
activeTokens.add(authToken);
|
||
authenticated = true;
|
||
wsSend(ws, { type: 'auth_result', success: true, token: authToken, mustChangePassword: !!authConfig.mustChange });
|
||
sendSessionList(ws);
|
||
} else {
|
||
wsSend(ws, { type: 'auth_result', success: false });
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (!authenticated) {
|
||
return wsSend(ws, { type: 'error', message: 'Not authenticated' });
|
||
}
|
||
|
||
switch (msg.type) {
|
||
case 'message':
|
||
if (msg.text && msg.text.trim().startsWith('/')) {
|
||
handleSlashCommand(ws, msg.text.trim(), msg.sessionId);
|
||
} else {
|
||
handleMessage(ws, msg);
|
||
}
|
||
break;
|
||
case 'abort':
|
||
handleAbort(ws);
|
||
break;
|
||
case 'new_session':
|
||
handleNewSession(ws);
|
||
break;
|
||
case 'load_session':
|
||
handleLoadSession(ws, msg.sessionId);
|
||
break;
|
||
case 'delete_session':
|
||
handleDeleteSession(ws, msg.sessionId);
|
||
break;
|
||
case 'rename_session':
|
||
handleRenameSession(ws, msg.sessionId, msg.title);
|
||
break;
|
||
case 'set_mode':
|
||
handleSetMode(ws, msg.sessionId, msg.mode);
|
||
break;
|
||
case 'list_sessions':
|
||
sendSessionList(ws);
|
||
break;
|
||
case 'get_notify_config':
|
||
wsSend(ws, { type: 'notify_config', config: getNotifyConfigMasked() });
|
||
break;
|
||
case 'save_notify_config':
|
||
handleSaveNotifyConfig(ws, msg.config);
|
||
break;
|
||
case 'test_notify':
|
||
handleTestNotify(ws);
|
||
break;
|
||
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}` });
|
||
}
|
||
});
|
||
|
||
ws.on('close', () => handleDisconnect(ws, wsId));
|
||
ws.on('error', (err) => {
|
||
plog('WARN', 'ws_error', { wsId, error: err.message });
|
||
handleDisconnect(ws, wsId);
|
||
});
|
||
});
|
||
|
||
// === Notify Config Handlers ===
|
||
function handleSaveNotifyConfig(ws, newConfig) {
|
||
if (!newConfig || !newConfig.provider) {
|
||
return wsSend(ws, { type: 'error', message: '无效的通知配置' });
|
||
}
|
||
const current = loadNotifyConfig();
|
||
// Merge: only update fields that are not masked (contain ****)
|
||
const merged = { provider: newConfig.provider };
|
||
// pushplus
|
||
merged.pushplus = { token: (newConfig.pushplus?.token && !newConfig.pushplus.token.includes('****')) ? newConfig.pushplus.token : current.pushplus?.token || '' };
|
||
// telegram
|
||
merged.telegram = {
|
||
botToken: (newConfig.telegram?.botToken && !newConfig.telegram.botToken.includes('****')) ? newConfig.telegram.botToken : current.telegram?.botToken || '',
|
||
chatId: newConfig.telegram?.chatId !== undefined ? newConfig.telegram.chatId : current.telegram?.chatId || '',
|
||
};
|
||
// serverchan
|
||
merged.serverchan = { sendKey: (newConfig.serverchan?.sendKey && !newConfig.serverchan.sendKey.includes('****')) ? newConfig.serverchan.sendKey : current.serverchan?.sendKey || '' };
|
||
// feishu
|
||
merged.feishu = { webhook: (newConfig.feishu?.webhook && !newConfig.feishu.webhook.includes('****')) ? newConfig.feishu.webhook : current.feishu?.webhook || '' };
|
||
// qqbot
|
||
merged.qqbot = { qmsgKey: (newConfig.qqbot?.qmsgKey && !newConfig.qqbot.qmsgKey.includes('****')) ? newConfig.qqbot.qmsgKey : current.qqbot?.qmsgKey || '' };
|
||
|
||
saveNotifyConfig(merged);
|
||
plog('INFO', 'notify_config_saved', { provider: merged.provider });
|
||
wsSend(ws, { type: 'notify_config', config: getNotifyConfigMasked() });
|
||
wsSend(ws, { type: 'system_message', message: '通知配置已保存' });
|
||
}
|
||
|
||
function handleTestNotify(ws) {
|
||
const config = loadNotifyConfig();
|
||
if (!config.provider || config.provider === 'off') {
|
||
return wsSend(ws, { type: 'notify_test_result', success: false, message: '通知已关闭,无法测试' });
|
||
}
|
||
sendNotification('CC-Web 测试通知', '这是一条测试消息,如果你收到了说明通知配置正确!').then((result) => {
|
||
wsSend(ws, { type: 'notify_test_result', success: result.ok, message: result.ok ? '测试消息已发送,请检查是否收到' : `发送失败: ${result.error || result.body || '未知错误'}` });
|
||
});
|
||
}
|
||
|
||
function handleChangePassword(ws, msg, currentToken) {
|
||
const { currentPassword, newPassword } = msg;
|
||
|
||
// Validate current password
|
||
if (currentPassword !== PASSWORD) {
|
||
return wsSend(ws, { type: 'password_changed', success: false, message: '当前密码错误' });
|
||
}
|
||
|
||
// Validate new password strength
|
||
const strength = validatePasswordStrength(newPassword);
|
||
if (!strength.valid) {
|
||
return wsSend(ws, { type: 'password_changed', success: false, message: strength.message });
|
||
}
|
||
|
||
// Save new password
|
||
authConfig = { password: newPassword, mustChange: false };
|
||
saveAuthConfig(authConfig);
|
||
PASSWORD = newPassword;
|
||
plog('INFO', 'password_changed', {});
|
||
|
||
// Clear all tokens (force all sessions to re-login)
|
||
activeTokens.clear();
|
||
|
||
// Generate new token for current connection
|
||
const newToken = crypto.randomBytes(32).toString('hex');
|
||
activeTokens.add(newToken);
|
||
|
||
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+/);
|
||
const cmd = parts[0].toLowerCase();
|
||
let session = sessionId ? loadSession(sessionId) : null;
|
||
|
||
switch (cmd) {
|
||
case '/clear': {
|
||
if (session) {
|
||
if (activeProcesses.has(sessionId)) {
|
||
const entry = activeProcesses.get(sessionId);
|
||
killProcess(entry.pid);
|
||
if (entry.tailer) entry.tailer.stop();
|
||
activeProcesses.delete(sessionId);
|
||
cleanRunDir(sessionId);
|
||
}
|
||
session.messages = [];
|
||
session.claudeSessionId = null;
|
||
session.updated = new Date().toISOString();
|
||
saveSession(session);
|
||
wsSend(ws, { type: 'session_info', sessionId: session.id, messages: [], title: session.title });
|
||
}
|
||
wsSend(ws, { type: 'system_message', message: '会话已清除,上下文已重置。' });
|
||
break;
|
||
}
|
||
|
||
case '/model': {
|
||
const modelInput = parts[1];
|
||
if (!modelInput) {
|
||
const current = session?.model ? modelShortName(session.model) || session.model : 'opus (默认)';
|
||
wsSend(ws, { type: 'system_message', message: `当前模型: ${current}\n可选: opus, sonnet, haiku` });
|
||
} else {
|
||
const modelKey = modelInput.toLowerCase();
|
||
if (!MODEL_MAP[modelKey]) {
|
||
wsSend(ws, { type: 'system_message', message: `无效模型: ${modelInput}\n可选: opus, sonnet, haiku` });
|
||
} else {
|
||
const model = MODEL_MAP[modelKey];
|
||
if (session) {
|
||
session.model = model;
|
||
session.updated = new Date().toISOString();
|
||
saveSession(session);
|
||
}
|
||
wsSend(ws, { type: 'model_changed', model: modelKey });
|
||
wsSend(ws, { type: 'system_message', message: `模型已切换为: ${modelKey}` });
|
||
}
|
||
}
|
||
break;
|
||
}
|
||
|
||
case '/cost': {
|
||
const cost = session?.totalCost || 0;
|
||
wsSend(ws, { type: 'system_message', message: `当前会话累计费用: $${cost.toFixed(4)}` });
|
||
break;
|
||
}
|
||
|
||
case '/compact': {
|
||
if (!sessionId || !session) {
|
||
wsSend(ws, { type: 'system_message', message: '当前没有可压缩的会话。请先进入一个已进行过对话的会话后再执行 /compact。' });
|
||
break;
|
||
}
|
||
if (activeProcesses.has(sessionId)) {
|
||
wsSend(ws, { type: 'system_message', message: '当前会话正在处理中,请先等待完成或点击停止,再执行 /compact。' });
|
||
break;
|
||
}
|
||
if (!session.claudeSessionId) {
|
||
wsSend(ws, { type: 'system_message', message: '当前会话尚未建立 Claude 上下文,暂时无需压缩。' });
|
||
break;
|
||
}
|
||
|
||
wsSend(ws, { type: 'system_message', message: '正在执行 Claude 原生 /compact 压缩上下文,请稍候…' });
|
||
pendingSlashCommands.set(session.id, { kind: 'compact' });
|
||
handleMessage(ws, { text: '/compact', sessionId: session.id, mode: session.permissionMode || 'yolo' }, { hideInHistory: true });
|
||
break;
|
||
}
|
||
|
||
case '/mode': {
|
||
const modeInput = parts[1];
|
||
const VALID_MODES = ['default', 'plan', 'yolo'];
|
||
const MODE_DESC = { default: '默认(需权限审批,受限操作)', plan: 'Plan(需确认计划后执行)', yolo: 'YOLO(跳过所有权限检查)' };
|
||
if (!modeInput) {
|
||
const cur = session?.permissionMode || 'yolo';
|
||
wsSend(ws, { type: 'system_message', message: `当前模式: ${MODE_DESC[cur] || cur}\n可选: default, plan, yolo` });
|
||
} else if (VALID_MODES.includes(modeInput.toLowerCase())) {
|
||
const mode = modeInput.toLowerCase();
|
||
if (session) {
|
||
session.permissionMode = mode;
|
||
session.claudeSessionId = null;
|
||
session.updated = new Date().toISOString();
|
||
saveSession(session);
|
||
}
|
||
wsSend(ws, { type: 'system_message', message: `权限模式已切换为: ${MODE_DESC[mode]}` });
|
||
wsSend(ws, { type: 'mode_changed', mode });
|
||
} else {
|
||
wsSend(ws, { type: 'system_message', message: `无效模式: ${modeInput}\n可选: default, plan, yolo` });
|
||
}
|
||
break;
|
||
}
|
||
|
||
case '/help': {
|
||
wsSend(ws, {
|
||
type: 'system_message',
|
||
message: '可用指令:\n' +
|
||
'/clear — 清除当前会话(含上下文)\n' +
|
||
'/model [名称] — 查看/切换模型(opus, sonnet, haiku)\n' +
|
||
'/mode [模式] — 查看/切换权限模式(default, plan, yolo)\n' +
|
||
'/cost — 查看当前会话累计费用\n' +
|
||
'/compact — 执行 Claude 原生上下文压缩(保留压缩计划并可自动续跑)\n' +
|
||
'/help — 显示本帮助',
|
||
});
|
||
break;
|
||
}
|
||
|
||
default:
|
||
wsSend(ws, { type: 'system_message', message: `未知指令: ${cmd}\n输入 /help 查看可用指令` });
|
||
}
|
||
}
|
||
|
||
// === Session Handlers ===
|
||
function handleNewSession(ws) {
|
||
const id = crypto.randomUUID();
|
||
const session = {
|
||
id,
|
||
title: 'New Chat',
|
||
created: new Date().toISOString(),
|
||
updated: new Date().toISOString(),
|
||
claudeSessionId: null,
|
||
model: null,
|
||
permissionMode: 'yolo',
|
||
totalCost: 0,
|
||
messages: [],
|
||
};
|
||
saveSession(session);
|
||
wsSessionMap.set(ws, id);
|
||
wsSend(ws, { type: 'session_info', sessionId: id, messages: [], title: session.title, mode: session.permissionMode, model: null });
|
||
sendSessionList(ws);
|
||
}
|
||
|
||
function handleLoadSession(ws, sessionId) {
|
||
const session = loadSession(sessionId);
|
||
if (!session) {
|
||
return wsSend(ws, { type: 'error', message: 'Session not found' });
|
||
}
|
||
|
||
// Detach ws from any previous session's process
|
||
for (const [, entry] of activeProcesses) {
|
||
if (entry.ws === ws) entry.ws = null;
|
||
}
|
||
|
||
wsSessionMap.set(ws, sessionId);
|
||
|
||
// Read and clear unread flag
|
||
const hadUnread = !!session.hasUnread;
|
||
if (session.hasUnread) {
|
||
session.hasUnread = false;
|
||
saveSession(session);
|
||
}
|
||
|
||
wsSend(ws, {
|
||
type: 'session_info',
|
||
sessionId: session.id,
|
||
messages: session.messages,
|
||
title: session.title,
|
||
mode: session.permissionMode || 'yolo',
|
||
model: modelShortName(session.model),
|
||
hasUnread: hadUnread,
|
||
});
|
||
|
||
// Resume streaming if process is still active
|
||
if (activeProcesses.has(sessionId)) {
|
||
const entry = activeProcesses.get(sessionId);
|
||
entry.ws = ws;
|
||
entry.wsDisconnectTime = null; // clear disconnect marker
|
||
plog('INFO', 'ws_resume_attach', {
|
||
sessionId: sessionId.slice(0, 8),
|
||
pid: entry.pid,
|
||
responseLen: (entry.fullText || '').length,
|
||
});
|
||
wsSend(ws, {
|
||
type: 'resume_generating',
|
||
sessionId,
|
||
text: entry.fullText || '',
|
||
toolCalls: entry.toolCalls || [],
|
||
});
|
||
}
|
||
}
|
||
|
||
function handleDeleteSession(ws, sessionId) {
|
||
pendingSlashCommands.delete(sessionId);
|
||
pendingCompactRetries.delete(sessionId);
|
||
if (activeProcesses.has(sessionId)) {
|
||
const entry = activeProcesses.get(sessionId);
|
||
try { killProcess(entry.pid); } catch {}
|
||
if (entry.tailer) entry.tailer.stop();
|
||
activeProcesses.delete(sessionId);
|
||
if (entry.ws) wsSend(entry.ws, { type: 'done', sessionId });
|
||
}
|
||
cleanRunDir(sessionId);
|
||
try {
|
||
const p = sessionPath(sessionId);
|
||
if (fs.existsSync(p)) fs.unlinkSync(p);
|
||
sendSessionList(ws);
|
||
} catch {
|
||
wsSend(ws, { type: 'error', message: 'Failed to delete session' });
|
||
}
|
||
}
|
||
|
||
function handleRenameSession(ws, sessionId, title) {
|
||
if (!sessionId || !title) return;
|
||
const session = loadSession(sessionId);
|
||
if (session) {
|
||
session.title = String(title).slice(0, 100);
|
||
session.updated = new Date().toISOString();
|
||
saveSession(session);
|
||
sendSessionList(ws);
|
||
wsSend(ws, { type: 'session_renamed', sessionId, title: session.title });
|
||
}
|
||
}
|
||
|
||
function handleSetMode(ws, sessionId, mode) {
|
||
const VALID_MODES = ['default', 'plan', 'yolo'];
|
||
if (!mode || !VALID_MODES.includes(mode)) return;
|
||
if (sessionId) {
|
||
const session = loadSession(sessionId);
|
||
if (session) {
|
||
session.permissionMode = mode;
|
||
session.claudeSessionId = null;
|
||
session.updated = new Date().toISOString();
|
||
saveSession(session);
|
||
}
|
||
}
|
||
wsSend(ws, { type: 'mode_changed', mode });
|
||
}
|
||
|
||
function handleDisconnect(ws, wsId) {
|
||
const affectedSessions = [];
|
||
for (const [sid, entry] of activeProcesses) {
|
||
if (entry.ws === ws) {
|
||
entry.ws = null;
|
||
entry.wsDisconnectTime = new Date().toISOString();
|
||
affectedSessions.push({ sessionId: sid.slice(0, 8), pid: entry.pid });
|
||
}
|
||
}
|
||
wsSessionMap.delete(ws);
|
||
plog('INFO', 'ws_disconnect', { wsId, activeProcessesAffected: affectedSessions });
|
||
}
|
||
|
||
function handleAbort(ws) {
|
||
const sessionId = wsSessionMap.get(ws);
|
||
if (!sessionId) return;
|
||
const entry = activeProcesses.get(sessionId);
|
||
if (!entry) return;
|
||
|
||
plog('INFO', 'user_abort', { sessionId: sessionId.slice(0, 8), pid: entry.pid });
|
||
killProcess(entry.pid);
|
||
setTimeout(() => {
|
||
killProcess(entry.pid, true);
|
||
}, 3000);
|
||
// handleProcessComplete will be triggered by the PID monitor
|
||
}
|
||
|
||
// === Claude Message Handler ===
|
||
function handleMessage(ws, msg, options = {}) {
|
||
const { text, sessionId, mode } = msg;
|
||
const { hideInHistory = false } = options;
|
||
if (!text || !text.trim()) return;
|
||
|
||
const normalizedText = text.trim();
|
||
|
||
if (sessionId && activeProcesses.has(sessionId)) {
|
||
return wsSend(ws, { type: 'error', message: '正在处理中,请先点击停止按钮。' });
|
||
}
|
||
|
||
let session;
|
||
if (sessionId) session = loadSession(sessionId);
|
||
if (!session) {
|
||
const id = crypto.randomUUID();
|
||
session = {
|
||
id,
|
||
title: text.slice(0, 60).replace(/\n/g, ' '),
|
||
created: new Date().toISOString(),
|
||
updated: new Date().toISOString(),
|
||
claudeSessionId: null,
|
||
model: null,
|
||
permissionMode: mode || 'yolo',
|
||
totalCost: 0,
|
||
messages: [],
|
||
};
|
||
}
|
||
|
||
if (mode && ['default', 'plan', 'yolo'].includes(mode)) {
|
||
session.permissionMode = mode;
|
||
}
|
||
|
||
if (!hideInHistory && normalizedText !== '/compact' && session.claudeSessionId) {
|
||
pendingCompactRetries.set(session.id, { text: normalizedText, mode: session.permissionMode || 'yolo', reason: 'normal' });
|
||
}
|
||
|
||
if (session.title === 'New Chat' || session.title === 'Untitled') {
|
||
session.title = text.slice(0, 60).replace(/\n/g, ' ');
|
||
}
|
||
|
||
if (!hideInHistory) {
|
||
session.messages.push({ role: 'user', content: text, timestamp: new Date().toISOString() });
|
||
}
|
||
session.updated = new Date().toISOString();
|
||
saveSession(session);
|
||
|
||
const currentSessionId = session.id;
|
||
|
||
for (const [, entry] of activeProcesses) {
|
||
if (entry.ws === ws) entry.ws = null;
|
||
}
|
||
wsSessionMap.set(ws, currentSessionId);
|
||
|
||
if (!sessionId) {
|
||
wsSend(ws, { type: 'session_info', sessionId: currentSessionId, messages: session.messages, title: session.title, mode: session.permissionMode || 'yolo', model: modelShortName(session.model) });
|
||
}
|
||
sendSessionList(ws);
|
||
|
||
// Build claude args
|
||
const args = ['-p', '--output-format', 'stream-json', '--verbose'];
|
||
const permMode = session.permissionMode || 'yolo';
|
||
switch (permMode) {
|
||
case 'yolo':
|
||
args.push('--dangerously-skip-permissions');
|
||
break;
|
||
case 'plan':
|
||
args.push('--permission-mode', 'plan');
|
||
break;
|
||
case 'default':
|
||
break;
|
||
}
|
||
if (session.claudeSessionId) {
|
||
args.push('--resume', session.claudeSessionId);
|
||
}
|
||
if (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);
|
||
fs.mkdirSync(dir, { recursive: true });
|
||
|
||
const inputPath = path.join(dir, 'input.txt');
|
||
const outputPath = path.join(dir, 'output.jsonl');
|
||
const errorPath = path.join(dir, 'error.log');
|
||
|
||
fs.writeFileSync(inputPath, text);
|
||
|
||
const inputFd = fs.openSync(inputPath, 'r');
|
||
const outputFd = fs.openSync(outputPath, 'w');
|
||
const errorFd = fs.openSync(errorPath, 'w');
|
||
|
||
let proc;
|
||
try {
|
||
proc = spawn(CLAUDE_PATH, args, {
|
||
env,
|
||
cwd: process.env.HOME || process.env.USERPROFILE || process.cwd(),
|
||
stdio: [inputFd, outputFd, errorFd],
|
||
detached: !IS_WIN,
|
||
windowsHide: true,
|
||
});
|
||
} catch (err) {
|
||
fs.closeSync(inputFd);
|
||
fs.closeSync(outputFd);
|
||
fs.closeSync(errorFd);
|
||
cleanRunDir(currentSessionId);
|
||
plog('ERROR', 'process_spawn_fail', { sessionId: currentSessionId.slice(0, 8), error: err.message });
|
||
return wsSend(ws, { type: 'error', message: `启动 Claude 失败: ${err.message}` });
|
||
}
|
||
|
||
fs.closeSync(inputFd);
|
||
fs.closeSync(outputFd);
|
||
fs.closeSync(errorFd);
|
||
|
||
fs.writeFileSync(path.join(dir, 'pid'), String(proc.pid));
|
||
proc.unref(); // Process survives Node.js exit
|
||
|
||
plog('INFO', 'process_spawn', {
|
||
sessionId: currentSessionId.slice(0, 8),
|
||
pid: proc.pid,
|
||
mode: permMode,
|
||
model: session.model || 'default',
|
||
resume: !!session.claudeSessionId,
|
||
args: args.join(' '),
|
||
});
|
||
|
||
// Fast exit detection (while Node.js is running)
|
||
proc.on('exit', (code, signal) => {
|
||
plog('INFO', 'process_exit_event', {
|
||
sessionId: currentSessionId.slice(0, 8),
|
||
pid: proc.pid,
|
||
exitCode: code,
|
||
signal: signal,
|
||
});
|
||
// Small delay to ensure file is fully flushed
|
||
setTimeout(() => handleProcessComplete(currentSessionId, code, signal), 300);
|
||
});
|
||
|
||
const entry = { pid: proc.pid, ws, fullText: '', toolCalls: [], lastCost: null, tailer: null };
|
||
activeProcesses.set(currentSessionId, entry);
|
||
|
||
// Tail the output file for real-time streaming
|
||
entry.tailer = new FileTailer(outputPath, (line) => {
|
||
try {
|
||
const event = JSON.parse(line);
|
||
processClaudeEvent(entry, event, currentSessionId);
|
||
} catch {}
|
||
});
|
||
entry.tailer.start();
|
||
}
|
||
|
||
// === Claude Event Processing ===
|
||
function processClaudeEvent(entry, event, sessionId) {
|
||
if (!event || !event.type) return;
|
||
|
||
switch (event.type) {
|
||
case 'system':
|
||
if (event.session_id) {
|
||
const session = loadSession(sessionId);
|
||
if (session) {
|
||
session.claudeSessionId = event.session_id;
|
||
saveSession(session);
|
||
}
|
||
}
|
||
break;
|
||
|
||
case 'assistant': {
|
||
const content = event.message?.content;
|
||
if (!Array.isArray(content)) break;
|
||
|
||
for (const block of content) {
|
||
if (block.type === 'text' && block.text) {
|
||
entry.fullText += block.text;
|
||
wsSend(entry.ws, { type: 'text_delta', text: block.text });
|
||
} else if (block.type === 'tool_use') {
|
||
const toolInput = sanitizeToolInput(block.name, block.input);
|
||
const tc = { name: block.name, id: block.id, input: toolInput, done: false };
|
||
entry.toolCalls.push(tc);
|
||
wsSend(entry.ws, { type: 'tool_start', name: block.name, toolUseId: block.id, input: tc.input });
|
||
} else if (block.type === 'tool_result') {
|
||
const resultText = typeof block.content === 'string'
|
||
? block.content
|
||
: Array.isArray(block.content)
|
||
? block.content.map(c => c.text || '').join('\n')
|
||
: JSON.stringify(block.content);
|
||
const tc = entry.toolCalls.find(t => t.id === block.tool_use_id);
|
||
if (tc) { tc.done = true; tc.result = resultText.slice(0, 2000); }
|
||
wsSend(entry.ws, { type: 'tool_end', toolUseId: block.tool_use_id, result: resultText.slice(0, 2000) });
|
||
}
|
||
}
|
||
|
||
if (event.session_id) {
|
||
const session = loadSession(sessionId);
|
||
if (session && !session.claudeSessionId) {
|
||
session.claudeSessionId = event.session_id;
|
||
saveSession(session);
|
||
}
|
||
}
|
||
break;
|
||
}
|
||
|
||
case 'result': {
|
||
const session = loadSession(sessionId);
|
||
if (session) {
|
||
if (event.session_id) session.claudeSessionId = event.session_id;
|
||
if (event.total_cost_usd) session.totalCost = (session.totalCost || 0) + event.total_cost_usd;
|
||
saveSession(session);
|
||
}
|
||
entry.lastCost = event.total_cost_usd || null;
|
||
if (entry.ws && event.total_cost_usd !== undefined) {
|
||
wsSend(entry.ws, { type: 'cost', costUsd: session?.totalCost || 0 });
|
||
}
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
function truncateObj(obj, maxLen) {
|
||
const s = JSON.stringify(obj);
|
||
if (s.length <= maxLen) return obj;
|
||
return s.slice(0, maxLen) + '...';
|
||
}
|
||
|
||
function safeJsonParse(input) {
|
||
if (input === null || input === undefined) return input;
|
||
if (typeof input !== 'string') return input;
|
||
const trimmed = input.trim();
|
||
if (!trimmed) return input;
|
||
if (!((trimmed.startsWith('{') && trimmed.endsWith('}')) || (trimmed.startsWith('[') && trimmed.endsWith(']')))) {
|
||
return input;
|
||
}
|
||
try {
|
||
return JSON.parse(trimmed);
|
||
} catch {
|
||
return input;
|
||
}
|
||
}
|
||
|
||
function sanitizeToolInput(toolName, input) {
|
||
const parsed = safeJsonParse(input);
|
||
if (toolName === 'AskUserQuestion') {
|
||
return parsed;
|
||
}
|
||
return truncateObj(parsed, 500);
|
||
}
|
||
|
||
// === Startup ===
|
||
recoverProcesses();
|
||
|
||
// Periodic heartbeat: log active processes status every 60s
|
||
setInterval(() => {
|
||
if (activeProcesses.size === 0) return;
|
||
const procs = [];
|
||
for (const [sid, entry] of activeProcesses) {
|
||
const alive = isProcessRunning(entry.pid);
|
||
procs.push({
|
||
sessionId: sid.slice(0, 8),
|
||
pid: entry.pid,
|
||
alive,
|
||
wsConnected: !!entry.ws,
|
||
wsDisconnectTime: entry.wsDisconnectTime || null,
|
||
responseLen: (entry.fullText || '').length,
|
||
});
|
||
}
|
||
plog('INFO', 'heartbeat', { activeCount: procs.length, wsClients: wss.clients.size, processes: procs });
|
||
}, 60000);
|
||
|
||
plog('INFO', 'server_start', { port: PORT });
|
||
|
||
server.listen(PORT, '127.0.0.1', () => {
|
||
console.log(`CC-Web server listening on 127.0.0.1:${PORT}`);
|
||
});
|