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:
@@ -753,6 +753,32 @@ function createTables() {
|
||||
END
|
||||
`);
|
||||
|
||||
// Assistant Sessions (Claude-Assistent)
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS assistant_sessions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
title TEXT DEFAULT 'Neue Session',
|
||||
status TEXT DEFAULT 'active',
|
||||
task_context TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
ended_at DATETIME,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
)
|
||||
`);
|
||||
|
||||
// Assistant Messages
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS assistant_messages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
session_id INTEGER NOT NULL,
|
||||
role TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (session_id) REFERENCES assistant_sessions(id) ON DELETE CASCADE
|
||||
)
|
||||
`);
|
||||
|
||||
// Indizes für Performance
|
||||
db.exec(`
|
||||
CREATE INDEX IF NOT EXISTS idx_tasks_project ON tasks(project_id);
|
||||
@@ -785,6 +811,9 @@ function createTables() {
|
||||
CREATE INDEX IF NOT EXISTS idx_details_type ON contact_details(type);
|
||||
CREATE INDEX IF NOT EXISTS idx_interactions_contact ON contact_interactions(contact_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_interactions_date ON contact_interactions(interaction_date);
|
||||
-- Assistant-Indizes
|
||||
CREATE INDEX IF NOT EXISTS idx_assistant_messages_session ON assistant_messages(session_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_assistant_sessions_user ON assistant_sessions(user_id);
|
||||
`);
|
||||
|
||||
logger.info('Datenbank-Tabellen erstellt');
|
||||
|
||||
464
backend/routes/assistant.js
Normale Datei
464
backend/routes/assistant.js
Normale Datei
@@ -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;
|
||||
@@ -48,6 +48,7 @@ const giteaRoutes = require('./routes/gitea');
|
||||
const knowledgeRoutes = require('./routes/knowledge');
|
||||
const codingRoutes = require('./routes/coding');
|
||||
const reminderRoutes = require('./routes/reminders');
|
||||
const assistantRoutes = require('./routes/assistant');
|
||||
|
||||
// Express App erstellen
|
||||
const app = express();
|
||||
@@ -178,6 +179,9 @@ app.use('/api/reminders', authenticateToken, csrfProtection, reminderRoutes);
|
||||
// Contacts-Routes (Kontakte)
|
||||
app.use('/api/contacts', authenticateToken, csrfProtection, require('./routes/contacts'));
|
||||
|
||||
// Assistant-Routes (Claude-Assistent)
|
||||
app.use('/api/assistant', authenticateToken, csrfProtection, assistantRoutes);
|
||||
|
||||
// =============================================================================
|
||||
// SOCKET.IO
|
||||
// =============================================================================
|
||||
@@ -241,6 +245,9 @@ io.on('connection', (socket) => {
|
||||
}))
|
||||
});
|
||||
});
|
||||
|
||||
// Assistant Socket-Events registrieren
|
||||
assistantRoutes.registerSocketEvents(socket);
|
||||
});
|
||||
|
||||
// Socket.io Instance global verfügbar machen für Routes
|
||||
@@ -328,11 +335,16 @@ database.initialize()
|
||||
// Graceful Shutdown
|
||||
process.on('SIGTERM', () => {
|
||||
logger.info('SIGTERM empfangen, fahre herunter...');
|
||||
|
||||
|
||||
// Reminder Service stoppen
|
||||
const reminderServiceInstance = reminderService.getInstance();
|
||||
reminderServiceInstance.stop();
|
||||
|
||||
|
||||
// Aktive Assistant-Sessions beenden
|
||||
for (const [userId] of connectedClients) {
|
||||
assistantRoutes.stopSession(userId);
|
||||
}
|
||||
|
||||
server.close(() => {
|
||||
database.close();
|
||||
logger.info('Server beendet');
|
||||
@@ -342,11 +354,16 @@ process.on('SIGTERM', () => {
|
||||
|
||||
process.on('SIGINT', () => {
|
||||
logger.info('SIGINT empfangen, fahre herunter...');
|
||||
|
||||
|
||||
// Reminder Service stoppen
|
||||
const reminderServiceInstance = reminderService.getInstance();
|
||||
reminderServiceInstance.stop();
|
||||
|
||||
|
||||
// Aktive Assistant-Sessions beenden
|
||||
for (const [userId] of connectedClients) {
|
||||
assistantRoutes.stopSession(userId);
|
||||
}
|
||||
|
||||
server.close(() => {
|
||||
database.close();
|
||||
logger.info('Server beendet');
|
||||
|
||||
In neuem Issue referenzieren
Einen Benutzer sperren