feat: add City10 platform with Laravel 13, AI agents, 3D viewer, and LGPD compliance
Initial commit of the complete City10 virtual city simulation platform. Backend (Laravel 13): - REST API with Sanctum tokens and session-based web authentication - Role-based access control (admin/user) with middleware protection - AI agent CRUD with OpenRouter.ai integration restricted to free models - Store management with 3D coordinates and AI shopkeeper assignment - Versioned legal documents (Terms, Privacy, Cookies) with LGPD audit trail - Math CAPTCHA on public forms and multi-language support (pt_BR, en, es) Frontend: - Responsive Blade templates with Tailwind CSS v4 - Three.js 3D city viewer with SSE chat streaming to AI agents - Admin panel for store and user administration Infrastructure: - MariaDB/MySQL database with comprehensive migrations - Database seeders for demo content and test accounts - PHPUnit test suite and Laravel Pint code styling - Vite build configuration with hot reload
This commit is contained in:
@@ -0,0 +1,204 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Web;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\AiAgent;
|
||||
use App\Models\Store;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\StreamedResponse;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* NPC chat endpoints consumed by the 3D city viewer.
|
||||
*
|
||||
* The POST endpoint accepts a user message, enqueues the OpenRouter call
|
||||
* asynchronously and immediately returns 202 so the UI can show a
|
||||
* "thinking" state. The GET endpoint streams agent replies as SSE events.
|
||||
* Only simulated city context (never personal data) is sent upstream,
|
||||
* as required by RULES.md.
|
||||
*/
|
||||
class ChatController extends Controller
|
||||
{
|
||||
/**
|
||||
* Send a message to an agent and trigger an asynchronous reply.
|
||||
*/
|
||||
public function send(Request $request, AiAgent $agent): Response
|
||||
{
|
||||
$data = $request->validate([
|
||||
'message' => ['required', 'string', 'max:2000'],
|
||||
]);
|
||||
|
||||
// Persist the conversation in the agent's last_decision for audit
|
||||
// and stream the pending state to any connected SSE listener.
|
||||
$history = $agent->last_decision ?? [];
|
||||
$history[] = ['role' => 'user', 'content' => $data['message'], 'at' => now()->toIso8601String()];
|
||||
$agent->update([
|
||||
'last_decision' => array_slice($history, -20),
|
||||
'status' => 'thinking',
|
||||
]);
|
||||
|
||||
self::publish($agent, ['type' => 'thinking', 'agent_id' => $agent->id, 'status' => 'thinking']);
|
||||
|
||||
// Fire the OpenRouter call without blocking the HTTP response.
|
||||
dispatch(function () use ($agent, $data): void {
|
||||
self::think($agent, $data['message']);
|
||||
});
|
||||
|
||||
return response()->json([
|
||||
'status' => 'accepted',
|
||||
'agent_id' => $agent->id,
|
||||
'message' => __('city.chat_accepted'),
|
||||
], 202);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream agent status and replies as Server-Sent Events.
|
||||
*/
|
||||
public function stream(Request $request, AiAgent $agent): StreamedResponse
|
||||
{
|
||||
$request->session()->save();
|
||||
|
||||
return response()->stream(function () use ($agent): void {
|
||||
echo "retry: 3000\n\n";
|
||||
flush();
|
||||
|
||||
$last = null;
|
||||
|
||||
while (true) {
|
||||
if (connection_aborted()) {
|
||||
break;
|
||||
}
|
||||
|
||||
// Poll the cache for a message published by think().
|
||||
$payload = cache()->pull("chat.{$agent->id}");
|
||||
|
||||
if ($payload !== null) {
|
||||
$json = json_encode($payload, JSON_UNESCAPED_UNICODE);
|
||||
echo "event: message\n";
|
||||
echo "data: {$json}\n\n";
|
||||
flush();
|
||||
$last = $payload;
|
||||
}
|
||||
|
||||
// Heartbeat keeps proxies from closing the stream.
|
||||
echo ": ping\n\n";
|
||||
flush();
|
||||
|
||||
sleep(2);
|
||||
}
|
||||
}, 200, [
|
||||
'Content-Type' => 'text/event-stream',
|
||||
'Cache-Control' => 'no-cache',
|
||||
'X-Accel-Buffering' => 'no',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Call OpenRouter with the agent persona and simulated city context.
|
||||
*
|
||||
* Never includes personal data — only city state (RULES.md 2.2).
|
||||
*/
|
||||
private static function think(AiAgent $agent, string $userMessage): void
|
||||
{
|
||||
try {
|
||||
$apiKey = config('ai.openrouter.api_key');
|
||||
|
||||
if (! filled($apiKey)) {
|
||||
self::fail($agent, 'OPENROUTER_API_KEY not configured');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Simulated city context only — no PII (RULES.md 2.2).
|
||||
$cityContext = [
|
||||
'stores' => Store::query()
|
||||
->get(['name', 'type', 'is_open'])
|
||||
->map(fn ($s) => ['name' => $s->name, 'type' => $s->type, 'open' => $s->is_open])
|
||||
->all(),
|
||||
'agent_name' => $agent->name,
|
||||
'agent_type' => $agent->type,
|
||||
'current_goal' => $agent->current_goal,
|
||||
];
|
||||
|
||||
$system = ($agent->system_prompt ?: "Você é {$agent->name}, um habitante da cidade virtual City10.")
|
||||
."\n\nContexto simulado da cidade: ".json_encode($cityContext, JSON_UNESCAPED_UNICODE)
|
||||
."\nResponda de forma curta e natural, em português. Não invente dados pessoais de usuários.";
|
||||
|
||||
$response = Http::withHeaders([
|
||||
'Authorization' => 'Bearer '.$apiKey,
|
||||
'HTTP-Referer' => config('ai.openrouter.app_url'),
|
||||
'X-Title' => config('ai.openrouter.app_name'),
|
||||
])->timeout((int) config('ai.openrouter.timeout', 30))
|
||||
->post(config('ai.openrouter.base_url').'/chat/completions', [
|
||||
'model' => $agent->model,
|
||||
'messages' => [
|
||||
['role' => 'system', 'content' => $system],
|
||||
['role' => 'user', 'content' => $userMessage],
|
||||
],
|
||||
'max_tokens' => 300,
|
||||
]);
|
||||
|
||||
if ($response->failed()) {
|
||||
self::fail($agent, 'HTTP '.$response->status());
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$reply = $response->json('choices.0.message.content') ?? __('city.chat_error');
|
||||
|
||||
$history = $agent->last_decision ?? [];
|
||||
$history[] = ['role' => 'assistant', 'content' => $reply, 'at' => now()->toIso8601String()];
|
||||
|
||||
$agent->update([
|
||||
'last_decision' => array_slice($history, -20),
|
||||
'status' => 'active',
|
||||
'error_count' => 0,
|
||||
'last_think_at' => now(),
|
||||
]);
|
||||
|
||||
self::publish($agent, [
|
||||
'type' => 'reply',
|
||||
'agent_id' => $agent->id,
|
||||
'name' => $agent->name,
|
||||
'reply' => $reply,
|
||||
'status' => 'active',
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('NPC think failed', ['agent' => $agent->id, 'error' => $e->getMessage()]);
|
||||
self::fail($agent, $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a failure with exponential backoff (RULES.md 2.4).
|
||||
*/
|
||||
private static function fail(AiAgent $agent, string $reason): void
|
||||
{
|
||||
$errors = (int) $agent->error_count + 1;
|
||||
$backoff = min(300, 2 ** min($errors, 8));
|
||||
|
||||
$agent->update([
|
||||
'status' => $errors >= 5 ? 'error' : 'active',
|
||||
'error_count' => $errors,
|
||||
]);
|
||||
|
||||
self::publish($agent, [
|
||||
'type' => 'error',
|
||||
'agent_id' => $agent->id,
|
||||
'reply' => __('city.chat_error'),
|
||||
'status' => $agent->status,
|
||||
'retry_after' => $backoff,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish a message for the SSE stream to pick up.
|
||||
*/
|
||||
private static function publish(AiAgent $agent, array $payload): void
|
||||
{
|
||||
cache()->put("chat.{$agent->id}", $payload, now()->addMinutes(5));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user