Skip to main content

💻 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

  1. Next.js 16 (App Router): Fast rendering, React Server Components (RSC) boundary separation, and production-optimized code bundles.
  2. React 19: Leverages native features like the use API for stream loading and refined Hook models.
  3. TypeScript: Provides compile-time type-safety across API payloads, graph objects, and custom state interfaces.
  4. Tailwind CSS & shadcn/ui: Combines utility class styling with accessible, clean-styled components (dialogs, cards, inputs).
  5. Framer Motion: Drives smooth, hardware-accelerated animations for page loads, sidebar panels, and modal popups.
  6. 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);
}
}
}
}