包含 extensions、skills、prompts、settings、auth、models、mcp 等配置。 排除 node_modules、npm 缓存、sessions 等运行时数据。
1952 lines
51 KiB
Markdown
1952 lines
51 KiB
Markdown
# 代码材料(后30页)
|
||
|
||
软件名称:StudioAgent AI视频制片平台软件
|
||
版本号:V0.1.0
|
||
|
||
## 第 55 页
|
||
|
||
```text
|
||
// Get or create conversation
|
||
const convs = await apiFetch<{ id: string }[]>(
|
||
`/projects/${projectId}/conversations`,
|
||
);
|
||
|
||
if (convs.length > 0) {
|
||
setConversationId(convs[0].id);
|
||
} else {
|
||
const newConv = await apiFetch<{ id: string }>(
|
||
`/projects/${projectId}/conversations`,
|
||
{ method: "POST" },
|
||
);
|
||
setConversationId(newConv.id);
|
||
}
|
||
} catch (err) {
|
||
console.error("Bootstrap error:", err);
|
||
router.push("/projects");
|
||
}
|
||
}
|
||
|
||
bootstrap();
|
||
}, [projectId, token, router]);
|
||
|
||
return (
|
||
<div className="flex h-screen flex-col">
|
||
{/* Header */}
|
||
<header className="flex items-center justify-between border-b px-6 py-3">
|
||
<div className="flex items-center gap-3">
|
||
<a href="/projects" className="text-lg font-bold hover:opacity-80">
|
||
StudioAgent
|
||
</a>
|
||
<span className="text-gray-500">|</span>
|
||
<span className="text-gray-700 dark:text-gray-300">{projectTitle}</span>
|
||
</div>
|
||
<div className="flex items-center gap-4">
|
||
<button className="text-gray-500 hover:text-gray-700">设置</button>
|
||
</div>
|
||
</header>
|
||
|
||
{/* Main workspace: Chat + Assets */}
|
||
<div className="flex flex-1 overflow-hidden">
|
||
{/* Agent chat panel (55%) */}
|
||
<div className="w-[55%] border-r">
|
||
{conversationId ? (
|
||
<ChatPanel conversationId={conversationId} />
|
||
) : (
|
||
<div className="flex h-full items-center justify-center text-gray-400">
|
||
初始化中...
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Asset panel (45%) */}
|
||
<div className="w-[45%]">
|
||
<AssetPanel />
|
||
</div>
|
||
</div>
|
||
|
||
{/* Footer status bar */}
|
||
<footer className="flex items-center justify-between border-t px-6 py-2 text-sm text-gray-500">
|
||
```
|
||
|
||
## 第 56 页
|
||
|
||
```text
|
||
<span>余额: ¥0.00</span>
|
||
<span>本项目消耗: ¥0.00</span>
|
||
<span>当前模型: Seedream 3.0</span>
|
||
</footer>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// File: frontend/src/app/projects/[id]/settings/page.tsx
|
||
export default function ProjectSettingsPage() {
|
||
return (
|
||
<div className="p-8">
|
||
<h1 className="text-2xl font-bold mb-8">项目设置</h1>
|
||
{/* TODO: Project settings form */}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// File: frontend/src/app/projects/page.tsx
|
||
"use client";
|
||
|
||
import { useState, useEffect } from "react";
|
||
import { useRouter } from "next/navigation";
|
||
import { apiFetch } from "@/lib/api";
|
||
import { useUserStore } from "@/stores/user";
|
||
|
||
interface Project {
|
||
id: string;
|
||
title: string;
|
||
description: string | null;
|
||
status: string;
|
||
style: string;
|
||
created_at: string;
|
||
}
|
||
|
||
export default function ProjectsPage() {
|
||
const router = useRouter();
|
||
const token = useUserStore((s) => s.token);
|
||
const [projects, setProjects] = useState<Project[]>([]);
|
||
const [loading, setLoading] = useState(true);
|
||
const [showCreate, setShowCreate] = useState(false);
|
||
const [newTitle, setNewTitle] = useState("");
|
||
const [newDesc, setNewDesc] = useState("");
|
||
const [creating, setCreating] = useState(false);
|
||
|
||
useEffect(() => {
|
||
if (!token) {
|
||
router.push("/login");
|
||
return;
|
||
}
|
||
loadProjects();
|
||
}, [token, router]);
|
||
|
||
async function loadProjects() {
|
||
try {
|
||
const data = await apiFetch<Project[]>("/projects");
|
||
setProjects(data);
|
||
} catch {
|
||
// token expired
|
||
router.push("/login");
|
||
```
|
||
|
||
## 第 57 页
|
||
|
||
```text
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}
|
||
|
||
async function handleCreate(e: React.FormEvent) {
|
||
e.preventDefault();
|
||
if (!newTitle.trim()) return;
|
||
setCreating(true);
|
||
|
||
try {
|
||
const project = await apiFetch<Project>("/projects", {
|
||
method: "POST",
|
||
body: JSON.stringify({ title: newTitle, description: newDesc || undefined }),
|
||
});
|
||
router.push(`/projects/${project.id}`);
|
||
} catch (err: any) {
|
||
alert(err.message || "创建失败");
|
||
} finally {
|
||
setCreating(false);
|
||
}
|
||
}
|
||
|
||
if (loading) {
|
||
return (
|
||
<div className="flex min-h-screen items-center justify-center text-gray-400">
|
||
加载中...
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="min-h-screen bg-gray-50 dark:bg-gray-950">
|
||
<div className="mx-auto max-w-5xl p-8">
|
||
<div className="flex items-center justify-between mb-8">
|
||
<h1 className="text-2xl font-bold">我的项目</h1>
|
||
<button
|
||
onClick={() => setShowCreate(true)}
|
||
className="rounded-lg bg-blue-600 px-4 py-2 text-white hover:bg-blue-700 transition"
|
||
>
|
||
新建项目
|
||
</button>
|
||
</div>
|
||
|
||
{/* Create project dialog */}
|
||
{showCreate && (
|
||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||
<div className="w-full max-w-md rounded-xl bg-white p-6 shadow-xl dark:bg-gray-900">
|
||
<h2 className="text-lg font-bold mb-4">新建项目</h2>
|
||
<form onSubmit={handleCreate} className="space-y-4">
|
||
<div>
|
||
<label className="block text-sm font-medium mb-1">项目名称</label>
|
||
<input
|
||
type="text"
|
||
value={newTitle}
|
||
onChange={(e) => setNewTitle(e.target.value)}
|
||
required
|
||
className="w-full rounded-lg border px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-800 dark:border-gray-700"
|
||
placeholder="给你的项目起个名字"
|
||
autoFocus
|
||
```
|
||
|
||
## 第 58 页
|
||
|
||
```text
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="block text-sm font-medium mb-1">描述(可选)</label>
|
||
<textarea
|
||
value={newDesc}
|
||
onChange={(e) => setNewDesc(e.target.value)}
|
||
rows={3}
|
||
className="w-full rounded-lg border px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-800 dark:border-gray-700"
|
||
placeholder="简单描述你想制作的内容"
|
||
/>
|
||
</div>
|
||
<div className="flex justify-end gap-2">
|
||
<button
|
||
type="button"
|
||
onClick={() => setShowCreate(false)}
|
||
className="rounded-lg px-4 py-2 text-gray-600 hover:bg-gray-100 dark:hover:bg-gray-800 transition"
|
||
>
|
||
取消
|
||
</button>
|
||
<button
|
||
type="submit"
|
||
disabled={creating}
|
||
className="rounded-lg bg-blue-600 px-4 py-2 text-white hover:bg-blue-700 disabled:opacity-50 transition"
|
||
>
|
||
{creating ? "创建中..." : "创建"}
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Project grid */}
|
||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||
{projects.map((project) => (
|
||
<button
|
||
key={project.id}
|
||
onClick={() => router.push(`/projects/${project.id}`)}
|
||
className="rounded-xl border bg-white p-6 text-left hover:shadow-md transition dark:bg-gray-900 dark:border-gray-800"
|
||
>
|
||
<h3 className="font-bold text-lg mb-2">{project.title}</h3>
|
||
{project.description && (
|
||
<p className="text-sm text-gray-500 mb-3 line-clamp-2">{project.description}</p>
|
||
)}
|
||
<div className="flex items-center gap-2 text-xs text-gray-400">
|
||
<span>{project.style}</span>
|
||
<span>·</span>
|
||
<span>{new Date(project.created_at).toLocaleDateString("zh-CN")}</span>
|
||
</div>
|
||
</button>
|
||
))}
|
||
|
||
{projects.length === 0 && (
|
||
<div className="col-span-full rounded-xl border border-dashed border-gray-300 p-12 text-center text-gray-400">
|
||
暂无项目,点击上方按钮创建
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
```
|
||
|
||
## 第 59 页
|
||
|
||
```text
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// File: frontend/src/app/providers.tsx
|
||
"use client";
|
||
|
||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||
import { useState } from "react";
|
||
|
||
export function Providers({ children }: { children: React.ReactNode }) {
|
||
const [queryClient] = useState(
|
||
() =>
|
||
new QueryClient({
|
||
defaultOptions: {
|
||
queries: {
|
||
staleTime: 60 * 1000,
|
||
retry: 1,
|
||
},
|
||
},
|
||
}),
|
||
);
|
||
|
||
return (
|
||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||
);
|
||
}
|
||
|
||
// File: frontend/src/app/settings/page.tsx
|
||
export default function SettingsPage() {
|
||
return (
|
||
<div className="p-8">
|
||
<h1 className="text-2xl font-bold mb-8">用户设置</h1>
|
||
{/* TODO: User settings form */}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// File: frontend/src/stores/conversation.ts
|
||
import { create } from "zustand";
|
||
|
||
interface ConversationState {
|
||
activeConversationId: string | null;
|
||
isStreaming: boolean;
|
||
interactionMode: "supervised" | "collaborative" | "autonomous";
|
||
setActiveConversation: (id: string | null) => void;
|
||
setIsStreaming: (streaming: boolean) => void;
|
||
setInteractionMode: (mode: ConversationState["interactionMode"]) => void;
|
||
}
|
||
|
||
export const useConversationStore = create<ConversationState>((set) => ({
|
||
activeConversationId: null,
|
||
isStreaming: false,
|
||
interactionMode: "collaborative",
|
||
setActiveConversation: (id) => set({ activeConversationId: id }),
|
||
setIsStreaming: (streaming) => set({ isStreaming: streaming }),
|
||
setInteractionMode: (mode) => set({ interactionMode: mode }),
|
||
}));
|
||
|
||
// File: frontend/src/stores/user.ts
|
||
```
|
||
|
||
## 第 60 页
|
||
|
||
```text
|
||
import { create } from "zustand";
|
||
|
||
interface User {
|
||
id: string;
|
||
email: string;
|
||
name: string | null;
|
||
avatar_url: string | null;
|
||
balance: number;
|
||
}
|
||
|
||
interface UserState {
|
||
user: User | null;
|
||
token: string | null;
|
||
setUser: (user: User, token: string) => void;
|
||
logout: () => void;
|
||
}
|
||
|
||
export const useUserStore = create<UserState>((set) => ({
|
||
user: null,
|
||
token: typeof window !== "undefined" ? localStorage.getItem("token") : null,
|
||
setUser: (user, token) => {
|
||
if (typeof window !== "undefined") {
|
||
localStorage.setItem("token", token);
|
||
}
|
||
set({ user, token });
|
||
},
|
||
logout: () => {
|
||
if (typeof window !== "undefined") {
|
||
localStorage.removeItem("token");
|
||
}
|
||
set({ user: null, token: null });
|
||
},
|
||
}));
|
||
|
||
// File: frontend/src/components/agent/AgentStatusBar.tsx
|
||
"use client";
|
||
|
||
const AGENTS = [
|
||
{ key: "producer", label: "制片人", emoji: "🎬" },
|
||
{ key: "screenwriter", label: "编剧", emoji: "📝" },
|
||
{ key: "director", label: "导演", emoji: "🎥" },
|
||
{ key: "camera", label: "摄影", emoji: "📷" },
|
||
{ key: "editor", label: "剪辑", emoji: "🎞️" },
|
||
{ key: "sound", label: "音效", emoji: "🔊" },
|
||
];
|
||
|
||
interface AgentStatusBarProps {
|
||
activeAgent?: string;
|
||
agentStatus?: Record<string, string>;
|
||
}
|
||
|
||
export function AgentStatusBar({ activeAgent, agentStatus = {} }: AgentStatusBarProps) {
|
||
return (
|
||
<div className="border-t px-4 py-2">
|
||
<div className="flex items-center gap-3 text-xs text-gray-500">
|
||
{AGENTS.map((agent) => (
|
||
<span
|
||
key={agent.key}
|
||
className={
|
||
activeAgent === agent.key
|
||
```
|
||
|
||
## 第 61 页
|
||
|
||
```text
|
||
? "text-blue-600 font-medium"
|
||
: ""
|
||
}
|
||
>
|
||
{agent.emoji} {agent.label}: {agentStatus[agent.key] || "空闲"}
|
||
</span>
|
||
))}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// File: frontend/src/components/agent/ChatInput.tsx
|
||
"use client";
|
||
|
||
import { useState, useCallback } from "react";
|
||
|
||
interface ChatInputProps {
|
||
onSend: (content: string) => void;
|
||
disabled?: boolean;
|
||
}
|
||
|
||
export function ChatInput({ onSend, disabled }: ChatInputProps) {
|
||
const [content, setContent] = useState("");
|
||
|
||
const handleSubmit = useCallback(() => {
|
||
const trimmed = content.trim();
|
||
if (!trimmed || disabled) return;
|
||
onSend(trimmed);
|
||
setContent("");
|
||
}, [content, disabled, onSend]);
|
||
|
||
return (
|
||
<div className="border-t p-4">
|
||
<div className="flex items-center gap-2">
|
||
<input
|
||
type="text"
|
||
value={content}
|
||
onChange={(e) => setContent(e.target.value)}
|
||
onKeyDown={(e) => e.key === "Enter" && !e.shiftKey && handleSubmit()}
|
||
placeholder="输入消息..."
|
||
disabled={disabled}
|
||
className="flex-1 rounded-lg border px-4 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-900 dark:border-gray-700"
|
||
/>
|
||
<button
|
||
onClick={handleSubmit}
|
||
disabled={disabled || !content.trim()}
|
||
className="rounded-lg bg-blue-600 px-4 py-2 text-white hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition"
|
||
>
|
||
发送
|
||
</button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// File: frontend/src/components/agent/ChatPanel.tsx
|
||
"use client";
|
||
|
||
import { useAgentStream } from "@/hooks/useAgentStream";
|
||
```
|
||
|
||
## 第 62 页
|
||
|
||
```text
|
||
import { MessageBubble } from "./MessageBubble";
|
||
import { ChatInput } from "./ChatInput";
|
||
import { AgentStatusBar } from "./AgentStatusBar";
|
||
import { useEffect, useRef } from "react";
|
||
|
||
interface ChatPanelProps {
|
||
conversationId: string;
|
||
}
|
||
|
||
export function ChatPanel({ conversationId }: ChatPanelProps) {
|
||
const { messages, agentStatus, isStreaming, sendMessage } = useAgentStream(conversationId);
|
||
const scrollRef = useRef<HTMLDivElement>(null);
|
||
|
||
// Auto-scroll to bottom on new messages
|
||
useEffect(() => {
|
||
if (scrollRef.current) {
|
||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
||
}
|
||
}, [messages]);
|
||
|
||
return (
|
||
<div className="flex h-full flex-col">
|
||
{/* Messages area */}
|
||
<div ref={scrollRef} className="flex-1 overflow-y-auto p-4 space-y-4">
|
||
{messages.length === 0 && (
|
||
<div className="flex h-full items-center justify-center text-gray-400">
|
||
<div className="text-center">
|
||
<p className="text-lg mb-2">开始与 AI 剧组对话</p>
|
||
<p className="text-sm">描述你想制作的视频,Producer 将为你规划制作流程</p>
|
||
</div>
|
||
</div>
|
||
)}
|
||
{messages.map((msg) => (
|
||
<MessageBubble
|
||
key={msg.id}
|
||
role={msg.role}
|
||
content={msg.content}
|
||
agentName={msg.agentName}
|
||
toolCalls={msg.toolCalls}
|
||
/>
|
||
))}
|
||
{isStreaming && messages[messages.length - 1]?.role !== "assistant" && (
|
||
<div className="flex justify-start">
|
||
<div className="rounded-2xl rounded-bl-md bg-gray-100 px-4 py-3 dark:bg-gray-800">
|
||
<span className="animate-pulse text-sm text-gray-500">思考中...</span>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Agent status bar */}
|
||
<AgentStatusBar agentStatus={agentStatus} />
|
||
|
||
{/* Input area */}
|
||
<ChatInput onSend={sendMessage} disabled={isStreaming} />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// File: frontend/src/components/agent/MessageBubble.tsx
|
||
```
|
||
|
||
## 第 63 页
|
||
|
||
```text
|
||
"use client";
|
||
|
||
import { cn } from "@/lib/utils";
|
||
|
||
interface MessageBubbleProps {
|
||
role: "user" | "assistant" | "tool" | "system";
|
||
content: string;
|
||
agentName?: string;
|
||
toolCalls?: any[];
|
||
}
|
||
|
||
const AGENT_LABELS: Record<string, string> = {
|
||
producer: "制片人",
|
||
screenwriter: "编剧",
|
||
director: "导演",
|
||
camera: "摄影",
|
||
editor: "剪辑",
|
||
sound: "音效",
|
||
};
|
||
|
||
export function MessageBubble({ role, content, agentName, toolCalls }: MessageBubbleProps) {
|
||
if (role === "user") {
|
||
return (
|
||
<div className="flex justify-end">
|
||
<div className="max-w-[80%] rounded-2xl rounded-br-md bg-blue-600 px-4 py-3 text-white">
|
||
<p className="whitespace-pre-wrap text-sm">{content}</p>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (role === "tool") {
|
||
return (
|
||
<div className="flex justify-start">
|
||
<div className="max-w-[80%] rounded-lg border border-gray-200 bg-gray-50 px-3 py-2 text-xs text-gray-600 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-400">
|
||
<span className="font-mono">{content}</span>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// assistant
|
||
return (
|
||
<div className="flex justify-start">
|
||
<div className="max-w-[80%]">
|
||
{agentName && (
|
||
<span className="mb-1 block text-xs font-medium text-gray-500">
|
||
{AGENT_LABELS[agentName] || agentName}
|
||
</span>
|
||
)}
|
||
<div
|
||
className={cn(
|
||
"rounded-2xl rounded-bl-md px-4 py-3",
|
||
"bg-gray-100 text-gray-900 dark:bg-gray-800 dark:text-gray-100",
|
||
)}
|
||
>
|
||
<p className="whitespace-pre-wrap text-sm">{content}</p>
|
||
</div>
|
||
{toolCalls && toolCalls.length > 0 && (
|
||
<div className="mt-1 space-y-1">
|
||
```
|
||
|
||
## 第 64 页
|
||
|
||
```text
|
||
{toolCalls.map((tc: any, i: number) => (
|
||
<div key={i} className="rounded border border-amber-200 bg-amber-50 px-2 py-1 text-xs text-amber-700 dark:border-amber-800 dark:bg-amber-950 dark:text-amber-400">
|
||
<span className="font-mono">{tc.tool || tc.name}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// File: frontend/src/components/assets/AssetPanel.tsx
|
||
"use client";
|
||
|
||
export function AssetPanel() {
|
||
return (
|
||
<div className="flex h-full flex-col">
|
||
{/* Tab bar */}
|
||
<div className="flex items-center gap-1 border-b px-4 py-2">
|
||
{["角色", "场景", "分镜", "视频", "音频"].map((tab) => (
|
||
<button
|
||
key={tab}
|
||
className="rounded-md px-3 py-1 text-sm text-gray-600 hover:bg-gray-100 dark:hover:bg-gray-800"
|
||
>
|
||
{tab}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
{/* Asset content area */}
|
||
<div className="flex-1 overflow-y-auto p-4">
|
||
<div className="text-center text-gray-400 py-12">
|
||
项目资产将在创作过程中自动生成
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// File: frontend/src/components/ui/avatar.tsx
|
||
"use client"
|
||
|
||
import * as React from "react"
|
||
import { Avatar as AvatarPrimitive } from "radix-ui"
|
||
|
||
import { cn } from "@/lib/utils"
|
||
|
||
function Avatar({
|
||
className,
|
||
size = "default",
|
||
...props
|
||
}: React.ComponentProps<typeof AvatarPrimitive.Root> & {
|
||
size?: "default" | "sm" | "lg"
|
||
}) {
|
||
return (
|
||
<AvatarPrimitive.Root
|
||
data-slot="avatar"
|
||
data-size={size}
|
||
className={cn(
|
||
```
|
||
|
||
## 第 65 页
|
||
|
||
```text
|
||
"group/avatar relative flex size-8 shrink-0 overflow-hidden rounded-full select-none data-[size=lg]:size-10 data-[size=sm]:size-6",
|
||
className
|
||
)}
|
||
{...props}
|
||
/>
|
||
)
|
||
}
|
||
|
||
function AvatarImage({
|
||
className,
|
||
...props
|
||
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
|
||
return (
|
||
<AvatarPrimitive.Image
|
||
data-slot="avatar-image"
|
||
className={cn("aspect-square size-full", className)}
|
||
{...props}
|
||
/>
|
||
)
|
||
}
|
||
|
||
function AvatarFallback({
|
||
className,
|
||
...props
|
||
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
|
||
return (
|
||
<AvatarPrimitive.Fallback
|
||
data-slot="avatar-fallback"
|
||
className={cn(
|
||
"flex size-full items-center justify-center rounded-full bg-muted text-sm text-muted-foreground group-data-[size=sm]/avatar:text-xs",
|
||
className
|
||
)}
|
||
{...props}
|
||
/>
|
||
)
|
||
}
|
||
|
||
function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) {
|
||
return (
|
||
<span
|
||
data-slot="avatar-badge"
|
||
className={cn(
|
||
"absolute right-0 bottom-0 z-10 inline-flex items-center justify-center rounded-full bg-primary text-primary-foreground ring-2 ring-background select-none",
|
||
"group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>svg]:hidden",
|
||
"group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2",
|
||
"group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2",
|
||
className
|
||
)}
|
||
{...props}
|
||
/>
|
||
)
|
||
}
|
||
|
||
function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||
return (
|
||
<div
|
||
data-slot="avatar-group"
|
||
className={cn(
|
||
"group/avatar-group flex -space-x-2 *:data-[slot=avatar]:ring-2 *:data-[slot=avatar]:ring-background",
|
||
className
|
||
```
|
||
|
||
## 第 66 页
|
||
|
||
```text
|
||
)}
|
||
{...props}
|
||
/>
|
||
)
|
||
}
|
||
|
||
function AvatarGroupCount({
|
||
className,
|
||
...props
|
||
}: React.ComponentProps<"div">) {
|
||
return (
|
||
<div
|
||
data-slot="avatar-group-count"
|
||
className={cn(
|
||
"relative flex size-8 shrink-0 items-center justify-center rounded-full bg-muted text-sm text-muted-foreground ring-2 ring-background group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=sm]/avatar-group:size-6 [&>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<typeof buttonVariants> & {
|
||
asChild?: boolean
|
||
}) {
|
||
const Comp = asChild ? Slot.Root : "button"
|
||
|
||
return (
|
||
<Comp
|
||
data-slot="button"
|
||
data-variant={variant}
|
||
data-size={size}
|
||
className={cn(buttonVariants({ variant, size, className }))}
|
||
{...props}
|
||
/>
|
||
)
|
||
}
|
||
|
||
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 (
|
||
<div
|
||
data-slot="card"
|
||
className={cn(
|
||
"flex flex-col gap-6 rounded-xl border bg-card py-6 text-card-foreground shadow-sm",
|
||
className
|
||
)}
|
||
{...props}
|
||
/>
|
||
)
|
||
}
|
||
|
||
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||
return (
|
||
<div
|
||
data-slot="card-header"
|
||
className={cn(
|
||
```
|
||
|
||
## 第 68 页
|
||
|
||
```text
|
||
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
|
||
className
|
||
)}
|
||
{...props}
|
||
/>
|
||
)
|
||
}
|
||
|
||
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||
return (
|
||
<div
|
||
data-slot="card-title"
|
||
className={cn("leading-none font-semibold", className)}
|
||
{...props}
|
||
/>
|
||
)
|
||
}
|
||
|
||
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||
return (
|
||
<div
|
||
data-slot="card-description"
|
||
className={cn("text-sm text-muted-foreground", className)}
|
||
{...props}
|
||
/>
|
||
)
|
||
}
|
||
|
||
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||
return (
|
||
<div
|
||
data-slot="card-action"
|
||
className={cn(
|
||
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||
className
|
||
)}
|
||
{...props}
|
||
/>
|
||
)
|
||
}
|
||
|
||
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||
return (
|
||
<div
|
||
data-slot="card-content"
|
||
className={cn("px-6", className)}
|
||
{...props}
|
||
/>
|
||
)
|
||
}
|
||
|
||
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||
return (
|
||
<div
|
||
data-slot="card-footer"
|
||
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
|
||
{...props}
|
||
/>
|
||
)
|
||
}
|
||
```
|
||
|
||
## 第 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 (
|
||
<input
|
||
type={type}
|
||
data-slot="input"
|
||
className={cn(
|
||
"h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none selection:bg-primary selection:text-primary-foreground file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm dark:bg-input/30",
|
||
"focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50",
|
||
"aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40",
|
||
className
|
||
)}
|
||
{...props}
|
||
/>
|
||
)
|
||
}
|
||
|
||
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<typeof LabelPrimitive.Root>) {
|
||
return (
|
||
<LabelPrimitive.Root
|
||
data-slot="label"
|
||
className={cn(
|
||
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
||
className
|
||
)}
|
||
{...props}
|
||
/>
|
||
)
|
||
}
|
||
|
||
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<typeof ScrollAreaPrimitive.Root>) {
|
||
return (
|
||
<ScrollAreaPrimitive.Root
|
||
data-slot="scroll-area"
|
||
className={cn("relative", className)}
|
||
{...props}
|
||
>
|
||
<ScrollAreaPrimitive.Viewport
|
||
data-slot="scroll-area-viewport"
|
||
className="size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1"
|
||
>
|
||
{children}
|
||
</ScrollAreaPrimitive.Viewport>
|
||
<ScrollBar />
|
||
<ScrollAreaPrimitive.Corner />
|
||
</ScrollAreaPrimitive.Root>
|
||
)
|
||
}
|
||
|
||
function ScrollBar({
|
||
className,
|
||
orientation = "vertical",
|
||
...props
|
||
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
|
||
return (
|
||
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||
data-slot="scroll-area-scrollbar"
|
||
orientation={orientation}
|
||
className={cn(
|
||
"flex touch-none p-px transition-colors select-none",
|
||
orientation === "vertical" &&
|
||
"h-full w-2.5 border-l border-l-transparent",
|
||
orientation === "horizontal" &&
|
||
"h-2.5 flex-col border-t border-t-transparent",
|
||
className
|
||
)}
|
||
{...props}
|
||
>
|
||
<ScrollAreaPrimitive.ScrollAreaThumb
|
||
data-slot="scroll-area-thumb"
|
||
className="relative flex-1 rounded-full bg-border"
|
||
/>
|
||
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||
)
|
||
}
|
||
|
||
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 (
|
||
<textarea
|
||
data-slot="textarea"
|
||
className={cn(
|
||
"flex field-sizing-content min-h-16 w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:aria-invalid:ring-destructive/40",
|
||
className
|
||
)}
|
||
{...props}
|
||
/>
|
||
)
|
||
}
|
||
|
||
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<string, any>;
|
||
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<Message[]>([]);
|
||
const [agentStatus, setAgentStatus] = useState<Record<string, string>>({});
|
||
const [isStreaming, setIsStreaming] = useState(false);
|
||
const [confirmRequest, setConfirmRequest] = useState<ConfirmData | null>(null);
|
||
|
||
function getAuthHeaders(): Record<string, string> {
|
||
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<Record<string, CandidateState>>({});
|
||
|
||
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<T>(
|
||
path: string,
|
||
options: RequestInit = {},
|
||
): Promise<T> {
|
||
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"]
|
||
}
|
||
|
||
```
|