@@ -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"));
|
||||
|
||||
@@ -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 关掉入场动画 —— 它每来一个字符都会
|
||||
重渲染,带动画的话整条会一直在闪;真消息落地时它被替换掉,
|
||||
那一下也不该再演一次入场。 */}
|
||||
|
||||
Reference in New Issue
Block a user