3424 lines
132 KiB
TypeScript
3424 lines
132 KiB
TypeScript
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 <strong key={index}>{part.slice(2, -2)}</strong>;
|
||
}
|
||
if (part.startsWith("`") && part.endsWith("`")) {
|
||
return <code key={index}>{part.slice(1, -1)}</code>;
|
||
}
|
||
if (part.startsWith("*") && part.endsWith("*")) {
|
||
return <em key={index}>{part.slice(1, -1)}</em>;
|
||
}
|
||
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(<h3 key={`heading-${index}`}>{renderInlineMarkdown(heading[1])}</h3>);
|
||
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(
|
||
<ol key={`ordered-${index}`} start={start}>
|
||
{items.map((item) => <li key={item.key}>{renderInlineMarkdown(item.text)}</li>)}
|
||
</ol>,
|
||
);
|
||
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(
|
||
<ul key={`unordered-${index}`}>
|
||
{items.map((item, itemIndex) => <li key={itemIndex}>{renderInlineMarkdown(item)}</li>)}
|
||
</ul>,
|
||
);
|
||
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(
|
||
<p key={`paragraph-${index}`}>
|
||
{paragraph.map((item, lineIndex) => (
|
||
<span key={lineIndex}>{lineIndex ? <br /> : null}{renderInlineMarkdown(item)}</span>
|
||
))}
|
||
</p>,
|
||
);
|
||
}
|
||
|
||
return <div className="omni-chat-markdown">{nodes}</div>;
|
||
}
|
||
|
||
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<string, unknown>): 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<string, unknown> => 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<HTMLFormElement>) => {
|
||
event.preventDefault();
|
||
const text = customIdea.trim();
|
||
if (!text || disabled) return;
|
||
onSubmit(text);
|
||
setCustomIdea("");
|
||
};
|
||
|
||
return (
|
||
<div className="omni-reply-guide-options">
|
||
{options.map((option, index) => (
|
||
<button
|
||
type="button"
|
||
className={index === 0 && options.length < 3 ? "primary" : ""}
|
||
key={option.text}
|
||
disabled={disabled}
|
||
onClick={() => {
|
||
if (option.action === "upload") onUpload(option);
|
||
else onSubmit(option.text);
|
||
}}
|
||
>
|
||
{option.label}
|
||
</button>
|
||
))}
|
||
<form className="omni-reply-custom-input" onSubmit={submitCustomIdea}>
|
||
<input
|
||
className="input"
|
||
value={customIdea}
|
||
disabled={disabled}
|
||
onChange={(event) => setCustomIdea(event.target.value)}
|
||
placeholder="输入自己的想法…"
|
||
aria-label="输入自己的想法"
|
||
/>
|
||
<button type="submit" aria-label="发送想法" disabled={disabled || !customIdea.trim()}>
|
||
<ArrowUp size={14} />
|
||
</button>
|
||
</form>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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(
|
||
/(?<!^|\n)(\d+(?:\.\d+)?\s*[-–~//]\s*\d+(?:\.\d+)?\s*(?:s|秒)\b)/g,
|
||
"\n$1",
|
||
).split("\n");
|
||
|
||
const blocks: PromptBlock[] = [];
|
||
let i = 0;
|
||
|
||
// 片头元信息:连续「标签:值」
|
||
const meta: Array<{ label: string; value: string }> = [];
|
||
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<CreationRef["type"], string> = {
|
||
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 (
|
||
<>
|
||
<div className={`omni-mention-chips is-${tone}`}>
|
||
{refs.map((ref) => {
|
||
const cover = (ref.cover || "").trim();
|
||
const label = shortRefName(ref.name) || REF_CHIP_LABEL[ref.type] || ref.type;
|
||
return (
|
||
<span className={`omni-mention-chip${cover ? " has-thumb" : ""}`} key={`${ref.type}-${ref.id}`}>
|
||
{cover ? (
|
||
<button
|
||
type="button"
|
||
className="omni-mention-thumb"
|
||
aria-label={`预览 ${label}`}
|
||
onClick={() => setPreview({ src: cover, name: label })}
|
||
>
|
||
<img src={cover} alt={label} />
|
||
</button>
|
||
) : (
|
||
<>
|
||
<i>{REF_CHIP_LABEL[ref.type] || ref.type}</i>
|
||
<b>{label}</b>
|
||
</>
|
||
)}
|
||
{onRemove ? (
|
||
<button
|
||
type="button"
|
||
className="omni-mention-remove"
|
||
aria-label={`移除 ${label}`}
|
||
onClick={(event) => {
|
||
event.stopPropagation();
|
||
onRemove(ref.id);
|
||
}}
|
||
>
|
||
<X />
|
||
</button>
|
||
) : null}
|
||
</span>
|
||
);
|
||
})}
|
||
</div>
|
||
<MediaLightbox
|
||
open={Boolean(preview?.src)}
|
||
src={preview?.src || ""}
|
||
kind="image"
|
||
name={preview?.name}
|
||
close={() => 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, unknown>): 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, unknown>): 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<string>();
|
||
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<Record<string, string>> | 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<string, unknown>, ...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<string, unknown>;
|
||
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<string, unknown> }) {
|
||
const items: Array<[string, string]> = [
|
||
["这条视频给谁看", strategyField(payload, "target", "audience", "who", "给谁看", "目标人群")],
|
||
["用户为什么相信", strategyField(payload, "trust", "credibility", "为什么相信", "信任")],
|
||
["希望用户相信什么", strategyField(payload, "belief", "希望相信", "想让他信什么", "认知")],
|
||
];
|
||
const direction = strategyField(payload, "direction", "创作方向", "方向", "style");
|
||
return (
|
||
<section className="omni-strategy-card">
|
||
<header className="omni-strategy-head">
|
||
<strong>创作策略理解</strong>
|
||
<span>平台自动判断</span>
|
||
</header>
|
||
<div className="omni-strategy-grid">
|
||
{items.map(([label, value]) => (
|
||
<div key={label}>
|
||
<span>{label}</span>
|
||
<strong className="omni-strategy-value">{value || "—"}</strong>
|
||
</div>
|
||
))}
|
||
</div>
|
||
{direction ? (
|
||
<div className="omni-strategy-direction">
|
||
<b>创作方向</b>
|
||
<span>{direction}</span>
|
||
</div>
|
||
) : null}
|
||
</section>
|
||
);
|
||
}
|
||
|
||
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<string, unknown>;
|
||
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<string, unknown> }) {
|
||
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 (
|
||
<section className="omni-video-plan-card">
|
||
<header className="omni-video-plan-head">
|
||
<strong>视频最终方案</strong>
|
||
<span>确认后进入出片参数核对</span>
|
||
</header>
|
||
<section className="omni-plan-section">
|
||
<strong>卖点做减法</strong>
|
||
<div className="omni-plan-points">
|
||
<span>
|
||
<b>主打卖点 USP</b>
|
||
<em className="omni-plan-point-body">{usp || "—"}</em>
|
||
</span>
|
||
{points.map((point, index) => (
|
||
<span key={`${point}-${index}`}>
|
||
<b>{pointLabels[index] || `核心支撑 P${index}`}</b>
|
||
<em className="omni-plan-point-body">{point}</em>
|
||
</span>
|
||
))}
|
||
</div>
|
||
</section>
|
||
{timeline.length > 0 ? (
|
||
<section className="omni-plan-section">
|
||
<strong>Hook 只负责留人,正文负责说服</strong>
|
||
<div className="omni-plan-timeline">
|
||
{timeline.map((item, index) => (
|
||
<span key={`${item.stage}-${index}`}>
|
||
<b>
|
||
{format(item.start)}–{format(item.end)} 秒 · {item.stage}
|
||
</b>
|
||
{item.desc || ""}
|
||
</span>
|
||
))}
|
||
</div>
|
||
</section>
|
||
) : null}
|
||
<div className="omni-plan-summary">
|
||
{voice.length === 2 ? (
|
||
<span>
|
||
口播目标{" "}
|
||
<b>
|
||
{voice[0]}–{voice[1]} 字
|
||
</b>
|
||
,预计覆盖约 90% 时长
|
||
</span>
|
||
) : null}
|
||
<span className="omni-plan-final-input">
|
||
出片准备:
|
||
<b>
|
||
已整合生成参数与 <em className="omni-plan-point-body">{String(payload.ref_count ?? 0)}</em> 组参考素材
|
||
</b>
|
||
</span>
|
||
</div>
|
||
</section>
|
||
);
|
||
}
|
||
|
||
function PromptFileCard({
|
||
payload,
|
||
onView,
|
||
}: {
|
||
payload: Record<string, unknown>;
|
||
onView: (title: string, body: string) => void;
|
||
}) {
|
||
const title = String(payload.title || "视频生成Prompt.md");
|
||
const body = String(payload.body || "").trim();
|
||
return (
|
||
<section className="omni-prompt-file-card">
|
||
<span className="omni-prompt-file-icon">
|
||
<FileText />
|
||
</span>
|
||
<div className="omni-prompt-file-copy">
|
||
<strong>出片指令已准备好</strong>
|
||
<small>已根据方案、参数和 {String(payload.ref_count ?? 0)} 组参考素材在后台整理完成</small>
|
||
</div>
|
||
{body ? (
|
||
<button type="button" onClick={() => onView(title, body)}>
|
||
查看 Prompt
|
||
</button>
|
||
) : null}
|
||
</section>
|
||
);
|
||
}
|
||
|
||
/** 视频阶段闸门:策略/方案下方的「按这个继续 / 我想改」。
|
||
* 「我想改」在卡片内展开反馈输入,不再依赖底部 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<string, string> | 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<HTMLTextAreaElement>(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 (
|
||
<section
|
||
className={`omni-step-confirm-card${submitted ? " is-submitted" : ""}${revising && !submitted ? " is-revising" : ""}`}
|
||
>
|
||
<div className="omni-step-confirm-top">
|
||
<div className="omni-step-confirm-copy">
|
||
<strong>{submitted ? (action === "revise" ? `已收到对${stepLabel}的修改意见` : `已确认${stepLabel}`) : revising ? `修改${stepLabel}` : `请确认${stepLabel}`}</strong>
|
||
<span>{submitted ? (action === "revise" ? "正在按你的反馈重写" : "继续下一步") : revising ? "写下你的想法,我们按这个重做这一步。" : (message.text || "确认后继续;要改可以直接说。")}</span>
|
||
</div>
|
||
{submitted || revising ? null : (
|
||
<div className="omni-step-confirm-actions">
|
||
<button
|
||
type="button"
|
||
className="is-secondary"
|
||
disabled={disabled}
|
||
onClick={(event) => {
|
||
event.stopPropagation();
|
||
setRevising(true);
|
||
}}
|
||
>
|
||
我想改
|
||
</button>
|
||
<button
|
||
type="button"
|
||
disabled={disabled}
|
||
onClick={(event) => {
|
||
event.stopPropagation();
|
||
onConfirm();
|
||
}}
|
||
>
|
||
按这个继续
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
{!submitted && revising ? (
|
||
<div className="omni-step-confirm-revise">
|
||
<textarea
|
||
ref={reviseRef}
|
||
className="omni-step-confirm-revise-input"
|
||
rows={3}
|
||
value={feedback}
|
||
disabled={disabled}
|
||
placeholder={placeholder}
|
||
onChange={(event) => setFeedback(event.target.value)}
|
||
onKeyDown={(event) => {
|
||
if (event.key === "Escape") {
|
||
event.preventDefault();
|
||
setRevising(false);
|
||
setFeedback("");
|
||
}
|
||
if (event.key === "Enter" && (event.metaKey || event.ctrlKey) && canSubmit && !disabled) {
|
||
event.preventDefault();
|
||
onReviseSubmit(feedback.trim());
|
||
}
|
||
}}
|
||
/>
|
||
<div className="omni-step-confirm-actions">
|
||
<button
|
||
type="button"
|
||
className="is-secondary"
|
||
disabled={disabled}
|
||
onClick={(event) => {
|
||
event.stopPropagation();
|
||
setRevising(false);
|
||
setFeedback("");
|
||
}}
|
||
>
|
||
取消
|
||
</button>
|
||
<button
|
||
type="button"
|
||
disabled={disabled || !canSubmit}
|
||
onClick={(event) => {
|
||
event.stopPropagation();
|
||
if (!canSubmit) return;
|
||
onReviseSubmit(feedback.trim());
|
||
}}
|
||
>
|
||
按这个重做
|
||
</button>
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
</section>
|
||
);
|
||
}
|
||
|
||
/** 追问默认是普通聊天气泡;下面的卡片只为兼容旧会话数据。 */
|
||
function ElicitCard({
|
||
message,
|
||
disabled,
|
||
onSubmit,
|
||
onChatAnswer,
|
||
}: {
|
||
message: CreationMessage;
|
||
disabled: boolean;
|
||
/** answers 给模型读(人话),refs 给后端取卖点和参考图。**选素材必须两样都回**,
|
||
只回名字的话同名素材会配错货。 */
|
||
onSubmit: (answers: Record<string, string | string[]>, 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<string, string | string[]> | undefined) || {};
|
||
const interaction = String(message.payload.interaction || "");
|
||
// hooks 必须在任何 early return 之前:step_confirm / chat / gate 共用同一组件身份
|
||
const [answers, setAnswers] = useState<Record<string, string | string[]>>(saved);
|
||
const [assetOptions, setAssetOptions] = useState<Record<string, CreationRef[]>>({});
|
||
const [assetPicked, setAssetPicked] = useState<Record<string, CreationRef>>({});
|
||
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<string>();
|
||
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 (
|
||
<StepConfirmCard
|
||
message={message}
|
||
disabled={disabled}
|
||
onConfirm={() => 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<HTMLFormElement>) => {
|
||
event.preventDefault();
|
||
const value = sellingPoint.trim();
|
||
if (!value || disabled) return;
|
||
onSubmit({ selling_point_mode: "manual", selling_point: value }, []);
|
||
};
|
||
return (
|
||
<div className="omni-elicit-slot">
|
||
<section className={`omni-selling-point-card${submitted ? " is-submitted" : ""}`}>
|
||
<header>
|
||
<div>
|
||
<strong>先确定核心卖点</strong>
|
||
<span>它会贯穿后面的策略、脚本和视频画面</span>
|
||
</div>
|
||
</header>
|
||
{submitted ? (
|
||
<p className="omni-selling-point-selected">
|
||
{savedMode === "auto" ? "已交给系统从商品和素材中推荐卖点" : `已使用:${String(saved.selling_point || "")}`}
|
||
</p>
|
||
) : (
|
||
<form onSubmit={submitManual}>
|
||
<input
|
||
className="input"
|
||
value={sellingPoint}
|
||
disabled={disabled}
|
||
placeholder="输入你最想让用户记住的真实卖点…"
|
||
aria-label="输入商品卖点"
|
||
onChange={(event) => setAnswers((prev) => ({ ...prev, selling_point: event.target.value }))}
|
||
/>
|
||
<div className="omni-selling-point-actions">
|
||
<button
|
||
type="button"
|
||
className="btn btn-sm btn-ghost"
|
||
disabled={disabled}
|
||
onClick={() => onSubmit({ selling_point_mode: "auto", selling_point: "" }, [])}
|
||
>
|
||
系统推荐卖点
|
||
</button>
|
||
<button type="submit" className="btn btn-sm btn-primary" disabled={disabled || !sellingPoint.trim()}>
|
||
使用这个卖点
|
||
</button>
|
||
</div>
|
||
</form>
|
||
)}
|
||
</section>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (interaction === "plot_twist_directions") {
|
||
const directions = Array.isArray(message.payload.directions)
|
||
? message.payload.directions
|
||
.filter((item): item is Record<string, unknown> => 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<HTMLFormElement>) => {
|
||
event.preventDefault();
|
||
const idea = customDirection.trim();
|
||
if (!idea || disabled) return;
|
||
setAnswers((prev) => ({ ...prev, story_direction: idea }));
|
||
setCustomDirection("");
|
||
};
|
||
return (
|
||
<div className="omni-elicit-slot omni-direction-slot">
|
||
<section className={`omni-direction-card${submitted ? " is-submitted" : ""}`}>
|
||
<header className="omni-direction-head">
|
||
<div>
|
||
<strong>三个剧情反转方向</strong>
|
||
<span>选择方向后确认,再继续展开完整方案</span>
|
||
</div>
|
||
</header>
|
||
<div className="omni-direction-list">
|
||
{directions.map((direction, index) => {
|
||
const isSelected = selected === direction.id || selected === direction.title;
|
||
return (
|
||
<button
|
||
type="button"
|
||
key={direction.id}
|
||
className={isSelected ? "is-selected" : ""}
|
||
disabled={disabled || submitted}
|
||
onClick={() => setAnswers((prev) => ({ ...prev, story_direction: direction.id }))}
|
||
>
|
||
<span className="omni-direction-index">方向 {index + 1}</span>
|
||
<strong>{direction.title}</strong>
|
||
<p><b>冲突</b>{direction.conflict}</p>
|
||
<p><b>商品作用</b>{direction.productRole}</p>
|
||
<p><b>反转</b>{direction.reversal}</p>
|
||
{direction.tone ? <small>{direction.tone}</small> : null}
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
{!submitted ? (
|
||
<>
|
||
<form className="omni-direction-custom" onSubmit={submitCustomDirection}>
|
||
<input
|
||
className="input"
|
||
value={customDirection}
|
||
disabled={disabled}
|
||
onChange={(event) => setCustomDirection(event.target.value)}
|
||
placeholder="或者写下你自己的剧情想法…"
|
||
aria-label="输入自己的剧情想法"
|
||
/>
|
||
<button type="submit" disabled={disabled || !customDirection.trim()}>选用这个想法</button>
|
||
</form>
|
||
<div className="omni-direction-confirm">
|
||
<span>{selectedDirection ? `已选:${selectedDirection.title}` : selected ? "已选自定义剧情方向" : "请先选择一个方向"}</span>
|
||
{selected ? (
|
||
<button
|
||
type="button"
|
||
className="btn btn-sm btn-ghost"
|
||
disabled={disabled}
|
||
onClick={() => setAnswers((prev) => ({ ...prev, story_direction: "" }))}
|
||
>
|
||
重新选择
|
||
</button>
|
||
) : null}
|
||
<button
|
||
type="button"
|
||
className="btn btn-sm btn-primary"
|
||
disabled={disabled || !selected}
|
||
onClick={() => onSubmit({ story_direction: selected }, [])}
|
||
>
|
||
确认选择
|
||
</button>
|
||
</div>
|
||
</>
|
||
) : null}
|
||
</section>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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 (
|
||
<div className="omni-chat-row agent">
|
||
<span className="omni-chat-avatar">
|
||
<Sparkles />
|
||
</span>
|
||
<div className="omni-chat-bubble omni-gate-bubble">
|
||
<ChatMarkdown text={message.text || gateLabel} />
|
||
{!submitted ? (
|
||
<div className="omni-gate-actions" aria-label="商品选择操作">
|
||
{(gateField?.options || []).map((option, index) => (
|
||
<button
|
||
type="button"
|
||
key={option.value}
|
||
className={index === 0 ? "primary" : ""}
|
||
disabled={disabled}
|
||
onClick={() => {
|
||
if (!gateField?.key) return;
|
||
onSubmit({ [gateField.key]: option.value }, []);
|
||
}}
|
||
>
|
||
{option.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
) : (
|
||
<p className="omni-gate-answered">
|
||
{selectedLabel ? `已选择:${selectedLabel}` : "已完成选择"}
|
||
</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (interaction === "chat") {
|
||
const question = message.text || fields[0]?.label || "这项你想怎么定?";
|
||
const submitChatAnswer = (event: FormEvent<HTMLFormElement>) => {
|
||
event.preventDefault();
|
||
const text = chatAnswer.trim();
|
||
if (!text || disabled) return;
|
||
onChatAnswer(text);
|
||
setChatAnswer("");
|
||
};
|
||
return (
|
||
<div className="omni-chat-row agent">
|
||
<span className="omni-chat-avatar">
|
||
<Sparkles />
|
||
</span>
|
||
<div className="omni-chat-stack">
|
||
<div className="omni-chat-bubble">
|
||
<ChatMarkdown text={question} />
|
||
</div>
|
||
{!submitted ? (
|
||
<div className="omni-reply-guide" aria-label="回复操作">
|
||
<form className="omni-chat-question-input" onSubmit={submitChatAnswer}>
|
||
<input
|
||
className="input"
|
||
value={chatAnswer}
|
||
disabled={disabled}
|
||
onChange={(event) => setChatAnswer(event.target.value)}
|
||
placeholder="输入你的想法…"
|
||
aria-label="回答这个问题"
|
||
/>
|
||
<button type="submit" aria-label="发送回答" disabled={disabled || !chatAnswer.trim()}>
|
||
<ArrowUp size={14} />
|
||
</button>
|
||
</form>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="omni-elicit-slot">
|
||
<section className={`omni-elicit-card${submitted ? " is-submitted" : ""}`}>
|
||
<header className="omni-elicit-head">
|
||
<strong>还需要你确认</strong>
|
||
<span>{submitted ? "已回答" : "选一下就好"}</span>
|
||
</header>
|
||
<div className="omni-elicit-body">
|
||
{fields.map((field) => {
|
||
const value = answers[field.key];
|
||
return (
|
||
<div className="omni-elicit-field" key={field.key}>
|
||
<label>{field.label}</label>
|
||
{(field.type === "single" || field.type === "multi") && (
|
||
<div className="omni-elicit-options">
|
||
{(field.options || []).map((option) => {
|
||
const active =
|
||
field.type === "multi"
|
||
? Array.isArray(value) && value.includes(option.value)
|
||
: value === option.value;
|
||
return (
|
||
<button
|
||
type="button"
|
||
key={option.value}
|
||
className={active ? "active" : ""}
|
||
onClick={() =>
|
||
field.type === "multi"
|
||
? toggleMulti(field.key, option.value)
|
||
: setAnswers((prev) => ({ ...prev, [field.key]: option.value }))
|
||
}
|
||
>
|
||
{option.label}
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
{field.type === "text" && (
|
||
<input
|
||
type="text"
|
||
placeholder={field.placeholder || ""}
|
||
value={typeof value === "string" ? value : ""}
|
||
disabled={submitted}
|
||
onChange={(event) =>
|
||
setAnswers((prev) => ({ ...prev, [field.key]: event.target.value }))
|
||
}
|
||
/>
|
||
)}
|
||
{field.type === "asset" && (
|
||
<div className="omni-elicit-assets">
|
||
{(assetOptions[field.key] || []).map((option) => (
|
||
<button
|
||
type="button"
|
||
key={option.id}
|
||
className={assetPicked[field.key]?.id === option.id ? "active" : ""}
|
||
onClick={() => {
|
||
// 高亮按 id 判,不按名字 —— 商品库里同名素材是常态
|
||
setAssetPicked((prev) => ({ ...prev, [field.key]: option }));
|
||
setAnswers((prev) => ({ ...prev, [field.key]: option.name }));
|
||
}}
|
||
>
|
||
<img src={option.cover || ""} alt="" />
|
||
<span>{option.name}</span>
|
||
</button>
|
||
))}
|
||
{(assetOptions[field.key] || []).length === 0 && <span>暂无可选素材</span>}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
{!submitted && (
|
||
<div className="omni-elicit-actions">
|
||
<button
|
||
type="button"
|
||
disabled={disabled || !complete}
|
||
onClick={() => onSubmit(answers, Object.values(assetPicked))}
|
||
>
|
||
提交
|
||
</button>
|
||
</div>
|
||
)}
|
||
</section>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 确认闸门:方案卡下面那条「开始生成 · 约 N 积分」。
|
||
* 点一次就锁住 —— 连点两下后端会 409,但前端也不该让它发生第二次。
|
||
*/
|
||
function asStringMap(value: unknown): Record<string, string> {
|
||
if (!value || typeof value !== "object") return {};
|
||
const out: Record<string, string> = {};
|
||
for (const [key, item] of Object.entries(value as Record<string, unknown>)) {
|
||
if (item != null && item !== "") out[key] = String(item);
|
||
}
|
||
return out;
|
||
}
|
||
|
||
function paramLine(params: Record<string, string>, 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<string, string>;
|
||
isVideo: boolean;
|
||
catalogModels?: ModelConfig[];
|
||
disabled: boolean;
|
||
onConfirm: (params: Record<string, string>) => 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 (
|
||
<section className="omni-confirm-card">
|
||
<div className="omni-confirm-copy">
|
||
<strong>{submitted ? "已确认" : `即将用 ${summary} 生成`}</strong>
|
||
<span>{submitted ? "正在提交生成" : "确认前可以改参数。改时长会按新时长重写脚本。"}</span>
|
||
</div>
|
||
{submitted ? null : (
|
||
<div className="omni-confirm-params">
|
||
<OmniParamBar
|
||
isVideo={cardIsVideo}
|
||
disabled={disabled}
|
||
catalogModels={catalogModels}
|
||
model={draft.model || ""}
|
||
resolution={draft.resolution || ""}
|
||
ratio={draft.ratio || ""}
|
||
duration={cardIsVideo ? (draft.duration || "") : (draft.count || "")}
|
||
onModel={(value) => setField("model", value)}
|
||
onResolution={(value) => setField("resolution", value)}
|
||
onRatio={(value) => setField("ratio", value)}
|
||
onDuration={(value) => setField(cardIsVideo ? "duration" : "count", value)}
|
||
/>
|
||
</div>
|
||
)}
|
||
{durationChanged ? (
|
||
<p className="omni-confirm-hint">改时长会重新生成脚本。确认后先重写方案,不会直接出片。</p>
|
||
) : willGenerateInSegments ? (
|
||
<p className="omni-confirm-hint">{durationSeconds} 秒将拆成 2 段生成,每段最长 30 秒;片段完成后先给你预览,再由你决定是否合并成片。</p>
|
||
) : null}
|
||
<div className="omni-confirm-foot">
|
||
<span>{submitted ? "已确认,正在出片" : "点确认后才会提交生成"}</span>
|
||
<button type="button" disabled={disabled || submitted} onClick={() => onConfirm(draft)}>
|
||
{durationChanged ? "确认并重写脚本" : String(message.payload.label || "开始生成")}
|
||
{!durationChanged && credits > 0 ? <i>约 {credits} 积分</i> : null}
|
||
</button>
|
||
</div>
|
||
</section>
|
||
);
|
||
}
|
||
|
||
function ResultCard({
|
||
payload,
|
||
onMerge,
|
||
merging,
|
||
}: {
|
||
payload: Record<string, unknown>;
|
||
onMerge?: () => void;
|
||
merging?: boolean;
|
||
}) {
|
||
const assets = (payload.assets as Array<Record<string, string>> | 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 (
|
||
<section className="omni-result-card">
|
||
<div className={`omni-result-media${assets.length > 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 (
|
||
<figure className="omni-result-tile" key={asset.id || `${url}-${index}`}>
|
||
<button
|
||
type="button"
|
||
className="omni-result-preview"
|
||
onClick={() => url && setPreview({ src: url, kind: video ? "video" : "image", name: label })}
|
||
>
|
||
<img src={cover} alt={label} />
|
||
{video ? (
|
||
<span className="omni-result-play" aria-hidden="true">
|
||
<Play />
|
||
</span>
|
||
) : null}
|
||
</button>
|
||
{url ? (
|
||
<a
|
||
className="omni-result-dl"
|
||
href={url}
|
||
download
|
||
target="_blank"
|
||
rel="noreferrer"
|
||
title="下载"
|
||
aria-label="下载"
|
||
onClick={(event) => event.stopPropagation()}
|
||
>
|
||
<Download />
|
||
</a>
|
||
) : null}
|
||
</figure>
|
||
);
|
||
})}
|
||
</div>
|
||
<div className="omni-result-info">
|
||
<div>
|
||
<strong>{
|
||
isGeneratedVideo
|
||
? payload.needs_merge ? `已生成 ${assets.length} 段视频` : "视频已生成"
|
||
: assets.length > 1 ? `已生成 ${assets.length} 张图` : "图片已生成"
|
||
}</strong>
|
||
<small>{payload.needs_merge ? "请先预览片段,确认后再合并成片" : meta}</small>
|
||
</div>
|
||
{payload.needs_merge ? (
|
||
<button
|
||
type="button"
|
||
disabled={merging || payload.merge_state === "queued" || payload.merge_state === "processing" || payload.merge_state === "completed"}
|
||
onClick={onMerge}
|
||
>
|
||
{payload.merge_state === "completed" ? "已合并" : merging || payload.merge_state === "queued" || payload.merge_state === "processing" ? "正在合并" : "合并成片"}
|
||
</button>
|
||
) : null}
|
||
</div>
|
||
<MediaLightbox
|
||
open={Boolean(preview?.src)}
|
||
src={preview?.src || ""}
|
||
kind={preview?.kind}
|
||
name={preview?.name}
|
||
close={() => setPreview(null)}
|
||
/>
|
||
</section>
|
||
);
|
||
}
|
||
|
||
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<string, unknown>, b: Record<string, unknown>) {
|
||
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<string>();
|
||
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<string>();
|
||
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<string, unknown> | 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<string, unknown>;
|
||
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<Record<string, string>> | undefined) || [];
|
||
const completedSegments = Number(payload.completed_segment_count || 0);
|
||
const waitingSegments = isSegmentedVideo ? Math.max(1, segmentCount - completedSegments) : 1;
|
||
return (
|
||
<section className="omni-result-card omni-process-card">
|
||
<div className={`omni-result-media${isSegmentedVideo ? " 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 || `第 ${index + 1} 段`);
|
||
return (
|
||
<figure className="omni-result-tile" key={asset.id || `${url}-${index}`}>
|
||
<button
|
||
type="button"
|
||
className="omni-result-preview"
|
||
onClick={() => url && onPreview?.(url, video ? "video" : "image", label)}
|
||
>
|
||
<img src={cover} alt={label} />
|
||
{video ? (
|
||
<span className="omni-result-play" aria-hidden="true">
|
||
<Play />
|
||
</span>
|
||
) : null}
|
||
</button>
|
||
</figure>
|
||
);
|
||
})}
|
||
{Array.from({ length: waitingSegments }).map((_, index) => (
|
||
<figure className="omni-result-tile" key={`generating-${index}`}>
|
||
<div className="omni-process-frame" aria-hidden="true">
|
||
<span className="omni-process-ring" />
|
||
<strong>{isVideo ? "视频生成中" : "图片生成中"}</strong>
|
||
</div>
|
||
</figure>
|
||
))}
|
||
</div>
|
||
<div className="omni-result-info">
|
||
<div>
|
||
<strong>{
|
||
isMerge
|
||
? "正在合并成片"
|
||
: isSegmentedVideo && completedSegments
|
||
? `第 ${completedSegments} 段已生成,正在生成第 ${completedSegments + 1} 段`
|
||
: isSegmentedVideo
|
||
? `正在生成 ${segmentCount || 2} 段视频`
|
||
: isVideo ? "正在生成视频" : "正在出图"
|
||
}</strong>
|
||
<small>{
|
||
isMerge
|
||
? "正在拼接已确认的片段"
|
||
: isSegmentedVideo && completedSegments
|
||
? "已生成的片段可以先点击预览"
|
||
: isSegmentedVideo ? "片段完成后会先展示给你确认" : isVideo ? "方案编译中,请稍候" : "画面生成中,请稍候"
|
||
}</small>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
);
|
||
}
|
||
|
||
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<string | null>(
|
||
(firstMessage?.trim() || (firstRefs && firstRefs.length > 0))
|
||
? `local-user-${Date.now()}`
|
||
: null
|
||
);
|
||
const [conversation, setConversation] = useState<CreationConversationDetail | null>(null);
|
||
const [messages, setMessages] = useState<CreationMessage[]>(() => {
|
||
const id = bootLocalIdRef.current;
|
||
if (!id) return [];
|
||
return [makeLocalUserMessage(firstMessage?.trim() || "", firstRefs || [], id)];
|
||
});
|
||
const [prompt, setPrompt] = useState("");
|
||
const [composerHint, setComposerHint] = useState<string | null>(null);
|
||
const [pendingRefs, setPendingRefs] = useState<CreationRef[]>([]);
|
||
const [sessionUploads, setSessionUploads] = useState<CreationRef[]>(firstUploads || []);
|
||
const [uploading, setUploading] = useState(false);
|
||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||
// 对话引导里的「上传人物图」要直达系统文件选择器,而不是绕回通用素材菜单。
|
||
const quickUploadReplyRef = useRef<ReplyOption | null>(null);
|
||
const composerRef = useRef<HTMLTextAreaElement>(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<CreationRef["type"]>("asset");
|
||
const [mentionLoading, setMentionLoading] = useState(false);
|
||
const [mentionResults, setMentionResults] = useState<CreationRef[]>([]);
|
||
const [typeLabels, setTypeLabels] = useState<Record<string, string>>({});
|
||
const mentionWrapRef = useRef<HTMLDivElement>(null);
|
||
const [uploadMenuOpen, setUploadMenuOpen] = useState(false);
|
||
const [confirming, setConfirming] = useState(false);
|
||
const [mergingMessageIds, setMergingMessageIds] = useState<string[]>([]);
|
||
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<string | null>(() => 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<HTMLElement>(null);
|
||
const firstSentRef = useRef(false);
|
||
const pendingUserIdRef = useRef<string | null>(bootLocalIdRef.current);
|
||
/** 发送时已见过的最大服务端 seq;只有更新的用户消息才能 ack 当前 pending local */
|
||
const pendingSinceSeqRef = useRef(0);
|
||
const messagesRef = useRef<CreationMessage[]>([]);
|
||
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<typeof api.creationSend>[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<string, string>) => {
|
||
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 (
|
||
<ElicitCard
|
||
key={message.clientKey || message.id}
|
||
message={message}
|
||
// 可见性只看 payload.submitted;awaiting_user 时即使 streaming 瞬间为 true 也不灰掉/误伤闸门按钮
|
||
disabled={
|
||
Boolean(message.payload?.submitted)
|
||
|| (streaming && conversation?.agent_status !== "awaiting_user")
|
||
}
|
||
onSubmit={(answers, refs) => {
|
||
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<string, string> = {
|
||
"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<string, unknown>,
|
||
},
|
||
}
|
||
: item
|
||
)
|
||
);
|
||
void send({ kind: "elicit_answer", reply_to: message.id, answers, refs });
|
||
}}
|
||
onChatAnswer={(text) => void send({ kind: "text", text })}
|
||
/>
|
||
);
|
||
case "strategy":
|
||
return <StrategyCard key={message.clientKey || message.id} payload={message.payload} />;
|
||
case "plan":
|
||
return <PlanCard key={message.clientKey || message.id} payload={message.payload} />;
|
||
case "prompt_file":
|
||
return (
|
||
<PromptFileCard
|
||
key={message.clientKey || message.id}
|
||
payload={message.payload}
|
||
onView={(title, body) => setPromptView({ title, body })}
|
||
/>
|
||
);
|
||
case "confirm": {
|
||
// 视频:若仍有未确认的步骤闸门,禁止点「开始生成」(防旧会话/异常连跳)
|
||
const pendingStep = isVideo
|
||
? messages.some(
|
||
(item) =>
|
||
item.kind === "elicit" &&
|
||
item.payload?.interaction === "step_confirm" &&
|
||
!item.payload?.submitted
|
||
)
|
||
: false;
|
||
return (
|
||
<ConfirmCard
|
||
key={message.clientKey || message.id}
|
||
message={message}
|
||
sessionParams={params}
|
||
isVideo={isVideo}
|
||
catalogModels={modelConfigs}
|
||
disabled={
|
||
confirming
|
||
|| pendingStep
|
||
|| (streaming && conversation?.agent_status !== "awaiting_user")
|
||
}
|
||
onConfirm={(nextParams) => void handleConfirm(message, nextParams)}
|
||
/>
|
||
);
|
||
}
|
||
case "generating":
|
||
return (
|
||
<ProcessCard
|
||
key={message.clientKey || message.id}
|
||
payload={message.payload}
|
||
onPreview={(src, kind, name) => setAssetPreview({ src, kind, name })}
|
||
/>
|
||
);
|
||
case "result":
|
||
return (
|
||
<ResultCard
|
||
key={message.clientKey || message.id}
|
||
payload={message.payload}
|
||
merging={mergingMessageIds.includes(message.id)}
|
||
onMerge={message.payload?.needs_merge ? () => 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 (
|
||
<div
|
||
className={`omni-chat-row ${message.role === "user" ? "is-user" : "agent"}${pending ? " is-pending" : ""}`}
|
||
key={message.clientKey || message.id}
|
||
>
|
||
{message.role !== "user" && (
|
||
<span className="omni-chat-avatar">
|
||
<Sparkles />
|
||
</span>
|
||
)}
|
||
<div className={`omni-chat-stack${pending ? " is-pending" : ""}`}>
|
||
{message.role === "user" ? <MentionChips refs={refs} tone="user" /> : null}
|
||
{(body || message.role !== "user") ? (
|
||
<div className="omni-chat-bubble" aria-busy={pending || undefined}>
|
||
{body ? (message.role === "assistant" ? <ChatMarkdown text={body} /> : <p className="omni-chat-text">{body}</p>) : null}
|
||
</div>
|
||
) : null}
|
||
{showReplyGuide ? (
|
||
<div className="omni-reply-guide" aria-label="回复操作">
|
||
<ReplyActions
|
||
options={replyOptions}
|
||
disabled={streaming}
|
||
onSubmit={(text) => void send({ kind: "text", text })}
|
||
onUpload={(option) => {
|
||
if (streaming || uploading) return;
|
||
quickUploadReplyRef.current = option;
|
||
fileInputRef.current?.click();
|
||
}}
|
||
/>
|
||
</div>
|
||
) : null}
|
||
{message.role === "user" && !pending ? (
|
||
<div className="omni-user-message-actions">
|
||
<time>{messageTime(message.created_at)}</time>
|
||
<button
|
||
type="button"
|
||
title="复制"
|
||
aria-label="复制消息"
|
||
disabled={streaming}
|
||
onClick={() => void copyUserMessage(body)}
|
||
>
|
||
<Copy />
|
||
</button>
|
||
<button
|
||
type="button"
|
||
title="编辑"
|
||
aria-label="编辑消息"
|
||
disabled={streaming}
|
||
onClick={() => editUserMessage(message)}
|
||
>
|
||
<Pencil />
|
||
</button>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
}
|
||
};
|
||
|
||
const [topbarSlot, setTopbarSlot] = useState<HTMLElement | null>(null);
|
||
useLayoutEffect(() => {
|
||
setTopbarSlot(document.getElementById("omni-session-topbar-slot"));
|
||
}, []);
|
||
|
||
const sessionHeader = (
|
||
<header className="omni-session-header">
|
||
<button type="button" className="omni-session-back" onClick={() => navigate("omniHistory")}>
|
||
<ArrowLeft />
|
||
<span>创作历史</span>
|
||
</button>
|
||
<div>
|
||
<h1>{conversation?.title || "未命名创作"}</h1>
|
||
<div className="omni-session-meta">
|
||
{[
|
||
conversation?.preset || "自由创作",
|
||
params.model,
|
||
params.resolution && params.ratio
|
||
? `${params.resolution} · ${params.ratio}`
|
||
: params.ratio,
|
||
params.duration,
|
||
]
|
||
.filter(Boolean)
|
||
.map((text) => (
|
||
<span key={String(text)}>{text}</span>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</header>
|
||
);
|
||
|
||
return (
|
||
<section className={`page-view omni-session-page${assetsOpen ? " has-assets-panel" : ""}`}>
|
||
{topbarSlot ? createPortal(sessionHeader, topbarSlot) : sessionHeader}
|
||
<div className="omni-session-shell">
|
||
<button
|
||
type="button"
|
||
className={`omni-assets-entry${assetsOpen ? " is-open" : ""}`}
|
||
aria-expanded={assetsOpen}
|
||
aria-controls="omni-session-assets-drawer"
|
||
title="查看对话资源"
|
||
onClick={() => setAssetsOpen((open) => !open)}
|
||
>
|
||
<List />
|
||
<span>查看对话资源</span>
|
||
</button>
|
||
|
||
<main className="omni-session-feed" ref={feedRef} aria-live="polite">
|
||
{visibleMessages.map(renderMessage)}
|
||
{/* 流式中的临时气泡。挂 is-live 关掉入场动画 —— 它每来一个字符都会
|
||
重渲染,带动画的话整条会一直在闪;真消息落地时它被替换掉,
|
||
那一下也不该再演一次入场。 */}
|
||
{(conversation?.agent_status !== "awaiting_user") && (streaming || conversation?.agent_status === "planning") && !hasGenerating ? (
|
||
/生成图片|生成视频/.test(activeTool) && !liveText ? (
|
||
<ProcessCard payload={{ kind: /生成视频/.test(activeTool) ? "video" : "image" }} />
|
||
) : liveText ? (
|
||
<div className="omni-chat-row agent is-live">
|
||
<span className="omni-chat-avatar">
|
||
<Sparkles />
|
||
</span>
|
||
<div className="omni-chat-bubble">
|
||
<ChatMarkdown text={liveText} />
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<div className="omni-chat-row agent is-live" aria-label="正在回复">
|
||
<span className="omni-chat-avatar">
|
||
<Sparkles />
|
||
</span>
|
||
<div className={`omni-chat-bubble${liveReasoning ? " is-reasoning" : " is-thinking"}`}>
|
||
<div className="omni-think-head">
|
||
<span className="omni-typing" aria-hidden="true"><i /><i /><i /></span>
|
||
<span>{conversation?.agent_status === "planning" || streaming ? (isVideo ? "正在整理方案" : "正在整理画面") : activeTool ? `${activeTool}…` : liveReasoning ? "思考中" : isVideo ? "正在整理方案" : "正在整理画面"}</span>
|
||
</div>
|
||
{liveReasoning ? <p className="omni-think-text">{liveReasoning}</p> : null}
|
||
</div>
|
||
</div>
|
||
)
|
||
) : null}
|
||
</main>
|
||
|
||
<footer className={`omni-session-composer${composerHint ? " is-revise-hint" : ""}`}>
|
||
<MentionChips
|
||
refs={pendingRefs}
|
||
tone="composer"
|
||
onRemove={(id) => setPendingRefs((prev) => prev.filter((ref) => ref.id !== id))}
|
||
/>
|
||
<textarea
|
||
id="omniSessionPrompt"
|
||
ref={composerRef}
|
||
rows={2}
|
||
placeholder={composerHint || "回复创作助手,也可以继续补充图片或要求……"}
|
||
value={prompt}
|
||
disabled={uploading || (streaming && conversation?.agent_status !== "awaiting_user")}
|
||
onChange={(event) => {
|
||
const value = event.target.value;
|
||
setPrompt(value);
|
||
const caret = event.target.selectionStart ?? 0;
|
||
if (caret > 0 && value.charAt(caret - 1) === "@") void openMentions();
|
||
}}
|
||
onKeyDown={(event) => {
|
||
if (event.key === "Enter" && !event.shiftKey) {
|
||
event.preventDefault();
|
||
handleSend();
|
||
}
|
||
}}
|
||
/>
|
||
<div className="omni-session-composer-tools">
|
||
<div className="omni-session-composer-options">
|
||
<div className={`omni-session-upload-wrap${uploading ? " is-uploading" : ""}`}>
|
||
<button
|
||
type="button"
|
||
className="omni-icon-tool"
|
||
aria-label={uploading ? "上传审核中" : "添加素材"}
|
||
title={uploading ? "上传并审核中…" : "添加素材"}
|
||
disabled={uploading || streaming}
|
||
onClick={() => {
|
||
if (uploading || streaming) return;
|
||
quickUploadReplyRef.current = null;
|
||
setUploadMenuOpen((open) => !open);
|
||
setMentionMenuOpen(false);
|
||
}}
|
||
>
|
||
{uploading ? <span className="omni-send-spinner" /> : <Plus />}
|
||
</button>
|
||
{uploading ? <span className="omni-upload-status">上传审核中…</span> : null}
|
||
<div className="omni-upload-menu" hidden={!uploadMenuOpen}>
|
||
<strong>添加参考素材</strong>
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
setUploadMenuOpen(false);
|
||
void openMentions();
|
||
}}
|
||
>
|
||
<FolderOpen />
|
||
<span>
|
||
从资产库选择<small>引用已有商品、人物或场景</small>
|
||
</span>
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
quickUploadReplyRef.current = null;
|
||
setUploadMenuOpen(false);
|
||
fileInputRef.current?.click();
|
||
}}
|
||
>
|
||
<Upload />
|
||
<span>
|
||
本地上传<small>添加电脑中的图片</small>
|
||
</span>
|
||
</button>
|
||
</div>
|
||
<input
|
||
ref={fileInputRef}
|
||
type="file"
|
||
accept="image/*"
|
||
multiple
|
||
hidden
|
||
onChange={async (event) => {
|
||
const files = Array.from(event.target.files || []);
|
||
event.target.value = "";
|
||
if (!files.length) return;
|
||
const images = files.filter((file) => file.type.startsWith("image/"));
|
||
if (images.length !== files.length) {
|
||
notify("info", "全能创作仅支持上传图片");
|
||
}
|
||
if (!images.length) return;
|
||
setUploading(true);
|
||
try {
|
||
const quickReply = quickUploadReplyRef.current;
|
||
const uploadedRefs: CreationRef[] = [];
|
||
for (const file of images) {
|
||
const form = new FormData();
|
||
form.append("file", file);
|
||
// 后端上传时同步送审并等待通过,期间按钮保持「上传中」
|
||
const data = await api.uploadFreeVideoRef(form);
|
||
const ref: CreationRef = {
|
||
type: "asset",
|
||
id: data.asset_id,
|
||
name: data.name || file.name,
|
||
cover: data.thumb_url || data.url,
|
||
};
|
||
setSessionUploads((prev) => (prev.some((item) => item.id === ref.id) ? prev : [...prev, ref]));
|
||
uploadedRefs.push(ref);
|
||
if (!quickReply) {
|
||
setPendingRefs((prev) => {
|
||
if (prev.some((item) => item.id === ref.id) || prev.length >= MENTION_REF_LIMIT) return prev;
|
||
return [...prev, ref];
|
||
});
|
||
}
|
||
}
|
||
notify("success", images.length > 1 ? "素材已上传并通过审核" : "素材已上传并通过审核");
|
||
if (quickReply && uploadedRefs.length) {
|
||
quickUploadReplyRef.current = null;
|
||
void send({ kind: "text", text: uploadReplyText(quickReply), refs: uploadedRefs });
|
||
}
|
||
} catch (error) {
|
||
notify("error", (error as Error).message);
|
||
quickUploadReplyRef.current = null;
|
||
} finally {
|
||
setUploading(false);
|
||
}
|
||
}}
|
||
/>
|
||
</div>
|
||
<div className="omni-session-mention-wrap" ref={mentionWrapRef}>
|
||
<button
|
||
type="button"
|
||
className="omni-icon-tool"
|
||
aria-label="引用素材"
|
||
onClick={() => {
|
||
if (mentionMenuOpen) setMentionMenuOpen(false);
|
||
else void openMentions("", mentionTab);
|
||
}}
|
||
>
|
||
@
|
||
</button>
|
||
<div className="omni-session-mention-menu" hidden={!mentionMenuOpen} role="dialog" aria-label="引用素材">
|
||
<div className="omni-at-cats" role="tablist">
|
||
{MENTION_TABS.map((tab) => (
|
||
<button
|
||
type="button"
|
||
key={tab.type}
|
||
role="tab"
|
||
aria-selected={mentionTab === tab.type}
|
||
className={mentionTab === tab.type ? "is-on" : ""}
|
||
onClick={() => { if (mentionTab !== tab.type) void openMentions("", tab.type); }}
|
||
>
|
||
<tab.Icon size={14} />
|
||
{tab.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
<div className="omni-at-list">
|
||
{mentionLoading ? (
|
||
<div className="omni-at-loading" aria-live="polite">
|
||
<span className="omni-send-spinner" />
|
||
加载中
|
||
</div>
|
||
) : mentionResults.length === 0 ? (
|
||
<strong>这类还没有可引用的内容</strong>
|
||
) : (
|
||
mentionResults.map((ref) => (
|
||
<button type="button" key={ref.id} onClick={() => insertMention(ref)}>
|
||
{ref.cover ? <img src={ref.cover} alt="" /> : <Play />}
|
||
<span>
|
||
{ref.name.split(" · ")[0]}
|
||
<small>{typeLabels[ref.type] || MENTION_TABS.find((tab) => tab.type === ref.type)?.label}</small>
|
||
</span>
|
||
</button>
|
||
))
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
className={`omni-session-send${isPlanning ? " is-stop" : ""}`}
|
||
aria-label={isPlanning ? "终止" : "发送"}
|
||
title={isPlanning ? "终止整理方案" : "发送"}
|
||
disabled={
|
||
isPlanning
|
||
? stopping
|
||
: (
|
||
uploading
|
||
|| (streaming && conversation?.agent_status !== "awaiting_user")
|
||
)
|
||
}
|
||
onClick={() => {
|
||
if (isPlanning) void handleStop();
|
||
else handleSend();
|
||
}}
|
||
>
|
||
{isPlanning ? <Square fill="currentColor" /> : <ArrowUp />}
|
||
</button>
|
||
</div>
|
||
</footer>
|
||
</div>
|
||
{promptView ? (
|
||
<>
|
||
<button type="button" className="omni-prompt-drawer-bg" aria-label="关闭 Prompt" onClick={() => setPromptView(null)} />
|
||
<aside className="omni-prompt-drawer" role="dialog" aria-label={promptView.title}>
|
||
<header>
|
||
<span className="omni-prompt-drawer-file">
|
||
<FileText />
|
||
</span>
|
||
<div>
|
||
<strong>{promptView.title}</strong>
|
||
<small>{documentDescription(promptView.title)}</small>
|
||
</div>
|
||
<button type="button" onClick={() => setPromptView(null)} aria-label="关闭">
|
||
<X />
|
||
</button>
|
||
</header>
|
||
<div className="omni-prompt-doc">
|
||
{promptBlocks(promptView.body).map((block, index) => {
|
||
if (block.type === "meta") {
|
||
return (
|
||
<article className="omni-prompt-block is-meta" key={`meta-${index}`}>
|
||
<h3>视频参数</h3>
|
||
<dl className="omni-prompt-meta">
|
||
{block.entries.map((entry) => (
|
||
<div key={`${entry.label}-${entry.value}`}>
|
||
<dt>{entry.label}</dt>
|
||
<dd>{entry.value}</dd>
|
||
</div>
|
||
))}
|
||
</dl>
|
||
</article>
|
||
);
|
||
}
|
||
if (block.type === "table") {
|
||
return (
|
||
<article className="omni-prompt-block is-table" key={`table-${index}`}>
|
||
<div className="omni-prompt-table-wrap">
|
||
<table className="omni-prompt-table">
|
||
<thead>
|
||
<tr>
|
||
{block.headers.map((header) => (
|
||
<th key={header}>{header}</th>
|
||
))}
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{block.rows.map((row, rowIndex) => (
|
||
<tr key={`row-${rowIndex}`}>
|
||
{block.headers.map((_, colIndex) => (
|
||
<td key={`cell-${rowIndex}-${colIndex}`}>{row[colIndex] || ""}</td>
|
||
))}
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</article>
|
||
);
|
||
}
|
||
if (block.type === "shot") {
|
||
return (
|
||
<article className="omni-prompt-block is-shot" key={`shot-${block.heading}-${index}`}>
|
||
<h3>{block.heading}</h3>
|
||
{block.fields.length ? (
|
||
<dl className="omni-prompt-shot-fields">
|
||
{block.fields.map((field) => (
|
||
<div key={`${field.label}-${field.value.slice(0, 12)}`}>
|
||
<dt>{field.label}</dt>
|
||
<dd>{field.value}</dd>
|
||
</div>
|
||
))}
|
||
</dl>
|
||
) : null}
|
||
{block.text ? <p>{block.text}</p> : null}
|
||
</article>
|
||
);
|
||
}
|
||
return (
|
||
<article className="omni-prompt-block" key={`text-${block.heading}-${index}`}>
|
||
{block.heading ? <h3>{block.heading}</h3> : null}
|
||
{block.text ? <p>{block.text}</p> : null}
|
||
</article>
|
||
);
|
||
})}
|
||
</div>
|
||
</aside>
|
||
</>
|
||
) : null}
|
||
{assetsOpen ? (
|
||
<aside
|
||
id="omni-session-assets-drawer"
|
||
className="drawer omni-assets-drawer show"
|
||
role="dialog"
|
||
aria-label="对话资源"
|
||
>
|
||
<header className="drawer-h omni-assets-drawer-head">
|
||
<div>
|
||
<strong>资源</strong>
|
||
<small>{sessionAssets.length ? `本会话 · ${sessionAssets.length} 项` : "仅展示本会话素材"}</small>
|
||
</div>
|
||
<div className="omni-assets-panel-tools">
|
||
<div className="view-toggle" aria-label="资源显示方式">
|
||
<button
|
||
type="button"
|
||
className={assetView === "grid" ? "active" : ""}
|
||
aria-label="网格显示"
|
||
title="网格显示"
|
||
onClick={() => setAssetView("grid")}
|
||
>
|
||
<Grid2X2 />
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className={assetView === "list" ? "active" : ""}
|
||
aria-label="列表显示"
|
||
title="列表显示"
|
||
onClick={() => setAssetView("list")}
|
||
>
|
||
<List />
|
||
</button>
|
||
</div>
|
||
<label className="omni-assets-search">
|
||
<Search />
|
||
<input
|
||
value={assetQuery}
|
||
onChange={(event) => setAssetQuery(event.target.value)}
|
||
placeholder="搜索资源…"
|
||
aria-label="搜索对话资源"
|
||
/>
|
||
</label>
|
||
<button type="button" className="x" onClick={() => setAssetsOpen(false)} aria-label="关闭资源">
|
||
<X />
|
||
</button>
|
||
</div>
|
||
</header>
|
||
<div className="drawer-b omni-assets-drawer-body">
|
||
{sessionAssets.length === 0 ? (
|
||
<div className="omni-assets-empty" role="status">
|
||
<FolderOpen />
|
||
<p>本会话还没有素材</p>
|
||
</div>
|
||
) : visibleSessionAssetGroups.length === 0 ? (
|
||
<div className="omni-assets-empty" role="status">
|
||
<Search />
|
||
<p>没有找到相关资源</p>
|
||
</div>
|
||
) : (
|
||
visibleSessionAssetGroups.map((group) => (
|
||
<section className="omni-assets-group" key={group.key}>
|
||
<h3>
|
||
{group.label}
|
||
<em>{group.items.length}</em>
|
||
</h3>
|
||
<div className={`omni-assets-grid${group.key === "document" || assetView === "list" ? " is-list" : ""}`}>
|
||
{group.items.map((asset) => {
|
||
if (asset.kind === "document") {
|
||
return (
|
||
<button
|
||
type="button"
|
||
className="omni-assets-card is-doc"
|
||
key={asset.key}
|
||
onClick={() => {
|
||
setPromptView({
|
||
title: asset.promptTitle || asset.title,
|
||
body: asset.promptBody || "暂无内容",
|
||
});
|
||
}}
|
||
>
|
||
<span className="omni-assets-thumb is-doc">
|
||
<FileText />
|
||
</span>
|
||
<span className="omni-assets-meta">
|
||
<strong>{asset.title}</strong>
|
||
<small>{asset.documentDescription || "文档 · 点击查看"}</small>
|
||
</span>
|
||
</button>
|
||
);
|
||
}
|
||
return (
|
||
<button
|
||
type="button"
|
||
className={`omni-assets-card is-${asset.kind}`}
|
||
key={asset.key}
|
||
disabled={!asset.src}
|
||
onClick={() => {
|
||
if (!asset.src) return;
|
||
setAssetPreview({
|
||
src: asset.src,
|
||
kind: asset.kind === "video" ? "video" : "image",
|
||
name: asset.title,
|
||
});
|
||
}}
|
||
>
|
||
<span className="omni-assets-thumb">
|
||
{asset.thumb ? (
|
||
<img src={asset.thumb} alt="" />
|
||
) : (
|
||
<i>{asset.kind === "video" ? <Play /> : <Image />}</i>
|
||
)}
|
||
{asset.kind === "video" ? (
|
||
<span className="omni-assets-play" aria-hidden="true">
|
||
<Play />
|
||
</span>
|
||
) : null}
|
||
</span>
|
||
<span className="omni-assets-meta">
|
||
<strong>{asset.title}</strong>
|
||
<small>{asset.kind === "video" ? "视频" : "图片"}</small>
|
||
</span>
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
</section>
|
||
))
|
||
)}
|
||
</div>
|
||
</aside>
|
||
) : null}
|
||
<MediaLightbox
|
||
open={Boolean(assetPreview?.src)}
|
||
src={assetPreview?.src || ""}
|
||
kind={assetPreview?.kind}
|
||
name={assetPreview?.name}
|
||
close={() => setAssetPreview(null)}
|
||
/>
|
||
</section>
|
||
);
|
||
}
|