优化全能创作

This commit is contained in:
Azmat@qq.com
2026-09-14 15:58:20 +08:00
parent 463613c0d7
commit 3fed9b9435
9 changed files with 1538 additions and 139 deletions
+410 -73
View File
@@ -8,10 +8,13 @@ import {
Download,
FileText,
FolderOpen,
Grid2X2,
Image,
List,
Play,
Plus,
Pencil,
Search,
Sparkles,
Square,
Upload,
@@ -296,6 +299,87 @@ const AGENT_POLL_INTERVAL_MS = 1500;
// ────────────────────────────────────────────────────────── 卡片
type SessionAssetKind = "image" | "video" | "document";
type SessionAsset = {
key: string;
kind: SessionAssetKind;
title: string;
thumb?: string;
src?: string;
promptTitle?: string;
promptBody?: string;
};
/** 从本会话已加载 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,
});
});
}
if (message.kind === "prompt_file") {
const title = String(message.payload?.title || "视频生成Prompt.md");
const body = String(message.payload?.body || "");
push({
key: `prompt:${message.id || title}`,
kind: "document",
title,
promptTitle: title,
promptBody: body || "暂无内容",
});
}
}
return out;
}
function strategyField(payload: Record<string, unknown>, ...keys: string[]): string {
for (const key of keys) {
const value = payload[key];
@@ -435,7 +519,7 @@ function PlanCard({ payload }: { payload: Record<string, unknown> }) {
90%
</span>
) : null}
<span>
<span className="omni-plan-final-input">
<b>
Prompt.md + <em className="omni-plan-point-body">{String(payload.ref_count ?? 0)}</em>
@@ -469,17 +553,18 @@ function PromptFileCard({
);
}
/** 视频 5 步闸门:策略/方案/Prompt 下方的「按这个继续 / 我想改」。 */
/** 视频 5 步闸门:策略/方案/Prompt 下方的「按这个继续 / 我想改」。
* 「我想改」在卡片内展开反馈输入,不再依赖底部 composer。 */
function StepConfirmCard({
message,
disabled,
onConfirm,
onRevise,
onReviseSubmit,
}: {
message: CreationMessage;
disabled: boolean;
onConfirm: () => void;
onRevise: () => void;
onReviseSubmit: (feedback: string) => void;
}) {
const submitted = Boolean(message.payload.submitted);
const step = String(message.payload.step || "");
@@ -487,41 +572,113 @@ function StepConfirmCard({
const action = String(answers.step_action || "");
const stepLabel =
step === "strategy" ? "创作策略" : step === "plan" ? "视频方案" : step === "prompt" ? "出片 Prompt" : "这一步";
const placeholder =
step === "strategy"
? "说说你想怎么改策略…"
: step === "plan"
? "说说你想怎么改方案…"
: step === "prompt"
? "说说你想怎么改 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" : ""}`}>
<div className="omni-step-confirm-copy">
<strong>{submitted ? (action === "revise" ? `已收到对${stepLabel}的修改意见` : `已确认${stepLabel}`) : `请确认${stepLabel}`}</strong>
<span>{submitted ? (action === "revise" ? "正在按你的反馈重写" : "继续下一步") : (message.text || "确认后继续;要改可以直接说。")}</span>
</div>
{submitted ? null : (
<div className="omni-step-confirm-actions">
<button
type="button"
className="is-secondary"
disabled={disabled}
onMouseDown={(event) => {
// 避免按钮抢焦点,否则 focus composer 会被立刻冲掉
event.preventDefault();
}}
onClick={(event) => {
event.stopPropagation();
onRevise();
}}
>
</button>
<button
type="button"
disabled={disabled}
onClick={(event) => {
event.stopPropagation();
onConfirm();
}}
>
</button>
<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>
);
}
@@ -531,15 +688,12 @@ function ElicitCard({
message,
disabled,
onSubmit,
onStepRevise,
}: {
message: CreationMessage;
disabled: boolean;
/** answers 给模型读(人话),refs 给后端取卖点和参考图。**选素材必须两样都回**,
只回名字的话同名素材会配错货。 */
onSubmit: (answers: Record<string, string | string[]>, refs: CreationRef[]) => void;
/** 步骤确认「我想改」:打开输入框让用户写反馈 */
onStepRevise?: () => void;
}) {
const fields = ((message.payload.fields as CreationField[] | undefined) || []).filter(Boolean);
const submitted = Boolean(message.payload.submitted);
@@ -587,10 +741,7 @@ function ElicitCard({
message={message}
disabled={disabled}
onConfirm={() => onSubmit({ step_action: "confirm" }, [])}
onRevise={() => {
if (onStepRevise) onStepRevise();
else onSubmit({ step_action: "revise" }, []);
}}
onReviseSubmit={(feedback) => onSubmit({ step_action: "revise", feedback }, [])}
/>
);
}
@@ -1089,11 +1240,16 @@ function mergeMessagePreserveLocal(prevHit: CreationMessage | undefined, msg: Cr
if (!prevHit) {
return msg.clientKey === clientKey ? msg : { ...msg, clientKey };
}
// 开放中的追问/闸门:服务端尚未 submitted 时,保住本地已选选项与乐观提交
// 开放中的追问:服务端尚未 submitted 时,保住本地已选选项与乐观提交
// step_confirm 除外 —— 取消修订时服务端会主动撤回 submitted,必须以服务端为准,
// 否则会卡在「已收到…正在按你的反馈重写」。
if (prevHit.kind === "elicit" && msg.kind === "elicit") {
const prevPayload = prevHit.payload || {};
const nextPayload = msg.payload || {};
if (prevPayload.submitted && !nextPayload.submitted) {
const isStepConfirm =
prevPayload.interaction === "step_confirm"
|| nextPayload.interaction === "step_confirm";
if (!isStepConfirm && prevPayload.submitted && !nextPayload.submitted) {
return {
...msg,
clientKey,
@@ -1304,6 +1460,10 @@ export function OmniSessionPage({
const [confirming, setConfirming] = useState(false);
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 依赖会让整条会话被反复重拉、消息数组被反复替换
@@ -1338,21 +1498,6 @@ export function OmniSessionPage({
});
}, []);
/** 「我想改」:露出底部输入框并聚焦,让用户写反馈(后端会把自由文本当 revise) */
const beginStepRevise = useCallback(() => {
setComposerHint("说说要改哪里…");
const focusComposer = () => {
const el = composerRef.current;
if (!el) return;
el.scrollIntoView({ behavior: "smooth", block: "nearest" });
el.focus({ preventScroll: true });
const len = el.value.length;
el.setSelectionRange(len, len);
};
focusComposer();
requestAnimationFrame(focusComposer);
}, []);
const feedRef = useRef<HTMLElement>(null);
const firstSentRef = useRef(false);
const pendingUserIdRef = useRef<string | null>(bootLocalIdRef.current);
@@ -1490,6 +1635,32 @@ export function OmniSessionPage({
[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 还在库里,进来立刻拉一次再轮询,
@@ -1783,7 +1954,7 @@ export function OmniSessionPage({
setStopping(true);
userCancelledRef.current = true;
holdPlanningUntilRef.current = 0;
// 先本地收态,再打 API —— 离开/重进靠服务端 cancel 标 + idle
// 先本地收态,再打 API —— 闸门修订中取消后服务端会恢复 step_confirm
streamingRef.current = false;
setStreaming(false);
setLiveText("");
@@ -1792,28 +1963,40 @@ export function OmniSessionPage({
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 {
await api.cancelCreationAgent(conversationId);
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) {
// 已结束或竞态完成 —— 保持 idle,不刷错误
// 已结束或竞态完成 —— 仍拉一次详情,可能已落新闸门确认
notify("info", "已终止");
await refreshAfterCancel();
} else {
userCancelledRef.current = false;
notify("error", (error as Error).message || "终止失败");
try {
const detail = await api.getCreation(conversationId);
mergeCreationDetail(detail);
} catch {
/* 恢复失败则等用户刷新 */
}
await refreshAfterCancel();
}
} finally {
setStopping(false);
}
}, [stopping, isPlanning, conversationId, notify, mergeCreationDetail]);
}, [stopping, isPlanning, conversationId, notify]);
const handleConfirm = async (message: CreationMessage, nextParams: Record<string, string>) => {
if (confirming) return;
@@ -1936,7 +2119,6 @@ export function OmniSessionPage({
);
void send({ kind: "elicit_answer", reply_to: message.id, answers, refs });
}}
onStepRevise={beginStepRevise}
/>
);
case "strategy":
@@ -2003,6 +2185,12 @@ export function OmniSessionPage({
{(body || message.role !== "user") ? (
<div className="omni-chat-bubble" aria-busy={pending || undefined}>
{body ? <p className="omni-chat-text">{body}</p> : null}
{message.role === "assistant" && typeof message.payload?.reply_hint === "string" && message.payload.reply_hint.trim() ? (
<p className="omni-reply-hint">
<span className="omni-reply-hint-label"></span>
{String(message.payload.reply_hint)}
</p>
) : null}
</div>
) : null}
{message.role === "user" && !pending ? (
@@ -2067,9 +2255,20 @@ export function OmniSessionPage({
);
return (
<section className="page-view omni-session-page">
<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)}
@@ -2381,6 +2580,144 @@ export function OmniSessionPage({
</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> · </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>
);
}