From c4304a4f883fc5a3e7502e7869baf14703f2dd58 Mon Sep 17 00:00:00 2001 From: Server Deploy Date: Thu, 19 Mar 2026 22:04:49 +0100 Subject: [PATCH] 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) --- CHANGELOG.txt | 47 ++++ backend/database.js | 29 +++ backend/routes/assistant.js | 464 ++++++++++++++++++++++++++++++++++++ backend/server.js | 25 +- frontend/css/assistant.css | 434 +++++++++++++++++++++++++++++++++ frontend/index.html | 57 +++++ frontend/js/api.js | 20 ++ frontend/js/app.js | 11 + frontend/js/assistant.js | 455 +++++++++++++++++++++++++++++++++++ frontend/js/task-modal.js | 33 +++ frontend/sw.js | 4 +- 11 files changed, 1574 insertions(+), 5 deletions(-) create mode 100644 backend/routes/assistant.js create mode 100644 frontend/css/assistant.css create mode 100644 frontend/js/assistant.js diff --git a/CHANGELOG.txt b/CHANGELOG.txt index 2d24c22..400d492 100644 --- a/CHANGELOG.txt +++ b/CHANGELOG.txt @@ -1,6 +1,53 @@ 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 diff --git a/backend/database.js b/backend/database.js index 61e37ea..1fdc8c1 100644 --- a/backend/database.js +++ b/backend/database.js @@ -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'); diff --git a/backend/routes/assistant.js b/backend/routes/assistant.js new file mode 100644 index 0000000..490c4fc --- /dev/null +++ b/backend/routes/assistant.js @@ -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; diff --git a/backend/server.js b/backend/server.js index b937634..f50912b 100644 --- a/backend/server.js +++ b/backend/server.js @@ -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'); diff --git a/frontend/css/assistant.css b/frontend/css/assistant.css new file mode 100644 index 0000000..af598bf --- /dev/null +++ b/frontend/css/assistant.css @@ -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; + } +} diff --git a/frontend/index.html b/frontend/index.html index fc360b2..0aebf85 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -38,6 +38,7 @@ + @@ -310,6 +311,12 @@ Kontakte +
+ + +