first commit

This commit is contained in:
team 1
2026-02-11 14:15:08 +01:00
parent a4742c2c38
commit aa7d362bc3
58 changed files with 9999 additions and 0 deletions

0
src/Controller/.gitignore vendored Normal file
View File

View File

@@ -0,0 +1,115 @@
<?php
declare(strict_types=1);
namespace App\Controller;
use App\Agent\AgentRunner;
use App\Http\ClientIdResolver;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\StreamedResponse;
use Symfony\Component\Routing\Annotation\Route;
final readonly class AskSseController
{
public function __construct(
private AgentRunner $agentRunner,
private ClientIdResolver $clientIdResolver,
) {}
#[Route('/ask-sse', name: 'ask_sse', methods: ['POST'])]
public function stream(Request $request): StreamedResponse
{
$data = json_decode($request->getContent(), true);
$prompt = trim((string) ($data['prompt'] ?? ''));
$cookieResponse = new Response();
$clientId = $this->clientIdResolver->resolve($request, $cookieResponse);
return new StreamedResponse(
function () use ($prompt, $clientId, $cookieResponse): void {
// ---------------------------------------------------------
// Disable all PHP output buffering
// ---------------------------------------------------------
while (ob_get_level() > 0) {
ob_end_flush();
}
// ---------------------------------------------------------
// Forward cookies
// ---------------------------------------------------------
foreach ($cookieResponse->headers->getCookies() as $cookie) {
header('Set-Cookie: ' . $cookie, false);
}
// ---------------------------------------------------------
// SSE prelude
// ---------------------------------------------------------
echo "retry: 3000\n\n";
flush();
if ($prompt === '') {
$this->sendEvent('error', 'Empty prompt');
return;
}
// ---------------------------------------------------------
// 🔥 FIXED: Sende Chunks direkt (behält \n!)
// ---------------------------------------------------------
foreach ($this->agentRunner->run($prompt, $clientId) as $chunk) {
// Normalize line endings
$chunk = str_replace(["\r\n", "\r"], "\n", $chunk);
// Sende Chunk direkt mit \n
$this->sendData($chunk);
}
// ---------------------------------------------------------
// Signal completion
// ---------------------------------------------------------
$this->sendEvent('done', '[DONE]');
},
200,
[
'Content-Type' => 'text/event-stream; charset=utf-8',
'Cache-Control' => 'no-cache, no-store, must-revalidate',
'Connection' => 'keep-alive',
'X-Accel-Buffering' => 'no',
]
);
}
/**
* FIXED: Behält Markdown-Struktur (\n) bei
*
* SSE erlaubt mehrere "data:"-Zeilen pro Event.
* Jede Zeile wird als separate data-Zeile gesendet.
*/
private function sendData(string $data): void
{
// Split by \n und sende jede Zeile einzeln
$lines = explode("\n", $data);
foreach ($lines as $line) {
echo 'data: ' . $line . "\n";
}
// Leere Zeile = Ende der SSE-Message
echo "\n\n";
flush();
}
/**
* Sends a named SSE event.
*/
private function sendEvent(string $event, string $data): void
{
$safe = str_replace(["\r", "\n"], ' ', $data);
echo "event: {$event}\n";
echo "data: {$safe}\n\n";
flush();
}
}

View File

@@ -0,0 +1,127 @@
<?php
declare(strict_types=1);
namespace App\Controller;
use App\Context\ContextService;
use App\Http\ClientIdResolver;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
/**
* HistoryController
*
* Read-only and destructive endpoints for conversation history.
*
* Responsibilities:
* - Expose stored chat history for frontend reload
* - Allow explicit deletion of the current client's history
*
* Identity handling:
* - Client identity is resolved exclusively via ClientIdResolver
* - No user identifiers are accepted from the request
*/
final class HistoryController
{
public function __construct(
private readonly ContextService $contextService,
private readonly ClientIdResolver $clientIdResolver,
) {}
/**
* Returns the full conversation history for the current client
* in a frontend-friendly structure.
*/
#[Route('/history', name: 'chat_history', methods: ['GET'])]
public function history(Request $request): JsonResponse
{
// Resolve client ID (cookie-based)
$response = new Response();
$clientId = $this->clientIdResolver->resolve($request, $response);
$raw = $this->contextService->buildUserContext($clientId, full: true);
if ($raw === '') {
return $this->jsonWithCookies([], $response);
}
$messages = [];
$lines = explode("\n", $raw);
$assistantBuffer = [];
foreach ($lines as $line) {
// User message
if (str_starts_with($line, 'Question: ')) {
// Flush previous assistant output
if ($assistantBuffer !== []) {
$messages[] = [
'role' => 'assistant',
'text' => trim(implode("\n", $assistantBuffer)),
];
$assistantBuffer = [];
}
$messages[] = [
'role' => 'user',
'text' => trim(substr($line, 10)),
];
continue;
}
// Assistant output (can span multiple lines)
if (trim($line) !== '') {
$assistantBuffer[] = $line;
}
}
// Flush trailing assistant output
if ($assistantBuffer !== []) {
$messages[] = [
'role' => 'assistant',
'text' => trim(implode("\n", $assistantBuffer)),
];
}
return $this->jsonWithCookies($messages, $response);
}
/**
* Deletes the complete conversation history for the current client.
*/
#[Route('/history/delete', name: 'delete_history', methods: ['POST'])]
public function delete(Request $request): JsonResponse
{
// Resolve client ID (cookie-based)
$response = new Response();
$clientId = $this->clientIdResolver->resolve($request, $response);
$this->contextService->deleteHistory($clientId);
return $this->jsonWithCookies(
[
'status' => 'ok',
'message' => 'History deleted',
],
$response
);
}
/**
* Helper to return JSON responses while forwarding cookies.
*/
private function jsonWithCookies(array $data, Response $cookieResponse): JsonResponse
{
$json = new JsonResponse($data);
foreach ($cookieResponse->headers->getCookies() as $cookie) {
$json->headers->setCookie($cookie);
}
return $json;
}
}