feat(*): Agent工作流 - 完善审批、分镜协作与资源交互体验
This commit is contained in:
@@ -2,12 +2,10 @@ import React from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import "antd/dist/reset.css";
|
||||
import "./styles/globals.css";
|
||||
import "./styles/auth-input-fix.css";
|
||||
import "./styles/shared/model-picker.css";
|
||||
import "./styles/shared/overlays.css";
|
||||
import "./styles/shared/scrollbars.css";
|
||||
// 全局自举内置插件注册(editor-shell 等预设以模块副作用注册编辑器插槽):
|
||||
// 冷启动直达编辑器时素材/时间线等插槽不再为空。
|
||||
import "@/lib/plugins/builtin";
|
||||
import { RouterProvider } from "react-router";
|
||||
|
||||
import { AppProviders } from "@/components/layout/app-providers";
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { useMemo } from "react";
|
||||
import { ModelPicker } from "@/components/model-picker";
|
||||
import { VideoSettingsPanel } from "@/components/video-settings-panel";
|
||||
import type { CanvasTheme } from "@/lib/canvas-theme";
|
||||
import { agentApprovalModel, agentApprovalModelSelection, type AgentVideoApproval } from "@/lib/canvas/agent-media-approval";
|
||||
import { modelCapabilityConfigFor, normalizeVideoValue } from "@/lib/model-capabilities";
|
||||
import { resolveCompatibleModel, type ModelRequirements } from "@/lib/model-selection";
|
||||
import type { AgentMediaSettings } from "@/services/api/agent";
|
||||
import { PUBLIC_MODEL_CATALOG_ID, useEffectiveConfig } from "@/stores/use-config-store";
|
||||
|
||||
export function CanvasAgentVideoApprovalSettings({ initial, value, theme, disabled, onChange }: {
|
||||
initial: AgentVideoApproval;
|
||||
value?: AgentMediaSettings;
|
||||
theme: CanvasTheme;
|
||||
disabled: boolean;
|
||||
onChange: (value: AgentMediaSettings) => void;
|
||||
}) {
|
||||
const effective = useEffectiveConfig();
|
||||
const config = useMemo(() => ({ ...effective, channels: effective.channels.filter((channel) => channel.scope === "system" || channel.id === PUBLIC_MODEL_CATALOG_ID) }), [effective]);
|
||||
const settings = value || initial;
|
||||
const model = agentApprovalModel(config, settings, "video");
|
||||
const duration = String(settings.durationSeconds || initial.durationSeconds || "");
|
||||
const audio = settings.videoGenerateAudio === undefined ? "" : String(settings.videoGenerateAudio);
|
||||
const watermark = settings.videoWatermark === undefined ? "" : String(settings.videoWatermark);
|
||||
const privateAssetUpload = settings.videoArkPrivateAssetUpload === undefined ? "" : String(settings.videoArkPrivateAssetUpload);
|
||||
const requirements: ModelRequirements = {
|
||||
capability: "video",
|
||||
input: { textCount: initial.sourceNodeId ? 1 : 0, imageCount: initial.referenceNodeIds.length, videoCount: 0, audioCount: 0, characterCount: 0 },
|
||||
videoSeconds: duration || undefined,
|
||||
options: {
|
||||
size: settings.size,
|
||||
vquality: settings.quality,
|
||||
videoSeconds: duration,
|
||||
...(settings.videoGenerateAudio === undefined ? {} : { videoGenerateAudio: settings.videoGenerateAudio }),
|
||||
...(settings.videoWatermark === undefined ? {} : { videoWatermark: settings.videoWatermark }),
|
||||
...(settings.videoArkPrivateAssetUpload === undefined ? {} : { videoArkPrivateAssetUpload: settings.videoArkPrivateAssetUpload }),
|
||||
},
|
||||
};
|
||||
|
||||
const changeModel = (next: string) => {
|
||||
if (disabled) return;
|
||||
const profile = modelCapabilityConfigFor(config, next).video;
|
||||
if (!profile) return;
|
||||
const normalized = normalizeVideoValue(profile, { seconds: duration, ratio: settings.size, resolution: settings.quality });
|
||||
onChange({ ...agentApprovalModelSelection(config, next), size: normalized.ratio, quality: normalized.resolution, durationSeconds: Number(normalized.seconds), videoGenerateAudio: settings.videoGenerateAudio, videoWatermark: settings.videoWatermark, videoArkPrivateAssetUpload: settings.videoArkPrivateAssetUpload });
|
||||
};
|
||||
|
||||
const changeOption = (key: "vquality" | "size" | "videoSeconds" | "videoGenerateAudio" | "videoWatermark" | "videoArkPrivateAssetUpload", next: string) => {
|
||||
if (disabled) return;
|
||||
const nextSettings: AgentMediaSettings = {
|
||||
...settings,
|
||||
size: key === "size" ? next : settings.size,
|
||||
quality: key === "vquality" ? next : settings.quality,
|
||||
durationSeconds: key === "videoSeconds" ? Number(next) : settings.durationSeconds,
|
||||
videoGenerateAudio: key === "videoGenerateAudio" ? next === "true" : settings.videoGenerateAudio,
|
||||
videoWatermark: key === "videoWatermark" ? next === "true" : settings.videoWatermark,
|
||||
videoArkPrivateAssetUpload: key === "videoArkPrivateAssetUpload" ? next === "true" : settings.videoArkPrivateAssetUpload,
|
||||
};
|
||||
const selected = resolveCompatibleModel(config, model, {
|
||||
...requirements,
|
||||
videoSeconds: String(nextSettings.durationSeconds || ""),
|
||||
options: {
|
||||
size: nextSettings.size,
|
||||
vquality: nextSettings.quality,
|
||||
videoSeconds: String(nextSettings.durationSeconds || ""),
|
||||
...(nextSettings.videoGenerateAudio === undefined ? {} : { videoGenerateAudio: nextSettings.videoGenerateAudio }),
|
||||
...(nextSettings.videoWatermark === undefined ? {} : { videoWatermark: nextSettings.videoWatermark }),
|
||||
...(nextSettings.videoArkPrivateAssetUpload === undefined ? {} : { videoArkPrivateAssetUpload: nextSettings.videoArkPrivateAssetUpload }),
|
||||
},
|
||||
});
|
||||
if (!selected) return;
|
||||
onChange({ ...agentApprovalModelSelection(config, selected), ...nextSettings });
|
||||
};
|
||||
|
||||
return (
|
||||
<fieldset disabled={disabled} className="min-w-0 space-y-3 border-0 p-0" data-canvas-no-zoom data-canvas-wheel-scroll aria-label="视频生成设置">
|
||||
<div className="space-y-1.5">
|
||||
<div className="text-xs" style={{ color: theme.node.muted }}>生成模型</div>
|
||||
<ModelPicker config={config} capability="video" value={model} onChange={changeModel} requirements={requirements} variant="creation" fullWidth showOptionPrices placeholder="选择生成模型" popoverClassName="agent-model-picker-popover" />
|
||||
</div>
|
||||
{model ? <VideoSettingsPanel config={{ ...config, model, size: settings.size, videoSeconds: duration, vquality: settings.quality, videoGenerateAudio: audio, videoWatermark: watermark, videoArkPrivateAssetUpload: privateAssetUpload }} onConfigChange={changeOption} theme={theme} showTitle={false} className="min-w-0 space-y-3" /> : <p className="text-xs" style={{ color: theme.node.muted }}>当前模型不在可选目录中,可重新选择;提交时将重新校验模型与规格。</p>}
|
||||
<p className="text-xs" style={{ color: theme.node.text }}>本次规格:{settings.size}{settings.quality ? ` · ${settings.quality}` : ""}{settings.durationSeconds ? ` · ${settings.durationSeconds} 秒` : ""}</p>
|
||||
<p className="text-xs" style={{ color: theme.node.muted }}>修改仅用于本次生成,费用按最终模型和规格计算。</p>
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
@@ -59,6 +59,7 @@ export type CloudAgentFormAnswer = {
|
||||
fieldTitles?: Record<string, string>;
|
||||
skippedFields?: string[];
|
||||
useRecommendedDefaults?: boolean;
|
||||
additionalNotes?: string;
|
||||
};
|
||||
export type CloudAgentChatMessage = {
|
||||
id: string;
|
||||
@@ -141,6 +142,7 @@ export function parseCloudAgentFormAnswer(value: string): CloudAgentFormAnswer |
|
||||
fieldTitles: formAnswerRecord(record.fieldTitles),
|
||||
skippedFields: Array.isArray(record.skippedFields) ? record.skippedFields.filter((item): item is string => typeof item === "string") : [],
|
||||
useRecommendedDefaults: record.useRecommendedDefaults === true,
|
||||
additionalNotes: typeof record.additionalNotes === "string" ? record.additionalNotes : undefined,
|
||||
};
|
||||
} catch {
|
||||
return undefined;
|
||||
@@ -170,7 +172,10 @@ function AgentFormAnswerCard({ answer, theme }: { answer: CloudAgentFormAnswer;
|
||||
<span className="agent-form-answer-value">{answer.displayAnswers?.[key] || agentFormValueLabel(value)}</span>
|
||||
</div>
|
||||
))}
|
||||
{answer.additionalNotes ? <div className="agent-form-answer-row"><span className="agent-form-answer-label">补充说明</span><span className="agent-form-answer-value">{answer.additionalNotes}</span></div> : null}
|
||||
</div>
|
||||
) : answer.additionalNotes ? (
|
||||
<div className="agent-form-answer-row"><span className="agent-form-answer-label">补充说明</span><span className="agent-form-answer-value">{answer.additionalNotes}</span></div>
|
||||
) : (
|
||||
<div className="agent-form-answer-note">已确认,按推荐方案继续</div>
|
||||
)}
|
||||
@@ -1009,11 +1014,13 @@ export function AgentQuestionBar({ question, theme, onAnswer, disabled = false }
|
||||
const fieldDefaultValue = (field: CloudAgentFormField) => field.defaultValue || field.options?.find((option) => option.recommended)?.id || field.options?.find((option) => option.recommended)?.label || "";
|
||||
const [values, setValues] = useState<Record<string, string>>(() => Object.fromEntries(fields.map((field) => [field.id, fieldDefaultValue(field)])));
|
||||
const [customFields, setCustomFields] = useState<Record<string, boolean>>({});
|
||||
const [additionalNotes, setAdditionalNotes] = useState("");
|
||||
const [validation, setValidation] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
setValues(Object.fromEntries(fields.map((field) => [field.id, fieldDefaultValue(field)])));
|
||||
setCustomFields({});
|
||||
setAdditionalNotes("");
|
||||
setValidation("");
|
||||
}, [question.questionId, fields]);
|
||||
|
||||
@@ -1042,6 +1049,7 @@ export function AgentQuestionBar({ question, theme, onAnswer, disabled = false }
|
||||
fieldTitles,
|
||||
skippedFields: fields.filter((field) => !String(answers[field.id] || "").trim()).map((field) => field.id),
|
||||
useRecommendedDefaults: useDefaults,
|
||||
additionalNotes: additionalNotes.trim() || undefined,
|
||||
}),
|
||||
);
|
||||
};
|
||||
@@ -1142,6 +1150,20 @@ export function AgentQuestionBar({ question, theme, onAnswer, disabled = false }
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="agent-question-field px-3 pb-2">
|
||||
<label className="agent-question-field-label" htmlFor="agent-question-additional-notes">补充说明</label>
|
||||
<textarea
|
||||
id="agent-question-additional-notes"
|
||||
rows={2}
|
||||
value={additionalNotes}
|
||||
placeholder="还有需要特别说明的场景、人物、节奏或限制吗?"
|
||||
disabled={disabled}
|
||||
className="agent-question-input agent-question-textarea"
|
||||
onChange={(event) => setAdditionalNotes(event.target.value)}
|
||||
onMouseDown={stop}
|
||||
onPointerDown={stop}
|
||||
/>
|
||||
</div>
|
||||
{validation ? <div className="agent-question-validation px-3 pb-1 text-[10px]">{validation}</div> : null}
|
||||
<div className="agent-question-actions px-3 pb-2.5">
|
||||
<button
|
||||
|
||||
@@ -26,9 +26,10 @@ import { AgentWelcome } from "./canvas-agent-welcome";
|
||||
import { useEffectiveConfig } from "@/stores/use-config-store";
|
||||
import { agentPermissionLabel, agentPermissionMenuItems, agentPermissionVisual } from "./canvas-cloud-agent-settings";
|
||||
import { ModelPicker } from "@/components/model-picker";
|
||||
import { agentImageApproval } from "@/lib/canvas/agent-media-approval";
|
||||
import { agentImageApproval, agentVideoApproval } from "@/lib/canvas/agent-media-approval";
|
||||
import { agentApprovalPresentation } from "@/lib/canvas/agent-approval-presentation";
|
||||
import { CanvasAgentImageApprovalSettings } from "./canvas-agent-image-approval-settings";
|
||||
import { CanvasAgentVideoApprovalSettings } from "./canvas-agent-video-approval-settings";
|
||||
import { markdownPlainText } from "@/lib/markdown-plain-text";
|
||||
import type { ApprovalState } from "./canvas-cloud-agent-events";
|
||||
|
||||
@@ -540,6 +541,7 @@ export function ApprovalCard({
|
||||
const [showReason, setShowReason] = useState(Boolean(approval.reason));
|
||||
const [mediaSettings, setMediaSettings] = useState<AgentMediaSettings>();
|
||||
const imageApproval = agentImageApproval(approval.detail);
|
||||
const videoApproval = agentVideoApproval(approval.detail);
|
||||
const action = agentApprovalPresentation(approval.detail);
|
||||
return (
|
||||
<section className="canvas-agent-approval-card" aria-label={action.title}>
|
||||
@@ -558,7 +560,7 @@ export function ApprovalCard({
|
||||
{action.items.map((item, index) => (
|
||||
<ApprovalPreviewItemView
|
||||
key={`${item.operation}-${item.nodeId || item.nodeTitle || index}-${index}`}
|
||||
item={imageApproval ? { ...item, details: item.details?.filter((detail) => !/^(模型|画幅|质量)[::]/.test(detail)) } : item}
|
||||
item={imageApproval || videoApproval ? { ...item, details: item.details?.filter((detail) => !/^(模型|画幅|质量|时长|音频)[::]/.test(detail)) } : item}
|
||||
theme={theme}
|
||||
onFocusNode={onFocusNode}
|
||||
/>
|
||||
@@ -569,7 +571,7 @@ export function ApprovalCard({
|
||||
无法确认具体目标,继续前请重新读取画布。
|
||||
</div>
|
||||
)}
|
||||
{imageApproval ? <CanvasAgentImageApprovalSettings initial={imageApproval} value={mediaSettings} onChange={setMediaSettings} theme={theme} disabled={submitting} /> : null}
|
||||
{imageApproval ? <CanvasAgentImageApprovalSettings initial={imageApproval} value={mediaSettings} onChange={setMediaSettings} theme={theme} disabled={submitting} /> : videoApproval ? <CanvasAgentVideoApprovalSettings initial={videoApproval} value={mediaSettings} onChange={setMediaSettings} theme={theme} disabled={submitting} /> : null}
|
||||
<button type="button" className="canvas-agent-approval-reason-toggle" aria-expanded={showReason} onClick={() => setShowReason((value) => !value)} disabled={submitting}>
|
||||
{showReason ? "收起拒绝理由" : "填写拒绝理由(可选)"}
|
||||
</button>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Popover } from "antd";
|
||||
import { Switch } from "@/components/ui/base/switch";
|
||||
import { CircleUserRound, Moon, Sun } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Link } from "react-router";
|
||||
|
||||
import { AppChangelogButton } from "@/components/layout/app-changelog-modal";
|
||||
import { WorkspaceAccountCard } from "./workspace-account-card";
|
||||
@@ -50,5 +51,5 @@ export function WorkspaceAccountMenu() {
|
||||
<UserAvatar user={user} className="size-6" />
|
||||
</button>
|
||||
</Popover></>
|
||||
) : <span className="inline-flex items-center gap-1.5 px-1.5 text-xs text-foreground/55" role="img" aria-label="游客模式" title="游客模式"><CircleUserRound className="size-5" /><span className="hidden sm:inline">游客</span></span>;
|
||||
) : <Link to="/login" className="inline-flex items-center gap-1.5 px-1.5 text-xs text-foreground/55" aria-label="登录" title="登录"><CircleUserRound className="size-5" /><span className="hidden sm:inline">游客</span></Link>;
|
||||
}
|
||||
|
||||
@@ -70,7 +70,7 @@ function WorkspaceSidebarProfile({ collapsed, user, onExpand, onCollapse }: { co
|
||||
useEffect(() => setFailed(false), [avatarUrl]);
|
||||
|
||||
if (!user) {
|
||||
return <div className={cn("app-workspace-sidebar-profile", collapsed && "is-collapsed")} role="img" aria-label="游客模式" title="游客模式"><span className="app-workspace-sidebar-profile-avatar"><CircleUserRound aria-hidden /></span>{!collapsed ? <span>游客模式</span> : null}</div>;
|
||||
return <Link to="/login" className={cn("app-workspace-sidebar-profile", collapsed && "is-collapsed")} aria-label="登录" title="登录"><span className="app-workspace-sidebar-profile-avatar"><CircleUserRound aria-hidden /></span>{!collapsed ? <span>游客模式</span> : null}</Link>;
|
||||
}
|
||||
|
||||
const avatar = avatarUrl && !failed ? <img src={avatarUrl} alt="" referrerPolicy="no-referrer" onError={() => setFailed(true)} /> : <CircleUserRound aria-hidden />;
|
||||
|
||||
@@ -25,11 +25,35 @@ export type ExternalAssetSourceState = {
|
||||
export function useExternalAssetSources(open: boolean): ExternalAssetSourceState {
|
||||
const hydrated = usePluginStore((state) => state.hydrated);
|
||||
const installations = usePluginStore((state) => state.installations);
|
||||
const sources = useMemo(() => createHostedAssetSources(hydrated ? installations : []), [hydrated, installations]);
|
||||
const needsEagle = open && hydrated && installations.some((installation) => installation.manifest.id === "eagle-asset-connector");
|
||||
const [eagleLoaded, setEagleLoaded] = useState(false);
|
||||
const [loadedSources, setLoadedSources] = useState<LoadedAssetSource[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (!needsEagle) {
|
||||
setEagleLoaded(true);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
setEagleLoaded(false);
|
||||
void import("@/lib/plugins/builtin/eagle")
|
||||
.catch(() => undefined)
|
||||
.finally(() => {
|
||||
if (!cancelled) setEagleLoaded(true);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [needsEagle]);
|
||||
|
||||
const sources = useMemo(() => {
|
||||
if (!hydrated || (needsEagle && !eagleLoaded)) return [];
|
||||
return createHostedAssetSources(installations);
|
||||
}, [eagleLoaded, hydrated, installations, needsEagle]);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
if (!open || !sources.length) {
|
||||
|
||||
@@ -4,6 +4,34 @@ import type { CanvasNodeData } from "@/types/canvas";
|
||||
import { logicalModelIDForConfig, modelOptionName, resolveModelChannel, selectableModelsByCapability, type AiConfig } from "@/stores/use-config-store";
|
||||
|
||||
export type AgentImageApproval = AgentMediaSettings & { prompt: string; referenceNodeIds: string[] };
|
||||
export type AgentVideoApproval = AgentImageApproval & { referenceTransientIds: string[]; sourceNodeId: string };
|
||||
|
||||
export function agentVideoApproval(detail: Record<string, unknown>): AgentVideoApproval | null {
|
||||
const call = detail.call as { function?: { name?: string; arguments?: unknown } } | undefined;
|
||||
if ((call?.function?.name || detail.toolName) !== "generate_media") return null;
|
||||
const raw = call?.function?.arguments ?? detail.arguments;
|
||||
try {
|
||||
const args = typeof raw === "string" ? JSON.parse(raw) : raw;
|
||||
if (!args || args.mode !== "video" || typeof args.prompt !== "string") return null;
|
||||
return {
|
||||
logicalModelId: typeof args.logicalModelId === "string" ? args.logicalModelId : "",
|
||||
channelId: typeof args.channelId === "string" ? args.channelId : "",
|
||||
channelModelKey: typeof args.channelModelKey === "string" ? args.channelModelKey : "",
|
||||
size: typeof args.size === "string" ? args.size : "",
|
||||
quality: typeof args.quality === "string" ? args.quality : "",
|
||||
durationSeconds: typeof args.durationSeconds === "number" && args.durationSeconds > 0 ? args.durationSeconds : undefined,
|
||||
videoGenerateAudio: typeof args.videoGenerateAudio === "boolean" ? args.videoGenerateAudio : undefined,
|
||||
videoWatermark: typeof args.videoWatermark === "boolean" ? args.videoWatermark : undefined,
|
||||
videoArkPrivateAssetUpload: typeof args.videoArkPrivateAssetUpload === "boolean" ? args.videoArkPrivateAssetUpload : undefined,
|
||||
prompt: args.prompt,
|
||||
referenceNodeIds: Array.isArray(args.referenceNodeIds) ? args.referenceNodeIds.filter((id: unknown): id is string => typeof id === "string") : [],
|
||||
referenceTransientIds: Array.isArray(args.referenceTransientIds) ? args.referenceTransientIds.filter((id: unknown): id is string => typeof id === "string") : [],
|
||||
sourceNodeId: typeof args.sourceNodeId === "string" ? args.sourceNodeId : "",
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function agentImageApproval(detail: Record<string, unknown>): AgentImageApproval | null {
|
||||
const call = detail.call as { function?: { name?: string; arguments?: unknown } } | undefined;
|
||||
@@ -26,16 +54,22 @@ export function agentImageApproval(detail: Record<string, unknown>): AgentImageA
|
||||
}
|
||||
}
|
||||
|
||||
export function agentApprovalModel(config: AiConfig, settings: AgentMediaSettings): string {
|
||||
return selectableModelsByCapability(config, "image").find((model) => {
|
||||
export function agentApprovalModel(config: AiConfig, settings: AgentMediaSettings, capability: "image" | "video" = "image"): string {
|
||||
return selectableModelsByCapability(config, capability).find((model) => {
|
||||
if (settings.logicalModelId) return logicalModelIDForConfig({ ...config, model }) === settings.logicalModelId;
|
||||
return resolveModelChannel(config, model).id === settings.channelId && modelOptionName(model) === settings.channelModelKey;
|
||||
}) || "";
|
||||
}
|
||||
|
||||
export function agentApprovalMatchesSettings(argumentsValue: unknown, settings: AgentMediaSettings): boolean {
|
||||
const approved = agentImageApproval({ toolName: "generate_media", arguments: argumentsValue });
|
||||
return Boolean(approved && (["logicalModelId", "channelId", "channelModelKey", "size", "quality"] as const).every((key) => (approved[key] || "") === (settings[key] || "")));
|
||||
const detail = { toolName: "generate_media", arguments: argumentsValue };
|
||||
const approved = agentImageApproval(detail) || agentVideoApproval(detail);
|
||||
if (!approved) return false;
|
||||
return (["logicalModelId", "channelId", "channelModelKey", "size", "quality", "durationSeconds", "videoGenerateAudio", "videoWatermark", "videoArkPrivateAssetUpload"] as const).every((key) => {
|
||||
const left = approved[key];
|
||||
const right = settings[key];
|
||||
return (left === undefined ? "" : left) === (right === undefined ? "" : right);
|
||||
});
|
||||
}
|
||||
|
||||
export function agentApprovalModelSelection(config: AiConfig, model: string): Pick<AgentMediaSettings, "logicalModelId" | "channelId" | "channelModelKey"> {
|
||||
|
||||
@@ -29,7 +29,24 @@ export function commitCanvasGenerationResult(current: CanvasNodeData[], before:
|
||||
if (!versionRootId || (node.metadata?.versionOfNodeId || node.id) !== versionRootId) return node;
|
||||
return { ...node, metadata: { ...node.metadata, versionPrimary: false } };
|
||||
});
|
||||
// 同一任务的图片子节点使用稳定 ID;重放只补缺失项,不覆盖用户已移动、改名的图片。
|
||||
const additionalById = new Map(additionalNodes.map((node) => [node.id, node]));
|
||||
const committedWithAdditional = committed.map((node) => {
|
||||
const refreshed = additionalById.get(node.id);
|
||||
if (!refreshed) return node;
|
||||
const manualSize = Boolean(node.metadata?.manualSize);
|
||||
return {
|
||||
...refreshed,
|
||||
title: node.title,
|
||||
position: node.position,
|
||||
width: manualSize ? node.width : refreshed.width,
|
||||
height: manualSize ? node.height : refreshed.height,
|
||||
};
|
||||
});
|
||||
const existingIds = new Set(current.map((node) => node.id));
|
||||
return [...committed, ...additionalNodes.filter((node) => !existingIds.has(node.id)).map((node) => ({ ...node, position: { x: node.position.x + merged.position.x - result.position.x, y: node.position.y + merged.position.y - result.position.y } }))];
|
||||
return [
|
||||
...committedWithAdditional,
|
||||
...additionalNodes
|
||||
.filter((node) => !existingIds.has(node.id))
|
||||
.map((node) => ({ ...node, position: { x: node.position.x + merged.position.x - result.position.x, y: node.position.y + merged.position.y - result.position.y } })),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -382,14 +382,17 @@ async function buildGenerationTaskNodeResults(node: CanvasNodeData, task: Genera
|
||||
for (let index = 0; index < images.length; index += 1) {
|
||||
const id = `${node.id}:task:${task.id}:image:${index}`;
|
||||
const existing = nodes.find((item) => item.id === id);
|
||||
if (existing) {
|
||||
children.push(existing);
|
||||
continue;
|
||||
}
|
||||
const child = await buildGenerationTaskNodeResult({ ...node, id, title: `${node.title} · ${index + 1}`, parentId: undefined }, task, nodes, index);
|
||||
const child = await buildGenerationTaskNodeResult(
|
||||
existing
|
||||
? { ...node, ...existing, id, title: existing.title, position: existing.position, width: existing.width, height: existing.height }
|
||||
: { ...node, id, title: `${node.title} · ${index + 1}`, parentId: undefined },
|
||||
task,
|
||||
nodes,
|
||||
index,
|
||||
);
|
||||
children.push({
|
||||
...child,
|
||||
position: imageGenerationChildPosition(resultNode.position, resultNode.width, child, index),
|
||||
...(existing ? { title: existing.title, position: existing.position, width: existing.metadata?.manualSize ? existing.width : child.width, height: existing.metadata?.manualSize ? existing.height : child.height } : { position: imageGenerationChildPosition(resultNode.position, resultNode.width, child, index) }),
|
||||
metadata: {
|
||||
...child.metadata,
|
||||
assetId: task.outputs?.find((output) => output.outputIndex === index)?.materializedAssetId,
|
||||
@@ -452,11 +455,6 @@ export async function buildImageLayerTaskResult(node: CanvasNodeData, task: Gene
|
||||
const children: CanvasNodeData[] = [];
|
||||
for (let index = 0; index < images.length; index += 1) {
|
||||
const id = `${node.id}:task:${task.id}:layer:${index}`;
|
||||
const existing = nodes.find((item) => item.id === id);
|
||||
if (existing) {
|
||||
children.push(existing);
|
||||
continue;
|
||||
}
|
||||
const image = images[index];
|
||||
const stored: UploadedImage = image.storageKey
|
||||
? {
|
||||
@@ -469,6 +467,30 @@ export async function buildImageLayerTaskResult(node: CanvasNodeData, task: Gene
|
||||
}
|
||||
: await uploadImage(decoded.layers[index].blob);
|
||||
if (stored.pendingRemoteUpload) throw new Error("图层仅缓存在本机,服务端保存未完成;请重试保存结果");
|
||||
const existing = nodes.find((item) => item.id === id);
|
||||
const metadata = commitProducedModel(
|
||||
{
|
||||
...(existing?.metadata || {}),
|
||||
...imageMetadata(stored),
|
||||
assetId: task.outputs?.find((output) => output.outputIndex === index)?.materializedAssetId,
|
||||
batchRootId: node.id,
|
||||
imageLayer: { groupId: node.id, outputIndex: index, kind: decoded.layers[index].transparent ? "transparent" : "base" },
|
||||
model: node.metadata?.model || task.model,
|
||||
producedModel: task.model,
|
||||
},
|
||||
task.model,
|
||||
);
|
||||
if (existing) {
|
||||
children.push({
|
||||
...existing,
|
||||
type: CanvasNodeType.Image,
|
||||
width: existing.metadata?.manualSize ? existing.width : size.width,
|
||||
height: existing.metadata?.manualSize ? existing.height : size.height,
|
||||
position: existing.position,
|
||||
metadata,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
children.push({
|
||||
id,
|
||||
type: CanvasNodeType.Image,
|
||||
@@ -478,17 +500,7 @@ export async function buildImageLayerTaskResult(node: CanvasNodeData, task: Gene
|
||||
const position = imageGenerationChildPosition(node.position, size.width, size, index);
|
||||
return { x: position.x + layoutPosition.x - firstPosition.x, y: position.y + layoutPosition.y - firstPosition.y };
|
||||
})(),
|
||||
metadata: commitProducedModel(
|
||||
{
|
||||
...imageMetadata(stored),
|
||||
assetId: task.outputs?.find((output) => output.outputIndex === index)?.materializedAssetId,
|
||||
batchRootId: node.id,
|
||||
imageLayer: { groupId: node.id, outputIndex: index, kind: decoded.layers[index].transparent ? "transparent" : "base" },
|
||||
model: node.metadata?.model || task.model,
|
||||
producedModel: task.model,
|
||||
},
|
||||
task.model,
|
||||
),
|
||||
metadata,
|
||||
});
|
||||
}
|
||||
const composite = await uploadImage(decoded.composite);
|
||||
|
||||
@@ -120,6 +120,13 @@ export function storyboardRowsOutputContract(requirement: string) {
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
// 旧版本会把项目画风执行规范追加到分镜剧情末尾;分镜规划只保留用户原始剧情。
|
||||
export function stripProjectStyleExecutionBlocks(prompt: string): string {
|
||||
const marker = "【项目画风执行规范】";
|
||||
const markerIndex = prompt.indexOf(marker);
|
||||
return (markerIndex >= 0 ? prompt.slice(0, markerIndex) : prompt).trim();
|
||||
}
|
||||
|
||||
// 分镜文本任务实际发出的 prompt:技能上下文只描述工作流、不约束输出结构,画布节点和章节分镜又都没有
|
||||
// metadata.promptTemplateOperation(后端只在有该字段时编译服务端模板),所以契约必须由调用方显式拼上,
|
||||
// 否则模型按自然语言习惯返回 Markdown 表格或直接续写正文,解析阶段拿不到 rows。
|
||||
|
||||
@@ -1,12 +1,6 @@
|
||||
import type { CanvasNodeData } from "@/types/canvas";
|
||||
|
||||
export type StoryboardGenerationContext = {
|
||||
projectStyle: {
|
||||
presetId: string;
|
||||
title: string;
|
||||
prompt: string;
|
||||
profileJson?: string;
|
||||
};
|
||||
characters: Array<{
|
||||
assetId: string;
|
||||
versionId: string;
|
||||
@@ -15,13 +9,8 @@ export type StoryboardGenerationContext = {
|
||||
}>;
|
||||
};
|
||||
|
||||
// 分镜的两条入口共用同一强校验,避免画风或角色版本在某条旁路里被遗漏。
|
||||
// 分镜的两条入口共用角色资产校验,避免角色版本在某条旁路里被遗漏。
|
||||
export function resolveStoryboardGenerationContext(nodes: CanvasNodeData[]): StoryboardGenerationContext {
|
||||
const styleNode = nodes.find((node) => node.metadata?.workflowKind === "styleboard");
|
||||
const stylePrompt = String(styleNode?.metadata?.content || styleNode?.metadata?.prompt || "").trim();
|
||||
const stylePresetId = String(styleNode?.metadata?.stylePresetId || "").trim();
|
||||
if (!styleNode || !stylePrompt || !stylePresetId) throw new Error("请先设置项目画风,再生成分镜");
|
||||
|
||||
// `workflowKind=character` is also used by standalone character-design
|
||||
// image workflows. Only nodes linked to a project character asset are
|
||||
// storyboard character cards and therefore participate in this check.
|
||||
@@ -30,12 +19,6 @@ export function resolveStoryboardGenerationContext(nodes: CanvasNodeData[]): Sto
|
||||
if (invalidCharacter) throw new Error(`角色卡“${invalidCharacter.metadata?.characterName || invalidCharacter.title || "未命名角色"}”版本未同步,请刷新角色资产后再生成分镜`);
|
||||
|
||||
return {
|
||||
projectStyle: {
|
||||
presetId: stylePresetId,
|
||||
title: styleNode.title.replace(/^(?:项目)?画风\s*[·::]?\s*/, "").trim() || styleNode.title,
|
||||
prompt: stylePrompt,
|
||||
profileJson: styleNode.metadata?.styleProfileJson,
|
||||
},
|
||||
characters: characterNodes.map((node) => ({
|
||||
assetId: node.metadata!.characterAssetId!.trim(),
|
||||
versionId: node.metadata!.characterVersionId!.trim(),
|
||||
|
||||
@@ -19,6 +19,8 @@ export function preloadWorkspaceRoute(pathnameOrSlug: string) {
|
||||
// 根路径就是创作页,预加载时仍映射到其内部模块名。
|
||||
const segments = pathnameOrSlug.replace(/^\//, "").split("/").filter(Boolean);
|
||||
const slug = segments[0] || "create";
|
||||
// 具体画布项目由 /canvas/:id 自己加载;不要在认证恢复阶段额外预加载画布列表。
|
||||
if (slug === "canvas" && segments.length > 1) return;
|
||||
if (slug === "projects" && segments.length > 1) {
|
||||
void workspaceRouteLoaders.projectDetail();
|
||||
return;
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
expandStoryboardTextMentions,
|
||||
storyboardRowsFromTask,
|
||||
storyboardPromptTemplateMetadata,
|
||||
stripProjectStyleExecutionBlocks,
|
||||
withStoryboardOutputContract,
|
||||
} from "@/lib/canvas/canvas-project-domain";
|
||||
import { buildNodeMentionReferences } from "@/lib/canvas/canvas-resource-references";
|
||||
@@ -131,7 +132,11 @@ export function useCanvasStoryboard({
|
||||
|
||||
const generateScriptRows = useCallback(async (nodeId: string, prompt: string, callerSignal?: AbortSignal) => {
|
||||
const scriptNode = nodesRef.current.find((node) => node.id === nodeId && node.type === CanvasNodeType.Script);
|
||||
if (!scriptNode || !prompt.trim()) return;
|
||||
const cleanedPrompt = stripProjectStyleExecutionBlocks(prompt);
|
||||
if (!scriptNode || !cleanedPrompt) {
|
||||
if (scriptNode && !cleanedPrompt) message.warning("分镜脚本缺少剧情内容,无法生成");
|
||||
return;
|
||||
}
|
||||
if (storyboardRequests.current.has(nodeId) || scriptNode.metadata?.status === NODE_STATUS_LOADING) { message.warning("当前分镜正在处理,请等待原任务完成"); return false; }
|
||||
const lifetimeSignal = storyboardLifetime.current.signal;
|
||||
const signal = callerSignal ? AbortSignal.any([lifetimeSignal, callerSignal]) : lifetimeSignal;
|
||||
@@ -153,7 +158,7 @@ export function useCanvasStoryboard({
|
||||
const shotDurationSeconds = shotDuration === "auto" ? 0 : Number(shotDuration);
|
||||
const shotCount = scriptNode.metadata?.storyboardShotCount || "auto";
|
||||
const requestedShotCount = shotCount === "auto" ? 0 : Number(shotCount);
|
||||
const expandedPrompt = expandStoryboardTextMentions(prompt, buildNodeMentionReferences(scriptNode, nodesRef.current, connectionsRef.current));
|
||||
const expandedPrompt = expandStoryboardTextMentions(cleanedPrompt, buildNodeMentionReferences(scriptNode, nodesRef.current, connectionsRef.current));
|
||||
const generationConfig = buildGenerationConfig(effectiveConfig, scriptNode, "text");
|
||||
if (!isAiConfigReady(generationConfig, generationConfig.model)) {
|
||||
navigateToSettings({ continueCreation: true });
|
||||
@@ -167,7 +172,7 @@ export function useCanvasStoryboard({
|
||||
skills: addedSkills,
|
||||
});
|
||||
assertCurrent();
|
||||
setNodes((current) => current.map((node) => node.id === nodeId ? { ...node, metadata: { ...node.metadata, composerContent: prompt, status: NODE_STATUS_LOADING, taskStage: "正在创建任务", taskProgress: 0, errorDetails: undefined, ...skillExecution.metadata } } : node));
|
||||
setNodes((current) => current.map((node) => node.id === nodeId ? { ...node, metadata: { ...node.metadata, composerContent: cleanedPrompt, status: NODE_STATUS_LOADING, taskStage: "正在创建任务", taskProgress: 0, errorDetails: undefined, ...skillExecution.metadata } } : node));
|
||||
assertCurrent();
|
||||
// 输入框内容只是剧情,拆镜要求与镜头数、每镜时长都必须随契约一起发出去。
|
||||
const storyboardPrompt = withStoryboardOutputContract(skillExecution.prompt, { shotCount: requestedShotCount, shotDurationSeconds });
|
||||
@@ -183,7 +188,6 @@ export function useCanvasStoryboard({
|
||||
prompt: storyboardPrompt,
|
||||
canvasAssets: buildStoryboardAssetCatalog(nodesRef.current),
|
||||
requirements: "输出可直接编辑并用于批量生成图片和视频的分镜表。",
|
||||
projectStyle: storyboardContext.projectStyle,
|
||||
characters: storyboardContext.characters,
|
||||
shotDurationSeconds,
|
||||
shotCount: requestedShotCount,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import "@/lib/plugins/builtin/editor/editor-shell";
|
||||
import {
|
||||
AlertTriangle,
|
||||
Check,
|
||||
|
||||
@@ -9,6 +9,10 @@ export type AgentMediaSettings = {
|
||||
channelModelKey?: string;
|
||||
size: string;
|
||||
quality: string;
|
||||
durationSeconds?: number;
|
||||
videoGenerateAudio?: boolean;
|
||||
videoWatermark?: boolean;
|
||||
videoArkPrivateAssetUpload?: boolean;
|
||||
};
|
||||
export type AgentProfileScope = "user" | "project" | "canvas";
|
||||
|
||||
|
||||
@@ -127,8 +127,24 @@ export type ResourceAccessFetchOptions = {
|
||||
|
||||
type ResourceAccessBatchItem = { resourceId: string; access?: ResourceAccess; error?: { msg?: string } };
|
||||
type CachedResourceAccess = { value: ResourceAccess; expiresAt: number };
|
||||
type ResourceAccessRequestPayload = {
|
||||
resourceId: string;
|
||||
purpose: ResourceAccessPurpose;
|
||||
variant: ResourceAccessVariant;
|
||||
downloadName?: string;
|
||||
};
|
||||
type PendingResourceAccess = {
|
||||
key: string;
|
||||
scope: string;
|
||||
generation: number;
|
||||
payload: ResourceAccessRequestPayload;
|
||||
resolve: (value: ResourceAccess) => void;
|
||||
reject: (reason?: unknown) => void;
|
||||
};
|
||||
const accessCache = new Map<string, CachedResourceAccess>();
|
||||
const accessRequests = new Map<string, Promise<ResourceAccess>>();
|
||||
const pendingResourceAccess = new Map<string, PendingResourceAccess>();
|
||||
let resourceAccessBatchTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
const PERSISTED_DISPLAY_ACCESS_PREFIX = "resource-access-v1:";
|
||||
|
||||
function persistedDisplayAccessKey(resourceId: string, variant: ResourceAccessVariant) {
|
||||
@@ -184,6 +200,13 @@ let accessGeneration = 0;
|
||||
*/
|
||||
export function clearResourceAccessCache() {
|
||||
accessGeneration += 1;
|
||||
if (resourceAccessBatchTimer !== undefined) {
|
||||
clearTimeout(resourceAccessBatchTimer);
|
||||
resourceAccessBatchTimer = undefined;
|
||||
}
|
||||
const abortError = new DOMException("资源访问请求已因账号切换失效", "AbortError");
|
||||
pendingResourceAccess.forEach((entry) => entry.reject(abortError));
|
||||
pendingResourceAccess.clear();
|
||||
accessCache.clear();
|
||||
accessRequests.clear();
|
||||
resourceCache.clear();
|
||||
@@ -378,6 +401,78 @@ function assertResourceRequestCurrent(generation: number, scope: string) {
|
||||
}
|
||||
}
|
||||
|
||||
const RESOURCE_ACCESS_BATCH_LIMIT = 100;
|
||||
const RESOURCE_ACCESS_BATCH_DELAY_MS = 16;
|
||||
|
||||
function resourceAccessAbortError() {
|
||||
return new DOMException("资源访问请求已因账号切换失效", "AbortError");
|
||||
}
|
||||
|
||||
function scheduleResourceAccessBatch() {
|
||||
if (resourceAccessBatchTimer !== undefined) return;
|
||||
resourceAccessBatchTimer = setTimeout(() => {
|
||||
resourceAccessBatchTimer = undefined;
|
||||
void flushResourceAccessBatch();
|
||||
}, RESOURCE_ACCESS_BATCH_DELAY_MS);
|
||||
}
|
||||
|
||||
function enqueueResourceAccess(entry: Omit<PendingResourceAccess, "resolve" | "reject">) {
|
||||
return new Promise<ResourceAccess>((resolve, reject) => {
|
||||
pendingResourceAccess.set(entry.key, { ...entry, resolve, reject });
|
||||
scheduleResourceAccessBatch();
|
||||
});
|
||||
}
|
||||
|
||||
async function completeResourceAccessBatchEntry(entry: PendingResourceAccess, item: ResourceAccessBatchItem | undefined) {
|
||||
if (entry.generation !== accessGeneration || entry.scope !== getActiveUserScope()) throw resourceAccessAbortError();
|
||||
if (!item?.access?.url) throw new Error(item?.error?.msg || "后端未返回资源访问地址");
|
||||
const value = item.access;
|
||||
const cachedEntry = { value, expiresAt: resourceAccessCacheExpiry(value) } satisfies CachedResourceAccess;
|
||||
if (cachedEntry.expiresAt > Date.now()) {
|
||||
accessCache.set(entry.key, cachedEntry);
|
||||
if (entry.payload.purpose === "display") await persistDisplayAccess(entry.scope, entry.payload.resourceId, entry.payload.variant, cachedEntry);
|
||||
}
|
||||
if (entry.generation !== accessGeneration || entry.scope !== getActiveUserScope()) {
|
||||
accessCache.delete(entry.key);
|
||||
throw resourceAccessAbortError();
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
async function flushResourceAccessBatch() {
|
||||
const currentScope = getActiveUserScope();
|
||||
const currentGeneration = accessGeneration;
|
||||
const queuedEntries = Array.from(pendingResourceAccess.values());
|
||||
pendingResourceAccess.clear();
|
||||
const entries: PendingResourceAccess[] = [];
|
||||
for (const entry of queuedEntries) {
|
||||
if (entry.scope !== currentScope || entry.generation !== currentGeneration) entry.reject(resourceAccessAbortError());
|
||||
else if (entries.length < RESOURCE_ACCESS_BATCH_LIMIT) entries.push(entry);
|
||||
else pendingResourceAccess.set(entry.key, entry);
|
||||
}
|
||||
if (!entries.length) {
|
||||
if (pendingResourceAccess.size) scheduleResourceAccessBatch();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await http.post<{ items: ResourceAccessBatchItem[] }>("/resources/access", entries.map((entry) => entry.payload));
|
||||
await Promise.all(
|
||||
entries.map(async (entry, index) => {
|
||||
try {
|
||||
entry.resolve(await completeResourceAccessBatchEntry(entry, data.items?.[index]));
|
||||
} catch (error) {
|
||||
entry.reject(error);
|
||||
}
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
entries.forEach((entry) => entry.reject(error));
|
||||
} finally {
|
||||
if (pendingResourceAccess.size) scheduleResourceAccessBatch();
|
||||
}
|
||||
}
|
||||
|
||||
export async function getResourceAccess(storageKey: string | undefined, purpose: ResourceAccessPurpose = "display", variant: ResourceAccessVariant = "original", downloadName = "", fetchOptions: ResourceAccessFetchOptions = {}) {
|
||||
const id = resourceIdFromStorageKey(storageKey);
|
||||
if (!id) throw new Error("当前媒体尚未上传到后端资源存储");
|
||||
@@ -413,19 +508,12 @@ export async function getResourceAccess(storageKey: string | undefined, purpose:
|
||||
return persisted.value;
|
||||
}
|
||||
}
|
||||
const data = await http.post<{ items: ResourceAccessBatchItem[] }>("/resources/access", [{ resourceId: id, purpose, variant, ...(downloadName ? { downloadName } : {}) }]);
|
||||
if (generation !== accessGeneration || scope !== getActiveUserScope()) {
|
||||
throw new DOMException("资源访问请求已因账号切换失效", "AbortError");
|
||||
}
|
||||
const item = data.items?.[0];
|
||||
if (!item?.access?.url) throw new Error(item?.error?.msg || "后端未返回资源访问地址");
|
||||
const value = item.access;
|
||||
const entry = { value, expiresAt: resourceAccessCacheExpiry(value) } satisfies CachedResourceAccess;
|
||||
if (entry.expiresAt > Date.now()) {
|
||||
accessCache.set(key, entry);
|
||||
if (purpose === "display") await persistDisplayAccess(scope, id, variant, entry);
|
||||
}
|
||||
return value;
|
||||
return await enqueueResourceAccess({
|
||||
key,
|
||||
scope,
|
||||
generation,
|
||||
payload: { resourceId: id, purpose, variant, ...(downloadName ? { downloadName } : {}) },
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError) throw new Error(error.message || "获取对象存储地址失败");
|
||||
throw error;
|
||||
|
||||
@@ -50,11 +50,11 @@ function effectStorage(scope: string): EffectStorage {
|
||||
}
|
||||
|
||||
function effectLock(): AsyncLock {
|
||||
if (typeof window !== "undefined") {
|
||||
const locks = navigator.locks;
|
||||
if (!locks) throw new Error("当前浏览器不支持跨页面生成副作用互斥");
|
||||
return locks;
|
||||
}
|
||||
// if (typeof window !== "undefined") {
|
||||
// const locks = navigator.locks;
|
||||
// if (!locks) throw new Error("当前浏览器不支持跨页面生成副作用互斥");
|
||||
// return locks;
|
||||
// }
|
||||
return {
|
||||
async request<T>(name: string, callback: () => Promise<T>) {
|
||||
const prior = inProcessLockTails.get(name) ?? Promise.resolve();
|
||||
|
||||
11
web/src/styles/auth-input-fix.css
Normal file
11
web/src/styles/auth-input-fix.css
Normal file
@@ -0,0 +1,11 @@
|
||||
/* 必须留在 utilities 层内:原聚焦阴影同样位于该层且使用 !important,
|
||||
层外样式无法覆盖层内的 important 声明。 */
|
||||
@layer utilities {
|
||||
.auth-scene-card .ant-input-affix-wrapper > :is(input, textarea).ant-input:focus,
|
||||
.auth-scene-card .ant-input-affix-wrapper > :is(input, textarea).ant-input:focus-visible {
|
||||
border-color: transparent !important;
|
||||
outline: none !important;
|
||||
background: transparent !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user