Backup #2119

ID2119
Dateipfad/var/www/dev.campus.systemische-tools.de/src/Controller/ChatController.php
Version126
Typ modified
Größe15.3 KB
Hash0e54172c93638ddc4c81d83a46dad4d7cb6853c0498faf14f62cc109a310e50f
Datum2025-12-29 09:17:53
Geändert vonclaude-code-hook
GrundClaude Code Pre-Hook Backup vor Edit-Operation
Datei existiert Ja

Dateiinhalt

<?php

declare(strict_types=1);

namespace Controller;

// @responsibility: HTTP-Endpunkte für KI-Chat (Sessions, Nachrichten, Export)

use Domain\Factory\ChatSessionFactory;
use Domain\Service\ModelRegistryInterface;
use Framework\Controller;
use Infrastructure\Formatting\ChatMessageFormatter;
use UseCases\Chat\CreateChatSessionUseCaseInterface;
use UseCases\Chat\DeleteChatSessionUseCaseInterface;
use UseCases\Chat\ExportChatSessionUseCase;
use UseCases\Chat\GetChatSessionUseCaseInterface;
use UseCases\Chat\SendChatMessageUseCase;
use UseCases\Chat\StreamingChatMessageUseCase;
use UseCases\Chat\UpdateChatSessionUseCaseInterface;

class ChatController extends Controller
{
    public function __construct(
        private CreateChatSessionUseCaseInterface $createSessionUseCase,
        private GetChatSessionUseCaseInterface $getSessionUseCase,
        private UpdateChatSessionUseCaseInterface $updateSessionUseCase,
        private DeleteChatSessionUseCaseInterface $deleteSessionUseCase,
        private SendChatMessageUseCase $messageUseCase,
        private StreamingChatMessageUseCase $streamingUseCase,
        private ChatMessageFormatter $formatter,
        private ExportChatSessionUseCase $exportUseCase,
        private ModelRegistryInterface $modelRegistry
    ) {
    }

    public function index(): void
    {
        $uuid = $this->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();

        // 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<string>,
     *   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<string> $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<string> $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();
    }
}

Vollständig herunterladen

Aktionen

Herunterladen

Andere Versionen dieser Datei

ID Version Typ Größe Datum
2128 131 modified 17.2 KB 2025-12-29 15:04
2126 130 modified 15.7 KB 2025-12-29 15:00
2125 129 modified 15.6 KB 2025-12-29 15:00
2124 128 modified 15.6 KB 2025-12-29 15:00
2120 127 modified 15.5 KB 2025-12-29 09:18
2119 126 modified 15.3 KB 2025-12-29 09:17
2111 125 modified 15.3 KB 2025-12-29 08:49
2091 124 modified 13.5 KB 2025-12-29 00:15
2090 123 modified 14.3 KB 2025-12-29 00:14
2089 122 modified 16.9 KB 2025-12-29 00:14
2088 121 modified 17.8 KB 2025-12-29 00:13
2087 120 modified 14.2 KB 2025-12-29 00:13
1583 119 modified 14.1 KB 2025-12-26 21:07
1579 118 modified 13.9 KB 2025-12-26 20:56
1578 117 modified 13.8 KB 2025-12-26 20:55
1574 116 modified 13.6 KB 2025-12-26 20:40
1573 115 modified 13.5 KB 2025-12-26 20:39
1572 114 modified 13.5 KB 2025-12-26 20:39
1571 113 modified 13.2 KB 2025-12-26 20:39
1559 112 modified 8.6 KB 2025-12-26 20:28
1558 111 modified 8.4 KB 2025-12-26 20:28
1544 110 modified 8.3 KB 2025-12-26 20:05
1539 109 modified 9.6 KB 2025-12-26 20:04
1538 108 modified 10.1 KB 2025-12-26 20:04
1536 107 modified 8.8 KB 2025-12-26 19:45
1535 106 modified 8.3 KB 2025-12-26 19:45
1494 105 modified 8.3 KB 2025-12-25 17:31
1488 104 modified 8.3 KB 2025-12-25 17:04
1487 103 modified 8.2 KB 2025-12-25 17:04
1486 102 modified 8.2 KB 2025-12-25 17:04
1485 101 modified 8.2 KB 2025-12-25 17:04
1484 100 modified 8.2 KB 2025-12-25 17:04
1483 99 modified 8.2 KB 2025-12-25 17:04
1482 98 modified 8.2 KB 2025-12-25 17:03
1466 97 modified 8.2 KB 2025-12-25 17:02
1465 96 modified 8.2 KB 2025-12-25 17:01
1464 95 modified 8.1 KB 2025-12-25 17:01
1453 94 modified 8.1 KB 2025-12-25 17:00
1452 93 modified 8.1 KB 2025-12-25 17:00
1451 92 modified 8.1 KB 2025-12-25 17:00
1445 91 modified 7.7 KB 2025-12-25 17:00
1293 90 modified 7.6 KB 2025-12-25 13:27
1292 89 modified 7.6 KB 2025-12-25 13:27
1291 88 modified 7.9 KB 2025-12-25 13:27
1145 87 modified 7.9 KB 2025-12-25 09:45
1144 86 modified 7.8 KB 2025-12-25 09:45
1142 85 modified 7.7 KB 2025-12-25 09:45
1137 84 modified 7.7 KB 2025-12-25 09:42
1136 83 modified 7.7 KB 2025-12-25 09:42
1135 82 modified 7.6 KB 2025-12-25 09:42
685 81 modified 7.5 KB 2025-12-23 07:51
681 80 modified 7.5 KB 2025-12-23 07:44
632 79 modified 7.8 KB 2025-12-23 04:44
631 78 modified 7.8 KB 2025-12-23 04:44
630 77 modified 7.8 KB 2025-12-23 04:44
629 76 modified 7.6 KB 2025-12-23 04:44
619 75 modified 7.7 KB 2025-12-23 04:42
564 74 modified 7.6 KB 2025-12-23 03:35
551 73 modified 7.6 KB 2025-12-23 02:39
550 72 modified 7.5 KB 2025-12-23 02:39
546 71 modified 7.4 KB 2025-12-23 02:38
540 70 modified 6.4 KB 2025-12-22 22:30
539 69 modified 6.4 KB 2025-12-22 22:29
470 68 modified 22.7 KB 2025-12-22 10:36
321 67 modified 22.7 KB 2025-12-22 08:06
293 66 modified 22.7 KB 2025-12-22 08:03
283 65 modified 23.2 KB 2025-12-22 02:19
282 64 modified 23.3 KB 2025-12-22 02:19
281 63 modified 23.4 KB 2025-12-22 02:19
274 62 modified 24.1 KB 2025-12-22 02:14
273 61 modified 25.6 KB 2025-12-22 02:14
272 60 modified 27.0 KB 2025-12-22 02:14
271 59 modified 27.4 KB 2025-12-22 02:13
270 58 modified 29.0 KB 2025-12-22 02:13
269 57 modified 28.8 KB 2025-12-22 02:13
268 56 modified 28.7 KB 2025-12-22 02:12
257 55 modified 29.0 KB 2025-12-22 02:00
256 54 modified 32.1 KB 2025-12-22 01:59
255 53 modified 32.1 KB 2025-12-22 01:59
254 52 modified 32.1 KB 2025-12-22 01:59
253 51 modified 32.1 KB 2025-12-22 01:59
252 50 modified 32.2 KB 2025-12-22 01:59
251 49 modified 32.2 KB 2025-12-22 01:59
250 48 modified 32.2 KB 2025-12-22 01:59
217 47 modified 32.2 KB 2025-12-22 01:43
216 46 modified 32.3 KB 2025-12-22 01:43
215 45 modified 32.3 KB 2025-12-22 01:43
197 44 modified 32.4 KB 2025-12-21 14:39
196 43 modified 32.4 KB 2025-12-21 14:39
195 42 modified 32.5 KB 2025-12-21 14:39
183 41 modified 31.9 KB 2025-12-21 14:15
182 40 modified 31.4 KB 2025-12-21 14:15
181 39 modified 31.0 KB 2025-12-21 14:15
180 38 modified 30.6 KB 2025-12-21 14:15
169 37 modified 30.5 KB 2025-12-21 04:12
168 36 modified 30.6 KB 2025-12-21 04:12
167 35 modified 30.6 KB 2025-12-21 04:12
148 34 modified 30.6 KB 2025-12-21 02:30
147 33 modified 30.6 KB 2025-12-21 02:30
146 32 modified 29.4 KB 2025-12-21 02:30
145 31 modified 29.4 KB 2025-12-21 02:29
144 30 modified 29.4 KB 2025-12-21 02:29
143 29 modified 29.5 KB 2025-12-21 02:29
142 28 modified 29.3 KB 2025-12-21 02:16
132 27 modified 27.8 KB 2025-12-20 19:46
131 26 modified 27.7 KB 2025-12-20 19:46
130 25 modified 26.5 KB 2025-12-20 19:42
82 24 modified 26.4 KB 2025-12-20 19:13
81 23 modified 26.3 KB 2025-12-20 19:13
80 22 modified 26.3 KB 2025-12-20 19:13
79 21 modified 25.9 KB 2025-12-20 19:12
78 20 modified 25.9 KB 2025-12-20 19:12
77 19 modified 25.9 KB 2025-12-20 19:12
76 18 modified 25.9 KB 2025-12-20 19:10
75 17 modified 25.8 KB 2025-12-20 19:10
74 16 modified 25.7 KB 2025-12-20 19:10
68 15 modified 25.9 KB 2025-12-20 18:49
67 14 modified 25.4 KB 2025-12-20 18:49
66 13 modified 25.3 KB 2025-12-20 18:49
65 12 modified 25.0 KB 2025-12-20 18:49
60 11 modified 24.5 KB 2025-12-20 18:32
59 10 modified 24.4 KB 2025-12-20 18:31
58 9 modified 24.3 KB 2025-12-20 18:31
57 8 modified 24.3 KB 2025-12-20 18:31
56 7 modified 24.0 KB 2025-12-20 18:31
55 6 modified 23.8 KB 2025-12-20 18:31
40 5 modified 24.1 KB 2025-12-20 17:24
39 4 modified 24.9 KB 2025-12-20 17:24
9 3 modified 24.9 KB 2025-12-20 16:32
5 2 modified 24.8 KB 2025-12-20 16:08
4 1 modified 24.8 KB 2025-12-20 16:08

← Zurück zur Übersicht