💻 Frontend Architecture
The Digital Twin's frontend is designed as a fast, beautiful, and interactive single-page console. Built using a modern React stack, it offers real-time streaming, drag-and-drop node interactions, and fluid UI animations.
🛠️ Technology Stack
- Next.js 16 (App Router): Fast rendering, React Server Components (RSC) boundary separation, and production-optimized code bundles.
- React 19: Leverages native features like the
useAPI for stream loading and refined Hook models. - TypeScript: Provides compile-time type-safety across API payloads, graph objects, and custom state interfaces.
- Tailwind CSS & shadcn/ui: Combines utility class styling with accessible, clean-styled components (dialogs, cards, inputs).
- Framer Motion: Drives smooth, hardware-accelerated animations for page loads, sidebar panels, and modal popups.
- Zustand: Handles light, reactive global state management without the boilerplate overhead of Redux.
🗄️ State Management: Zustand
We use a Zustand store to coordinate the chat logs, connection status, and active graph highlights.
import { create } from 'zustand';
interface ChatMessage {
id: string;
role: 'user' | 'assistant';
content: string;
}
interface TwinState {
chatHistory: ChatMessage[];
isStreaming: boolean;
highlightedNodeId: string | null;
addMessage: (msg: ChatMessage) => void;
setStreaming: (streaming: boolean) => void;
setHighlightNode: (nodeId: string | null) => void;
clearHistory: () => void;
}
export const useTwinStore = create<TwinState>((set) => ({
chatHistory: [],
isStreaming: false,
highlightedNodeId: null,
addMessage: (msg) => set((state) => ({ chatHistory: [...state.chatHistory, msg] })),
setStreaming: (streaming) => set({ isStreaming: streaming }),
setHighlightNode: (nodeId) => set({ highlightedNodeId: nodeId }),
clearHistory: () => set({ chatHistory: [] }),
}));
🎬 Animating Chat with Framer Motion
We use Framer Motion to animate message bubbles as they appear, ensuring they transition smoothly without layout shifts.
import { motion } from 'framer-motion';
export function MessageBubble({ message }: { message: ChatMessage }) {
const isUser = message.role === 'user';
return (
<motion.div
initial={{ opacity: 0, y: 15, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
transition={{ duration: 0.25, ease: 'easeOut' }}
className={`flex w-full mb-4 ${isUser ? 'justify-end' : 'justify-start'}`}
>
<div
className={`max-w-xl p-4 rounded-xl shadow-md border ${
isUser
? 'bg-blue-600 text-white border-blue-500 rounded-tr-none'
: 'bg-zinc-800 text-zinc-100 border-zinc-700 rounded-tl-none'
}`}
>
<p className="text-sm leading-relaxed whitespace-pre-wrap">{message.content}</p>
</div>
</motion.div>
);
}
📡 Frontend Stream Reader
This function handles reading the SSE chunks from our FastAPI server, updating the UI progressively as tokens arrive.
async function fetchStream(prompt: string, onToken: (token: string) => void) {
const response = await fetch(`http://localhost:8000/api/chat/stream?prompt=${encodeURIComponent(prompt)}`);
if (!response.body) return;
const reader = response.body.getReader();
const decoder = new TextDecoder('utf-8');
let done = false;
while (!done) {
const { value, done: readerDone } = await reader.read();
done = readerDone;
const chunk = decoder.decode(value, { stream: !done });
const lines = chunk.split('\n');
for (const line of lines) {
if (line.startsWith('data: ')) {
const data = line.slice(6).trim();
if (data === '[DONE]') {
done = true;
break;
}
onToken(data);
}
}
}
}