const express = require('express'); const http = require('http'); const { Server } = require('socket.io'); const cors = require('cors'); const Database = require('better-sqlite3'); const { claude, ConsoleLogger, LogLevel } = require('@instantlyeasy/claude-code-sdk-ts'); const path = require('path'); const { exec } = require('child_process'); // 补充 PATH const cliDir = 'C:\\Users\\MerCry\\AppData\\Roaming\\npm'; process.env.PATH = `${cliDir};${process.env.PATH || ''}`; // 启动时自检 exec('claude --version', { env: process.env }, (error, stdout, stderr) => { if (error) { console.error('[ERROR] 无法在 Node 进程中执行 claude CLI:', error.message); } else { console.log('[INFO] claude CLI 检测通过:', (stdout || stderr || '').trim()); } }); const app = express(); const server = http.createServer(app); const io = new Server(server, { cors: { origin: "*" } }); app.use(cors()); app.use(express.json()); // 数据库初始化 const dbPath = path.resolve(__dirname, '..', 'claude_web.db'); const db = new Database(dbPath); db.exec(` CREATE TABLE IF NOT EXISTS tabs ( id TEXT PRIMARY KEY, name TEXT, layout_config TEXT ); CREATE TABLE IF NOT EXISTS sessions ( id TEXT PRIMARY KEY, name TEXT, tab_id TEXT, mode TEXT DEFAULT 'ask', cwd TEXT, allowed_tools TEXT, denied_tools TEXT, claude_session_id TEXT, layout_config TEXT ); CREATE TABLE IF NOT EXISTS messages ( id INTEGER PRIMARY KEY AUTOINCREMENT, session_id TEXT, role TEXT, content TEXT, type TEXT DEFAULT 'text', metadata TEXT, timestamp DATETIME DEFAULT CURRENT_TIMESTAMP ); `); const activeAborts = new Map(); // 辅助函数:保存全量状态 function saveState(tabs, sessions) { const transaction = db.transaction(() => { db.prepare('DELETE FROM tabs').run(); db.prepare('DELETE FROM sessions').run(); for (const tab of tabs) { db.prepare('INSERT INTO tabs (id, name, layout_config) VALUES (?, ?, ?)') .run(tab.id, tab.name, JSON.stringify(tab.layoutConfig || {})); for (const pane of tab.panes) { const session = sessions[pane.i] || { mode: 'ask', cwd: '', allowedTools: [], deniedTools: [] }; db.prepare('INSERT INTO sessions (id, name, tab_id, mode, cwd, allowed_tools, denied_tools, claude_session_id, layout_config) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)') .run( pane.i, pane.i, tab.id, session.mode, session.cwd, JSON.stringify(session.allowedTools || []), JSON.stringify(session.deniedTools || []), session.claudeSessionId || null, JSON.stringify(pane) ); } } }); transaction(); } io.on('connection', (socket) => { socket.on('join_session', (sessionId) => { socket.join(sessionId); console.log(`[Socket] Client ${socket.id} joined session ${sessionId}`); }); socket.on('save_state', ({ tabs, sessions }) => { try { saveState(tabs, sessions); } catch (err) { console.error('Failed to save state:', err); } }); socket.on('stop_session', ({ sessionId }) => { const controller = activeAborts.get(sessionId); if (controller) { controller.abort(); activeAborts.delete(sessionId); console.log(`Session ${sessionId} stop requested`); } }); socket.on('stop_all', () => { for (const [sessionId, controller] of activeAborts.entries()) { controller.abort(); } activeAborts.clear(); console.log('All sessions stop requested'); }); socket.on('close_session', ({ sessionId }) => { const controller = activeAborts.get(sessionId); if (controller) { controller.abort(); activeAborts.delete(sessionId); } socket.leave(sessionId); }); socket.on('send_message', async ({ sessionId, prompt, mode, cwd, allowedTools, deniedTools }) => { try { console.log('[send_message] received', { sessionId, mode, cwd, promptPreview: String(prompt).slice(0, 50) }); if (activeAborts.has(sessionId)) { activeAborts.get(sessionId).abort(); } // 记录用户消息 db.prepare('INSERT INTO messages (session_id, role, content) VALUES (?, ?, ?)').run(sessionId, 'user', prompt); const abortController = new AbortController(); activeAborts.set(sessionId, abortController); // 从 DB 获取已有的 claude_session_id 实现 resume const sessionData = db.prepare('SELECT claude_session_id FROM sessions WHERE id = ?').get(sessionId); const claudeSessionId = sessionData?.claude_session_id; const sdkLogger = new ConsoleLogger(LogLevel.INFO); let queryBuilder = claude() .withLogger(sdkLogger) .withSignal(abortController.signal) .inDirectory(cwd || process.cwd()); if (claudeSessionId) { queryBuilder = queryBuilder.withSessionId(claudeSessionId); } if (mode === 'plan') { queryBuilder = queryBuilder.denyTools(['*']); } else if (mode === 'auto') { queryBuilder = queryBuilder.skipPermissions(); } const effectiveAllowedTools = (allowedTools && allowedTools.length > 0) ? allowedTools : ['Read', 'Bash', 'Write', 'Grep', 'Glob']; queryBuilder = queryBuilder.allowTools(...effectiveAllowedTools); if (deniedTools && deniedTools.length > 0) { queryBuilder = queryBuilder.denyTools(...deniedTools); } const parser = queryBuilder.query(prompt); let fullAssistantContent = ''; let gotAnyMessage = false; const watchdog = setTimeout(() => { if (!gotAnyMessage) { io.to(sessionId).emit('error', { sessionId, error: 'Claude 响应超时,请检查本地 CLI 状态。' }); abortController.abort(); } }, 30000); // 获取并持久化真实 Claude Session ID parser.getSessionId().then(id => { if (id && id !== claudeSessionId) { db.prepare('UPDATE sessions SET claude_session_id = ? WHERE id = ?').run(id, sessionId); console.log(`[Session ${sessionId}] Updated Claude Session ID: ${id}`); } }).catch(() => {}); await parser.stream(async (message) => { gotAnyMessage = true; clearTimeout(watchdog); // 转发所有原始消息给前端,以便前端进行复杂渲染(thinking, tool_use 等) io.to(sessionId).emit('claude_event', { sessionId, message }); // 提取纯文本用于简单兼容和拼接 const text = extractTextFromMessage(message); if (text) { fullAssistantContent += text; io.to(sessionId).emit('text', { sessionId, token: text }); } }); // 保存完整的助手回复到数据库 if (fullAssistantContent) { db.prepare('INSERT INTO messages (session_id, role, content) VALUES (?, ?, ?)').run(sessionId, 'assistant', fullAssistantContent); } clearTimeout(watchdog); io.to(sessionId).emit('done', { sessionId }); activeAborts.delete(sessionId); } catch (err) { if (err.name === 'AbortError') { console.log(`[Session ${sessionId}] Aborted`); } else { console.error(`[Session ${sessionId}] Error:`, err); io.to(sessionId).emit('error', { sessionId, error: err.message }); } activeAborts.delete(sessionId); } }); }); function extractTextFromMessage(message) { if (typeof message === 'string') return message; if (!message || typeof message !== 'object') return null; if (message.type === 'text' && typeof message.text === 'string') return message.text; if (message.delta && typeof message.delta.text === 'string') return message.delta.text; if (Array.isArray(message.content)) { return message.content.map(p => p.text || (p.delta ? p.delta.text : '')).join(''); } return null; } // REST API app.get('/api/state', (req, res) => { try { const tabs = db.prepare('SELECT * FROM tabs').all(); const sessions = db.prepare('SELECT * FROM sessions').all(); const resultTabs = tabs.map(t => { const tabPanes = sessions .filter(s => s.tab_id === t.id) .map(s => JSON.parse(s.layout_config)); return { id: t.id, name: t.name, panes: tabPanes, layoutConfig: JSON.parse(t.layout_config || '{}') }; }); const resultSessions = {}; sessions.forEach(s => { const messages = db.prepare('SELECT role, content, type, metadata, timestamp FROM messages WHERE session_id = ? ORDER BY timestamp ASC').all(s.id); resultSessions[s.id] = { mode: s.mode, cwd: s.cwd, allowedTools: JSON.parse(s.allowed_tools || '[]'), deniedTools: JSON.parse(s.denied_tools || '[]'), claudeSessionId: s.claude_session_id, messages, status: 'idle' }; }); res.json({ tabs: resultTabs, sessions: resultSessions }); } catch (err) { res.status(500).json({ error: err.message }); } }); app.get('/api/config', (req, res) => { res.json({ status: 'ok', tools: ['Read', 'Edit', 'Write', 'Bash', 'Grep', 'Glob', 'WebSearch'] }); }); app.get('/api/logs/search', (req, res) => { const { sessionId, query: q } = req.query; try { let sql = 'SELECT * FROM messages WHERE 1=1'; const params = []; if (sessionId) { sql += ' AND session_id = ?'; params.push(sessionId); } if (q) { sql += ' AND content LIKE ?'; params.push(`%${q}%`); } sql += ' ORDER BY timestamp DESC'; res.json(db.prepare(sql).all(...params)); } catch (err) { res.status(500).json({ error: err.message }); } }); app.get('/api/logs/export', (req, res) => { const { sessionId, format } = req.query; try { let sql = 'SELECT * FROM messages'; if (sessionId) sql += ' WHERE session_id = ?'; sql += ' ORDER BY timestamp ASC'; const logs = db.prepare(sql).all(sessionId ? [sessionId] : []); if (format === 'markdown') { let md = `# Claude Web Logs\n\n`; logs.forEach(l => { md += `### ${l.role.toUpperCase()} (${l.timestamp})\n${l.content}\n\n---\n\n`; }); res.setHeader('Content-Type', 'text/markdown'); return res.send(md); } res.json(logs); } catch (err) { res.status(500).json({ error: err.message }); } }); const PORT = 3001; server.listen(PORT, () => console.log(`Backend running on http://localhost:${PORT}`));