Files
DreamChat/doc/frontend-guide.md

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,
  },
});