feat: add implementation plan and monorepo guide for DreamChat project
This commit is contained in:
635
doc/frontend-guide.md
Normal file
635
doc/frontend-guide.md
Normal file
@@ -0,0 +1,635 @@
|
||||
# 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,
|
||||
},
|
||||
});
|
||||
```
|
||||
Reference in New Issue
Block a user