createSessionUseCase->createSession(); header('Location: /chat/' . $uuid); exit; } public function show(string $uuid): void { $session = $this->getSessionUseCase->getSession($uuid); if ($session === null) { header('Location: /chat'); exit; } // Convert entities to arrays for views $messages = $this->getSessionUseCase->getMessages($session->getId() ?? 0); $messagesArray = array_map(fn ($m) => $m->toArray(), $messages); $this->view('chat.index', [ 'title' => $session->getTitle() ?? 'KI-Chat', 'session' => ChatSessionFactory::toArray($session), 'messages' => $messagesArray, 'sessions' => $this->getSessionUseCase->getAllSessionsWithStats(), 'authorProfiles' => $this->getSessionUseCase->getAuthorProfiles(), 'systemPrompts' => $this->getSessionUseCase->getSystemPrompts(), 'outputStructures' => $this->getSessionUseCase->getOutputStructures(), 'collections' => $this->getSessionUseCase->getAvailableCollections(), 'models' => $this->modelRegistry->getChatModels(), 'defaultModel' => $this->modelRegistry->getDefaultChatModel(), ]); } public function sessionList(): void { $this->view('chat.partials.session-list', [ 'sessions' => $this->getSessionUseCase->getAllSessionsWithStats(), 'currentUuid' => $this->getString('current') ?: null, ]); } public function message(string $uuid): void { $session = $this->getSessionUseCase->getSession($uuid); if ($session === null) { $this->view('chat.partials.error', ['error' => 'Session nicht gefunden.']); return; } $sessionId = $session->getId() ?? 0; $question = trim($_POST['message'] ?? ''); $requestedModel = $_POST['model'] ?? $session->getModel(); $model = $this->modelRegistry->isValid($requestedModel) ? $requestedModel : $this->modelRegistry->getDefaultChatModel(); $sessionCollections = $session->getCollections(); $collections = $_POST['collections'] ?? $sessionCollections; $contextLimit = (int) ($_POST['context_limit'] ?? $session->getContextLimit()); $authorProfileId = (int) ($_POST['author_profile_id'] ?? $session->getAuthorProfileId() ?? 0); $systemPromptId = (int) ($_POST['system_prompt_id'] ?? $session->getSystemPromptId() ?? 1); $structureId = (int) ($_POST['structure_id'] ?? 0); $temperature = (float) ($_POST['temperature'] ?? $session->getTemperature()); $maxTokens = (int) ($_POST['max_tokens'] ?? $session->getMaxTokens()); if ($this->updateSessionUseCase->settingsHaveChanged($session, $model, $collections, $contextLimit, $authorProfileId, $temperature, $maxTokens)) { $this->updateSessionUseCase->updateSettings($sessionId, $model, $collections, $contextLimit, $authorProfileId, $temperature, $maxTokens); } if ($question === '') { $this->view('chat.partials.error', ['error' => 'Bitte gib eine Frage ein.']); return; } if (!empty($collections)) { $compatibility = $this->updateSessionUseCase->validateCollectionCompatibility($collections); if (!$compatibility['valid']) { $this->view('chat.partials.error', [ 'error' => 'Collection-Fehler: ' . $compatibility['error'], 'details' => 'Bitte wähle nur Collections mit gleichem Embedding-Modell.', ]); return; } } $qualityCheck = isset($_POST['quality_check']) && $_POST['quality_check'] === '1'; $response = $this->messageUseCase->execute( sessionUuid: $uuid, message: $question, model: $model, collections: $collections, contextLimit: $contextLimit, authorProfileId: $authorProfileId, systemPromptId: $systemPromptId, temperature: $temperature, maxTokens: $maxTokens, structureId: $structureId, qualityCheck: $qualityCheck ); if ($response->hasError()) { $this->view('chat.partials.error', ['error' => $response->getError()]); return; } $result = $response->toArray(); $this->view('chat.partials.response', [ 'question' => $question, 'result' => $result, 'model' => $model, 'formattedAnswer' => $this->formatter->formatAnswer($result['answer'] ?? ''), ]); } /** * Streaming message endpoint with SSE progress events */ public function messageStream(string $uuid): void { $session = $this->getSessionUseCase->getSession($uuid); if ($session === null) { $this->sseError('Session nicht gefunden.'); return; } $sessionId = $session->getId() ?? 0; $question = trim($_POST['message'] ?? ''); $requestedModel = $_POST['model'] ?? $session->getModel(); $model = $this->modelRegistry->isValid($requestedModel) ? $requestedModel : $this->modelRegistry->getDefaultChatModel(); $sessionCollections = $session->getCollections(); $collections = $_POST['collections'] ?? $sessionCollections; $contextLimit = (int) ($_POST['context_limit'] ?? $session->getContextLimit()); $authorProfileId = (int) ($_POST['author_profile_id'] ?? $session->getAuthorProfileId() ?? 0); $systemPromptId = (int) ($_POST['system_prompt_id'] ?? $session->getSystemPromptId() ?? 1); $structureId = (int) ($_POST['structure_id'] ?? 0); $temperature = (float) ($_POST['temperature'] ?? $session->getTemperature()); $maxTokens = (int) ($_POST['max_tokens'] ?? $session->getMaxTokens()); if ($this->updateSessionUseCase->settingsHaveChanged($session, $model, $collections, $contextLimit, $authorProfileId, $temperature, $maxTokens)) { $this->updateSessionUseCase->updateSettings($sessionId, $model, $collections, $contextLimit, $authorProfileId, $temperature, $maxTokens); } if ($question === '') { $this->sseError('Bitte gib eine Frage ein.'); return; } if (!empty($collections)) { $compatibility = $this->updateSessionUseCase->validateCollectionCompatibility($collections); if (!$compatibility['valid']) { $this->sseError('Collection-Fehler: ' . $compatibility['error']); return; } } $qualityCheck = isset($_POST['quality_check']) && $_POST['quality_check'] === '1'; // Setup SSE headers - critical for streaming header('Content-Type: text/event-stream; charset=utf-8'); header('Cache-Control: no-cache, no-store, must-revalidate'); header('Pragma: no-cache'); header('Connection: keep-alive'); header('X-Accel-Buffering: no'); header('Content-Encoding: none'); // Disable ALL output buffering if (function_exists('apache_setenv')) { apache_setenv('no-gzip', '1'); } @ini_set('zlib.output_compression', '0'); @ini_set('implicit_flush', '1'); @ini_set('output_buffering', '0'); while (ob_get_level()) { ob_end_clean(); } ob_implicit_flush(true); // Send 4KB padding to force buffer flush (browsers need minimum bytes) echo ':' . str_repeat(' ', 4096) . "\n\n"; flush(); // Set progress callback - send padding after each event to force flush $this->streamingUseCase->setProgressCallback(function (string $step, string $message, ?int $durationMs) { $timestamp = (new \DateTime())->format('H:i:s.v'); $data = [ 'ts' => $timestamp, 'step' => $step, 'msg' => $message, 'ms' => $durationMs, ]; echo "event: progress\n"; echo 'data: ' . json_encode($data, JSON_UNESCAPED_UNICODE) . "\n\n"; // Force buffer flush with padding (Apache/proxy buffering workaround) echo ':' . str_repeat(' ', 4096) . "\n"; flush(); }); // Execute with streaming $response = $this->streamingUseCase->execute( sessionUuid: $uuid, message: $question, model: $model, collections: $collections, contextLimit: $contextLimit, authorProfileId: $authorProfileId, systemPromptId: $systemPromptId, temperature: $temperature, maxTokens: $maxTokens, structureId: $structureId, qualityCheck: $qualityCheck ); // Send final result if ($response->hasError()) { $errorData = ['error' => $response->getError()]; echo "event: error\n"; echo 'data: ' . json_encode($errorData, JSON_UNESCAPED_UNICODE) . "\n\n"; } else { $result = $response->toArray(); $formattedAnswer = $this->formatter->formatAnswer($result['answer'] ?? ''); // Render the response partial to HTML ob_start(); $this->view('chat.partials.response', [ 'question' => $question, 'result' => $result, 'model' => $model, 'formattedAnswer' => $formattedAnswer, ]); $html = ob_get_clean(); $doneData = ['html' => $html]; echo "event: done\n"; echo 'data: ' . json_encode($doneData, JSON_UNESCAPED_UNICODE) . "\n\n"; } @ob_flush(); flush(); } private function sseError(string $message): void { header('Content-Type: text/event-stream; charset=utf-8'); header('Cache-Control: no-cache, no-store, must-revalidate'); header('Content-Encoding: none'); while (ob_get_level()) { ob_end_clean(); } // Padding for browser buffering echo ':' . str_repeat(' ', 4096) . "\n\n"; $errorData = ['error' => $message]; echo "event: error\n"; echo 'data: ' . json_encode($errorData, JSON_UNESCAPED_UNICODE) . "\n\n"; flush(); } public function updateTitle(string $uuid): void { $session = $this->getSessionUseCase->getSession($uuid); if ($session === null) { $this->notFound('Session nicht gefunden'); } $title = $this->updateSessionUseCase->updateTitle($session->getId() ?? 0, $_POST['title'] ?? ''); $this->html(htmlspecialchars($title)); } public function systemPrompt(string $uuid): void { $session = $this->getSessionUseCase->getSession($uuid); if ($session === null) { $this->notFound('Session nicht gefunden'); } $result = $this->updateSessionUseCase->updateSystemPrompt($session->getId() ?? 0, $_POST['system_prompt'] ?? ''); $this->view('chat.partials.success', ['message' => $result->message]); } public function getSystemPrompt(string $uuid): void { $session = $this->getSessionUseCase->getSession($uuid); if ($session === null) { $this->notFound('Session nicht gefunden'); } $this->view('chat.partials.system-prompt-modal', [ 'session' => ChatSessionFactory::toArray($session), 'currentPrompt' => $this->getSessionUseCase->getSystemPromptById($session->getSystemPromptId()), 'defaultPrompt' => $this->getSessionUseCase->getDefaultSystemPrompt(), ]); } public function delete(string $uuid): void { $session = $this->getSessionUseCase->getSession($uuid); if ($session !== null) { $this->deleteSessionUseCase->deleteSession($session->getId() ?? 0); } $this->htmxRedirect('/chat'); } public function deleteAll(): void { $this->deleteSessionUseCase->deleteAllSessions(); $this->htmxRedirect('/chat'); } public function export(string $uuid): void { $format = $this->getString('format') ?: 'markdown'; $filename = $this->exportUseCase->generateFilename($uuid, $format); if ($format === 'json') { $data = $this->exportUseCase->exportAsJson($uuid); if ($data === null) { $this->notFound('Session nicht gefunden'); } $content = json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE); $this->download($content, $filename, 'application/json'); } else { $content = $this->exportUseCase->exportAsMarkdown($uuid); if ($content === null) { $this->notFound('Session nicht gefunden'); } $this->download($content, $filename, 'text/markdown'); } } /** * Extract chat message parameters from POST and session. * * @return array{ * question: string, * model: string, * collections: array, * contextLimit: int, * authorProfileId: int, * systemPromptId: int, * structureId: int, * temperature: float, * maxTokens: int, * qualityCheck: bool * } */ private function extractChatParams(\Domain\Entity\ChatSession $session): array { $requestedModel = $_POST['model'] ?? $session->getModel(); $model = $this->modelRegistry->isValid($requestedModel) ? $requestedModel : $this->modelRegistry->getDefaultChatModel(); return [ 'question' => trim($_POST['message'] ?? ''), 'model' => $model, 'collections' => $_POST['collections'] ?? $session->getCollections(), 'contextLimit' => (int) ($_POST['context_limit'] ?? $session->getContextLimit()), 'authorProfileId' => (int) ($_POST['author_profile_id'] ?? $session->getAuthorProfileId() ?? 0), 'systemPromptId' => (int) ($_POST['system_prompt_id'] ?? $session->getSystemPromptId() ?? 1), 'structureId' => (int) ($_POST['structure_id'] ?? 0), 'temperature' => (float) ($_POST['temperature'] ?? $session->getTemperature()), 'maxTokens' => (int) ($_POST['max_tokens'] ?? $session->getMaxTokens()), 'qualityCheck' => isset($_POST['quality_check']) && $_POST['quality_check'] === '1', ]; } /** * Update session settings if changed. * * @param array $collections */ private function updateSessionIfChanged( \Domain\Entity\ChatSession $session, string $model, array $collections, int $contextLimit, int $authorProfileId, float $temperature, int $maxTokens ): void { if ($this->updateSessionUseCase->settingsHaveChanged($session, $model, $collections, $contextLimit, $authorProfileId, $temperature, $maxTokens)) { $this->updateSessionUseCase->updateSettings($session->getId() ?? 0, $model, $collections, $contextLimit, $authorProfileId, $temperature, $maxTokens); } } /** * Validate collections compatibility. * * @param array $collections * @return array{valid: bool, error: string|null} */ private function validateCollections(array $collections): array { if (empty($collections)) { return ['valid' => true, 'error' => null]; } $compatibility = $this->updateSessionUseCase->validateCollectionCompatibility($collections); return [ 'valid' => $compatibility['valid'], 'error' => $compatibility['error'] ?? null, ]; } /** * Setup SSE stream headers and disable buffering. */ private function setupSseStream(): void { header('Content-Type: text/event-stream; charset=utf-8'); header('Cache-Control: no-cache, no-store, must-revalidate'); header('Pragma: no-cache'); header('Connection: keep-alive'); header('X-Accel-Buffering: no'); header('Content-Encoding: none'); if (function_exists('apache_setenv')) { apache_setenv('no-gzip', '1'); } @ini_set('zlib.output_compression', '0'); @ini_set('implicit_flush', '1'); @ini_set('output_buffering', '0'); while (ob_get_level()) { ob_end_clean(); } ob_implicit_flush(true); // Send padding to force buffer flush echo ':' . str_repeat(' ', 4096) . "\n\n"; flush(); } }