17 KiB
17 KiB
DreamChat Frontend Guide
Overview
This document outlines the frontend architecture, component structure, and development guidelines for the DreamChat React application.
Tech Stack
| Category | Technology | Purpose |
|---|---|---|
| Framework | React 18+ | UI library |
| Build Tool | Vite | Fast development and building |
| Language | TypeScript | Type safety |
| Styling | Tailwind CSS | Utility-first CSS |
| State Management | Zustand | Global state |
| Server State | TanStack Query | API data caching |
| Routing | React Router v6 | Navigation |
| WebSocket | Socket.io-client | Real-time communication |
| Shared Types | @dreamchat/shared |
Monorepo shared package |
| API Client | OpenAPI Generator | Auto-generated API client |
| Forms | React Hook Form + Zod | Form handling and validation |
| UI Components | Radix UI + shadcn/ui | Accessible primitives |
| Icons | Lucide React | Icon library |
| Testing | Vitest + React Testing Library | Unit/component tests |
Project Structure
apps/frontend/
├── src/
│ ├── api/
│ │ ├── generated/ # Auto-generated from OpenAPI
│ │ │ ├── models/ # DTO types
│ │ │ └── apis/ # API client classes
│ │ └── client.ts # Axios/fetch configuration
│ │
│ ├── websocket/
│ │ ├── socket.ts # Socket.io client setup
│ │ └── hooks.ts # WebSocket hooks (uses @dreamchat/shared)
│ │
│ ├── components/
│ │ ├── ui/ # Reusable UI components
│ │ │ ├── button.tsx
│ │ │ ├── input.tsx
│ │ │ ├── card.tsx
│ │ │ ├── dialog.tsx
│ │ │ └── ...
│ │ ├── layout/
│ │ │ ├── sidebar.tsx
│ │ │ ├── header.tsx
│ │ │ └── main-layout.tsx
│ │ ├── character/
│ │ │ ├── character-card.tsx
│ │ │ ├── character-form.tsx
│ │ │ ├── attribute-editor.tsx
│ │ │ └── personality-editor.tsx
│ │ ├── chat/
│ │ │ ├── chat-container.tsx
│ │ │ ├── message-list.tsx
│ │ │ ├── message-bubble.tsx
│ │ │ ├── chat-input.tsx
│ │ │ └── typing-indicator.tsx
│ │ └── import/
│ │ ├── file-dropzone.tsx
│ │ ├── url-input.tsx
│ │ └── import-progress.tsx
│ │
│ ├── pages/
│ │ ├── login-page.tsx
│ │ ├── register-page.tsx
│ │ ├── character-list-page.tsx
│ │ ├── character-detail-page.tsx
│ │ ├── chat-page.tsx
│ │ ├── story-page.tsx
│ │ └── import-page.tsx
│ │
│ ├── hooks/
│ │ ├── use-auth.ts
│ │ ├── use-chat.ts
│ │ ├── use-characters.ts
│ │ └── use-import.ts
│ │
│ ├── stores/
│ │ ├── auth-store.ts
│ │ ├── chat-store.ts
│ │ └── ui-store.ts
│ │
│ ├── lib/
│ │ ├── utils.ts # Utility functions
│ │ └── constants.ts # App constants
│ │
│ ├── types/
│ │ └── index.ts # App-specific types
│ │
│ ├── styles/
│ │ └── globals.css
│ │
│ ├── main.tsx
│ └── App.tsx
│
├── public/
│ └── assets/
│
├── index.html
├── vite.config.ts
├── tailwind.config.ts
├── tsconfig.json
└── package.json
Component Architecture
UI Components (shadcn/ui pattern)
Base components built on Radix UI primitives:
// components/ui/button.tsx
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive: "bg-destructive text-destructive-foreground",
outline: "border border-input bg-background hover:bg-accent",
secondary: "bg-secondary text-secondary-foreground",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "underline-offset-4 hover:underline text-primary",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
icon: "h-10 w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, ...props }, ref) => {
return (
<button
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
);
}
);
Button.displayName = "Button";
export { Button, buttonVariants };
Feature Components
Character form example:
// components/character/character-form.tsx
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import * as z from "zod";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { AttributeEditor } from "./attribute-editor";
const characterSchema = z.object({
name: z.string().min(1, "Name is required"),
personalityPrompt: z.string().min(10, "Personality prompt too short"),
backstory: z.string().optional(),
attributes: z.record(z.any()).default({}),
});
type CharacterFormData = z.infer<typeof characterSchema>;
interface CharacterFormProps {
initialData?: Partial<CharacterFormData>;
onSubmit: (data: CharacterFormData) => Promise<void>;
}
export function CharacterForm({ initialData, onSubmit }: CharacterFormProps) {
const form = useForm<CharacterFormData>({
resolver: zodResolver(characterSchema),
defaultValues: initialData || {
name: "",
personalityPrompt: "",
backstory: "",
attributes: {},
},
});
return (
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<div>
<label>Name</label>
<Input {...form.register("name")} />
{form.formState.errors.name && (
<span className="text-red-500">{form.formState.errors.name.message}</span>
)}
</div>
<div>
<label>Personality Prompt</label>
<Textarea {...form.register("personalityPrompt")} rows={5} />
</div>
<div>
<label>Backstory</label>
<Textarea {...form.register("backstory")} rows={5} />
</div>
<div>
<label>Attributes</label>
<AttributeEditor
value={form.watch("attributes")}
onChange={(attrs) => form.setValue("attributes", attrs)}
/>
</div>
<Button type="submit" disabled={form.formState.isSubmitting}>
{initialData ? "Update" : "Create"} Character
</Button>
</form>
);
}
State Management
Zustand Store Example
// stores/auth-store.ts
import { create } from "zustand";
import { persist } from "zustand/middleware";
interface User {
id: string;
email: string;
username: string;
}
interface AuthState {
user: User | null;
accessToken: string | null;
refreshToken: string | null;
isAuthenticated: boolean;
setAuth: (user: User, accessToken: string, refreshToken: string) => void;
clearAuth: () => void;
updateAccessToken: (token: string) => void;
}
export const useAuthStore = create<AuthState>()(
persist(
(set) => ({
user: null,
accessToken: null,
refreshToken: null,
isAuthenticated: false,
setAuth: (user, accessToken, refreshToken) =>
set({ user, accessToken, refreshToken, isAuthenticated: true }),
clearAuth: () =>
set({
user: null,
accessToken: null,
refreshToken: null,
isAuthenticated: false,
}),
updateAccessToken: (token) => set({ accessToken: token }),
}),
{
name: "auth-storage",
partialize: (state) => ({
accessToken: state.accessToken,
refreshToken: state.refreshToken,
user: state.user,
}),
}
)
);
TanStack Query for Server State
// hooks/use-characters.ts
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { CharactersApi } from "@/api/generated";
const charactersApi = new CharactersApi();
export function useCharacters() {
return useQuery({
queryKey: ["characters"],
queryFn: () => charactersApi.charactersControllerFindAll(),
});
}
export function useCreateCharacter() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: CreateCharacterDto) =>
charactersApi.charactersControllerCreate(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["characters"] });
},
});
}
WebSocket Integration
Uses shared types from @dreamchat/shared for type-safe communication.
// src/websocket/socket.ts
import { io, Socket } from "socket.io-client";
import { useAuthStore } from "@/stores/auth-store";
let socket: Socket | null = null;
export function getSocket(): Socket {
if (!socket) {
const token = useAuthStore.getState().accessToken;
socket = io(import.meta.env.VITE_WS_URL, {
auth: { token },
transports: ["websocket"],
});
socket.on("connect", () => {
console.log("WebSocket connected");
});
socket.on("disconnect", () => {
console.log("WebSocket disconnected");
});
socket.on("ERROR", (error) => {
console.error("WebSocket error:", error);
});
}
return socket;
}
export function disconnectSocket() {
if (socket) {
socket.disconnect();
socket = null;
}
}
// src/websocket/hooks.ts
import { useEffect, useCallback } from "react";
import { getSocket } from "./socket";
import {
WebSocketEventType,
WebSocketMessage,
JoinConversationPayload,
SendMessagePayload,
StreamChunkPayload,
StreamCompletePayload,
ErrorPayload,
} from "@dreamchat/shared";
export function useChatSocket(conversationId: string) {
const socket = getSocket();
useEffect(() => {
const payload: JoinConversationPayload = { conversationId };
socket.emit(WebSocketEventType.JOIN_CONVERSATION, payload);
return () => {
socket.emit(WebSocketEventType.LEAVE_CONVERSATION, { conversationId });
};
}, [conversationId, socket]);
const sendMessage = useCallback(
(content: string, streaming = true) => {
const payload: SendMessagePayload = {
conversationId,
content,
streaming,
};
socket.emit(WebSocketEventType.SEND_MESSAGE, payload);
},
[conversationId, socket]
);
const stopGeneration = useCallback(() => {
socket.emit(WebSocketEventType.STOP_GENERATION, { conversationId });
}, [conversationId, socket]);
return { sendMessage, stopGeneration };
}
export function useChatEvents(
onStreamChunk: (chunk: string) => void,
onStreamComplete: (message: StreamCompletePayload) => void,
onError: (error: ErrorPayload) => void
) {
const socket = getSocket();
useEffect(() => {
const handleChunk = (msg: WebSocketMessage<StreamChunkPayload>) => {
onStreamChunk(msg.payload.chunk);
};
const handleComplete = (msg: WebSocketMessage<StreamCompletePayload>) => {
onStreamComplete(msg.payload);
};
const handleError = (msg: WebSocketMessage<ErrorPayload>) => {
onError(msg.payload);
};
socket.on(WebSocketEventType.STREAM_CHUNK, handleChunk);
socket.on(WebSocketEventType.STREAM_COMPLETE, handleComplete);
socket.on(WebSocketEventType.ERROR, handleError);
return () => {
socket.off(WebSocketEventType.STREAM_CHUNK, handleChunk);
socket.off(WebSocketEventType.STREAM_COMPLETE, handleComplete);
socket.off(WebSocketEventType.ERROR, handleError);
};
}, [socket, onStreamChunk, onStreamComplete, onError]);
}
Routing
// App.tsx
import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom";
import { useAuthStore } from "@/stores/auth-store";
import { MainLayout } from "@/components/layout/main-layout";
import { LoginPage } from "@/pages/login-page";
import { CharacterListPage } from "@/pages/character-list-page";
import { ChatPage } from "@/pages/chat-page";
function ProtectedRoute({ children }: { children: React.ReactNode }) {
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
return isAuthenticated ? <>{children}</> : <Navigate to="/login" />;
}
function App() {
return (
<BrowserRouter>
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route
path="/"
element={
<ProtectedRoute>
<MainLayout />
</ProtectedRoute>
}
>
<Route index element={<Navigate to="/characters" />} />
<Route path="characters" element={<CharacterListPage />} />
<Route path="characters/:id" element={<CharacterDetailPage />} />
<Route path="chat/:conversationId" element={<ChatPage />} />
<Route path="stories" element={<StoryPage />} />
<Route path="import" element={<ImportPage />} />
</Route>
</Routes>
</BrowserRouter>
);
}
Styling with Tailwind
// tailwind.config.ts
import type { Config } from "tailwindcss";
const config: Config = {
darkMode: ["class"],
content: ["./src/**/*.{js,ts,jsx,tsx}"],
theme: {
extend: {
colors: {
border: "hsl(var(--border))",
input: "hsl(var(--input))",
ring: "hsl(var(--ring))",
background: "hsl(var(--background))",
foreground: "hsl(var(--foreground))",
primary: {
DEFAULT: "hsl(var(--primary))",
foreground: "hsl(var(--primary-foreground))",
},
secondary: {
DEFAULT: "hsl(var(--secondary))",
foreground: "hsl(var(--secondary-foreground))",
},
destructive: {
DEFAULT: "hsl(var(--destructive))",
foreground: "hsl(var(--destructive-foreground))",
},
muted: {
DEFAULT: "hsl(var(--muted))",
foreground: "hsl(var(--muted-foreground))",
},
accent: {
DEFAULT: "hsl(var(--accent))",
foreground: "hsl(var(--accent-foreground))",
},
},
},
},
plugins: [require("tailwindcss-animate")],
};
export default config;
Testing Strategy
Component Tests
// components/character/__tests__/character-card.test.tsx
import { render, screen } from "@testing-library/react";
import { CharacterCard } from "../character-card";
const mockCharacter = {
id: "1",
name: "Alice",
avatarUrl: "https://example.com/avatar.png",
personalityPrompt: "A curious explorer",
};
describe("CharacterCard", () => {
it("renders character name", () => {
render(<CharacterCard character={mockCharacter} />);
expect(screen.getByText("Alice")).toBeInTheDocument();
});
it("renders avatar when provided", () => {
render(<CharacterCard character={mockCharacter} />);
expect(screen.getByAltText("Alice")).toHaveAttribute(
"src",
"https://example.com/avatar.png"
);
});
});
Hook Tests
// hooks/__tests__/use-auth.test.ts
import { renderHook, act } from "@testing-library/react";
import { useAuthStore } from "@/stores/auth-store";
describe("useAuthStore", () => {
it("sets authentication state", () => {
const { result } = renderHook(() => useAuthStore());
act(() => {
result.current.setAuth(
{ id: "1", email: "test@test.com", username: "test" },
"access-token",
"refresh-token"
);
});
expect(result.current.isAuthenticated).toBe(true);
expect(result.current.user?.username).toBe("test");
});
});
Environment Variables
// apps/frontend/.env
VITE_API_URL=http://localhost:3000/api
VITE_WS_URL=ws://localhost:3000
VITE_KEYCLOAK_URL=http://localhost:8080
VITE_KEYCLOAK_REALM=dreamchat
VITE_KEYCLOAK_CLIENT_ID=dreamchat-frontend
Build Configuration
// vite.config.ts
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import path from "path";
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
server: {
port: 5173,
proxy: {
"/api": {
target: "http://localhost:3000",
changeOrigin: true,
},
},
},
build: {
outDir: "dist",
sourcemap: true,
},
});