feat: add subject image agent workflow

This commit is contained in:
2026-05-20 12:51:02 +08:00
parent 3d198b024b
commit 35fc088375
5 changed files with 873 additions and 370 deletions

View File

@@ -4,7 +4,7 @@ import { type MouseEvent as ReactMouseEvent, type ReactNode, type RefObject, use
import { createPortal } from "react-dom"
import {
AlertTriangle, BookOpen, Check, ChevronDown, Circle, Film, FileText, Image as ImageIcon, Info, Link2, Loader2,
Mic, Moon, Package, PanelRight, Play, Plus, RefreshCw, Scissors, Sparkles, Sun, Trash2, Upload, Wand2,
MessageSquare, Mic, Moon, Package, PanelRight, Play, Plus, RefreshCw, Scissors, Send, Sparkles, Sun, Trash2, Upload, Wand2,
} from "lucide-react"
import { toast } from "sonner"
import {
@@ -29,10 +29,12 @@ import {
type StoryboardScene,
type SubjectAsset,
type SubjectImageModelPreference,
type SubjectModelBundle,
type SubjectProfilePreference,
type SubjectKind,
addElement,
analyzeJob,
analyzeSubjectAgent,
analyzeProductViews,
apiAssetUrl,
characterLibraryImageUrl,
@@ -59,6 +61,7 @@ import {
rewriteStoryboardScript,
saveSubjectTemplate,
saveProductRefs,
sendSubjectAgentMessage,
sourceAudioUrl,
subjectTemplateImageUrl,
updateElement,
@@ -271,12 +274,6 @@ const SUBJECT_VIEW_ORDER = [
const COMMON_SUBJECT_VIEW_VALUES = ["front", "three_quarter_left", "three_quarter_right", "bust_front"]
const RECONSTRUCTION_SUBJECT_VIEW_VALUES = ["front", "three_quarter_left", "left", "back", "right", "three_quarter_right"]
const RECONSTRUCTION_FRAME_LIMIT = 3
const EMPTY_RECONSTRUCTION_FRAME_MAP: Record<SubjectReconstructionMode, number[]> = {
realistic: [],
cartoon: [],
elements: [],
custom: [],
}
const DEFAULT_RECONSTRUCTION_DIRECTIONS: Record<SubjectReconstructionMode, string> = {
realistic: "",
cartoon: "",
@@ -294,9 +291,9 @@ const CARTOON_RECONSTRUCTION_STYLES: Array<{ value: CartoonReconstructionStyle;
const RECONSTRUCTION_MODES: Array<{ value: SubjectReconstructionMode; label: string; subtitle: string; placeholder: string }> = [
{
value: "realistic",
label: "真人重构",
subtitle: "参考非身份化人物特点,生成全新真人 6 视图",
placeholder: "如:更年轻、亚洲女性、运动感、不要像原人",
label: "形象锁定",
subtitle: "参考可见主体和服装,生成同一形象的多视图",
placeholder: "如:保持透明骨骼男孩、蓝色头带和短裤,人物更大",
},
{
value: "cartoon",
@@ -306,8 +303,8 @@ const RECONSTRUCTION_MODES: Array<{ value: SubjectReconstructionMode; label: str
},
{
value: "elements",
label: "元素重构",
subtitle: "参考姿态、色块和镜头语言,生成差异化主体",
label: "创意复刻",
subtitle: "参考姿态、色块和镜头语言,生成差异化主体",
placeholder: "如:保留运动气质,去掉原服装和原脸",
},
{
@@ -318,12 +315,10 @@ const RECONSTRUCTION_MODES: Array<{ value: SubjectReconstructionMode; label: str
},
]
const SUBJECT_IMAGE_MODEL_OPTIONS: Array<{ value: SubjectImageModelPreference; label: string; detail: string }> = [
{ value: "auto", label: "自动", detail: "GPT 失败才兜底" },
{ value: "gpt-image-2", label: "GPT", detail: "只用 gpt-image-2" },
{ value: "gemini-3-pro-image-preview", label: "Gemini", detail: "直接用 Gemini" },
const SUBJECT_MODEL_BUNDLE_OPTIONS: Array<{ value: SubjectModelBundle; label: string; detail: string }> = [
{ value: "gpt", label: "GPT 套件", detail: "GPT 对话 + gpt-image-2 生图" },
{ value: "gemini", label: "Gemini 套件", detail: "Gemini 对话 + Gemini 生图" },
]
const SUBJECT_MODEL_MEMORY_KEY = "skg:subject-image-model:v1"
const SUBJECT_PROMPT_MEMORY_KEY = "skg:subject-prompt-memory:v1"
const SUBJECT_PROMPT_MEMORY_LIMIT = 28
@@ -680,21 +675,6 @@ function saveSubjectPromptMemory(jobId: string, memory: Record<SubjectReconstruc
}
}
function loadSubjectImageModelPreference(jobId: string): SubjectImageModelPreference {
if (typeof window === "undefined") return "auto"
const raw = window.localStorage.getItem(subjectScopedStorageKey(SUBJECT_MODEL_MEMORY_KEY, jobId))
return SUBJECT_IMAGE_MODEL_OPTIONS.some((item) => item.value === raw) ? raw as SubjectImageModelPreference : "auto"
}
function saveSubjectImageModelPreference(jobId: string, value: SubjectImageModelPreference) {
if (typeof window === "undefined") return
try {
window.localStorage.setItem(subjectScopedStorageKey(SUBJECT_MODEL_MEMORY_KEY, jobId), value)
} catch {
/* localStorage may be unavailable */
}
}
function subjectPromptChipsFromText(text: string): string[] {
const normalized = text.replace(/[,。;;、\n]/g, ",").replace(/\s+/g, " ").trim()
const rawParts = normalized.split(",").map((item) => item.trim()).filter(Boolean)
@@ -717,13 +697,6 @@ function mergeSubjectPromptMemory(current: string[], text: string) {
return [...chips, ...current.filter((item) => !chips.includes(item))].slice(0, SUBJECT_PROMPT_MEMORY_LIMIT)
}
function appendSubjectPromptChip(text: string, chip: string) {
const trimmed = text.trim()
if (!trimmed) return chip
if (trimmed.includes(chip)) return trimmed
return `${trimmed}${chip}`
}
function formatSeconds(raw?: number) {
if (!raw || Number.isNaN(raw)) return "0.0s"
return `${raw.toFixed(1)}s`
@@ -1100,6 +1073,32 @@ function reconstructionModeConfig(mode: SubjectReconstructionMode) {
return RECONSTRUCTION_MODES.find((item) => item.value === mode) ?? RECONSTRUCTION_MODES[0]
}
function subjectModelBundleConfig(bundle: SubjectModelBundle) {
return SUBJECT_MODEL_BUNDLE_OPTIONS.find((item) => item.value === bundle) ?? SUBJECT_MODEL_BUNDLE_OPTIONS[0]
}
function subjectImageModelFromBundle(bundle: SubjectModelBundle): SubjectImageModelPreference {
return bundle === "gemini" ? "gemini-3-pro-image-preview" : "gpt-image-2"
}
function subjectViewsForQuantity(quantity: number) {
const count = Math.max(1, Math.min(10, Math.round(quantity || 6)))
const views = [
"front",
"three_quarter_left",
"left",
"back",
"right",
"three_quarter_right",
"bust_front",
"bust_left_45",
"bust_right_45",
"back_neck_detail",
]
if (count <= 4) return ["front", "three_quarter_left", "back", "three_quarter_right"].slice(0, count)
return views.slice(0, count)
}
function cartoonStyleConfig(style: CartoonReconstructionStyle) {
return CARTOON_RECONSTRUCTION_STYLES.find((item) => item.value === style) ?? CARTOON_RECONSTRUCTION_STYLES[0]
}
@@ -1129,13 +1128,14 @@ function buildReconstructionDirection(
mode: SubjectReconstructionMode,
direction: string,
cartoonStyle: CartoonReconstructionStyle,
viewCount = RECONSTRUCTION_SUBJECT_VIEW_VALUES.length,
) {
const trimmed = direction.trim()
const style = cartoonStyleConfig(cartoonStyle)
const common = [
"Legal-safe reference reconstruction: use selected reference frames only as non-identifying creative evidence.",
"Do not copy the original person, face, biometric identity, unique likeness, watermark, platform UI, captions, exact outfit, exact background, exact composition, or source pixels.",
`Generate exactly ${RECONSTRUCTION_SUBJECT_VIEW_VALUES.length} separate views of one newly designed subject.`,
`Generate exactly ${viewCount} separate views of one newly designed subject.`,
"Keep the neck, collarbone, shoulders, upper back, and side neck clean and usable for SKG neck-and-shoulder product placement.",
]
if (mode === "realistic") {
@@ -3306,10 +3306,15 @@ function SourceSubjectPipeline({
onDropFilmstripFrame?: (time: number) => void
}) {
const [referenceDropActive, setReferenceDropActive] = useState(false)
const [activeDropMode, setActiveDropMode] = useState<SubjectReconstructionMode | null>(null)
const [conversionFrameIndicesByMode, setConversionFrameIndicesByMode] = useState<Record<SubjectReconstructionMode, number[]>>(() => ({ ...EMPTY_RECONSTRUCTION_FRAME_MAP }))
const [agentDropActive, setAgentDropActive] = useState(false)
const [reconstructionDirections, setReconstructionDirections] = useState<Record<SubjectReconstructionMode, string>>(() => ({ ...DEFAULT_RECONSTRUCTION_DIRECTIONS }))
const [subjectImageModelPreference, setSubjectImageModelPreference] = useState<SubjectImageModelPreference>(() => loadSubjectImageModelPreference(job.id))
const [subjectModelBundle, setSubjectModelBundle] = useState<SubjectModelBundle>(() => job.subject_agent?.model_bundle ?? "gpt")
const [agentReferenceFrameIndices, setAgentReferenceFrameIndices] = useState<number[]>(() => job.subject_agent?.source_frame_indices ?? [])
const [agentMode, setAgentMode] = useState<SubjectReconstructionMode>(() => job.subject_agent?.selected_mode ?? "custom")
const [agentQuantity, setAgentQuantity] = useState(() => job.subject_agent?.quantity ?? 6)
const [agentRequirement, setAgentRequirement] = useState(() => job.subject_agent?.requirements_zh ?? "")
const [agentInput, setAgentInput] = useState("")
const [subjectAgentBusy, setSubjectAgentBusy] = useState<"analyze" | "message" | null>(null)
const [promptMemoryByMode, setPromptMemoryByMode] = useState<Record<SubjectReconstructionMode, string[]>>(() => loadSubjectPromptMemory(job.id))
const [cartoonStyle, setCartoonStyle] = useState<CartoonReconstructionStyle>("3d_animation")
const [cartoonStyleOpen, setCartoonStyleOpen] = useState(false)
@@ -3318,19 +3323,16 @@ function SourceSubjectPipeline({
const [expandedSubjectPackKey, setExpandedSubjectPackKey] = useState<string | null>(null)
const [lastSubjectProfile, setLastSubjectProfile] = useState<ResolvedSubjectProfile | null>(null)
const subjectBusy = !!subjectBusyFor
const selectedSubjectViews = RECONSTRUCTION_SUBJECT_VIEW_VALUES
const conversionFramesByMode = useMemo(() => {
const next = {} as Record<SubjectReconstructionMode, KeyFrame[]>
for (const config of RECONSTRUCTION_MODES) {
next[config.value] = conversionFrameIndicesByMode[config.value]
.map((index) => frames.find((frame) => frame.index === index))
.filter((frame): frame is KeyFrame => !!frame)
}
return next
}, [conversionFrameIndicesByMode, frames])
const selectedSubjectViews = useMemo(() => subjectViewsForQuantity(agentQuantity), [agentQuantity])
const allConversionFrameIndices = useMemo(
() => new Set(Object.values(conversionFrameIndicesByMode).flat()),
[conversionFrameIndicesByMode],
() => new Set(agentReferenceFrameIndices),
[agentReferenceFrameIndices],
)
const agentReferenceFrames = useMemo(
() => agentReferenceFrameIndices
.map((index) => frames.find((frame) => frame.index === index))
.filter((frame): frame is KeyFrame => !!frame),
[agentReferenceFrameIndices, frames],
)
const actorSources = useMemo(() => {
const items: Array<{ frame: KeyFrame; element: KeyElement; mode: SubjectReconstructionMode }> = []
@@ -3403,21 +3405,30 @@ function SourceSubjectPipeline({
}, [subjectAssetPacks])
useEffect(() => {
setConversionFrameIndicesByMode({ ...EMPTY_RECONSTRUCTION_FRAME_MAP })
setReconstructionDirections({ ...DEFAULT_RECONSTRUCTION_DIRECTIONS })
setSubjectImageModelPreference(loadSubjectImageModelPreference(job.id))
setSubjectModelBundle(job.subject_agent?.model_bundle ?? "gpt")
setAgentReferenceFrameIndices(job.subject_agent?.source_frame_indices ?? [])
setAgentMode(job.subject_agent?.selected_mode ?? "custom")
setAgentQuantity(job.subject_agent?.quantity ?? 6)
setAgentRequirement(job.subject_agent?.requirements_zh ?? "")
setAgentInput("")
setSubjectAgentBusy(null)
setPromptMemoryByMode(loadSubjectPromptMemory(job.id))
setLastSubjectProfile(null)
setSubjectBusyFor(null)
setSubjectAssetBusy(null)
setActiveDropMode(null)
setCartoonStyleOpen(false)
setExpandedSubjectPackKey(null)
}, [job.id])
useEffect(() => {
saveSubjectImageModelPreference(job.id, subjectImageModelPreference)
}, [job.id, subjectImageModelPreference])
const agent = job.subject_agent
setSubjectModelBundle(agent?.model_bundle ?? "gpt")
setAgentReferenceFrameIndices(agent?.source_frame_indices ?? [])
setAgentMode(agent?.selected_mode ?? "custom")
setAgentQuantity(agent?.quantity ?? 6)
setAgentRequirement(agent?.requirements_zh ?? "")
}, [job.id, job.subject_agent?.updated_at])
useEffect(() => {
saveSubjectPromptMemory(job.id, promptMemoryByMode)
@@ -3430,13 +3441,7 @@ function SourceSubjectPipeline({
}, [expandedSubjectPackKey, subjectAssetPacks])
useEffect(() => {
setConversionFrameIndicesByMode((current) => {
const next = {} as Record<SubjectReconstructionMode, number[]>
for (const config of RECONSTRUCTION_MODES) {
next[config.value] = current[config.value].filter((index) => frames.some((frame) => frame.index === index))
}
return next
})
setAgentReferenceFrameIndices((current) => current.filter((index) => frames.some((frame) => frame.index === index)))
}, [frames])
const buildSubjectProfileForRequest = () => {
@@ -3452,22 +3457,9 @@ function SourceSubjectPipeline({
}))
}
const applyPromptChip = (mode: SubjectReconstructionMode, chip: string) => {
setReconstructionDirections((current) => ({
...current,
[mode]: appendSubjectPromptChip(current[mode], chip),
}))
setPromptMemoryByMode((current) => ({
...current,
[mode]: [chip, ...(current[mode] || []).filter((item) => item !== chip)].slice(0, SUBJECT_PROMPT_MEMORY_LIMIT),
}))
}
const subjectModelLabel = (value: SubjectModelBundle) => subjectModelBundleConfig(value).label
const subjectModelLabel = (value: SubjectImageModelPreference) => {
return SUBJECT_IMAGE_MODEL_OPTIONS.find((item) => item.value === value)?.label ?? "自动"
}
const generateSubjectPack = async (mode: SubjectReconstructionMode, sourceIndices = conversionFrameIndicesByMode[mode]) => {
const generateSubjectPack = async (mode: SubjectReconstructionMode, sourceIndices = agentReferenceFrameIndices) => {
if (subjectBusyFor) {
toast.warning("主体套图正在生成中,完成后再重生。")
return
@@ -3479,8 +3471,9 @@ function SourceSubjectPipeline({
const sourceFrames = sourceIndices
.map((index) => frames.find((frame) => frame.index === index))
.filter((frame): frame is KeyFrame => !!frame)
const rawDirection = reconstructionDirections[mode].trim()
const sourceLockedReplication = mode === "custom" && !rawDirection
const agentPrompt = (job.subject_agent?.generation_prompt_en || agentRequirement || "").trim()
const rawDirection = (agentPrompt || reconstructionDirections[mode]).trim()
const sourceLockedReplication = mode === "realistic" || (mode === "custom" && !rawDirection)
if (!sourceFrames.length && mode !== "custom") {
toast.warning(`先把参考帧拖到${reconstructionModeConfig(mode).label}`)
return
@@ -3499,8 +3492,8 @@ function SourceSubjectPipeline({
? null
: buildSubjectProfileForRequest()
const subjectStyle = reconstructionSubjectStyle(mode)
const userDirection = buildReconstructionDirection(mode, reconstructionDirections[mode], cartoonStyle)
rememberPromptForMode(mode, reconstructionDirections[mode])
const userDirection = buildReconstructionDirection(mode, rawDirection, cartoonStyle, selectedSubjectViews.length)
rememberPromptForMode(mode, rawDirection)
const modeName = reconstructionElementName(mode)
setSubjectBusyFor({
jobId: requestJobId,
@@ -3509,7 +3502,7 @@ function SourceSubjectPipeline({
viewCount: selectedSubjectViews.length,
sourceCount: sourceFrames.length,
profileLabel: requestProfile?.summary ?? "按自主描述",
modelLabel: subjectModelLabel(subjectImageModelPreference),
modelLabel: subjectModelLabel(subjectModelBundle),
})
try {
let workingJob = job
@@ -3539,9 +3532,9 @@ function SourceSubjectPipeline({
views: selectedSubjectViews,
subject_profile: requestProfile?.payload ?? null,
prompt: sourceLockedReplication
? buildSourceLockedSubjectPrompt(subjectStyle)
? `${buildSourceLockedSubjectPrompt(subjectStyle)} ${userDirection}`
: buildSimilarSubjectPrompt(subjectStyle, userDirection, null, requestProfile),
image_model_preference: subjectImageModelPreference,
image_model_preference: subjectImageModelFromBundle(subjectModelBundle),
replace_views: false,
pack_label: `${reconstructionModeConfig(mode).label} ${new Date().toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit", hour12: false })}`,
pack_mode: mode,
@@ -3564,39 +3557,14 @@ function SourceSubjectPipeline({
}
}
const addConversionFrame = (mode: SubjectReconstructionMode, frame: KeyFrame) => {
const current = conversionFrameIndicesByMode[mode]
const existed = current.includes(frame.index)
const next = existed
? current
: current.length >= RECONSTRUCTION_FRAME_LIMIT
? [...current.slice(1), frame.index]
: [...current, frame.index]
setConversionFrameIndicesByMode((state) => ({ ...state, [mode]: next }))
if (existed) {
toast.info(`这张参考帧已经在${reconstructionModeConfig(mode).label}里。`)
return
}
if (current.length >= RECONSTRUCTION_FRAME_LIMIT) {
toast.warning(`${reconstructionModeConfig(mode).label}最多保留 ${RECONSTRUCTION_FRAME_LIMIT} 张参考帧,已替换为最近拖入的组合。`)
}
toast.info(`已加入${reconstructionModeConfig(mode).label}${frame.timestamp.toFixed(1)}s。放好参考后点击生成 6 视图。`)
}
const removeConversionFrame = (mode: SubjectReconstructionMode, frameIndex: number) => {
setConversionFrameIndicesByMode((state) => ({
...state,
[mode]: state[mode].filter((index) => index !== frameIndex),
}))
}
const regenerateSubjectAsset = async (item: { frame: KeyFrame; element: KeyElement; mode: SubjectReconstructionMode; asset: SubjectAsset }) => {
const { frame, element, mode, asset } = item
const sourceIndices = asset.source_frame_indices?.length
? asset.source_frame_indices
: conversionFrameIndicesByMode[mode]
const rawDirection = reconstructionDirections[mode].trim()
const sourceLockedReplication = mode === "custom" && !rawDirection
: agentReferenceFrameIndices
const agentPrompt = (job.subject_agent?.generation_prompt_en || agentRequirement || "").trim()
const rawDirection = (agentPrompt || reconstructionDirections[mode]).trim()
const sourceLockedReplication = mode === "realistic" || (mode === "custom" && !rawDirection)
if (!sourceIndices.length && mode !== "custom") {
toast.warning("转换层没有参考帧,不能重生。")
return
@@ -3611,7 +3579,7 @@ function SourceSubjectPipeline({
? null
: lastSubjectProfile ?? buildSubjectProfileForRequest()
const subjectStyle = reconstructionSubjectStyle(mode)
rememberPromptForMode(mode, reconstructionDirections[mode])
rememberPromptForMode(mode, rawDirection)
const updated = await generateSubjectAssets(job.id, frame.index, element.id, {
subject_kind: "living",
subject_style: subjectStyle,
@@ -3622,14 +3590,14 @@ function SourceSubjectPipeline({
views: [asset.view],
subject_profile: requestProfile?.payload ?? null,
prompt: sourceLockedReplication
? buildSourceLockedSubjectPrompt(subjectStyle)
? `${buildSourceLockedSubjectPrompt(subjectStyle)} ${buildReconstructionDirection(mode, rawDirection, cartoonStyle, 1)}`
: buildSimilarSubjectPrompt(
subjectStyle,
buildReconstructionDirection(mode, reconstructionDirections[mode], cartoonStyle),
buildReconstructionDirection(mode, rawDirection, cartoonStyle, 1),
null,
requestProfile,
),
image_model_preference: subjectImageModelPreference,
image_model_preference: subjectImageModelFromBundle(subjectModelBundle),
replace_views: true,
pack_id: asset.pack_id ?? "",
pack_label: asset.pack_label ?? "",
@@ -3659,6 +3627,98 @@ function SourceSubjectPipeline({
}
}
const addAgentReferenceFrame = (frame: KeyFrame) => {
setAgentReferenceFrameIndices((current) => {
if (current.includes(frame.index)) {
toast.info("这张参考帧已经在转换层里。")
return current
}
const next = current.length >= RECONSTRUCTION_FRAME_LIMIT ? [...current.slice(1), frame.index] : [...current, frame.index]
if (current.length >= RECONSTRUCTION_FRAME_LIMIT) {
toast.warning(`最多保留 ${RECONSTRUCTION_FRAME_LIMIT} 张参考帧,已替换为最近拖入的组合。`)
} else {
toast.info(`已加入转换层:${frame.timestamp.toFixed(1)}s。`)
}
return next
})
}
const removeAgentReferenceFrame = (frameIndex: number) => {
setAgentReferenceFrameIndices((current) => current.filter((index) => index !== frameIndex))
}
const runSubjectAgentAnalyze = async () => {
if (!agentReferenceFrameIndices.length) {
toast.warning("先从左侧拖入 1-3 张参考帧,再开始分析。")
return
}
setSubjectAgentBusy("analyze")
try {
const updated = await analyzeSubjectAgent(job.id, {
model_bundle: subjectModelBundle,
source_frame_indices: agentReferenceFrameIndices,
})
onJobUpdate(updated)
toast.success("转换层分析完成")
} catch (e) {
toast.error("转换层分析失败:" + (e instanceof Error ? e.message : String(e)))
} finally {
setSubjectAgentBusy(null)
}
}
const sendSubjectAgentRequirement = async (message = agentInput) => {
const text = message.trim()
if (!text && !agentRequirement.trim()) {
toast.warning("先写一句要怎么生成,或者点快捷选项。")
return
}
setSubjectAgentBusy("message")
try {
const updated = await sendSubjectAgentMessage(job.id, {
model_bundle: subjectModelBundle,
source_frame_indices: agentReferenceFrameIndices,
selected_mode: agentMode,
selected_traits: job.subject_agent?.selected_traits ?? [],
requirements_zh: agentRequirement,
message: text,
quantity: agentQuantity,
})
onJobUpdate(updated)
setAgentInput("")
} catch (e) {
toast.error("生图要求更新失败:" + (e instanceof Error ? e.message : String(e)))
} finally {
setSubjectAgentBusy(null)
}
}
const toggleSubjectAgentTrait = (trait: string) => {
const selected = job.subject_agent?.selected_traits ?? []
const next = selected.includes(trait) ? selected.filter((item) => item !== trait) : [...selected, trait].slice(0, 24)
void sendSubjectAgentMessage(job.id, {
model_bundle: subjectModelBundle,
source_frame_indices: agentReferenceFrameIndices,
selected_mode: agentMode,
selected_traits: next,
requirements_zh: agentRequirement,
message: next.includes(trait) ? `保留或强调:${trait}` : `不再强制:${trait}`,
quantity: agentQuantity,
}).then(onJobUpdate).catch((e) => {
toast.error("特征选择失败:" + (e instanceof Error ? e.message : String(e)))
})
}
const subjectAgent = job.subject_agent
const agentAnalysis = subjectAgent?.analysis ?? null
const agentMessages = subjectAgent?.messages ?? []
const agentTraits = agentAnalysis?.trait_chips ?? []
const selectedAgentTraits = subjectAgent?.selected_traits ?? []
const canGenerateAgentPack = agentMode === "custom"
? Boolean(agentRequirement.trim() || agentReferenceFrames.length)
: agentReferenceFrames.length > 0
const agentModeRunning = runningActorModes.has(agentMode)
return (
<div className="grid gap-2 xl:grid-cols-[150px_minmax(210px,0.75fr)_minmax(0,1.25fr)] 2xl:grid-cols-[170px_minmax(240px,0.8fr)_minmax(0,1.3fr)]">
<div className="min-w-0">
@@ -3759,188 +3819,258 @@ function SourceSubjectPipeline({
<div className="min-w-0">
<div className="mb-2 flex items-center justify-between gap-2">
<SectionTitle icon={<Wand2 className="h-4 w-4" />} title="转换层" />
<ModelTrace trace={similarSubjectModelTrace(runtimeModels, subjectBusyFor?.mode === "cartoon" ? "cartoon_subject" : "source_actor")} compact />
<span className="rounded-full border border-cyan-200/20 bg-cyan-300/[0.08] px-2 py-0.5 text-[9.5px] text-cyan-50/70">
{subjectModelBundleConfig(subjectModelBundle).detail}
</span>
</div>
<div className="max-h-[520px] min-h-[410px] overflow-y-auto rounded-md border border-white/10 bg-black/32 p-2 2xl:max-h-[600px] 2xl:min-h-[500px]">
<div className="mb-2 rounded-md border border-white/10 bg-black/24 p-1.5">
<div
className={`max-h-[520px] min-h-[410px] overflow-y-auto rounded-md border p-2 transition 2xl:max-h-[600px] 2xl:min-h-[500px] ${
agentDropActive ? "border-[#d6b36a]/80 bg-[#d6b36a]/10 ring-1 ring-[#d6b36a]/45" : "border-white/10 bg-black/32"
}`}
onDragEnter={(event) => {
if (!Array.from(event.dataTransfer.types).includes(SOURCE_KEYFRAME_DRAG_TYPE)) return
event.preventDefault()
setAgentDropActive(true)
}}
onDragOver={(event) => {
if (!Array.from(event.dataTransfer.types).includes(SOURCE_KEYFRAME_DRAG_TYPE)) return
event.preventDefault()
event.dataTransfer.dropEffect = "copy"
}}
onDragLeave={(event) => {
const next = event.relatedTarget as Node | null
if (next && event.currentTarget.contains(next)) return
setAgentDropActive(false)
}}
onDrop={(event) => {
event.preventDefault()
setAgentDropActive(false)
const frameIndex = Number(event.dataTransfer.getData(SOURCE_KEYFRAME_DRAG_TYPE))
const frame = frames.find((item) => item.index === frameIndex)
if (frame) addAgentReferenceFrame(frame)
}}
>
<div className="grid grid-cols-2 gap-1">
{SUBJECT_MODEL_BUNDLE_OPTIONS.map((option) => (
<button
key={option.value}
type="button"
onClick={() => setSubjectModelBundle(option.value)}
className={`min-h-9 rounded-md border px-2 py-1 text-left transition ${
subjectModelBundle === option.value
? "border-[#d6b36a]/70 bg-[#d6b36a]/16 text-white"
: "border-white/10 bg-black/28 text-white/50 hover:border-white/22 hover:text-white"
}`}
title={option.detail}
>
<span className="block text-[10.5px] font-semibold">{option.label}</span>
<span className="mt-0.5 block truncate text-[8.5px] text-white/36">{option.detail}</span>
</button>
))}
</div>
<div className="mt-2 rounded-md border border-white/10 bg-black/24 p-2">
<div className="mb-1.5 flex items-center justify-between gap-2">
<span className="text-[10px] font-semibold text-white/62"></span>
<span className="text-[9px] text-white/34"></span>
<span className="text-[10px] font-semibold text-white/62"></span>
<span className="rounded-full border border-white/10 bg-black/35 px-1.5 py-0.5 font-mono text-[9px] text-white/42">
{agentReferenceFrames.length}/{RECONSTRUCTION_FRAME_LIMIT}
</span>
</div>
<div className="grid grid-cols-3 gap-1">
{SUBJECT_IMAGE_MODEL_OPTIONS.map((option) => (
<div className="flex min-h-[64px] items-center gap-1.5 overflow-x-auto pb-0.5">
{agentReferenceFrames.map((frame, index) => (
<div key={frame.index} className="relative shrink-0">
<MediaAssetTile
src={effectiveFrameUrl(job.id, frame)}
alt={`转换层参考 ${index + 1}`}
label={String(index + 1).padStart(2, "0")}
meta={`${frame.timestamp.toFixed(1)}s`}
className="aspect-[9/16] w-[42px] 2xl:w-[46px]"
objectFit="contain"
disablePreview
topLeft={<span className="rounded bg-black/72 px-0.5 font-mono text-[8px] text-white/70">{String(index + 1).padStart(2, "0")}</span>}
/>
<button
type="button"
onClick={() => removeAgentReferenceFrame(frame.index)}
className="absolute -right-1 -top-1 z-20 inline-flex h-4 w-4 items-center justify-center rounded-full border border-rose-100/35 bg-black/82 text-rose-100 transition hover:border-rose-100/70 hover:bg-rose-500/25"
aria-label="移出转换层参考"
title="移出转换层参考"
>
<Trash2 className="h-2.5 w-2.5" />
</button>
</div>
))}
{!agentReferenceFrames.length ? (
<div className="flex min-h-[54px] flex-1 items-center justify-center rounded border border-dashed border-white/12 px-2 text-center text-[10px] leading-snug text-white/34">
</div>
) : null}
</div>
<button
type="button"
onClick={() => void runSubjectAgentAnalyze()}
disabled={subjectAgentBusy === "analyze" || !agentReferenceFrames.length}
className="mt-2 inline-flex h-8 w-full items-center justify-center gap-1 rounded-md border border-cyan-200/25 bg-cyan-300/[0.08] px-3 text-[10.5px] font-semibold text-cyan-50/78 transition hover:border-cyan-200/45 hover:bg-cyan-300/[0.12] disabled:cursor-not-allowed disabled:opacity-40"
>
{subjectAgentBusy === "analyze" ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <MessageSquare className="h-3.5 w-3.5" />}
</button>
</div>
<div className="mt-2 grid grid-cols-2 gap-1">
{RECONSTRUCTION_MODES.map((modeConfig) => (
<button
key={modeConfig.value}
type="button"
onClick={() => setAgentMode(modeConfig.value)}
className={`rounded-md border px-2 py-1.5 text-left transition ${
agentMode === modeConfig.value
? "border-[#d6b36a]/70 bg-[#d6b36a]/14 text-white"
: "border-white/10 bg-black/24 text-white/48 hover:border-white/22 hover:text-white"
}`}
title={modeConfig.subtitle}
>
<span className="block text-[10.5px] font-semibold">{modeConfig.label}</span>
<span className="mt-0.5 block truncate text-[8.5px] text-white/34">{modeConfig.subtitle}</span>
</button>
))}
</div>
{agentMode === "cartoon" ? (
<div className="mt-2 rounded-md border border-white/10 bg-black/24 p-1.5">
<button
type="button"
onClick={() => setCartoonStyleOpen((open) => !open)}
className="inline-flex h-7 w-full items-center justify-between rounded-md border border-white/10 bg-black/28 px-2 text-[10px] font-semibold text-white/58 transition hover:border-white/20 hover:text-white"
>
<span>{cartoonStyleConfig(cartoonStyle).label}</span>
<ChevronDown className={`h-3.5 w-3.5 transition ${cartoonStyleOpen ? "rotate-180" : ""}`} />
</button>
{cartoonStyleOpen ? (
<div className="mt-1 grid grid-cols-2 gap-1">
{CARTOON_RECONSTRUCTION_STYLES.map((style) => (
<button
key={style.value}
type="button"
onClick={() => {
setCartoonStyle(style.value)
setCartoonStyleOpen(false)
}}
className={`h-7 rounded-md border px-1.5 text-[9.5px] font-semibold transition ${
cartoonStyle === style.value ? "border-white bg-white text-black" : "border-white/10 bg-black/24 text-white/45 hover:text-white"
}`}
>
{style.label}
</button>
))}
</div>
) : null}
</div>
) : null}
<div className="mt-2 flex items-center justify-between gap-2 rounded-md border border-white/10 bg-black/24 px-2 py-1.5">
<span className="text-[10px] font-semibold text-white/58"></span>
<div className="flex gap-1">
{[4, 6, 8, 10].map((count) => (
<button
key={option.value}
key={count}
type="button"
onClick={() => setSubjectImageModelPreference(option.value)}
className={`min-h-9 rounded-md border px-1.5 py-1 text-left transition ${
subjectImageModelPreference === option.value
? "border-[#d6b36a]/70 bg-[#d6b36a]/16 text-white"
: "border-white/10 bg-black/28 text-white/48 hover:border-white/22 hover:text-white"
onClick={() => setAgentQuantity(count)}
className={`h-6 rounded-full border px-2 text-[9.5px] font-semibold transition ${
agentQuantity === count ? "border-white bg-white text-black" : "border-white/10 bg-black/24 text-white/45 hover:text-white"
}`}
title={option.detail}
>
<span className="block text-[10px] font-semibold">{option.label}</span>
<span className="mt-0.5 block truncate text-[8.5px] text-white/36">{option.detail}</span>
{count}
</button>
))}
</div>
</div>
<div className="mb-2 rounded-md border border-[#d6b36a]/18 bg-[#d6b36a]/[0.06] px-2.5 py-2 text-[10px] leading-snug text-white/62">
1-3
</div>
<div className="space-y-2">
{RECONSTRUCTION_MODES.map((modeConfig) => {
const mode = modeConfig.value
const modeFrames = conversionFramesByMode[mode]
const promptChips = [...subjectPromptChipsFromText(reconstructionDirections[mode]), ...(promptMemoryByMode[mode] || [])]
.filter((chip, index, list) => chip && list.indexOf(chip) === index)
.slice(0, 10)
const dropActive = activeDropMode === mode
const canGenerate = mode === "custom"
? Boolean(reconstructionDirections.custom.trim() || modeFrames.length)
: modeFrames.length > 0
const modeRunning = runningActorModes.has(mode)
return (
<div
key={mode}
className={`rounded-md border p-2 transition ${
dropActive ? "border-[#d6b36a]/80 bg-[#d6b36a]/12 ring-1 ring-[#d6b36a]/45" : "border-white/10 bg-black/24"
}`}
onDragEnter={(event) => {
if (!Array.from(event.dataTransfer.types).includes(SOURCE_KEYFRAME_DRAG_TYPE)) return
event.preventDefault()
setActiveDropMode(mode)
}}
onDragOver={(event) => {
if (!Array.from(event.dataTransfer.types).includes(SOURCE_KEYFRAME_DRAG_TYPE)) return
event.preventDefault()
event.dataTransfer.dropEffect = "copy"
}}
onDragLeave={(event) => {
const next = event.relatedTarget as Node | null
if (next && event.currentTarget.contains(next)) return
setActiveDropMode(null)
}}
onDrop={(event) => {
event.preventDefault()
setActiveDropMode(null)
const frameIndex = Number(event.dataTransfer.getData(SOURCE_KEYFRAME_DRAG_TYPE))
const frame = frames.find((item) => item.index === frameIndex)
if (frame) addConversionFrame(mode, frame)
}}
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<div className="text-[11px] font-semibold text-white">{modeConfig.label}</div>
<div className="mt-0.5 text-[9.5px] leading-snug text-white/42">{modeConfig.subtitle}</div>
</div>
<span className="shrink-0 rounded-full border border-white/10 bg-black/35 px-1.5 py-0.5 font-mono text-[9px] text-white/42">
{modeFrames.length}/{RECONSTRUCTION_FRAME_LIMIT}
</span>
</div>
<div className="mt-2 flex min-h-[56px] items-center gap-1 overflow-x-auto pb-0.5">
{modeFrames.map((frame, index) => (
<div key={frame.index} className="relative shrink-0">
<MediaAssetTile
src={effectiveFrameUrl(job.id, frame)}
alt={`${modeConfig.label}参考 ${index + 1}`}
label={String(index + 1).padStart(2, "0")}
meta={`${frame.timestamp.toFixed(1)}s`}
className="aspect-[9/16] w-[34px] 2xl:w-[38px]"
objectFit="contain"
disablePreview
topLeft={<span className="rounded bg-black/72 px-0.5 font-mono text-[8px] text-white/70">{String(index + 1).padStart(2, "0")}</span>}
/>
<button
type="button"
onClick={() => removeConversionFrame(mode, frame.index)}
className="absolute -right-1 -top-1 z-20 inline-flex h-4 w-4 items-center justify-center rounded-full border border-rose-100/35 bg-black/82 text-rose-100 transition hover:border-rose-100/70 hover:bg-rose-500/25"
aria-label="移出转换层参考"
title="移出转换层参考"
>
<Trash2 className="h-2.5 w-2.5" />
</button>
</div>
))}
{!modeFrames.length ? (
<div className="flex min-h-[48px] flex-1 items-center justify-center rounded border border-dashed border-white/12 px-2 text-center text-[10px] leading-snug text-white/34">
{mode === "custom" ? "可只写描述,也可拖入参考。" : "把参考帧拖到这里。"}
</div>
) : null}
</div>
{mode === "cartoon" ? (
<div className="mt-2">
<button
type="button"
onClick={() => setCartoonStyleOpen((open) => !open)}
className="inline-flex h-7 w-full items-center justify-between rounded-md border border-white/10 bg-black/28 px-2 text-[10px] font-semibold text-white/58 transition hover:border-white/20 hover:text-white"
>
<span>{cartoonStyleConfig(cartoonStyle).label}</span>
<ChevronDown className={`h-3.5 w-3.5 transition ${cartoonStyleOpen ? "rotate-180" : ""}`} />
</button>
{cartoonStyleOpen ? (
<div className="mt-1 grid grid-cols-2 gap-1">
{CARTOON_RECONSTRUCTION_STYLES.map((style) => (
<button
key={style.value}
type="button"
onClick={() => {
setCartoonStyle(style.value)
setCartoonStyleOpen(false)
}}
className={`h-7 rounded-md border px-1.5 text-[9.5px] font-semibold transition ${
cartoonStyle === style.value ? "border-white bg-white text-black" : "border-white/10 bg-black/24 text-white/45 hover:text-white"
}`}
>
{style.label}
</button>
))}
</div>
) : null}
</div>
) : null}
<textarea
value={reconstructionDirections[mode]}
onChange={(event) => setReconstructionDirections((current) => ({ ...current, [mode]: event.target.value }))}
onBlur={(event) => rememberPromptForMode(mode, event.target.value)}
placeholder={modeConfig.placeholder}
rows={2}
className="mt-2 min-h-[48px] w-full resize-none rounded-md border border-white/10 bg-black/35 px-2.5 py-2 text-[10.5px] leading-snug text-white outline-none placeholder:text-white/28 focus:border-cyan-300/50"
/>
{promptChips.length ? (
<div className="mt-1.5 flex flex-wrap gap-1">
{promptChips.map((chip) => (
<button
key={chip}
type="button"
onClick={() => applyPromptChip(mode, chip)}
className="h-6 rounded-full border border-white/10 bg-black/28 px-2 text-[9.5px] text-white/52 transition hover:border-[#d6b36a]/50 hover:bg-[#d6b36a]/12 hover:text-white"
title="点击加入提示词"
>
{chip}
</button>
))}
</div>
) : null}
{agentAnalysis ? (
<div className="mt-2 rounded-md border border-cyan-200/18 bg-cyan-300/[0.055] p-2 text-[10px] leading-snug text-cyan-50/72">
<div className="mb-1 font-semibold text-cyan-50">AI </div>
<div>{agentAnalysis.summary_zh}</div>
{agentAnalysis.questions.length ? (
<div className="mt-1.5 text-cyan-50/56">{agentAnalysis.questions[0]}</div>
) : null}
</div>
) : (
<div className="mt-2 rounded-md border border-white/10 bg-black/24 px-2.5 py-2 text-[10px] leading-snug text-white/42">
/
</div>
)}
{agentTraits.length ? (
<div className="mt-2 flex flex-wrap gap-1">
{agentTraits.map((trait) => {
const selected = selectedAgentTraits.includes(trait)
return (
<button
key={trait}
type="button"
onClick={() => void generateSubjectPack(mode)}
disabled={subjectBusy || modeRunning || !canGenerate}
className="skg-primary-action mt-2 inline-flex h-8 w-full items-center justify-center gap-1 px-3 text-[10.5px] font-semibold transition disabled:cursor-not-allowed disabled:opacity-40"
onClick={() => toggleSubjectAgentTrait(trait)}
className={`h-6 rounded-full border px-2 text-[9.5px] transition ${
selected
? "border-[#d6b36a]/70 bg-[#d6b36a]/16 text-white"
: "border-white/10 bg-black/28 text-white/52 hover:border-[#d6b36a]/50 hover:text-white"
}`}
>
{subjectBusyFor?.mode === mode || modeRunning ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Sparkles className="h-3.5 w-3.5" />}
{subjectBusyFor?.mode === mode || modeRunning ? "逐张生成中" : `生成${modeConfig.label} 6视图`}
{trait}
</button>
</div>
)
})}
{lastSubjectProfile ? (
<div className="rounded border border-cyan-200/16 bg-cyan-300/[0.055] px-2 py-1.5 text-[9.5px] leading-snug text-cyan-50/62">
{lastSubjectProfile.summary}
)
})}
</div>
) : null}
<div className="mt-2 max-h-32 space-y-1.5 overflow-y-auto rounded-md border border-white/10 bg-black/24 p-2">
{agentMessages.length ? agentMessages.slice(-6).map((message, index) => (
<div key={`${message.created_at}-${index}`} className={`rounded px-2 py-1.5 text-[10px] leading-snug ${
message.role === "user" ? "ml-6 bg-white/[0.07] text-white/70" : "mr-6 bg-cyan-300/[0.07] text-cyan-50/68"
}`}>
{message.content}
</div>
) : null}
)) : (
<div className="flex h-16 items-center justify-center text-center text-[10px] leading-snug text-white/32">
</div>
)}
</div>
<textarea
value={agentInput}
onChange={(event) => {
setAgentInput(event.target.value)
setAgentRequirement(event.target.value || job.subject_agent?.requirements_zh || "")
}}
placeholder="例如:保留透明骨骼男孩和蓝色头带,人物占画面 90%,服装每张完全一致,生成 6 张"
rows={2}
className="mt-2 min-h-[56px] w-full resize-none rounded-md border border-white/10 bg-black/35 px-2.5 py-2 text-[10.5px] leading-snug text-white outline-none placeholder:text-white/28 focus:border-cyan-300/50"
/>
<div className="mt-2 grid grid-cols-[1fr_auto] gap-1.5">
<button
type="button"
onClick={() => void sendSubjectAgentRequirement()}
disabled={subjectAgentBusy === "message"}
className="inline-flex h-8 items-center justify-center gap-1 rounded-md border border-white/10 bg-white/[0.06] px-3 text-[10.5px] font-semibold text-white/68 transition hover:border-white/25 hover:text-white disabled:cursor-not-allowed disabled:opacity-40"
>
{subjectAgentBusy === "message" ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Send className="h-3.5 w-3.5" />}
</button>
<button
type="button"
onClick={() => void generateSubjectPack(agentMode)}
disabled={subjectBusy || agentModeRunning || !canGenerateAgentPack}
className="skg-primary-action inline-flex h-8 items-center justify-center gap-1 px-3 text-[10.5px] font-semibold transition disabled:cursor-not-allowed disabled:opacity-40"
>
{subjectBusyFor?.mode === agentMode || agentModeRunning ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Sparkles className="h-3.5 w-3.5" />}
{selectedSubjectViews.length}
</button>
</div>
{lastSubjectProfile ? (
<div className="mt-2 rounded border border-cyan-200/16 bg-cyan-300/[0.055] px-2 py-1.5 text-[9.5px] leading-snug text-cyan-50/62">
{lastSubjectProfile.summary}
</div>
) : null}
</div>
</div>