Files
yingqing/core/frontend/src/routes/omni-session.tsx
T
2026-09-16 16:52:01 +08:00

3424 lines
132 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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.submittedawaiting_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>
);
}