Feature: Claude Assistent Chat in TaskMate

Neuer Tab "Assistent" mit interaktiver Claude Code Session:
- Chat-UI mit Session-Verwaltung (History, neue/alte Sessions)
- Claude CLI als Child-Process auf dem Host (interaktiv, mit Rueckfragen)
- Streaming-Output per Socket.io
- Nur fuer autorisierte User (Hendrik, Monami)
- 30 Min Inaktivitaets-Timeout
- Task-Uebergabe: Button im Task-Modal sendet Aufgabe an Assistenten
- Chat-Verlauf wird in DB gespeichert

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Dieser Commit ist enthalten in:
Server Deploy
2026-03-19 22:04:49 +01:00
Ursprung 71f59b276b
Commit c4304a4f88
11 geänderte Dateien mit 1574 neuen und 5 gelöschten Zeilen

464
backend/routes/assistant.js Normale Datei
Datei anzeigen

@@ -0,0 +1,464 @@
/**
* TASKMATE - Claude Assistant Routes
* ====================================
* REST-Endpunkte und Session-Manager fuer den Claude-Assistenten
*/
const express = require('express');
const router = express.Router();
const { spawn } = require('child_process');
const { getDb } = require('../database');
const logger = require('../utils/logger');
// ============================================================================
// SESSION MANAGER
// ============================================================================
// Aktive Prozesse: userId -> { process, sessionId, timeout, outputBuffer, saveTimer }
const activeSessions = new Map();
const SESSION_TIMEOUT_MS = 30 * 60 * 1000; // 30 Minuten
const DB_SAVE_INTERVAL_MS = 2000; // Output alle 2 Sekunden in DB speichern
/**
* Berechtigungs-Middleware: Nur Hendrik und Monami duerfen den Assistenten nutzen
*/
function requireAssistantAccess(req, res, next) {
const username = (req.user.username || '').toLowerCase();
if (username !== 'hendrik' && username !== 'monami') {
return res.status(403).json({ error: 'Kein Zugriff auf den Assistenten' });
}
next();
}
/**
* Socket-Berechtigung pruefen
*/
function checkSocketAccess(socket) {
const username = (socket.user.username || '').toLowerCase();
return username === 'hendrik' || username === 'monami';
}
/**
* Timeout zuruecksetzen fuer eine Session
*/
function resetTimeout(userId) {
const session = activeSessions.get(userId);
if (!session) return;
if (session.timeout) {
clearTimeout(session.timeout);
}
session.timeout = setTimeout(() => {
logger.info(`[Assistant] Session-Timeout fuer User ${userId} - Prozess wird beendet`);
stopSession(userId);
}, SESSION_TIMEOUT_MS);
}
/**
* Gepufferten Output in DB speichern (Debounce)
*/
function flushOutputBuffer(userId) {
const session = activeSessions.get(userId);
if (!session || !session.outputBuffer || session.outputBuffer.length === 0) return;
try {
const db = getDb();
const content = session.outputBuffer.join('');
session.outputBuffer = [];
db.prepare(`
INSERT INTO assistant_messages (session_id, role, content)
VALUES (?, 'assistant', ?)
`).run(session.sessionId, content);
} catch (err) {
logger.error(`[Assistant] Fehler beim Speichern des Outputs: ${err.message}`);
}
}
/**
* Claude-Prozess starten
*/
function startSession(userId, sessionId, socket) {
if (activeSessions.has(userId)) {
throw new Error('Es laeuft bereits eine aktive Session');
}
const proc = spawn('claude', [], {
cwd: '/home/claude-dev/TaskMate',
env: { ...process.env, HOME: '/home/claude-dev' },
stdio: ['pipe', 'pipe', 'pipe']
});
const sessionData = {
process: proc,
sessionId,
timeout: null,
outputBuffer: [],
saveTimer: null
};
activeSessions.set(userId, sessionData);
resetTimeout(userId);
logger.info(`[Assistant] Session ${sessionId} gestartet fuer User ${userId} (PID: ${proc.pid})`);
// stdout verarbeiten
proc.stdout.on('data', (data) => {
const text = data.toString();
sessionData.outputBuffer.push(text);
// An Socket senden
if (socket && socket.connected) {
socket.emit('assistant:output', { sessionId, content: text, stream: 'stdout' });
}
// Debounced DB-Save
if (!sessionData.saveTimer) {
sessionData.saveTimer = setTimeout(() => {
sessionData.saveTimer = null;
flushOutputBuffer(userId);
}, DB_SAVE_INTERVAL_MS);
}
});
// stderr verarbeiten (gleich wie stdout)
proc.stderr.on('data', (data) => {
const text = data.toString();
sessionData.outputBuffer.push(text);
if (socket && socket.connected) {
socket.emit('assistant:output', { sessionId, content: text, stream: 'stderr' });
}
if (!sessionData.saveTimer) {
sessionData.saveTimer = setTimeout(() => {
sessionData.saveTimer = null;
flushOutputBuffer(userId);
}, DB_SAVE_INTERVAL_MS);
}
});
// Prozess beendet
proc.on('close', (code) => {
logger.info(`[Assistant] Prozess beendet fuer User ${userId} (Code: ${code})`);
// Restlichen Buffer speichern
flushOutputBuffer(userId);
// Aufraumen
const session = activeSessions.get(userId);
if (session) {
if (session.timeout) clearTimeout(session.timeout);
if (session.saveTimer) clearTimeout(session.saveTimer);
activeSessions.delete(userId);
}
// Session in DB als beendet markieren
try {
const db = getDb();
db.prepare(`
UPDATE assistant_sessions SET status = 'ended', ended_at = CURRENT_TIMESTAMP
WHERE id = ?
`).run(sessionId);
} catch (err) {
logger.error(`[Assistant] Fehler beim Beenden der Session: ${err.message}`);
}
// Socket benachrichtigen
if (socket && socket.connected) {
socket.emit('assistant:status', { sessionId, status: 'ended', code });
}
});
proc.on('error', (err) => {
logger.error(`[Assistant] Prozess-Fehler fuer User ${userId}: ${err.message}`);
const session = activeSessions.get(userId);
if (session) {
if (session.timeout) clearTimeout(session.timeout);
if (session.saveTimer) clearTimeout(session.saveTimer);
activeSessions.delete(userId);
}
if (socket && socket.connected) {
socket.emit('assistant:status', { sessionId, status: 'error', error: err.message });
}
});
return proc;
}
/**
* Nachricht an Claude-Prozess senden
*/
function sendMessage(userId, message) {
const session = activeSessions.get(userId);
if (!session) {
throw new Error('Keine aktive Session');
}
// In DB speichern
try {
const db = getDb();
db.prepare(`
INSERT INTO assistant_messages (session_id, role, content)
VALUES (?, 'user', ?)
`).run(session.sessionId, message);
} catch (err) {
logger.error(`[Assistant] Fehler beim Speichern der Nachricht: ${err.message}`);
}
// An Prozess senden
session.process.stdin.write(message + '\n');
resetTimeout(userId);
}
/**
* Session beenden
*/
function stopSession(userId) {
const session = activeSessions.get(userId);
if (!session) return;
logger.info(`[Assistant] Session ${session.sessionId} wird beendet fuer User ${userId}`);
// Restlichen Buffer speichern
flushOutputBuffer(userId);
// Timer aufraemen
if (session.timeout) clearTimeout(session.timeout);
if (session.saveTimer) clearTimeout(session.saveTimer);
// Prozess beenden
try {
session.process.kill('SIGTERM');
} catch (err) {
logger.error(`[Assistant] Fehler beim Killen des Prozesses: ${err.message}`);
}
activeSessions.delete(userId);
// DB aktualisieren
try {
const db = getDb();
db.prepare(`
UPDATE assistant_sessions SET status = 'ended', ended_at = CURRENT_TIMESTAMP
WHERE id = ?
`).run(session.sessionId);
} catch (err) {
logger.error(`[Assistant] Fehler beim DB-Update: ${err.message}`);
}
}
// ============================================================================
// REST ENDPOINTS
// ============================================================================
// Alle Routen brauchen Assistant-Zugriff
router.use(requireAssistantAccess);
/**
* GET /sessions - Alle Sessions des Users
*/
router.get('/sessions', (req, res) => {
try {
const db = getDb();
const sessions = db.prepare(`
SELECT id, user_id, title, status, task_context, created_at, ended_at
FROM assistant_sessions
WHERE user_id = ?
ORDER BY created_at DESC
`).all(req.user.id);
res.json(sessions);
} catch (err) {
logger.error(`[Assistant] Fehler beim Laden der Sessions: ${err.message}`);
res.status(500).json({ error: 'Fehler beim Laden der Sessions' });
}
});
/**
* GET /sessions/:id/messages - Alle Nachrichten einer Session
*/
router.get('/sessions/:id/messages', (req, res) => {
try {
const db = getDb();
const sessionId = parseInt(req.params.id, 10);
// Pruefen ob Session dem User gehoert
const session = db.prepare(`
SELECT id FROM assistant_sessions WHERE id = ? AND user_id = ?
`).get(sessionId, req.user.id);
if (!session) {
return res.status(404).json({ error: 'Session nicht gefunden' });
}
const messages = db.prepare(`
SELECT id, session_id, role, content, created_at
FROM assistant_messages
WHERE session_id = ?
ORDER BY created_at ASC
`).all(sessionId);
res.json(messages);
} catch (err) {
logger.error(`[Assistant] Fehler beim Laden der Nachrichten: ${err.message}`);
res.status(500).json({ error: 'Fehler beim Laden der Nachrichten' });
}
});
/**
* POST /sessions - Neue Session erstellen
*/
router.post('/sessions', (req, res) => {
try {
const db = getDb();
const { title, taskContext } = req.body;
const result = db.prepare(`
INSERT INTO assistant_sessions (user_id, title, task_context)
VALUES (?, ?, ?)
`).run(req.user.id, title || 'Neue Session', taskContext || null);
const session = db.prepare(`
SELECT id, user_id, title, status, task_context, created_at, ended_at
FROM assistant_sessions WHERE id = ?
`).get(result.lastInsertRowid);
logger.info(`[Assistant] Neue Session ${session.id} erstellt von ${req.user.username}`);
res.status(201).json(session);
} catch (err) {
logger.error(`[Assistant] Fehler beim Erstellen der Session: ${err.message}`);
res.status(500).json({ error: 'Fehler beim Erstellen der Session' });
}
});
/**
* DELETE /sessions/:id - Session loeschen
*/
router.delete('/sessions/:id', (req, res) => {
try {
const db = getDb();
const sessionId = parseInt(req.params.id, 10);
// Pruefen ob Session dem User gehoert
const session = db.prepare(`
SELECT id, user_id FROM assistant_sessions WHERE id = ? AND user_id = ?
`).get(sessionId, req.user.id);
if (!session) {
return res.status(404).json({ error: 'Session nicht gefunden' });
}
// Wenn aktiver Prozess laeuft, zuerst beenden
const activeSession = activeSessions.get(req.user.id);
if (activeSession && activeSession.sessionId === sessionId) {
stopSession(req.user.id);
}
// Session und zugehoerige Nachrichten loeschen (CASCADE)
db.prepare('DELETE FROM assistant_sessions WHERE id = ?').run(sessionId);
logger.info(`[Assistant] Session ${sessionId} geloescht von ${req.user.username}`);
res.json({ success: true });
} catch (err) {
logger.error(`[Assistant] Fehler beim Loeschen der Session: ${err.message}`);
res.status(500).json({ error: 'Fehler beim Loeschen der Session' });
}
});
// ============================================================================
// SOCKET EVENT HANDLER (exportiert fuer server.js)
// ============================================================================
/**
* Socket-Events registrieren
*/
function registerSocketEvents(socket) {
const userId = socket.user.id;
// assistant:start - Session starten
socket.on('assistant:start', (data) => {
try {
if (!checkSocketAccess(socket)) {
socket.emit('assistant:status', { status: 'error', error: 'Kein Zugriff' });
return;
}
const sessionId = data && data.sessionId;
if (!sessionId) {
socket.emit('assistant:status', { status: 'error', error: 'Keine Session-ID angegeben' });
return;
}
// Pruefen ob Session existiert und dem User gehoert
const db = getDb();
const session = db.prepare(`
SELECT id, user_id, status FROM assistant_sessions WHERE id = ? AND user_id = ?
`).get(sessionId, userId);
if (!session) {
socket.emit('assistant:status', { status: 'error', error: 'Session nicht gefunden' });
return;
}
startSession(userId, sessionId, socket);
socket.emit('assistant:status', { sessionId, status: 'running' });
} catch (err) {
logger.error(`[Assistant] Fehler beim Starten: ${err.message}`);
socket.emit('assistant:status', { status: 'error', error: err.message });
}
});
// assistant:message - Nachricht senden
socket.on('assistant:message', (data) => {
try {
if (!checkSocketAccess(socket)) {
socket.emit('assistant:status', { status: 'error', error: 'Kein Zugriff' });
return;
}
const message = data && data.message;
if (!message || typeof message !== 'string') {
socket.emit('assistant:status', { status: 'error', error: 'Keine Nachricht angegeben' });
return;
}
sendMessage(userId, message);
} catch (err) {
logger.error(`[Assistant] Fehler beim Senden: ${err.message}`);
socket.emit('assistant:status', { status: 'error', error: err.message });
}
});
// assistant:stop - Session beenden
socket.on('assistant:stop', () => {
try {
if (!checkSocketAccess(socket)) {
socket.emit('assistant:status', { status: 'error', error: 'Kein Zugriff' });
return;
}
stopSession(userId);
socket.emit('assistant:status', { status: 'stopped' });
} catch (err) {
logger.error(`[Assistant] Fehler beim Stoppen: ${err.message}`);
socket.emit('assistant:status', { status: 'error', error: err.message });
}
});
// Bei Disconnect: aktive Session beenden
socket.on('disconnect', () => {
if (activeSessions.has(userId)) {
logger.info(`[Assistant] Socket disconnect - Session wird beendet fuer User ${userId}`);
stopSession(userId);
}
});
}
module.exports = router;
module.exports.registerSocketEvents = registerSocketEvents;
module.exports.stopSession = stopSession;