import { type FormEvent, type ReactNode, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { createPortal } from "react-dom"; import { ArrowLeft, ArrowUp, Box, Copy, Download, FileText, FolderOpen, Grid2X2, Image, List, Play, Plus, Pencil, Search, Sparkles, Square, Upload, UserRound, Users, X, } from "lucide-react"; import { api } from "../api"; import { findCatalogModel, OmniParamBar } from "../components/omni-param-bar"; import { estimateCost, pointsPerImageFromCatalog } from "../components/free-create/constants"; import { MediaLightbox } from "../components/overlays"; import type { CreationConversationDetail, CreationField, CreationMessage, CreationRef, ModelConfig, } from "../types"; import type { NavigateFn } from "./route-config"; type PromptBlock = | { type: "meta"; entries: Array<{ label: string; value: string }> } | { type: "table"; headers: string[]; rows: string[][] } | { type: "shot"; heading: string; fields: Array<{ label: string; value: string }>; text: string } | { type: "text"; heading: string; text: string }; type ReplyOption = { label: string; text: string; action?: "upload" }; function renderInlineMarkdown(text: string): ReactNode[] { return text.split(/(\*\*[^*]+\*\*|`[^`]+`|\*[^*]+\*)/g).filter(Boolean).map((part, index) => { if (part.startsWith("**") && part.endsWith("**")) { return {part.slice(2, -2)}; } if (part.startsWith("`") && part.endsWith("`")) { return {part.slice(1, -1)}; } if (part.startsWith("*") && part.endsWith("*")) { return {part.slice(1, -1)}; } return part; }); } /** 对话正文的安全 Markdown 子集;不使用 HTML 注入,避免模型文本影响页面结构。 */ function ChatMarkdown({ text }: { text: string }) { const lines = text.replace(/\r\n/g, "\n").split("\n"); const nodes: ReactNode[] = []; let index = 0; while (index < lines.length) { const line = lines[index].trim(); if (!line) { index += 1; continue; } const heading = line.match(/^#{1,3}\s+(.+)$/); if (heading) { nodes.push(

{renderInlineMarkdown(heading[1])}

); index += 1; continue; } const ordered = line.match(/^(\d+)[.、.]\s+(.+)$/); if (ordered) { const start = Number(ordered[1]); const items: Array<{ key: number; text: string }> = []; while (index < lines.length) { const match = lines[index].trim().match(/^(\d+)[.、.]\s+(.+)$/); if (!match) break; items.push({ key: Number(match[1]), text: match[2] }); index += 1; } nodes.push(
    {items.map((item) =>
  1. {renderInlineMarkdown(item.text)}
  2. )}
, ); continue; } if (/^[-*+](?:\s+)/.test(line)) { const items: string[] = []; while (index < lines.length) { const match = lines[index].trim().match(/^[-*+](?:\s+)(.+)$/); if (!match) break; items.push(match[1]); index += 1; } nodes.push( , ); continue; } const paragraph: string[] = []; while (index < lines.length) { const current = lines[index].trim(); if (!current || /^#{1,3}\s+/.test(current) || /^(\d+)[.、.]\s+/.test(current) || /^[-*+](?:\s+)/.test(current)) break; paragraph.push(lines[index]); index += 1; } nodes.push(

{paragraph.map((item, lineIndex) => ( {lineIndex ?
: null}{renderInlineMarkdown(item)}
))}

, ); } return
{nodes}
; } function stripNumericReplyInstruction(text: string): string { return (text || "") .replace( /(?:\n)?(?:你|请)?\s*(?:直接)?(?:回复|选择|选)\s*(?:数字|编号)?\s*1\s*(?:[/、,,]\s*2)(?:\s*(?:[/、,,]\s*3))?[^。!?!\n]*(?:[。!?!]|$)/g, "", ) .trim(); } function numberedReplyOptions(text: string): ReplyOption[] { const matches = [...(text || "").matchAll(/(?:^|\n)\s*([1-3])[.、.]\s*(?:\*\*)?([^\n*]{1,80})/g)]; if (matches.length < 2) return []; // 分镜、步骤、卖点清单也经常使用 1./2./3.;只有助手明确要求用户在这些项中选择时, // 才把编号转成操作按钮,避免把分镜 1–6 错当成三个可选方案。 const hasChoiceInstruction = /(?:请|你)?(?:选择|选)(?:其中|一个|一项|下面|以下|第[一二三123]|\s*1\s*[/、,]\s*2)|(?:回复|输入)(?:数字|编号)\s*1\s*[/、,]\s*2|(?:下面|以下)(?:有|为)?(?:三|3)个?(?:方向|方案|选项)/.test(text || ""); if (!hasChoiceInstruction) return []; return matches.slice(0, 3).map((match) => ({ label: match[1], text: `选择第${match[1]}个方向`, })); } function contextualReplyOptions(text: string): ReplyOption[] { if (/(?:两位|两个|多位|多个).{0,24}(?:人物|角色|模特|男士|女生).{0,80}(?:哪位|哪个|选择|用哪|出镜)/i.test(text)) { return [ { label: "1", text: "选择第 1 位人物出镜" }, { label: "2", text: "选择第 2 位人物出镜" }, { label: "你来决定", text: "请根据当前脚本帮我选择更合适的人物" }, ]; } // 人物参考和商品参考是两件事。已锁定人物后,助手若提到实物图,不能又给出「上传人物图」—— // 这会让用户误以为刚上传的参考没有生效。 if (/实物图|商品图|产品图|商品外观|产品外观|产品素材|耳机(?:图|外观)?|IP设计/i.test(text)) { return [ { label: "上传商品实物图", text: "我上传商品实物图", action: "upload" }, { label: "按当前描述继续", text: "先按当前商品描述继续,不再补图" }, { label: "换一个商品", text: "我想换一个商品来创作" }, ]; } if (/颜色|色号|色彩|SKU|款式|几种/i.test(text)) { return [ { label: "补充颜色和顺序", text: "我来补充每个颜色和展示顺序" }, { label: "上传各款实物图", text: "我补充各颜色/款式的实物图", action: "upload" }, { label: "先按当前主款做", text: "先按当前主款做,其他颜色后面再补" }, ]; } if (/商品|产品|主推|哪款/i.test(text)) { return [ { label: "发商品列表", text: "把商品列表发给我选" }, { label: "我直接说商品名", text: "我直接告诉你商品名" }, { label: "你来推荐", text: "你根据当前需求推荐一款" }, ]; } if (/人物|角色|模特|出镜/i.test(text)) { return [ { label: "上传人物图", text: "我上传人物参考图", action: "upload" }, { label: "由你设定角色", text: "你先帮我设定一个合适的角色" }, { label: "不需要人物", text: "这条先不需要人物出镜" }, ]; } if (/场景|地点|背景|在哪/i.test(text)) { return [ { label: "上传场景图", text: "我上传场景参考图", action: "upload" }, { label: "你来推荐场景", text: "你按商品和预设推荐场景" }, { label: "用干净日常场景", text: "先用干净自然的日常场景" }, ]; } if (/卖点|功能|效果|优惠|价格/i.test(text)) { return [ { label: "补充真实卖点", text: "我来补充商品真实卖点" }, { label: "从素材里判断", text: "先根据我上传的素材判断可表达的卖点" }, { label: "先只突出一个点", text: "先围绕一个最核心的卖点创作" }, ]; } return []; } function uploadReplyText(option: ReplyOption): string { if (/人物|角色|模特/.test(option.label)) { return "我上传人物参考图,请使用刚上传的这张图作为本次视频唯一的出镜人物参考,不需要再让我选择其他人物。"; } if (/商品|实物|款/.test(option.label)) { return "我上传商品实物图,请使用刚上传的图片作为本次视频的商品外观参考。"; } if (/场景/.test(option.label)) { return "我上传场景参考图,请使用刚上传的图片作为本次视频的主要场景参考。"; } return option.text; } function replyOptionsForMessage(text: string, payload: Record): ReplyOption[] { const numbered = numberedReplyOptions(text); if (numbered.length) return numbered; // 文本正在明确索要的内容永远优先于旧的 reply_options。 // Agent 的历史 payload 可能残留「上传人物图」,但当前气泡已改成要商品实物图; // 此时沿用旧按钮会让用户上传错素材。 const contextual = contextualReplyOptions(text); if (contextual.length) return contextual; const stored = payload.reply_options; if (Array.isArray(stored)) { const options = stored .filter((item): item is Record => Boolean(item) && typeof item === "object") .map((item) => { const label = String(item.label || "").trim(); return { label, text: String(item.text || "").trim(), // 任何引导里的“上传图片/实物图/素材图”都走本地文件选择器,避免同一套对话里有的能传、有的只是文字回复。 action: /上传.*(?:图|图片|素材)|(?:图|图片|素材).*上传/.test(label) ? "upload" as const : undefined, }; }) .filter((item) => item.label && item.text) .slice(0, 3); const legacyGeneric = ["继续完善方案", "换个场景", "改卖点", "按这个方向出图", "改人物状态"]; if (options.length && !options.every((item) => legacyGeneric.includes(item.label))) return options; } return []; } function ReplyActions({ options, disabled, onSubmit, onUpload, }: { options: ReplyOption[]; disabled?: boolean; onSubmit: (text: string) => void; onUpload: (option: ReplyOption) => void; }) { const [customIdea, setCustomIdea] = useState(""); const submitCustomIdea = (event: FormEvent) => { event.preventDefault(); const text = customIdea.trim(); if (!text || disabled) return; onSubmit(text); setCustomIdea(""); }; return (
{options.map((option, index) => ( ))}
setCustomIdea(event.target.value)} placeholder="输入自己的想法…" aria-label="输入自己的想法" />
); } function parseMarkdownTable(lines: string[]): { headers: string[]; rows: string[][] } | null { if (lines.length < 2) return null; const splitRow = (line: string) => line .trim() .replace(/^\|/, "") .replace(/\|$/, "") .split("|") .map((cell) => cell.trim()); const headers = splitRow(lines[0]); if (headers.length < 2) return null; const divider = lines[1].trim(); if (!/^\|?[\s:\-|]+ \|?/.test(divider) && !/^[\s:\-|]+$/.test(divider.replace(/\|/g, ""))) { // 宽松:第二行主要是 --- 分隔 if (!divider.includes("---") && !divider.includes(":-")) return null; } const rows = lines.slice(2).map(splitRow).filter((row) => row.some(Boolean)); return { headers, rows }; } function extractShotFields(text: string): { fields: Array<{ label: string; value: string }>; rest: string } { const labels = ["景别", "机位", "运镜", "动作", "信息变化", "人声", "画面", "口播文案", "口播", "时长", "画面描述"]; const fields: Array<{ label: string; value: string }> = []; let rest = text; for (const label of labels) { const re = new RegExp(`(?:^|\\n)\\s*${label}\\s*[::]\\s*([^\\n]+)`, "i"); const match = rest.match(re); if (!match) continue; fields.push({ label, value: match[1].trim() }); rest = rest.replace(match[0], "\n"); } return { fields, rest: rest.replace(/\n{3,}/g, "\n\n").trim() }; } function promptBlocks(body: string): PromptBlock[] { const headingRe = /^(?:#{1,3}\s+.+|【.+】|\d+(?:\.\d+)?\s*[-–~//]\s*\d+(?:\.\d+)?\s*(?:s|秒)\b.*|(?:Hook|Body|CTA|过桥|正文|总览|概述|整体|镜头)\b.*)$/i; const metaRe = /^(.{1,20}?)[::]\s+(.+)$/; const rawLines = body.replace(/\r\n/g, "\n").replace( /(? = []; while (i < rawLines.length) { const raw = rawLines[i].trim(); if (!raw) { if (meta.length) break; i += 1; continue; } if (raw.startsWith("|") || headingRe.test(raw) || raw.startsWith("#")) break; const m = raw.match(metaRe); if (!m || headingRe.test(raw)) break; // 避免把「0-3秒:xxx」当 meta if (/^\d/.test(m[1])) break; meta.push({ label: m[1].trim(), value: m[2].trim() }); i += 1; } if (meta.length) blocks.push({ type: "meta", entries: meta }); let heading = ""; let textLines: string[] = []; const flushText = () => { const text = textLines.join("\n").trim(); textLines = []; if (!heading && !text) return; const bodyLines = text ? text.split("\n") : []; const tableIdx = bodyLines.findIndex((line) => line.trim().startsWith("|")); if (tableIdx >= 0) { let end = tableIdx + 1; while (end < bodyLines.length && bodyLines[end].trim().startsWith("|")) end += 1; const before = bodyLines.slice(0, tableIdx).join("\n").trim(); const table = parseMarkdownTable(bodyLines.slice(tableIdx, end)); const after = bodyLines.slice(end).join("\n").trim(); if (before || heading) { const shotLike = /镜头|\d+(?:\.\d+)?\s*[-–~//]\s*\d+/.test(heading); if (shotLike && heading) { const extracted = extractShotFields(before); blocks.push({ type: "shot", heading, fields: extracted.fields, text: extracted.rest }); } else { blocks.push({ type: "text", heading, text: before }); } } if (table) blocks.push({ type: "table", headers: table.headers, rows: table.rows }); if (after) blocks.push({ type: "text", heading: "", text: after }); heading = ""; return; } const shotLike = /镜头|\d+(?:\.\d+)?\s*[-–~//]\s*\d+|(?:Hook|Body|CTA|过桥|正文)/i.test(heading); if (shotLike && heading) { const extracted = extractShotFields(text); blocks.push({ type: "shot", heading, fields: extracted.fields, text: extracted.rest }); } else { blocks.push({ type: "text", heading, text }); } heading = ""; }; while (i < rawLines.length) { const line = rawLines[i]; const raw = line.trim(); // 独立 markdown 表 if (raw.startsWith("|")) { flushText(); let end = i + 1; while (end < rawLines.length && rawLines[end].trim().startsWith("|")) end += 1; const table = parseMarkdownTable(rawLines.slice(i, end)); if (table) blocks.push({ type: "table", headers: table.headers, rows: table.rows }); else blocks.push({ type: "text", heading: "", text: rawLines.slice(i, end).join("\n") }); i = end; continue; } if (raw && headingRe.test(raw)) { flushText(); heading = raw.replace(/^#+\s*/, ""); const split = heading.match(/^(.{2,40}?)[::]\s+(.+)$/); if (split) { heading = split[1].trim(); textLines = [split[2]]; } i += 1; continue; } textLines.push(line); i += 1; } flushText(); return blocks.length ? blocks : [{ type: "text", heading: "", text: body.trim() }]; } /** @deprecated 兼容旧调用;新 UI 用 promptBlocks */ function promptSections(body: string): Array<{ heading: string; text: string }> { return promptBlocks(body).flatMap((block) => { if (block.type === "meta") { return [{ heading: "视频参数", text: block.entries.map((e) => `${e.label}:${e.value}`).join("\n") }]; } if (block.type === "table") { const head = `| ${block.headers.join(" | ")} |`; const rows = block.rows.map((r) => `| ${r.join(" | ")} |`).join("\n"); return [{ heading: "", text: `${head}\n${rows}` }]; } if (block.type === "shot") { const fieldText = block.fields.map((f) => `${f.label}:${f.value}`).join("\n"); return [{ heading: block.heading, text: [fieldText, block.text].filter(Boolean).join("\n") }]; } return [{ heading: block.heading, text: block.text }]; }); } const REF_CHIP_LABEL: Record = { character: "角色", model: "模特", product: "商品", scene: "场景", asset: "资产", }; function shortRefName(name: string) { const part = name.split(" · ")[0]?.trim(); return part || name; } function escapeRegExp(value: string) { return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } function stripMentionText(text: string, refs: CreationRef[]) { let out = text; for (const ref of refs) { if (!ref.name) continue; out = out.replace(new RegExp(`@${escapeRegExp(ref.name)}\\s*`, "g"), ""); } return out.replace(/@\s*$/g, "").trim(); } function messageTime(createdAt: string) { const date = new Date(createdAt); if (Number.isNaN(date.getTime())) return ""; return date.toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit", hour12: false, }); } function MentionChips({ refs, tone = "user", onRemove, }: { refs: CreationRef[]; tone?: "user" | "composer"; onRemove?: (id: string) => void; }) { const [preview, setPreview] = useState<{ src: string; name: string } | null>(null); if (!refs.length) return null; return ( <>
{refs.map((ref) => { const cover = (ref.cover || "").trim(); const label = shortRefName(ref.name) || REF_CHIP_LABEL[ref.type] || ref.type; return ( {cover ? ( ) : ( <> {REF_CHIP_LABEL[ref.type] || ref.type} {label} )} {onRemove ? ( ) : null} ); })}
setPreview(null)} /> ); } /** 生成中的消息靠轮询转成结果。视频 5–10 分钟,间隔别设太密。 */ const POLL_INTERVAL_MS = 4000; const AGENT_POLL_INTERVAL_MS = 1500; // 请求已发出、但 Celery 还没把会话切到 planning 时,不能让旧 idle 快照把 loading 收掉。 const AGENT_START_GRACE_MS = 20_000; // ────────────────────────────────────────────────────────── 卡片 type SessionAssetKind = "image" | "video" | "document"; type SessionAsset = { key: string; kind: SessionAssetKind; title: string; thumb?: string; src?: string; promptTitle?: string; promptBody?: string; documentDescription?: string; }; function formatDocumentTime(value: unknown): string { const number = Number(value); return Number.isFinite(number) ? (Number.isInteger(number) ? String(number) : number.toFixed(1)) : "—"; } function strategyDocumentBody(payload: Record): string { const sections: Array<[string, string]> = [ ["这条视频给谁看", strategyField(payload, "target", "audience", "who", "给谁看", "目标人群")], ["用户为什么相信", strategyField(payload, "trust", "credibility", "为什么相信", "信任")], ["希望用户相信什么", strategyField(payload, "belief", "希望相信", "想让他信什么", "认知")], ["创作方向", strategyField(payload, "direction", "创作方向", "方向", "style")], ]; return ["# 创作策略摘要", ...sections.map(([label, value]) => `## ${label}\n${value || "—"}`)].join("\n\n"); } function planDocumentBody(payload: Record): string { const usp = strategyField(payload, "usp", "主打卖点", "卖点"); const points = coercePlanPoints(payload.points); const timeline = (payload.timeline as PlanTimelineItem[] | undefined) || []; const voice = coerceVoiceChars(payload.voice_chars); const pointText = points.length ? points.map((point) => `- ${point}`).join("\n") : "—"; const timelineText = timeline.length ? timeline.map((item) => `- ${formatDocumentTime(item.start)}–${formatDocumentTime(item.end)} 秒 · ${item.stage}${item.desc ? `:${item.desc}` : ""}`).join("\n") : "—"; const voiceText = voice.length === 2 ? `${voice[0]}–${voice[1]} 字,预计覆盖约 90% 时长` : "—"; return [ "# 视频最终方案", `## 主打卖点 USP\n${usp || "—"}`, `## 核心支撑\n${pointText}`, `## 时间轴\n${timelineText}`, `## 口播目标\n${voiceText}`, `## 出片准备\n已整合生成参数与 ${String(payload.ref_count ?? 0)} 组参考素材。`, ].join("\n\n"); } function documentDescription(title: string): string { if (title.includes("创作策略")) return "创作策略 · 点击查看"; if (title.includes("视频最终方案")) return "视频方案 · 点击查看"; return "出片指令 · 点击查看"; } /** 从本会话已加载 messages(+pinned_refs / 会话上传)聚合聊天资产,不另打 BE。 */ function collectSessionAssets( messages: CreationMessage[], pinnedRefs: CreationRef[] = [], sessionUploads: CreationRef[] = [], ): SessionAsset[] { const seen = new Set(); const out: SessionAsset[] = []; const push = (item: SessionAsset) => { if (!item.key || seen.has(item.key)) return; seen.add(item.key); out.push(item); }; const addRef = (ref: CreationRef | null | undefined) => { if (!ref?.id) return; const cover = (ref.cover || "").trim(); push({ key: `ref:${ref.type}:${ref.id}`, kind: "image", title: shortRefName(ref.name) || REF_CHIP_LABEL[ref.type] || ref.name || "参考素材", thumb: cover || undefined, src: cover || undefined, }); }; for (const ref of pinnedRefs) addRef(ref); for (const ref of sessionUploads) addRef(ref); for (const message of messages) { for (const ref of message.refs || []) addRef(ref); if (message.kind === "result") { const assets = (message.payload?.assets as Array> | undefined) || []; assets.forEach((asset, index) => { const url = (asset.url || asset.cover || "").trim(); const cover = (asset.cover || asset.url || "").trim(); if (!url && !cover) return; const video = asset.type === "video"; const id = asset.id || url || `${message.id}-${index}`; push({ key: `result:${id}`, kind: video ? "video" : "image", title: video ? (assets.length > 1 ? `生成视频 ${index + 1}` : "生成视频") : (assets.length > 1 ? `生成结果 ${index + 1}` : "生成图片"), thumb: cover || undefined, src: url || cover, }); }); } } // 策略、方案、Prompt 都是会话的正式产物。资源栏只保留各自最新的一版, // 避免多次修改后堆出一串相同文档。 const latestDocument = (kind: CreationMessage["kind"]) => { for (let index = messages.length - 1; index >= 0; index -= 1) { const message = messages[index]; if (message.kind !== kind) continue; return { message, payload: message.payload || {}, index }; } return null; }; const strategy = latestDocument("strategy"); if (strategy) { const title = "创作策略摘要.md"; push({ key: `strategy:${strategy.message.id || strategy.index}`, kind: "document", title, promptTitle: title, promptBody: strategyDocumentBody(strategy.payload), documentDescription: documentDescription(title), }); } const plan = latestDocument("plan"); if (plan) { const title = "视频最终方案.md"; push({ key: `plan:${plan.message.id || plan.index}`, kind: "document", title, promptTitle: title, promptBody: planDocumentBody(plan.payload), documentDescription: documentDescription(title), }); } for (let index = messages.length - 1; index >= 0; index -= 1) { const message = messages[index]; const payload = message.payload || {}; const body = String( message.kind === "prompt_file" ? payload.body || "" : payload.video_prompt || "", ).trim(); if (!body) continue; push({ key: `prompt:${message.id || index}`, kind: "document", title: String(payload.title || "视频生成Prompt.md"), promptTitle: String(payload.title || "视频生成Prompt.md"), promptBody: body, documentDescription: documentDescription(String(payload.title || "视频生成Prompt.md")), }); break; } return out; } function strategyField(payload: Record, ...keys: string[]): string { for (const key of keys) { const value = payload[key]; if (typeof value === "string" && value.trim()) return value.trim(); if (value && typeof value === "object") { const nest = value as Record; for (const nested of ["text", "value", "content", "desc"]) { const inner = nest[nested]; if (typeof inner === "string" && inner.trim()) return inner.trim(); } } } return ""; } function StrategyCard({ payload }: { payload: Record }) { const items: Array<[string, string]> = [ ["这条视频给谁看", strategyField(payload, "target", "audience", "who", "给谁看", "目标人群")], ["用户为什么相信", strategyField(payload, "trust", "credibility", "为什么相信", "信任")], ["希望用户相信什么", strategyField(payload, "belief", "希望相信", "想让他信什么", "认知")], ]; const direction = strategyField(payload, "direction", "创作方向", "方向", "style"); return (
创作策略理解 平台自动判断
{items.map(([label, value]) => (
{label} {value || "—"}
))}
{direction ? (
创作方向 {direction}
) : null}
); } type PlanTimelineItem = { start: number; end: number; stage: string; desc?: string }; function coercePlanPoints(raw: unknown): string[] { if (Array.isArray(raw)) { return raw .map((item) => { if (typeof item === "string") return item.trim(); if (item && typeof item === "object") { const nest = item as Record; for (const key of ["text", "value", "content", "desc", "point", "label"]) { const value = nest[key]; if (typeof value === "string" && value.trim()) return value.trim(); } } return ""; }) .filter((text) => text.length > 1) .slice(0, 3); } if (typeof raw === "string" && raw.trim()) { return raw .split(/[\n;;]/) .map((part) => part.trim()) .filter((part) => part.length > 1) .slice(0, 3); } return []; } function coerceVoiceChars(raw: unknown): number[] { if (Array.isArray(raw) && raw.length >= 2) { const lo = Number(raw[0]); const hi = Number(raw[1]); if (Number.isFinite(lo) && Number.isFinite(hi) && lo > 0 && hi >= lo) return [lo, hi]; } if (typeof raw === "number" && raw > 0) return [Math.max(1, raw - 5), raw + 5]; return []; } function PlanCard({ payload }: { payload: Record }) { const usp = strategyField(payload, "usp", "主打卖点", "卖点"); const points = coercePlanPoints(payload.points); const timeline = (payload.timeline as PlanTimelineItem[] | undefined) || []; const voice = coerceVoiceChars(payload.voice_chars); const format = (value: number) => (Number.isInteger(value) ? String(value) : value.toFixed(1)); const pointLabels = ["核心支撑 P0", "视觉支撑 P0", "转化支撑 P0"]; return (
视频最终方案 确认后进入出片参数核对
卖点做减法
主打卖点 USP {usp || "—"} {points.map((point, index) => ( {pointLabels[index] || `核心支撑 P${index}`} {point} ))}
{timeline.length > 0 ? (
Hook 只负责留人,正文负责说服
{timeline.map((item, index) => ( {format(item.start)}–{format(item.end)} 秒 · {item.stage} {item.desc || ""} ))}
) : null}
{voice.length === 2 ? ( 口播目标{" "} {voice[0]}–{voice[1]} 字 ,预计覆盖约 90% 时长 ) : null} 出片准备: 已整合生成参数与 {String(payload.ref_count ?? 0)} 组参考素材
); } function PromptFileCard({ payload, onView, }: { payload: Record; onView: (title: string, body: string) => void; }) { const title = String(payload.title || "视频生成Prompt.md"); const body = String(payload.body || "").trim(); return (
出片指令已准备好 已根据方案、参数和 {String(payload.ref_count ?? 0)} 组参考素材在后台整理完成
{body ? ( ) : null}
); } /** 视频阶段闸门:策略/方案下方的「按这个继续 / 我想改」。 * 「我想改」在卡片内展开反馈输入,不再依赖底部 composer。 */ function StepConfirmCard({ message, disabled, onConfirm, onReviseSubmit, }: { message: CreationMessage; disabled: boolean; onConfirm: () => void; onReviseSubmit: (feedback: string) => void; }) { const submitted = Boolean(message.payload.submitted); const step = String(message.payload.step || ""); const answers = (message.payload.answers as Record | undefined) || {}; const action = String(answers.step_action || ""); const stepLabel = step === "strategy" ? "创作策略" : step === "plan" ? "视频方案" : step === "prompt" ? "出片指令" : "这一步"; const placeholder = step === "strategy" ? "说说你想怎么改策略…" : step === "plan" ? "说说你想怎么改方案…" : step === "prompt" ? "说说你想怎么改出片细节…" : "说说你想怎么改…"; const [revising, setRevising] = useState(false); const [feedback, setFeedback] = useState(""); const reviseRef = useRef(null); useEffect(() => { if (!revising) return; const el = reviseRef.current; if (!el) return; el.focus(); const len = el.value.length; el.setSelectionRange(len, len); }, [revising]); const canSubmit = feedback.trim().length > 0; return (
{submitted ? (action === "revise" ? `已收到对${stepLabel}的修改意见` : `已确认${stepLabel}`) : revising ? `修改${stepLabel}` : `请确认${stepLabel}`} {submitted ? (action === "revise" ? "正在按你的反馈重写" : "继续下一步") : revising ? "写下你的想法,我们按这个重做这一步。" : (message.text || "确认后继续;要改可以直接说。")}
{submitted || revising ? null : (
)}
{!submitted && revising ? (