Files
shumengya 50edff80f5 feat: 导出 SproutClaw .sproutclaw 配置
包含 extensions、skills、prompts、settings、auth、models、mcp 等配置。
排除 node_modules、npm 缓存、sessions 等运行时数据。
2026-06-26 15:48:56 +08:00

1952 lines
51 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 代码材料后30页
软件名称StudioAgent AI视频制片平台软件
版本号V0.1.0
## 第 55 页
```text
// Get or create conversation
const convs = await apiFetch<{ id: string }[]>(
`/projects/${projectId}/conversations`,
);
if (convs.length > 0) {
setConversationId(convs[0].id);
} else {
const newConv = await apiFetch<{ id: string }>(
`/projects/${projectId}/conversations`,
{ method: "POST" },
);
setConversationId(newConv.id);
}
} catch (err) {
console.error("Bootstrap error:", err);
router.push("/projects");
}
}
bootstrap();
}, [projectId, token, router]);
return (
<div className="flex h-screen flex-col">
{/* Header */}
<header className="flex items-center justify-between border-b px-6 py-3">
<div className="flex items-center gap-3">
<a href="/projects" className="text-lg font-bold hover:opacity-80">
StudioAgent
</a>
<span className="text-gray-500">|</span>
<span className="text-gray-700 dark:text-gray-300">{projectTitle}</span>
</div>
<div className="flex items-center gap-4">
<button className="text-gray-500 hover:text-gray-700">设置</button>
</div>
</header>
{/* Main workspace: Chat + Assets */}
<div className="flex flex-1 overflow-hidden">
{/* Agent chat panel (55%) */}
<div className="w-[55%] border-r">
{conversationId ? (
<ChatPanel conversationId={conversationId} />
) : (
<div className="flex h-full items-center justify-center text-gray-400">
初始化中...
</div>
)}
</div>
{/* Asset panel (45%) */}
<div className="w-[45%]">
<AssetPanel />
</div>
</div>
{/* Footer status bar */}
<footer className="flex items-center justify-between border-t px-6 py-2 text-sm text-gray-500">
```
## 第 56 页
```text
<span>余额: ¥0.00</span>
<span>本项目消耗: ¥0.00</span>
<span>当前模型: Seedream 3.0</span>
</footer>
</div>
);
}
// File: frontend/src/app/projects/[id]/settings/page.tsx
export default function ProjectSettingsPage() {
return (
<div className="p-8">
<h1 className="text-2xl font-bold mb-8">项目设置</h1>
{/* TODO: Project settings form */}
</div>
);
}
// File: frontend/src/app/projects/page.tsx
"use client";
import { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import { apiFetch } from "@/lib/api";
import { useUserStore } from "@/stores/user";
interface Project {
id: string;
title: string;
description: string | null;
status: string;
style: string;
created_at: string;
}
export default function ProjectsPage() {
const router = useRouter();
const token = useUserStore((s) => s.token);
const [projects, setProjects] = useState<Project[]>([]);
const [loading, setLoading] = useState(true);
const [showCreate, setShowCreate] = useState(false);
const [newTitle, setNewTitle] = useState("");
const [newDesc, setNewDesc] = useState("");
const [creating, setCreating] = useState(false);
useEffect(() => {
if (!token) {
router.push("/login");
return;
}
loadProjects();
}, [token, router]);
async function loadProjects() {
try {
const data = await apiFetch<Project[]>("/projects");
setProjects(data);
} catch {
// token expired
router.push("/login");
```
## 第 57 页
```text
} finally {
setLoading(false);
}
}
async function handleCreate(e: React.FormEvent) {
e.preventDefault();
if (!newTitle.trim()) return;
setCreating(true);
try {
const project = await apiFetch<Project>("/projects", {
method: "POST",
body: JSON.stringify({ title: newTitle, description: newDesc || undefined }),
});
router.push(`/projects/${project.id}`);
} catch (err: any) {
alert(err.message || "创建失败");
} finally {
setCreating(false);
}
}
if (loading) {
return (
<div className="flex min-h-screen items-center justify-center text-gray-400">
加载中...
</div>
);
}
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-950">
<div className="mx-auto max-w-5xl p-8">
<div className="flex items-center justify-between mb-8">
<h1 className="text-2xl font-bold">我的项目</h1>
<button
onClick={() => setShowCreate(true)}
className="rounded-lg bg-blue-600 px-4 py-2 text-white hover:bg-blue-700 transition"
>
新建项目
</button>
</div>
{/* Create project dialog */}
{showCreate && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="w-full max-w-md rounded-xl bg-white p-6 shadow-xl dark:bg-gray-900">
<h2 className="text-lg font-bold mb-4">新建项目</h2>
<form onSubmit={handleCreate} className="space-y-4">
<div>
<label className="block text-sm font-medium mb-1">项目名称</label>
<input
type="text"
value={newTitle}
onChange={(e) => setNewTitle(e.target.value)}
required
className="w-full rounded-lg border px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-800 dark:border-gray-700"
placeholder="给你的项目起个名字"
autoFocus
```
## 第 58 页
```text
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">描述(可选)</label>
<textarea
value={newDesc}
onChange={(e) => setNewDesc(e.target.value)}
rows={3}
className="w-full rounded-lg border px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-800 dark:border-gray-700"
placeholder="简单描述你想制作的内容"
/>
</div>
<div className="flex justify-end gap-2">
<button
type="button"
onClick={() => setShowCreate(false)}
className="rounded-lg px-4 py-2 text-gray-600 hover:bg-gray-100 dark:hover:bg-gray-800 transition"
>
取消
</button>
<button
type="submit"
disabled={creating}
className="rounded-lg bg-blue-600 px-4 py-2 text-white hover:bg-blue-700 disabled:opacity-50 transition"
>
{creating ? "创建中..." : "创建"}
</button>
</div>
</form>
</div>
</div>
)}
{/* Project grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{projects.map((project) => (
<button
key={project.id}
onClick={() => router.push(`/projects/${project.id}`)}
className="rounded-xl border bg-white p-6 text-left hover:shadow-md transition dark:bg-gray-900 dark:border-gray-800"
>
<h3 className="font-bold text-lg mb-2">{project.title}</h3>
{project.description && (
<p className="text-sm text-gray-500 mb-3 line-clamp-2">{project.description}</p>
)}
<div className="flex items-center gap-2 text-xs text-gray-400">
<span>{project.style}</span>
<span>·</span>
<span>{new Date(project.created_at).toLocaleDateString("zh-CN")}</span>
</div>
</button>
))}
{projects.length === 0 && (
<div className="col-span-full rounded-xl border border-dashed border-gray-300 p-12 text-center text-gray-400">
暂无项目,点击上方按钮创建
</div>
)}
</div>
</div>
```
## 第 59 页
```text
</div>
);
}
// File: frontend/src/app/providers.tsx
"use client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useState } from "react";
export function Providers({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000,
retry: 1,
},
},
}),
);
return (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
}
// File: frontend/src/app/settings/page.tsx
export default function SettingsPage() {
return (
<div className="p-8">
<h1 className="text-2xl font-bold mb-8">用户设置</h1>
{/* TODO: User settings form */}
</div>
);
}
// File: frontend/src/stores/conversation.ts
import { create } from "zustand";
interface ConversationState {
activeConversationId: string | null;
isStreaming: boolean;
interactionMode: "supervised" | "collaborative" | "autonomous";
setActiveConversation: (id: string | null) => void;
setIsStreaming: (streaming: boolean) => void;
setInteractionMode: (mode: ConversationState["interactionMode"]) => void;
}
export const useConversationStore = create<ConversationState>((set) => ({
activeConversationId: null,
isStreaming: false,
interactionMode: "collaborative",
setActiveConversation: (id) => set({ activeConversationId: id }),
setIsStreaming: (streaming) => set({ isStreaming: streaming }),
setInteractionMode: (mode) => set({ interactionMode: mode }),
}));
// File: frontend/src/stores/user.ts
```
## 第 60 页
```text
import { create } from "zustand";
interface User {
id: string;
email: string;
name: string | null;
avatar_url: string | null;
balance: number;
}
interface UserState {
user: User | null;
token: string | null;
setUser: (user: User, token: string) => void;
logout: () => void;
}
export const useUserStore = create<UserState>((set) => ({
user: null,
token: typeof window !== "undefined" ? localStorage.getItem("token") : null,
setUser: (user, token) => {
if (typeof window !== "undefined") {
localStorage.setItem("token", token);
}
set({ user, token });
},
logout: () => {
if (typeof window !== "undefined") {
localStorage.removeItem("token");
}
set({ user: null, token: null });
},
}));
// File: frontend/src/components/agent/AgentStatusBar.tsx
"use client";
const AGENTS = [
{ key: "producer", label: "制片人", emoji: "🎬" },
{ key: "screenwriter", label: "编剧", emoji: "📝" },
{ key: "director", label: "导演", emoji: "🎥" },
{ key: "camera", label: "摄影", emoji: "📷" },
{ key: "editor", label: "剪辑", emoji: "🎞️" },
{ key: "sound", label: "音效", emoji: "🔊" },
];
interface AgentStatusBarProps {
activeAgent?: string;
agentStatus?: Record<string, string>;
}
export function AgentStatusBar({ activeAgent, agentStatus = {} }: AgentStatusBarProps) {
return (
<div className="border-t px-4 py-2">
<div className="flex items-center gap-3 text-xs text-gray-500">
{AGENTS.map((agent) => (
<span
key={agent.key}
className={
activeAgent === agent.key
```
## 第 61 页
```text
? "text-blue-600 font-medium"
: ""
}
>
{agent.emoji} {agent.label}: {agentStatus[agent.key] || "空闲"}
</span>
))}
</div>
</div>
);
}
// File: frontend/src/components/agent/ChatInput.tsx
"use client";
import { useState, useCallback } from "react";
interface ChatInputProps {
onSend: (content: string) => void;
disabled?: boolean;
}
export function ChatInput({ onSend, disabled }: ChatInputProps) {
const [content, setContent] = useState("");
const handleSubmit = useCallback(() => {
const trimmed = content.trim();
if (!trimmed || disabled) return;
onSend(trimmed);
setContent("");
}, [content, disabled, onSend]);
return (
<div className="border-t p-4">
<div className="flex items-center gap-2">
<input
type="text"
value={content}
onChange={(e) => setContent(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && !e.shiftKey && handleSubmit()}
placeholder="输入消息..."
disabled={disabled}
className="flex-1 rounded-lg border px-4 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-900 dark:border-gray-700"
/>
<button
onClick={handleSubmit}
disabled={disabled || !content.trim()}
className="rounded-lg bg-blue-600 px-4 py-2 text-white hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition"
>
发送
</button>
</div>
</div>
);
}
// File: frontend/src/components/agent/ChatPanel.tsx
"use client";
import { useAgentStream } from "@/hooks/useAgentStream";
```
## 第 62 页
```text
import { MessageBubble } from "./MessageBubble";
import { ChatInput } from "./ChatInput";
import { AgentStatusBar } from "./AgentStatusBar";
import { useEffect, useRef } from "react";
interface ChatPanelProps {
conversationId: string;
}
export function ChatPanel({ conversationId }: ChatPanelProps) {
const { messages, agentStatus, isStreaming, sendMessage } = useAgentStream(conversationId);
const scrollRef = useRef<HTMLDivElement>(null);
// Auto-scroll to bottom on new messages
useEffect(() => {
if (scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}
}, [messages]);
return (
<div className="flex h-full flex-col">
{/* Messages area */}
<div ref={scrollRef} className="flex-1 overflow-y-auto p-4 space-y-4">
{messages.length === 0 && (
<div className="flex h-full items-center justify-center text-gray-400">
<div className="text-center">
<p className="text-lg mb-2">开始与 AI 剧组对话</p>
<p className="text-sm">描述你想制作的视频Producer 将为你规划制作流程</p>
</div>
</div>
)}
{messages.map((msg) => (
<MessageBubble
key={msg.id}
role={msg.role}
content={msg.content}
agentName={msg.agentName}
toolCalls={msg.toolCalls}
/>
))}
{isStreaming && messages[messages.length - 1]?.role !== "assistant" && (
<div className="flex justify-start">
<div className="rounded-2xl rounded-bl-md bg-gray-100 px-4 py-3 dark:bg-gray-800">
<span className="animate-pulse text-sm text-gray-500">思考中...</span>
</div>
</div>
)}
</div>
{/* Agent status bar */}
<AgentStatusBar agentStatus={agentStatus} />
{/* Input area */}
<ChatInput onSend={sendMessage} disabled={isStreaming} />
</div>
);
}
// File: frontend/src/components/agent/MessageBubble.tsx
```
## 第 63 页
```text
"use client";
import { cn } from "@/lib/utils";
interface MessageBubbleProps {
role: "user" | "assistant" | "tool" | "system";
content: string;
agentName?: string;
toolCalls?: any[];
}
const AGENT_LABELS: Record<string, string> = {
producer: "制片人",
screenwriter: "编剧",
director: "导演",
camera: "摄影",
editor: "剪辑",
sound: "音效",
};
export function MessageBubble({ role, content, agentName, toolCalls }: MessageBubbleProps) {
if (role === "user") {
return (
<div className="flex justify-end">
<div className="max-w-[80%] rounded-2xl rounded-br-md bg-blue-600 px-4 py-3 text-white">
<p className="whitespace-pre-wrap text-sm">{content}</p>
</div>
</div>
);
}
if (role === "tool") {
return (
<div className="flex justify-start">
<div className="max-w-[80%] rounded-lg border border-gray-200 bg-gray-50 px-3 py-2 text-xs text-gray-600 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-400">
<span className="font-mono">{content}</span>
</div>
</div>
);
}
// assistant
return (
<div className="flex justify-start">
<div className="max-w-[80%]">
{agentName && (
<span className="mb-1 block text-xs font-medium text-gray-500">
{AGENT_LABELS[agentName] || agentName}
</span>
)}
<div
className={cn(
"rounded-2xl rounded-bl-md px-4 py-3",
"bg-gray-100 text-gray-900 dark:bg-gray-800 dark:text-gray-100",
)}
>
<p className="whitespace-pre-wrap text-sm">{content}</p>
</div>
{toolCalls && toolCalls.length > 0 && (
<div className="mt-1 space-y-1">
```
## 第 64 页
```text
{toolCalls.map((tc: any, i: number) => (
<div key={i} className="rounded border border-amber-200 bg-amber-50 px-2 py-1 text-xs text-amber-700 dark:border-amber-800 dark:bg-amber-950 dark:text-amber-400">
<span className="font-mono">{tc.tool || tc.name}</span>
</div>
))}
</div>
)}
</div>
</div>
);
}
// File: frontend/src/components/assets/AssetPanel.tsx
"use client";
export function AssetPanel() {
return (
<div className="flex h-full flex-col">
{/* Tab bar */}
<div className="flex items-center gap-1 border-b px-4 py-2">
{["角色", "场景", "分镜", "视频", "音频"].map((tab) => (
<button
key={tab}
className="rounded-md px-3 py-1 text-sm text-gray-600 hover:bg-gray-100 dark:hover:bg-gray-800"
>
{tab}
</button>
))}
</div>
{/* Asset content area */}
<div className="flex-1 overflow-y-auto p-4">
<div className="text-center text-gray-400 py-12">
项目资产将在创作过程中自动生成
</div>
</div>
</div>
);
}
// File: frontend/src/components/ui/avatar.tsx
"use client"
import * as React from "react"
import { Avatar as AvatarPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Avatar({
className,
size = "default",
...props
}: React.ComponentProps<typeof AvatarPrimitive.Root> & {
size?: "default" | "sm" | "lg"
}) {
return (
<AvatarPrimitive.Root
data-slot="avatar"
data-size={size}
className={cn(
```
## 第 65 页
```text
"group/avatar relative flex size-8 shrink-0 overflow-hidden rounded-full select-none data-[size=lg]:size-10 data-[size=sm]:size-6",
className
)}
{...props}
/>
)
}
function AvatarImage({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
return (
<AvatarPrimitive.Image
data-slot="avatar-image"
className={cn("aspect-square size-full", className)}
{...props}
/>
)
}
function AvatarFallback({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
return (
<AvatarPrimitive.Fallback
data-slot="avatar-fallback"
className={cn(
"flex size-full items-center justify-center rounded-full bg-muted text-sm text-muted-foreground group-data-[size=sm]/avatar:text-xs",
className
)}
{...props}
/>
)
}
function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="avatar-badge"
className={cn(
"absolute right-0 bottom-0 z-10 inline-flex items-center justify-center rounded-full bg-primary text-primary-foreground ring-2 ring-background select-none",
"group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>svg]:hidden",
"group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2",
"group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2",
className
)}
{...props}
/>
)
}
function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="avatar-group"
className={cn(
"group/avatar-group flex -space-x-2 *:data-[slot=avatar]:ring-2 *:data-[slot=avatar]:ring-background",
className
```
## 第 66 页
```text
)}
{...props}
/>
)
}
function AvatarGroupCount({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="avatar-group-count"
className={cn(
"relative flex size-8 shrink-0 items-center justify-center rounded-full bg-muted text-sm text-muted-foreground ring-2 ring-background group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=sm]/avatar-group:size-6 [&>svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3",
className
)}
{...props}
/>
)
}
export {
Avatar,
AvatarImage,
AvatarFallback,
AvatarBadge,
AvatarGroup,
AvatarGroupCount,
}
// File: frontend/src/components/ui/button.tsx
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
```
## 第 67 页
```text
"icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
"icon-sm": "size-8",
"icon-lg": "size-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant = "default",
size = "default",
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot.Root : "button"
return (
<Comp
data-slot="button"
data-variant={variant}
data-size={size}
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }
// File: frontend/src/components/ui/card.tsx
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"flex flex-col gap-6 rounded-xl border bg-card py-6 text-card-foreground shadow-sm",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
```
## 第 68 页
```text
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-6", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props}
/>
)
}
```
## 第 69 页
```text
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}
// File: frontend/src/components/ui/input.tsx
import * as React from "react"
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none selection:bg-primary selection:text-primary-foreground file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm dark:bg-input/30",
"focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50",
"aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
export { Input }
// File: frontend/src/components/ui/label.tsx
"use client"
import * as React from "react"
import { Label as LabelPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props}
/>
)
}
export { Label }
```
## 第 70 页
```text
// File: frontend/src/components/ui/scroll-area.tsx
"use client"
import * as React from "react"
import { ScrollArea as ScrollAreaPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function ScrollArea({
className,
children,
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
return (
<ScrollAreaPrimitive.Root
data-slot="scroll-area"
className={cn("relative", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport
data-slot="scroll-area-viewport"
className="size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1"
>
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
)
}
function ScrollBar({
className,
orientation = "vertical",
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
return (
<ScrollAreaPrimitive.ScrollAreaScrollbar
data-slot="scroll-area-scrollbar"
orientation={orientation}
className={cn(
"flex touch-none p-px transition-colors select-none",
orientation === "vertical" &&
"h-full w-2.5 border-l border-l-transparent",
orientation === "horizontal" &&
"h-2.5 flex-col border-t border-t-transparent",
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb
data-slot="scroll-area-thumb"
className="relative flex-1 rounded-full bg-border"
/>
</ScrollAreaPrimitive.ScrollAreaScrollbar>
)
}
export { ScrollArea, ScrollBar }
```
## 第 71 页
```text
// File: frontend/src/components/ui/textarea.tsx
import * as React from "react"
import { cn } from "@/lib/utils"
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"flex field-sizing-content min-h-16 w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
export { Textarea }
// File: frontend/src/hooks/useAgentStream.ts
"use client";
import { useState, useCallback } from "react";
export interface Message {
id: string;
role: "user" | "assistant" | "tool" | "system";
agentName?: string;
content: string;
toolCalls?: any[];
metadata?: Record<string, any>;
createdAt: Date;
}
export interface ConfirmData {
type: string;
summary: string;
data: any;
options: string[];
}
/**
* SSE event stream hook for Agent interaction.
*/
export function useAgentStream(conversationId: string) {
const [messages, setMessages] = useState<Message[]>([]);
const [agentStatus, setAgentStatus] = useState<Record<string, string>>({});
const [isStreaming, setIsStreaming] = useState(false);
const [confirmRequest, setConfirmRequest] = useState<ConfirmData | null>(null);
function getAuthHeaders(): Record<string, string> {
const token = typeof window !== "undefined" ? localStorage.getItem("token") : null;
return {
"Content-Type": "application/json",
...(token ? { Authorization: `Bearer ${token}` } : {}),
};
}
async function parseSSEStream(response: Response) {
```
## 第 72 页
```text
if (!response.ok) {
const err = await response.json().catch(() => ({ detail: response.statusText }));
throw new Error(err.detail || response.statusText);
}
if (!response.body) return;
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
let currentEvent = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() || "";
for (const line of lines) {
if (line.startsWith("event: ")) {
currentEvent = line.slice(7);
} else if (line.startsWith("data: ") && currentEvent) {
try {
const data = JSON.parse(line.slice(6));
handleSSEEvent(currentEvent, data);
} catch {
// skip malformed data
}
currentEvent = "";
}
}
}
}
const sendMessage = useCallback(
async (content: string) => {
setIsStreaming(true);
// Add user message
setMessages((prev) => [
...prev,
{
id: crypto.randomUUID(),
role: "user",
content,
createdAt: new Date(),
},
]);
try {
const response = await fetch(
`/api/v1/conversations/${conversationId}/messages`,
{
method: "POST",
headers: getAuthHeaders(),
body: JSON.stringify({ content }),
},
);
await parseSSEStream(response);
```
## 第 73 页
```text
} catch (err) {
console.error("Send message error:", err);
} finally {
setIsStreaming(false);
}
},
[conversationId],
);
const resume = useCallback(
async (action: string, feedback?: string) => {
setIsStreaming(true);
setConfirmRequest(null);
try {
const response = await fetch(
`/api/v1/conversations/${conversationId}/resume`,
{
method: "POST",
headers: getAuthHeaders(),
body: JSON.stringify({ action, feedback }),
},
);
await parseSSEStream(response);
} catch (err) {
console.error("Resume error:", err);
} finally {
setIsStreaming(false);
}
},
[conversationId],
);
function handleSSEEvent(event: string, data: any) {
switch (event) {
case "agent.text_start":
setAgentStatus((prev) => ({ ...prev, [data.agent]: "工作中" }));
break;
case "agent.text_delta":
setMessages((prev) => {
const last = prev[prev.length - 1];
if (last?.role === "assistant" && last.agentName === data.agent) {
return [
...prev.slice(0, -1),
{ ...last, content: last.content + data.content },
];
}
return [
...prev,
{
id: crypto.randomUUID(),
role: "assistant",
agentName: data.agent,
content: data.content,
createdAt: new Date(),
},
];
});
break;
```
## 第 74 页
```text
case "agent.text_end":
break;
case "agent.tool_call":
setMessages((prev) => {
const last = prev[prev.length - 1];
if (last?.role === "assistant" && last.agentName === data.agent) {
return [
...prev.slice(0, -1),
{
...last,
toolCalls: [...(last.toolCalls || []), { tool: data.tool, id: data.id }],
},
];
}
return prev;
});
break;
case "agent.tool_result":
setMessages((prev) => [
...prev,
{
id: crypto.randomUUID(),
role: "tool",
content: typeof data.content === "string" ? data.content : JSON.stringify(data.content),
metadata: { tool_call_id: data.tool_call_id },
createdAt: new Date(),
},
]);
break;
case "agent.handoff":
setAgentStatus((prev) => ({
...prev,
[data.from]: "已交接",
[data.to]: "工作中",
}));
break;
case "agent.confirm_request":
setConfirmRequest(data);
break;
case "done":
setAgentStatus({});
break;
case "error":
console.error("SSE error:", data);
break;
}
}
return { messages, setMessages, agentStatus, isStreaming, confirmRequest, sendMessage, resume };
}
// File: frontend/src/hooks/useCandidateSystem.ts
"use client";
```
## 第 75 页
```text
import { useState, useCallback } from "react";
export interface CandidateState {
originalUrl: string | null;
candidates: string[]; // may contain "PENDING:xxx" placeholders
selectedIndex: number; // -1=original, 0~N=candidate
previousUrl: string | null;
}
/**
* Universal candidate card-draw state management.
* Ported from waoowaoo's useCandidateSystem hook.
*
* Used for character appearances, location images, and panel images.
*/
export function useCandidateSystem() {
const [states, setStates] = useState<Record<string, CandidateState>>({});
const initCandidates = useCallback(
(id: string, state: CandidateState) => {
setStates((prev) => ({ ...prev, [id]: state }));
},
[],
);
const selectCandidate = useCallback((id: string, index: number) => {
setStates((prev) => ({
...prev,
[id]: { ...prev[id], selectedIndex: index },
}));
}, []);
const confirmCandidate = useCallback(async (id: string) => {
const state = states[id];
if (!state) return;
await fetch("/api/v1/candidates/confirm", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
entity_type: "auto", // determined by backend
entity_id: id,
selected_index: state.selectedIndex,
}),
});
}, [states]);
const cancelCandidates = useCallback((id: string) => {
setStates((prev) => {
const next = { ...prev };
delete next[id];
return next;
});
}, []);
const undoSelection = useCallback(async (id: string) => {
await fetch("/api/v1/candidates/undo", {
method: "POST",
headers: { "Content-Type": "application/json" },
```
## 第 76 页
```text
body: JSON.stringify({ entity_type: "auto", entity_id: id }),
});
}, []);
const getDisplayImage = useCallback(
(id: string, fallback?: string): string => {
const state = states[id];
if (!state) return fallback || "";
if (state.selectedIndex === -1) return state.originalUrl || fallback || "";
return state.candidates[state.selectedIndex] || fallback || "";
},
[states],
);
return {
states,
initCandidates,
selectCandidate,
confirmCandidate,
cancelCandidates,
undoSelection,
getDisplayImage,
};
}
// File: frontend/src/lib/api.ts
/**
* API client — thin fetch wrapper with auth header injection.
*/
const API_BASE = "/api/v1";
export async function apiFetch<T>(
path: string,
options: RequestInit = {},
): Promise<T> {
const token =
typeof window !== "undefined" ? localStorage.getItem("token") : null;
const res = await fetch(`${API_BASE}${path}`, {
...options,
headers: {
"Content-Type": "application/json",
...(token ? { Authorization: `Bearer ${token}` } : {}),
...options.headers,
},
});
if (!res.ok) {
const error = await res.json().catch(() => ({ detail: res.statusText }));
throw new Error(error.detail || res.statusText);
}
return res.json();
}
// File: frontend/src/lib/utils.ts
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
```
## 第 77 页
```text
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
// File: frontend/next.config.ts
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
// Proxy API requests to backend in development
async rewrites() {
return [
{
source: "/api/:path*",
destination: "http://localhost:8000/api/:path*",
},
];
},
};
export default nextConfig;
// File: frontend/src/app/globals.css
@import "tailwindcss";
@import "tw-animate-css";
@import "shadcn/tailwind.css";
@custom-variant dark (&:is(.dark *));
body {
font-family: system-ui, -apple-system, sans-serif;
}
@theme inline {
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--radius-2xl: calc(var(--radius) + 8px);
--radius-3xl: calc(var(--radius) + 12px);
--radius-4xl: calc(var(--radius) + 16px);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
```
## 第 78 页
```text
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
}
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
```
## 第 79 页
```text
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}
// File: backend/pyproject.toml
[project]
name = "studio-agent-backend"
version = "0.1.0"
description = "StudioAgent — Multi-Agent AI Production Platform Backend"
readme = "README.md"
requires-python = ">=3.12"
dependencies = [
# ── Web Framework ──
"fastapi>=0.115.0",
"uvicorn[standard]>=0.32.0",
"python-multipart>=0.0.12",
"sse-starlette>=2.1.0",
# ── Agent Orchestration ──
"langgraph>=0.4.0",
"langgraph-swarm>=0.0.4",
"langgraph-checkpoint-postgres>=2.0.0",
"langchain-core>=0.3.0",
"langchain-openai>=0.3.0",
"langchain-google-genai>=2.1.0",
# ── Database ──
"sqlalchemy[asyncio]>=2.0.36",
"asyncpg>=0.30.0",
"alembic>=1.14.0",
# ── Task Queue ──
"celery[redis]>=5.4.0",
```
## 第 80 页
```text
# ── Auth ──
"pyjwt>=2.10.0",
"passlib[bcrypt]>=1.7.4",
# ── Utilities ──
"pydantic>=2.10.0",
"pydantic-settings>=2.7.0",
"httpx>=0.28.0",
"python-dotenv>=1.0.1",
"email-validator>=2.1.0",
"structlog>=24.4.0",
"tenacity>=9.0.0",
# ── Cloud Storage ──
"boto3>=1.35.0",
]
[project.optional-dependencies]
dev = [
"pytest>=8.3.0",
"pytest-asyncio>=0.24.0",
"pytest-cov>=6.0.0",
"httpx>=0.28.0",
"ruff>=0.8.0",
"mypy>=1.13.0",
]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.ruff]
target-version = "py312"
line-length = 100
[tool.ruff.lint]
select = ["E", "F", "I", "N", "W", "UP"]
[tool.pytest.ini_options]
asyncio_mode = "auto"
testpaths = ["tests"]
[tool.mypy]
python_version = "3.12"
strict = true
// File: docker-compose.dev.yml
# Development overrides — lighter weight, no Celery workers
services:
backend:
command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
environment:
- DEBUG=true
frontend:
command: npm run dev
# Disable Celery workers in dev (use inline execution)
celery-image:
```
## 第 81 页
```text
profiles: ["workers"]
celery-video:
profiles: ["workers"]
celery-voice:
profiles: ["workers"]
celery-text:
profiles: ["workers"]
// File: docker-compose.yml
services:
# ── Database ──
postgres:
image: postgres:16-alpine
environment:
POSTGRES_DB: studioagent
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 5s
retries: 5
# ── Redis ──
redis:
image: redis:7-alpine
ports:
- "6379:6379"
volumes:
- redis_data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 5s
retries: 5
# ── Backend API ──
backend:
build:
context: ./backend
dockerfile: Dockerfile
ports:
- "8000:8000"
env_file:
- .env
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
volumes:
- ./backend:/app
command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
# ── Celery Worker (Image) ──
celery-image:
```
## 第 82 页
```text
build:
context: ./backend
dockerfile: Dockerfile
env_file:
- .env
depends_on:
redis:
condition: service_healthy
postgres:
condition: service_healthy
volumes:
- ./backend:/app
command: celery -A celery_app worker -Q image --concurrency=20 -l info
# ── Celery Worker (Video) ──
celery-video:
build:
context: ./backend
dockerfile: Dockerfile
env_file:
- .env
depends_on:
redis:
condition: service_healthy
volumes:
- ./backend:/app
command: celery -A celery_app worker -Q video --concurrency=4 -l info
# ── Celery Worker (Voice) ──
celery-voice:
build:
context: ./backend
dockerfile: Dockerfile
env_file:
- .env
depends_on:
redis:
condition: service_healthy
volumes:
- ./backend:/app
command: celery -A celery_app worker -Q voice --concurrency=10 -l info
# ── Celery Worker (Text/LLM) ──
celery-text:
build:
context: ./backend
dockerfile: Dockerfile
env_file:
- .env
depends_on:
redis:
condition: service_healthy
volumes:
- ./backend:/app
command: celery -A celery_app worker -Q text --concurrency=10 -l info
# ── Frontend ──
frontend:
build:
context: ./frontend
```
## 第 83 页
```text
dockerfile: Dockerfile
ports:
- "3000:3000"
depends_on:
- backend
volumes:
- ./frontend:/app
- /app/node_modules
command: npm run dev
volumes:
postgres_data:
redis_data:
// File: backend/celery_app.py
"""Celery application entry point."""
from celery import Celery
from app.config import settings
celery_app = Celery(
"studioagent",
broker=settings.celery_broker_url,
backend=settings.celery_result_backend,
)
celery_app.conf.update(
task_serializer="json",
accept_content=["json"],
result_serializer="json",
timezone="UTC",
enable_utc=True,
task_track_started=True,
task_routes={
"app.workers.image_worker.*": {"queue": "image"},
"app.workers.video_worker.*": {"queue": "video"},
"app.workers.voice_worker.*": {"queue": "voice"},
"app.workers.text_worker.*": {"queue": "text"},
},
worker_concurrency=4,
)
celery_app.autodiscover_tasks(["app.workers"])
// File: frontend/package.json
{
"name": "studio-agent-frontend",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev --turbopack",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"@radix-ui/react-avatar": "^1.1.3",
"@radix-ui/react-dialog": "^1.1.6",
"@radix-ui/react-dropdown-menu": "^2.1.6",
```
## 第 84 页
```text
"@radix-ui/react-scroll-area": "^1.2.3",
"@radix-ui/react-slot": "^1.1.1",
"@radix-ui/react-tabs": "^1.1.3",
"@radix-ui/react-tooltip": "^1.1.8",
"@tanstack/react-query": "^5.68.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"framer-motion": "^12.5.0",
"lucide-react": "^0.474.0",
"next": "^15.3.0",
"radix-ui": "^1.4.3",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"tailwind-merge": "^3.5.0",
"zustand": "^5.0.3"
},
"devDependencies": {
"@eslint/eslintrc": "^3.2.0",
"@tailwindcss/postcss": "^4.1.0",
"@types/node": "^22.12.0",
"@types/react": "^19.1.0",
"@types/react-dom": "^19.1.0",
"eslint": "^9.18.0",
"eslint-config-next": "^15.3.0",
"postcss": "^8.5.0",
"shadcn": "^3.8.5",
"tailwindcss": "^4.1.0",
"tw-animate-css": "^1.4.0",
"typescript": "^5.7.0"
}
}
// File: frontend/tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [{ "name": "next" }],
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}
```