feat(*): Agent工作流 - 完善审批、分镜协作与资源交互体验

This commit is contained in:
ddcat666
2026-10-11 22:23:11 +08:00
parent 435bc487cf
commit 298bee1fa9
38 changed files with 508 additions and 1263 deletions

View File

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

View File

@@ -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

View File

@@ -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>

View File

@@ -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>;
}

View File

@@ -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 />;