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
205 lines
6.9 KiB
PHP
205 lines
6.9 KiB
PHP
<?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));
|
|
}
|
|
}
|