svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3",
className
)}
{...props}
/>
)
}
export {
Avatar,
AvatarImage,
AvatarFallback,
AvatarBadge,
AvatarGroup,
AvatarGroupCount,
}
// File: frontend/src/components/ui/button.tsx
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
```
## 第 67 页
```text
"icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
"icon-sm": "size-8",
"icon-lg": "size-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant = "default",
size = "default",
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps
& {
asChild?: boolean
}) {
const Comp = asChild ? Slot.Root : "button"
return (
)
}
export { Button, buttonVariants }
// File: frontend/src/components/ui/card.tsx
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
)
}
```
## 第 69 页
```text
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}
// File: frontend/src/components/ui/input.tsx
import * as React from "react"
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
)
}
export { Input }
// File: frontend/src/components/ui/label.tsx
"use client"
import * as React from "react"
import { Label as LabelPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Label({
className,
...props
}: React.ComponentProps) {
return (
)
}
export { Label }
```
## 第 70 页
```text
// File: frontend/src/components/ui/scroll-area.tsx
"use client"
import * as React from "react"
import { ScrollArea as ScrollAreaPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function ScrollArea({
className,
children,
...props
}: React.ComponentProps) {
return (
{children}
)
}
function ScrollBar({
className,
orientation = "vertical",
...props
}: React.ComponentProps) {
return (
)
}
export { ScrollArea, ScrollBar }
```
## 第 71 页
```text
// File: frontend/src/components/ui/textarea.tsx
import * as React from "react"
import { cn } from "@/lib/utils"
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
)
}
export { Textarea }
// File: frontend/src/hooks/useAgentStream.ts
"use client";
import { useState, useCallback } from "react";
export interface Message {
id: string;
role: "user" | "assistant" | "tool" | "system";
agentName?: string;
content: string;
toolCalls?: any[];
metadata?: Record;
createdAt: Date;
}
export interface ConfirmData {
type: string;
summary: string;
data: any;
options: string[];
}
/**
* SSE event stream hook for Agent interaction.
*/
export function useAgentStream(conversationId: string) {
const [messages, setMessages] = useState([]);
const [agentStatus, setAgentStatus] = useState>({});
const [isStreaming, setIsStreaming] = useState(false);
const [confirmRequest, setConfirmRequest] = useState(null);
function getAuthHeaders(): Record {
const token = typeof window !== "undefined" ? localStorage.getItem("token") : null;
return {
"Content-Type": "application/json",
...(token ? { Authorization: `Bearer ${token}` } : {}),
};
}
async function parseSSEStream(response: Response) {
```
## 第 72 页
```text
if (!response.ok) {
const err = await response.json().catch(() => ({ detail: response.statusText }));
throw new Error(err.detail || response.statusText);
}
if (!response.body) return;
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
let currentEvent = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() || "";
for (const line of lines) {
if (line.startsWith("event: ")) {
currentEvent = line.slice(7);
} else if (line.startsWith("data: ") && currentEvent) {
try {
const data = JSON.parse(line.slice(6));
handleSSEEvent(currentEvent, data);
} catch {
// skip malformed data
}
currentEvent = "";
}
}
}
}
const sendMessage = useCallback(
async (content: string) => {
setIsStreaming(true);
// Add user message
setMessages((prev) => [
...prev,
{
id: crypto.randomUUID(),
role: "user",
content,
createdAt: new Date(),
},
]);
try {
const response = await fetch(
`/api/v1/conversations/${conversationId}/messages`,
{
method: "POST",
headers: getAuthHeaders(),
body: JSON.stringify({ content }),
},
);
await parseSSEStream(response);
```
## 第 73 页
```text
} catch (err) {
console.error("Send message error:", err);
} finally {
setIsStreaming(false);
}
},
[conversationId],
);
const resume = useCallback(
async (action: string, feedback?: string) => {
setIsStreaming(true);
setConfirmRequest(null);
try {
const response = await fetch(
`/api/v1/conversations/${conversationId}/resume`,
{
method: "POST",
headers: getAuthHeaders(),
body: JSON.stringify({ action, feedback }),
},
);
await parseSSEStream(response);
} catch (err) {
console.error("Resume error:", err);
} finally {
setIsStreaming(false);
}
},
[conversationId],
);
function handleSSEEvent(event: string, data: any) {
switch (event) {
case "agent.text_start":
setAgentStatus((prev) => ({ ...prev, [data.agent]: "工作中" }));
break;
case "agent.text_delta":
setMessages((prev) => {
const last = prev[prev.length - 1];
if (last?.role === "assistant" && last.agentName === data.agent) {
return [
...prev.slice(0, -1),
{ ...last, content: last.content + data.content },
];
}
return [
...prev,
{
id: crypto.randomUUID(),
role: "assistant",
agentName: data.agent,
content: data.content,
createdAt: new Date(),
},
];
});
break;
```
## 第 74 页
```text
case "agent.text_end":
break;
case "agent.tool_call":
setMessages((prev) => {
const last = prev[prev.length - 1];
if (last?.role === "assistant" && last.agentName === data.agent) {
return [
...prev.slice(0, -1),
{
...last,
toolCalls: [...(last.toolCalls || []), { tool: data.tool, id: data.id }],
},
];
}
return prev;
});
break;
case "agent.tool_result":
setMessages((prev) => [
...prev,
{
id: crypto.randomUUID(),
role: "tool",
content: typeof data.content === "string" ? data.content : JSON.stringify(data.content),
metadata: { tool_call_id: data.tool_call_id },
createdAt: new Date(),
},
]);
break;
case "agent.handoff":
setAgentStatus((prev) => ({
...prev,
[data.from]: "已交接",
[data.to]: "工作中",
}));
break;
case "agent.confirm_request":
setConfirmRequest(data);
break;
case "done":
setAgentStatus({});
break;
case "error":
console.error("SSE error:", data);
break;
}
}
return { messages, setMessages, agentStatus, isStreaming, confirmRequest, sendMessage, resume };
}
// File: frontend/src/hooks/useCandidateSystem.ts
"use client";
```
## 第 75 页
```text
import { useState, useCallback } from "react";
export interface CandidateState {
originalUrl: string | null;
candidates: string[]; // may contain "PENDING:xxx" placeholders
selectedIndex: number; // -1=original, 0~N=candidate
previousUrl: string | null;
}
/**
* Universal candidate card-draw state management.
* Ported from waoowaoo's useCandidateSystem hook.
*
* Used for character appearances, location images, and panel images.
*/
export function useCandidateSystem() {
const [states, setStates] = useState>({});
const initCandidates = useCallback(
(id: string, state: CandidateState) => {
setStates((prev) => ({ ...prev, [id]: state }));
},
[],
);
const selectCandidate = useCallback((id: string, index: number) => {
setStates((prev) => ({
...prev,
[id]: { ...prev[id], selectedIndex: index },
}));
}, []);
const confirmCandidate = useCallback(async (id: string) => {
const state = states[id];
if (!state) return;
await fetch("/api/v1/candidates/confirm", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
entity_type: "auto", // determined by backend
entity_id: id,
selected_index: state.selectedIndex,
}),
});
}, [states]);
const cancelCandidates = useCallback((id: string) => {
setStates((prev) => {
const next = { ...prev };
delete next[id];
return next;
});
}, []);
const undoSelection = useCallback(async (id: string) => {
await fetch("/api/v1/candidates/undo", {
method: "POST",
headers: { "Content-Type": "application/json" },
```
## 第 76 页
```text
body: JSON.stringify({ entity_type: "auto", entity_id: id }),
});
}, []);
const getDisplayImage = useCallback(
(id: string, fallback?: string): string => {
const state = states[id];
if (!state) return fallback || "";
if (state.selectedIndex === -1) return state.originalUrl || fallback || "";
return state.candidates[state.selectedIndex] || fallback || "";
},
[states],
);
return {
states,
initCandidates,
selectCandidate,
confirmCandidate,
cancelCandidates,
undoSelection,
getDisplayImage,
};
}
// File: frontend/src/lib/api.ts
/**
* API client — thin fetch wrapper with auth header injection.
*/
const API_BASE = "/api/v1";
export async function apiFetch(
path: string,
options: RequestInit = {},
): Promise {
const token =
typeof window !== "undefined" ? localStorage.getItem("token") : null;
const res = await fetch(`${API_BASE}${path}`, {
...options,
headers: {
"Content-Type": "application/json",
...(token ? { Authorization: `Bearer ${token}` } : {}),
...options.headers,
},
});
if (!res.ok) {
const error = await res.json().catch(() => ({ detail: res.statusText }));
throw new Error(error.detail || res.statusText);
}
return res.json();
}
// File: frontend/src/lib/utils.ts
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
```
## 第 77 页
```text
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
// File: frontend/next.config.ts
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
// Proxy API requests to backend in development
async rewrites() {
return [
{
source: "/api/:path*",
destination: "http://localhost:8000/api/:path*",
},
];
},
};
export default nextConfig;
// File: frontend/src/app/globals.css
@import "tailwindcss";
@import "tw-animate-css";
@import "shadcn/tailwind.css";
@custom-variant dark (&:is(.dark *));
body {
font-family: system-ui, -apple-system, sans-serif;
}
@theme inline {
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--radius-2xl: calc(var(--radius) + 8px);
--radius-3xl: calc(var(--radius) + 12px);
--radius-4xl: calc(var(--radius) + 16px);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
```
## 第 78 页
```text
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
}
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
```
## 第 79 页
```text
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}
// File: backend/pyproject.toml
[project]
name = "studio-agent-backend"
version = "0.1.0"
description = "StudioAgent — Multi-Agent AI Production Platform Backend"
readme = "README.md"
requires-python = ">=3.12"
dependencies = [
# ── Web Framework ──
"fastapi>=0.115.0",
"uvicorn[standard]>=0.32.0",
"python-multipart>=0.0.12",
"sse-starlette>=2.1.0",
# ── Agent Orchestration ──
"langgraph>=0.4.0",
"langgraph-swarm>=0.0.4",
"langgraph-checkpoint-postgres>=2.0.0",
"langchain-core>=0.3.0",
"langchain-openai>=0.3.0",
"langchain-google-genai>=2.1.0",
# ── Database ──
"sqlalchemy[asyncio]>=2.0.36",
"asyncpg>=0.30.0",
"alembic>=1.14.0",
# ── Task Queue ──
"celery[redis]>=5.4.0",
```
## 第 80 页
```text
# ── Auth ──
"pyjwt>=2.10.0",
"passlib[bcrypt]>=1.7.4",
# ── Utilities ──
"pydantic>=2.10.0",
"pydantic-settings>=2.7.0",
"httpx>=0.28.0",
"python-dotenv>=1.0.1",
"email-validator>=2.1.0",
"structlog>=24.4.0",
"tenacity>=9.0.0",
# ── Cloud Storage ──
"boto3>=1.35.0",
]
[project.optional-dependencies]
dev = [
"pytest>=8.3.0",
"pytest-asyncio>=0.24.0",
"pytest-cov>=6.0.0",
"httpx>=0.28.0",
"ruff>=0.8.0",
"mypy>=1.13.0",
]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.ruff]
target-version = "py312"
line-length = 100
[tool.ruff.lint]
select = ["E", "F", "I", "N", "W", "UP"]
[tool.pytest.ini_options]
asyncio_mode = "auto"
testpaths = ["tests"]
[tool.mypy]
python_version = "3.12"
strict = true
// File: docker-compose.dev.yml
# Development overrides — lighter weight, no Celery workers
services:
backend:
command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
environment:
- DEBUG=true
frontend:
command: npm run dev
# Disable Celery workers in dev (use inline execution)
celery-image:
```
## 第 81 页
```text
profiles: ["workers"]
celery-video:
profiles: ["workers"]
celery-voice:
profiles: ["workers"]
celery-text:
profiles: ["workers"]
// File: docker-compose.yml
services:
# ── Database ──
postgres:
image: postgres:16-alpine
environment:
POSTGRES_DB: studioagent
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 5s
retries: 5
# ── Redis ──
redis:
image: redis:7-alpine
ports:
- "6379:6379"
volumes:
- redis_data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 5s
retries: 5
# ── Backend API ──
backend:
build:
context: ./backend
dockerfile: Dockerfile
ports:
- "8000:8000"
env_file:
- .env
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
volumes:
- ./backend:/app
command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
# ── Celery Worker (Image) ──
celery-image:
```
## 第 82 页
```text
build:
context: ./backend
dockerfile: Dockerfile
env_file:
- .env
depends_on:
redis:
condition: service_healthy
postgres:
condition: service_healthy
volumes:
- ./backend:/app
command: celery -A celery_app worker -Q image --concurrency=20 -l info
# ── Celery Worker (Video) ──
celery-video:
build:
context: ./backend
dockerfile: Dockerfile
env_file:
- .env
depends_on:
redis:
condition: service_healthy
volumes:
- ./backend:/app
command: celery -A celery_app worker -Q video --concurrency=4 -l info
# ── Celery Worker (Voice) ──
celery-voice:
build:
context: ./backend
dockerfile: Dockerfile
env_file:
- .env
depends_on:
redis:
condition: service_healthy
volumes:
- ./backend:/app
command: celery -A celery_app worker -Q voice --concurrency=10 -l info
# ── Celery Worker (Text/LLM) ──
celery-text:
build:
context: ./backend
dockerfile: Dockerfile
env_file:
- .env
depends_on:
redis:
condition: service_healthy
volumes:
- ./backend:/app
command: celery -A celery_app worker -Q text --concurrency=10 -l info
# ── Frontend ──
frontend:
build:
context: ./frontend
```
## 第 83 页
```text
dockerfile: Dockerfile
ports:
- "3000:3000"
depends_on:
- backend
volumes:
- ./frontend:/app
- /app/node_modules
command: npm run dev
volumes:
postgres_data:
redis_data:
// File: backend/celery_app.py
"""Celery application entry point."""
from celery import Celery
from app.config import settings
celery_app = Celery(
"studioagent",
broker=settings.celery_broker_url,
backend=settings.celery_result_backend,
)
celery_app.conf.update(
task_serializer="json",
accept_content=["json"],
result_serializer="json",
timezone="UTC",
enable_utc=True,
task_track_started=True,
task_routes={
"app.workers.image_worker.*": {"queue": "image"},
"app.workers.video_worker.*": {"queue": "video"},
"app.workers.voice_worker.*": {"queue": "voice"},
"app.workers.text_worker.*": {"queue": "text"},
},
worker_concurrency=4,
)
celery_app.autodiscover_tasks(["app.workers"])
// File: frontend/package.json
{
"name": "studio-agent-frontend",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev --turbopack",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"@radix-ui/react-avatar": "^1.1.3",
"@radix-ui/react-dialog": "^1.1.6",
"@radix-ui/react-dropdown-menu": "^2.1.6",
```
## 第 84 页
```text
"@radix-ui/react-scroll-area": "^1.2.3",
"@radix-ui/react-slot": "^1.1.1",
"@radix-ui/react-tabs": "^1.1.3",
"@radix-ui/react-tooltip": "^1.1.8",
"@tanstack/react-query": "^5.68.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"framer-motion": "^12.5.0",
"lucide-react": "^0.474.0",
"next": "^15.3.0",
"radix-ui": "^1.4.3",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"tailwind-merge": "^3.5.0",
"zustand": "^5.0.3"
},
"devDependencies": {
"@eslint/eslintrc": "^3.2.0",
"@tailwindcss/postcss": "^4.1.0",
"@types/node": "^22.12.0",
"@types/react": "^19.1.0",
"@types/react-dom": "^19.1.0",
"eslint": "^9.18.0",
"eslint-config-next": "^15.3.0",
"postcss": "^8.5.0",
"shadcn": "^3.8.5",
"tailwindcss": "^4.1.0",
"tw-animate-css": "^1.4.0",
"typescript": "^5.7.0"
}
}
// File: frontend/tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [{ "name": "next" }],
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}
```