优化只能创作里的图片创作
This commit is contained in:
@@ -137,17 +137,30 @@
|
||||
box-shadow: 0 24px 58px rgba(21, 29, 42, .14), 0 0 0 4px rgba(0, 47, 167, .055);
|
||||
}
|
||||
|
||||
.omni-start-context {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.omni-start-context[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.omni-selected-case {
|
||||
width: fit-content;
|
||||
max-width: 100%;
|
||||
height: 36px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 6px;
|
||||
padding: 8px 9px 8px 11px;
|
||||
gap: 6px;
|
||||
margin: 0;
|
||||
padding: 0 5px 0 11px;
|
||||
border: 1px solid rgba(0, 47, 167, .13);
|
||||
border-radius: 10px;
|
||||
border-radius: 8px;
|
||||
background: #edf5ff;
|
||||
}
|
||||
|
||||
@@ -170,8 +183,8 @@
|
||||
}
|
||||
|
||||
.omni-selected-case button {
|
||||
width: 27px;
|
||||
height: 27px;
|
||||
width: 23px;
|
||||
height: 23px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 8px;
|
||||
@@ -189,7 +202,7 @@
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 7px;
|
||||
margin-top: 7px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.omni-start-attachments:empty {
|
||||
@@ -315,6 +328,39 @@
|
||||
height: 15px;
|
||||
}
|
||||
|
||||
.omni-icon-tool.is-uploading {
|
||||
color: var(--black-alpha-56);
|
||||
background: var(--background-lighter);
|
||||
border-color: var(--border-faint);
|
||||
cursor: wait;
|
||||
}
|
||||
|
||||
.omni-icon-tool.is-uploading svg,
|
||||
.omni-upload-status svg {
|
||||
animation: omni-upload-spin .8s linear infinite;
|
||||
}
|
||||
|
||||
.omni-upload-status {
|
||||
height: 34px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 0 4px;
|
||||
color: var(--black-alpha-56);
|
||||
font-size: 12px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.omni-upload-status svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
@keyframes omni-upload-spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.omni-mention-wrap {
|
||||
position: relative;
|
||||
}
|
||||
@@ -788,6 +834,36 @@
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
/* 图片预设不需要分类筛选,直接用更舒展的画幅呈现风格本身。 */
|
||||
.omni-case-library.is-image-presets .omni-case-grid {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.omni-image-preset-intro {
|
||||
margin: 0 0 14px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.omni-image-preset-intro strong,
|
||||
.omni-image-preset-intro span {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.omni-image-preset-intro strong {
|
||||
color: var(--accent-black);
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.omni-image-preset-intro span {
|
||||
margin-top: 4px;
|
||||
color: var(--black-alpha-56);
|
||||
font-size: 13px;
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
.omni-case-card {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
@@ -865,6 +941,11 @@
|
||||
display: none;
|
||||
}
|
||||
|
||||
.omni-case-card[data-mode="image"] .omni-case-visual {
|
||||
height: auto;
|
||||
aspect-ratio: 4 / 3;
|
||||
}
|
||||
|
||||
.omni-card-use {
|
||||
position: absolute;
|
||||
right: 10px;
|
||||
@@ -1105,6 +1186,19 @@
|
||||
-webkit-line-clamp: 2;
|
||||
}
|
||||
|
||||
/* 图片预设只展示风格标题,去掉说明后同步收紧卡片底部留白。 */
|
||||
.omni-case-card[data-mode="image"] .omni-case-copy {
|
||||
min-height: 52px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding-top: 10px;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
|
||||
.omni-case-card[data-mode="image"] .omni-case-copy strong {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.omni-history-page {
|
||||
width: min(1320px, 100%);
|
||||
margin: 0 auto;
|
||||
@@ -1527,6 +1621,10 @@
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.omni-case-library.is-image-presets .omni-case-grid {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.omni-start-toolbar {
|
||||
align-items: flex-end;
|
||||
}
|
||||
@@ -1537,6 +1635,10 @@
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.omni-case-library.is-image-presets .omni-case-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.omni-preset-modal {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
@@ -188,6 +188,65 @@
|
||||
display: none;
|
||||
}
|
||||
|
||||
.omni-reply-guide {
|
||||
margin-top: 8px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px dashed var(--border-faint);
|
||||
}
|
||||
|
||||
.omni-reply-guide p {
|
||||
margin: 0 0 8px;
|
||||
color: var(--black-alpha-56);
|
||||
font-size: 12px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.omni-reply-guide p > span {
|
||||
margin-right: 6px;
|
||||
color: var(--accent-black);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
letter-spacing: .04em;
|
||||
}
|
||||
|
||||
.omni-reply-guide-options {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.omni-reply-guide-options button {
|
||||
min-height: 30px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid var(--border-faint);
|
||||
border-radius: var(--r-md);
|
||||
color: var(--accent-black);
|
||||
background: var(--surface);
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background-color 160ms ease, border-color 160ms ease, color 160ms ease;
|
||||
}
|
||||
|
||||
.omni-reply-guide-options button:hover:not(:disabled) {
|
||||
border-color: var(--heat-40);
|
||||
color: var(--heat);
|
||||
background: var(--heat-12);
|
||||
}
|
||||
|
||||
.omni-reply-guide-options button.primary {
|
||||
border-color: var(--heat-20);
|
||||
color: var(--heat);
|
||||
background: var(--heat-12);
|
||||
}
|
||||
|
||||
.omni-reply-guide-options 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;
|
||||
@@ -1481,7 +1540,10 @@
|
||||
position: relative;
|
||||
/* 不要压过 sticky composer(z-index:3);下拉菜单自身另有更高 z-index */
|
||||
z-index: 1;
|
||||
width: min(760px, 100%);
|
||||
/* 和其余助手卡片一样预留左侧头像对齐位。
|
||||
资源面板打开时会话栏会收窄,不能再用 100% 后额外叠加 44px 左边距。 */
|
||||
box-sizing: border-box;
|
||||
width: min(760px, calc(100% - 44px));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
Reference in New Issue
Block a user