优化全能创作
Deploy dev / deploy (push) Successful in 38s

This commit is contained in:
Azmat@qq.com
2026-09-29 10:46:59 +08:00
parent 4421706290
commit 1130ecd4ba
9 changed files with 574 additions and 65 deletions
+5 -2
View File
@@ -173,8 +173,11 @@ export function OmniCreatePage({
setRatio("1:1");
setDuration("1 张");
} else {
const vid = findCatalogModel(modelConfigs, "", "video");
const label = vid ? (vid.display_name || vid.name) : "Seedance 2.5";
const vid = (
findCatalogModel(modelConfigs, "doubao-seedance-2-0-fast-260128", "video")
|| findCatalogModel(modelConfigs, "Seedance 2.0 Fast", "video")
);
const label = vid ? (vid.display_name || vid.name) : "Seedance 2.0 Fast";
const resList = modelResolutions(vid);
setModel(label);
setResolution(resList.includes("720p") ? "720p" : (resList[0] || "1080p"));
+176 -37
View File
@@ -1352,6 +1352,7 @@ function ElicitCard({
const [chatAnswer, setChatAnswer] = useState("");
const [personPromptOpen, setPersonPromptOpen] = useState(false);
const [personPrompt, setPersonPrompt] = useState("");
const productBriefNoteRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (submitted || interaction === "chat" || interaction === "step_confirm") return;
@@ -1415,8 +1416,9 @@ function ElicitCard({
<div className="omni-gate-actions" aria-label={isPet ? "宠物角色来源选择" : "人物来源选择"}>
<button
type="button"
className="primary"
className={personPromptOpen ? "" : "primary"}
disabled={disabled}
aria-pressed={!personPromptOpen}
onClick={() => onPersonSourceAction("local_upload")}
>
本地上传
@@ -1430,8 +1432,10 @@ function ElicitCard({
</button>
<button
type="button"
className={personPromptOpen ? "primary" : ""}
disabled={disabled}
aria-expanded={personPromptOpen}
aria-pressed={personPromptOpen}
onClick={() => setPersonPromptOpen((open) => !open)}
>
平台帮忙生成
@@ -1901,11 +1905,31 @@ function ElicitCard({
type="button"
key={option.value}
className={active ? "active" : ""}
onClick={() =>
field.type === "multi"
? toggleMulti(field.key, option.value)
: setAnswers((prev) => ({ ...prev, [field.key]: option.value }))
}
disabled={disabled || submitted}
onClick={() => {
if (field.type === "multi") {
toggleMulti(field.key, option.value);
return;
}
setAnswers((prev) => {
const next = { ...prev, [field.key]: option.value };
if (
interaction === "product_brief_review"
&& field.key === "review_action"
&& option.value !== "supplement"
) {
next.product_brief_note = "";
}
return next;
});
if (
interaction === "product_brief_review"
&& field.key === "review_action"
&& option.value === "supplement"
) {
requestAnimationFrame(() => productBriefNoteRef.current?.focus());
}
}}
>
{option.label}
</button>
@@ -1915,10 +1939,23 @@ function ElicitCard({
)}
{field.type === "text" && (
<input
ref={
interaction === "product_brief_review" && field.key === "product_brief_note"
? productBriefNoteRef
: undefined
}
type="text"
placeholder={field.placeholder || ""}
value={typeof value === "string" ? value : ""}
disabled={submitted}
disabled={
disabled
|| submitted
|| (
interaction === "product_brief_review"
&& field.key === "product_brief_note"
&& answers.review_action !== "supplement"
)
}
onChange={(event) =>
setAnswers((prev) => ({ ...prev, [field.key]: event.target.value }))
}
@@ -2026,15 +2063,13 @@ function ConfirmCard({
const snapshot = { ...sessionParams, ...payloadParams };
const [draft, setDraft] = useState(snapshot);
const [initialDuration] = useState(snapshot.duration || "");
const [initialModel] = useState(snapshot.model || "");
const cardIsVideo = message.payload.kind !== "image" && isVideo;
const durationChanged =
cardIsVideo
&& Boolean(draft.duration)
&& Boolean(initialDuration)
&& normalizeDurationValue(draft.duration) !== normalizeDurationValue(initialDuration);
const modelChanged = cardIsVideo && Boolean(initialModel) && Boolean(draft.model) && draft.model !== initialModel;
const needsRebuild = durationChanged || modelChanged;
const needsRebuild = durationChanged;
const durationSeconds = Number(String(draft.duration || "").replace(/\D/g, ""));
const willGenerateInSegments = cardIsVideo && durationSeconds > 30 && durationSeconds <= 60;
const generationPlan = (message.payload.generation_plan as Record<string, unknown> | undefined) || {};
@@ -2072,7 +2107,7 @@ function ConfirmCard({
: generationPhase === "failed" ? "可重新确认方案后再生成"
: generationPhase === "running" ? "正在出片,请稍候"
: generationPhase === "submitted" ? "正在提交生成"
: "确认前可以改参数。改时长或模型会同步更新视频架构与 Prompt。";
: "确认前可以改参数。只有改时长时会同步更新视频架构与 Prompt。";
const foot =
generationPhase === "done" ? "已完成出片"
: generationPhase === "failed" ? "出片失败"
@@ -2111,7 +2146,7 @@ function ConfirmCard({
</div>
)}
{submitted ? null : needsRebuild ? (
<p className="omni-confirm-hint">参数变化会同步更新受影响的视频架构与 Prompt,不会直接出片。</p>
<p className="omni-confirm-hint">时长变化会同步更新受影响的视频架构与 Prompt,不会直接出片。</p>
) : willGenerateInSegments ? (
<p className="omni-confirm-hint">
{draft.duration === snapshot.duration && generationPlan.note
@@ -2439,7 +2474,11 @@ function mergeMessagePreserveLocal(prevHit: CreationMessage | undefined, msg: Cr
* 合并服务端快照时保留尚未 ack 的乐观气泡,并稳住未变消息的对象引用。
* local 只对上「本轮新出现」的服务端用户消息,绝不按正文吞历史。
*/
function keepUnackedLocals(prev: CreationMessage[], incoming: CreationMessage[]): CreationMessage[] {
function keepUnackedLocals(
prev: CreationMessage[],
incoming: CreationMessage[],
pendingSinceSeq: number,
): CreationMessage[] {
const prevById = new Map(prev.map((m) => [m.id, m]));
const locals = prev.filter((m) => isLocalUserId(m.id));
if (!locals.length) {
@@ -2463,18 +2502,14 @@ function keepUnackedLocals(prev: CreationMessage[], incoming: CreationMessage[])
}
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;
// 确认角色等短路径会先返回 seq 更大的确认卡,用户消息稍后才在详情快照里出现。
// 应以发送前的 seq 判断是否属于本轮,不能用此刻列表的最大 seq。
if (serverSeq(msg) <= pendingSinceSeq) return withKey;
const local = locals.find((item) => !used.has(item.id) && sameUserBubble(item, msg));
if (!local) return withKey;
used.add(local.id);
@@ -2534,6 +2569,51 @@ function withoutLegacyGateArtifacts(
return hidden.size ? list.filter((message) => !hidden.has(message.id)) : list;
}
function isMultiPersonReferenceCard(message: CreationMessage): boolean {
return (
(message.kind === "generating" || message.kind === "result")
&& message.payload?.kind === "person_reference"
&& Number(message.payload?.cast_total || 1) > 1
);
}
/**
* 多角色任务可以在后台并行,但对话流按角色顺序逐个揭示:当前角色完成后,
* 才展示下一张生成卡。已经揭示的结果和当前进度归为一组,交给 flex 横向排列。
*/
function groupVisibleCreationMessages(list: CreationMessage[]): CreationMessage[][] {
const groups: CreationMessage[][] = [];
let index = 0;
while (index < list.length) {
const message = list[index];
if (!isMultiPersonReferenceCard(message)) {
groups.push([message]);
index += 1;
continue;
}
const castTotal = Number(message.payload?.cast_total || 1);
const batch: CreationMessage[] = [];
let cursor = index;
while (
cursor < list.length
&& isMultiPersonReferenceCard(list[cursor])
&& Number(list[cursor].payload?.cast_total || 1) === castTotal
) {
batch.push(list[cursor]);
cursor += 1;
}
const currentGeneratingIndex = batch.findIndex((item) => item.kind === "generating");
groups.push(
currentGeneratingIndex >= 0
? batch.slice(0, currentGeneratingIndex + 1)
: batch,
);
index = cursor;
}
return groups;
}
function ProcessCard({
payload,
}: {
@@ -2554,6 +2634,7 @@ function ProcessCard({
<div className="omni-process-frame" aria-hidden="true">
<span className="omni-process-ring" />
<strong>{isVideo ? "视频生成中" : "图片生成中"}</strong>
<span className="omni-process-meta">[ RENDERING ]</span>
</div>
</figure>
</div>
@@ -2765,7 +2846,7 @@ export function OmniSessionPage({
if (cancelled) return;
setConversation(detail);
// 保留尚未 ack 的乐观用户气泡,避免进页种子/发送中的气泡被首包冲掉
setMessages((prev) => keepUnackedLocals(prev, detail.messages || []));
setMessages((prev) => keepUnackedLocals(prev, detail.messages || [], pendingSinceSeqRef.current));
if (bootLocalIdRef.current && pendingUserIdRef.current === bootLocalIdRef.current) {
const boot = makeLocalUserMessage(
firstMessage?.trim() || "",
@@ -2894,10 +2975,26 @@ export function OmniSessionPage({
),
[conversation?.agent_started_at, conversation?.agent_status, messages],
);
const visibleMessageGroups = useMemo(
() => groupVisibleCreationMessages(visibleMessages),
[visibleMessages],
);
const currentPlanningHasError = Boolean(
conversation && hasCurrentPlanningError(conversation)
);
const visibleProgressHistory = conversation?.agent_progress?.history || [];
const visibleProgressHistory = useMemo(() => {
const history = conversation?.agent_progress?.history || [];
const seen = new Set<string>();
const uniqueReversed = [] as typeof history;
for (let index = history.length - 1; index >= 0; index -= 1) {
const item = history[index];
const key = `${item.label}\u0000${item.detail}`;
if (seen.has(key)) continue;
seen.add(key);
uniqueReversed.push(item);
}
return uniqueReversed.reverse();
}, [conversation?.agent_progress?.history]);
useEffect(() => {
const log = thinkingLogRef.current;
if (log) log.scrollTop = log.scrollHeight;
@@ -2967,7 +3064,7 @@ export function OmniSessionPage({
.then((detail) => {
if (cancelled) return;
setConversation(detail);
setMessages((prev) => keepUnackedLocals(prev, detail.messages || []));
setMessages((prev) => keepUnackedLocals(prev, detail.messages || [], pendingSinceSeqRef.current));
})
.catch(() => {
/* 轮询失败静默重试,不打扰用户 */
@@ -2994,7 +3091,7 @@ export function OmniSessionPage({
}
return detail;
});
setMessages((prev) => keepUnackedLocals(prev, detail.messages || []));
setMessages((prev) => keepUnackedLocals(prev, detail.messages || [], pendingSinceSeqRef.current));
if (localId && pendingUserIdRef.current === localId) {
// 必须是「发送之后」新出现的用户消息,不能靠正文撞上历史气泡
const acked = detail.messages.some(
@@ -3202,7 +3299,12 @@ export function OmniSessionPage({
}
return next;
});
if (localId && pendingUserIdRef.current === localId) {
const userAcknowledged = (result.messages || []).some((message) =>
message.role === "user"
&& serverSeq(message) > pendingSinceSeqRef.current
&& (message.text || "") === (payload.text || "")
);
if (localId && userAcknowledged && pendingUserIdRef.current === localId) {
pendingUserIdRef.current = null;
setPendingUserId(null);
}
@@ -3347,18 +3449,20 @@ export function OmniSessionPage({
const handleConfirm = async (message: CreationMessage, nextParams: Record<string, string>) => {
if (confirming) return;
setConfirming(true);
// 提交视频前还要同步完成素材检查和上游任务创建,可能需要数秒。
// 先把确认卡切成「正在提交」,不能让用户面对一张毫无变化的可点击卡片。
setMessages((prev) =>
prev.map((item) =>
item.id === message.id
? { ...item, payload: { ...item.payload, submitted: true, params: nextParams } }
: item
)
);
setConversation((prev) =>
prev ? { ...prev, params: { ...prev.params, ...nextParams } } : prev
);
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", "参数已更新,正在同步视频架构与 Prompt");
void send({
@@ -3367,11 +3471,38 @@ export function OmniSessionPage({
});
return;
}
if (result.messages?.length) {
// 兼容旧确认卡:服务端可能把本次确认改道到补角色步骤,并重开确认卡。
// 拉服务端快照,避免本地乐观的 submitted 状态盖住新步骤。
const detail = await api.getCreation(conversationId);
mergeCreationDetail(detail);
return;
}
if (result.message) {
setMessages((prev) => [...prev, result.message as CreationMessage]);
}
} catch (error) {
notify("error", (error as Error).message);
let recovered = false;
try {
// 响应断开不等于提交失败:以后端快照为准,防止用户再次点击造成重复出片。
const detail = await api.getCreation(conversationId);
const remoteCard = detail.messages.find((item) => item.id === message.id);
recovered = Boolean(remoteCard?.payload?.submitted);
mergeCreationDetail(detail);
} catch {
setMessages((prev) =>
prev.map((item) =>
item.id === message.id
? { ...item, payload: { ...item.payload, submitted: false } }
: item
)
);
}
if (recovered) {
notify("info", "生成任务已提交,正在处理");
} else {
notify("error", (error as Error).message);
}
} finally {
setConfirming(false);
}
@@ -3733,7 +3864,15 @@ export function OmniSessionPage({
</button>
<main className="omni-session-feed" ref={feedRef} aria-live="polite">
{visibleMessages.map(renderMessage)}
{visibleMessageGroups.map((group) => {
const isPersonReferenceGroup = group.some(isMultiPersonReferenceCard);
if (!isPersonReferenceGroup) return renderMessage(group[0]);
return (
<div className="omni-person-reference-flow" key={`person-reference-${group[0].id}`}>
{group.map(renderMessage)}
</div>
);
})}
{/* 流式中的临时气泡。挂 is-live 关掉入场动画 —— 它每来一个字符都会
重渲染,带动画的话整条会一直在闪;真消息落地时它被替换掉,
那一下也不该再演一次入场。 */}