Implements proximity-based street conversations between player characters and peers (human or agentic NPC). New PersonTalk model, controller, and migrations store daily message threads that persist across sessions and provide context for future interactions. Agent replies include backoff throttling and fallback persona handling to avoid dead-end chats. Frontend changes refactor the chat panel to support both character and agent peers, opening automatically when the player moves within range, polling for replies, rendering day separators, and displaying rate-limit warnings. Styling updated to a transparent blurred panel. Translations extended with new hint text and rate-limit messages across en, es, and pt_BR.
536 lines
20 KiB
PHP
536 lines
20 KiB
PHP
<?php
|
|
|
|
namespace App\Http\Controllers\Web;
|
|
|
|
use App\Http\Controllers\Controller;
|
|
use App\Models\AiAgent;
|
|
use App\Models\Character;
|
|
use App\Models\PersonTalk;
|
|
use App\Models\Store;
|
|
use Illuminate\Http\JsonResponse;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Support\Facades\Db;
|
|
use Illuminate\Support\Facades\Http;
|
|
use Illuminate\Support\Facades\Log;
|
|
|
|
/**
|
|
* Daily street conversations between the local character and another
|
|
* character (human peer) or an agentic NPC.
|
|
*
|
|
* GET /talk/person — conversation history (all days) used both by the
|
|
* chat UI and as the persistent context that keeps the thread of previous
|
|
* conversations. POST /talk/person — registers one message per day; for
|
|
* agent peers it also triggers the OpenRouter reply asynchronously.
|
|
*
|
|
* Peers without a live human session (seeded citizens, NPCs, idle demo
|
|
* accounts) answer through a generic city-inhabitant persona so chats
|
|
* never dead-end; a peer with a recent session is relayed as-is and is
|
|
* never impersonated.
|
|
*
|
|
* Only simulated city context and the dialogue itself are sent upstream,
|
|
* never registration data (RULES.md 2.2). Failures are retried with
|
|
* backoff bookkeeping (RULES.md 2.4) and decisions are persisted for
|
|
* audit (RULES.md 2.5).
|
|
*/
|
|
class PersonTalkController extends Controller
|
|
{
|
|
/**
|
|
* Maximum messages returned for the UI and model context.
|
|
*
|
|
* @var int
|
|
*/
|
|
private const HISTORY_LIMIT = 300;
|
|
|
|
/**
|
|
* Per-peer spacing between two reasoning calls, in seconds.
|
|
*
|
|
* Conversational replies are user-driven (one call per message), and
|
|
* this damper keeps rapid bursts from hammering the upstream API.
|
|
*
|
|
* @var int
|
|
*/
|
|
private const THINK_SPACING_SECONDS = 3;
|
|
|
|
/**
|
|
* How recent a peer session must be to count as a live human.
|
|
*
|
|
* @var int
|
|
*/
|
|
private const LIVE_PEER_SECONDS = 300;
|
|
|
|
/**
|
|
* Conversation history (newest last) plus the peer summary.
|
|
*/
|
|
public function index(Request $request): JsonResponse
|
|
{
|
|
$me = Character::ensureForUser($request->user());
|
|
[$kind, $peer] = $this->resolvePeer($request);
|
|
|
|
$rows = PersonTalk::conversation($me->id, $kind, (int) $peer->id)
|
|
->orderBy('id')
|
|
->limit(self::HISTORY_LIMIT)
|
|
->get();
|
|
|
|
$messages = $rows->map(fn (PersonTalk $talk): array => [
|
|
'id' => (int) $talk->id,
|
|
'from' => (int) $talk->from_character_id === (int) $me->id ? 'me' : 'them',
|
|
'body' => $talk->body,
|
|
'at' => $talk->created_at?->toIso8601String(),
|
|
'talked_on' => $talk->talked_on?->toDateString(),
|
|
'fallback' => (bool) $talk->is_fallback,
|
|
])->values();
|
|
|
|
$last = $messages->last();
|
|
$awaitingMe = $last !== null && $last['from'] === 'me';
|
|
$thinking = $kind === PersonTalk::KIND_AGENT
|
|
? $awaitingMe
|
|
: ($awaitingMe && cache()->has(self::pendingKey((int) $peer->id)));
|
|
|
|
return response()->json([
|
|
'me' => ['character_id' => (int) $me->id, 'name' => $me->name],
|
|
'peer' => [
|
|
'kind' => $kind,
|
|
'id' => (int) $peer->id,
|
|
'name' => $peer->name,
|
|
'status' => $kind === PersonTalk::KIND_AGENT ? $peer->status : 'active',
|
|
],
|
|
'messages' => $messages,
|
|
'thinking' => $thinking,
|
|
])->header('Cache-Control', 'no-store');
|
|
}
|
|
|
|
/**
|
|
* Register one message of the day and reply when nobody human is
|
|
* manning the other side.
|
|
*/
|
|
public function send(Request $request): JsonResponse
|
|
{
|
|
$me = Character::ensureForUser($request->user());
|
|
|
|
$data = $request->validate([
|
|
'message' => ['required', 'string', 'max:1000'],
|
|
]);
|
|
|
|
[$kind, $peer] = $this->resolvePeer($request);
|
|
|
|
if ($kind === PersonTalk::KIND_CHARACTER && (int) $peer->id === (int) $me->id) {
|
|
return response()->json(['message' => __('city.social_self')], 422);
|
|
}
|
|
|
|
$talk = PersonTalk::create([
|
|
'from_character_id' => $me->id,
|
|
'to_character_id' => $kind === PersonTalk::KIND_CHARACTER ? $peer->id : null,
|
|
'to_agent_id' => $kind === PersonTalk::KIND_AGENT ? $peer->id : null,
|
|
'body' => $data['message'],
|
|
'talked_on' => now()->toDateString(),
|
|
]);
|
|
|
|
$talkId = (int) $talk->id;
|
|
|
|
if ($kind === PersonTalk::KIND_AGENT) {
|
|
/** @var AiAgent $peer */
|
|
$peer->update(['status' => 'thinking']);
|
|
|
|
// Answer after the HTTP response so the chat stays snappy;
|
|
// runs in-process (no queue worker required).
|
|
dispatch(function () use ($talkId): void {
|
|
self::think($talkId);
|
|
})->afterResponse();
|
|
} elseif (! self::characterPeerIsLive($peer)) {
|
|
// No live human on the other side (seeded citizen, NPC child,
|
|
// demo account): answer through the generic persona so the
|
|
// conversation keeps flowing.
|
|
/** @var Character $peer */
|
|
cache()->put(self::pendingKey((int) $peer->id), 1, now()->addSeconds(60));
|
|
|
|
dispatch(function () use ($talkId): void {
|
|
self::thinkAsCitizen($talkId);
|
|
})->afterResponse();
|
|
}
|
|
|
|
return response()->json([
|
|
'id' => (int) $talk->id,
|
|
'from' => 'me',
|
|
'body' => $talk->body,
|
|
'at' => $talk->created_at?->toIso8601String(),
|
|
'talked_on' => $talk->talked_on->toDateString(),
|
|
], 201);
|
|
}
|
|
|
|
/**
|
|
* Generate the agent reply for a pending talk message.
|
|
*
|
|
* Public static so it can be exercised directly in tests with a
|
|
* faked HTTP client. Reads the whole daily conversation from the
|
|
* database, which keeps the thread of previous days intact.
|
|
*/
|
|
public static function think(int $talkId): void
|
|
{
|
|
try {
|
|
$talk = PersonTalk::query()->find($talkId);
|
|
if ($talk === null || $talk->to_agent_id === null) {
|
|
return;
|
|
}
|
|
|
|
$agent = AiAgent::query()->find($talk->to_agent_id);
|
|
$me = Character::query()->find($talk->from_character_id);
|
|
if ($agent === null || $me === null) {
|
|
return;
|
|
}
|
|
|
|
if (! self::acquireThinkLock('agent.'.$agent->id)) {
|
|
return;
|
|
}
|
|
|
|
// Another reply already landed after this message (serialised
|
|
// bursts) — nothing left to answer.
|
|
$answered = PersonTalk::conversation($me->id, PersonTalk::KIND_AGENT, (int) $agent->id)
|
|
->where('from_agent_id', $agent->id)
|
|
->where('id', '>', $talk->id)
|
|
->exists();
|
|
if ($answered) {
|
|
return;
|
|
}
|
|
|
|
if (! filled(config('ai.openrouter.api_key'))) {
|
|
self::recordAgentFailure($agent);
|
|
self::storeReply(
|
|
['from_agent_id' => $agent->id, 'to_character_id' => $me->id],
|
|
__('city.chat_error'),
|
|
true
|
|
);
|
|
|
|
return;
|
|
}
|
|
|
|
$system = ($agent->system_prompt ?: "Você é {$agent->name}, um habitante da cidade virtual City10.")
|
|
."\n\nContexto simulado da cidade: ".json_encode(self::simulatedCityContext(), JSON_UNESCAPED_UNICODE)
|
|
."\nAs mensagens abaixo são o histórico da conversa de rua com este cidadão, incluindo dias anteriores."
|
|
.' Mantenha o fio da meada: referencie o que já foi dito quando fizer sentido.'
|
|
."\nResponda de forma curta e natural, em português. Não invente dados pessoais de usuários.";
|
|
|
|
$reply = self::askModel($agent, $system, $me->id, $talk);
|
|
if ($reply === null) {
|
|
self::recordAgentFailure($agent);
|
|
self::storeReply(
|
|
['from_agent_id' => $agent->id, 'to_character_id' => $me->id],
|
|
__('city.chat_error'),
|
|
true
|
|
);
|
|
|
|
return;
|
|
}
|
|
|
|
self::storeReply(
|
|
['from_agent_id' => $agent->id, 'to_character_id' => $me->id],
|
|
$reply
|
|
);
|
|
|
|
$historyLog = $agent->last_decision ?? [];
|
|
$historyLog[] = ['role' => 'user', 'content' => $talk->body, 'at' => $talk->created_at?->toIso8601String()];
|
|
$historyLog[] = ['role' => 'assistant', 'content' => $reply, 'at' => now()->toIso8601String()];
|
|
|
|
$agent->update([
|
|
'last_decision' => array_slice($historyLog, -20),
|
|
'status' => 'active',
|
|
'error_count' => 0,
|
|
'last_think_at' => now(),
|
|
]);
|
|
} catch (\Throwable $e) {
|
|
Log::error('Person talk think failed', ['talk' => $talkId, 'error' => $e->getMessage()]);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Generate a reply on behalf of a character peer that has no live
|
|
* human behind it, using an anonymous city-inhabitant persona.
|
|
*
|
|
* The payload never includes registration data — no names, e-mails
|
|
* or any other PII (RULES.md 2.2); only simulated city context and
|
|
* the dialogue itself travel upstream.
|
|
*/
|
|
public static function thinkAsCitizen(int $talkId): void
|
|
{
|
|
$pendingKey = null;
|
|
|
|
try {
|
|
$talk = PersonTalk::query()->find($talkId);
|
|
if ($talk === null || $talk->to_character_id === null) {
|
|
return;
|
|
}
|
|
|
|
$citizen = Character::query()->find($talk->to_character_id);
|
|
$me = Character::query()->find($talk->from_character_id);
|
|
if ($citizen === null || $me === null) {
|
|
return;
|
|
}
|
|
|
|
$pendingKey = self::pendingKey((int) $citizen->id);
|
|
$replySides = [
|
|
'from_character_id' => $citizen->id,
|
|
'to_character_id' => $me->id,
|
|
];
|
|
|
|
if (! self::acquireThinkLock('character.'.$citizen->id)) {
|
|
return;
|
|
}
|
|
|
|
// A human (or a serialised sibling job) already answered.
|
|
$answered = PersonTalk::conversation($me->id, PersonTalk::KIND_CHARACTER, (int) $citizen->id)
|
|
->where('from_character_id', $citizen->id)
|
|
->where('id', '>', $talk->id)
|
|
->exists();
|
|
if ($answered) {
|
|
cache()->forget($pendingKey);
|
|
|
|
return;
|
|
}
|
|
|
|
if (! filled(config('ai.openrouter.api_key'))) {
|
|
self::storeReply($replySides, __('city.chat_error'), true);
|
|
cache()->forget($pendingKey);
|
|
|
|
return;
|
|
}
|
|
|
|
// Persona plays the citizen in-world: no registration data
|
|
// travels upstream (RULES.md 2.2) — only simulated city state
|
|
// and the dialogue itself, which already carries whatever the
|
|
// peers chose to type.
|
|
$system = 'Você é um morador da cidade virtual City10, conversando na rua com alguém que chegou perto de você.'
|
|
."\n\nContexto simulado da cidade: ".json_encode(self::simulatedCityContext(), JSON_UNESCAPED_UNICODE)
|
|
."\nAs mensagens abaixo são o histórico real da conversa, inclusive de dias anteriores:"
|
|
.' mantenha o fio da meada e referencie o que já foi dito quando couber.'
|
|
."\nSe te chamarem por um nome na conversa, aquele é o seu nome — aceite-o e não se diga outra pessoa nem se recuse a responder."
|
|
."\nFrases curtas e naturais, tom de vizinho conversando na rua; nada de linguagem de atendimento, como 'como posso ajudar'."
|
|
."\nNunca forneça nem invente e-mail, senha, endereço, telefone ou dados de conta de ninguém."
|
|
."\nResponda em português.";
|
|
|
|
$reply = self::askModel(null, $system, $me->id, $talk);
|
|
if ($reply === null) {
|
|
self::storeReply($replySides, __('city.chat_error'), true);
|
|
cache()->forget($pendingKey);
|
|
|
|
return;
|
|
}
|
|
|
|
self::storeReply($replySides, $reply);
|
|
cache()->forget($pendingKey);
|
|
} catch (\Throwable $e) {
|
|
Log::error('Citizen talk think failed', ['talk' => $talkId, 'error' => $e->getMessage()]);
|
|
if ($pendingKey !== null) {
|
|
cache()->forget($pendingKey);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Validate and resolve the peer query parameters (kind + id).
|
|
*
|
|
* @return array{0: string, 1: AiAgent|Character}
|
|
*/
|
|
private function resolvePeer(Request $request): array
|
|
{
|
|
$data = $request->validate([
|
|
'kind' => ['required', 'in:agent,character'],
|
|
'peer' => ['required', 'integer', 'min:1'],
|
|
]);
|
|
|
|
$peer = $data['kind'] === PersonTalk::KIND_AGENT
|
|
? AiAgent::query()->findOrFail($data['peer'])
|
|
: Character::query()->findOrFail($data['peer']);
|
|
|
|
return [$data['kind'], $peer];
|
|
}
|
|
|
|
/**
|
|
* Whether a live human session currently drives this character.
|
|
*
|
|
* Based on the sessions table: a peer with a session touched within
|
|
* LIVE_PEER_SECONDS answers for itself and is never impersonated.
|
|
* Everything else (seeded citizens, NPC children, demo accounts) is
|
|
* answered by the generic persona.
|
|
*/
|
|
private static function characterPeerIsLive(Character $peer): bool
|
|
{
|
|
if ($peer->user_id === null) {
|
|
return false;
|
|
}
|
|
|
|
try {
|
|
$last = Db::table('sessions')
|
|
->where('user_id', $peer->user_id)
|
|
->max('last_activity');
|
|
} catch (\Throwable) {
|
|
return false;
|
|
}
|
|
|
|
return $last !== null
|
|
&& (int) $last >= now()->getTimestamp() - self::LIVE_PEER_SECONDS;
|
|
}
|
|
|
|
/**
|
|
* Cache key of a pending auto-reply for a character peer.
|
|
*/
|
|
private static function pendingKey(int $characterId): string
|
|
{
|
|
return 'talk.pending.c.'.$characterId;
|
|
}
|
|
|
|
/**
|
|
* Space out reasoning calls per peer (RULES.md 2.3 in spirit: never
|
|
* repeat API calls at high frequency for the same counterpart).
|
|
*/
|
|
private static function acquireThinkLock(string $scope): bool
|
|
{
|
|
$lockKey = 'talk.think.'.$scope;
|
|
$deadline = microtime(true) + (self::THINK_SPACING_SECONDS * 2);
|
|
while (! cache()->add($lockKey, 1, self::THINK_SPACING_SECONDS) && microtime(true) < $deadline) {
|
|
usleep(400000);
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Simulated city snapshot sent upstream — no PII (RULES.md 2.2).
|
|
*
|
|
* @return array<string, mixed>
|
|
*/
|
|
private static function simulatedCityContext(): array
|
|
{
|
|
return [
|
|
'stores' => Store::query()
|
|
->get(['name', 'type', 'is_open'])
|
|
->map(fn ($s): array => ['name' => $s->name, 'type' => $s->type, 'open' => $s->is_open])
|
|
->all(),
|
|
'clock' => now()->toTimeString(),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Call OpenRouter with the given system prompt and the full daily
|
|
* conversation of the pair. Returns null on any failure.
|
|
*/
|
|
private static function askModel(?AiAgent $agent, string $system, int $meCharacterId, PersonTalk $talk): ?string
|
|
{
|
|
$peerKind = $talk->to_agent_id !== null ? PersonTalk::KIND_AGENT : PersonTalk::KIND_CHARACTER;
|
|
$peerId = (int) ($talk->to_agent_id ?? $talk->to_character_id);
|
|
|
|
$history = PersonTalk::conversation($meCharacterId, $peerKind, $peerId)
|
|
// Error placeholders are UI notes, never part of the dialogue.
|
|
// Legacy rows (stored before the flag existed) are matched by
|
|
// their localized body so old apologies stay excluded too.
|
|
->where('is_fallback', false)
|
|
->whereNotIn('body', self::fallbackBodies())
|
|
->orderBy('id')
|
|
->limit(80)
|
|
->get();
|
|
|
|
$messages = [['role' => 'system', 'content' => $system]];
|
|
foreach ($history as $row) {
|
|
$role = (int) $row->from_character_id === $meCharacterId ? 'user' : 'assistant';
|
|
$lastIdx = count($messages) - 1;
|
|
if ($lastIdx > 0 && $messages[$lastIdx]['role'] === $role) {
|
|
$messages[$lastIdx]['content'] .= "\n".$row->body;
|
|
} else {
|
|
$messages[] = ['role' => $role, 'content' => $row->body];
|
|
}
|
|
}
|
|
|
|
$model = ($agent === null ? null : $agent->model);
|
|
if (! in_array($model, config('ai.allowed_models', []), true)) {
|
|
$model = config('ai.default_model');
|
|
}
|
|
|
|
// Cap the upstream wait below PHP's default execution limit so
|
|
// the after-response job is never killed mid-flight.
|
|
$timeout = min((int) config('ai.openrouter.timeout', 30), 20);
|
|
|
|
try {
|
|
$response = Http::withHeaders([
|
|
'Authorization' => 'Bearer '.config('ai.openrouter.api_key'),
|
|
'HTTP-Referer' => config('ai.openrouter.app_url'),
|
|
'X-Title' => config('ai.openrouter.app_name'),
|
|
])->timeout($timeout)
|
|
->post(config('ai.openrouter.base_url').'/chat/completions', [
|
|
'model' => $model,
|
|
'messages' => $messages,
|
|
// Room for models that reason before answering — too low
|
|
// a budget starves the visible content (e.g. "Ah").
|
|
'max_tokens' => 700,
|
|
]);
|
|
} catch (\Throwable $e) {
|
|
Log::warning('Person talk upstream exception', ['error' => $e->getMessage()]);
|
|
|
|
return null;
|
|
}
|
|
|
|
if ($response->failed()) {
|
|
Log::warning('Person talk upstream failed', ['status' => $response->status()]);
|
|
|
|
return null;
|
|
}
|
|
|
|
$reply = $response->json('choices.0.message.content');
|
|
|
|
// Some models return content as an array of text parts.
|
|
if (is_array($reply)) {
|
|
$reply = implode('', array_map(
|
|
static fn ($part): string => is_array($part) ? (string) ($part['text'] ?? '') : (string) $part,
|
|
$reply
|
|
));
|
|
}
|
|
|
|
if (is_string($reply) && mb_strlen(trim($reply)) < 10) {
|
|
Log::warning('Person talk suspiciously short reply', [
|
|
'finish_reason' => $response->json('choices.0.finish_reason'),
|
|
'raw' => substr((string) json_encode($response->json('choices.0.message'), JSON_UNESCAPED_UNICODE), 0, 800),
|
|
]);
|
|
}
|
|
|
|
return is_string($reply) && trim($reply) !== '' ? $reply : null;
|
|
}
|
|
|
|
/**
|
|
* Localized error-fallback bodies (current and past locales).
|
|
*
|
|
* @return list<string>
|
|
*/
|
|
private static function fallbackBodies(): array
|
|
{
|
|
return array_values(array_unique(array_filter([
|
|
__('city.chat_error'),
|
|
__('city.chat_error', [], 'en'),
|
|
__('city.chat_error', [], 'es'),
|
|
])));
|
|
}
|
|
|
|
/**
|
|
* Persist one side of the conversation.
|
|
*
|
|
* @param array<string, int|null> $sides from/to identifiers
|
|
* @param bool $isFallback stored error placeholder (kept out of model context)
|
|
*/
|
|
private static function storeReply(array $sides, string $body, bool $isFallback = false): PersonTalk
|
|
{
|
|
return PersonTalk::create($sides + [
|
|
'body' => $body,
|
|
'talked_on' => now()->toDateString(),
|
|
'is_fallback' => $isFallback,
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Count a failed attempt against the agent (RULES.md 2.4).
|
|
*/
|
|
private static function recordAgentFailure(AiAgent $agent): void
|
|
{
|
|
$errors = (int) $agent->error_count + 1;
|
|
$agent->update([
|
|
'status' => $errors >= 5 ? 'error' : 'active',
|
|
'error_count' => $errors,
|
|
]);
|
|
}
|
|
}
|