feat(*): Agent工作流 - 完善审批、分镜协作与资源交互体验
This commit is contained in:
@@ -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 />;
|
||||
|
||||
Reference in New Issue
Block a user