包含 extensions、skills、prompts、settings、auth、models、mcp 等配置。 排除 node_modules、npm 缓存、sessions 等运行时数据。
1955 lines
53 KiB
Markdown
1955 lines
53 KiB
Markdown
# 代码材料(前30页)
|
||
|
||
软件名称:StudioAgent AI视频制片平台软件
|
||
版本号:V0.1.0
|
||
|
||
## 第 1 页
|
||
|
||
```text
|
||
// File: frontend/src/app/layout.tsx
|
||
import type { Metadata } from "next";
|
||
import { Providers } from "./providers";
|
||
import "./globals.css";
|
||
|
||
export const metadata: Metadata = {
|
||
title: "StudioAgent — AI 制片平台",
|
||
description: "多 Agent 协作的 AI 制片平台",
|
||
};
|
||
|
||
export default function RootLayout({
|
||
children,
|
||
}: {
|
||
children: React.ReactNode;
|
||
}) {
|
||
return (
|
||
<html lang="zh-CN">
|
||
<body className="antialiased">
|
||
<Providers>{children}</Providers>
|
||
</body>
|
||
</html>
|
||
);
|
||
}
|
||
|
||
// File: frontend/src/app/page.tsx
|
||
export default function Home() {
|
||
return (
|
||
<main className="flex min-h-screen flex-col items-center justify-center p-24">
|
||
<h1 className="text-4xl font-bold mb-4">StudioAgent</h1>
|
||
<p className="text-lg text-gray-600 dark:text-gray-400 mb-8">
|
||
多 Agent 协作的 AI 制片平台
|
||
</p>
|
||
<div className="flex gap-4">
|
||
<a
|
||
href="/projects"
|
||
className="rounded-lg bg-blue-600 px-6 py-3 text-white hover:bg-blue-700 transition"
|
||
>
|
||
开始创作
|
||
</a>
|
||
<a
|
||
href="/login"
|
||
className="rounded-lg border border-gray-300 px-6 py-3 hover:bg-gray-50 dark:hover:bg-gray-900 transition"
|
||
>
|
||
登录
|
||
</a>
|
||
</div>
|
||
</main>
|
||
);
|
||
}
|
||
|
||
// File: backend/app/agents/compaction.py
|
||
"""Context compaction service (inspired by pi-mono session.compact())."""
|
||
|
||
|
||
async def compact_conversation(conversation_id: str) -> None:
|
||
"""Compress conversation history.
|
||
|
||
Strategy:
|
||
1. Keep last 10 messages intact
|
||
2. Summarize older messages into a single system message via LLM
|
||
```
|
||
|
||
## 第 2 页
|
||
|
||
```text
|
||
3. Preserve all interrupt/confirm checkpoint decisions
|
||
4. Preserve tool_call result summaries (not full params)
|
||
"""
|
||
# TODO: implement
|
||
pass
|
||
|
||
// File: backend/app/agents/graph.py
|
||
"""LangGraph Swarm graph definition — 6-agent production crew."""
|
||
|
||
from langgraph.checkpoint.postgres.aio import AsyncPostgresSaver
|
||
from langgraph.graph.state import CompiledStateGraph
|
||
from langgraph_swarm import create_handoff_tool, create_swarm
|
||
from langchain.agents import create_agent
|
||
|
||
|
||
def _load_prompt(agent_name: str) -> str:
|
||
"""Load system prompt from markdown file."""
|
||
from pathlib import Path
|
||
|
||
prompt_path = Path(__file__).parent.parent / "llm" / "prompts" / f"{agent_name}.md"
|
||
if prompt_path.exists():
|
||
return prompt_path.read_text(encoding="utf-8")
|
||
return f"You are the {agent_name} agent of the StudioAgent production crew."
|
||
|
||
|
||
def _build_swarm():
|
||
"""Build the swarm graph (deferred to avoid import-time LLM initialization)."""
|
||
from app.agents.tools.producer_tools import (
|
||
plan_production,
|
||
estimate_cost,
|
||
query_project_status,
|
||
)
|
||
from app.agents.tools.screenwriter_tools import (
|
||
analyze_text,
|
||
extract_characters,
|
||
extract_locations,
|
||
generate_script,
|
||
edit_script,
|
||
)
|
||
from app.agents.tools.director_tools import (
|
||
generate_storyboard,
|
||
plan_shots,
|
||
review_continuity,
|
||
approve_visual,
|
||
)
|
||
from app.agents.tools.camera_tools import (
|
||
generate_character_image,
|
||
generate_location_image,
|
||
generate_panel_image,
|
||
modify_image,
|
||
)
|
||
from app.agents.tools.editor_tools import (
|
||
generate_video,
|
||
extend_video,
|
||
compose_timeline,
|
||
)
|
||
from app.agents.tools.sound_tools import (
|
||
analyze_dialogue,
|
||
generate_voice,
|
||
design_sfx,
|
||
```
|
||
|
||
## 第 3 页
|
||
|
||
```text
|
||
match_bgm,
|
||
)
|
||
from app.llm import get_llm
|
||
from app.config import settings
|
||
|
||
# ═══════════ Handoff Tools ═══════════
|
||
|
||
handoff_to_producer = create_handoff_tool(
|
||
agent_name="producer",
|
||
description="将控制权交还给制片人,用于汇报工作结果或请求下一步指示",
|
||
)
|
||
handoff_to_screenwriter = create_handoff_tool(
|
||
agent_name="screenwriter",
|
||
description="将任务交给编剧,用于文本分析、角色提取、剧本生成",
|
||
)
|
||
handoff_to_director = create_handoff_tool(
|
||
agent_name="director",
|
||
description="将任务交给导演,用于分镜生成、镜头规划、视觉一致性审核",
|
||
)
|
||
handoff_to_camera = create_handoff_tool(
|
||
agent_name="camera",
|
||
description="将任务交给摄影,用于角色形象/场景图/分镜画面生成",
|
||
)
|
||
handoff_to_editor = create_handoff_tool(
|
||
agent_name="editor",
|
||
description="将任务交给剪辑,用于视频生成和时间轴编排",
|
||
)
|
||
handoff_to_sound = create_handoff_tool(
|
||
agent_name="sound",
|
||
description="将任务交给音效,用于配音生成和音效设计",
|
||
)
|
||
|
||
# ═══════════ Agent Definitions ═══════════
|
||
|
||
producer = create_agent(
|
||
model=get_llm(settings.producer_model),
|
||
tools=[
|
||
plan_production,
|
||
estimate_cost,
|
||
query_project_status,
|
||
handoff_to_screenwriter,
|
||
handoff_to_director,
|
||
handoff_to_camera,
|
||
handoff_to_editor,
|
||
handoff_to_sound,
|
||
],
|
||
system_prompt=_load_prompt("producer"),
|
||
name="producer",
|
||
)
|
||
|
||
screenwriter = create_agent(
|
||
model=get_llm(settings.screenwriter_model),
|
||
tools=[
|
||
analyze_text,
|
||
extract_characters,
|
||
extract_locations,
|
||
generate_script,
|
||
edit_script,
|
||
handoff_to_producer,
|
||
],
|
||
```
|
||
|
||
## 第 4 页
|
||
|
||
```text
|
||
system_prompt=_load_prompt("screenwriter"),
|
||
name="screenwriter",
|
||
)
|
||
|
||
director = create_agent(
|
||
model=get_llm(settings.director_model),
|
||
tools=[
|
||
generate_storyboard,
|
||
plan_shots,
|
||
review_continuity,
|
||
approve_visual,
|
||
handoff_to_producer,
|
||
],
|
||
system_prompt=_load_prompt("director"),
|
||
name="director",
|
||
)
|
||
|
||
camera = create_agent(
|
||
model=get_llm(settings.camera_model),
|
||
tools=[
|
||
generate_character_image,
|
||
generate_location_image,
|
||
generate_panel_image,
|
||
modify_image,
|
||
handoff_to_producer,
|
||
],
|
||
system_prompt=_load_prompt("camera"),
|
||
name="camera",
|
||
)
|
||
|
||
editor = create_agent(
|
||
model=get_llm(settings.editor_model),
|
||
tools=[
|
||
generate_video,
|
||
extend_video,
|
||
compose_timeline,
|
||
handoff_to_producer,
|
||
],
|
||
system_prompt=_load_prompt("editor"),
|
||
name="editor",
|
||
)
|
||
|
||
sound = create_agent(
|
||
model=get_llm(settings.sound_model),
|
||
tools=[
|
||
analyze_dialogue,
|
||
generate_voice,
|
||
design_sfx,
|
||
match_bgm,
|
||
handoff_to_producer,
|
||
],
|
||
system_prompt=_load_prompt("sound"),
|
||
name="sound",
|
||
)
|
||
|
||
# ═══════════ Build Swarm ═══════════
|
||
|
||
return create_swarm(
|
||
agents=[producer, screenwriter, director, camera, editor, sound],
|
||
default_active_agent="producer",
|
||
```
|
||
|
||
## 第 5 页
|
||
|
||
```text
|
||
)
|
||
|
||
|
||
async def build_graph(db_url: str):
|
||
"""Compile the swarm graph with PostgreSQL checkpointing.
|
||
|
||
Returns (compiled_graph, checkpointer_context) — caller must keep
|
||
the context alive for the lifetime of the app.
|
||
"""
|
||
swarm_graph = _build_swarm()
|
||
checkpointer_ctx = AsyncPostgresSaver.from_conn_string(db_url)
|
||
checkpointer = await checkpointer_ctx.__aenter__()
|
||
await checkpointer.setup()
|
||
return swarm_graph.compile(checkpointer=checkpointer), checkpointer_ctx
|
||
|
||
// File: backend/app/agents/session.py
|
||
"""Conversation session management (inspired by pi-mono AgentSession)."""
|
||
|
||
from app.agents.state import ProductionState
|
||
|
||
|
||
class ConversationSession:
|
||
"""Manages a conversation session's lifecycle.
|
||
|
||
Provides equivalents to pi-mono's AgentSession interface:
|
||
- send (prompt) → POST /conversations/{id}/messages
|
||
- confirm (resume) → POST /conversations/{id}/resume
|
||
- steer → POST /conversations/{id}/steer
|
||
- fork → POST /conversations/{id}/fork
|
||
- compact → POST /conversations/{id}/compact
|
||
- abort → POST /conversations/{id}/abort
|
||
"""
|
||
|
||
def __init__(self, conversation_id: str, project_id: str):
|
||
self.conversation_id = conversation_id
|
||
self.project_id = project_id
|
||
|
||
async def get_state(self) -> ProductionState | None:
|
||
"""Get current production state from LangGraph checkpoint."""
|
||
# TODO: implement
|
||
return None
|
||
|
||
// File: backend/app/agents/sse_transformer.py
|
||
"""LangGraph → SSE bridge — transforms graph stream events to SSE protocol."""
|
||
|
||
import json
|
||
import uuid
|
||
from collections.abc import AsyncGenerator
|
||
|
||
from langchain_core.messages import AIMessageChunk, ToolMessage
|
||
from langgraph.graph.state import CompiledStateGraph
|
||
|
||
|
||
def sse_event(event_type: str, data: dict) -> str:
|
||
"""Format a single SSE event."""
|
||
return f"event: {event_type}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n"
|
||
|
||
|
||
async def stream_graph_to_sse(
|
||
graph: CompiledStateGraph,
|
||
```
|
||
|
||
## 第 6 页
|
||
|
||
```text
|
||
input_state: dict,
|
||
config: dict,
|
||
) -> AsyncGenerator[str, None]:
|
||
"""Stream LangGraph execution as SSE events.
|
||
|
||
Maps LangGraph stream events to the SSE protocol:
|
||
- AIMessageChunk.content → agent.text_start / text_delta / text_end
|
||
- AIMessageChunk.tool_call_chunks → agent.tool_call
|
||
- ToolMessage → agent.tool_result
|
||
- Exception → error
|
||
- Normal end → done
|
||
"""
|
||
message_id = str(uuid.uuid4())
|
||
current_agent = "producer"
|
||
text_started = False
|
||
full_response = ""
|
||
|
||
try:
|
||
async for event in graph.astream(input_state, config, stream_mode="messages"):
|
||
# stream_mode="messages" yields (message_chunk, metadata) tuples
|
||
if not isinstance(event, tuple) or len(event) != 2:
|
||
continue
|
||
|
||
chunk, metadata = event
|
||
|
||
# Track current agent from metadata
|
||
agent_name = metadata.get("langgraph_node", current_agent)
|
||
if agent_name != current_agent:
|
||
# Agent handoff
|
||
yield sse_event("agent.handoff", {
|
||
"from": current_agent,
|
||
"to": agent_name,
|
||
})
|
||
current_agent = agent_name
|
||
# Reset text state for new agent
|
||
if text_started:
|
||
yield sse_event("agent.text_end", {
|
||
"agent": current_agent,
|
||
"message_id": message_id,
|
||
})
|
||
text_started = False
|
||
message_id = str(uuid.uuid4())
|
||
|
||
if isinstance(chunk, AIMessageChunk):
|
||
# Text content
|
||
if chunk.content:
|
||
content = chunk.content if isinstance(chunk.content, str) else str(chunk.content)
|
||
if content:
|
||
if not text_started:
|
||
yield sse_event("agent.text_start", {
|
||
"agent": current_agent,
|
||
"message_id": message_id,
|
||
})
|
||
text_started = True
|
||
yield sse_event("agent.text_delta", {
|
||
"agent": current_agent,
|
||
"content": content,
|
||
})
|
||
full_response += content
|
||
|
||
```
|
||
|
||
## 第 7 页
|
||
|
||
```text
|
||
# Tool calls
|
||
if chunk.tool_call_chunks:
|
||
for tc in chunk.tool_call_chunks:
|
||
if tc.get("name"):
|
||
yield sse_event("agent.tool_call", {
|
||
"agent": current_agent,
|
||
"tool": tc.get("name"),
|
||
"args": tc.get("args", ""),
|
||
"id": tc.get("id", ""),
|
||
})
|
||
|
||
elif isinstance(chunk, ToolMessage):
|
||
yield sse_event("agent.tool_result", {
|
||
"agent": current_agent,
|
||
"tool_call_id": chunk.tool_call_id,
|
||
"content": chunk.content if isinstance(chunk.content, str) else json.dumps(chunk.content, ensure_ascii=False),
|
||
})
|
||
|
||
# End text stream if still open
|
||
if text_started:
|
||
yield sse_event("agent.text_end", {
|
||
"agent": current_agent,
|
||
"message_id": message_id,
|
||
})
|
||
|
||
yield sse_event("done", {"reason": "complete", "full_response": full_response})
|
||
|
||
except Exception as e:
|
||
if text_started:
|
||
yield sse_event("agent.text_end", {
|
||
"agent": current_agent,
|
||
"message_id": message_id,
|
||
})
|
||
yield sse_event("error", {"message": str(e)})
|
||
|
||
// File: backend/app/agents/state.py
|
||
"""Production State — LangGraph state schema for the Swarm."""
|
||
|
||
from __future__ import annotations
|
||
|
||
from typing import Annotated
|
||
|
||
from langchain_core.messages import BaseMessage
|
||
from langgraph.graph.message import add_messages
|
||
from pydantic import BaseModel
|
||
from typing_extensions import TypedDict
|
||
|
||
|
||
class ProductionPlan(BaseModel):
|
||
"""A production plan created by Producer."""
|
||
|
||
title: str
|
||
description: str
|
||
phases: list[str]
|
||
estimated_cost: float
|
||
estimated_duration: str
|
||
|
||
|
||
class CharacterRef(BaseModel):
|
||
"""Character reference for production state."""
|
||
```
|
||
|
||
## 第 8 页
|
||
|
||
```text
|
||
|
||
id: str
|
||
name: str
|
||
description: str | None = None
|
||
appearance_url: str | None = None
|
||
|
||
|
||
class LocationRef(BaseModel):
|
||
"""Location reference for production state."""
|
||
|
||
id: str
|
||
name: str
|
||
description: str | None = None
|
||
image_url: str | None = None
|
||
|
||
|
||
class ScriptData(BaseModel):
|
||
"""Structured script data."""
|
||
|
||
episode_index: int = 0
|
||
title: str | None = None
|
||
scenes: list[dict] = []
|
||
raw_text: str | None = None
|
||
|
||
|
||
class StoryboardData(BaseModel):
|
||
"""Structured storyboard data."""
|
||
|
||
panels: list[dict] = []
|
||
|
||
|
||
class ProductionState(TypedDict):
|
||
"""Global state for the LangGraph Swarm.
|
||
|
||
This state is shared across all agents and persisted via checkpointing.
|
||
Producer uses `completed_steps` and `available_assets` for dynamic state
|
||
awareness instead of a fixed `current_phase` enum.
|
||
"""
|
||
|
||
messages: Annotated[list[BaseMessage], add_messages]
|
||
project_id: str
|
||
plan: ProductionPlan | None
|
||
plan_approved: bool
|
||
characters: list[CharacterRef]
|
||
locations: list[LocationRef]
|
||
script: ScriptData | None
|
||
storyboard: StoryboardData | None
|
||
|
||
# ── Dynamic state (replaces fixed current_phase) ──
|
||
completed_steps: list[str] # e.g. ["characters_extracted", "script_generated"]
|
||
available_assets: dict # e.g. {"characters": 5, "locations": 3, "panels": 0}
|
||
|
||
# ── User interaction preference ──
|
||
interaction_mode: str # "collaborative" | "autonomous" | "supervised"
|
||
|
||
# ── Agent runtime ──
|
||
awaiting_confirmation: str | None
|
||
active_agent: str # tracked by LangGraph Swarm
|
||
|
||
// File: backend/app/agents/tools/base.py
|
||
```
|
||
|
||
## 第 9 页
|
||
|
||
```text
|
||
"""Tool definition base classes."""
|
||
|
||
from pydantic import BaseModel
|
||
from typing import Any, Callable, Awaitable
|
||
|
||
|
||
class ToolDefinition(BaseModel):
|
||
"""Tool definition (inspired by pi-mono Tool interface).
|
||
|
||
Each tool uses a Pydantic Model for parameters, auto-generating
|
||
JSON Schema for the LLM.
|
||
"""
|
||
|
||
name: str
|
||
label: str
|
||
description: str
|
||
parameters: type[BaseModel]
|
||
execute: Callable[..., Awaitable[Any]]
|
||
|
||
model_config = {"arbitrary_types_allowed": True}
|
||
|
||
|
||
class ToolContext(BaseModel):
|
||
"""Tool execution context."""
|
||
|
||
project_id: str
|
||
user_id: str
|
||
conversation_id: str
|
||
stream_writer: Any = None # LangGraph get_stream_writer() reference
|
||
|
||
// File: backend/app/agents/tools/camera_tools.py
|
||
"""Camera Agent tools."""
|
||
|
||
from langchain_core.tools import tool
|
||
|
||
|
||
@tool
|
||
def generate_character_image(
|
||
character_id: str,
|
||
prompt: str,
|
||
style: str = "anime",
|
||
model: str = "seedream-3.0",
|
||
count: int = 4,
|
||
width: int = 1024,
|
||
height: int = 1024,
|
||
) -> dict:
|
||
"""生成角色形象候选图。为指定角色生成多张外貌参考图供选择。
|
||
|
||
Args:
|
||
character_id: 角色ID
|
||
prompt: 角色外貌描述 prompt
|
||
style: 画风
|
||
model: 图片生成模型
|
||
count: 生成数量
|
||
width: 图片宽度
|
||
height: 图片高度
|
||
"""
|
||
return {"candidates": [], "task_ids": []}
|
||
|
||
|
||
```
|
||
|
||
## 第 10 页
|
||
|
||
```text
|
||
@tool
|
||
def generate_location_image(
|
||
location_id: str,
|
||
prompt: str,
|
||
style: str = "anime",
|
||
model: str = "seedream-3.0",
|
||
count: int = 4,
|
||
width: int = 1280,
|
||
height: int = 720,
|
||
) -> dict:
|
||
"""生成场景图候选。为指定场景生成多张场景图供选择。
|
||
|
||
Args:
|
||
location_id: 场景ID
|
||
prompt: 场景描述 prompt
|
||
style: 画风
|
||
model: 图片生成模型
|
||
count: 生成数量
|
||
width: 图片宽度
|
||
height: 图片高度
|
||
"""
|
||
return {"candidates": [], "task_ids": []}
|
||
|
||
|
||
@tool
|
||
def generate_panel_image(
|
||
panel_id: str,
|
||
prompt: str,
|
||
character_refs: list[str] | None = None,
|
||
location_ref: str | None = None,
|
||
style: str = "anime",
|
||
model: str = "seedream-3.0",
|
||
width: int = 1280,
|
||
height: int = 720,
|
||
) -> dict:
|
||
"""生成分镜画面候选图。根据角色和场景参考生成分镜画面。
|
||
|
||
Args:
|
||
panel_id: 分镜ID
|
||
prompt: 画面描述 prompt
|
||
character_refs: 参考角色形象 URL 列表
|
||
location_ref: 参考场景图 URL
|
||
style: 画风
|
||
model: 图片生成模型
|
||
width: 图片宽度
|
||
height: 图片高度
|
||
"""
|
||
return {"candidates": [], "task_ids": []}
|
||
|
||
|
||
@tool
|
||
def modify_image(prompt: str, image_url: str, mask_url: str | None = None) -> dict:
|
||
"""修改已有图片。支持局部编辑和整体调整。
|
||
|
||
Args:
|
||
prompt: 修改描述
|
||
image_url: 原图URL
|
||
mask_url: 蒙版URL(局部编辑时使用)
|
||
"""
|
||
return {"image_url": ""}
|
||
```
|
||
|
||
## 第 11 页
|
||
|
||
```text
|
||
|
||
// File: backend/app/agents/tools/candidate.py
|
||
"""Candidate card-draw tool — interrupt + confirm for AI-generated candidates."""
|
||
|
||
from pydantic import BaseModel
|
||
|
||
|
||
class CandidateState(BaseModel):
|
||
"""Universal candidate state (character appearance / location image / panel)."""
|
||
|
||
entity_type: str # "character_appearance" | "location_image" | "panel"
|
||
entity_id: str
|
||
original_url: str | None = None
|
||
candidates: list[str] = [] # URLs, may contain "PENDING:{task_id}" placeholders
|
||
selected_index: int = -1 # -1 = original, 0~N = candidate
|
||
previous_url: str | None = None
|
||
|
||
// File: backend/app/agents/tools/confirm.py
|
||
"""Agent confirmation tool — Human-in-the-Loop via LangGraph interrupt()."""
|
||
|
||
from langgraph.types import interrupt
|
||
from pydantic import BaseModel
|
||
|
||
|
||
class ConfirmationResult(BaseModel):
|
||
action: str # "confirm" | "modify" | "redo" | "skip"
|
||
feedback: str | None = None
|
||
|
||
|
||
def request_user_confirmation(
|
||
confirmation_type: str,
|
||
summary: str,
|
||
data: dict,
|
||
urgency: str = "normal",
|
||
) -> dict:
|
||
"""Agent-driven confirmation tool.
|
||
|
||
Unlike traditional workflow checkpoints, this is called by Producer
|
||
when it autonomously decides user input is needed — guided by
|
||
system prompt strategy, not hardcoded if-else.
|
||
|
||
LangGraph mechanism:
|
||
- interrupt() pauses current node
|
||
- Frontend receives awaiting_confirmation event
|
||
- User chooses confirm/modify/redo/skip
|
||
- Frontend calls POST /conversations/{id}/resume with Command(resume=...)
|
||
- Graph resumes from pause point
|
||
"""
|
||
result = interrupt({
|
||
"type": confirmation_type,
|
||
"summary": summary,
|
||
"data": data,
|
||
"urgency": urgency,
|
||
"options": ["confirm", "modify", "redo", "skip"],
|
||
})
|
||
return result
|
||
|
||
// File: backend/app/agents/tools/director_tools.py
|
||
"""Director Agent tools."""
|
||
|
||
```
|
||
|
||
## 第 12 页
|
||
|
||
```text
|
||
from langchain_core.tools import tool
|
||
|
||
|
||
@tool
|
||
def generate_storyboard(script_id: str, panel_count: int = 10) -> dict:
|
||
"""根据剧本生成分镜脚本,规划每个镜头的画面描述。
|
||
|
||
Args:
|
||
script_id: 剧本ID
|
||
panel_count: 分镜数量
|
||
"""
|
||
return {"storyboard_id": "placeholder", "panels": []}
|
||
|
||
|
||
@tool
|
||
def plan_shots(panel_id: str, description: str) -> dict:
|
||
"""为单个分镜规划镜头构图、运镜方式。
|
||
|
||
Args:
|
||
panel_id: 分镜ID
|
||
description: 画面描述
|
||
"""
|
||
return {"shot_plan": {}}
|
||
|
||
|
||
@tool
|
||
def review_continuity(
|
||
episode_id: str,
|
||
check_characters: bool = True,
|
||
check_locations: bool = True,
|
||
) -> dict:
|
||
"""审核视觉连续性,检查角色形象和场景的一致性。
|
||
|
||
Args:
|
||
episode_id: 集ID
|
||
check_characters: 是否检查角色一致性
|
||
check_locations: 是否检查场景一致性
|
||
"""
|
||
return {"issues": [], "approved": True}
|
||
|
||
|
||
@tool
|
||
def approve_visual(panel_id: str, image_url: str) -> dict:
|
||
"""审批视觉输出,确认画面质量。
|
||
|
||
Args:
|
||
panel_id: 分镜ID
|
||
image_url: 待审批的图片URL
|
||
"""
|
||
return {"approved": True}
|
||
|
||
// File: backend/app/agents/tools/editor_tools.py
|
||
"""Editor Agent tools."""
|
||
|
||
from langchain_core.tools import tool
|
||
|
||
|
||
@tool
|
||
def generate_video(
|
||
panel_id: str,
|
||
```
|
||
|
||
## 第 13 页
|
||
|
||
```text
|
||
image_url: str,
|
||
prompt: str,
|
||
camera_move: str | None = None,
|
||
duration: float = 5.0,
|
||
model: str = "seedance-1.5",
|
||
) -> dict:
|
||
"""从图片生成视频。使用首帧图片和运动描述生成视频片段。
|
||
|
||
Args:
|
||
panel_id: 分镜ID
|
||
image_url: 首帧图片 URL
|
||
prompt: 运动描述 prompt
|
||
camera_move: 运镜指令
|
||
duration: 时长(秒)
|
||
model: 视频生成模型
|
||
"""
|
||
return {"video_url": "", "task_id": ""}
|
||
|
||
|
||
@tool
|
||
def extend_video(video_url: str, duration: float = 5.0) -> dict:
|
||
"""延长视频时长。
|
||
|
||
Args:
|
||
video_url: 原视频URL
|
||
duration: 延长时长(秒)
|
||
"""
|
||
return {"video_url": ""}
|
||
|
||
|
||
@tool
|
||
def compose_timeline(
|
||
episode_id: str,
|
||
panel_ids: list[str],
|
||
transitions: list[str] | None = None,
|
||
) -> dict:
|
||
"""编排视频时间轴。将多个分镜视频按顺序组合,添加转场效果。
|
||
|
||
Args:
|
||
episode_id: 集ID
|
||
panel_ids: 按顺序排列的分镜 ID
|
||
transitions: 转场效果列表
|
||
"""
|
||
return {"timeline_url": ""}
|
||
|
||
// File: backend/app/agents/tools/global_asset_picker.py
|
||
"""Global asset picker tool."""
|
||
|
||
|
||
async def pick_global_asset(asset_type: str, user_id: str) -> dict | None:
|
||
"""Pick an asset from the global Asset Hub.
|
||
|
||
Triggered when user says "use my character from asset library".
|
||
Frontend renders a GlobalAssetPicker modal for user selection.
|
||
"""
|
||
# TODO: integrate with interrupt() for frontend modal
|
||
return None
|
||
|
||
// File: backend/app/agents/tools/producer_tools.py
|
||
"""Producer Agent tools."""
|
||
```
|
||
|
||
## 第 14 页
|
||
|
||
```text
|
||
|
||
from langchain_core.tools import tool
|
||
|
||
|
||
@tool
|
||
def plan_production(
|
||
title: str,
|
||
description: str,
|
||
style: str = "anime",
|
||
episode_count: int = 1,
|
||
panel_count_per_episode: int = 10,
|
||
) -> dict:
|
||
"""制定制作方案。根据用户需求分析项目规模,规划制作阶段和资源分配。
|
||
|
||
Args:
|
||
title: 项目标题
|
||
description: 用户原始需求描述
|
||
style: 画风偏好:写实/动漫/3D/水墨
|
||
episode_count: 集数
|
||
panel_count_per_episode: 每集分镜数
|
||
"""
|
||
total_panels = episode_count * panel_count_per_episode
|
||
phases = []
|
||
if total_panels > 0:
|
||
phases.append("文本分析与角色提取")
|
||
phases.append("场景设定与角色形象设计")
|
||
phases.append("剧本生成与分镜规划")
|
||
phases.append("画面生成")
|
||
if total_panels > 5:
|
||
phases.append("视频合成与音效制作")
|
||
|
||
return {
|
||
"title": title,
|
||
"description": description,
|
||
"style": style,
|
||
"episode_count": episode_count,
|
||
"panel_count_per_episode": panel_count_per_episode,
|
||
"total_panels": total_panels,
|
||
"phases": phases,
|
||
"estimated_cost": round(total_panels * 0.5, 2),
|
||
"estimated_duration": f"{max(1, total_panels // 5)} 分钟",
|
||
}
|
||
|
||
|
||
@tool
|
||
def estimate_cost(
|
||
image_count: int,
|
||
video_count: int,
|
||
voice_count: int,
|
||
image_model: str = "seedream-3.0",
|
||
video_model: str = "seedance-1.5",
|
||
voice_model: str = "qwen-tts",
|
||
) -> dict:
|
||
"""估算制作成本。根据各类素材数量和选用模型计算预估费用。
|
||
|
||
Args:
|
||
image_count: 图片数量
|
||
video_count: 视频数量
|
||
voice_count: 配音数量
|
||
image_model: 图片模型
|
||
```
|
||
|
||
## 第 15 页
|
||
|
||
```text
|
||
video_model: 视频模型
|
||
voice_model: 配音模型
|
||
"""
|
||
pricing = {
|
||
"seedream-3.0": 0.2,
|
||
"imagen-3": 0.4,
|
||
"flux-1.1": 0.3,
|
||
"seedance-1.5": 2.0,
|
||
"veo-2": 3.0,
|
||
"kling-1.5": 2.5,
|
||
"qwen-tts": 0.05,
|
||
"elevenlabs": 0.15,
|
||
}
|
||
image_cost = image_count * pricing.get(image_model, 0.3)
|
||
video_cost = video_count * pricing.get(video_model, 2.0)
|
||
voice_cost = voice_count * pricing.get(voice_model, 0.1)
|
||
return {
|
||
"total_cost": round(image_cost + video_cost + voice_cost, 2),
|
||
"breakdown": {
|
||
"image": {"count": image_count, "model": image_model, "cost": round(image_cost, 2)},
|
||
"video": {"count": video_count, "model": video_model, "cost": round(video_cost, 2)},
|
||
"voice": {"count": voice_count, "model": voice_model, "cost": round(voice_cost, 2)},
|
||
},
|
||
}
|
||
|
||
|
||
@tool
|
||
def query_project_status(project_id: str, include_details: bool = False) -> dict:
|
||
"""查询项目制作进度。返回当前已完成的步骤和可用资产统计。
|
||
|
||
Args:
|
||
project_id: 项目ID
|
||
include_details: 是否包含详细信息
|
||
"""
|
||
# TODO: implement with database query
|
||
return {"project_id": project_id, "status": "active", "completed_steps": [], "available_assets": {}}
|
||
|
||
// File: backend/app/agents/tools/reference_collector.py
|
||
"""Reference image auto-collector for panel generation."""
|
||
|
||
from pydantic import BaseModel
|
||
|
||
|
||
class CharacterRef(BaseModel):
|
||
character_id: str
|
||
image_url: str
|
||
description: str | None = None
|
||
|
||
|
||
class PanelReferences(BaseModel):
|
||
"""References collected for panel image generation."""
|
||
|
||
sketch: str | None = None
|
||
character_refs: list[CharacterRef] = []
|
||
location_ref: str | None = None
|
||
|
||
|
||
async def collect_panel_references(panel_id: str, project_id: str) -> PanelReferences:
|
||
"""Collect reference images for panel generation.
|
||
|
||
```
|
||
|
||
## 第 16 页
|
||
|
||
```text
|
||
Priority:
|
||
1. Sketch reference (user-uploaded hand-drawn sketch)
|
||
2. Character appearance reference (confirmed selectedIndex image)
|
||
3. Location reference (confirmed isSelected location image)
|
||
"""
|
||
# TODO: implement with database queries
|
||
return PanelReferences()
|
||
|
||
// File: backend/app/agents/tools/screenwriter_tools.py
|
||
"""Screenwriter Agent tools."""
|
||
|
||
from langchain_core.tools import tool
|
||
|
||
|
||
@tool
|
||
def analyze_text(text: str, analysis_type: str = "full") -> dict:
|
||
"""分析文本内容(小说/故事大纲/用户描述),提取关键信息。
|
||
|
||
Args:
|
||
text: 待分析的文本内容
|
||
analysis_type: 分析类型:full/characters/locations/plot
|
||
"""
|
||
return {"analysis": {}, "analysis_type": analysis_type, "text_length": len(text)}
|
||
|
||
|
||
@tool
|
||
def extract_characters(text: str, max_characters: int = 10) -> dict:
|
||
"""从文本中提取角色信息,包括名称、外貌、性格等。
|
||
|
||
Args:
|
||
text: 待分析文本
|
||
max_characters: 最大角色数
|
||
"""
|
||
return {"characters": []}
|
||
|
||
|
||
@tool
|
||
def extract_locations(text: str, max_locations: int = 10) -> dict:
|
||
"""从文本中提取场景/地点信息。
|
||
|
||
Args:
|
||
text: 待分析文本
|
||
max_locations: 最大场景数
|
||
"""
|
||
return {"locations": []}
|
||
|
||
|
||
@tool
|
||
def generate_script(
|
||
plot_summary: str,
|
||
characters: list[dict] | None = None,
|
||
locations: list[dict] | None = None,
|
||
episode_index: int = 0,
|
||
style: str = "drama",
|
||
panel_count: int = 10,
|
||
) -> dict:
|
||
"""生成剧本。根据角色、场景和剧情生成结构化剧本。
|
||
|
||
Args:
|
||
plot_summary: 剧情概要
|
||
```
|
||
|
||
## 第 17 页
|
||
|
||
```text
|
||
characters: 角色列表
|
||
locations: 场景列表
|
||
episode_index: 集数索引
|
||
style: 风格
|
||
panel_count: 分镜数量
|
||
"""
|
||
return {"script_id": "placeholder", "script": {}}
|
||
|
||
|
||
@tool
|
||
def edit_script(script_id: str, modifications: str) -> dict:
|
||
"""修改剧本。根据用户反馈调整剧本内容。
|
||
|
||
Args:
|
||
script_id: 剧本ID
|
||
modifications: 用户要求的修改内容
|
||
"""
|
||
return {"script_id": script_id, "updated": True}
|
||
|
||
// File: backend/app/agents/tools/sound_tools.py
|
||
"""Sound Agent tools."""
|
||
|
||
from langchain_core.tools import tool
|
||
|
||
|
||
@tool
|
||
def analyze_dialogue(script_id: str, episode_index: int = 0) -> dict:
|
||
"""分析剧本中的对白,提取需要配音的文本段落。
|
||
|
||
Args:
|
||
script_id: 剧本ID
|
||
episode_index: 集索引
|
||
"""
|
||
return {"dialogues": []}
|
||
|
||
|
||
@tool
|
||
def generate_voice(
|
||
panel_id: str,
|
||
text: str,
|
||
character_id: str,
|
||
voice_id: str | None = None,
|
||
emotion: str = "neutral",
|
||
model: str = "qwen-tts",
|
||
) -> dict:
|
||
"""生成角色配音。根据角色音色和情感生成对白语音。
|
||
|
||
Args:
|
||
panel_id: 分镜ID
|
||
text: 对白文本
|
||
character_id: 角色 ID,用于匹配音色
|
||
voice_id: 指定音色 ID
|
||
emotion: 情感
|
||
model: 配音模型
|
||
"""
|
||
return {"voice_url": "", "task_id": ""}
|
||
|
||
|
||
@tool
|
||
def design_sfx(panel_id: str, scene_description: str, mood: str = "neutral") -> dict:
|
||
```
|
||
|
||
## 第 18 页
|
||
|
||
```text
|
||
"""设计音效。根据场景描述和氛围生成环境音效。
|
||
|
||
Args:
|
||
panel_id: 分镜ID
|
||
scene_description: 场景描述
|
||
mood: 氛围
|
||
"""
|
||
return {"sfx_url": ""}
|
||
|
||
|
||
@tool
|
||
def match_bgm(episode_id: str, mood: str = "neutral", duration: float = 60.0) -> dict:
|
||
"""匹配背景音乐。根据集的整体氛围推荐/生成背景音乐。
|
||
|
||
Args:
|
||
episode_id: 集ID
|
||
mood: 氛围
|
||
duration: 时长(秒)
|
||
"""
|
||
return {"bgm_url": ""}
|
||
|
||
// File: backend/app/api/asset_hub.py
|
||
"""Global Asset Hub API routes."""
|
||
|
||
from fastapi import APIRouter
|
||
|
||
router = APIRouter()
|
||
|
||
# ── Folders ──
|
||
|
||
|
||
@router.get("/folders")
|
||
async def list_folders():
|
||
"""List asset folders."""
|
||
...
|
||
|
||
|
||
@router.post("/folders")
|
||
async def create_folder():
|
||
"""Create a folder."""
|
||
...
|
||
|
||
|
||
@router.patch("/folders/{folder_id}")
|
||
async def rename_folder(folder_id: str):
|
||
"""Rename a folder."""
|
||
...
|
||
|
||
|
||
@router.delete("/folders/{folder_id}")
|
||
async def delete_folder(folder_id: str):
|
||
"""Delete a folder."""
|
||
...
|
||
|
||
|
||
# ── Global Characters ──
|
||
|
||
|
||
@router.get("/characters")
|
||
async def list_global_characters():
|
||
```
|
||
|
||
## 第 19 页
|
||
|
||
```text
|
||
"""List global characters (paginated, searchable)."""
|
||
...
|
||
|
||
|
||
@router.post("/characters")
|
||
async def create_global_character():
|
||
"""Create a global character."""
|
||
...
|
||
|
||
|
||
@router.patch("/characters/{character_id}")
|
||
async def update_global_character(character_id: str):
|
||
"""Update a global character."""
|
||
...
|
||
|
||
|
||
@router.delete("/characters/{character_id}")
|
||
async def delete_global_character(character_id: str):
|
||
"""Delete a global character."""
|
||
...
|
||
|
||
|
||
@router.post("/characters/{character_id}/generate")
|
||
async def generate_character_image(character_id: str):
|
||
"""Generate character appearance candidates."""
|
||
...
|
||
|
||
|
||
@router.post("/characters/{character_id}/select")
|
||
async def select_character_image(character_id: str):
|
||
"""Select character appearance (card-draw confirm)."""
|
||
...
|
||
|
||
|
||
@router.post("/characters/{character_id}/undo")
|
||
async def undo_character_image(character_id: str):
|
||
"""Undo character appearance selection."""
|
||
...
|
||
|
||
|
||
# ── Global Locations ──
|
||
|
||
|
||
@router.get("/locations")
|
||
async def list_global_locations():
|
||
"""List global locations."""
|
||
...
|
||
|
||
|
||
@router.post("/locations")
|
||
async def create_global_location():
|
||
"""Create a global location."""
|
||
...
|
||
|
||
|
||
@router.post("/locations/{location_id}/generate")
|
||
async def generate_location_image(location_id: str):
|
||
"""Generate location image candidates."""
|
||
...
|
||
|
||
```
|
||
|
||
## 第 20 页
|
||
|
||
```text
|
||
|
||
@router.post("/locations/{location_id}/select")
|
||
async def select_location_image(location_id: str):
|
||
"""Select location image."""
|
||
...
|
||
|
||
|
||
# ── Global Voices ──
|
||
|
||
|
||
@router.get("/voices")
|
||
async def list_global_voices():
|
||
"""List global voices."""
|
||
...
|
||
|
||
|
||
@router.post("/voices")
|
||
async def create_global_voice():
|
||
"""Create/clone a voice."""
|
||
...
|
||
|
||
|
||
@router.delete("/voices/{voice_id}")
|
||
async def delete_global_voice(voice_id: str):
|
||
"""Delete a voice."""
|
||
...
|
||
|
||
// File: backend/app/api/assets.py
|
||
"""Project assets API routes."""
|
||
|
||
from fastapi import APIRouter
|
||
|
||
router = APIRouter()
|
||
|
||
|
||
@router.get("/projects/{project_id}/characters")
|
||
async def list_characters(project_id: str):
|
||
"""List characters for a project."""
|
||
...
|
||
|
||
|
||
@router.get("/projects/{project_id}/characters/{character_id}")
|
||
async def get_character(project_id: str, character_id: str):
|
||
"""Get character details with appearances."""
|
||
...
|
||
|
||
|
||
@router.patch("/projects/{project_id}/characters/{character_id}")
|
||
async def update_character(project_id: str, character_id: str):
|
||
"""Update character (manual edit)."""
|
||
...
|
||
|
||
|
||
@router.post("/projects/{project_id}/characters/{character_id}/select-appearance")
|
||
async def select_character_appearance(project_id: str, character_id: str):
|
||
"""Select a character appearance."""
|
||
...
|
||
|
||
|
||
@router.get("/projects/{project_id}/locations")
|
||
```
|
||
|
||
## 第 21 页
|
||
|
||
```text
|
||
async def list_locations(project_id: str):
|
||
"""List locations for a project."""
|
||
...
|
||
|
||
|
||
@router.get("/projects/{project_id}/episodes")
|
||
async def list_episodes(project_id: str):
|
||
"""List episodes for a project."""
|
||
...
|
||
|
||
|
||
@router.get("/projects/{project_id}/episodes/{episode_id}/panels")
|
||
async def list_panels(project_id: str, episode_id: str):
|
||
"""List panels for an episode with media URLs."""
|
||
...
|
||
|
||
|
||
@router.get("/projects/{project_id}/tasks")
|
||
async def list_tasks(project_id: str):
|
||
"""List async tasks (paginated, filterable by status)."""
|
||
...
|
||
|
||
|
||
@router.post("/projects/{project_id}/copy-from-global")
|
||
async def copy_from_global(project_id: str):
|
||
"""Deep-copy asset from global Asset Hub to project."""
|
||
...
|
||
|
||
// File: backend/app/api/auth.py
|
||
"""Authentication API routes."""
|
||
|
||
from fastapi import APIRouter, HTTPException
|
||
|
||
from app.deps import DbSession, CurrentUser
|
||
from app.schemas.auth import RegisterRequest, LoginRequest, TokenResponse, UserResponse
|
||
from app.services.auth_service import (
|
||
create_access_token,
|
||
create_user,
|
||
get_user_by_email,
|
||
verify_password,
|
||
)
|
||
|
||
router = APIRouter()
|
||
|
||
|
||
@router.post("/register", response_model=TokenResponse)
|
||
async def register(body: RegisterRequest, db: DbSession):
|
||
"""Register a new user."""
|
||
existing = await get_user_by_email(db, body.email)
|
||
if existing:
|
||
raise HTTPException(status_code=400, detail="Email already registered")
|
||
|
||
user = await create_user(db, body.email, body.password, body.name)
|
||
token = create_access_token(user.id)
|
||
return TokenResponse(access_token=token)
|
||
|
||
|
||
@router.post("/login", response_model=TokenResponse)
|
||
async def login(body: LoginRequest, db: DbSession):
|
||
"""Login and return JWT token."""
|
||
```
|
||
|
||
## 第 22 页
|
||
|
||
```text
|
||
user = await get_user_by_email(db, body.email)
|
||
if not user or not verify_password(body.password, user.password_hash):
|
||
raise HTTPException(status_code=401, detail="Invalid email or password")
|
||
|
||
token = create_access_token(user.id)
|
||
return TokenResponse(access_token=token)
|
||
|
||
|
||
@router.get("/me", response_model=UserResponse)
|
||
async def get_me(user: CurrentUser):
|
||
"""Get current user info."""
|
||
return user
|
||
|
||
// File: backend/app/api/billing.py
|
||
"""Billing API routes."""
|
||
|
||
from fastapi import APIRouter
|
||
|
||
router = APIRouter()
|
||
|
||
|
||
@router.get("/balance")
|
||
async def get_balance():
|
||
"""Get user balance."""
|
||
...
|
||
|
||
|
||
@router.get("/transactions")
|
||
async def list_transactions():
|
||
"""List transactions (paginated)."""
|
||
...
|
||
|
||
|
||
@router.post("/topup")
|
||
async def topup():
|
||
"""Top up balance."""
|
||
...
|
||
|
||
// File: backend/app/api/candidates.py
|
||
"""Candidate card-draw API routes."""
|
||
|
||
from fastapi import APIRouter
|
||
from pydantic import BaseModel
|
||
|
||
router = APIRouter()
|
||
|
||
|
||
class ConfirmCandidateRequest(BaseModel):
|
||
entity_type: str # "character_appearance" | "location_image" | "panel"
|
||
entity_id: str
|
||
selected_index: int
|
||
|
||
|
||
class CancelCandidateRequest(BaseModel):
|
||
entity_type: str
|
||
entity_id: str
|
||
|
||
|
||
class UndoCandidateRequest(BaseModel):
|
||
entity_type: str
|
||
```
|
||
|
||
## 第 23 页
|
||
|
||
```text
|
||
entity_id: str
|
||
|
||
|
||
@router.post("/confirm")
|
||
async def confirm_candidate(body: ConfirmCandidateRequest):
|
||
"""Confirm candidate selection — persist to entity."""
|
||
...
|
||
|
||
|
||
@router.post("/cancel")
|
||
async def cancel_candidate(body: CancelCandidateRequest):
|
||
"""Cancel card-draw, clear candidates."""
|
||
...
|
||
|
||
|
||
@router.post("/undo")
|
||
async def undo_candidate(body: UndoCandidateRequest):
|
||
"""Undo to previous version."""
|
||
...
|
||
|
||
|
||
@router.get("/panels/{panel_id}/history")
|
||
async def get_panel_history(panel_id: str):
|
||
"""Get panel image version history."""
|
||
...
|
||
|
||
|
||
@router.post("/panels/{panel_id}/restore")
|
||
async def restore_panel_version(panel_id: str):
|
||
"""Restore panel to a specific history version."""
|
||
...
|
||
|
||
// File: backend/app/api/conversations.py
|
||
"""Conversation API routes — Core Agent interaction entry point."""
|
||
|
||
import json
|
||
import uuid
|
||
|
||
from fastapi import APIRouter, HTTPException
|
||
from fastapi.responses import StreamingResponse
|
||
from langchain_core.messages import HumanMessage
|
||
|
||
from app.deps import DbSession, CurrentUser, AgentGraph
|
||
from app.db.session import async_session_factory
|
||
from app.schemas.conversation import (
|
||
SendMessageRequest,
|
||
ResumeRequest,
|
||
ConversationResponse,
|
||
ConversationDetailResponse,
|
||
MessageResponse,
|
||
)
|
||
from app.services import conversation_service, project_service
|
||
from app.agents.sse_transformer import stream_graph_to_sse
|
||
|
||
router = APIRouter()
|
||
|
||
|
||
@router.post("/projects/{project_id}/conversations", response_model=ConversationResponse)
|
||
async def create_conversation(project_id: uuid.UUID, user: CurrentUser, db: DbSession):
|
||
"""Create a new conversation for a project."""
|
||
```
|
||
|
||
## 第 24 页
|
||
|
||
```text
|
||
project = await project_service.get_project(db, project_id)
|
||
if not project or project.user_id != user.id:
|
||
raise HTTPException(status_code=404, detail="Project not found")
|
||
|
||
conv = await conversation_service.create_conversation(db, project_id, user.id)
|
||
return conv
|
||
|
||
|
||
@router.get("/projects/{project_id}/conversations", response_model=list[ConversationResponse])
|
||
async def list_conversations(project_id: uuid.UUID, user: CurrentUser, db: DbSession):
|
||
"""List conversations for a project."""
|
||
project = await project_service.get_project(db, project_id)
|
||
if not project or project.user_id != user.id:
|
||
raise HTTPException(status_code=404, detail="Project not found")
|
||
|
||
convs = await conversation_service.list_conversations(db, project_id)
|
||
return convs
|
||
|
||
|
||
@router.get("/conversations/{conversation_id}", response_model=ConversationDetailResponse)
|
||
async def get_conversation(conversation_id: uuid.UUID, user: CurrentUser, db: DbSession):
|
||
"""Get conversation details with message history."""
|
||
conv = await conversation_service.get_conversation(db, conversation_id)
|
||
if not conv or conv.user_id != user.id:
|
||
raise HTTPException(status_code=404, detail="Conversation not found")
|
||
|
||
messages = await conversation_service.get_messages(db, conversation_id)
|
||
return ConversationDetailResponse(
|
||
id=conv.id,
|
||
project_id=conv.project_id,
|
||
title=conv.title,
|
||
status=conv.status,
|
||
created_at=conv.created_at,
|
||
messages=[MessageResponse.model_validate(m) for m in messages],
|
||
)
|
||
|
||
|
||
@router.post("/conversations/{conversation_id}/messages")
|
||
async def send_message(
|
||
conversation_id: uuid.UUID,
|
||
body: SendMessageRequest,
|
||
user: CurrentUser,
|
||
db: DbSession,
|
||
graph: AgentGraph,
|
||
):
|
||
"""Send a message and return SSE stream."""
|
||
# Verify conversation exists and belongs to user
|
||
conv = await conversation_service.get_conversation(db, conversation_id)
|
||
if not conv or conv.user_id != user.id:
|
||
raise HTTPException(status_code=404, detail="Conversation not found")
|
||
|
||
# Save user message to DB
|
||
await conversation_service.save_message(db, conversation_id, "user", body.content)
|
||
|
||
# Build LangGraph input
|
||
input_state = {"messages": [HumanMessage(content=body.content)]}
|
||
config = {"configurable": {"thread_id": str(conversation_id)}}
|
||
|
||
async def event_stream():
|
||
full_response = ""
|
||
```
|
||
|
||
## 第 25 页
|
||
|
||
```text
|
||
try:
|
||
async for event in stream_graph_to_sse(graph, input_state, config):
|
||
yield event
|
||
# Capture full response from done event
|
||
if event.startswith("event: done"):
|
||
try:
|
||
data_line = event.split("data: ", 1)[1].strip()
|
||
done_data = json.loads(data_line)
|
||
full_response = done_data.get("full_response", "")
|
||
except (IndexError, json.JSONDecodeError):
|
||
pass
|
||
finally:
|
||
# Save assistant message in a new session (stream outlives request session)
|
||
if full_response:
|
||
async with async_session_factory() as save_db:
|
||
try:
|
||
await conversation_service.save_message(
|
||
save_db, conversation_id, "assistant", full_response, agent_name="producer"
|
||
)
|
||
await save_db.commit()
|
||
except Exception:
|
||
await save_db.rollback()
|
||
|
||
return StreamingResponse(
|
||
event_stream(),
|
||
media_type="text/event-stream",
|
||
headers={
|
||
"Cache-Control": "no-cache",
|
||
"Connection": "keep-alive",
|
||
"X-Accel-Buffering": "no",
|
||
},
|
||
)
|
||
|
||
|
||
@router.post("/conversations/{conversation_id}/resume")
|
||
async def resume_conversation(conversation_id: uuid.UUID, body: ResumeRequest):
|
||
"""Resume conversation after user confirmation/modification."""
|
||
|
||
async def event_stream():
|
||
yield f"event: done\ndata: {json.dumps({'reason': 'complete'})}\n\n"
|
||
|
||
return StreamingResponse(event_stream(), media_type="text/event-stream")
|
||
|
||
|
||
@router.post("/conversations/{conversation_id}/steer")
|
||
async def steer_conversation(conversation_id: uuid.UUID):
|
||
"""Inject system instruction without triggering full Agent cycle."""
|
||
...
|
||
|
||
|
||
@router.post("/conversations/{conversation_id}/abort")
|
||
async def abort_conversation(conversation_id: uuid.UUID):
|
||
"""Abort current Agent execution."""
|
||
...
|
||
|
||
|
||
@router.post("/conversations/{conversation_id}/fork")
|
||
async def fork_conversation(conversation_id: uuid.UUID):
|
||
"""Fork conversation from current checkpoint."""
|
||
...
|
||
```
|
||
|
||
## 第 26 页
|
||
|
||
```text
|
||
|
||
|
||
@router.post("/conversations/{conversation_id}/compact")
|
||
async def compact_conversation(conversation_id: uuid.UUID):
|
||
"""Compress conversation history."""
|
||
...
|
||
|
||
// File: backend/app/api/projects.py
|
||
"""Project API routes."""
|
||
|
||
import uuid
|
||
|
||
from fastapi import APIRouter, HTTPException
|
||
|
||
from app.deps import DbSession, CurrentUser
|
||
from app.schemas.project import CreateProjectRequest, UpdateProjectRequest, ProjectResponse
|
||
from app.services import project_service
|
||
|
||
router = APIRouter()
|
||
|
||
|
||
@router.post("", response_model=ProjectResponse)
|
||
async def create_project(body: CreateProjectRequest, user: CurrentUser, db: DbSession):
|
||
"""Create a new project."""
|
||
project = await project_service.create_project(
|
||
db, user.id, body.title, body.description, body.style
|
||
)
|
||
return project
|
||
|
||
|
||
@router.get("", response_model=list[ProjectResponse])
|
||
async def list_projects(user: CurrentUser, db: DbSession):
|
||
"""List projects (paginated)."""
|
||
projects = await project_service.list_projects(db, user.id)
|
||
return projects
|
||
|
||
|
||
@router.get("/{project_id}", response_model=ProjectResponse)
|
||
async def get_project(project_id: uuid.UUID, user: CurrentUser, db: DbSession):
|
||
"""Get project details."""
|
||
project = await project_service.get_project(db, project_id)
|
||
if not project or project.user_id != user.id:
|
||
raise HTTPException(status_code=404, detail="Project not found")
|
||
return project
|
||
|
||
|
||
@router.patch("/{project_id}", response_model=ProjectResponse)
|
||
async def update_project(
|
||
project_id: uuid.UUID,
|
||
body: UpdateProjectRequest,
|
||
user: CurrentUser,
|
||
db: DbSession,
|
||
):
|
||
"""Update project."""
|
||
project = await project_service.get_project(db, project_id)
|
||
if not project or project.user_id != user.id:
|
||
raise HTTPException(status_code=404, detail="Project not found")
|
||
|
||
updated = await project_service.update_project(
|
||
db, project, **body.model_dump(exclude_unset=True)
|
||
```
|
||
|
||
## 第 27 页
|
||
|
||
```text
|
||
)
|
||
return updated
|
||
|
||
|
||
@router.delete("/{project_id}")
|
||
async def delete_project(project_id: uuid.UUID, user: CurrentUser, db: DbSession):
|
||
"""Soft-delete project."""
|
||
project = await project_service.get_project(db, project_id)
|
||
if not project or project.user_id != user.id:
|
||
raise HTTPException(status_code=404, detail="Project not found")
|
||
|
||
await project_service.soft_delete_project(db, project)
|
||
return {"ok": True}
|
||
|
||
// File: backend/app/config.py
|
||
"""Application configuration via environment variables."""
|
||
|
||
from pydantic_settings import BaseSettings
|
||
|
||
|
||
class Settings(BaseSettings):
|
||
"""Application settings loaded from environment variables."""
|
||
|
||
# ── App ──
|
||
app_name: str = "StudioAgent"
|
||
debug: bool = False
|
||
api_prefix: str = "/api/v1"
|
||
|
||
# ── Database ──
|
||
database_url: str = "postgresql+asyncpg://postgres:postgres@localhost:5432/studioagent"
|
||
database_url_sync: str = "postgresql://postgres:postgres@localhost:5432/studioagent"
|
||
|
||
# ── Redis ──
|
||
redis_url: str = "redis://localhost:6379/0"
|
||
|
||
# ── Auth ──
|
||
jwt_secret: str = "change-me-in-production"
|
||
jwt_algorithm: str = "HS256"
|
||
jwt_expire_minutes: int = 60 * 24 * 7 # 7 days
|
||
|
||
# ── LLM Providers ──
|
||
openrouter_api_key: str = ""
|
||
openrouter_base_url: str = "https://openrouter.ai/api/v1"
|
||
google_api_key: str = ""
|
||
google_base_url: str = "" # empty = SDK default
|
||
volcengine_api_key: str = ""
|
||
volcengine_base_url: str = "https://ark.cn-beijing.volces.com/api/v3"
|
||
volcengine_endpoint_id: str = ""
|
||
|
||
# ── Agent Models (format: "provider/model_id") ──
|
||
producer_model: str = "openrouter/anthropic/claude-opus-4"
|
||
screenwriter_model: str = "google/gemini-2.5-flash-preview-05-20"
|
||
director_model: str = "google/gemini-2.5-flash-preview-05-20"
|
||
camera_model: str = "google/gemini-2.5-flash-preview-05-20"
|
||
editor_model: str = "google/gemini-2.5-flash-preview-05-20"
|
||
sound_model: str = "google/gemini-2.5-flash-preview-05-20"
|
||
|
||
# ── Image Generation ──
|
||
fal_api_key: str = ""
|
||
volcengine_image_api_key: str = ""
|
||
```
|
||
|
||
## 第 28 页
|
||
|
||
```text
|
||
|
||
# ── Video Generation ──
|
||
volcengine_video_api_key: str = ""
|
||
|
||
# ── Voice Generation ──
|
||
dashscope_api_key: str = "" # Alibaba Qwen TTS
|
||
elevenlabs_api_key: str = ""
|
||
|
||
# ── Storage ──
|
||
s3_bucket: str = ""
|
||
s3_region: str = ""
|
||
s3_access_key: str = ""
|
||
s3_secret_key: str = ""
|
||
s3_endpoint_url: str = ""
|
||
|
||
# ── Agent ──
|
||
max_handoff_count: int = 10
|
||
default_interaction_mode: str = "collaborative"
|
||
|
||
# ── Celery ──
|
||
celery_broker_url: str = "redis://localhost:6379/1"
|
||
celery_result_backend: str = "redis://localhost:6379/2"
|
||
|
||
model_config = {"env_file": ".env", "env_file_encoding": "utf-8", "extra": "allow"}
|
||
|
||
|
||
settings = Settings()
|
||
|
||
// File: backend/app/db/migrations/env.py
|
||
"""Alembic async migration environment."""
|
||
|
||
import asyncio
|
||
import sys
|
||
from logging.config import fileConfig
|
||
from pathlib import Path
|
||
|
||
from alembic import context
|
||
from sqlalchemy import pool
|
||
from sqlalchemy.ext.asyncio import create_async_engine
|
||
|
||
# Ensure backend/ is on sys.path so `app` package is importable
|
||
sys.path.insert(0, str(Path(__file__).resolve().parents[3]))
|
||
|
||
from app.config import settings # noqa: E402
|
||
|
||
# Import Base and all models so Alembic can see them for autogenerate
|
||
from app.models import Base # noqa: E402, F401
|
||
import app.models # noqa: E402, F401
|
||
|
||
config = context.config
|
||
|
||
if config.config_file_name is not None:
|
||
fileConfig(config.config_file_name)
|
||
|
||
target_metadata = Base.metadata
|
||
|
||
|
||
def run_migrations_offline() -> None:
|
||
"""Run migrations in 'offline' mode."""
|
||
url = settings.database_url_sync
|
||
```
|
||
|
||
## 第 29 页
|
||
|
||
```text
|
||
context.configure(
|
||
url=url,
|
||
target_metadata=target_metadata,
|
||
literal_binds=True,
|
||
dialect_opts={"paramstyle": "named"},
|
||
)
|
||
with context.begin_transaction():
|
||
context.run_migrations()
|
||
|
||
|
||
def do_run_migrations(connection) -> None:
|
||
context.configure(connection=connection, target_metadata=target_metadata)
|
||
with context.begin_transaction():
|
||
context.run_migrations()
|
||
|
||
|
||
async def run_async_migrations() -> None:
|
||
"""Run migrations in 'online' mode with async engine."""
|
||
connectable = create_async_engine(
|
||
settings.database_url,
|
||
poolclass=pool.NullPool,
|
||
)
|
||
async with connectable.connect() as connection:
|
||
await connection.run_sync(do_run_migrations)
|
||
await connectable.dispose()
|
||
|
||
|
||
def run_migrations_online() -> None:
|
||
"""Run migrations in 'online' mode."""
|
||
asyncio.run(run_async_migrations())
|
||
|
||
|
||
if context.is_offline_mode():
|
||
run_migrations_offline()
|
||
else:
|
||
run_migrations_online()
|
||
|
||
// File: backend/app/db/migrations/versions/001_initial_schema.py
|
||
"""initial schema
|
||
|
||
Revision ID: 001_initial
|
||
Revises:
|
||
Create Date: 2026-03-05
|
||
|
||
"""
|
||
from typing import Sequence, Union
|
||
|
||
from alembic import op
|
||
import sqlalchemy as sa
|
||
from sqlalchemy.dialects import postgresql
|
||
|
||
# revision identifiers, used by Alembic.
|
||
revision: str = "001_initial"
|
||
down_revision: Union[str, None] = None
|
||
branch_labels: Union[str, Sequence[str], None] = None
|
||
depends_on: Union[str, Sequence[str], None] = None
|
||
|
||
|
||
def upgrade() -> None:
|
||
# ── Users ──
|
||
```
|
||
|
||
## 第 30 页
|
||
|
||
```text
|
||
op.create_table(
|
||
"users",
|
||
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||
sa.Column("email", sa.String(), nullable=False, unique=True),
|
||
sa.Column("password_hash", sa.String(), nullable=False),
|
||
sa.Column("name", sa.String(), nullable=True),
|
||
sa.Column("avatar_url", sa.Text(), nullable=True),
|
||
sa.Column("balance", sa.Numeric(12, 2), server_default="0"),
|
||
sa.Column("preferences", postgresql.JSONB(), server_default="{}"),
|
||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||
)
|
||
|
||
# ── Projects ──
|
||
op.create_table(
|
||
"projects",
|
||
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||
sa.Column("user_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
|
||
sa.Column("title", sa.String(), nullable=False),
|
||
sa.Column("description", sa.Text(), nullable=True),
|
||
sa.Column("status", sa.String(), server_default="active"),
|
||
sa.Column("style", sa.String(), server_default="anime"),
|
||
sa.Column("metadata", postgresql.JSONB(), server_default="{}"),
|
||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||
)
|
||
|
||
# ── Conversations ──
|
||
op.create_table(
|
||
"conversations",
|
||
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||
sa.Column("project_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("projects.id", ondelete="CASCADE"), nullable=False),
|
||
sa.Column("user_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("users.id"), nullable=False),
|
||
sa.Column("title", sa.String(), nullable=True),
|
||
sa.Column("status", sa.String(), server_default="idle"),
|
||
sa.Column("parent_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("conversations.id"), nullable=True),
|
||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||
)
|
||
|
||
# ── Messages ──
|
||
op.create_table(
|
||
"messages",
|
||
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||
sa.Column("conversation_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("conversations.id", ondelete="CASCADE"), nullable=False),
|
||
sa.Column("role", sa.String(), nullable=False),
|
||
sa.Column("agent_name", sa.String(), nullable=True),
|
||
sa.Column("content", sa.Text(), nullable=True),
|
||
sa.Column("tool_calls", postgresql.JSONB(), nullable=True),
|
||
sa.Column("tool_call_id", sa.String(), nullable=True),
|
||
sa.Column("reasoning", sa.Text(), nullable=True),
|
||
sa.Column("metadata", postgresql.JSONB(), server_default="{}"),
|
||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||
)
|
||
|
||
# ── Characters ──
|
||
op.create_table(
|
||
"characters",
|
||
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||
sa.Column("project_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("projects.id", ondelete="CASCADE"), nullable=False),
|
||
```
|