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; } $params = $this->extractChatParams($session); $this->updateSessionIfChanged( $session, $params['model'], $params['collections'], $params['contextLimit'], $params['authorProfileId'], $params['temperature'], $params['maxTokens'] ); if ($params['question'] === '') { $this->view('chat.partials.error', ['error' => 'Bitte gib eine Frage ein.']); return; } $validation = $this->validateCollections($params['collections']); if (!$validation['valid']) { $this->view('chat.partials.error', [ 'error' => 'Collection-Fehler: ' . $validation['error'], 'details' => 'Bitte wähle nur Collections mit gleichem Embedding-Modell.', ]); return; } $response = $this->messageUseCase->execute( sessionUuid: $uuid, message: $params['question'], model: $params['model'], collections: $params['collections'], contextLimit: $params['contextLimit'], authorProfileId: $params['authorProfileId'], systemPromptId: $params['systemPromptId'], temperature: $params['temperature'], maxTokens: $params['maxTokens'], structureId: $params['structureId'], qualityCheck: $params['qualityCheck'] ); if ($response->hasError()) { $this->view('chat.partials.error', ['error' => $response->getError()]); return; } $result = $response->toArray(); $this->view('chat.partials.response', [ 'question' => $params['question'], 'result' => $result, 'model' => $params['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; } $params = $this->extractChatParams($session); $this->updateSessionIfChanged( $session, $params['model'], $params['collections'], $params['contextLimit'], $params['authorProfileId'], $params['temperature'], $params['maxTokens'] ); if ($params['question'] === '') { $this->sseError('Bitte gib eine Frage ein.'); return; } $validation = $this->validateCollections($params['collections']); if (!$validation['valid']) { $this->sseError('Collection-Fehler: ' . $validation['error']); return; } $this->setupSseStream(); $this->setupProgressCallback(); // Release session lock before long-running LLM call // This allows other requests from same user to proceed session_write_close(); // Execute with streaming $response = $this->streamingUseCase->execute( sessionUuid: $uuid, message: $params['question'], model: $params['model'], collections: $params['collections'], contextLimit: $params['contextLimit'], authorProfileId: $params['authorProfileId'], systemPromptId: $params['systemPromptId'], temperature: $params['temperature'], maxTokens: $params['maxTokens'], structureId: $params['structureId'], qualityCheck: $params['qualityCheck'], requestIp: $_SERVER['REMOTE_ADDR'] ?? '127.0.0.1' ); // Send final result $this->sendStreamResult($response, $params['question'], $params['model']); } 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(); } /** * Setup progress callback for SSE events. */ private function setupProgressCallback(): void { $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"; echo ':' . str_repeat(' ', 4096) . "\n"; flush(); }); } /** * Send streaming result as SSE event. */ private function sendStreamResult(\UseCases\Chat\ChatResponse $response, string $question, string $model): void { 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'] ?? ''); 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(); } }