大量优化全能创作

This commit is contained in:
Azmat@qq.com
2026-09-16 16:07:56 +08:00
parent a95cd159db
commit 7f5307d97c
9 changed files with 1155 additions and 65 deletions
+7
View File
@@ -601,6 +601,13 @@ export const api = {
}
);
},
/** 分段视频全部完成后,用户明确确认才触发服务端拼接。 */
mergeCreationVideoSegments(id: string, messageId: string) {
return request<{ message: CreationMessage }>(`/api/ai/creations/${id}/merge-video-segments/`, {
method: "POST",
body: JSON.stringify({ message_id: messageId }),
});
},
/**
* 发一条消息 → 异步整理方案。成功 **202** `{conversation_id, agent_status}`
* 闸门短路径可能 **200** 带 `messages`;冲突 **409**(团队已有对话在整理方案)。
+129 -10
View File
@@ -281,34 +281,81 @@
.omni-reply-custom-input {
display: flex;
flex: 1 1 220px;
flex: 1 1 260px;
gap: 6px;
width: min(300px, 100%);
max-width: 300px;
}
.omni-reply-custom-input .input {
min-width: 0;
height: 30px;
padding: 0 10px;
height: 36px;
padding: 0 12px;
font-size: 12px;
}
.omni-reply-custom-input button {
flex: 0 0 30px;
width: 30px;
flex: 0 0 36px;
width: 36px;
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
}
/* 小云雀式跟随追问:答案留在问题附近,尺寸不抢占对话空间。 */
.omni-chat-question-input {
display: flex;
align-items: center;
gap: 6px;
width: min(300px, 100%);
}
.omni-chat-question-input .input {
min-width: 0;
height: 36px;
padding: 0 12px;
font-size: 12px;
border-color: var(--border-faint);
background: var(--surface);
}
.omni-chat-question-input button {
flex: 0 0 36px;
width: 36px;
height: 36px;
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
border: 1px solid var(--border-faint);
border-radius: var(--r-md);
color: var(--text-secondary);
background: var(--surface);
cursor: pointer;
}
.omni-chat-question-input button:hover:not(:disabled) {
border-color: var(--heat-40);
color: var(--heat);
background: var(--heat-12);
}
.omni-chat-question-input button:disabled {
color: var(--black-alpha-48);
background: var(--black-alpha-4);
cursor: not-allowed;
}
.omni-chat-row.is-user .omni-chat-bubble {
align-items: flex-end;
border-color: var(--black);
border-color: var(--black-alpha-12);
border-bottom-right-radius: 4px;
color: #fff;
background: var(--black);
color: var(--accent-black);
background: color-mix(in srgb, var(--surface) 72%, transparent);
-webkit-backdrop-filter: blur(14px) saturate(115%);
backdrop-filter: blur(14px) saturate(115%);
}
.omni-result-card,
@@ -1359,6 +1406,64 @@
padding: 14px 18px 16px;
}
/* 卖点确认在策略和脚本之前出现:输入真实卖点,或把筛选权交给系统。 */
.omni-selling-point-card {
box-sizing: border-box;
width: min(760px, calc(100% - 44px));
margin: 0 0 24px 44px;
overflow: hidden;
border-radius: var(--r-md);
background: var(--surface);
box-shadow: inset 0 0 0 1px var(--border-faint);
}
.omni-selling-point-card > header {
display: flex;
align-items: center;
min-height: 58px;
padding: 12px 16px;
box-shadow: inset 0 -1px 0 var(--border-faint);
}
.omni-selling-point-card header > div {
display: flex;
flex-direction: column;
gap: 3px;
}
.omni-selling-point-card header strong {
color: var(--accent-black);
font-size: 14px;
font-weight: 600;
}
.omni-selling-point-card header span,
.omni-selling-point-selected {
color: var(--black-alpha-56);
font-size: 12px;
}
.omni-selling-point-card form {
display: flex;
flex-direction: column;
gap: 10px;
padding: 14px 16px 16px;
}
.omni-selling-point-card .input {
width: 100%;
}
.omni-selling-point-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
}
.omni-selling-point-selected {
padding: 14px 16px 16px;
}
/* 剧情反转预设的方向选择:三个完整方向直接平铺,避免只留一句「我准备了三个方向」。 */
.omni-direction-card {
box-sizing: border-box;
@@ -1497,6 +1602,20 @@
cursor: not-allowed;
}
.omni-direction-confirm {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
padding: 0 16px 16px;
}
.omni-direction-confirm > span {
margin-right: auto;
color: var(--black-alpha-56);
font-size: 12px;
}
.omni-elicit-field > label {
display: block;
margin-bottom: 9px;
@@ -2104,8 +2223,8 @@
}
.omni-chat-row.is-user .omni-chat-stack.is-pending .omni-chat-bubble {
background: #3a424e;
border-color: #3a424e;
background: color-mix(in srgb, var(--surface) 84%, transparent);
border-color: var(--black-alpha-16);
}
.omni-send-spinner {
+328 -40
View File
@@ -41,7 +41,7 @@ type PromptBlock =
| { type: "shot"; heading: string; fields: Array<{ label: string; value: string }>; text: string }
| { type: "text"; heading: string; text: string };
type ReplyOption = { label: 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) => {
@@ -143,6 +143,10 @@ function stripNumericReplyInstruction(text: string): string {
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]}个方向`,
@@ -150,10 +154,26 @@ function numberedReplyOptions(text: string): ReplyOption[] {
}
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: "我补充各颜色/款式的实物图" },
{ label: "上传各款实物图", text: "我补充各颜色/款式的实物图", action: "upload" },
{ label: "先按当前主款做", text: "先按当前主款做,其他颜色后面再补" },
];
}
@@ -166,14 +186,14 @@ function contextualReplyOptions(text: string): ReplyOption[] {
}
if (/人物|角色|模特|出镜/i.test(text)) {
return [
{ label: "上传人物图", text: "我上传人物参考图" },
{ label: "上传人物图", text: "我上传人物参考图", action: "upload" },
{ label: "由你设定角色", text: "你先帮我设定一个合适的角色" },
{ label: "不需要人物", text: "这条先不需要人物出镜" },
];
}
if (/场景|地点|背景|在哪/i.test(text)) {
return [
{ label: "上传场景图", text: "我上传场景参考图" },
{ label: "上传场景图", text: "我上传场景参考图", action: "upload" },
{ label: "你来推荐场景", text: "你按商品和预设推荐场景" },
{ label: "用干净日常场景", text: "先用干净自然的日常场景" },
];
@@ -188,30 +208,58 @@ function contextualReplyOptions(text: string): ReplyOption[] {
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) => ({ label: String(item.label || "").trim(), text: String(item.text || "").trim() }))
.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 contextualReplyOptions(text);
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>) => {
@@ -230,7 +278,10 @@ function ReplyActions({
className={index === 0 && options.length < 3 ? "primary" : ""}
key={option.text}
disabled={disabled}
onClick={() => onSubmit(option.text)}
onClick={() => {
if (option.action === "upload") onUpload(option);
else onSubmit(option.text);
}}
>
{option.label}
</button>
@@ -522,8 +573,50 @@ type SessionAsset = {
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[],
@@ -579,8 +672,43 @@ function collectSessionAssets(
}
// 视频确认卡保存的是最终用于出片的 Prompt。它不是聊天正文,而是本会话
// 的一份可复查资源;右侧资源栏始终只保留最新的一版,避免多次改稿堆出一串副本
// 策略、方案、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 || {};
@@ -594,6 +722,7 @@ function collectSessionAssets(
title: String(payload.title || "视频生成Prompt.md"),
promptTitle: String(payload.title || "视频生成Prompt.md"),
promptBody: body,
documentDescription: documentDescription(String(payload.title || "视频生成Prompt.md")),
});
break;
}
@@ -913,12 +1042,15 @@ 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);
@@ -929,6 +1061,7 @@ function ElicitCard({
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;
@@ -972,6 +1105,58 @@ function ElicitCard({
);
}
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
@@ -986,12 +1171,16 @@ function ElicitCard({
}))
.filter((item) => item.id)
: [];
const selected = String(saved.story_direction || "");
// 选卡片只是本地暂存;明确点「确认选择」后才真正提交并开始整理下一步。
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;
onSubmit({ story_direction: idea }, []);
setAnswers((prev) => ({ ...prev, story_direction: idea }));
setCustomDirection("");
};
return (
@@ -1000,7 +1189,7 @@ function ElicitCard({
<header className="omni-direction-head">
<div>
<strong></strong>
<span></span>
<span></span>
</div>
</header>
<div className="omni-direction-list">
@@ -1012,7 +1201,7 @@ function ElicitCard({
key={direction.id}
className={isSelected ? "is-selected" : ""}
disabled={disabled || submitted}
onClick={() => onSubmit({ story_direction: direction.id }, [])}
onClick={() => setAnswers((prev) => ({ ...prev, story_direction: direction.id }))}
>
<span className="omni-direction-index"> {index + 1}</span>
<strong>{direction.title}</strong>
@@ -1025,17 +1214,40 @@ function ElicitCard({
})}
</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>
<>
<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>
@@ -1102,6 +1314,13 @@ function ElicitCard({
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">
@@ -1113,11 +1332,19 @@ function ElicitCard({
</div>
{!submitted ? (
<div className="omni-reply-guide" aria-label="回复操作">
<div className="omni-reply-guide-options">
<button type="button" className="primary" onClick={() => document.getElementById("omniSessionPrompt")?.focus()}>
<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>
</div>
</form>
</div>
) : null}
</div>
@@ -1324,8 +1551,12 @@ function ConfirmCard({
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] || {};
@@ -1340,7 +1571,7 @@ function ResultCard({
const cover = asset.cover || asset.url || "";
const url = asset.url || cover;
const video = asset.type === "video";
const label = assets.length > 1 ? `生成结果 ${index + 1}` : "生成结果";
const label = String(asset.label || (assets.length > 1 ? ` ${index + 1}` : "生成结果"));
return (
<figure className="omni-result-tile" key={asset.id || `${url}-${index}`}>
<button
@@ -1375,9 +1606,22 @@ function ResultCard({
</div>
<div className="omni-result-info">
<div>
<strong>{isGeneratedVideo ? "视频已生成" : assets.length > 1 ? `已生成 ${assets.length} 张图` : "图片已生成"}</strong>
<small>{meta}</small>
<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)}
@@ -1674,7 +1918,10 @@ function withoutLegacyGateArtifacts(list: CreationMessage[]): CreationMessage[]
}
function ProcessCard({ payload }: { payload: Record<string, unknown> }) {
const isVideo = payload.kind === "video";
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;
return (
<section className="omni-result-card omni-process-card">
<div className="omni-result-media">
@@ -1687,8 +1934,8 @@ function ProcessCard({ payload }: { payload: Record<string, unknown> }) {
</div>
<div className="omni-result-info">
<div>
<strong>{isVideo ? "正在生成视频" : "正在出图"}</strong>
<small>{isVideo ? "方案编译中,请稍候" : "画面生成中,请稍候"}</small>
<strong>{isMerge ? "正在合并成片" : isSegmentedVideo ? `正在生成 ${segmentCount || 2} 段视频` : isVideo ? "正在生成视频" : "正在出图"}</strong>
<small>{isMerge ? "正在拼接已确认的片段" : isSegmentedVideo ? "片段完成后会先展示给你确认" : isVideo ? "方案编译中,请稍候" : "画面生成中,请稍候"}</small>
</div>
</div>
</section>
@@ -1755,6 +2002,8 @@ export function OmniSessionPage({
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("");
@@ -1768,6 +2017,7 @@ export function OmniSessionPage({
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);
@@ -2354,6 +2604,24 @@ export function OmniSessionPage({
}
};
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 再清输入框。反过来的话,流式期间敲一次回车
// 会把已经打好的内容清空,消息却没发出去。
@@ -2462,6 +2730,7 @@ export function OmniSessionPage({
);
void send({ kind: "elicit_answer", reply_to: message.id, answers, refs });
}}
onChatAnswer={(text) => void send({ kind: "text", text })}
/>
);
case "strategy":
@@ -2509,6 +2778,8 @@ export function OmniSessionPage({
<ResultCard
key={message.clientKey || message.id}
payload={message.payload}
merging={mergingMessageIds.includes(message.id)}
onMerge={message.payload?.needs_merge ? () => void handleMergeSegments(message) : undefined}
/>
);
default: {
@@ -2545,6 +2816,11 @@ export function OmniSessionPage({
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}
@@ -2696,6 +2972,7 @@ export function OmniSessionPage({
disabled={uploading || streaming}
onClick={() => {
if (uploading || streaming) return;
quickUploadReplyRef.current = null;
setUploadMenuOpen((open) => !open);
setMentionMenuOpen(false);
}}
@@ -2720,6 +2997,7 @@ export function OmniSessionPage({
<button
type="button"
onClick={() => {
quickUploadReplyRef.current = null;
setUploadMenuOpen(false);
fileInputRef.current?.click();
}}
@@ -2747,6 +3025,8 @@ export function OmniSessionPage({
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);
@@ -2759,14 +3039,22 @@ export function OmniSessionPage({
cover: data.thumb_url || data.url,
};
setSessionUploads((prev) => (prev.some((item) => item.id === ref.id) ? prev : [...prev, ref]));
setPendingRefs((prev) => {
if (prev.some((item) => item.id === ref.id) || prev.length >= MENTION_REF_LIMIT) return prev;
return [...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);
}
@@ -2857,7 +3145,7 @@ export function OmniSessionPage({
</span>
<div>
<strong>{promptView.title}</strong>
<small> · </small>
<small>{documentDescription(promptView.title)}</small>
</div>
<button type="button" onClick={() => setPromptView(null)} aria-label="关闭">
<X />
@@ -3020,7 +3308,7 @@ export function OmniSessionPage({
</span>
<span className="omni-assets-meta">
<strong>{asset.title}</strong>
<small> · </small>
<small>{asset.documentDescription || "文档 · 点击查看"}</small>
</span>
</button>
);