feat: add subject image agent workflow
This commit is contained in:
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user