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) => - {renderInlineMarkdown(item.text)}
)}
,
);
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(
{items.map((item, itemIndex) => - {renderInlineMarkdown(item)}
)}
,
);
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) => (
))}
);
}
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 ? (
) : null}
);
}
/** 追问默认是普通聊天气泡;下面的卡片只为兼容旧会话数据。 */
function ElicitCard({
message,
disabled,
onSubmit,
onChatAnswer,
}: {
message: CreationMessage;
disabled: boolean;
/** answers 给模型读(人话),refs 给后端取卖点和参考图。**选素材必须两样都回**,
只回名字的话同名素材会配错货。 */
onSubmit: (answers: Record, refs: CreationRef[]) => void;
/** 普通追问直接在气泡下回答,仍走 text 链路以保留用户消息与上下文。 */
onChatAnswer: (text: string) => void;
}) {
const fields = ((message.payload.fields as CreationField[] | undefined) || []).filter(Boolean);
const submitted = Boolean(message.payload.submitted);
const saved = (message.payload.answers as Record | undefined) || {};
const interaction = String(message.payload.interaction || "");
// hooks 必须在任何 early return 之前:step_confirm / chat / gate 共用同一组件身份
const [answers, setAnswers] = useState>(saved);
const [assetOptions, setAssetOptions] = useState>({});
const [assetPicked, setAssetPicked] = useState>({});
const [customDirection, setCustomDirection] = useState("");
const [chatAnswer, setChatAnswer] = useState("");
useEffect(() => {
if (submitted || interaction === "chat" || interaction === "step_confirm") return;
// 选素材字段要现拉候选:后端只给了 asset_types,具体有哪些素材是团队数据
const assetFields = fields.filter((f) => f.type === "asset");
if (assetFields.length === 0) return;
let cancelled = false;
void Promise.all(
assetFields.map((f) =>
api
.searchMentions({ types: f.asset_types, limit: 24 })
.then((res) => [f.key, res.results] as const)
.catch(() => [f.key, [] as CreationRef[]] as const)
)
).then((pairs) => {
if (cancelled) return;
// 同一张图可能既算商品又算资产库素材,两个 type 各返一条 —— 按 id 去重
setAssetOptions(
Object.fromEntries(
pairs.map(([key, refs]) => {
const seen = new Set();
return [key, refs.filter((r) => !seen.has(r.id) && seen.add(r.id))];
})
)
);
});
return () => {
cancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [message.id, submitted, interaction]);
if (interaction === "step_confirm") {
return (
onSubmit({ step_action: "confirm" }, [])}
onReviseSubmit={(feedback) => onSubmit({ step_action: "revise", feedback }, [])}
/>
);
}
if (interaction === "selling_point_gate") {
const sellingPoint = typeof answers.selling_point === "string" ? answers.selling_point : "";
const savedMode = String(saved.selling_point_mode || "");
const submitManual = (event: FormEvent) => {
event.preventDefault();
const value = sellingPoint.trim();
if (!value || disabled) return;
onSubmit({ selling_point_mode: "manual", selling_point: value }, []);
};
return (
{submitted ? (
{savedMode === "auto" ? "已交给系统从商品和素材中推荐卖点" : `已使用:${String(saved.selling_point || "")}`}
) : (
)}
);
}
if (interaction === "plot_twist_directions") {
const directions = Array.isArray(message.payload.directions)
? message.payload.directions
.filter((item): item is Record => Boolean(item) && typeof item === "object")
.map((item) => ({
id: String(item.id || "").trim(),
title: String(item.title || "剧情方向").trim(),
conflict: String(item.conflict || "").trim(),
productRole: String(item.product_role || "").trim(),
reversal: String(item.reversal || "").trim(),
tone: String(item.tone || "").trim(),
}))
.filter((item) => item.id)
: [];
// 选卡片只是本地暂存;明确点「确认选择」后才真正提交并开始整理下一步。
const selected = String(answers.story_direction || saved.story_direction || "");
const selectedDirection = directions.find(
(direction) => selected === direction.id || selected === direction.title,
);
const submitCustomDirection = (event: FormEvent) => {
event.preventDefault();
const idea = customDirection.trim();
if (!idea || disabled) return;
setAnswers((prev) => ({ ...prev, story_direction: idea }));
setCustomDirection("");
};
return (
{directions.map((direction, index) => {
const isSelected = selected === direction.id || selected === direction.title;
return (
);
})}
{!submitted ? (
<>
{selectedDirection ? `已选:${selectedDirection.title}` : selected ? "已选自定义剧情方向" : "请先选择一个方向"}
{selected ? (
) : null}
>
) : null}
);
}
const toggleMulti = (key: string, value: string) => {
setAnswers((prev) => {
const current = Array.isArray(prev[key]) ? (prev[key] as string[]) : [];
const next = current.includes(value) ? current.filter((v) => v !== value) : [...current, value];
return { ...prev, [key]: next };
});
};
const complete = fields.every((field) => {
if (field.required === false) return true;
const value = answers[field.key];
return Array.isArray(value) ? value.length > 0 : Boolean(value);
});
const phase = String(message.payload.phase || "pick");
const gateField = fields[0];
const gateLabel = gateField?.label || "要我把列表发给你选吗?";
// 素材选择闸门:先轻量询问,用户愿意时再展开真正的选择卡
if (phase === "gate") {
const gateAnswer = saved._asset_gate;
const selectedLabel =
gateField?.options?.find((option) => option.value === gateAnswer)?.label
|| (gateAnswer ? String(gateAnswer) : undefined);
return (
{!submitted ? (
{(gateField?.options || []).map((option, index) => (
))}
) : (
{selectedLabel ? `已选择:${selectedLabel}` : "已完成选择"}
)}
);
}
if (interaction === "chat") {
const question = message.text || fields[0]?.label || "这项你想怎么定?";
const submitChatAnswer = (event: FormEvent) => {
event.preventDefault();
const text = chatAnswer.trim();
if (!text || disabled) return;
onChatAnswer(text);
setChatAnswer("");
};
return (
{!submitted ? (
) : null}
);
}
return (
还需要你确认
{submitted ? "已回答" : "选一下就好"}
{fields.map((field) => {
const value = answers[field.key];
return (
{(field.type === "single" || field.type === "multi") && (
{(field.options || []).map((option) => {
const active =
field.type === "multi"
? Array.isArray(value) && value.includes(option.value)
: value === option.value;
return (
);
})}
)}
{field.type === "text" && (
setAnswers((prev) => ({ ...prev, [field.key]: event.target.value }))
}
/>
)}
{field.type === "asset" && (
{(assetOptions[field.key] || []).map((option) => (
))}
{(assetOptions[field.key] || []).length === 0 &&
暂无可选素材}
)}
);
})}
{!submitted && (
)}
);
}
/**
* 确认闸门:方案卡下面那条「开始生成 · 约 N 积分」。
* 点一次就锁住 —— 连点两下后端会 409,但前端也不该让它发生第二次。
*/
function asStringMap(value: unknown): Record {
if (!value || typeof value !== "object") return {};
const out: Record = {};
for (const [key, item] of Object.entries(value as Record)) {
if (item != null && item !== "") out[key] = String(item);
}
return out;
}
function paramLine(params: Record, isVideo: boolean) {
return [
params.model,
isVideo ? params.resolution : "",
params.ratio,
isVideo ? params.duration : params.count,
]
.filter(Boolean)
.join(" · ");
}
function ConfirmCard({
message,
sessionParams,
isVideo,
catalogModels,
disabled,
onConfirm,
}: {
message: CreationMessage;
sessionParams: Record;
isVideo: boolean;
catalogModels?: ModelConfig[];
disabled: boolean;
onConfirm: (params: Record) => void;
}) {
const submitted = Boolean(message.payload.submitted);
const payloadCredits = Number(message.payload.estimated_credits || 0);
const payloadParams = asStringMap(message.payload.params);
const snapshot = { ...sessionParams, ...payloadParams };
const [draft, setDraft] = useState(snapshot);
const cardIsVideo = message.payload.kind !== "image" && isVideo;
const durationChanged =
cardIsVideo && Boolean(draft.duration) && Boolean(snapshot.duration) && draft.duration !== snapshot.duration;
const durationSeconds = Number(String(draft.duration || "").replace(/\D/g, ""));
const willGenerateInSegments = cardIsVideo && durationSeconds > 30 && durationSeconds <= 60;
const setField = (key: string, value: string) => setDraft((prev) => ({ ...prev, [key]: value }));
const summary = paramLine(draft, cardIsVideo) || "当前参数";
// 确认卡积分:改模型/分辨率/时长/张数时按后台挂牌实时重算(与后端 quote_* 同口径;标准团队系数=1)
const credits = (() => {
if (cardIsVideo) {
const model = findCatalogModel(catalogModels, draft.model || "", "video");
const resolution = (draft.resolution || "720p").toLowerCase();
const raw = String(draft.duration || "");
const digits = raw.replace(/\D/g, "");
// 与后端 video_duration 一致:「智能时长」/解析不出 → 15 秒
const duration = digits ? Math.max(4, Math.min(Number(digits), 60)) : 15;
const est = estimateCost(model, { ratio: draft.ratio || "9:16", resolution, duration, refs: [] });
if (est.listed && est.points > 0) return est.points;
// 挂牌缺失时仍展示后端下发的预估(若有)
return payloadCredits;
}
const model = findCatalogModel(catalogModels, draft.model || "", "image");
const unit = pointsPerImageFromCatalog(model);
if (unit == null || unit <= 0) return payloadCredits;
const countLabel = String(draft.count || draft.duration || "1");
const count = Math.max(1, Math.min(8, parseInt(countLabel, 10) || 1));
return unit * count;
})();
return (
{submitted ? "已确认" : `即将用 ${summary} 生成`}
{submitted ? "正在提交生成" : "确认前可以改参数。改时长会按新时长重写脚本。"}
{submitted ? null : (
setField("model", value)}
onResolution={(value) => setField("resolution", value)}
onRatio={(value) => setField("ratio", value)}
onDuration={(value) => setField(cardIsVideo ? "duration" : "count", value)}
/>
)}
{durationChanged ? (
改时长会重新生成脚本。确认后先重写方案,不会直接出片。
) : willGenerateInSegments ? (
{durationSeconds} 秒将拆成 2 段生成,每段最长 30 秒;片段完成后先给你预览,再由你决定是否合并成片。
) : null}
{submitted ? "已确认,正在出片" : "点确认后才会提交生成"}
);
}
function ResultCard({
payload,
onMerge,
merging,
}: {
payload: Record;
onMerge?: () => void;
merging?: boolean;
}) {
const assets = (payload.assets as Array> | undefined) || [];
const first = assets[0] || {};
const meta = [payload.model, payload.resolution, payload.ratio].filter(Boolean).join(" · ");
const isGeneratedVideo = first.type === "video";
const [preview, setPreview] = useState<{ src: string; kind: "image" | "video"; name: string } | null>(null);
return (
1 ? " is-grid" : ""}`}>
{assets.map((asset, index) => {
const cover = asset.cover || asset.url || "";
const url = asset.url || cover;
const video = asset.type === "video";
const label = String(asset.label || (assets.length > 1 ? `第 ${index + 1} 段` : "生成结果"));
return (
{url ? (
event.stopPropagation()}
>
) : null}
);
})}
{
isGeneratedVideo
? payload.needs_merge ? `已生成 ${assets.length} 段视频` : "视频已生成"
: assets.length > 1 ? `已生成 ${assets.length} 张图` : "图片已生成"
}
{payload.needs_merge ? "请先预览片段,确认后再合并成片" : meta}
{payload.needs_merge ? (
) : null}
setPreview(null)}
/>
);
}
function isLocalUserId(id: string) {
return id.startsWith("local-user-");
}
function serverSeq(message: CreationMessage) {
const seq = Number(message.seq);
if (!Number.isFinite(seq) || seq >= Number.MAX_SAFE_INTEGER) return 0;
return seq;
}
/** 当前列表里已落库消息的最大 seq;乐观 local 用 MAX_SAFE_INTEGER,不算进去 */
function lastKnownServerSeq(list: CreationMessage[]) {
let max = 0;
for (const message of list) {
if (isLocalUserId(message.id)) continue;
max = Math.max(max, serverSeq(message));
}
return max;
}
/** 乐观用户气泡:发请求前立刻上屏,poll/202 回来再换成服务端消息 */
function makeLocalUserMessage(text: string, refs: CreationRef[], id?: string): CreationMessage {
const localId = id || `local-user-${Date.now()}`;
return {
id: localId,
role: "user",
kind: "text",
text: text || "",
payload: {},
refs: refs || [],
task: null,
seq: Number.MAX_SAFE_INTEGER,
created_at: new Date().toISOString(),
clientKey: localId,
};
}
/**
* 把服务端用户消息嵌进本地列表。
* 只替换「当前 pending 的 local 气泡」,绝不按正文去覆盖历史已发布用户消息
* (编辑后重发相同文案时,旧气泡和新气泡必须同时存在)。
*/
function replaceLocalUser(prev: CreationMessage[], message: CreationMessage): CreationMessage[] {
const existing = prev.findIndex((m) => m.id === message.id);
if (existing !== -1) {
const next = [...prev];
const prevMsg = prev[existing];
next[existing] = { ...message, clientKey: prevMsg.clientKey || message.clientKey || message.id };
return next;
}
if (message.role === "user") {
const knownSeq = lastKnownServerSeq(prev);
const isFreshServerUser = serverSeq(message) > knownSeq;
// 仅当这条服务端用户消息确实是「新一轮」时,才拿它替换 pending local
if (isFreshServerUser) {
const localIdx = prev.findIndex((m) => isLocalUserId(m.id) && sameUserBubble(m, message));
if (localIdx !== -1) {
const next = [...prev];
const local = prev[localIdx];
next[localIdx] = { ...message, clientKey: local.clientKey || local.id };
return next;
}
// 也允许按「唯一 pending local」对齐(正文被服务端微调时)
const pendingLocals = prev
.map((m, idx) => ({ m, idx }))
.filter(({ m }) => isLocalUserId(m.id));
if (pendingLocals.length === 1) {
const { idx, m: local } = pendingLocals[0];
const next = [...prev];
next[idx] = { ...message, clientKey: local.clientKey || local.id };
return next;
}
}
}
return [...prev, { ...message, clientKey: message.clientKey || message.id }];
}
/**
* 只折叠「同一轮」里相邻的 local ↔ server 重复;
* 两个都已落库的用户消息即使正文相同也不合并(允许编辑重发相同文案)。
*/
function collapseDupUser(list: CreationMessage[]): CreationMessage[] {
const out: CreationMessage[] = [];
for (const m of list) {
const last = out[out.length - 1];
const consecutiveSameText =
last?.role === "user"
&& m.role === "user"
&& (last.text || "") === (m.text || "");
const localServerPair =
consecutiveSameText
&& (isLocalUserId(last.id) !== isLocalUserId(m.id));
if (localServerPair) {
if (isLocalUserId(m.id)) {
out[out.length - 1] = last;
} else {
out[out.length - 1] = {
...m,
clientKey: last.clientKey || m.clientKey || m.id,
};
}
continue;
}
out.push(m.clientKey ? m : { ...m, clientKey: m.id });
}
return out;
}
function sameUserBubble(a: CreationMessage, b: CreationMessage) {
if (a.role !== "user" || b.role !== "user") return false;
if ((a.text || "") !== (b.text || "")) return false;
const aRefs = (a.refs || []).map((r) => `${r.type}:${r.id}`).sort().join("|");
const bRefs = (b.refs || []).map((r) => `${r.type}:${r.id}`).sort().join("|");
return aRefs === bRefs;
}
function turnLooksSettled(detail: CreationConversationDetail) {
const msgs = detail.messages || [];
let userIdx = -1;
for (let i = msgs.length - 1; i >= 0; i -= 1) {
if (msgs[i].role === "user") {
userIdx = i;
break;
}
}
if (userIdx === -1) return false;
// 用户气泡之后已有助手/系统/卡片消息 → 本轮已落地,可收起 thinking
return msgs.slice(userIdx + 1).some((m) => m.role !== "user");
}
/** payload 是否实质相同(忽略 key 顺序之外的引用身份) */
function samePayload(a: Record, b: Record) {
return JSON.stringify(a || {}) === JSON.stringify(b || {});
}
/** 两条消息内容是否可原地复用(保持 React 组件身份,避免入场动画重放) */
function sameMessageContent(prev: CreationMessage, next: CreationMessage) {
return (
prev.id === next.id
&& prev.kind === next.kind
&& prev.role === next.role
&& (prev.text || "") === (next.text || "")
&& serverSeq(prev) === serverSeq(next)
&& samePayload(prev.payload || {}, next.payload || {})
);
}
/**
* 合并服务端消息时保留本地 UI 态:
* - 乐观 submitted / answers 尚未落库时不要被 poll 冲掉
* - id/seq/payload 未变则复用原对象引用,避免 elicit / StepConfirm 卸载闪烁
*/
function mergeMessagePreserveLocal(prevHit: CreationMessage | undefined, msg: CreationMessage): CreationMessage {
if (prevHit && sameMessageContent(prevHit, msg)) {
return prevHit;
}
const clientKey = prevHit?.clientKey || msg.clientKey || msg.id;
if (!prevHit) {
return msg.clientKey === clientKey ? msg : { ...msg, clientKey };
}
// 开放中的追问:服务端尚未 submitted 时,保住本地已选选项与乐观提交。
// step_confirm 除外 —— 取消修订时服务端会主动撤回 submitted,必须以服务端为准,
// 否则会卡在「已收到…正在按你的反馈重写」。
if (prevHit.kind === "elicit" && msg.kind === "elicit") {
const prevPayload = prevHit.payload || {};
const nextPayload = msg.payload || {};
const isStepConfirm =
prevPayload.interaction === "step_confirm"
|| nextPayload.interaction === "step_confirm";
if (!isStepConfirm && prevPayload.submitted && !nextPayload.submitted) {
return {
...msg,
clientKey,
payload: {
...nextPayload,
submitted: true,
answers: prevPayload.answers ?? nextPayload.answers,
},
};
}
// 字段定义未变时复用旧 payload 引用,避免选中态随 poll 重置
if (
!nextPayload.submitted
&& !prevPayload.submitted
&& samePayload(
{ ...prevPayload, answers: undefined, submitted: undefined },
{ ...nextPayload, answers: undefined, submitted: undefined },
)
) {
return {
...prevHit,
text: msg.text || prevHit.text,
seq: msg.seq,
created_at: msg.created_at || prevHit.created_at,
clientKey,
payload: prevPayload,
};
}
}
return { ...msg, clientKey };
}
/**
* 合并服务端快照时保留尚未 ack 的乐观气泡,并稳住未变消息的对象引用。
* local 只对上「本轮新出现」的服务端用户消息,绝不按正文吞历史。
*/
function keepUnackedLocals(prev: CreationMessage[], incoming: CreationMessage[]): CreationMessage[] {
const prevById = new Map(prev.map((m) => [m.id, m]));
const locals = prev.filter((m) => isLocalUserId(m.id));
if (!locals.length) {
let changed = prev.length !== incoming.length;
const merged = incoming.map((msg, index) => {
const prevHit = prevById.get(msg.id) || (prev[index]?.id === msg.id ? prev[index] : undefined);
const next = mergeMessagePreserveLocal(prevHit, msg);
if (next !== prevHit) changed = true;
return next;
});
if (!changed) {
// 长度与每条引用都未变 → 直接复用旧数组,阻断无意义重渲染
let identical = true;
for (let i = 0; i < merged.length; i += 1) {
if (merged[i] !== prev[i]) {
identical = false;
break;
}
}
if (identical) return prev;
}
return merged;
}
const knownServerIds = new Set(
prev.filter((m) => !isLocalUserId(m.id)).map((m) => m.id)
);
const knownSeq = lastKnownServerSeq(prev);
const used = new Set();
const merged = incoming.map((msg) => {
const prevHit = prevById.get(msg.id);
let withKey = mergeMessagePreserveLocal(prevHit, msg);
if (msg.role !== "user") return withKey;
const isNewServerUser =
!knownServerIds.has(msg.id) && serverSeq(msg) > knownSeq;
if (!isNewServerUser) return withKey;
const local = locals.find((item) => !used.has(item.id) && sameUserBubble(item, msg));
if (!local) return withKey;
used.add(local.id);
return { ...withKey, clientKey: local.clientKey || local.id };
});
const leftover = locals.filter((m) => !used.has(m.id));
// leftover 追加在末尾;不要对历史做正文去重
return collapseDupUser(leftover.length ? [...merged, ...leftover] : merged);
}
function withoutLegacyGateArtifacts(list: CreationMessage[]): CreationMessage[] {
/**
* 旧版本点「先不用」时会落一条系统伪造的 user 消息,紧接着再回一句空话。
* 数据先保留,这里只在能确认完整错误序列时隐藏,避免老会话刷新后还显示这组脏气泡。
*/
const hidden = new Set();
for (let index = 1; index < list.length; index += 1) {
const gate = list[index - 1];
const echo = list[index];
const gatePayload = gate?.payload || {};
if (
gate?.kind !== "elicit"
|| gatePayload.phase !== "gate"
|| (gatePayload.answers as Record | undefined)?._asset_gate !== "skip"
|| echo?.role !== "user"
|| echo.text !== "先不选素材也可以,有需要再说。"
) continue;
hidden.add(echo.id);
const deadEnd = list[index + 1];
if (
deadEnd?.role === "assistant"
&& deadEnd.kind === "text"
&& /^在的[,,]有需要再说(?:一声)?[。.]?$/.test(deadEnd.text.trim())
) {
hidden.add(deadEnd.id);
}
}
return hidden.size ? list.filter((message) => !hidden.has(message.id)) : list;
}
function ProcessCard({
payload,
onPreview,
}: {
payload: Record;
onPreview?: (src: string, kind: "image" | "video", name: string) => void;
}) {
const isSegmentedVideo = payload.kind === "video_segments";
const isMerge = payload.kind === "video_merge";
const isVideo = payload.kind === "video" || isSegmentedVideo || isMerge;
const segmentCount = Array.isArray(payload.segments) ? payload.segments.length : 0;
const assets = (payload.assets as Array> | undefined) || [];
const completedSegments = Number(payload.completed_segment_count || 0);
const waitingSegments = isSegmentedVideo ? Math.max(1, segmentCount - completedSegments) : 1;
return (
{assets.map((asset, index) => {
const cover = asset.cover || asset.url || "";
const url = asset.url || cover;
const video = asset.type === "video";
const label = String(asset.label || `第 ${index + 1} 段`);
return (
);
})}
{Array.from({ length: waitingSegments }).map((_, index) => (
{isVideo ? "视频生成中" : "图片生成中"}
))}
{
isMerge
? "正在合并成片"
: isSegmentedVideo && completedSegments
? `第 ${completedSegments} 段已生成,正在生成第 ${completedSegments + 1} 段`
: isSegmentedVideo
? `正在生成 ${segmentCount || 2} 段视频`
: isVideo ? "正在生成视频" : "正在出图"
}
{
isMerge
? "正在拼接已确认的片段"
: isSegmentedVideo && completedSegments
? "已生成的片段可以先点击预览"
: isSegmentedVideo ? "片段完成后会先展示给你确认" : isVideo ? "方案编译中,请稍候" : "画面生成中,请稍候"
}
);
}
const MENTION_REF_LIMIT = 5;
const MENTION_TABS: Array<{ type: CreationRef["type"]; label: string; Icon: typeof Image }> = [
{ type: "asset", label: "素材", Icon: Image },
{ type: "character", label: "角色", Icon: UserRound },
{ type: "model", label: "模特", Icon: Users },
{ type: "product", label: "商品", Icon: Box },
{ type: "scene", label: "场景", Icon: FolderOpen },
];
function mergeSessionUploads(results: CreationRef[], uploads: CreationRef[], query: string, type: CreationRef["type"]) {
if (type !== "asset") return results;
const q = query.trim().toLowerCase();
// 上传时间倒序:sessionUploads 末尾最新,反过来放最前
const extras = uploads
.filter((item) => !q || item.name.toLowerCase().includes(q))
.slice()
.reverse();
const seen = new Set(extras.map((item) => item.id));
return [...extras, ...results.filter((item) => !seen.has(item.id))];
}
// ────────────────────────────────────────────────────────── 页面
export function OmniSessionPage({
conversationId,
firstMessage,
firstRefs,
firstUploads,
modelConfigs,
navigate,
onNotify,
}: {
conversationId: string;
/** 首页「开始创作」带过来的第一句话。只在刚进页面时发一次,刷新后不重发(它已经在库里)。 */
firstMessage?: string;
firstRefs?: CreationRef[];
firstUploads?: CreationRef[];
modelConfigs?: ModelConfig[];
navigate: NavigateFn;
onNotify?: (type: "success" | "error" | "info", text: string) => void;
}) {
// 首页带过来的第一句:进页立刻画出用户气泡,不要等 getCreation / creationSend
const bootLocalIdRef = useRef(
(firstMessage?.trim() || (firstRefs && firstRefs.length > 0))
? `local-user-${Date.now()}`
: null
);
const [conversation, setConversation] = useState(null);
const [messages, setMessages] = useState(() => {
const id = bootLocalIdRef.current;
if (!id) return [];
return [makeLocalUserMessage(firstMessage?.trim() || "", firstRefs || [], id)];
});
const [prompt, setPrompt] = useState("");
const [composerHint, setComposerHint] = useState(null);
const [pendingRefs, setPendingRefs] = useState([]);
const [sessionUploads, setSessionUploads] = useState(firstUploads || []);
const [uploading, setUploading] = useState(false);
const fileInputRef = useRef(null);
// 对话引导里的「上传人物图」要直达系统文件选择器,而不是绕回通用素材菜单。
const quickUploadReplyRef = useRef(null);
const composerRef = useRef(null);
const [streaming, setStreaming] = useState(false);
const [liveText, setLiveText] = useState("");
const [liveReasoning, setLiveReasoning] = useState("");
const [activeTool, setActiveTool] = useState("");
const [mentionMenuOpen, setMentionMenuOpen] = useState(false);
const [mentionTab, setMentionTab] = useState("asset");
const [mentionLoading, setMentionLoading] = useState(false);
const [mentionResults, setMentionResults] = useState([]);
const [typeLabels, setTypeLabels] = useState>({});
const mentionWrapRef = useRef(null);
const [uploadMenuOpen, setUploadMenuOpen] = useState(false);
const [confirming, setConfirming] = useState(false);
const [mergingMessageIds, setMergingMessageIds] = useState([]);
const [stopping, setStopping] = useState(false);
const [promptView, setPromptView] = useState<{ title: string; body: string } | null>(null);
const [assetsOpen, setAssetsOpen] = useState(false);
const [assetView, setAssetView] = useState<"grid" | "list">("grid");
const [assetQuery, setAssetQuery] = useState("");
const [assetPreview, setAssetPreview] = useState<{ src: string; kind: "image" | "video"; name: string } | null>(null);
const [pendingUserId, setPendingUserId] = useState(() => bootLocalIdRef.current);
// App 传进来的 onNotify 是内联箭头,**每次 App 重渲染都是新身份**。
// 直接放进 useEffect / useCallback 依赖会让整条会话被反复重拉、消息数组被反复替换
// —— 页面看起来就是一直在跳。放进 ref,回调永远拿到最新的那个,身份却是稳定的。
const notifyRef = useRef(onNotify);
notifyRef.current = onNotify;
const notify = useCallback(
(type: "success" | "error" | "info", text: string) => notifyRef.current?.(type, text),
[]
);
const copyUserMessage = useCallback(async (text: string) => {
try {
await navigator.clipboard.writeText(text);
notify("success", "已复制");
} catch {
notify("error", "复制失败,请手动复制");
}
}, [notify]);
const editUserMessage = useCallback((message: CreationMessage) => {
const refs = message.refs || [];
setPrompt(stripMentionText(message.text, refs));
setPendingRefs(refs);
setComposerHint(null);
requestAnimationFrame(() => {
composerRef.current?.focus();
composerRef.current?.setSelectionRange(
composerRef.current.value.length,
composerRef.current.value.length,
);
});
}, []);
const feedRef = useRef(null);
const firstSentRef = useRef(false);
const pendingUserIdRef = useRef(bootLocalIdRef.current);
/** 发送时已见过的最大服务端 seq;只有更新的用户消息才能 ack 当前 pending local */
const pendingSinceSeqRef = useRef(0);
const messagesRef = useRef([]);
const streamingRef = useRef(false);
/** 刚发出去后短时间忽略轮询里的陈旧 idle,避免 thinking 被冲掉 */
const holdPlanningUntilRef = useRef(0);
/** 用户点了终止:忽略随后可能迟到的 planning 快照,直到真正 idle */
const userCancelledRef = useRef(false);
/** 卡在 planning 过久时只提示一次,避免轮询刷屏 */
const stalePlanningNotifiedRef = useRef(false);
const isVideo = conversation?.mode === "video";
const params = useMemo(() => conversation?.params || {}, [conversation]);
useEffect(() => {
let cancelled = false;
api
.getCreation(conversationId)
.then((detail) => {
if (cancelled) return;
setConversation(detail);
// 保留尚未 ack 的乐观用户气泡,避免进页种子/发送中的气泡被首包冲掉
setMessages((prev) => keepUnackedLocals(prev, detail.messages || []));
if (bootLocalIdRef.current && pendingUserIdRef.current === bootLocalIdRef.current) {
const boot = makeLocalUserMessage(
firstMessage?.trim() || "",
firstRefs || [],
bootLocalIdRef.current
);
const acked = (detail.messages || []).some(
(m) => m.role === "user" && sameUserBubble(m, boot)
);
if (acked) {
pendingUserIdRef.current = null;
setPendingUserId(null);
}
}
// 刷新/重进时若仍在整理方案,直接恢复轮询 UI —— 不要 abort 杀 Celery
const planning = detail.agent_status === "planning";
const waitingForFreshTurn =
!planning
&& streamingRef.current
&& Date.now() < holdPlanningUntilRef.current
&& !turnLooksSettled(detail);
// 刚发送时这里可能拿到请求之前的 idle 快照。保留 loading,等本轮真正落消息。
if (waitingForFreshTurn) return;
streamingRef.current = planning;
setStreaming(planning);
})
.catch((error) => notify("error", (error as Error).message));
return () => {
cancelled = true;
};
}, [conversationId, notify]);
// 滚的是**整个页面**,不是 feed —— 这一版布局里 feed 没有自己的滚动条
// (shell 撑满高度、输入框 position:sticky),对 feed 调 scrollTo 是空操作。
//
// 三条规则,少一条就会「条来跳去」:
// 1. 用户手动上滑看历史时**不要拽回底部** —— 只有本来就在底部附近才跟。
// 2. 流式期间用 instant:每个字符都发一次 smooth,动画会互相打架。
// 3. 用 rAF 合并同一帧里的多次调用,不要每个 delta 都真滚一次。
const stickToBottomRef = useRef(true);
const scrollFrameRef = useRef(0);
useEffect(() => {
const onScroll = () => {
const gap = document.documentElement.scrollHeight - window.scrollY - window.innerHeight;
stickToBottomRef.current = gap < 120;
};
window.addEventListener("scroll", onScroll, { passive: true });
return () => window.removeEventListener("scroll", onScroll);
}, []);
const scrollToBottom = useCallback((smooth: boolean) => {
if (!stickToBottomRef.current) return;
if (scrollFrameRef.current) cancelAnimationFrame(scrollFrameRef.current);
scrollFrameRef.current = requestAnimationFrame(() => {
scrollFrameRef.current = 0;
window.scrollTo({
top: document.documentElement.scrollHeight,
behavior: smooth ? "smooth" : "instant",
});
});
}, []);
// 新消息落地:平滑滚一次
useEffect(() => {
scrollToBottom(true);
}, [messages.length, scrollToBottom]);
// 流式吐字:instant + rAF 合帧,不然逐字 smooth 会把页面抖散
useEffect(() => {
if (liveText || liveReasoning) scrollToBottom(false);
}, [liveText, liveReasoning, scrollToBottom]);
useEffect(() => () => {
if (scrollFrameRef.current) cancelAnimationFrame(scrollFrameRef.current);
}, []);
// 首页带过来的第一句话:等会话拉回来、且确认库里还没有消息才发。
// firstSentRef 挡住 StrictMode 的双次挂载,否则会重复发一条。
useEffect(() => {
if (!conversation || firstSentRef.current) return;
const text = firstMessage?.trim() || "";
const refs = firstRefs || [];
if ((!text && refs.length === 0) || conversation.messages.length > 0) {
firstSentRef.current = true;
// 库里已有消息时丢掉进页种子,避免和历史重影
if (bootLocalIdRef.current) {
const bootId = bootLocalIdRef.current;
setMessages((prev) => prev.filter((m) => m.id !== bootId));
if (pendingUserIdRef.current === bootId) {
pendingUserIdRef.current = null;
setPendingUserId(null);
}
}
return;
}
firstSentRef.current = true;
void send({ kind: "text", text, refs });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [conversation, firstMessage]);
// 离开页面不再 abort:整理方案在 Celery,断连不该停任务。
useEffect(() => {
const fromHistory = messages.flatMap((message) => message.refs || []).filter((ref) => ref.type === "asset");
if (!fromHistory.length) return;
setSessionUploads((prev) => {
const seen = new Set(prev.map((item) => item.id));
const extra = fromHistory.filter((item) => !seen.has(item.id));
return extra.length ? [...prev, ...extra] : prev;
});
}, [messages]);
const hasGenerating = useMemo(
() => messages.some((message) => message.kind === "generating"),
[messages]
);
const visibleMessages = useMemo(() => withoutLegacyGateArtifacts(messages), [messages]);
const sessionAssets = useMemo(
() => collectSessionAssets(messages, conversation?.pinned_refs || [], sessionUploads),
[messages, conversation?.pinned_refs, sessionUploads],
);
const sessionAssetGroups = useMemo(() => {
const groups: Array<{ key: SessionAssetKind; label: string; items: SessionAsset[] }> = [
{ key: "image", label: "图片", items: [] },
{ key: "video", label: "视频", items: [] },
{ key: "document", label: "文档", items: [] },
];
for (const asset of sessionAssets) {
const group = groups.find((item) => item.key === asset.kind);
if (group) group.items.push(asset);
}
return groups.filter((group) => group.items.length > 0);
}, [sessionAssets]);
const visibleSessionAssetGroups = useMemo(() => {
const query = assetQuery.trim().toLocaleLowerCase();
if (!query) return sessionAssetGroups;
return sessionAssetGroups
.map((group) => ({
...group,
items: group.items.filter((item) => item.title.toLocaleLowerCase().includes(query)),
}))
.filter((group) => group.items.length > 0);
}, [assetQuery, sessionAssetGroups]);
messagesRef.current = messages;
// 出片在 worker 里跑。刷新后 GENERATING 还在库里,进来立刻拉一次再轮询,
// 不要等流式对话结束,也不要空等一个间隔才看见进度。
useEffect(() => {
if (!hasGenerating) return;
let cancelled = false;
const pull = () => {
api
.getCreation(conversationId)
.then((detail) => {
if (cancelled) return;
setConversation(detail);
setMessages((prev) => keepUnackedLocals(prev, detail.messages || []));
})
.catch(() => {
/* 轮询失败静默重试,不打扰用户 */
});
};
pull();
const timer = window.setInterval(pull, POLL_INTERVAL_MS);
return () => {
cancelled = true;
window.clearInterval(timer);
};
}, [hasGenerating, conversationId]);
const mergeCreationDetail = useCallback((detail: CreationConversationDetail, localId?: string | null, payloadText?: string) => {
setConversation((prev) => {
if (
prev
&& prev.id === detail.id
&& prev.agent_status === detail.agent_status
&& prev.updated_at === detail.updated_at
&& prev.params === detail.params
) {
return prev;
}
return detail;
});
setMessages((prev) => keepUnackedLocals(prev, detail.messages || []));
if (localId && pendingUserIdRef.current === localId) {
// 必须是「发送之后」新出现的用户消息,不能靠正文撞上历史气泡
const acked = detail.messages.some(
(m) =>
m.role === "user"
&& !isLocalUserId(m.id)
&& serverSeq(m) > pendingSinceSeqRef.current
&& (payloadText == null || payloadText === "" || m.text === payloadText)
);
if (acked) {
pendingUserIdRef.current = null;
setPendingUserId(null);
}
}
const remotePlanning = detail.agent_status === "planning";
const remoteAwaiting = detail.agent_status === "awaiting_user";
const holding = Date.now() < holdPlanningUntilRef.current;
const clearThinking = () => {
holdPlanningUntilRef.current = 0;
streamingRef.current = false;
setStreaming(false);
setLiveText("");
setLiveReasoning("");
setActiveTool("");
};
if (remoteAwaiting) {
// 轮次结束:立刻收起 thinking,不再被 hold 拖住
stalePlanningNotifiedRef.current = false;
clearThinking();
return;
}
if (remotePlanning) {
if (userCancelledRef.current) {
// 用户已终止:等服务端落到 idle,不把 thinking 又拉起来
return;
}
// 本轮已落 error:不要继续挂「正在整理方案」
const msgs = detail.messages || [];
let lastUserIdx = -1;
for (let i = msgs.length - 1; i >= 0; i -= 1) {
if (msgs[i].role === "user") {
lastUserIdx = i;
break;
}
}
if (lastUserIdx !== -1 && msgs.slice(lastUserIdx + 1).some((m) => m.kind === "error")) {
stalePlanningNotifiedRef.current = false;
clearThinking();
setConversation((prev) => (prev ? { ...prev, agent_status: "idle" } : prev));
return;
}
// 服务端卡死过久(worker 挂掉 / 任务未消费):尝试 cancel 并收起,避免假 idle 导致下一发 409
const startedMs = detail.agent_started_at ? Date.parse(detail.agent_started_at) : 0;
const staleMs = 15 * 60 * 1000;
if (startedMs && Date.now() - startedMs > staleMs) {
if (!stalePlanningNotifiedRef.current) {
stalePlanningNotifiedRef.current = true;
notify("error", "整理方案超时,已停止,请重试");
void api.cancelCreationAgent(conversationId).catch(() => {
/* 已 idle / 竞态忽略 */
});
}
clearThinking();
setConversation((prev) => (prev ? { ...prev, agent_status: "idle" } : prev));
return;
}
// 服务端确认 planning 后,再续一小段 hold,挡住中间夹杂的旧 idle 快照
holdPlanningUntilRef.current = Math.max(holdPlanningUntilRef.current, Date.now() + 2500);
streamingRef.current = true;
setStreaming(true);
return;
}
// idle / 其它:用户终止完成
stalePlanningNotifiedRef.current = false;
if (userCancelledRef.current) {
userCancelledRef.current = false;
}
// idle:hold 窗口内且本轮还没落助手消息时,忽略陈旧快照;已落消息或窗口过期则收起
if (holding && streamingRef.current && !turnLooksSettled(detail)) {
setStreaming(true);
return;
}
clearThinking();
}, [conversationId, notify]);
// awaiting_user 时不要被短暂 streaming 闪回拖进 planning 轮询,否则闸门按钮会跟着灰/闪
const isPlanning =
conversation?.agent_status === "planning"
|| (Boolean(streaming) && conversation?.agent_status !== "awaiting_user");
// 若状态已是等待用户而 streaming 仍残留,立刻收起,避免 send 被 streamingRef 卡住、按钮被灰掉
useEffect(() => {
if (conversation?.agent_status !== "awaiting_user") return;
if (!streaming && !streamingRef.current) return;
holdPlanningUntilRef.current = 0;
streamingRef.current = false;
setStreaming(false);
setLiveText("");
setLiveReasoning("");
setActiveTool("");
}, [conversation?.agent_status, streaming]);
// 整理方案在 Celery:靠 poll 续进度。刷新/重进只要 agent_status=planning 就会接着转。
useEffect(() => {
if (!isPlanning) return;
let cancelled = false;
const pull = () => {
api
.getCreation(conversationId)
.then((detail) => {
if (cancelled) return;
mergeCreationDetail(detail);
})
.catch(() => {
/* 轮询失败静默重试 */
});
};
pull();
const timer = window.setInterval(pull, AGENT_POLL_INTERVAL_MS);
return () => {
cancelled = true;
window.clearInterval(timer);
};
}, [isPlanning, conversationId, mergeCreationDetail]);
const send = useCallback(
async (payload: Parameters[1]) => {
// awaiting_user 下允许回答闸门;其它状态仍禁止并发发送
if (streamingRef.current && conversation?.agent_status !== "awaiting_user") return;
// 先占位用户气泡,再亮「正在整理方案」—— 顺序不能反
const isTextTurn = payload.kind !== "elicit_answer";
let localId: string | null = null;
if (isTextTurn) {
const draft = makeLocalUserMessage(payload.text || "", payload.refs || []);
// 仅首页进页种子可复用同一个 local id;编辑重发必须新开气泡,即使正文相同
const bootPending =
bootLocalIdRef.current
&& pendingUserIdRef.current === bootLocalIdRef.current
&& isLocalUserId(bootLocalIdRef.current)
? bootLocalIdRef.current
: null;
localId = bootPending || draft.id;
const optimistic = bootPending
? makeLocalUserMessage(payload.text || "", payload.refs || [], bootPending)
: draft;
pendingUserIdRef.current = localId;
setPendingUserId(localId);
pendingSinceSeqRef.current = lastKnownServerSeq(messagesRef.current);
setMessages((prev) => {
// 只按精确 localId 更新(进页种子);绝不按正文去重历史用户消息
if (prev.some((m) => m.id === localId)) {
return prev.map((m) =>
m.id === localId
? { ...optimistic, clientKey: m.clientKey || optimistic.clientKey }
: m
);
}
return [...prev, optimistic];
});
}
stalePlanningNotifiedRef.current = false;
userCancelledRef.current = false;
// 先占住 loading;服务端初始 202 / 旧 idle 快照回来时不能把它闪没。
holdPlanningUntilRef.current = Math.max(
holdPlanningUntilRef.current,
Date.now() + AGENT_START_GRACE_MS,
);
streamingRef.current = true;
setStreaming(true);
setLiveText("");
setLiveReasoning("");
setActiveTool("");
try {
const result = await api.creationSend(conversationId, { ...payload });
if (result.messages?.length) {
setMessages((prev) => {
let next = prev;
for (const message of result.messages || []) {
next = collapseDupUser(replaceLocalUser(next, message));
}
return next;
});
if (localId && pendingUserIdRef.current === localId) {
pendingUserIdRef.current = null;
setPendingUserId(null);
}
}
// 用户在 await 期间点了终止:若服务端已入队 planning,补一刀 cancel,不再点亮 thinking
if (userCancelledRef.current) {
holdPlanningUntilRef.current = 0;
streamingRef.current = false;
setStreaming(false);
setConversation((prev) =>
prev ? { ...prev, agent_status: "idle" } : prev
);
if (result.agent_status === "planning") {
void api.cancelCreationAgent(conversationId).catch(() => {
/* 已 idle / 竞态忽略 */
});
}
return;
}
setConversation((prev) =>
prev
? { ...prev, agent_status: result.agent_status || prev.agent_status }
: prev
);
const planning = result.agent_status === "planning";
if (planning) {
// 发出后短时间忽略轮询里的陈旧 idle,避免 thinking 闪灭
holdPlanningUntilRef.current = Math.max(
holdPlanningUntilRef.current,
Date.now() + AGENT_START_GRACE_MS,
);
streamingRef.current = true;
setStreaming(true);
} else {
// creationSend 可能先回 idle、Celery 随后才切 planning。此处不抢着收起,
// 紧接着的详情快照一旦落到助手消息/确认卡就会通过 mergeCreationDetail 正常收起。
streamingRef.current = true;
setStreaming(true);
}
api
.getCreation(conversationId)
.then((detail) => mergeCreationDetail(detail, localId, payload.text || ""))
.catch(() => {
/* agent poll 会接着拉 */
});
} catch (error) {
if (localId) {
setMessages((prev) => prev.filter((m) => m.id !== localId));
if (pendingUserIdRef.current === localId) {
pendingUserIdRef.current = null;
setPendingUserId(null);
}
}
streamingRef.current = false;
setStreaming(false);
setLiveText("");
setLiveReasoning("");
setActiveTool("");
const status = (error as { status?: number }).status;
notify(status === 409 ? "info" : "error", (error as Error).message);
}
},
[conversationId, conversation?.agent_status, mergeCreationDetail, notify]
);
const handleStop = useCallback(async () => {
if (stopping || !isPlanning) return;
setStopping(true);
userCancelledRef.current = true;
holdPlanningUntilRef.current = 0;
// 先本地收态,再打 API —— 闸门修订中取消后服务端会恢复 step_confirm
streamingRef.current = false;
setStreaming(false);
setLiveText("");
setLiveReasoning("");
setActiveTool("");
setConversation((prev) =>
prev ? { ...prev, agent_status: "idle" } : prev
);
const refreshAfterCancel = async () => {
try {
const detail = await api.getCreation(conversationId);
// 直接采用服务端快照,避免 merge 把已撤回的 step_confirm 又乐观粘回 submitted
setConversation(detail);
setMessages(detail.messages || []);
} catch {
/* 恢复失败则等 poll / 用户刷新 */
}
};
try {
const result = await api.cancelCreationAgent(conversationId);
notify("success", "已终止");
if (result.agent_status) {
setConversation((prev) =>
prev ? { ...prev, agent_status: result.agent_status } : prev
);
}
await refreshAfterCancel();
} catch (error) {
const status = (error as { status?: number }).status;
if (status === 409) {
// 已结束或竞态完成 —— 仍拉一次详情,可能已落新闸门确认
notify("info", "已终止");
await refreshAfterCancel();
} else {
userCancelledRef.current = false;
notify("error", (error as Error).message || "终止失败");
await refreshAfterCancel();
}
} finally {
setStopping(false);
}
}, [stopping, isPlanning, conversationId, notify]);
const handleConfirm = async (message: CreationMessage, nextParams: Record) => {
if (confirming) return;
setConfirming(true);
try {
const result = await api.confirmCreationPlan(conversationId, message.id, nextParams);
setMessages((prev) =>
prev.map((m) =>
m.id === message.id
? { ...m, payload: { ...m.payload, submitted: true, params: nextParams } }
: m
)
);
setConversation((prev) =>
prev ? { ...prev, params: { ...prev.params, ...nextParams } } : prev
);
if (result.regenerate) {
notify("info", `时长已改为 ${nextParams.duration || ""},正在按新时长重写脚本`);
void send({
kind: "text",
text: `时长改成了${nextParams.duration},请按新参数重新写方案,旧方案作废`,
});
return;
}
if (result.message) {
setMessages((prev) => [...prev, result.message as CreationMessage]);
}
} catch (error) {
notify("error", (error as Error).message);
} finally {
setConfirming(false);
}
};
const handleMergeSegments = async (message: CreationMessage) => {
if (mergingMessageIds.includes(message.id)) return;
setMergingMessageIds((prev) => [...prev, message.id]);
try {
const result = await api.mergeCreationVideoSegments(conversationId, message.id);
setMessages((prev) => [
...prev.map((item) => item.id === message.id
? { ...item, payload: { ...item.payload, merge_state: "queued" } }
: item),
result.message,
]);
notify("info", "正在合并成片");
} catch (error) {
notify("error", (error as Error).message || "合并失败,请重试");
setMergingMessageIds((prev) => prev.filter((id) => id !== message.id));
}
};
const handleSend = () => {
// 顺序要紧:先判 streaming 再清输入框。反过来的话,流式期间敲一次回车
// 会把已经打好的内容清空,消息却没发出去。
if (uploading) return;
if (streaming && conversation?.agent_status !== "awaiting_user") return;
const text = prompt.trim();
if (!text && pendingRefs.length === 0) return;
setPrompt("");
setComposerHint(null);
const refs = pendingRefs;
setPendingRefs([]);
void send({ kind: "text", text, refs });
};
const openMentions = async (query = "", type: CreationRef["type"] = mentionTab) => {
setMentionTab(type);
setMentionMenuOpen(true);
setUploadMenuOpen(false);
setMentionLoading(true);
setMentionResults([]);
try {
const res = await api.searchMentions({ q: query, types: [type], limit: 24 });
setMentionResults(mergeSessionUploads(res.results, sessionUploads, query, type));
setTypeLabels(res.type_labels);
} catch (error) {
notify("error", (error as Error).message);
} finally {
setMentionLoading(false);
}
};
useEffect(() => {
if (!mentionMenuOpen) return;
const onDoc = (event: MouseEvent) => {
if (!mentionWrapRef.current?.contains(event.target as Node)) {
setMentionMenuOpen(false);
}
};
document.addEventListener("mousedown", onDoc);
return () => document.removeEventListener("mousedown", onDoc);
}, [mentionMenuOpen]);
const insertMention = (ref: CreationRef) => {
// 整条 Ref 存起来一起发:只把名字拼进文本的话,后端取不到卖点和参考图
if (pendingRefs.some((r) => r.id === ref.id)) {
setMentionMenuOpen(false);
return;
}
if (pendingRefs.length >= MENTION_REF_LIMIT) {
notify("info", `最多引用 ${MENTION_REF_LIMIT} 个`);
setMentionMenuOpen(false);
return;
}
setPendingRefs((prev) => [...prev, ref]);
setPrompt((prev) => prev.replace(/@\S*$/, "").replace(/\s+$/, " "));
setMentionMenuOpen(false);
};
const renderMessage = (message: CreationMessage) => {
switch (message.kind) {
case "elicit":
return (
{
const selectedDuration = String(answers.duration || "").trim();
if (selectedDuration) {
setConversation((prev) =>
prev ? { ...prev, params: { ...prev.params, duration: selectedDuration } } : prev
);
}
// 剧情反转预设选择故事深度后,顶部参数同步显示实际时长;服务端仍是最终事实来源。
if (message.payload?.interaction === "plot_twist_story_depth") {
const durationByDepth: Record = {
"15s": "15 秒",
"30s": "30 秒",
"60s": "60 秒",
};
const duration = durationByDepth[String(answers.story_depth || "")];
if (duration) {
setConversation((prev) =>
prev ? { ...prev, params: { ...prev.params, duration } } : prev
);
}
}
// 乐观标记已提交,避免短路径返回前按钮还能连点
setMessages((prev) =>
prev.map((item) =>
item.id === message.id
? {
...item,
payload: {
...item.payload,
submitted: true,
answers: answers as Record,
},
}
: item
)
);
void send({ kind: "elicit_answer", reply_to: message.id, answers, refs });
}}
onChatAnswer={(text) => void send({ kind: "text", text })}
/>
);
case "strategy":
return ;
case "plan":
return ;
case "prompt_file":
return (
setPromptView({ title, body })}
/>
);
case "confirm": {
// 视频:若仍有未确认的步骤闸门,禁止点「开始生成」(防旧会话/异常连跳)
const pendingStep = isVideo
? messages.some(
(item) =>
item.kind === "elicit" &&
item.payload?.interaction === "step_confirm" &&
!item.payload?.submitted
)
: false;
return (
void handleConfirm(message, nextParams)}
/>
);
}
case "generating":
return (
setAssetPreview({ src, kind, name })}
/>
);
case "result":
return (
void handleMergeSegments(message) : undefined}
/>
);
default: {
const refs = message.refs || [];
const rawBody = stripMentionText(message.text, refs);
const body = message.role === "assistant" ? stripNumericReplyInstruction(rawBody) : rawBody;
const pending = message.id === pendingUserId;
const showReplyGuide =
message.role === "assistant"
&& message.kind === "text"
&& messages[messages.length - 1]?.id === message.id
&& !streaming;
const replyOptions = showReplyGuide ? replyOptionsForMessage(body, message.payload) : [];
return (
{message.role !== "user" && (
)}
{message.role === "user" ?
: null}
{(body || message.role !== "user") ? (
{body ? (message.role === "assistant" ?
:
{body}
) : null}
) : null}
{showReplyGuide ? (
void send({ kind: "text", text })}
onUpload={(option) => {
if (streaming || uploading) return;
quickUploadReplyRef.current = option;
fileInputRef.current?.click();
}}
/>
) : null}
{message.role === "user" && !pending ? (
) : null}
);
}
}
};
const [topbarSlot, setTopbarSlot] = useState(null);
useLayoutEffect(() => {
setTopbarSlot(document.getElementById("omni-session-topbar-slot"));
}, []);
const sessionHeader = (
);
return (
{topbarSlot ? createPortal(sessionHeader, topbarSlot) : sessionHeader}
{visibleMessages.map(renderMessage)}
{/* 流式中的临时气泡。挂 is-live 关掉入场动画 —— 它每来一个字符都会
重渲染,带动画的话整条会一直在闪;真消息落地时它被替换掉,
那一下也不该再演一次入场。 */}
{(conversation?.agent_status !== "awaiting_user") && (streaming || conversation?.agent_status === "planning") && !hasGenerating ? (
/生成图片|生成视频/.test(activeTool) && !liveText ? (
) : liveText ? (
) : (
{conversation?.agent_status === "planning" || streaming ? (isVideo ? "正在整理方案" : "正在整理画面") : activeTool ? `${activeTool}…` : liveReasoning ? "思考中" : isVideo ? "正在整理方案" : "正在整理画面"}
{liveReasoning ?
{liveReasoning}
: null}
)
) : null}
{promptView ? (
<>
);
}