- CSS: Feste Hoehe, overflow hidden, Input-Bar immer sichtbar - Auth: Prueft username UND displayName gegen E-Mail und Name - Beendete Sessions werden automatisch reaktiviert beim Senden Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
506 Zeilen
14 KiB
JavaScript
506 Zeilen
14 KiB
JavaScript
/**
|
|
* 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 = '<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;
|
|
|
|
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 `<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 = {
|
|
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;
|