feat(city): add bump-to-talk conversations with persistent context
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.
This commit is contained in:
+195
-50
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user