Files
sproutclaw-data/agent/skills-disabled/SoftwareCopyright-Skill/生成demo/软件著作权申请资料/草稿/代码-后30页.md
shumengya 50edff80f5 feat: 导出 SproutClaw .sproutclaw 配置
包含 extensions、skills、prompts、settings、auth、models、mcp 等配置。
排除 node_modules、npm 缓存、sessions 等运行时数据。
2026-06-26 15:48:56 +08:00

51 KiB
Raw Blame History

代码材料后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"]
}