|null Cached collections list */ private ?array $collectionsCache = null; public function __construct() { $config = AIConfig::fromCredentialsFile(); $this->chatService = $config->createChatService(); $this->qdrantService = $config->createQdrantService(); $this->db = $this->initializeDatabase(); $this->systemPromptRepo = new SystemPromptRepository(); } /** * GET /chat * Show chat interface with session list, create new session if none */ public function index(): void { $sessions = $this->getSessions(); // Create new session and redirect $uuid = $this->createSession(); header('Location: /chat/' . $uuid); exit; } /** * GET /chat/{uuid} * Show specific chat session */ public function show(string $uuid): void { $session = $this->getSession($uuid); if ($session === null) { header('Location: /chat'); exit; } $messages = $this->getMessages($session['id']); $sessions = $this->getSessions(); $authorProfiles = $this->getAuthorProfiles(); $systemPrompts = $this->systemPromptRepo->findAllActive(); $collections = $this->getAvailableCollections(); $this->view('chat.index', [ 'title' => $session['title'] ?? 'KI-Chat', 'session' => $session, 'messages' => $messages, 'sessions' => $sessions, 'authorProfiles' => $authorProfiles, 'systemPrompts' => $systemPrompts, 'collections' => $collections, ]); } /** * GET /chat/sessions (HTMX partial) * Return session list HTML */ public function sessionList(): void { $sessions = $this->getSessions(); $this->view('chat.partials.session-list', [ 'sessions' => $sessions, 'currentUuid' => $_GET['current'] ?? null, ]); } /** * POST /chat/{uuid}/message (HTMX) * Process message and return HTML response */ public function message(string $uuid): void { $session = $this->getSession($uuid); if ($session === null) { echo '
Session nicht gefunden.
'; return; } $question = trim($_POST['message'] ?? ''); $model = $this->validateModel($_POST['model'] ?? $session['model']); $sessionCollections = json_decode($session['collections'] ?? '["documents"]', true) ?: ['documents']; $collections = $this->validateCollections($_POST['collections'] ?? $sessionCollections); $contextLimit = $this->validateContextLimit((int) ($_POST['context_limit'] ?? $session['context_limit'] ?? 5)); $authorProfileId = $this->validateAuthorProfileId((int) ($_POST['author_profile_id'] ?? $session['author_profile_id'] ?? 0)); $systemPromptId = (int) ($_POST['system_prompt_id'] ?? $session['system_prompt_id'] ?? 1); $temperature = $this->validateTemperature((float) ($_POST['temperature'] ?? $session['temperature'] ?? 0.7)); $maxTokens = $this->validateMaxTokens((int) ($_POST['max_tokens'] ?? $session['max_tokens'] ?? 4096)); // Update session if settings changed $currentLimit = (int) ($session['context_limit'] ?? 5); $currentProfileId = (int) ($session['author_profile_id'] ?? 0); $currentTemperature = (float) ($session['temperature'] ?? 0.7); $currentMaxTokens = (int) ($session['max_tokens'] ?? 4096); $collectionsJson = json_encode($collections); if ($model !== $session['model'] || $collectionsJson !== ($session['collections'] ?? '["documents"]') || $contextLimit !== $currentLimit || $authorProfileId !== $currentProfileId || $temperature !== $currentTemperature || $maxTokens !== $currentMaxTokens) { $this->updateSessionSettings($session['id'], $model, $collections, $contextLimit, $authorProfileId, $temperature, $maxTokens); } if ($question === '') { echo '
Bitte gib eine Frage ein.
'; return; } try { // Save user message $this->saveMessage($session['id'], 'user', $question, $model); // Auto-set title from first message if ($session['title'] === null) { $title = mb_substr($question, 0, 50) . (mb_strlen($question) > 50 ? '...' : ''); $this->updateSessionTitle($session['id'], $title); } // Get style prompt from author profile $stylePrompt = $this->getStylePromptFromProfile($authorProfileId); // Get system prompt from database $systemPromptData = $this->systemPromptRepo->findById($systemPromptId); $customSystemPrompt = $systemPromptData['content'] ?? null; // Track timing $startMicrotime = microtime(true); // Get response from AI $result = $this->askChat( $question, $model, $collections, $contextLimit, $stylePrompt, $customSystemPrompt, $temperature, $maxTokens ); $endMicrotime = microtime(true); if (isset($result['error'])) { echo '
' . htmlspecialchars($result['error']) . '
'; return; } // Save assistant message with full tracking $tokensInput = $result['usage']['input_tokens'] ?? null; $tokensOutput = $result['usage']['output_tokens'] ?? null; $sources = $result['sources'] ?? []; $this->saveMessage( $session['id'], 'assistant', $result['answer'], $model, $tokensInput, $tokensOutput, $sources, $startMicrotime, $endMicrotime, $authorProfileId > 0 ? $authorProfileId : null, $systemPromptId > 0 ? $systemPromptId : null, $collectionsJson, $contextLimit ); $this->renderResponse($question, $result, $model); } catch (\Exception $e) { echo '
Fehler: ' . htmlspecialchars($e->getMessage()) . '
'; } } /** * POST /chat/{uuid}/title (HTMX) * Update session title */ public function updateTitle(string $uuid): void { $session = $this->getSession($uuid); if ($session === null) { http_response_code(404); echo ''; return; } $title = trim($_POST['title'] ?? ''); // Validate title if ($title === '') { $title = 'Neuer Chat'; } // Max 100 characters $title = mb_substr($title, 0, 100); // Save to database $this->updateSessionTitle($session['id'], $title); // Return updated title HTML echo htmlspecialchars($title); } /** * POST /chat/{uuid}/system-prompt (HTMX) * Update session system prompt */ public function systemPrompt(string $uuid): void { $session = $this->getSession($uuid); if ($session === null) { http_response_code(404); echo '
Session nicht gefunden.
'; return; } $systemPrompt = trim($_POST['system_prompt'] ?? ''); // Validate and sanitize system prompt $systemPrompt = $this->validateSystemPrompt($systemPrompt); // Save to database $this->updateSystemPrompt($session['id'], $systemPrompt); // Return success message echo '
System-Prompt gespeichert.
'; } /** * GET /chat/{uuid}/system-prompt (HTMX) * Get system prompt form */ public function getSystemPrompt(string $uuid): void { $session = $this->getSession($uuid); if ($session === null) { http_response_code(404); echo '
Session nicht gefunden.
'; return; } $defaultPrompt = $this->getDefaultSystemPrompt(); $currentPrompt = $session['system_prompt'] ?? ''; $this->view('chat.partials.system-prompt-modal', [ 'session' => $session, 'currentPrompt' => $currentPrompt, 'defaultPrompt' => $defaultPrompt, ]); } /** * DELETE /chat/{uuid} * Delete a session */ public function delete(string $uuid): void { $session = $this->getSession($uuid); if ($session !== null) { $stmt = $this->db->prepare('DELETE FROM chat_sessions WHERE id = ?'); $stmt->execute([$session['id']]); } // Return success for HTMX header('HX-Redirect: /chat'); echo 'OK'; } // ========== Private Helper Methods ========== /** * Initialize database connection */ private function initializeDatabase(): \PDO { return \Infrastructure\Config\DatabaseFactory::content(); } /** * Create a new chat session */ private function createSession(): string { $uuid = $this->generateUuid(); $stmt = $this->db->prepare( 'INSERT INTO chat_sessions (uuid, model, collections, context_limit) VALUES (?, ?, ?, ?)' ); $stmt->execute([$uuid, 'claude-opus-4-5-20251101', '["documents"]', 5]); return $uuid; } /** * Get session by UUID */ private function getSession(string $uuid): ?array { $stmt = $this->db->prepare('SELECT * FROM chat_sessions WHERE uuid = ?'); $stmt->execute([$uuid]); $result = $stmt->fetch(\PDO::FETCH_ASSOC); return $result !== false ? $result : null; } /** * Get all sessions ordered by most recent */ private function getSessions(): array { $stmt = $this->db->query( 'SELECT s.*, (SELECT COUNT(*) FROM chat_messages WHERE session_id = s.id) as message_count, (SELECT COALESCE(SUM(tokens_input), 0) FROM chat_messages WHERE session_id = s.id) as total_input_tokens, (SELECT COALESCE(SUM(tokens_output), 0) FROM chat_messages WHERE session_id = s.id) as total_output_tokens, (SELECT COALESCE(SUM(end_microtime - start_microtime), 0) FROM chat_messages WHERE session_id = s.id AND start_microtime IS NOT NULL) as total_duration, (SELECT model FROM chat_messages WHERE session_id = s.id AND role = "assistant" ORDER BY id DESC LIMIT 1) as last_model FROM chat_sessions s ORDER BY s.last_activity DESC LIMIT 50' ); return $stmt->fetchAll(\PDO::FETCH_ASSOC); } /** * Get author profiles from ki_content database */ private function getAuthorProfiles(): array { $stmt = $this->db->query('SELECT id, name, slug, config FROM author_profiles WHERE is_active = 1 ORDER BY id'); return $stmt->fetchAll(\PDO::FETCH_ASSOC); } /** * Get author profile by ID */ private function getAuthorProfile(int $profileId): ?array { $profiles = $this->getAuthorProfiles(); foreach ($profiles as $profile) { if ((int) $profile['id'] === $profileId) { return $profile; } } return null; } /** * Get messages for a session */ private function getMessages(int $sessionId): array { $stmt = $this->db->prepare( 'SELECT * FROM chat_messages WHERE session_id = ? ORDER BY created_at ASC' ); $stmt->execute([$sessionId]); return $stmt->fetchAll(\PDO::FETCH_ASSOC); } /** * Save a message to the database */ private function saveMessage( int $sessionId, string $role, string $content, string $model, ?int $tokensInput = null, ?int $tokensOutput = null, array $sources = [], ?float $startMicrotime = null, ?float $endMicrotime = null, ?int $authorProfileId = null, ?int $systemPromptId = null, ?string $collectionsJson = null, ?int $contextLimit = null ): void { $stmt = $this->db->prepare( 'INSERT INTO chat_messages (session_id, role, content, model, tokens_input, tokens_output, sources, start_microtime, end_microtime, author_profile_id, system_prompt_id, collections, context_limit) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)' ); $stmt->execute([ $sessionId, $role, $content, $model, $tokensInput, $tokensOutput, $sources !== [] ? json_encode($sources) : null, $startMicrotime, $endMicrotime, $authorProfileId, $systemPromptId, $collectionsJson, $contextLimit, ]); // Update session timestamp $this->db->prepare('UPDATE chat_sessions SET updated_at = NOW() WHERE id = ?') ->execute([$sessionId]); } /** * Update session title */ private function updateSessionTitle(int $sessionId, string $title): void { $stmt = $this->db->prepare('UPDATE chat_sessions SET title = ? WHERE id = ?'); $stmt->execute([$title, $sessionId]); } /** * Generate UUID v4 */ private function generateUuid(): string { $data = random_bytes(16); $data[6] = chr(ord($data[6]) & 0x0f | 0x40); $data[8] = chr(ord($data[8]) & 0x3f | 0x80); return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($data), 4)); } /** * Validate model parameter * Format: claude-* or ollama:* */ private function validateModel(string $model): string { if (str_starts_with($model, 'claude-') || str_starts_with($model, 'ollama:')) { return $model; } return 'claude-opus-4-5-20251101'; } /** * Get available Qdrant collections (cached per request) * * @return array List of collection names */ private function getAvailableCollections(): array { if ($this->collectionsCache === null) { $this->collectionsCache = $this->qdrantService->listCollections(); // Fallback if Qdrant is unavailable if ($this->collectionsCache === []) { $this->collectionsCache = ['documents']; } } return $this->collectionsCache; } /** * Validate collections array against available collections * * @param array|string $collections Collections to validate (array or single string) * @return array Validated collections array (may be empty for no RAG) */ private function validateCollections(array|string $collections): array { $availableCollections = $this->getAvailableCollections(); // Handle string input (backwards compatibility) if (is_string($collections)) { $collections = [$collections]; } // Filter to only valid collections $valid = array_filter($collections, fn($c) => in_array($c, $availableCollections, true)); return array_values($valid); } /** * Validate context limit parameter against allowed values */ private function validateContextLimit(int $limit): int { $allowedLimits = [3, 5, 10, 15]; return in_array($limit, $allowedLimits, true) ? $limit : 5; } /** * Validate author profile ID (0 = no profile) */ private function validateAuthorProfileId(int $profileId): int { if ($profileId === 0) { return 0; } $profile = $this->getAuthorProfile($profileId); return $profile !== null ? $profileId : 0; } /** * Validate temperature parameter (0.0 - 1.0) */ private function validateTemperature(float $temperature): float { return max(0.0, min(1.0, $temperature)); } /** * Validate max_tokens parameter against allowed values */ private function validateMaxTokens(int $maxTokens): int { $allowedValues = [1024, 2048, 4096, 8192]; return in_array($maxTokens, $allowedValues, true) ? $maxTokens : 4096; } /** * Generate style prompt from author profile config */ private function getStylePromptFromProfile(int $profileId): ?string { if ($profileId === 0) { return null; } $profile = $this->getAuthorProfile($profileId); if ($profile === null) { return null; } $config = json_decode($profile['config'], true); if ($config === null) { return null; } $parts = []; // Build style instructions from config if (isset($config['stimme']['ton'])) { $parts[] = 'Ton: ' . $config['stimme']['ton']; } if (isset($config['stimme']['perspektive'])) { $parts[] = 'Perspektive: ' . $config['stimme']['perspektive']; } if (isset($config['stil']['fachsprache']) && $config['stil']['fachsprache']) { $parts[] = 'Verwende Fachsprache'; } if (isset($config['stil']['beispiele']) && $config['stil']['beispiele'] === 'häufig') { $parts[] = 'Nutze häufig Beispiele'; } if (isset($config['stil']['listen']) && $config['stil']['listen'] === 'bevorzugt') { $parts[] = 'Bevorzuge Listen und Bullet-Points'; } if (isset($config['tabus']) && is_array($config['tabus'])) { $parts[] = 'Vermeide: ' . implode(', ', $config['tabus']); } if ($parts === []) { return null; } return 'Schreibstil (' . $profile['name'] . '): ' . implode('. ', $parts) . '.'; } /** * Update session model, collection, context limit, author profile, temperature and max_tokens settings */ private function updateSessionSettings(int $sessionId, string $model, string $collection, int $contextLimit = 5, int $authorProfileId = 0, float $temperature = 0.7, int $maxTokens = 4096): void { $stmt = $this->db->prepare('UPDATE chat_sessions SET model = ?, collection = ?, context_limit = ?, author_profile_id = ?, temperature = ?, max_tokens = ? WHERE id = ?'); $stmt->execute([$model, $collection, $contextLimit, $authorProfileId > 0 ? $authorProfileId : null, $temperature, $maxTokens, $sessionId]); } /** * Validate and sanitize system prompt */ private function validateSystemPrompt(string $prompt): string { // Max length: 2000 characters $prompt = mb_substr($prompt, 0, 2000); // Remove potentially dangerous patterns $dangerousPatterns = [ '/ignore\s+(all\s+)?previous\s+instructions/i', '/forget\s+(all\s+)?previous/i', '/disregard\s+(all\s+)?instructions/i', '/system\s*:\s*override/i', ]; foreach ($dangerousPatterns as $pattern) { $prompt = preg_replace($pattern, '[removed]', $prompt) ?? $prompt; } return trim($prompt); } /** * Update session system prompt */ private function updateSystemPrompt(int $sessionId, string $systemPrompt): void { $stmt = $this->db->prepare('UPDATE chat_sessions SET system_prompt = ? WHERE id = ?'); $stmt->execute([$systemPrompt !== '' ? $systemPrompt : null, $sessionId]); } /** * Get default system prompt from ClaudeService */ private function getDefaultSystemPrompt(): string { return <<<'PROMPT' Du bist ein hilfreicher Assistent für Fragen zu systemischem Teamcoaching und Teamentwicklung. Beantworte die Frage des Nutzers basierend auf dem bereitgestellten Kontext. - Antworte auf Deutsch - Sei präzise und hilfreich - Wenn der Kontext die Frage nicht beantwortet, sage das ehrlich - Verweise auf die Quellen wenn passend PROMPT; } /** * Ask chat question using ChatService */ private function askChat(string $question, string $model, string $collection, int $limit, ?string $stylePrompt = null, ?string $customSystemPrompt = null, float $temperature = 0.7, int $maxTokens = 4096): array { try { return $this->chatService->chat($question, $model, $collection, $limit, $stylePrompt, $customSystemPrompt, $temperature, $maxTokens); } catch (\RuntimeException $e) { throw new \RuntimeException('Chat-Service konnte nicht ausgeführt werden: ' . $e->getMessage(), 0, $e); } } /** * Render chat response HTML */ private function renderResponse(string $question, array $result, string $model): void { $answer = $result['answer'] ?? ''; $sources = $result['sources'] ?? []; $usage = $result['usage'] ?? null; $modelLabel = str_starts_with($model, 'ollama:') ? substr($model, 7) : $model; // Convert markdown-style formatting to HTML $answer = $this->formatAnswer($answer); echo '
'; echo '
' . htmlspecialchars($question) . '
'; echo '
'; echo '
'; echo '
' . $answer . '
'; if (count($sources) > 0) { $sourceCount = count($sources); $uniqueId = uniqid('sources-'); echo '
'; echo ''; echo '
'; foreach ($sources as $source) { $title = htmlspecialchars($source['title'] ?? 'Unbekannt'); $score = round(($source['score'] ?? 0) * 100); $content = isset($source['content']) ? htmlspecialchars(mb_substr($source['content'], 0, 200)) : ''; echo '
'; echo '
'; echo '' . $title . ''; echo '' . $score . '%'; echo '
'; if ($content !== '') { echo '
"' . $content . (mb_strlen($source['content'] ?? '') > 200 ? '...' : '') . '"
'; } echo '
'; } echo '
'; echo $this->renderMessageMeta($modelLabel, $usage, $model); echo '
'; } else { echo $this->renderMessageMeta($modelLabel, $usage, $model); } echo '
'; } /** * Render message meta (model, tokens, cost) */ private function renderMessageMeta(string $modelLabel, ?array $usage, string $model): string { $html = '
'; $html .= 'Modell: ' . htmlspecialchars($modelLabel) . ''; if (str_starts_with($model, 'claude-') && $usage !== null) { $inputTokens = $usage['input_tokens'] ?? 0; $outputTokens = $usage['output_tokens'] ?? 0; $cost = $this->calculateCost($inputTokens, $outputTokens); $html .= ''; $html .= '↓' . number_format($inputTokens) . ' '; $html .= '↑' . number_format($outputTokens) . ''; $html .= ''; $html .= '~$' . number_format($cost, 4) . ''; } elseif (str_starts_with($model, 'ollama:')) { $html .= 'lokal'; } $html .= '
'; return $html; } /** * Calculate cost for Claude API usage * Opus 4.5 Pricing: $15 per 1M input, $75 per 1M output */ private function calculateCost(int $inputTokens, int $outputTokens): float { $inputCost = $inputTokens * 0.000015; // $15 per 1M $outputCost = $outputTokens * 0.000075; // $75 per 1M return $inputCost + $outputCost; } /** * Format markdown-like answer to HTML */ private function formatAnswer(string $text): string { // Escape HTML first $text = htmlspecialchars($text); // Headers $text = preg_replace('/^### (.+)$/m', '

$1

', $text); $text = preg_replace('/^## (.+)$/m', '

$1

', $text); $text = preg_replace('/^# (.+)$/m', '

$1

', $text); // Bold $text = preg_replace('/\*\*(.+?)\*\*/', '$1', $text); // Italic $text = preg_replace('/\*(.+?)\*/', '$1', $text); // Code $text = preg_replace('/`(.+?)`/', '$1', $text); // Blockquotes $text = preg_replace('/^> (.+)$/m', '
$1
', $text); // Lists $text = preg_replace('/^- (.+)$/m', '
  • $1
  • ', $text); $text = preg_replace('/(
  • .*<\/li>\n?)+/', '
      $0
    ', $text); // Line breaks $text = nl2br($text); // Clean up multiple
    tags $text = preg_replace('/(\s*){3,}/', '

    ', $text); return $text; } }