636 lines
17 KiB
Markdown
636 lines
17 KiB
Markdown
# 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:
|
|
|
|
```typescript
|
|
// 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:
|
|
|
|
```typescript
|
|
// 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
|
|
|
|
```typescript
|
|
// 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
|
|
|
|
```typescript
|
|
// 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.
|
|
|
|
```typescript
|
|
// 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;
|
|
}
|
|
}
|
|
```
|
|
|
|
```typescript
|
|
// 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
|
|
|
|
```typescript
|
|
// 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
|
|
|
|
```typescript
|
|
// 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
|
|
|
|
```typescript
|
|
// 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
|
|
|
|
```typescript
|
|
// 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
|
|
|
|
```typescript
|
|
// 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
|
|
|
|
```typescript
|
|
// 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,
|
|
},
|
|
});
|
|
```
|