ai-chatbot/components/canvas/chat-panel/video-canvas-agent-panel.tsx

325 lines
12 KiB
TypeScript
Raw 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.

"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>
);
}