claude-web/server/index.js

326 lines
10 KiB
JavaScript
Raw Normal View History

2026-02-23 02:23:38 +00:00
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}`));