File size: 10,308 Bytes
1794757 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 | "use client";
import { useRef, useState, useEffect } from "react";
import { Send, X, Loader2 } from "lucide-react";
import gsap from "gsap";
import { cn } from "@/src/lib/utils";
import { getRuntimeEnv } from "@/src/lib/env";
type Message = {
id: string;
role: "user" | "assistant" | "system";
content: string;
timestamp: number;
};
interface ChatPanelProps {
open: boolean;
onClose: () => void;
sessionId?: string | null;
}
export function ChatPanel({ open, onClose, sessionId }: ChatPanelProps) {
const panelRef = useRef<HTMLDivElement>(null);
const messagesEndRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const [messages, setMessages] = useState<Message[]>([
{
id: "sys-0",
role: "system",
content: "TRENCHES AI — Ask about the simulation, agent behaviors, tensions, or world state.",
timestamp: Date.now(),
},
]);
const [input, setInput] = useState("");
const [loading, setLoading] = useState(false);
// GSAP open/close animation
useEffect(() => {
if (!panelRef.current) return;
if (open) {
gsap.fromTo(
panelRef.current,
{ y: 40, opacity: 0, scale: 0.95, backdropFilter: "blur(0px)", pointerEvents: "none" },
{
y: 0,
opacity: 1,
scale: 1,
backdropFilter: "blur(16px)",
pointerEvents: "auto",
duration: 0.35,
ease: "power3.out",
}
);
setTimeout(() => inputRef.current?.focus(), 350);
} else {
gsap.to(panelRef.current, {
y: 20,
opacity: 0,
scale: 0.97,
backdropFilter: "blur(0px)",
pointerEvents: "none",
duration: 0.2,
ease: "power2.in",
});
}
}, [open]);
// Auto-scroll on new messages
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages]);
const sendMessage = async () => {
const text = input.trim();
if (!text || loading) return;
const userMsg: Message = {
id: `user-${Date.now()}`,
role: "user",
content: text,
timestamp: Date.now(),
};
setMessages((prev) => [...prev, userMsg]);
setInput("");
setLoading(true);
try {
// Fetch current session state for context
let context = "";
if (sessionId) {
const { apiBaseUrl } = getRuntimeEnv();
try {
const stateRes = await fetch(`${apiBaseUrl}/sessions/${sessionId}`);
if (stateRes.ok) {
const state = await stateRes.json();
context = `Current simulation state (Turn ${state.world?.turn ?? "?"}): ` +
`Tension=${state.world?.tension_level?.toFixed(1) ?? "?"}, ` +
`Market Stress=${state.world?.market_stress?.toFixed(1) ?? "?"}, ` +
`Oil Pressure=${state.world?.oil_pressure?.toFixed(1) ?? "?"}, ` +
`Active Events=${state.world?.active_events?.length ?? 0}. ` +
`Agents: ${Object.keys(state.observations ?? {}).join(", ")}. `;
// Add recent reactions if available
try {
const reactRes = await fetch(`${apiBaseUrl}/sessions/${sessionId}/reactions`);
if (reactRes.ok) {
const reactions = await reactRes.json();
if (reactions.length > 0) {
const recent = reactions.slice(-3);
context += "Recent agent reactions: " +
recent.map((r: { agent_id: string; summary: string }) =>
`${r.agent_id}: ${r.summary}`
).join("; ") + ". ";
}
}
} catch {
// reactions endpoint optional
}
}
} catch {
context = "Backend not reachable — answering from general knowledge. ";
}
}
// Build a local response based on context (no external LLM dependency)
const assistantMsg: Message = {
id: `asst-${Date.now()}`,
role: "assistant",
content: generateLocalResponse(text, context),
timestamp: Date.now(),
};
// Simulate slight delay for UX
await new Promise((r) => setTimeout(r, 400 + Math.random() * 300));
setMessages((prev) => [...prev, assistantMsg]);
} catch {
setMessages((prev) => [
...prev,
{
id: `err-${Date.now()}`,
role: "assistant",
content: "Connection error. Unable to fetch simulation data.",
timestamp: Date.now(),
},
]);
} finally {
setLoading(false);
}
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
void sendMessage();
}
};
return (
<div
ref={panelRef}
className="pointer-events-none absolute bottom-20 left-1/2 z-30 w-[540px] -translate-x-1/2 opacity-0"
>
<div
className="pointer-events-auto flex h-[320px] flex-col overflow-hidden rounded-md border border-border/40 bg-card/40 backdrop-blur-lg"
style={{
boxShadow:
"0 0 8px rgba(0,0,0,0.03), 0 4px 12px rgba(0,0,0,0.15), inset 0 0 6px 6px rgba(255,255,255,0.04), 0 0 20px rgba(0,0,0,0.2)",
}}
>
{/* Header */}
<div className="flex items-center justify-between border-b border-border/30 px-4 py-2.5">
<div className="flex items-center gap-2">
<div className="h-2 w-2 animate-pulse rounded-full bg-primary" />
<span className="text-[10px] font-semibold tracking-[0.2em] text-foreground/80 uppercase font-sans">
AI Intel
</span>
</div>
<button
onClick={onClose}
className="flex h-6 w-6 cursor-pointer items-center justify-center text-muted-foreground transition-colors hover:text-foreground"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
{/* Messages */}
<div className="flex-1 overflow-y-auto px-4 py-3 scrollbar-thin">
<div className="flex flex-col gap-3">
{messages.map((msg) => (
<div
key={msg.id}
className={cn(
"max-w-[85%] text-xs leading-relaxed",
msg.role === "user"
? "ml-auto rounded-md bg-primary/15 px-3 py-2 text-foreground"
: msg.role === "system"
? "text-muted-foreground font-mono text-[10px] border-l-2 border-primary/30 pl-3 py-1"
: "rounded-md border border-border/30 bg-muted/20 px-3 py-2 text-foreground/90 font-sans"
)}
>
{msg.content}
</div>
))}
{loading && (
<div className="flex items-center gap-2 text-muted-foreground">
<Loader2 className="h-3 w-3 animate-spin" />
<span className="text-[10px] font-mono">Analyzing...</span>
</div>
)}
<div ref={messagesEndRef} />
</div>
</div>
{/* Input */}
<div className="border-t border-border/30 px-3 py-2.5">
<div className="flex items-center gap-2">
<input
ref={inputRef}
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Ask about the simulation..."
className="flex-1 bg-transparent text-xs text-foreground font-sans placeholder:text-muted-foreground/50 outline-none"
disabled={loading}
/>
<button
onClick={() => void sendMessage()}
disabled={loading || !input.trim()}
className={cn(
"flex h-7 w-7 shrink-0 cursor-pointer items-center justify-center rounded-sm transition-colors",
input.trim()
? "bg-primary/20 text-primary hover:bg-primary/30"
: "text-muted-foreground/30"
)}
>
<Send className="h-3.5 w-3.5" />
</button>
</div>
</div>
</div>
</div>
);
}
// Local response generator using session context
function generateLocalResponse(question: string, context: string): string {
const q = question.toLowerCase();
if (!context) {
return "No active session connected. Start the backend and create a session to get real-time simulation intelligence.";
}
if (q.includes("tension") || q.includes("stress") || q.includes("escalat")) {
return `${context}\n\nThe simulation tracks tension as a composite metric influenced by agent actions (strikes, sanctions, mobilizations increase it; negotiations and holds decrease it). Values above 60 are considered critical.`;
}
if (q.includes("agent") || q.includes("who") || q.includes("player")) {
return `${context}\n\nThe simulation runs 6 geopolitical agents: US, Israel, Iran, Hezbollah, Gulf coalition, and an Oversight entity. Each agent selects actions per turn based on their observations and fog-of-war constraints.`;
}
if (q.includes("oil") || q.includes("market") || q.includes("econom")) {
return `${context}\n\nOil pressure and market stress reflect economic dimensions of the crisis. Strikes and sanctions tend to spike these values, while diplomatic actions stabilize them.`;
}
if (q.includes("event") || q.includes("news") || q.includes("intel")) {
return `${context}\n\nEvents are injected via real-world source harvesting or scenario scripts. Each event has a severity (0-1) and source attribution. Agents receive filtered intel based on their fog-of-war visibility.`;
}
if (q.includes("reward") || q.includes("score") || q.includes("perform")) {
return `${context}\n\nRewards are differentiated per agent based on their objectives: stability-oriented agents gain from reduced tension, while adversarial agents may benefit from escalation. The oversight entity penalizes rule violations.`;
}
return `${context}\n\nFor specific analysis, try asking about: tension levels, agent behaviors, market impacts, active events, or reward patterns.`;
}
|