325 lines
12 KiB
TypeScript
325 lines
12 KiB
TypeScript
"use client";
|
||
|
||
import { useChat } from "@ai-sdk/react";
|
||
import { DefaultChatTransport, type UIMessage } from "ai";
|
||
import { ArrowUp, MessageSquare, X } from "lucide-react";
|
||
import { useCallback, useEffect, useRef, useState } from "react";
|
||
import { toast } from "sonner";
|
||
import { CanvasAgentRunPanel } from "./canvas-agent-run-panel";
|
||
import {
|
||
videoAgentSettingsSchema,
|
||
type VideoAgentSettings,
|
||
} from "@/lib/ai/canvas-agent-runtime/video-protocol";
|
||
|
||
type ModelOption = { id: string; displayName: string };
|
||
export function VideoCanvasAgentPanel(props: {
|
||
projectId: string;
|
||
selectedNodeIds: string[];
|
||
videoModels: ModelOption[];
|
||
imageModels: ModelOption[];
|
||
beforeSend: () => Promise<void>;
|
||
onResults: (projectId: string, nodeIds: string[]) => void;
|
||
onClose: () => void;
|
||
}) {
|
||
const [input, setInput] = useState("");
|
||
const [settings, setSettings] = useState<VideoAgentSettings>(() =>
|
||
videoAgentSettingsSchema.parse({}),
|
||
);
|
||
const [preparing, setPreparing] = useState(false);
|
||
const [loading, setLoading] = useState(true);
|
||
const [backgroundRunning, setBackgroundRunning] = useState(false);
|
||
const latest = useRef({ props, settings });
|
||
latest.current = { props, settings };
|
||
const resumeRef = useRef<string | undefined>(undefined);
|
||
const mounted = useRef(true);
|
||
const resultIds = useRef<string[]>([]);
|
||
const restoredRunId = useRef("");
|
||
const { messages, setMessages, sendMessage, status } = useChat({
|
||
id: `video-agent-${props.projectId}`,
|
||
generateId: () => crypto.randomUUID(),
|
||
transport: new DefaultChatTransport({
|
||
api: "/api/canvas/agent-chat",
|
||
prepareSendMessagesRequest: ({ messages }) => {
|
||
const current = latest.current;
|
||
const resumeRunId = resumeRef.current;
|
||
resumeRef.current = undefined;
|
||
return {
|
||
body: {
|
||
id: current.props.projectId,
|
||
message: messages[messages.length - 1],
|
||
selectedChatModel: "agent",
|
||
selectedVisibilityType: "private",
|
||
composerMode: "agent",
|
||
workspaceVariant: "video",
|
||
videoAgentSettings: current.settings,
|
||
resumeRunId,
|
||
contextPayload: {
|
||
source: "canvas",
|
||
projectId: current.props.projectId,
|
||
selectedNodeIds: current.props.selectedNodeIds,
|
||
},
|
||
},
|
||
};
|
||
},
|
||
}),
|
||
onData: (part) => {
|
||
if (part.type === "data-videoCanvasMutation") window.dispatchEvent(new CustomEvent("video-canvas-agent-mutation", { detail: part.data }));
|
||
if (part.type === "data-canvasAgentUiCommand") window.dispatchEvent(new CustomEvent("canvas-agent-ui-command", { detail: part.data }));
|
||
if (part.type === "data-canvasAgentRun")
|
||
window.dispatchEvent(new CustomEvent("canvas-agent-run:update", { detail: part.data }));
|
||
},
|
||
onError: (error) => toast.error(error.message || "视频任务启动失败,请重试"),
|
||
});
|
||
useEffect(() => {
|
||
mounted.current = true;
|
||
const controller = new AbortController();
|
||
void fetch(`/api/canvas/video-agent?projectId=${props.projectId}`, {
|
||
cache: "no-store",
|
||
signal: controller.signal,
|
||
})
|
||
.then(async (response) => {
|
||
const data = await response.json();
|
||
if (!response.ok) throw new Error(data.error || "读取视频对话失败");
|
||
if (!controller.signal.aborted) setMessages(data.messages as UIMessage[]);
|
||
})
|
||
.catch((error) => {
|
||
if (!controller.signal.aborted) toast.error(error.message);
|
||
})
|
||
.finally(() => {
|
||
if (!controller.signal.aborted) setLoading(false);
|
||
});
|
||
return () => {
|
||
mounted.current = false;
|
||
controller.abort();
|
||
};
|
||
}, [props.projectId, setMessages]);
|
||
const busy = backgroundRunning || preparing || status === "submitted" || status === "streaming";
|
||
const submit = useCallback(
|
||
async (resumeRunId?: string) => {
|
||
if (busy || loading) return;
|
||
const text =
|
||
input.trim() ||
|
||
(resumeRunId ? "请继续尚未完成的视频创作步骤,保留已完成的脚本、关键帧和视频。" : "");
|
||
if (!text) return;
|
||
setPreparing(true);
|
||
try {
|
||
await latest.current.props.beforeSend();
|
||
if (!mounted.current) return;
|
||
resumeRef.current = resumeRunId;
|
||
setInput("");
|
||
await sendMessage({ text });
|
||
} catch (error) {
|
||
toast.error(error instanceof Error ? error.message : "视频任务启动失败");
|
||
} finally {
|
||
resumeRef.current = undefined;
|
||
if (mounted.current) setPreparing(false);
|
||
}
|
||
},
|
||
[busy, loading, input, sendMessage],
|
||
);
|
||
const onRunChange = useCallback(
|
||
(run: import("@/lib/ai/canvas-agent-runtime/protocol").AgentRunView | null) => {
|
||
resultIds.current = run?.resultNodeIds || [];
|
||
if (run?.videoSettings && restoredRunId.current !== run.id) {
|
||
restoredRunId.current = run.id;
|
||
setSettings(run.videoSettings);
|
||
}
|
||
setBackgroundRunning(run?.status === "running");
|
||
},
|
||
[],
|
||
);
|
||
const onResults = useCallback(
|
||
(projectId: string, ids: string[]) => latest.current.props.onResults(projectId, ids),
|
||
[],
|
||
);
|
||
useEffect(() => {
|
||
const timer = setInterval(() => {
|
||
if (resultIds.current.length)
|
||
latest.current.props.onResults(latest.current.props.projectId, resultIds.current);
|
||
}, 10_000);
|
||
return () => clearInterval(timer);
|
||
}, []);
|
||
return (
|
||
<aside
|
||
className="dark absolute bottom-3 right-3 top-[72px] z-40 flex w-[390px] max-w-[calc(100vw-24px)] flex-col overflow-hidden rounded-2xl border border-white/10 bg-[#202020] text-zinc-100 shadow-2xl"
|
||
data-canvas-overlay="true"
|
||
data-testid="video-agent-panel"
|
||
aria-label="视频创作 Agent"
|
||
>
|
||
<div className="flex items-center gap-2 border-b border-white/10 px-4 py-3">
|
||
<MessageSquare className="h-4 w-4" />
|
||
<span className="flex-1 font-medium">视频创作 Agent</span>
|
||
<button
|
||
type="button"
|
||
onClick={props.onClose}
|
||
aria-label="收起视频 Agent"
|
||
className="rounded-lg p-1 hover:bg-white/10"
|
||
>
|
||
<X className="h-4 w-4" />
|
||
</button>
|
||
</div>
|
||
<div className="flex-1 space-y-4 overflow-y-auto px-4 py-4" aria-live="polite">
|
||
{loading ? (
|
||
<p className="text-sm text-zinc-400">正在读取对话…</p>
|
||
) : messages.length === 0 ? (
|
||
<p className="text-sm leading-7 text-zinc-400">
|
||
选择画布素材,告诉我你想制作的视频。我可以分析参考、编写分镜、生成关键帧并继续生成视频。
|
||
</p>
|
||
) : null}
|
||
{messages.map((message) => (
|
||
<div
|
||
key={message.id}
|
||
className={
|
||
message.role === "user"
|
||
? "ml-5 whitespace-pre-wrap rounded-2xl bg-white/10 px-3 py-2 text-sm leading-6"
|
||
: "whitespace-pre-wrap text-sm leading-6 text-zinc-300"
|
||
}
|
||
>
|
||
{message.parts
|
||
.filter((part) => part.type === "text")
|
||
.map((part, i) => (
|
||
<span key={`${message.id}-${i}`}>{part.type === "text" ? part.text : ""}</span>
|
||
))}
|
||
</div>
|
||
))}
|
||
</div>
|
||
<div className="border-t border-white/10 p-3">
|
||
<CanvasAgentRunPanel
|
||
enabled={!loading}
|
||
chatId={props.projectId}
|
||
projectId={props.projectId}
|
||
busy={busy}
|
||
onResume={(id) => void submit(id)}
|
||
onResults={onResults}
|
||
onRunChange={onRunChange}
|
||
/>
|
||
<div className="mb-2 grid grid-cols-2 gap-2 text-xs">
|
||
<select
|
||
aria-label="视频模型"
|
||
disabled={busy}
|
||
value={settings.modelRef || ""}
|
||
onChange={(e) => setSettings({ ...settings, modelRef: e.target.value || undefined })}
|
||
className="min-w-0 rounded-lg border border-white/10 bg-zinc-800 p-2"
|
||
>
|
||
<option value="">默认视频模型</option>
|
||
{props.videoModels.map((m) => (
|
||
<option key={m.id} value={m.id}>
|
||
{m.displayName}
|
||
</option>
|
||
))}
|
||
</select>
|
||
<select
|
||
aria-label="关键帧模型"
|
||
disabled={busy}
|
||
value={settings.imageModelRef || ""}
|
||
onChange={(e) =>
|
||
setSettings({ ...settings, imageModelRef: e.target.value || undefined })
|
||
}
|
||
className="min-w-0 rounded-lg border border-white/10 bg-zinc-800 p-2"
|
||
>
|
||
<option value="">默认关键帧模型</option>
|
||
{props.imageModels.map((m) => (
|
||
<option key={m.id} value={m.id}>
|
||
{m.displayName}
|
||
</option>
|
||
))}
|
||
</select>
|
||
<select
|
||
aria-label="视频模式"
|
||
disabled={busy}
|
||
value={settings.mode}
|
||
onChange={(e) =>
|
||
setSettings({ ...settings, mode: e.target.value as VideoAgentSettings["mode"] })
|
||
}
|
||
className="rounded-lg border border-white/10 bg-zinc-800 p-2"
|
||
>
|
||
{[
|
||
["auto", "按参考图匹配模式"],
|
||
["text-to-video", "文生视频"],
|
||
["first-frame", "首帧视频"],
|
||
["first-last-frame", "首尾帧视频"],
|
||
["reference-images", "参考图视频"],
|
||
].map(([value, label]) => (
|
||
<option key={value} value={value}>
|
||
{label}
|
||
</option>
|
||
))}
|
||
</select>
|
||
<div className="flex items-center gap-1 rounded-lg border border-white/10 bg-zinc-800 px-2">
|
||
<input
|
||
aria-label="视频时长(秒)"
|
||
type="number"
|
||
min={1}
|
||
max={120}
|
||
disabled={busy}
|
||
value={settings.duration}
|
||
onChange={(e) => setSettings({ ...settings, duration: Number(e.target.value) })}
|
||
className="w-12 min-w-0 bg-transparent py-2"
|
||
/>
|
||
<span>秒</span>
|
||
<select
|
||
aria-label="视频比例"
|
||
disabled={busy}
|
||
value={settings.ratio}
|
||
onChange={(e) =>
|
||
setSettings({ ...settings, ratio: e.target.value as VideoAgentSettings["ratio"] })
|
||
}
|
||
className="min-w-0 flex-1 bg-zinc-800"
|
||
>
|
||
{["16:9", "9:16", "1:1", "4:3", "3:4", "21:9"].map((v) => (
|
||
<option key={v}>{v}</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
<select
|
||
aria-label="视频分辨率"
|
||
disabled={busy}
|
||
value={settings.resolution}
|
||
onChange={(e) =>
|
||
setSettings({
|
||
...settings,
|
||
resolution: e.target.value as VideoAgentSettings["resolution"],
|
||
})
|
||
}
|
||
className="rounded-lg border border-white/10 bg-zinc-800 p-2"
|
||
>
|
||
{["480p", "720p", "1080p"].map((v) => (
|
||
<option key={v}>{v}</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
<form
|
||
onSubmit={(event) => {
|
||
event.preventDefault();
|
||
void submit();
|
||
}}
|
||
className="rounded-2xl border border-white/10 bg-[#151515] p-3"
|
||
>
|
||
<textarea
|
||
aria-label="视频创作要求"
|
||
value={input}
|
||
onChange={(event) => setInput(event.target.value)}
|
||
maxLength={2000}
|
||
rows={3}
|
||
placeholder="例如:先分析选中素材,写分镜,再生成一条5秒商品展示视频"
|
||
className="w-full resize-none bg-transparent text-sm outline-none placeholder:text-zinc-500"
|
||
/>
|
||
<div className="mt-2 flex items-center justify-between">
|
||
<span className="text-xs text-zinc-500">
|
||
已选择 {props.selectedNodeIds.length} 个素材
|
||
</span>
|
||
<button
|
||
type="submit"
|
||
aria-label="开始视频任务"
|
||
disabled={busy || loading || !input.trim()}
|
||
className="rounded-full bg-zinc-100 p-2 text-zinc-900 disabled:opacity-30"
|
||
>
|
||
<ArrowUp className="h-4 w-4" />
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</aside>
|
||
);
|
||
}
|