diff --git a/.playwright-mcp/maria-resposta-ia-corrigida.png b/.playwright-mcp/maria-resposta-ia-corrigida.png new file mode 100644 index 0000000..456ab82 Binary files /dev/null and b/.playwright-mcp/maria-resposta-ia-corrigida.png differ diff --git a/.playwright-mcp/talk-person-chat-esbarramento.png b/.playwright-mcp/talk-person-chat-esbarramento.png new file mode 100644 index 0000000..48fc6c7 Binary files /dev/null and b/.playwright-mcp/talk-person-chat-esbarramento.png differ diff --git a/app/Http/Controllers/Web/PersonTalkController.php b/app/Http/Controllers/Web/PersonTalkController.php new file mode 100644 index 0000000..703b363 --- /dev/null +++ b/app/Http/Controllers/Web/PersonTalkController.php @@ -0,0 +1,535 @@ +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 + */ + 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 + */ + 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 $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, + ]); + } +} diff --git a/app/Models/PersonTalk.php b/app/Models/PersonTalk.php new file mode 100644 index 0000000..769ace4 --- /dev/null +++ b/app/Models/PersonTalk.php @@ -0,0 +1,119 @@ + + */ + protected function casts(): array + { + return [ + 'talked_on' => 'date', + 'is_fallback' => 'boolean', + ]; + } + + /** + * Character that sent the message. + * + * @return BelongsTo + */ + public function fromCharacter(): BelongsTo + { + return $this->belongsTo(Character::class, 'from_character_id'); + } + + /** + * Agent that sent the message. + * + * @return BelongsTo + */ + public function fromAgent(): BelongsTo + { + return $this->belongsTo(AiAgent::class, 'from_agent_id'); + } + + /** + * Character that received the message. + * + * @return BelongsTo + */ + public function toCharacter(): BelongsTo + { + return $this->belongsTo(Character::class, 'to_character_id'); + } + + /** + * Agent that received the message. + * + * @return BelongsTo + */ + public function toAgent(): BelongsTo + { + return $this->belongsTo(AiAgent::class, 'to_agent_id'); + } + + /** + * Ordered conversation rows between a character and a peer. + * + * @param int $characterId the local character + * @param string $peerKind PersonTalk::KIND_AGENT or PersonTalk::KIND_CHARACTER + * @param int $peerId agent id or character id of the peer + * @return Builder + */ + public static function conversation(int $characterId, string $peerKind, int $peerId): Builder + { + return self::query()->where(function (Builder $outer) use ($characterId, $peerKind, $peerId): void { + if ($peerKind === self::KIND_AGENT) { + $outer->where(function (Builder $q) use ($characterId, $peerId): void { + $q->where('from_character_id', $characterId)->where('to_agent_id', $peerId); + })->orWhere(function (Builder $q) use ($characterId, $peerId): void { + $q->where('from_agent_id', $peerId)->where('to_character_id', $characterId); + }); + + return; + } + + $outer->where(function (Builder $q) use ($characterId, $peerId): void { + $q->where('from_character_id', $characterId)->where('to_character_id', $peerId); + })->orWhere(function (Builder $q) use ($characterId, $peerId): void { + $q->where('from_character_id', $peerId)->where('to_character_id', $characterId); + }); + }); + } +} diff --git a/database/migrations/2026_10_31_200632_create_person_talks_table.php b/database/migrations/2026_10_31_200632_create_person_talks_table.php new file mode 100644 index 0000000..738f9a4 --- /dev/null +++ b/database/migrations/2026_10_31_200632_create_person_talks_table.php @@ -0,0 +1,46 @@ +id(); + $table->foreignId('from_character_id')->nullable()->constrained('characters')->cascadeOnDelete(); + $table->foreignId('from_agent_id')->nullable()->constrained('ai_agents')->cascadeOnDelete(); + $table->foreignId('to_character_id')->nullable()->constrained('characters')->cascadeOnDelete(); + $table->foreignId('to_agent_id')->nullable()->constrained('ai_agents')->cascadeOnDelete(); + $table->string('body', 1000); + $table->date('talked_on'); + $table->timestamps(); + + $table->index(['talked_on']); + $table->index(['from_character_id', 'to_character_id']); + $table->index(['from_character_id', 'to_agent_id']); + $table->index(['from_agent_id', 'to_character_id']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('person_talks'); + } +}; diff --git a/database/migrations/2026_10_31_204319_add_is_fallback_to_person_talks_table.php b/database/migrations/2026_10_31_204319_add_is_fallback_to_person_talks_table.php new file mode 100644 index 0000000..26b5f9d --- /dev/null +++ b/database/migrations/2026_10_31_204319_add_is_fallback_to_person_talks_table.php @@ -0,0 +1,35 @@ +boolean('is_fallback')->default(false); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('person_talks', function (Blueprint $table): void { + $table->dropColumn('is_fallback'); + }); + } +}; diff --git a/lang/en.json b/lang/en.json index 671cc61..ade7ed1 100755 --- a/lang/en.json +++ b/lang/en.json @@ -88,7 +88,7 @@ "city.error": "Failed to load city data.", "city.stores_count": "stores", "city.agents_count": "agents", - "city.hint": "Click the scene to control · WASD to walk · Mouse to look · Esc to exit", + "city.hint": "Click the scene to control · WASD to walk · Mouse to look · Esc to exit · Get close to someone to talk", "city.refresh": "Reload", "city.selected": "Selected", "city.open": "Open", @@ -102,6 +102,7 @@ "city.chat_responding": "responding…", "city.chat_accepted": "Message sent to agent.", "city.chat_error": "Sorry, I can't reply right now. Please try again.", + "city.talk_ratelimited": "Too many messages — wait a moment and try again.", "city.chat_send": "Send", "city.chat_with": "Chatting with", "city.clock": "Time", diff --git a/lang/es.json b/lang/es.json index 0edbff0..e3ae985 100755 --- a/lang/es.json +++ b/lang/es.json @@ -88,7 +88,7 @@ "city.error": "Error al cargar los datos de la ciudad.", "city.stores_count": "tiendas", "city.agents_count": "agentes", - "city.hint": "Haz clic en la escena para controlar · WASD para caminar · Ratón para mirar · Esc para salir", + "city.hint": "Haz clic en la escena para controlar · WASD para caminar · Ratón para mirar · Esc para salir · Acércate a alguien para hablar", "city.refresh": "Recargar", "city.selected": "Seleccionada", "city.open": "Abierta", @@ -102,6 +102,7 @@ "city.chat_responding": "respondiendo…", "city.chat_accepted": "Mensaje enviado al agente.", "city.chat_error": "Lo siento, no puedo responder ahora. Inténtalo de nuevo.", + "city.talk_ratelimited": "Muchos mensajes — espera un instante e inténtalo de nuevo.", "city.chat_send": "Enviar", "city.chat_with": "Chateando con", "city.clock": "Hora", diff --git a/lang/pt_BR.json b/lang/pt_BR.json index e46b1e4..fe304e1 100755 --- a/lang/pt_BR.json +++ b/lang/pt_BR.json @@ -88,7 +88,7 @@ "city.error": "Falha ao carregar os dados da cidade.", "city.stores_count": "lojas", "city.agents_count": "agentes", - "city.hint": "Clique na tela para controlar · WASD para andar · Mouse para olhar · Esc para sair", + "city.hint": "Clique na tela para controlar · WASD para andar · Mouse para olhar · Esc para sair · Chegue perto de alguém para conversar", "city.refresh": "Recarregar", "city.selected": "Selecionada", "city.open": "Aberta", @@ -102,6 +102,7 @@ "city.chat_responding": "respondendo…", "city.chat_accepted": "Mensagem enviada ao agente.", "city.chat_error": "Desculpe, não consegui responder agora. Tente novamente.", + "city.talk_ratelimited": "Muitas mensagens — aguarde um instante e tente de novo.", "city.chat_send": "Enviar", "city.chat_with": "Conversando com", "city.clock": "Horário", diff --git a/public/js/city/app.js b/public/js/city/app.js index ee30b43..50071d2 100755 --- a/public/js/city/app.js +++ b/public/js/city/app.js @@ -3524,7 +3524,13 @@ renderer.domElement.addEventListener('pointerup', (e) => { let obj = agentHits[0].object; while (obj && !obj.userData?.agent) obj = obj.parent; if (obj?.userData?.agent) { - openChat(obj.userData.agent); + const agent = obj.userData.agent; + openTalkChat({ + kind: 'agent', + id: agent.id, + name: agent.name, + status: agent.status || 'active', + }); return; } } @@ -3541,7 +3547,12 @@ renderer.domElement.addEventListener('pointerup', (e) => { }); // ------------------------------------------------------------------ -// ChatPanel +// ChatPanel — street conversations via /talk/person +// +// Opens automatically when the player bumps into another citizen +// (human peer) or agentic NPC, polls for replies while open, closes +// when the pair moves apart, and renders the daily history that backs +// the persistent context of future conversations. // ------------------------------------------------------------------ const chatPanel = document.getElementById('chat-panel'); const chatDot = document.getElementById('chat-dot'); @@ -3553,8 +3564,24 @@ const chatMessages = document.getElementById('chat-messages'); const chatForm = document.getElementById('chat-form'); const chatInput = document.getElementById('chat-input'); -let currentAgent = null; -let eventSource = null; +const TALK_OPEN_RADIUS = 2.6; +const TALK_CLOSE_RADIUS = 3.6; +const TALK_POLL_MS = 2500; + +let currentPeer = null; // { kind, id, name, status, key } +let talkPollTimer = null; +let talkLastDay = null; +let talkErrorShown = false; +let dismissedPeerKey = null; +const talkSeenIds = new Set(); + +function peerKey(kind, id) { + return `${kind}:${id}`; +} + +function clockNow() { + return new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); +} function addBubble(text, who) { const div = document.createElement('div'); @@ -3564,9 +3591,18 @@ function addBubble(text, who) { chatMessages.scrollTop = chatMessages.scrollHeight; } +function addDaySeparator(day) { + const div = document.createElement('div'); + div.className = 'chat-bubble meta'; + const parsed = new Date(`${day}T12:00:00`); + div.textContent = Number.isNaN(parsed.getTime()) ? day : parsed.toLocaleDateString(); + chatMessages.appendChild(div); + chatMessages.scrollTop = chatMessages.scrollHeight; +} + function setStatus(status) { - if (!currentAgent) return; - currentAgent.status = status; + if (!currentPeer) return; + currentPeer.status = status; const map = { thinking: ['#f59e0b', i18n.chatThinking || 'thinking…'], responding: ['#f59e0b', i18n.chatResponding || 'responding…'], @@ -3576,69 +3612,175 @@ function setStatus(status) { const [color, label] = map[status] || map.active; chatDot.style.background = color; chatStatus.textContent = label; - const mesh = agentMeshes.find(m => m.userData?.agent?.id === currentAgent.id); - if (mesh) refreshLabel(mesh, status); + if (currentPeer.kind === 'agent') { + const mesh = agentMeshes.find(m => m.userData?.agent?.id === currentPeer.id); + if (mesh) refreshLabel(mesh, status); + } } -function openChat(agent) { - currentAgent = agent; - chatPanel.style.display = 'flex'; - chatName.textContent = agent.name; - chatTime.textContent = new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); - chatMessages.innerHTML = ''; - - const history = agent.last_decision || []; - history.forEach(m => { - if (m.role === 'user') addBubble(m.content, 'user'); - else if (m.role === 'assistant') addBubble(m.content, 'npc'); - }); - - setStatus(agent.status || 'active'); - - if (eventSource) { eventSource.close(); eventSource = null; } - eventSource = new EventSource(`/city/chat/${agent.id}/stream`); - eventSource.addEventListener('message', (e) => { - try { - const payload = JSON.parse(e.data); - if (payload.type === 'thinking') { - setStatus('thinking'); - addBubble(i18n.chatThinking || 'thinking…', 'meta'); - } else if (payload.type === 'reply') { - setStatus('active'); - addBubble(payload.reply, 'npc'); - chatTime.textContent = new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); - } else if (payload.type === 'error') { - setStatus('error'); - addBubble(payload.reply || i18n.chatError || 'error', 'meta'); +/** Poll the daily conversation and append anything not seen yet. */ +async function pullTalk() { + if (!currentPeer) return; + const peer = currentPeer; + try { + const res = await fetch(`/talk/person?kind=${encodeURIComponent(peer.kind)}&peer=${encodeURIComponent(peer.id)}`, { + headers: { 'Accept': 'application/json', 'X-Requested-With': 'XMLHttpRequest' }, + cache: 'no-store', + }); + if (!res.ok || !currentPeer || currentPeer.key !== peer.key) return; + const data = await res.json(); + const messages = data.messages || []; + let arrived = false; + for (const m of messages) { + if (talkSeenIds.has(m.id)) continue; + if (m.talked_on && m.talked_on !== talkLastDay) { + talkLastDay = m.talked_on; + addDaySeparator(m.talked_on); } - } catch (_) { /* ignore */ } - }); - eventSource.onerror = () => { /* auto-retry */ }; + talkSeenIds.add(m.id); + addBubble(m.body, m.fallback ? 'meta' : (m.from === 'me' ? 'user' : 'npc')); + if (m.at) chatTime.textContent = new Date(m.at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); + arrived = true; + } + if (data.thinking) { + setStatus('thinking'); + } else if (peer.kind === 'agent') { + setStatus(data.peer?.status || 'active'); + const last = messages[messages.length - 1]; + if (data.peer?.status === 'error' && last?.from === 'me' && !talkErrorShown) { + talkErrorShown = true; + addBubble(i18n.chatError || 'error', 'meta'); + } + } else if (currentPeer.status === 'thinking') { + setStatus('active'); + } + if (arrived) talkErrorShown = false; + } catch (_) { /* offline-safe */ } } -function closeChat() { +async function openTalkChat(peer) { + if (currentPeer && currentPeer.key === peerKey(peer.kind, peer.id)) return; + currentPeer = { ...peer, key: peerKey(peer.kind, peer.id) }; + talkSeenIds.clear(); + talkLastDay = null; + talkErrorShown = false; + chatMessages.innerHTML = ''; + chatPanel.style.display = 'flex'; + chatName.textContent = peer.name; + chatTime.textContent = clockNow(); + setStatus(peer.status || 'active'); + if (talkPollTimer) clearInterval(talkPollTimer); + talkPollTimer = setInterval(pullTalk, TALK_POLL_MS); + await pullTalk(); +} + +function closeChat(manual = false) { chatPanel.style.display = 'none'; - currentAgent = null; - if (eventSource) { eventSource.close(); eventSource = null; } + if (manual && currentPeer) dismissedPeerKey = currentPeer.key; + currentPeer = null; + if (talkPollTimer) { + clearInterval(talkPollTimer); + talkPollTimer = null; + } } -chatClose.addEventListener('click', closeChat); +// ------------------------------------------------------------------ +// Bump detection: auto-open near a citizen, auto-close when apart +// ------------------------------------------------------------------ +const _talkPos = new THREE.Vector3(); +let talkProximityTimer = 0; + +function talkCandidates() { + const out = []; + for (const mesh of agentMeshes) { + const agent = mesh.userData?.agent; + if (!agent || typeof agent.id !== 'number') continue; + mesh.getWorldPosition(_talkPos); + out.push({ + kind: 'agent', + id: agent.id, + name: agent.name, + status: agent.status || 'active', + d: Math.hypot(_talkPos.x - player.pos.x, _talkPos.z - player.pos.z), + }); + } + for (const mesh of remoteCitizens.values()) { + const citizen = mesh.userData?.citizen; + if (!citizen) continue; + out.push({ + kind: 'character', + id: citizen.id, + name: citizen.name, + status: 'active', + d: Math.hypot(mesh.position.x - player.pos.x, mesh.position.z - player.pos.z), + }); + } + return out; +} + +function updateTalkProximity(dt) { + talkProximityTimer -= dt; + if (talkProximityTimer > 0) return; + talkProximityTimer = 0.25; + + const candidates = talkCandidates(); + + // A manually dismissed chat only reopens after a fresh bump. + if (dismissedPeerKey) { + const dis = candidates.find(c => peerKey(c.kind, c.id) === dismissedPeerKey); + if (!dis || dis.d > TALK_CLOSE_RADIUS) dismissedPeerKey = null; + } + + if (currentPeer) { + const cur = candidates.find(c => peerKey(c.kind, c.id) === currentPeer.key); + if (!cur || cur.d > TALK_CLOSE_RADIUS) closeChat(false); + return; + } + + let best = null; + for (const c of candidates) { + if (!best || c.d < best.d) best = c; + } + if (best && best.d <= TALK_OPEN_RADIUS && peerKey(best.kind, best.id) !== dismissedPeerKey) { + openTalkChat(best); + } +} + +chatClose.addEventListener('click', () => closeChat(true)); chatForm.addEventListener('submit', async (e) => { e.preventDefault(); const msg = chatInput.value.trim(); - if (!msg || !currentAgent) return; + if (!msg || !currentPeer) return; chatInput.value = ''; - addBubble(msg, 'user'); - setStatus('thinking'); + if (currentPeer.kind === 'agent') setStatus('thinking'); + talkErrorShown = false; try { - await fetch(`/city/chat/${currentAgent.id}`, { + const res = await fetch('/talk/person', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': csrf, 'Accept': 'application/json' }, - body: JSON.stringify({ message: msg }), + body: JSON.stringify({ kind: currentPeer.kind, peer: currentPeer.id, message: msg }), }); + if (res.ok) { + const data = await res.json(); + // Day separator first so live and polled rows read in order. + if (data.talked_on && data.talked_on !== talkLastDay) { + talkLastDay = data.talked_on; + addDaySeparator(data.talked_on); + } + addBubble(msg, 'user'); + if (data.id) talkSeenIds.add(data.id); + if (currentPeer.kind !== 'agent') chatTime.textContent = clockNow(); + } else { + // 429 has its own cause — do not reuse the AI fallback text. + addBubble(res.status === 429 + ? (i18n.talkRateLimited || 'rate limited') + : (i18n.chatError || 'error'), 'meta'); + if (currentPeer.kind === 'agent') setStatus(currentPeer.status || 'active'); + } } catch (_) { addBubble(i18n.chatError || 'error', 'meta'); + if (currentPeer.kind === 'agent') setStatus(currentPeer.status || 'active'); } }); @@ -3982,6 +4124,7 @@ function animate() { updateHUD(); updateVehicleHeadlights(); syncPositionThrottled(); + updateTalkProximity(dt); drawMinimap(); // Footsteps while the local player is actually walking (not just turning). @@ -4013,6 +4156,8 @@ function upsertRemoteCitizen(c) { status: c.status || 'active', id: c.id, }, true); + // Human peer marker used by the bump-to-talk detection. + mesh.userData.citizen = { kind: 'character', id: c.id, name: c.name }; const [cx, , cz] = c.position; mesh.position.set(cx, 0, cz); worldGroup.add(mesh); diff --git a/resources/views/city/viewer.blade.php b/resources/views/city/viewer.blade.php index 746d75a..7a3eefc 100755 --- a/resources/views/city/viewer.blade.php +++ b/resources/views/city/viewer.blade.php @@ -237,14 +237,15 @@ padding:.35rem .6rem; font-size:.75rem; color:var(--muted);">{{ __('city.hint') }} - {{-- Chat panel (bottom-right, like example images) --}} + {{-- Chat panel (bottom-right, transparent — opens on bump) --}}