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:
@@ -1,6 +1,53 @@
|
|||||||
TASKMATE - CHANGELOG
|
TASKMATE - CHANGELOG
|
||||||
====================
|
====================
|
||||||
|
|
||||||
|
================================================================================
|
||||||
|
19.03.2026 - v396 - Claude Assistent: Task an Assistent Button (Schritt 3/3)
|
||||||
|
|
||||||
|
NEUES FEATURE:
|
||||||
|
- "An Assistent" Button im Task-Modal Footer (links neben Duplizieren)
|
||||||
|
- Nur sichtbar im Edit-Modus (nicht beim Erstellen neuer Aufgaben)
|
||||||
|
- Sammelt Task-Daten (Titel, Beschreibung, Prioritaet, Labels, Subtasks)
|
||||||
|
- Wechselt automatisch zum Assistent-View und startet neue Session mit Kontext
|
||||||
|
|
||||||
|
GEAENDERTE DATEIEN:
|
||||||
|
- frontend/index.html - Button mit message-square Icon im modal-footer-left
|
||||||
|
- frontend/js/task-modal.js - Import assistantManager, Event-Listener, handleTaskToAssistant()
|
||||||
|
- frontend/js/assistant.js - handleTaskHandover() Methode
|
||||||
|
- frontend/sw.js - CACHE_VERSION 396
|
||||||
|
|
||||||
|
================================================================================
|
||||||
|
19.03.2026 - v395 - Claude Assistent: Frontend (Schritt 2/3)
|
||||||
|
|
||||||
|
NEUES FEATURE:
|
||||||
|
- Neuer Tab "Assistent" in der View-Leiste mit Chat-Icon
|
||||||
|
- Sidebar mit Session-Liste (erstellen, auswaehlen, loeschen)
|
||||||
|
- Chat-Bereich mit Nachrichten-Bubbles (User rechts/Gold, Assistant links/Weiss)
|
||||||
|
- Streaming-Output von Claude mit Blink-Cursor
|
||||||
|
- Markdown-Rendering fuer Assistant-Antworten (Code, Listen, Headers, etc.)
|
||||||
|
- Status-Badges: Aktiv (gruen), Denkt (orange), Beendet (grau), Fehler (rot)
|
||||||
|
- Textarea mit Enter=Senden, Shift+Enter=Neue Zeile, Auto-Resize
|
||||||
|
- Responsive Design: Sidebar hidden unter 768px
|
||||||
|
- Neue Dateien: assistant.js, assistant.css
|
||||||
|
- API-Methoden in api.js: Sessions CRUD + Messages laden
|
||||||
|
- Service Worker Cache v395 mit neuen Assets
|
||||||
|
- Mobile-Navigation: Assistent-Button hinzugefuegt
|
||||||
|
|
||||||
|
================================================================================
|
||||||
|
19.03.2026 - v394 - Claude Assistent: Backend (Schritt 1/3)
|
||||||
|
|
||||||
|
NEUES FEATURE:
|
||||||
|
- Neue DB-Tabellen: assistant_sessions, assistant_messages
|
||||||
|
- Neue Route: /api/assistant mit Session-CRUD
|
||||||
|
- Socket.io Events: assistant:start, assistant:message, assistant:stop
|
||||||
|
- Session Manager: Claude-Prozess spawnen, Output-Streaming, 30min Timeout
|
||||||
|
- Zugriffsbeschraenkung auf Hendrik und Monami
|
||||||
|
|
||||||
|
GEAENDERTE DATEIEN:
|
||||||
|
- backend/database.js - Zwei neue Tabellen + Indizes
|
||||||
|
- backend/routes/assistant.js - NEUE DATEI (REST + Socket + Session Manager)
|
||||||
|
- backend/server.js - Import, Route, Socket-Events, Graceful Shutdown
|
||||||
|
|
||||||
================================================================================
|
================================================================================
|
||||||
19.03.2026 - v393 - Wissensdatenbank: Wiki-Layout Redesign
|
19.03.2026 - v393 - Wissensdatenbank: Wiki-Layout Redesign
|
||||||
|
|
||||||
|
|||||||
@@ -753,6 +753,32 @@ function createTables() {
|
|||||||
END
|
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
|
// Indizes für Performance
|
||||||
db.exec(`
|
db.exec(`
|
||||||
CREATE INDEX IF NOT EXISTS idx_tasks_project ON tasks(project_id);
|
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_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_contact ON contact_interactions(contact_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_interactions_date ON contact_interactions(interaction_date);
|
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');
|
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 knowledgeRoutes = require('./routes/knowledge');
|
||||||
const codingRoutes = require('./routes/coding');
|
const codingRoutes = require('./routes/coding');
|
||||||
const reminderRoutes = require('./routes/reminders');
|
const reminderRoutes = require('./routes/reminders');
|
||||||
|
const assistantRoutes = require('./routes/assistant');
|
||||||
|
|
||||||
// Express App erstellen
|
// Express App erstellen
|
||||||
const app = express();
|
const app = express();
|
||||||
@@ -178,6 +179,9 @@ app.use('/api/reminders', authenticateToken, csrfProtection, reminderRoutes);
|
|||||||
// Contacts-Routes (Kontakte)
|
// Contacts-Routes (Kontakte)
|
||||||
app.use('/api/contacts', authenticateToken, csrfProtection, require('./routes/contacts'));
|
app.use('/api/contacts', authenticateToken, csrfProtection, require('./routes/contacts'));
|
||||||
|
|
||||||
|
// Assistant-Routes (Claude-Assistent)
|
||||||
|
app.use('/api/assistant', authenticateToken, csrfProtection, assistantRoutes);
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// SOCKET.IO
|
// 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
|
// Socket.io Instance global verfügbar machen für Routes
|
||||||
@@ -333,6 +340,11 @@ process.on('SIGTERM', () => {
|
|||||||
const reminderServiceInstance = reminderService.getInstance();
|
const reminderServiceInstance = reminderService.getInstance();
|
||||||
reminderServiceInstance.stop();
|
reminderServiceInstance.stop();
|
||||||
|
|
||||||
|
// Aktive Assistant-Sessions beenden
|
||||||
|
for (const [userId] of connectedClients) {
|
||||||
|
assistantRoutes.stopSession(userId);
|
||||||
|
}
|
||||||
|
|
||||||
server.close(() => {
|
server.close(() => {
|
||||||
database.close();
|
database.close();
|
||||||
logger.info('Server beendet');
|
logger.info('Server beendet');
|
||||||
@@ -347,6 +359,11 @@ process.on('SIGINT', () => {
|
|||||||
const reminderServiceInstance = reminderService.getInstance();
|
const reminderServiceInstance = reminderService.getInstance();
|
||||||
reminderServiceInstance.stop();
|
reminderServiceInstance.stop();
|
||||||
|
|
||||||
|
// Aktive Assistant-Sessions beenden
|
||||||
|
for (const [userId] of connectedClients) {
|
||||||
|
assistantRoutes.stopSession(userId);
|
||||||
|
}
|
||||||
|
|
||||||
server.close(() => {
|
server.close(() => {
|
||||||
database.close();
|
database.close();
|
||||||
logger.info('Server beendet');
|
logger.info('Server beendet');
|
||||||
|
|||||||
434
frontend/css/assistant.css
Normale Datei
434
frontend/css/assistant.css
Normale Datei
@@ -0,0 +1,434 @@
|
|||||||
|
/**
|
||||||
|
* TASKMATE - Assistant View Styles
|
||||||
|
* =================================
|
||||||
|
* Claude Assistant Chat Interface
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* Layout */
|
||||||
|
.assistant-layout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 280px 1fr;
|
||||||
|
height: calc(100vh - 120px);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===================== */
|
||||||
|
/* SIDEBAR */
|
||||||
|
/* ===================== */
|
||||||
|
|
||||||
|
.assistant-sidebar {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border-right: 1px solid var(--border-default);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-sidebar-header {
|
||||||
|
padding: 16px;
|
||||||
|
border-bottom: 1px solid var(--border-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-sidebar-header .btn-block {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-sessions-list {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-session-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-session-item:hover {
|
||||||
|
background: var(--bg-main);
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-session-item.active {
|
||||||
|
background: var(--primary-light);
|
||||||
|
border-left: 3px solid var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-session-info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-session-title {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-primary);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-session-meta {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-session-delete {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--text-muted);
|
||||||
|
padding: 4px;
|
||||||
|
border-radius: 4px;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.15s, color 0.15s;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-session-item:hover .assistant-session-delete {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-session-delete:hover {
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===================== */
|
||||||
|
/* CHAT AREA */
|
||||||
|
/* ===================== */
|
||||||
|
|
||||||
|
.assistant-chat {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
background: var(--bg-main);
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-chat-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px 20px;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border-bottom: 1px solid var(--border-default);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-chat-header h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: var(--text-base);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Status Badges */
|
||||||
|
.assistant-status-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
font-weight: 500;
|
||||||
|
padding: 2px 10px;
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-status-badge:empty {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-status-badge::before {
|
||||||
|
content: '';
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-status-badge.status-running {
|
||||||
|
background: rgba(34, 197, 94, 0.1);
|
||||||
|
color: #16a34a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-status-badge.status-running::before {
|
||||||
|
background: #22c55e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-status-badge.status-thinking {
|
||||||
|
background: rgba(245, 158, 11, 0.1);
|
||||||
|
color: #d97706;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-status-badge.status-thinking::before {
|
||||||
|
background: #f59e0b;
|
||||||
|
animation: pulse-dot 1.5s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-status-badge.status-ended,
|
||||||
|
.assistant-status-badge.status-stopped {
|
||||||
|
background: rgba(100, 116, 139, 0.1);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-status-badge.status-ended::before,
|
||||||
|
.assistant-status-badge.status-stopped::before {
|
||||||
|
background: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-status-badge.status-error {
|
||||||
|
background: rgba(239, 68, 68, 0.1);
|
||||||
|
color: #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-status-badge.status-error::before {
|
||||||
|
background: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse-dot {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.3; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Messages Area */
|
||||||
|
.assistant-messages {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 20px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Message Bubbles */
|
||||||
|
.assistant-message {
|
||||||
|
max-width: 80%;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
line-height: 1.6;
|
||||||
|
word-wrap: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-message.user {
|
||||||
|
align-self: flex-end;
|
||||||
|
background: var(--primary);
|
||||||
|
color: var(--text-inverse);
|
||||||
|
border-bottom-right-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-message.assistant {
|
||||||
|
align-self: flex-start;
|
||||||
|
background: var(--bg-card);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border: 1px solid var(--border-default);
|
||||||
|
border-bottom-left-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Markdown in assistant messages */
|
||||||
|
.assistant-message.assistant code {
|
||||||
|
background: rgba(0, 0, 0, 0.06);
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.85em;
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-message.assistant pre {
|
||||||
|
background: #1e293b;
|
||||||
|
color: #e2e8f0;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow-x: auto;
|
||||||
|
margin: 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-message.assistant pre code {
|
||||||
|
background: none;
|
||||||
|
padding: 0;
|
||||||
|
color: inherit;
|
||||||
|
font-size: 0.85em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-message.assistant ul,
|
||||||
|
.assistant-message.assistant ol {
|
||||||
|
margin: 4px 0;
|
||||||
|
padding-left: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-message.assistant li {
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-message.assistant h1,
|
||||||
|
.assistant-message.assistant h2,
|
||||||
|
.assistant-message.assistant h3,
|
||||||
|
.assistant-message.assistant h4 {
|
||||||
|
margin: 8px 0 4px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-message.assistant h1 { font-size: 1.2em; }
|
||||||
|
.assistant-message.assistant h2 { font-size: 1.1em; }
|
||||||
|
.assistant-message.assistant h3 { font-size: 1.05em; }
|
||||||
|
|
||||||
|
.assistant-message.assistant a {
|
||||||
|
color: var(--primary);
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-message.assistant blockquote {
|
||||||
|
border-left: 3px solid var(--border-default);
|
||||||
|
margin: 8px 0;
|
||||||
|
padding: 4px 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-message .message-time {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
opacity: 0.6;
|
||||||
|
margin-top: 4px;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Streaming cursor */
|
||||||
|
.assistant-message.streaming::after {
|
||||||
|
content: '';
|
||||||
|
display: inline-block;
|
||||||
|
width: 8px;
|
||||||
|
height: 16px;
|
||||||
|
background: var(--text-secondary);
|
||||||
|
margin-left: 2px;
|
||||||
|
vertical-align: text-bottom;
|
||||||
|
animation: blink-cursor 0.8s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes blink-cursor {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Input Bar */
|
||||||
|
.assistant-input-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px 20px;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border-top: 1px solid var(--border-default);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-input {
|
||||||
|
flex: 1;
|
||||||
|
border: 1px solid var(--border-default);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 10px 16px;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-family: 'Poppins', sans-serif;
|
||||||
|
resize: none;
|
||||||
|
max-height: 150px;
|
||||||
|
line-height: 1.5;
|
||||||
|
background: var(--bg-main);
|
||||||
|
color: var(--text-primary);
|
||||||
|
transition: border-color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-input::placeholder {
|
||||||
|
color: var(--text-placeholder);
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-send-btn {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
min-width: 40px;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Empty State */
|
||||||
|
.assistant-empty {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-muted);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-empty-icon {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
opacity: 0.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-empty h3 {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
font-size: var(--text-lg);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-empty p {
|
||||||
|
margin: 0;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hide empty state when messages present */
|
||||||
|
.assistant-messages:not(:empty) ~ .assistant-empty {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hide input bar when no session */
|
||||||
|
.assistant-chat.no-session .assistant-input-bar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===================== */
|
||||||
|
/* RESPONSIVE */
|
||||||
|
/* ===================== */
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.assistant-layout {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
height: calc(100vh - 60px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-sidebar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-sidebar.mobile-visible {
|
||||||
|
display: flex;
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-message {
|
||||||
|
max-width: 90%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-input-bar {
|
||||||
|
padding: 8px 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -38,6 +38,7 @@
|
|||||||
<link rel="stylesheet" href="css/knowledge.css">
|
<link rel="stylesheet" href="css/knowledge.css">
|
||||||
<link rel="stylesheet" href="css/reminders.css">
|
<link rel="stylesheet" href="css/reminders.css">
|
||||||
<link rel="stylesheet" href="css/contacts.css">
|
<link rel="stylesheet" href="css/contacts.css">
|
||||||
|
<link rel="stylesheet" href="css/assistant.css">
|
||||||
<link rel="stylesheet" href="css/responsive.css">
|
<link rel="stylesheet" href="css/responsive.css">
|
||||||
<link rel="stylesheet" href="css/mobile.css">
|
<link rel="stylesheet" href="css/mobile.css">
|
||||||
<link rel="stylesheet" href="css/pwa.css">
|
<link rel="stylesheet" href="css/pwa.css">
|
||||||
@@ -310,6 +311,12 @@
|
|||||||
</svg>
|
</svg>
|
||||||
Kontakte
|
Kontakte
|
||||||
</button>
|
</button>
|
||||||
|
<button class="view-tab" data-view="assistant">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
|
||||||
|
</svg>
|
||||||
|
Assistent
|
||||||
|
</button>
|
||||||
</nav>
|
</nav>
|
||||||
<div class="filter-bar-actions">
|
<div class="filter-bar-actions">
|
||||||
<button id="btn-filter-toggle" class="btn btn-outline filter-toggle-btn">
|
<button id="btn-filter-toggle" class="btn btn-outline filter-toggle-btn">
|
||||||
@@ -687,6 +694,48 @@
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Assistant View -->
|
||||||
|
<div id="view-assistant" class="view view-assistant hidden">
|
||||||
|
<div class="assistant-layout">
|
||||||
|
<aside class="assistant-sidebar">
|
||||||
|
<div class="assistant-sidebar-header">
|
||||||
|
<button id="btn-new-session" class="btn btn-primary btn-block">
|
||||||
|
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M12 5v14M5 12h14"/>
|
||||||
|
</svg>
|
||||||
|
Neue Session
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div id="assistant-sessions-list" class="assistant-sessions-list"></div>
|
||||||
|
</aside>
|
||||||
|
<main class="assistant-chat">
|
||||||
|
<div class="assistant-chat-header">
|
||||||
|
<h3 id="assistant-chat-title">Assistent</h3>
|
||||||
|
<span id="assistant-status-badge" class="assistant-status-badge"></span>
|
||||||
|
</div>
|
||||||
|
<div id="assistant-messages" class="assistant-messages"></div>
|
||||||
|
<div class="assistant-input-bar">
|
||||||
|
<textarea id="assistant-input" class="assistant-input" placeholder="Nachricht eingeben..." rows="1"></textarea>
|
||||||
|
<button id="btn-send-message" class="btn btn-primary assistant-send-btn" title="Senden">
|
||||||
|
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<line x1="22" y1="2" x2="11" y2="13"/>
|
||||||
|
<polygon points="22 2 15 22 11 13 2 9 22 2"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div id="assistant-empty" class="assistant-empty">
|
||||||
|
<div class="assistant-empty-icon">
|
||||||
|
<svg viewBox="0 0 24 24" width="64" height="64" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||||
|
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h3>Claude Assistent</h3>
|
||||||
|
<p>Starte eine neue Session oder waehle eine bestehende aus.</p>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Contacts View -->
|
<!-- Contacts View -->
|
||||||
<div id="view-contacts" class="view view-contacts hidden">
|
<div id="view-contacts" class="view view-contacts hidden">
|
||||||
<div class="view-wrapper">
|
<div class="view-wrapper">
|
||||||
@@ -935,6 +984,10 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<div class="modal-footer-left">
|
<div class="modal-footer-left">
|
||||||
|
<button type="button" id="btn-task-to-assistant" class="btn btn-text hidden">
|
||||||
|
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
|
||||||
|
An Assistent
|
||||||
|
</button>
|
||||||
<button type="button" id="btn-duplicate-task" class="btn btn-text hidden">Duplizieren</button>
|
<button type="button" id="btn-duplicate-task" class="btn btn-text hidden">Duplizieren</button>
|
||||||
<button type="button" id="btn-archive-task" class="btn btn-text hidden">Archivieren</button>
|
<button type="button" id="btn-archive-task" class="btn btn-text hidden">Archivieren</button>
|
||||||
<button type="button" id="btn-restore-task" class="btn btn-text hidden">Wiederherstellen</button>
|
<button type="button" id="btn-restore-task" class="btn btn-text hidden">Wiederherstellen</button>
|
||||||
@@ -1973,6 +2026,10 @@
|
|||||||
<svg viewBox="0 0 24 24" width="20" height="20"><path d="M16 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" stroke="currentColor" stroke-width="2" fill="none"/><circle cx="8.5" cy="7" r="4" stroke="currentColor" stroke-width="2" fill="none"/><line x1="20" y1="8" x2="20" y2="14" stroke="currentColor" stroke-width="2"/><line x1="23" y1="11" x2="17" y2="11" stroke="currentColor" stroke-width="2"/></svg>
|
<svg viewBox="0 0 24 24" width="20" height="20"><path d="M16 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" stroke="currentColor" stroke-width="2" fill="none"/><circle cx="8.5" cy="7" r="4" stroke="currentColor" stroke-width="2" fill="none"/><line x1="20" y1="8" x2="20" y2="14" stroke="currentColor" stroke-width="2"/><line x1="23" y1="11" x2="17" y2="11" stroke="currentColor" stroke-width="2"/></svg>
|
||||||
<span>Kontakte</span>
|
<span>Kontakte</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button class="mobile-nav-item" data-view="assistant">
|
||||||
|
<svg viewBox="0 0 24 24" width="20" height="20"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" stroke="currentColor" stroke-width="2" fill="none"/></svg>
|
||||||
|
<span>Assistent</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1350,6 +1350,26 @@ class ApiClient {
|
|||||||
async getContactTags() {
|
async getContactTags() {
|
||||||
return this.get('/contacts/tags/all');
|
return this.get('/contacts/tags/all');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// ASSISTANT
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
async getAssistantSessions() {
|
||||||
|
return this.get('/assistant/sessions');
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAssistantMessages(sessionId) {
|
||||||
|
return this.get(`/assistant/sessions/${sessionId}/messages`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async createAssistantSession(data) {
|
||||||
|
return this.post('/assistant/sessions', data);
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteAssistantSession(id) {
|
||||||
|
return this.delete(`/assistant/sessions/${id}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Custom API Error Class
|
// Custom API Error Class
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import codingManager from './coding.js';
|
|||||||
import mobileManager from './mobile.js';
|
import mobileManager from './mobile.js';
|
||||||
import reminderManager from './reminders.js';
|
import reminderManager from './reminders.js';
|
||||||
import pwaManager from './pwa.js';
|
import pwaManager from './pwa.js';
|
||||||
|
import assistantManager from './assistant.js';
|
||||||
import { $, $$, debounce, getFromStorage, setToStorage } from './utils.js';
|
import { $, $$, debounce, getFromStorage, setToStorage } from './utils.js';
|
||||||
|
|
||||||
class App {
|
class App {
|
||||||
@@ -90,6 +91,9 @@ class App {
|
|||||||
// Initialize knowledge manager
|
// Initialize knowledge manager
|
||||||
await knowledgeManager.init();
|
await knowledgeManager.init();
|
||||||
|
|
||||||
|
// Initialize assistant manager
|
||||||
|
await assistantManager.init();
|
||||||
|
|
||||||
// Initialize mobile features
|
// Initialize mobile features
|
||||||
mobileManager.init();
|
mobileManager.init();
|
||||||
|
|
||||||
@@ -708,6 +712,13 @@ class App {
|
|||||||
knowledgeManager.hide();
|
knowledgeManager.hide();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Show/hide assistant manager
|
||||||
|
if (view === 'assistant') {
|
||||||
|
assistantManager.show();
|
||||||
|
} else {
|
||||||
|
assistantManager.hide();
|
||||||
|
}
|
||||||
|
|
||||||
// Initialize contacts view when switching to it
|
// Initialize contacts view when switching to it
|
||||||
if (view === 'contacts') {
|
if (view === 'contacts') {
|
||||||
window.initContactsPromise = window.initContactsPromise || import('./contacts.js').then(module => {
|
window.initContactsPromise = window.initContactsPromise || import('./contacts.js').then(module => {
|
||||||
|
|||||||
455
frontend/js/assistant.js
Normale Datei
455
frontend/js/assistant.js
Normale Datei
@@ -0,0 +1,455 @@
|
|||||||
|
/**
|
||||||
|
* TASKMATE - Assistant Manager
|
||||||
|
* ============================
|
||||||
|
* Claude Assistant Chat Interface
|
||||||
|
*/
|
||||||
|
|
||||||
|
import api from './api.js';
|
||||||
|
import syncManager from './sync.js';
|
||||||
|
import { $ } from './utils.js';
|
||||||
|
|
||||||
|
class AssistantManager {
|
||||||
|
constructor() {
|
||||||
|
this.sessions = [];
|
||||||
|
this.currentSessionId = null;
|
||||||
|
this.sessionStatus = null; // running, ended, stopped, error
|
||||||
|
this.streamingMessageEl = null;
|
||||||
|
this.streamingContent = '';
|
||||||
|
this.initialized = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async init() {
|
||||||
|
if (this.initialized) return;
|
||||||
|
|
||||||
|
// DOM Elements
|
||||||
|
this.sessionsListEl = $('#assistant-sessions-list');
|
||||||
|
this.messagesEl = $('#assistant-messages');
|
||||||
|
this.inputEl = $('#assistant-input');
|
||||||
|
this.sendBtn = $('#btn-send-message');
|
||||||
|
this.newSessionBtn = $('#btn-new-session');
|
||||||
|
this.chatTitleEl = $('#assistant-chat-title');
|
||||||
|
this.statusBadgeEl = $('#assistant-status-badge');
|
||||||
|
this.chatEl = document.querySelector('.assistant-chat');
|
||||||
|
this.emptyEl = $('#assistant-empty');
|
||||||
|
|
||||||
|
this.bindEvents();
|
||||||
|
this.setupSocketListeners();
|
||||||
|
this.updateChatState();
|
||||||
|
|
||||||
|
this.initialized = true;
|
||||||
|
console.log('[Assistant] Initialized');
|
||||||
|
}
|
||||||
|
|
||||||
|
bindEvents() {
|
||||||
|
// New session
|
||||||
|
this.newSessionBtn?.addEventListener('click', () => this.startSession());
|
||||||
|
|
||||||
|
// Send message
|
||||||
|
this.sendBtn?.addEventListener('click', () => this.sendMessage());
|
||||||
|
|
||||||
|
// Textarea: Enter sends, Shift+Enter new line, auto-resize
|
||||||
|
this.inputEl?.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
this.sendMessage();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.inputEl?.addEventListener('input', () => {
|
||||||
|
this.autoResizeInput();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setupSocketListeners() {
|
||||||
|
const socket = syncManager.socket;
|
||||||
|
if (!socket) {
|
||||||
|
// Socket not yet connected, wait for it
|
||||||
|
const checkSocket = setInterval(() => {
|
||||||
|
if (syncManager.socket) {
|
||||||
|
clearInterval(checkSocket);
|
||||||
|
this._attachSocketEvents(syncManager.socket);
|
||||||
|
}
|
||||||
|
}, 500);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this._attachSocketEvents(socket);
|
||||||
|
}
|
||||||
|
|
||||||
|
_attachSocketEvents(socket) {
|
||||||
|
// Streaming output
|
||||||
|
socket.on('assistant:output', (data) => {
|
||||||
|
if (data.sessionId !== this.currentSessionId) return;
|
||||||
|
this.handleOutput(data.content);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Status changes
|
||||||
|
socket.on('assistant:status', (data) => {
|
||||||
|
if (data.sessionId && data.sessionId !== this.currentSessionId) return;
|
||||||
|
|
||||||
|
if (data.status === 'error') {
|
||||||
|
this.setStatus('error');
|
||||||
|
this.showToast(data.error || 'Fehler beim Assistenten', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.setStatus(data.status);
|
||||||
|
|
||||||
|
// If session ended, finalize streaming
|
||||||
|
if (data.status === 'ended' || data.status === 'stopped') {
|
||||||
|
this.finalizeStreaming();
|
||||||
|
this.loadSessions();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// =====================
|
||||||
|
// SHOW / HIDE
|
||||||
|
// =====================
|
||||||
|
|
||||||
|
async show() {
|
||||||
|
await this.loadSessions();
|
||||||
|
}
|
||||||
|
|
||||||
|
hide() {
|
||||||
|
// Nothing to clean up
|
||||||
|
}
|
||||||
|
|
||||||
|
// =====================
|
||||||
|
// SESSIONS
|
||||||
|
// =====================
|
||||||
|
|
||||||
|
async loadSessions() {
|
||||||
|
try {
|
||||||
|
this.sessions = await api.getAssistantSessions();
|
||||||
|
this.renderSessionsList();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Assistant] Fehler beim Laden der Sessions:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
renderSessionsList() {
|
||||||
|
if (!this.sessionsListEl) return;
|
||||||
|
|
||||||
|
if (this.sessions.length === 0) {
|
||||||
|
this.sessionsListEl.innerHTML = '<div style="padding: 16px; text-align: center; color: var(--text-muted); font-size: var(--text-sm);">Keine Sessions vorhanden</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.sessionsListEl.innerHTML = this.sessions.map(s => {
|
||||||
|
const isActive = s.id === this.currentSessionId;
|
||||||
|
const date = new Date(s.created_at);
|
||||||
|
const dateStr = `${String(date.getDate()).padStart(2, '0')}.${String(date.getMonth() + 1).padStart(2, '0')}.${date.getFullYear()}`;
|
||||||
|
const statusLabel = s.status === 'active' ? 'Aktiv' : 'Beendet';
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="assistant-session-item ${isActive ? 'active' : ''}" data-session-id="${s.id}">
|
||||||
|
<div class="assistant-session-info">
|
||||||
|
<div class="assistant-session-title">${this.escapeHtml(s.title)}</div>
|
||||||
|
<div class="assistant-session-meta">${dateStr} - ${statusLabel}</div>
|
||||||
|
</div>
|
||||||
|
<button class="assistant-session-delete" data-delete-id="${s.id}" title="Session loeschen">
|
||||||
|
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 6L6 18M6 6l12 12"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
// Click handlers
|
||||||
|
this.sessionsListEl.querySelectorAll('.assistant-session-item').forEach(el => {
|
||||||
|
el.addEventListener('click', (e) => {
|
||||||
|
if (e.target.closest('.assistant-session-delete')) return;
|
||||||
|
const id = parseInt(el.dataset.sessionId, 10);
|
||||||
|
this.selectSession(id);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
this.sessionsListEl.querySelectorAll('.assistant-session-delete').forEach(btn => {
|
||||||
|
btn.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
const id = parseInt(btn.dataset.deleteId, 10);
|
||||||
|
this.deleteSession(id);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async selectSession(id) {
|
||||||
|
this.currentSessionId = id;
|
||||||
|
const session = this.sessions.find(s => s.id === id);
|
||||||
|
|
||||||
|
if (session) {
|
||||||
|
this.chatTitleEl.textContent = session.title;
|
||||||
|
this.setStatus(session.status === 'active' ? 'ended' : session.status);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.renderSessionsList();
|
||||||
|
this.updateChatState();
|
||||||
|
await this.loadMessages(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadMessages(sessionId) {
|
||||||
|
if (!this.messagesEl) return;
|
||||||
|
this.messagesEl.innerHTML = '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const messages = await api.getAssistantMessages(sessionId);
|
||||||
|
messages.forEach(msg => {
|
||||||
|
this.renderMessage(msg.role, msg.content);
|
||||||
|
});
|
||||||
|
this.scrollToBottom();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Assistant] Fehler beim Laden der Nachrichten:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async startSession(taskContext = null) {
|
||||||
|
try {
|
||||||
|
const session = await api.createAssistantSession({
|
||||||
|
title: 'Neue Session',
|
||||||
|
taskContext
|
||||||
|
});
|
||||||
|
|
||||||
|
this.currentSessionId = session.id;
|
||||||
|
this.chatTitleEl.textContent = session.title;
|
||||||
|
this.messagesEl.innerHTML = '';
|
||||||
|
this.setStatus('running');
|
||||||
|
this.updateChatState();
|
||||||
|
|
||||||
|
// Start Claude process via Socket
|
||||||
|
const socket = syncManager.socket;
|
||||||
|
if (socket) {
|
||||||
|
socket.emit('assistant:start', { sessionId: session.id });
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.loadSessions();
|
||||||
|
this.showToast('Session gestartet', 'success');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Assistant] Fehler beim Starten:', err);
|
||||||
|
this.showToast('Fehler beim Starten der Session', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteSession(id) {
|
||||||
|
if (!confirm('Session wirklich loeschen?')) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await api.deleteAssistantSession(id);
|
||||||
|
|
||||||
|
if (this.currentSessionId === id) {
|
||||||
|
this.currentSessionId = null;
|
||||||
|
this.messagesEl.innerHTML = '';
|
||||||
|
this.chatTitleEl.textContent = 'Assistent';
|
||||||
|
this.setStatus(null);
|
||||||
|
this.updateChatState();
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.loadSessions();
|
||||||
|
this.showToast('Session geloescht', 'success');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Assistant] Fehler beim Loeschen:', err);
|
||||||
|
this.showToast('Fehler beim Loeschen', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async endSession() {
|
||||||
|
const socket = syncManager.socket;
|
||||||
|
if (socket) {
|
||||||
|
socket.emit('assistant:stop');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =====================
|
||||||
|
// MESSAGING
|
||||||
|
// =====================
|
||||||
|
|
||||||
|
sendMessage() {
|
||||||
|
const text = this.inputEl?.value?.trim();
|
||||||
|
if (!text || !this.currentSessionId) return;
|
||||||
|
|
||||||
|
// Render user message
|
||||||
|
this.renderMessage('user', text);
|
||||||
|
this.inputEl.value = '';
|
||||||
|
this.autoResizeInput();
|
||||||
|
this.scrollToBottom();
|
||||||
|
|
||||||
|
// Send via socket
|
||||||
|
const socket = syncManager.socket;
|
||||||
|
if (socket) {
|
||||||
|
socket.emit('assistant:message', { message: text });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prepare streaming bubble
|
||||||
|
this.streamingContent = '';
|
||||||
|
this.streamingMessageEl = this.createMessageEl('assistant', '');
|
||||||
|
this.streamingMessageEl.classList.add('streaming');
|
||||||
|
this.messagesEl.appendChild(this.streamingMessageEl);
|
||||||
|
this.setStatus('thinking');
|
||||||
|
}
|
||||||
|
|
||||||
|
handleOutput(content) {
|
||||||
|
if (!this.streamingMessageEl) {
|
||||||
|
// Create streaming bubble if not exists
|
||||||
|
this.streamingContent = '';
|
||||||
|
this.streamingMessageEl = this.createMessageEl('assistant', '');
|
||||||
|
this.streamingMessageEl.classList.add('streaming');
|
||||||
|
this.messagesEl.appendChild(this.streamingMessageEl);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.streamingContent += content;
|
||||||
|
this.streamingMessageEl.innerHTML = this.renderMarkdown(this.streamingContent);
|
||||||
|
this.setStatus('running');
|
||||||
|
this.scrollToBottom();
|
||||||
|
}
|
||||||
|
|
||||||
|
finalizeStreaming() {
|
||||||
|
if (this.streamingMessageEl) {
|
||||||
|
this.streamingMessageEl.classList.remove('streaming');
|
||||||
|
this.streamingMessageEl = null;
|
||||||
|
this.streamingContent = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =====================
|
||||||
|
// RENDERING
|
||||||
|
// =====================
|
||||||
|
|
||||||
|
renderMessage(role, content) {
|
||||||
|
const el = this.createMessageEl(role, content);
|
||||||
|
this.messagesEl.appendChild(el);
|
||||||
|
}
|
||||||
|
|
||||||
|
createMessageEl(role, content) {
|
||||||
|
const el = document.createElement('div');
|
||||||
|
el.className = `assistant-message ${role}`;
|
||||||
|
|
||||||
|
if (role === 'assistant') {
|
||||||
|
el.innerHTML = this.renderMarkdown(content);
|
||||||
|
} else {
|
||||||
|
el.textContent = content;
|
||||||
|
}
|
||||||
|
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
|
||||||
|
renderMarkdown(text) {
|
||||||
|
if (!text) return '';
|
||||||
|
|
||||||
|
let html = this.escapeHtml(text);
|
||||||
|
|
||||||
|
// Code blocks
|
||||||
|
html = html.replace(/```(\w*)\n?([\s\S]*?)```/g, (_, lang, code) => {
|
||||||
|
return `<pre><code>${code.trim()}</code></pre>`;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Inline code
|
||||||
|
html = html.replace(/`([^`]+)`/g, '<code>$1</code>');
|
||||||
|
|
||||||
|
// Bold
|
||||||
|
html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
|
||||||
|
|
||||||
|
// Italic
|
||||||
|
html = html.replace(/\*(.+?)\*/g, '<em>$1</em>');
|
||||||
|
|
||||||
|
// Headers
|
||||||
|
html = html.replace(/^#### (.+)$/gm, '<h4>$1</h4>');
|
||||||
|
html = html.replace(/^### (.+)$/gm, '<h3>$1</h3>');
|
||||||
|
html = html.replace(/^## (.+)$/gm, '<h2>$1</h2>');
|
||||||
|
html = html.replace(/^# (.+)$/gm, '<h1>$1</h1>');
|
||||||
|
|
||||||
|
// Unordered lists
|
||||||
|
html = html.replace(/^- (.+)$/gm, '<li>$1</li>');
|
||||||
|
html = html.replace(/(<li>.*<\/li>\n?)+/g, '<ul>$&</ul>');
|
||||||
|
|
||||||
|
// Blockquotes
|
||||||
|
html = html.replace(/^> (.+)$/gm, '<blockquote>$1</blockquote>');
|
||||||
|
|
||||||
|
// Line breaks (but not inside pre)
|
||||||
|
html = html.replace(/\n/g, '<br>');
|
||||||
|
|
||||||
|
// Clean up extra <br> in pre blocks
|
||||||
|
html = html.replace(/<pre><code>([\s\S]*?)<\/code><\/pre>/g, (match) => {
|
||||||
|
return match.replace(/<br>/g, '\n');
|
||||||
|
});
|
||||||
|
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
|
||||||
|
// =====================
|
||||||
|
// UI HELPERS
|
||||||
|
// =====================
|
||||||
|
|
||||||
|
updateChatState() {
|
||||||
|
if (!this.chatEl) return;
|
||||||
|
|
||||||
|
if (this.currentSessionId) {
|
||||||
|
this.chatEl.classList.remove('no-session');
|
||||||
|
} else {
|
||||||
|
this.chatEl.classList.add('no-session');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setStatus(status) {
|
||||||
|
this.sessionStatus = status;
|
||||||
|
|
||||||
|
if (!this.statusBadgeEl) return;
|
||||||
|
|
||||||
|
// Remove all status classes
|
||||||
|
this.statusBadgeEl.className = 'assistant-status-badge';
|
||||||
|
|
||||||
|
if (!status) {
|
||||||
|
this.statusBadgeEl.textContent = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const labels = {
|
||||||
|
running: 'Aktiv',
|
||||||
|
thinking: 'Denkt...',
|
||||||
|
ended: 'Beendet',
|
||||||
|
stopped: 'Gestoppt',
|
||||||
|
error: 'Fehler'
|
||||||
|
};
|
||||||
|
|
||||||
|
this.statusBadgeEl.classList.add(`status-${status}`);
|
||||||
|
this.statusBadgeEl.textContent = labels[status] || status;
|
||||||
|
}
|
||||||
|
|
||||||
|
autoResizeInput() {
|
||||||
|
if (!this.inputEl) return;
|
||||||
|
this.inputEl.style.height = 'auto';
|
||||||
|
this.inputEl.style.height = Math.min(this.inputEl.scrollHeight, 150) + 'px';
|
||||||
|
}
|
||||||
|
|
||||||
|
scrollToBottom() {
|
||||||
|
if (!this.messagesEl) return;
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
this.messagesEl.scrollTop = this.messagesEl.scrollHeight;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
escapeHtml(text) {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.textContent = text;
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleTaskHandover(taskContext) {
|
||||||
|
// Switch to assistant view
|
||||||
|
const assistantTab = document.querySelector('.view-tab[data-view="assistant"]');
|
||||||
|
if (assistantTab) assistantTab.click();
|
||||||
|
|
||||||
|
// Ensure initialized
|
||||||
|
await this.init();
|
||||||
|
|
||||||
|
// Start new session with task context
|
||||||
|
await this.startSession(taskContext);
|
||||||
|
}
|
||||||
|
|
||||||
|
showToast(message, type = 'info') {
|
||||||
|
window.dispatchEvent(new CustomEvent('toast:show', {
|
||||||
|
detail: { message, type }
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Singleton
|
||||||
|
const assistantManager = new AssistantManager();
|
||||||
|
export default assistantManager;
|
||||||
@@ -7,6 +7,7 @@
|
|||||||
import store from './store.js';
|
import store from './store.js';
|
||||||
import api from './api.js';
|
import api from './api.js';
|
||||||
import syncManager from './sync.js';
|
import syncManager from './sync.js';
|
||||||
|
import assistantManager from './assistant.js';
|
||||||
import {
|
import {
|
||||||
$, $$, createElement, clearElement, formatDate, formatDateTime,
|
$, $$, createElement, clearElement, formatDate, formatDateTime,
|
||||||
formatRelativeTime, formatFileSize, getInitials, hexToRgba,
|
formatRelativeTime, formatFileSize, getInitials, hexToRgba,
|
||||||
@@ -72,6 +73,9 @@ class TaskModalManager {
|
|||||||
// Archive button
|
// Archive button
|
||||||
$('#btn-archive-task')?.addEventListener('click', () => this.handleArchive());
|
$('#btn-archive-task')?.addEventListener('click', () => this.handleArchive());
|
||||||
|
|
||||||
|
// Task to Assistant button
|
||||||
|
$('#btn-task-to-assistant')?.addEventListener('click', () => this.handleTaskToAssistant());
|
||||||
|
|
||||||
// Restore button
|
// Restore button
|
||||||
$('#btn-restore-task')?.addEventListener('click', () => this.handleRestore());
|
$('#btn-restore-task')?.addEventListener('click', () => this.handleRestore());
|
||||||
|
|
||||||
@@ -225,6 +229,7 @@ class TaskModalManager {
|
|||||||
const duplicateBtn = $('#btn-duplicate-task');
|
const duplicateBtn = $('#btn-duplicate-task');
|
||||||
const archiveBtn = $('#btn-archive-task');
|
const archiveBtn = $('#btn-archive-task');
|
||||||
const restoreBtn = $('#btn-restore-task');
|
const restoreBtn = $('#btn-restore-task');
|
||||||
|
const assistantBtn = $('#btn-task-to-assistant');
|
||||||
const saveBtn = $('#btn-save-task');
|
const saveBtn = $('#btn-save-task');
|
||||||
const cancelBtn = $('#btn-cancel-task');
|
const cancelBtn = $('#btn-cancel-task');
|
||||||
const backBtn = $('#btn-back-task');
|
const backBtn = $('#btn-back-task');
|
||||||
@@ -233,6 +238,7 @@ class TaskModalManager {
|
|||||||
if (deleteBtn) deleteBtn.classList.toggle('hidden', mode === 'create');
|
if (deleteBtn) deleteBtn.classList.toggle('hidden', mode === 'create');
|
||||||
if (duplicateBtn) duplicateBtn.classList.toggle('hidden', mode === 'create');
|
if (duplicateBtn) duplicateBtn.classList.toggle('hidden', mode === 'create');
|
||||||
if (archiveBtn) archiveBtn.classList.toggle('hidden', mode === 'create');
|
if (archiveBtn) archiveBtn.classList.toggle('hidden', mode === 'create');
|
||||||
|
if (assistantBtn) assistantBtn.classList.toggle('hidden', mode === 'create');
|
||||||
if (restoreBtn) restoreBtn.classList.add('hidden'); // Always hide initially, shown in loadTaskData if archived
|
if (restoreBtn) restoreBtn.classList.add('hidden'); // Always hide initially, shown in loadTaskData if archived
|
||||||
|
|
||||||
// Right side buttons (create vs edit mode)
|
// Right side buttons (create vs edit mode)
|
||||||
@@ -596,6 +602,33 @@ class TaskModalManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
handleTaskToAssistant() {
|
||||||
|
if (!this.originalTask) return;
|
||||||
|
|
||||||
|
const task = this.originalTask;
|
||||||
|
const priorityLabels = { low: 'Niedrig', medium: 'Mittel', high: 'Hoch', urgent: 'Dringend' };
|
||||||
|
|
||||||
|
// Build context text
|
||||||
|
let context = `Aufgabe: ${task.title}\n`;
|
||||||
|
if (task.description) context += `Beschreibung: ${task.description}\n`;
|
||||||
|
context += `Prioritaet: ${priorityLabels[task.priority] || task.priority}\n`;
|
||||||
|
|
||||||
|
if (task.labels && task.labels.length > 0) {
|
||||||
|
context += `Labels: ${task.labels.map(l => l.name).join(', ')}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.subtasks && this.subtasks.length > 0) {
|
||||||
|
context += `\nSubtasks:\n`;
|
||||||
|
this.subtasks.forEach(st => {
|
||||||
|
context += `- [${st.completed ? 'x' : ' '}] ${st.title}\n`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close modal and hand over to assistant
|
||||||
|
this.close();
|
||||||
|
assistantManager.handleTaskHandover(context);
|
||||||
|
}
|
||||||
|
|
||||||
showColumnSelectDialog() {
|
showColumnSelectDialog() {
|
||||||
const columns = store.get('columns');
|
const columns = store.get('columns');
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
* Offline support and caching
|
* Offline support and caching
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const CACHE_VERSION = '394';
|
const CACHE_VERSION = '396';
|
||||||
const CACHE_NAME = 'taskmate-v' + CACHE_VERSION;
|
const CACHE_NAME = 'taskmate-v' + CACHE_VERSION;
|
||||||
const STATIC_CACHE_NAME = 'taskmate-static-v' + CACHE_VERSION;
|
const STATIC_CACHE_NAME = 'taskmate-static-v' + CACHE_VERSION;
|
||||||
const DYNAMIC_CACHE_NAME = 'taskmate-dynamic-v' + CACHE_VERSION;
|
const DYNAMIC_CACHE_NAME = 'taskmate-dynamic-v' + CACHE_VERSION;
|
||||||
@@ -42,6 +42,7 @@ const STATIC_ASSETS = [
|
|||||||
'/js/mobile.js',
|
'/js/mobile.js',
|
||||||
'/js/reminders.js',
|
'/js/reminders.js',
|
||||||
'/js/contacts.js',
|
'/js/contacts.js',
|
||||||
|
'/js/assistant.js',
|
||||||
'/js/pwa.js',
|
'/js/pwa.js',
|
||||||
'/css/list.css',
|
'/css/list.css',
|
||||||
'/css/mobile.css',
|
'/css/mobile.css',
|
||||||
@@ -53,6 +54,7 @@ const STATIC_ASSETS = [
|
|||||||
'/css/coding.css',
|
'/css/coding.css',
|
||||||
'/css/reminders.css',
|
'/css/reminders.css',
|
||||||
'/css/contacts.css',
|
'/css/contacts.css',
|
||||||
|
'/css/assistant.css',
|
||||||
'/css/pwa.css',
|
'/css/pwa.css',
|
||||||
'/manifest.json'
|
'/manifest.json'
|
||||||
];
|
];
|
||||||
|
|||||||
In neuem Issue referenzieren
Einen Benutzer sperren