claude-web/server/index.js

326 lines
10 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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}`));