优化只能创作里的图片创作

This commit is contained in:
Azmat@qq.com
2026-09-14 18:56:07 +08:00
parent 3fed9b9435
commit f15fe21b1a
16 changed files with 540 additions and 106 deletions
+93 -59
View File
@@ -6,6 +6,7 @@ import {
FolderOpen,
History,
Image as ImageIcon,
LoaderCircle,
Play,
Plus,
Sparkles,
@@ -31,12 +32,13 @@ type PresetItem = {
category: string;
mode: OutputMode;
title: string;
desc: string;
starter: string;
desc?: string;
starter?: string;
cover: string;
};
const VIDEO_PRESETS: PresetItem[] = [
{ name: "真实使用演示", category: "commerce", mode: "video", title: "真实使用演示", desc: "从商品真实用途出发,拍清使用过程与卖点证据,避免不合理的夸张演示。", starter: "基于商品的真实用途创作一条可信的使用演示:拍清使用步骤、产品状态和核心卖点,不夸张、不出现不合理的使用场景。", cover: "/assets/prototype/video-oneclick-film-v3.png" },
{ name: "剧情反转带货", category: "story", mode: "video", title: "剧情反转带货", desc: "用人物冲突与意外反转建立记忆点,商品承担剧情中的关键作用。", starter: "创作一条有前后反转的剧情带货视频,让商品自然成为解决问题的关键。", cover: "/assets/prototype/photo-1529139574466-a303027c1d8b.jpg" },
{ name: "商品拟人广告", category: "commerce", mode: "video", title: "商品拟人广告", desc: "让商品成为故事主角,通过个性、动作和情绪完成趣味表达。", starter: "把商品塑造成有性格的拟人角色,创作一条轻快、有趣的短广告。", cover: "/assets/prototype/photo-1608248543803-ba4f8c70ae0b.jpg" },
{ name: "达人口播种草", category: "speaker", mode: "video", title: "达人口播种草", desc: "用真实体验与生活化表达建立信任,适合电商和本地生活内容。", starter: "创作一条真实自然的达人口播种草视频,重点讲清使用场景和核心卖点。", cover: "/assets/prototype/photo-1494790108377-be9c29b29330.jpg" },
@@ -48,12 +50,15 @@ const VIDEO_PRESETS: PresetItem[] = [
];
const IMAGE_PRESETS: PresetItem[] = [
{ name: "商品场景套图", category: "product", mode: "image", title: "商品场景套图", desc: "一次生成多张统一视觉的电商图片,覆盖主图、场景和细节表达。", starter: "根据商品参考图生成一组统一风格的主图、场景图和细节图。", cover: "/assets/prototype/photo-1556229010-6c3f2c9ca5f8.jpg" },
{ name: "极简棚拍", category: "product", mode: "image", title: "极简棚拍", desc: "干净背景、柔和投影与明确主体,适合商品主图和详情页头图。", starter: "生成一组留白克制、光线干净的极简棚拍商品图。", cover: "/assets/prototype/photo-1523275335684-37898b6baf30.jpg" },
{ name: "清透自然光人像", category: "portrait", mode: "image", title: "清透自然光人像", desc: "保留真实肤质与自然光影,适合人物种草和生活方式内容。", starter: "生成一组清透自然光、肤色真实的人物氛围图。", cover: "/assets/prototype/photo-1524504388940-b1c1722653e1.jpg" },
{ name: "生活方式场景", category: "scene", mode: "image", title: "生活方式场景", desc: "将商品融入真实空间和使用动作,强调自然、可信的生活气息。", starter: "把商品放入真实、舒适的生活方式场景中,生成自然使用感的图片。", cover: "/assets/prototype/photo-1600210492486-724fe5c67fb0.jpg" },
{ name: "高级奢华质感", category: "style", mode: "image", title: "高级奢华质感", desc: "通过深色环境、局部高光和材质细节强化品牌高级感。", starter: "生成一组深色光影、精致材质与高级氛围的品牌视觉图片。", cover: "/assets/prototype/video-quick-cinematic-v2.png" },
{ name: "复古胶片风格", category: "style", mode: "image", title: "复古胶片风格", desc: "使用低饱和色彩、胶片颗粒和柔和对比形成怀旧情绪。", starter: "生成一组低饱和、细腻颗粒与复古色调的胶片感图片。", cover: "/assets/prototype/photo-1485846234645-a62644f84728.jpg" },
{ name: "电影感", category: "style", mode: "image", title: "电影感", cover: "/assets/image-presets/cinematic.png" },
{ name: "日式清新", category: "style", mode: "image", title: "日式清新", cover: "/assets/image-presets/japanese-fresh.png" },
{ name: "复古胶片", category: "style", mode: "image", title: "复古胶片", cover: "/assets/image-presets/film-vintage.png" },
{ name: "暖调生活感", category: "style", mode: "image", title: "暖调生活感", cover: "/assets/image-presets/warm-lifestyle.png" },
{ name: "高质感杂志", category: "style", mode: "image", title: "高质感杂志", cover: "/assets/image-presets/editorial.png" },
{ name: "梦幻柔光", category: "style", mode: "image", title: "梦幻柔光", cover: "/assets/image-presets/dreamy-soft.png" },
{ name: "黑金高级", category: "style", mode: "image", title: "黑金高级", cover: "/assets/image-presets/black-gold.png" },
{ name: "冷峻科技", category: "style", mode: "image", title: "冷峻科技", cover: "/assets/image-presets/tech-cool.png" },
{ name: "赛博朋克", category: "style", mode: "image", title: "赛博朋克", cover: "/assets/image-presets/cyberpunk.png" },
];
const VIDEO_FILTERS = [
@@ -66,9 +71,6 @@ const VIDEO_FILTERS = [
const IMAGE_FILTERS = [
{ key: "all", label: "全部" },
{ key: "product", label: "商品" },
{ key: "portrait", label: "人像" },
{ key: "scene", label: "场景" },
{ key: "style", label: "风格" },
];
@@ -116,6 +118,7 @@ export function OmniCreatePage({
const [prompt, setPrompt] = useState("");
const [sessionUploads, setSessionUploads] = useState<CreationRef[]>([]);
const [uploading, setUploading] = useState(false);
const [uploadProgress, setUploadProgress] = useState<{ current: number; total: number } | null>(null);
const [model, setModel] = useState("Seedance 2.5");
const [resolution, setResolution] = useState("1080p");
const [ratio, setRatio] = useState("9:16");
@@ -173,7 +176,8 @@ export function OmniCreatePage({
const applyPreset = (preset: PresetItem) => {
setSelectedCase(preset);
setPrompt(preset.starter);
// 图片预设只作为隐藏的生成风格,用户输入区保留自己的创作描述,不把内部 Prompt 展示出来。
if (preset.mode !== "image" && preset.starter) setPrompt(preset.starter);
setPreviewCase(null);
onNotify?.("info", `已选择预设:${preset.name}`);
};
@@ -221,8 +225,10 @@ export function OmniCreatePage({
}
if (!images.length) return;
setUploading(true);
setUploadProgress({ current: 1, total: images.length });
try {
for (const file of images) {
for (const [index, file] of images.entries()) {
setUploadProgress({ current: index + 1, total: images.length });
const form = new FormData();
form.append("file", file);
const data = await api.uploadFreeVideoRef(form);
@@ -242,6 +248,7 @@ export function OmniCreatePage({
onNotify?.("error", (error as Error).message);
} finally {
setUploading(false);
setUploadProgress(null);
}
};
@@ -265,29 +272,31 @@ export function OmniCreatePage({
</header>
<section className="omni-start-composer" aria-label="全能创作输入区">
<div className="omni-selected-case" hidden={!selectedCase}>
<span>
<WandSparkles />
<strong>{selectedCase?.name}</strong>
</span>
<button type="button" aria-label="取消预设" onClick={() => setSelectedCase(null)}>
<X />
</button>
</div>
<div className="omni-start-attachments" aria-live="polite">{pendingRefs.map((ref) => (
<span className="omni-attachment-chip" key={ref.id}>
{ref.cover ? <img src={ref.cover} alt="" /> : <ImageIcon />}
<span>{ref.name.split(" · ")[0]}</span>
<button
type="button"
className="omni-attachment-remove"
aria-label={`删除 ${ref.name}`}
onClick={() => setPendingRefs((prev) => prev.filter((item) => item.id !== ref.id))}
>
<div className="omni-start-context" hidden={!selectedCase && pendingRefs.length === 0}>
<div className="omni-selected-case" hidden={!selectedCase}>
<span>
<WandSparkles />
<strong>{selectedCase?.title}</strong>
</span>
<button type="button" aria-label="取消预设" onClick={() => setSelectedCase(null)}>
<X />
</button>
</span>
))}</div>
</div>
<div className="omni-start-attachments" aria-live="polite">{pendingRefs.map((ref) => (
<span className="omni-attachment-chip" key={ref.id}>
{ref.cover ? <img src={ref.cover} alt="" /> : <ImageIcon />}
<span>{ref.name.split(" · ")[0]}</span>
<button
type="button"
className="omni-attachment-remove"
aria-label={`删除 ${ref.name}`}
onClick={() => setPendingRefs((prev) => prev.filter((item) => item.id !== ref.id))}
>
<X />
</button>
</span>
))}</div>
</div>
<textarea
id="omniStartPrompt"
rows={3}
@@ -305,14 +314,16 @@ export function OmniCreatePage({
<div className="omni-upload-wrap">
<button
type="button"
className="omni-icon-tool"
aria-label="添加参考素材"
className={`omni-icon-tool${uploading ? " is-uploading" : ""}`}
aria-label={uploading ? "正在上传参考素材" : "添加参考素材"}
aria-busy={uploading}
disabled={uploading}
onClick={() => {
setUploadMenuOpen((open) => !open);
setMentionMenuOpen(false);
}}
>
<Plus />
{uploading ? <LoaderCircle /> : <Plus />}
</button>
<div className="omni-upload-menu" hidden={!uploadMenuOpen}>
<strong></strong>
@@ -332,6 +343,12 @@ export function OmniCreatePage({
</button>
</div>
</div>
{uploading ? (
<span className="omni-upload-status" role="status" aria-live="polite">
<LoaderCircle aria-hidden="true" />
{uploadProgress?.current || 1}/{uploadProgress?.total || 1}
</span>
) : null}
<input
ref={fileInputRef}
type="file"
@@ -438,22 +455,34 @@ export function OmniCreatePage({
</div>
</section>
<section className="omni-case-library" aria-labelledby="omniCaseTitle">
<div className="omni-case-head">
<strong className="omni-case-head-label" id="omniCaseTitle"></strong>
<div className="omni-case-filters" role="tablist" aria-label="预设筛选">
{filters.map((filter) => (
<button
type="button"
key={filter.key}
className={activeCategory === filter.key ? "active" : ""}
onClick={() => setActiveCategory(filter.key)}
>
{filter.label}
</button>
))}
<section
className={`omni-case-library${outputMode === "image" ? " is-image-presets" : ""}`}
aria-labelledby={outputMode === "video" ? "omniCaseTitle" : undefined}
aria-label={outputMode === "image" ? "图片风格预设" : undefined}
>
{outputMode === "video" ? (
<div className="omni-case-head">
<strong className="omni-case-head-label" id="omniCaseTitle"></strong>
<div className="omni-case-filters" role="tablist" aria-label="预设筛选">
{filters.map((filter) => (
<button
type="button"
key={filter.key}
className={activeCategory === filter.key ? "active" : ""}
onClick={() => setActiveCategory(filter.key)}
>
{filter.label}
</button>
))}
</div>
</div>
</div>
) : null}
{outputMode === "image" ? (
<div className="omni-image-preset-intro">
<strong></strong>
<span></span>
</div>
) : null}
<div className="omni-case-grid">
{presets.map((card) => (
<button
@@ -461,7 +490,10 @@ export function OmniCreatePage({
className={`omni-case-card${selectedCase?.name === card.name ? " active" : ""}`}
data-mode={card.mode}
key={card.name}
onClick={() => setPreviewCase(card)}
onClick={() => {
if (card.mode === "image") applyPreset(card);
else setPreviewCase(card);
}}
>
<span className="omni-case-visual">
<img src={card.cover} alt="" />
@@ -471,7 +503,7 @@ export function OmniCreatePage({
</span>
<span className="omni-case-copy">
<strong>{card.title}</strong>
<small>{card.desc}</small>
{card.desc ? <small>{card.desc}</small> : null}
</span>
<span
className="omni-card-use"
@@ -505,11 +537,13 @@ export function OmniCreatePage({
<div className="omni-preset-detail">
<span className="omni-preset-kind">{previewCase.mode === "video" ? "视频预设" : "图片预设"}</span>
<h2 id="omniPresetDialogTitle">{previewCase.title}</h2>
<p>{previewCase.desc}</p>
<div className="omni-preset-default">
<span></span>
<p>{previewCase.starter}</p>
</div>
{previewCase.desc ? <p>{previewCase.desc}</p> : null}
{previewCase.starter ? (
<div className="omni-preset-default">
<span></span>
<p>{previewCase.starter}</p>
</div>
) : null}
<div className="omni-preset-actions">
<button type="button" onClick={() => setPreviewCase(null)}></button>
<button type="button" className="primary" onClick={() => applyPreset(previewCase)}>使</button>
+82 -13
View File
@@ -41,6 +41,39 @@ 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 };
function replyOptionsForMessage(payload: Record<string, unknown>, isVideo: boolean): ReplyOption[] {
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() }))
.filter((item) => item.label && item.text)
.slice(0, 3);
if (options.length) return options;
}
return isVideo
? [
{ label: "继续完善方案", text: "继续完善方案" },
{ label: "换个场景", text: "我想换个场景" },
{ label: "改卖点", text: "我想改卖点" },
]
: [
{ label: "按这个方向出图", text: "按这个方向出图" },
{ label: "换个场景", text: "我想换个场景" },
{ label: "改人物状态", text: "我想改人物状态" },
];
}
function replyHintForMessage(payload: Record<string, unknown>, isVideo: boolean): string {
const stored = String(payload.reply_hint || "").trim();
if (stored) return stored;
return isVideo
? "可以回复「继续完善方案」,也可以选下方想调整的方向。"
: "可以回复「按这个方向出图」,也可以选下方想调整的方向。";
}
function parseMarkdownTable(lines: string[]): { headers: string[]; rows: string[][] } | null {
if (lines.length < 2) return null;
const splitRow = (line: string) =>
@@ -296,6 +329,8 @@ function MentionChips({
/** 生成中的消息靠轮询转成结果。视频 5–10 分钟,间隔别设太密。 */
const POLL_INTERVAL_MS = 4000;
const AGENT_POLL_INTERVAL_MS = 1500;
// 请求已发出、但 Celery 还没把会话切到 planning 时,不能让旧 idle 快照把 loading 收掉。
const AGENT_START_GRACE_MS = 20_000;
// ────────────────────────────────────────────────────────── 卡片
@@ -1540,6 +1575,13 @@ export function OmniSessionPage({
}
// 刷新/重进时若仍在整理方案,直接恢复轮询 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);
})
@@ -1869,6 +1911,11 @@ export function OmniSessionPage({
}
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("");
@@ -1912,16 +1959,17 @@ export function OmniSessionPage({
const planning = result.agent_status === "planning";
if (planning) {
// 发出后短时间忽略轮询里的陈旧 idle,避免 thinking 闪灭
holdPlanningUntilRef.current = Date.now() + 6000;
holdPlanningUntilRef.current = Math.max(
holdPlanningUntilRef.current,
Date.now() + AGENT_START_GRACE_MS,
);
streamingRef.current = true;
setStreaming(true);
} else {
holdPlanningUntilRef.current = 0;
streamingRef.current = false;
setStreaming(false);
setLiveText("");
setLiveReasoning("");
setActiveTool("");
// creationSend 可能先回 idle、Celery 随后才切 planning。此处不抢着收起,
// 紧接着的详情快照一旦落到助手消息/确认卡就会通过 mergeCreationDetail 正常收起。
streamingRef.current = true;
setStreaming(true);
}
api
.getCreation(conversationId)
@@ -2170,6 +2218,13 @@ export function OmniSessionPage({
const refs = message.refs || [];
const body = stripMentionText(message.text, refs);
const pending = message.id === pendingUserId;
const showReplyGuide =
message.role === "assistant"
&& message.kind === "text"
&& messages[messages.length - 1]?.id === message.id
&& !streaming;
const replyHint = showReplyGuide ? replyHintForMessage(message.payload, isVideo) : "";
const replyOptions = showReplyGuide ? replyOptionsForMessage(message.payload, isVideo) : [];
return (
<div
className={`omni-chat-row ${message.role === "user" ? "is-user" : "agent"}${pending ? " is-pending" : ""}`}
@@ -2185,12 +2240,26 @@ 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}
{showReplyGuide ? (
<div className="omni-reply-guide" aria-label="下一步引导">
<p>
<span></span>
{replyHint}
</p>
<div className="omni-reply-guide-options">
{replyOptions.map((option, index) => (
<button
type="button"
className={index === 0 ? "primary" : ""}
key={option.text}
onClick={() => void send({ kind: "text", text: option.text })}
>
{option.label}
</button>
))}
</div>
</div>
) : null}
{message.role === "user" && !pending ? (