/** * 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); // Finalize streaming when message processing completes if (data.status === 'ended' || data.status === 'stopped' || data.status === 'active') { this.finalizeStreaming(); if (data.status !== 'active') { 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 = '
Keine Sessions vorhanden
'; 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 `
${this.escapeHtml(s.title)}
${dateStr} - ${statusLabel}
`; }).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; if (session.status === 'active') { // Aktive Session mit Backend verbinden const socket = syncManager.socket; if (socket) { socket.emit('assistant:start', { sessionId: id }); } } else { this.setStatus(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('active'); 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 // ===================== async sendMessage() { const text = this.inputEl?.value?.trim(); if (!text || !this.currentSessionId) return; // Beendete/gestoppte Session zuerst reaktivieren if (this.sessionStatus === 'ended' || this.sessionStatus === 'stopped') { try { await this._reactivateSession(); this.loadSessions(); } catch (err) { console.error('[Assistant] Reaktivierung fehlgeschlagen:', err); this.showToast('Session konnte nicht reaktiviert werden', 'error'); 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'); } _reactivateSession() { return new Promise((resolve, reject) => { const socket = syncManager.socket; if (!socket) return reject(new Error('Kein Socket')); const onStatus = (data) => { if (data.sessionId !== this.currentSessionId) return; socket.off('assistant:status', onStatus); clearTimeout(timeout); if (data.status === 'error') { reject(new Error(data.error || 'Fehler')); } else { resolve(); } }; const timeout = setTimeout(() => { socket.off('assistant:status', onStatus); reject(new Error('Timeout')); }, 10000); socket.on('assistant:status', onStatus); socket.emit('assistant:start', { sessionId: this.currentSessionId }); }); } 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 `
${code.trim()}
`; }); // Inline code html = html.replace(/`([^`]+)`/g, '$1'); // Bold html = html.replace(/\*\*(.+?)\*\*/g, '$1'); // Italic html = html.replace(/\*(.+?)\*/g, '$1'); // Headers html = html.replace(/^#### (.+)$/gm, '

$1

'); html = html.replace(/^### (.+)$/gm, '

$1

'); html = html.replace(/^## (.+)$/gm, '

$1

'); html = html.replace(/^# (.+)$/gm, '

$1

'); // Unordered lists html = html.replace(/^- (.+)$/gm, '
  • $1
  • '); html = html.replace(/(
  • .*<\/li>\n?)+/g, ''); // Blockquotes html = html.replace(/^> (.+)$/gm, '
    $1
    '); // Line breaks (but not inside pre) html = html.replace(/\n/g, '
    '); // Clean up extra
    in pre blocks html = html.replace(/
    ([\s\S]*?)<\/code><\/pre>/g, (match) => {
          return match.replace(/
    /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 = { active: 'Bereit', 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;