包含 extensions、skills、prompts、settings、auth、models、mcp 等配置。 排除 node_modules、npm 缓存、sessions 等运行时数据。
51 KiB
51 KiB
代码材料(后30页)
软件名称:StudioAgent AI视频制片平台软件 版本号:V0.1.0
第 55 页
// 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 页
<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 页
} 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 页
/>
</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 页
</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 页
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-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 页
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 页
"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 页
{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 页
"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 页
)}
{...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 页
"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 页
"@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 页
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 页
// 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 页
// 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 页
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 页
} 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 页
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 页
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 页
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 页
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 页
--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 页
--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 页
# ── 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 页
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 页
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 页
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 页
"@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"]
}