feat(previs): 图片生成预演台 - 从图片节点交给 Agent 复现为可编辑 3D 预演
图片工具栏新增「生成预演台」,自动选用支持看图的文本模型并提交复现指令。对话用节点卡片展示「此图」,普通图片演员只写名称时不再要求角色卡绑定。
This commit is contained in:
@@ -84,6 +84,7 @@ export type CloudAgentChatMessage = {
|
||||
meta?: string;
|
||||
detail?: unknown;
|
||||
attachments?: CloudAgentChatAttachment[];
|
||||
canvasReferenceNodeId?: string;
|
||||
interjection?: "sent" | "undelivered";
|
||||
};
|
||||
|
||||
@@ -381,6 +382,8 @@ export function AgentChatMessage({
|
||||
const formAnswer = item.formAnswer ?? (isUser ? parseCloudAgentFormAnswer(item.text) : undefined);
|
||||
const markdownComponents = useMemo(() => createAgentMessageMarkdownComponents(references, onFocusNode), [onFocusNode, references]);
|
||||
const agentMarkdownText = rewriteAgentNodeLinks(displayedText, references);
|
||||
const canvasReference = item.canvasReferenceNodeId ? references.find((reference) => reference.nodeId === item.canvasReferenceNodeId) : undefined;
|
||||
const userCanvasMarkdown = canvasReference ? item.text.replace("此图", createAgentNodeLink(canvasReference.nodeId, canvasReference)) : item.text;
|
||||
const errorTone = item.errorSeverity === "warning" ? "warning" : "error";
|
||||
const color = theme.node.text;
|
||||
if (item.reasoning) {
|
||||
@@ -484,6 +487,10 @@ export function AgentChatMessage({
|
||||
<AIMessageMarkdown className="text-left" isStreaming={isStreaming} streamingAnimation="none" components={markdownComponents}>
|
||||
{agentMarkdownText}
|
||||
</AIMessageMarkdown>
|
||||
) : isUser && canvasReference ? (
|
||||
<AIMessageMarkdown className="text-left" components={markdownComponents}>
|
||||
{userCanvasMarkdown}
|
||||
</AIMessageMarkdown>
|
||||
) : formAnswer ? (
|
||||
<AgentFormAnswerCard answer={formAnswer} theme={theme} />
|
||||
) : (
|
||||
|
||||
@@ -42,8 +42,10 @@ import {
|
||||
saveCloudAgentPendingSubmission,
|
||||
type CloudAgentConversation,
|
||||
type CloudAgentPendingSubmission,
|
||||
type CloudAgentMessagePresentation,
|
||||
} from "@/services/cloud-agent-conversations";
|
||||
import { logicalModelIDForConfig, modelOptionName, resolveModelRequestConfig, selectableModelsByCapability, useConfigStore, useEffectiveConfig } from "@/stores/use-config-store";
|
||||
import { selectImageInspectionModel } from "@/lib/canvas/image-to-previs-agent";
|
||||
import { useActiveTheme } from "@/stores/canvas/use-canvas-theme-store";
|
||||
import { useUserStore } from "@/stores/use-user-store";
|
||||
import { getActiveUserScope } from "@/lib/user-scope";
|
||||
@@ -67,8 +69,8 @@ type CloudAgentPanelProps = {
|
||||
selectedNodeIds: string[];
|
||||
references: CanvasResourceReference[];
|
||||
open: boolean;
|
||||
/** 外部预填请求(如右键“发送到 Agent”);id 变化即追加一次 text。 */
|
||||
prefillRequest?: { id: number; text: string } | null;
|
||||
/** 外部预填请求(如右键“发送到 Agent”);id 变化即追加一次 text,可选自动提交和视觉模型路由。 */
|
||||
prefillRequest?: (CloudAgentMessagePresentation & { id: number; text: string; autoSubmit?: boolean; requiresVision?: boolean }) | null;
|
||||
onOpen: () => void;
|
||||
onCollapse: () => void;
|
||||
onFocusNode?: (nodeId: string) => void;
|
||||
@@ -92,6 +94,8 @@ export function CanvasCloudAgentPanel({ canvasId, domainProjectId, nodeCount, se
|
||||
const [messages, setMessages] = useState<CloudAgentChatMessage[]>([]);
|
||||
const [prompt, setPrompt] = useState("");
|
||||
const lastPrefillIdRef = useRef(0);
|
||||
const [pendingAutoSubmit, setPendingAutoSubmit] = useState<CloudAgentPanelProps["prefillRequest"]>(null);
|
||||
const prefilledSubmissionRef = useRef<CloudAgentPanelProps["prefillRequest"]>(null);
|
||||
const [reasoningMode, setReasoningMode] = useState<AgentReasoningMode>("off");
|
||||
const [profileView, setProfileView] = useState<AgentProfileView | null>(null);
|
||||
const [profileLoading, setProfileLoading] = useState(false);
|
||||
@@ -319,7 +323,9 @@ export function CanvasCloudAgentPanel({ canvasId, domainProjectId, nodeCount, se
|
||||
if (!prefillRequest || prefillRequest.id === lastPrefillIdRef.current) return;
|
||||
lastPrefillIdRef.current = prefillRequest.id;
|
||||
if (!prefillRequest.text.trim()) return;
|
||||
setPrompt((current) => appendAgentPromptPrefill(current, prefillRequest.text));
|
||||
setPrompt((current) => appendAgentPromptPrefill(current, prefillRequest.displayText || prefillRequest.text));
|
||||
prefilledSubmissionRef.current = prefillRequest;
|
||||
setPendingAutoSubmit(prefillRequest.autoSubmit ? prefillRequest : null);
|
||||
setView("chat");
|
||||
}, [prefillRequest]);
|
||||
|
||||
@@ -490,7 +496,7 @@ export function CanvasCloudAgentPanel({ canvasId, domainProjectId, nodeCount, se
|
||||
const pending = await loadCloudAgentPendingSubmission(canvasId, current.id);
|
||||
if (!active) return;
|
||||
pendingSubmission.current = pending;
|
||||
if (pending?.request) setPrompt(pending.request.prompt);
|
||||
if (pending?.request) setPrompt(pending.displayText || pending.request.prompt);
|
||||
} else {
|
||||
setActiveConversationId(nanoid());
|
||||
pendingSubmission.current = null;
|
||||
@@ -550,7 +556,7 @@ export function CanvasCloudAgentPanel({ canvasId, domainProjectId, nodeCount, se
|
||||
if (event.seq > lastSeqRef.current + 1) canvasSyncRef.current?.reconcile();
|
||||
lastSeqRef.current = event.seq;
|
||||
}
|
||||
setMessages((current) => current.filter((item) => item.id !== `stream-error-${run.id}`));
|
||||
setMessages((current) => current.some((item) => item.id === `stream-error-${run.id}`) ? current.filter((item) => item.id !== `stream-error-${run.id}`) : current);
|
||||
setContextUsage((current) => reduceAgentContextUsage(current, event));
|
||||
applyAgentEvent(event, setMessages, setRun, setApproval, setPrompt);
|
||||
canvasSyncRef.current?.receive(event);
|
||||
@@ -597,8 +603,14 @@ export function CanvasCloudAgentPanel({ canvasId, domainProjectId, nodeCount, se
|
||||
}
|
||||
};
|
||||
|
||||
const submit = async (override?: string) => {
|
||||
const value = (override ?? prompt).trim();
|
||||
const submit = async (override?: string, options?: CloudAgentMessagePresentation & { requiresVision?: boolean }) => {
|
||||
const draft = (override ?? prompt).trim();
|
||||
const pendingRequest = pendingSubmission.current;
|
||||
const prefilled = prefilledSubmissionRef.current;
|
||||
const matchesPending = pendingRequest?.request && pendingRequest.displayText === draft;
|
||||
const matchesPrefill = prefilled?.displayText === draft;
|
||||
const value = matchesPending ? pendingRequest.request!.prompt : matchesPrefill ? prefilled!.text : draft;
|
||||
options = matchesPending ? { ...options, displayText: pendingRequest.displayText, canvasReferenceNodeId: pendingRequest.canvasReferenceNodeId } : matchesPrefill ? prefilled! : options;
|
||||
if (running) {
|
||||
await interject(value);
|
||||
return;
|
||||
@@ -609,6 +621,8 @@ export function CanvasCloudAgentPanel({ canvasId, domainProjectId, nodeCount, se
|
||||
setBusy(true);
|
||||
let accepted = false;
|
||||
try {
|
||||
const effectiveModel = options?.requiresVision ? selectImageInspectionModel(config, selectedModel) : selectedModel;
|
||||
if (options?.requiresVision && !effectiveModel) throw new Error("当前没有配置支持图片输入的文本模型,请在模型能力设置中为一个文本模型开启图片输入后重试");
|
||||
const pending = pendingSubmission.current;
|
||||
// An ambiguous previous POST owns its body/key until reconciled.
|
||||
// Editing model settings or prompt must not silently create a new charge.
|
||||
@@ -622,16 +636,16 @@ export function CanvasCloudAgentPanel({ canvasId, domainProjectId, nodeCount, se
|
||||
if (selectedSkillIds.length && !capabilities.skills) throw new Error("当前后端尚未接入技能库");
|
||||
await saveRemoteUserDataNow();
|
||||
if (currentScope.current !== scope) return;
|
||||
const agentConfig = { ...config, model: selectedModel };
|
||||
const requestConfig = resolveModelRequestConfig(agentConfig, selectedModel);
|
||||
const agentConfig = { ...config, model: effectiveModel };
|
||||
const requestConfig = resolveModelRequestConfig(agentConfig, effectiveModel);
|
||||
const logicalModelId = logicalModelIDForConfig(agentConfig);
|
||||
const input = {
|
||||
canvasId,
|
||||
prompt: value,
|
||||
reasoningMode,
|
||||
profileRevision: profileView.revision,
|
||||
model: modelOptionName(selectedModel) || undefined,
|
||||
...(logicalModelId ? { logicalModelId } : requestConfig.channelId ? { channelId: requestConfig.channelId, channelModelKey: modelOptionName(selectedModel) || undefined } : {}),
|
||||
model: modelOptionName(effectiveModel) || undefined,
|
||||
...(logicalModelId ? { logicalModelId } : requestConfig.channelId ? { channelId: requestConfig.channelId, channelModelKey: modelOptionName(effectiveModel) || undefined } : {}),
|
||||
skillIds: [...new Set([...selectedSkillIds, ...resolveSkillMentions(value, installedSkills).map((skill) => skill.skillId)])],
|
||||
focusNodeIds: selectedNodeIds.length <= 8 ? selectedNodeIds : [],
|
||||
permissionMode,
|
||||
@@ -641,7 +655,7 @@ export function CanvasCloudAgentPanel({ canvasId, domainProjectId, nodeCount, se
|
||||
const fingerprint = JSON.stringify({ scope, parent: run?.id, input });
|
||||
if (pending && pending.fingerprint !== fingerprint) throw new Error("上一条请求尚未确认,请恢复原消息与设置后核对,不能覆盖原幂等记录");
|
||||
const key = pending?.key || crypto.randomUUID();
|
||||
const next = { fingerprint, key, request: { ...input, idempotencyKey: key }, parentRunId: run?.id, messageId: `user-${key}` };
|
||||
const next = { fingerprint, key, request: { ...input, idempotencyKey: key }, parentRunId: run?.id, messageId: `user-${key}`, displayText: options?.displayText, canvasReferenceNodeId: options?.canvasReferenceNodeId };
|
||||
// Persist before sending. A failed local save must not submit a request
|
||||
// whose recovery identity will disappear on reload.
|
||||
await saveCloudAgentPendingSubmission(canvasId, activeConversationId, next);
|
||||
@@ -654,8 +668,9 @@ export function CanvasCloudAgentPanel({ canvasId, domainProjectId, nodeCount, se
|
||||
const nextMessages = appendUniqueMessage(messages, {
|
||||
id: submission.messageId || `user-${submission.key}`,
|
||||
role: "user",
|
||||
text: formAnswer ? "已确认创作方向" : request.prompt,
|
||||
text: formAnswer ? "已确认创作方向" : submission.displayText || request.prompt,
|
||||
...(formAnswer ? { formAnswer } : {}),
|
||||
...(submission.canvasReferenceNodeId ? { canvasReferenceNodeId: submission.canvasReferenceNodeId } : {}),
|
||||
});
|
||||
const now = new Date().toISOString();
|
||||
const existing = conversations.find((item) => item.id === activeConversationId);
|
||||
@@ -667,7 +682,7 @@ export function CanvasCloudAgentPanel({ canvasId, domainProjectId, nodeCount, se
|
||||
title: cloudAgentConversationTitle(nextMessages),
|
||||
messages: nextMessages,
|
||||
run,
|
||||
model: selectedModel || undefined,
|
||||
model: request.model || effectiveModel || undefined,
|
||||
permissionMode,
|
||||
skillIds: selectedSkillIds,
|
||||
createdAt: existing?.createdAt || now,
|
||||
@@ -677,6 +692,7 @@ export function CanvasCloudAgentPanel({ canvasId, domainProjectId, nodeCount, se
|
||||
]);
|
||||
if (currentScope.current !== scope) return;
|
||||
setPrompt("");
|
||||
prefilledSubmissionRef.current = null;
|
||||
setMessages(nextMessages);
|
||||
const result = submission.parentRunId ? await sendAgentMessage(submission.parentRunId, request) : await createAgentRun(request);
|
||||
accepted = true;
|
||||
@@ -708,6 +724,13 @@ export function CanvasCloudAgentPanel({ canvasId, domainProjectId, nodeCount, se
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const pending = pendingAutoSubmit;
|
||||
if (!pending || !historyHydrated || !pendingHydrated || profileLoading || !profileView || profileError || busy || running) return;
|
||||
setPendingAutoSubmit(null);
|
||||
void submit(pending.text, { requiresVision: pending.requiresVision, displayText: pending.displayText, canvasReferenceNodeId: pending.canvasReferenceNodeId });
|
||||
}, [busy, historyHydrated, pendingAutoSubmit, pendingHydrated, profileError, profileLoading, profileView, running]);
|
||||
|
||||
const stop = async () => {
|
||||
const activeRun = run;
|
||||
if (!activeRun?.id || stopping) return;
|
||||
@@ -857,7 +880,7 @@ export function CanvasCloudAgentPanel({ canvasId, domainProjectId, nodeCount, se
|
||||
if (currentScope.current === `${canvasId}:${conversation.id}`) {
|
||||
pendingSubmission.current = pending;
|
||||
setPendingHydrated(true);
|
||||
if (pending?.request) setPrompt(pending.request.prompt);
|
||||
if (pending?.request) setPrompt(pending.displayText || pending.request.prompt);
|
||||
}
|
||||
})
|
||||
.catch((cause) => {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Brush, Camera, Clapperboard, Contrast, Copy, FastForward, FileText, Glo
|
||||
import type { CanvasNodeData } from "@/types/canvas";
|
||||
import type { NodeToolbarGroup } from "@/lib/canvas/tool-registry";
|
||||
|
||||
type ImageNodeActionToolId = "copyPrompt" | "reversePrompt" | "replace" | "resize" | "annotation" | "annotationEdit" | "textEdit" | "maskEdit" | "removeBackground" | "layerDecomposition" | "emotion" | "portraitTexture" | "crop" | "split" | "upscale" | "superResolve" | "angle" | "lighting" | "panorama" | "view" | "multi_camera_nine_grid" | "story_pitch_four_grid" | "character_face_three_view" | "product_three_view" | "storyboard_25_grid" | "character_three_view_generation" | "cinematic_light_correction" | "image_projection_after_3s" | "image_projection_before_5s";
|
||||
type ImageNodeActionToolId = "copyPrompt" | "reversePrompt" | "replace" | "resize" | "annotation" | "annotationEdit" | "textEdit" | "maskEdit" | "removeBackground" | "layerDecomposition" | "emotion" | "portraitTexture" | "crop" | "split" | "upscale" | "superResolve" | "angle" | "lighting" | "panorama" | "view" | "previs" | "multi_camera_nine_grid" | "story_pitch_four_grid" | "character_face_three_view" | "product_three_view" | "storyboard_25_grid" | "character_three_view_generation" | "cinematic_light_correction" | "image_projection_after_3s" | "image_projection_before_5s";
|
||||
|
||||
type ImageToolHandlers = {
|
||||
onUpload: (node: CanvasNodeData) => void;
|
||||
@@ -23,6 +23,7 @@ type ImageToolHandlers = {
|
||||
onAngle: (node: CanvasNodeData) => void;
|
||||
onLighting: (node: CanvasNodeData) => void;
|
||||
onPanorama: (node: CanvasNodeData) => void;
|
||||
onPrevis: (node: CanvasNodeData) => void;
|
||||
onViewImage: (node: CanvasNodeData) => void;
|
||||
onCopyPrompt: (node: CanvasNodeData) => void;
|
||||
onReversePrompt: (node: CanvasNodeData) => void;
|
||||
@@ -58,6 +59,16 @@ function nineGridRun(node: CanvasNodeData, handlers: ImageToolHandlers, tool: Im
|
||||
}
|
||||
|
||||
const imageToolDefinitions: ImageToolDefinition[] = [
|
||||
{
|
||||
id: "previs",
|
||||
label: "生成预演台",
|
||||
icon: () => <Layers3 className="size-3.5" />,
|
||||
group: "primary",
|
||||
order: 20,
|
||||
section: "3D 预演",
|
||||
description: "让 Agent 识别当前图片并复现为可编辑的 3D 预演台",
|
||||
run: (node, handlers) => handlers.onPrevis(node),
|
||||
},
|
||||
{
|
||||
id: "copyPrompt",
|
||||
section: "生成信息",
|
||||
|
||||
@@ -51,6 +51,7 @@ type CanvasNodeToolbarProps = {
|
||||
onAngle: (node: CanvasNodeData) => void;
|
||||
onLighting: (node: CanvasNodeData) => void;
|
||||
onPanorama: (node: CanvasNodeData) => void;
|
||||
onPrevis: (node: CanvasNodeData) => void;
|
||||
onViewImage: (node: CanvasNodeData) => void;
|
||||
onExtractVideoFrames: (node: CanvasNodeData) => void;
|
||||
onExtractAudioFromVideo: (node: CanvasNodeData) => void;
|
||||
@@ -117,6 +118,7 @@ export function CanvasNodeToolbar({
|
||||
onAngle,
|
||||
onLighting,
|
||||
onPanorama,
|
||||
onPrevis,
|
||||
onViewImage,
|
||||
onExtractVideoFrames,
|
||||
onExtractAudioFromVideo,
|
||||
@@ -234,7 +236,7 @@ export function CanvasNodeToolbar({
|
||||
}
|
||||
copyText(prompt, "提示词已复制");
|
||||
};
|
||||
const imageTools = buildImageToolbarTools(node, { onUpload, onToggleFreeResize, onAnnotate, onAnnotationEdit, onTextEdit, onMaskEdit, onRemoveBackground, onLayerDecomposition, onEmotion, onPortraitTexture, onCrop, onUpscale, onSuperResolve, onAngle, onLighting, onPanorama, onViewImage, onCopyPrompt: copyImagePrompt, onReversePrompt, onNineGrid });
|
||||
const imageTools = buildImageToolbarTools(node, { onUpload, onToggleFreeResize, onAnnotate, onAnnotationEdit, onTextEdit, onMaskEdit, onRemoveBackground, onLayerDecomposition, onEmotion, onPortraitTexture, onCrop, onUpscale, onSuperResolve, onAngle, onLighting, onPanorama, onPrevis, onViewImage, onCopyPrompt: copyImagePrompt, onReversePrompt, onNineGrid });
|
||||
|
||||
// 构建 ToolContext——供注册表解析工具
|
||||
const nodeHoverHandlers = {
|
||||
|
||||
Reference in New Issue
Block a user