模特库标签分页与全能创作收口:藏长视频、选择器分页

角色库导入打标签并支持筛选;模特库与全能创作角色/商品选择改为每页 20 条分页。临时限制成片 ≤60 秒,过滤对话里的超长时长选项,并收拢本地全能创作与后台用户相关修复。
This commit is contained in:
Azmat@qq.com
2026-09-21 16:24:37 +08:00
parent 690eb3d843
commit 0bd1db6bf9
48 changed files with 3386 additions and 896 deletions
@@ -250,6 +250,7 @@ export function AdminUsersPage({ notify }: { notify: Notify }) {
// 直接开户:不发邀请码,后端建号时自动配一个个人团队当钱包(团队体系保留,后台只按用户视角管)
const [createOpen, setCreateOpen] = useState(false);
const [newName, setNewName] = useState("");
const [newLogin, setNewLogin] = useState("");
const [newPwd, setNewPwd] = useState("");
const [newCredits, setNewCredits] = useState("");
const [creating, setCreating] = useState(false);
@@ -307,6 +308,7 @@ export function AdminUsersPage({ notify }: { notify: Notify }) {
function closeCreate() {
setCreateOpen(false);
setNewName("");
setNewLogin("");
setNewPwd("");
setNewCredits("");
}
@@ -314,14 +316,24 @@ export function AdminUsersPage({ notify }: { notify: Notify }) {
async function doCreate() {
if (creating) return;
const name = newName.trim();
const login = newLogin.trim();
if (!name) { notify("error", "请填写用户名"); return; }
if (!/^[A-Za-z0-9]{6}$/.test(login)) {
notify("error", "登录账号须为 6 位英文或数字,不能含其他字符");
return;
}
if (newPwd.trim().length < 8) { notify("error", "密码至少 8 位"); return; }
const credits = newCredits.trim();
if (credits && (!Number.isFinite(Number(credits)) || Number(credits) < 0)) { notify("error", "初始积分需为非负数"); return; }
setCreating(true);
try {
await adminApi.createUser({ username: name, password: newPwd.trim(), initial_credits: credits || "0" });
notify("success", `已创建用户 ${name}`);
await adminApi.createUser({
username: login,
display_name: name,
password: newPwd.trim(),
initial_credits: credits || "0",
});
notify("success", `已创建用户 ${name}(${login})`);
closeCreate();
setPage(1);
await load();
@@ -375,7 +387,7 @@ export function AdminUsersPage({ notify }: { notify: Notify }) {
<button key={t.key || "all"} type="button" className={`tab-sub${statusFilter === t.key ? " active" : ""}`} onClick={() => { setStatusFilter(t.key); setPage(1); }}>{t.label}</button>
))}
</div>
<input className="input admin-search" type="text" placeholder="搜索用户名…" value={search} onChange={(e) => { setSearch(e.target.value); setPage(1); }} />
<input className="input admin-search" type="text" placeholder="搜索用户名 / 登录账号…" value={search} onChange={(e) => { setSearch(e.target.value); setPage(1); }} />
</div>
{loading ? (
@@ -386,15 +398,16 @@ export function AdminUsersPage({ notify }: { notify: Notify }) {
<div className="admin-table-wrap">
<table className="t admin-table">
<thead>
<tr><th>用户名</th><th>积分余额</th><th>所属团队</th><th>状态</th><th>注册时间</th><th className="col-actions">操作</th></tr>
<tr><th>用户名</th><th>登录账号</th><th>积分余额</th><th>所属团队</th><th>状态</th><th>注册时间</th><th className="col-actions">操作</th></tr>
</thead>
<tbody>
{users.map((u) => (
<tr key={u.id}>
<td>
{u.username}
{u.first_name || u.username}
{u.is_platform_admin && <span className="pill info admin-inline-pill"><span className="dot" />超管</span>}
</td>
<td className="mono">{u.username}</td>
<td className="num mono">
{u.wallet_team ? `${pts(u.balance)} 积分` : <span className="muted">—</span>}
</td>
@@ -436,10 +449,25 @@ export function AdminUsersPage({ notify }: { notify: Notify }) {
<button className="x modal-x" type="button" aria-label="关闭" onClick={closeCreate}><X size={14} /></button>
</div>
<div className="modal-b">
<p className="admin-modal-desc">直接开户,不用发邀请码。建好即可用该用户名密码登录,并拥有一个独立的积分钱包。</p>
<p className="admin-modal-desc">直接开户,不用发邀请码。用户名可中文展示;登录账号仅英文+数字且必须 6 位,建好后用登录账号和密码登录。</p>
<div className="field">
<label className="field-label" htmlFor="new-username">用户名 <span className="req">*</span></label>
<input id="new-username" className="input" type="text" placeholder="登录用的用户名" value={newName} onChange={(e) => setNewName(e.target.value)} />
<label className="field-label" htmlFor="new-display-name">用户名 <span className="req">*</span></label>
<input id="new-display-name" className="input" type="text" placeholder="展示名称,如:适柔" value={newName} onChange={(e) => setNewName(e.target.value)} />
</div>
<div className="field">
<label className="field-label" htmlFor="new-login">登录账号 <span className="req">*</span> <span className="lbl-note">(6 位英文或数字)</span></label>
<input
id="new-login"
className="input"
type="text"
inputMode="text"
autoComplete="off"
spellCheck={false}
maxLength={6}
placeholder="例如:sr0001"
value={newLogin}
onChange={(e) => setNewLogin(e.target.value.replace(/[^A-Za-z0-9]/g, "").slice(0, 6).toLowerCase())}
/>
</div>
<div className="field">
<label className="field-label" htmlFor="new-password">初始密码 <span className="req">*</span> <span className="lbl-note">(至少 8 位)</span></label>
-4
View File
@@ -435,7 +435,6 @@ const MODE_META: Record<
WorkMode,
{
title: string;
tag: string;
desc: string;
ratio: string;
promptTemplate: (productTitle: string) => string;
@@ -443,21 +442,18 @@ const MODE_META: Record<
> = {
image: {
title: "自由创作",
tag: "[ IMAGE · STUDIO ]",
desc: "使用提示词与参考图,自由生成或修改电商视觉素材",
ratio: "1:1",
promptTemplate: (title) => `${title},电商高转化视觉,干净背景,商品主体清晰`
},
model: {
title: "模特上身",
tag: "[ MODEL · TRY-ON ]",
desc: "选择授权模特与生成规格,为商品创建自然真实的上身展示图",
ratio: "1:1",
promptTemplate: (title) => `${title},模特上身展示,自然光,真实质感,电商主图`
},
cover: {
title: "平台套图",
tag: "[ PLATFORM · KIT ]",
desc: "根据平台规范生成主图、卖点图、细节图与场景图",
// 优化版:商品上架主图默认 1:1(原 4:5 会 fallback 成方图致比例错乱);竖图按平台/类目再选
ratio: "1:1",
-1
View File
@@ -266,7 +266,6 @@ export function AuthScreen({
<div className="login-brand-copy"><span>YINGQING AIGC STUDIO</span></div>
</div>
<div className="login-hero">
<p className="login-eyebrow">// AIGC COMMERCE CONTENT ENGINE</p>
<div className="login-hero-copy">
<h1>
<span className="login-hero-line">让每一次商品表达,</span>
+98 -19
View File
@@ -7,6 +7,7 @@ import { useFileDrop } from "../components/use-file-drop";
import { generationErrorText } from "../generation-error";
import type { ModelEntity } from "../types";
import { SystemLoading } from "../components/loading";
import { Pager } from "../components/pager";
import { ConfirmModal, MediaLightbox, useBodyScrollLock, useOverlayTransition } from "../components/overlays";
import "../models-page.css";
@@ -19,6 +20,8 @@ const TABS: { k: Tab; label: string; title: string; note: string }[] = [
{ k: "mine", label: "我的模特", title: "我的模特", note: "维护可复用的品牌模特与人物参考资产" }
];
const PAGE_SIZE = 20;
type Preview = { src: string; kind: "image"; name: string };
const formatPoints = (value: string) => {
@@ -83,6 +86,11 @@ function ModelDetailModal({ model, close, onZoom, onSaved, onNotify, onBillingCh
useEffect(() => () => {
if (pendingPortraitUrlRef.current) URL.revokeObjectURL(pendingPortraitUrlRef.current);
}, []);
// Hooks must stay above the early return — opening the modal used to add useFileDrop mid-tree and crash.
const portraitDrop = useFileDrop(
(files) => selectPortrait(files[0]),
{ disabled: saving || !model || Boolean(model?.is_official), accept: (f) => f.type.startsWith("image/") }
);
if (!mounted || !model) return null;
const currentModel = model;
const portraitUrl = pendingPortraitUrl || model.portrait;
@@ -121,13 +129,9 @@ function ModelDetailModal({ model, close, onZoom, onSaved, onNotify, onBillingCh
setSaving(false);
}
}
const portraitDrop = useFileDrop(
(files) => selectPortrait(files[0]),
{ disabled: saving, accept: (f) => f.type.startsWith("image/") }
);
function selectPortrait(file?: File) {
if (!file || currentModel.is_official || saving) return;
if (!file || !model || model.is_official || saving) return;
clearPendingPortrait();
const url = URL.createObjectURL(file);
pendingPortraitUrlRef.current = url;
@@ -223,6 +227,9 @@ function ModelDetailModal({ model, close, onZoom, onSaved, onNotify, onBillingCh
{model.is_official && <span className="pill info">官方模板</span>}
<span className="pill neutral">{model.source === "upload" ? "真人上传" : "AI 生成"}</span>
{pendingPortrait && <span className="pill info">待保存</span>}
{(Array.isArray(model.metadata?.tags) ? model.metadata.tags : []).filter((t): t is string => typeof t === "string" && Boolean(t.trim())).map((tag) => (
<span className="pill neutral" key={tag}>{tag}</span>
))}
</div>
</div>
<div className="model-detail-right">
@@ -279,12 +286,23 @@ function ModelDetailModal({ model, close, onZoom, onSaved, onNotify, onBillingCh
// 模特库:顶级实体(团队级可复用)= 形象图 + 三视图 + 声线(尾期)。官方预制打「官方模板」标签。
// 图片创作(模特上身图)与视频项目(角色)都引用它。期1:看归好的成套模特 + 真人上传 + 删自建。
function modelTags(m: ModelEntity): string[] {
const raw = m.metadata?.tags;
if (!Array.isArray(raw)) return [];
return raw.filter((t): t is string => typeof t === "string" && Boolean(t.trim()));
}
export function ModelsPage({ onNotify, onBillingChanged }: {
onNotify?: (type: "success" | "error", text: string) => void;
onBillingChanged?: () => void;
}) {
const [tab, setTab] = useState<Tab>("all");
const [tagFilter, setTagFilter] = useState<string[]>([]);
const [tagOptions, setTagOptions] = useState<{ name: string; label?: string; count: number }[]>([]);
const [items, setItems] = useState<ModelEntity[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(true);
const [uploading, setUploading] = useState(false);
const [editMode, setEditMode] = useState(false);
@@ -304,15 +322,38 @@ export function ModelsPage({ onNotify, onBillingChanged }: {
useEffect(() => {
let alive = true;
setLoading(true);
const tabParam = tab === "all" ? undefined : tab;
api
.listModels({ tab: tab === "all" ? undefined : tab })
.then((r) => { if (alive) setItems(r.results); })
.catch(() => { if (alive) setItems([]); })
.listModels({ tab: tabParam, tags: tagFilter, page, pageSize: PAGE_SIZE })
.then((r) => {
if (!alive) return;
setItems(r.results);
setTotal(r.count ?? r.results.length);
})
.catch(() => {
if (!alive) return;
setItems([]);
setTotal(0);
})
.finally(() => { if (alive) setLoading(false); });
return () => { alive = false; };
}, [tab, tagFilter, page]);
useEffect(() => {
let alive = true;
api
.listModelTags({ tab: tab === "all" ? undefined : tab })
.then((r) => { if (alive) setTagOptions(r.results || []); })
.catch(() => { if (alive) setTagOptions([]); });
return () => { alive = false; };
}, [tab]);
useEffect(() => { setSelected(new Set()); }, [tab]);
useEffect(() => { setSelected(new Set()); setTagFilter([]); setPage(1); }, [tab]);
useEffect(() => { setPage(1); }, [tagFilter]);
function toggleTag(name: string) {
setTagFilter((prev) => prev.includes(name) ? prev.filter((t) => t !== name) : [...prev, name]);
}
const modelDrop = useFileDrop(
(files) => { void acceptModelFile(files[0]); },
@@ -333,7 +374,11 @@ export function ModelsPage({ onNotify, onBillingChanged }: {
form.append("file", file);
form.append("name", file.name.replace(/\.[^.]+$/, ""));
const created = await api.uploadModel(form);
setItems((list) => (tab === "official" ? list : [created, ...list]));
if (tab !== "official") {
setPage(1);
setTotal((n) => n + 1);
setItems((list) => [created, ...list].slice(0, PAGE_SIZE));
}
} finally {
setUploading(false);
}
@@ -351,8 +396,11 @@ export function ModelsPage({ onNotify, onBillingChanged }: {
const removed = ids.filter((id) => !failed.has(id));
if (removed.length) {
setItems((list) => list.filter((m) => !removed.includes(m.id)));
setTotal((n) => Math.max(0, n - removed.length));
setSelected((prev) => new Set([...prev].filter((id) => !removed.includes(id))));
onNotify?.("success", removed.length > 1 ? `已移至垃圾桶 · ${removed.length} 个` : "已移至垃圾桶");
// 当前页删空且还有上一页 → 回退一页重新拉
if (items.length <= removed.length && page > 1) setPage((p) => p - 1);
}
if (failed.size) onNotify?.("error", "部分模特删除失败");
setConfirmIds(null);
@@ -379,15 +427,38 @@ export function ModelsPage({ onNotify, onBillingChanged }: {
</div>
</header>
<div className="ml-toolbar">
<div className="ml-seg" role="tablist" aria-label="模特来源">
{TABS.map((t) => (
<button key={t.k} type="button" role="tab" aria-selected={tab === t.k} className={`ml-seg-btn${tab === t.k ? " active" : ""}`} onClick={() => setTab(t.k)}>
{t.label}
</button>
))}
</div>
<span className="ml-note">官方模特均为平台导入的授权素材</span>
<div className="ml-tag-bar" role="toolbar" aria-label="按来源与标签筛选">
{TABS.map((t) => (
<button
key={t.k}
type="button"
className={`ml-tag-chip ml-tag-scope${tab === t.k ? " is-on" : ""}`}
onClick={() => setTab(t.k)}
aria-pressed={tab === t.k}
>
{t.label}
</button>
))}
<span className="ml-tag-sep" aria-hidden="true" />
<button
type="button"
className={`ml-tag-chip${tagFilter.length === 0 ? " is-on" : ""}`}
onClick={() => setTagFilter([])}
>
不限标签
</button>
{tagOptions.map((tag) => (
<button
key={tag.name}
type="button"
className={`ml-tag-chip${tagFilter.includes(tag.name) ? " is-on" : ""}`}
onClick={() => toggleTag(tag.name)}
title={`${tag.count} 个`}
>
{tag.label || tag.name}
<i>{tag.count}</i>
</button>
))}
</div>
<div className="ml-section">
@@ -404,6 +475,7 @@ export function ModelsPage({ onNotify, onBillingChanged }: {
<span>点右上「添加模特」上传一张形象图,或在图片/视频流程里生成</span>
</div>
) : (
<>
<div className={`ml-grid${modelDrop.dragging ? " is-dragover" : ""}`} {...modelDrop.dropProps}>
{items.map((m) => {
const selectable = !m.is_official;
@@ -458,12 +530,19 @@ export function ModelsPage({ onNotify, onBillingChanged }: {
</>
)}
<span className="ml-tag">{m.source === "upload" ? "真人上传" : "AI 生成"}</span>
{modelTags(m).slice(0, 4).map((tag) => (
<span className="ml-tag" key={tag}>{tag}</span>
))}
</div>
</div>
</article>
);
})}
</div>
<div className="ml-pager">
<Pager page={page} total={total} pageSize={PAGE_SIZE} onChange={setPage} alwaysShow />
</div>
</>
)}
</div>
+291 -138
View File
@@ -147,6 +147,20 @@ function stripNumericReplyInstruction(text: string): string {
.trim();
}
/** 临时隐藏 >60 秒长视频入口:选项文案里带秒数且超过 60 的不展示。 */
const OMNI_VISIBLE_MAX_DURATION = 60;
function durationSecondsFromLabel(label: string): number | null {
const match = String(label || "").match(/(\d+(?:\.\d+)?)\s*秒/);
if (!match) return null;
const n = Number(match[1]);
return Number.isFinite(n) ? n : null;
}
function isHiddenLongVideoOption(label: string): boolean {
const seconds = durationSecondsFromLabel(label);
return seconds != null && seconds > OMNI_VISIBLE_MAX_DURATION;
}
function numberedReplyOptions(text: string): ReplyOption[] {
const matches = [...(text || "").matchAll(/(?:^|\n)\s*([1-3])[.、.]\s*(?:\*\*)?([^\n*]{1,80})/g)];
if (matches.length < 2) return [];
@@ -314,12 +328,13 @@ function ReplyActions({
setCustomIdea("");
};
const visibleOptions = options.filter((option) => !isHiddenLongVideoOption(option.label) && !isHiddenLongVideoOption(option.text));
return (
<div className="omni-reply-guide-options">
{options.map((option, index) => (
{visibleOptions.map((option, index) => (
<button
type="button"
className={index === 0 && options.length < 3 ? "primary" : ""}
className={index === 0 && visibleOptions.length < 3 ? "primary" : ""}
key={option.text}
disabled={disabled}
onClick={() => {
@@ -537,6 +552,18 @@ function messageTime(createdAt: string) {
});
}
function mentionHoverPlacement(chip: HTMLElement): "above" | "below" {
const rect = chip.getBoundingClientRect();
const previewNeed = 300;
const topbarRaw = getComputedStyle(document.documentElement).getPropertyValue("--topbar-height").trim();
const topbarH = Number.parseFloat(topbarRaw) || 116;
// 往上展开会伸进顶栏区域时改往下开,避免被 sticky 顶栏压住
const wouldHitTopbar = rect.top - previewNeed < topbarH;
if (!wouldHitTopbar) return "above";
const spaceBelow = window.innerHeight - rect.bottom;
return spaceBelow >= Math.min(previewNeed, 160) ? "below" : "above";
}
function MentionChips({
refs,
tone = "user",
@@ -547,6 +574,8 @@ function MentionChips({
onRemove?: (id: string) => void;
}) {
const [preview, setPreview] = useState<{ src: string; name: string } | null>(null);
const [hoverKey, setHoverKey] = useState<string>("");
const [hoverDir, setHoverDir] = useState<"above" | "below">("above");
if (!refs.length) return null;
return (
<>
@@ -554,8 +583,19 @@ function MentionChips({
{refs.map((ref) => {
const cover = (ref.cover || "").trim();
const label = shortRefName(ref.name) || REF_CHIP_LABEL[ref.type] || ref.type;
const chipKey = `${ref.type}-${ref.id}`;
const openBelow = hoverKey === chipKey && hoverDir === "below";
return (
<span className={`omni-mention-chip${cover ? " has-thumb" : ""}`} key={`${ref.type}-${ref.id}`}>
<span
className={`omni-mention-chip${cover ? " has-thumb" : ""}${openBelow ? " is-preview-below" : ""}`}
key={chipKey}
onMouseEnter={(event) => {
if (!cover) return;
setHoverKey(chipKey);
setHoverDir(mentionHoverPlacement(event.currentTarget));
}}
data-chip-key={chipKey}
>
{cover ? (
<>
<button
@@ -563,6 +603,12 @@ function MentionChips({
className="omni-mention-thumb"
aria-label={`预览 ${label}`}
onClick={() => setPreview({ src: cover, name: label })}
onFocus={(event) => {
const chip = event.currentTarget.closest(".omni-mention-chip") as HTMLElement | null;
if (!chip) return;
setHoverKey(chipKey);
setHoverDir(mentionHoverPlacement(chip));
}}
>
<img src={cover} alt={label} />
</button>
@@ -1646,7 +1692,7 @@ function ElicitCard({
{ value: "poor_absorption", label: "护肤浮在表面吸收慢,黏腻厚重不透气" },
]
: [];
const choiceField = rawChoiceField || (
const choiceFieldRaw = rawChoiceField || (
fallbackOptions.length > 0
? {
key: fields[0]?.key || "pain_point_direction",
@@ -1656,6 +1702,16 @@ function ElicitCard({
}
: null
);
const choiceField = choiceFieldRaw
? {
...choiceFieldRaw,
options: (choiceFieldRaw.options || []).filter((option: any) => {
const label = typeof option === "string" ? option : String(option?.label || option?.text || option?.value || "");
const value = typeof option === "string" ? option : String(option?.value || option?.label || "");
return !isHiddenLongVideoOption(label) && !isHiddenLongVideoOption(value);
}),
}
: null;
const savedChoice = choiceField ? String(saved[choiceField.key] || "") : "";
const savedChoiceLabel = (choiceField?.options as any[])?.find((option: any) => {
const val = typeof option === "string" ? option : option?.value;
@@ -1688,7 +1744,7 @@ function ElicitCard({
{(choiceField.options || []).map((option: any, index: number) => {
const label = typeof option === "string" ? option : String(option?.label || option?.text || option?.title || option?.value || `选项 ${index + 1}`).trim();
const value = typeof option === "string" ? option : String(option?.value || option?.label || option?.text || option?.title || `option_${index + 1}`).trim();
if (!label) return null;
if (!label || isHiddenLongVideoOption(label) || isHiddenLongVideoOption(value)) return null;
return (
<button
type="button"
@@ -1866,6 +1922,7 @@ function ConfirmCard({
catalogModels,
disabled,
onConfirm,
generationPhase = "idle",
}: {
message: CreationMessage;
sessionParams: Record<string, string>;
@@ -1873,8 +1930,10 @@ function ConfirmCard({
catalogModels?: ModelConfig[];
disabled: boolean;
onConfirm: (params: Record<string, string>) => void;
/** idle=未点确认;submitted=已点还没出片消息;running=生成中;done=下方已有成片;failed=出片失败 */
generationPhase?: "idle" | "submitted" | "running" | "done" | "failed";
}) {
const submitted = Boolean(message.payload.submitted);
const submitted = Boolean(message.payload.submitted) || generationPhase !== "idle";
const payloadCredits = Number(message.payload.estimated_credits || 0);
const payloadParams = asStringMap(message.payload.params);
const snapshot = { ...sessionParams, ...payloadParams };
@@ -1912,11 +1971,36 @@ function ConfirmCard({
return unit * count;
})();
const title =
generationPhase === "done" ? "出片已完成"
: generationPhase === "failed" ? "出片未完成"
: generationPhase === "running" || generationPhase === "submitted" ? "已确认"
: `即将用 ${summary} 生成`;
const subtitle =
generationPhase === "done" ? "成片见下方"
: generationPhase === "failed" ? "可重新确认方案后再生成"
: generationPhase === "running" ? "正在出片,请稍候"
: generationPhase === "submitted" ? "正在提交生成"
: "确认前可以改参数。改时长会按新时长重写脚本。";
const foot =
generationPhase === "done" ? "已完成出片"
: generationPhase === "failed" ? "出片失败"
: generationPhase === "running" || generationPhase === "submitted" ? "已确认,正在出片"
: "点确认后才会提交生成";
const buttonLabel =
generationPhase === "done" ? "已生成"
: generationPhase === "failed" ? "生成失败"
: generationPhase === "running" || generationPhase === "submitted"
? "生成中"
: durationChanged
? "确认并重写脚本"
: String(message.payload.label || "开始生成");
return (
<section className="omni-confirm-card">
<section className={`omni-confirm-card${generationPhase === "done" ? " is-done" : ""}`}>
<div className="omni-confirm-copy">
<strong>{submitted ? "已确认" : `即将用 ${summary} 生成`}</strong>
<span>{submitted ? "正在提交生成" : "确认前可以改参数。改时长会按新时长重写脚本。"}</span>
<strong>{title}</strong>
<span>{subtitle}</span>
</div>
{submitted ? null : (
<div className="omni-confirm-params">
@@ -1935,16 +2019,18 @@ function ConfirmCard({
/>
</div>
)}
{durationChanged ? (
{submitted ? null : durationChanged ? (
<p className="omni-confirm-hint">改时长会重新生成脚本。确认后先重写方案,不会直接出片。</p>
) : willGenerateInSegments ? (
<p className="omni-confirm-hint">{durationSeconds} 秒将拆成 2 段生成,每段最长 30 秒;片段完成后先给你预览,再由你决定是否合并成片。</p>
<p className="omni-confirm-hint">
{durationSeconds} 秒成片一次生成,时长较长会多花一点时间,请耐心等待。
</p>
) : null}
<div className="omni-confirm-foot">
<span>{submitted ? "已确认,正在出片" : "点确认后才会提交生成"}</span>
<span>{foot}</span>
<button type="button" disabled={disabled || submitted} onClick={() => onConfirm(draft)}>
{durationChanged ? "确认并重写脚本" : String(message.payload.label || "开始生成")}
{!durationChanged && credits > 0 ? <i>约 {credits} 积分</i> : null}
{buttonLabel}
{!submitted && !durationChanged && credits > 0 ? <i>约 {credits} 积分</i> : null}
</button>
</div>
</section>
@@ -1953,30 +2039,35 @@ function ConfirmCard({
function ResultCard({
payload,
onMerge,
merging,
}: {
payload: Record<string, unknown>;
onMerge?: () => void;
merging?: boolean;
}) {
const assets = (payload.assets as Array<Record<string, string>> | undefined) || [];
const first = assets[0] || {};
const meta = [payload.model, payload.resolution, payload.ratio].filter(Boolean).join(" · ");
const isGeneratedVideo = first.type === "video";
const partialFailure = Boolean(payload.partial_failure);
// 全成功自动合并:分段结果卡隐藏,只展示后面的合并进度/成片。
// 局部失败才摊开已成功分段;普通单段/图片结果照常展示。
if (Boolean(payload.needs_merge) && !partialFailure) return null;
const showSegmentGrid = partialFailure && assets.length > 0;
const showSingleMedia = !showSegmentGrid && assets.length > 0;
const [preview, setPreview] = useState<{ src: string; kind: "image" | "video"; name: string } | null>(null);
const ratio = String(payload.ratio || "").trim();
const ratioClass = ratio === "16:9" ? "is-ratio-16-9" : ratio === "1:1" ? "is-ratio-1-1" : "is-ratio-9-16";
const multiClass = assets.length > 1 ? "has-multiple" : "has-single";
const tileCount = showSegmentGrid ? assets.length : showSingleMedia ? 1 : 0;
const multiClass = tileCount > 1 ? "has-multiple" : "has-single";
const manyClass = tileCount > 4 ? " has-many" : "";
const displayAssets = showSegmentGrid ? assets : showSingleMedia ? assets.slice(0, 1) : [];
return (
<section className={`omni-result-card ${multiClass} ${ratioClass}`}>
<div className={`omni-result-media${assets.length > 1 ? " is-grid" : ""}`}>
{assets.map((asset, index) => {
<section className={`omni-result-card ${multiClass}${manyClass} ${ratioClass}`}>
<div className={`omni-result-media${displayAssets.length > 1 ? " is-grid" : ""}`}>
{displayAssets.map((asset, index) => {
const cover = asset.cover || asset.url || "";
const url = asset.url || cover;
const video = asset.type === "video";
const label = String(asset.label || (assets.length > 1 ? `第 ${index + 1} 段` : "生成结果"));
const label = String(asset.label || (displayAssets.length > 1 ? `第 ${index + 1} 段` : "生成结果"));
return (
<figure className="omni-result-tile" key={asset.id || `${url}-${index}`}>
<button
@@ -2012,21 +2103,14 @@ function ResultCard({
<div className="omni-result-info">
<div>
<strong>{
isGeneratedVideo
? payload.needs_merge ? `已生成 ${assets.length} 段视频` : "视频已生成"
: assets.length > 1 ? `已生成 ${assets.length} 张图` : "图片已生成"
partialFailure
? `已保留 ${assets.length} 段成功视频`
: isGeneratedVideo
? "视频已生成"
: assets.length > 1 ? `已生成 ${assets.length} 张图` : "图片已生成"
}</strong>
<small>{payload.needs_merge ? "请先预览片段,确认后再合并成片" : meta}</small>
{partialFailure && payload.error ? <small>{String(payload.error)}</small> : null}
</div>
{payload.needs_merge ? (
<button
type="button"
disabled={merging || payload.merge_state === "queued" || payload.merge_state === "processing" || payload.merge_state === "completed"}
onClick={onMerge}
>
{payload.merge_state === "completed" ? "已合并" : merging || payload.merge_state === "queued" || payload.merge_state === "processing" ? "正在合并" : "合并成片"}
</button>
) : null}
</div>
<MediaLightbox
open={Boolean(preview?.src)}
@@ -2039,6 +2123,7 @@ function ResultCard({
);
}
function isLocalUserId(id: string) {
return id.startsWith("local-user-");
}
@@ -2169,6 +2254,21 @@ function turnLooksSettled(detail: CreationConversationDetail) {
return msgs.slice(userIdx + 1).some((m) => m.role !== "user");
}
/**
* 只把「本次规划启动以后」产生的错误视为当前轮失败。
* 确认卡续跑不会新增 user 气泡,若简单检查最后一条用户消息之后是否有 error,
* 上一轮遗留的超时提示会误杀本轮真实 planning,让进度卡闪一下就消失。
*/
function hasCurrentPlanningError(detail: CreationConversationDetail) {
const startedAt = Date.parse(detail.agent_started_at || "");
if (!Number.isFinite(startedAt)) return false;
return (detail.messages || []).some((message) => (
message.kind === "error"
&& Number.isFinite(Date.parse(message.created_at || ""))
&& Date.parse(message.created_at) >= startedAt
));
}
/** payload 是否实质相同(忽略 key 顺序之外的引用身份) */
function samePayload(a: Record<string, unknown>, b: Record<string, unknown>) {
return JSON.stringify(a || {}) === JSON.stringify(b || {});
@@ -2291,7 +2391,10 @@ function keepUnackedLocals(prev: CreationMessage[], incoming: CreationMessage[])
return collapseDupUser(leftover.length ? [...merged, ...leftover] : merged);
}
function withoutLegacyGateArtifacts(list: CreationMessage[]): CreationMessage[] {
function withoutLegacyGateArtifacts(
list: CreationMessage[],
activePlanningStartedAt?: string | null,
): CreationMessage[] {
/**
* 旧版本点「先不用」时会落一条系统伪造的 user 消息,紧接着再回一句空话。
* 数据先保留,这里只在能确认完整错误序列时隐藏,避免老会话刷新后还显示这组脏气泡。
@@ -2319,79 +2422,62 @@ function withoutLegacyGateArtifacts(list: CreationMessage[]): CreationMessage[]
hidden.add(deadEnd.id);
}
}
// 同一会话反复重试时,旧的「整理超时」只会制造噪音。保留最近一次,
// 不删除数据库历史,用户再次操作后也不会看到两三条同义错误堆在一起。
const timeoutErrors = list.filter((message) => (
message.kind === "error"
&& /(?:方案整理.*(?:超时|超过)|整理\s*\d+\s*秒方案耗时过长)/.test(message.text || "")
));
const planningStartedMs = Date.parse(activePlanningStartedAt || "");
const visibleTimeoutErrors = Number.isFinite(planningStartedMs)
// 已开始新的整理:上一次的超时只属于历史,当前界面只应展示本轮进度。
? timeoutErrors.filter((message) => Date.parse(message.created_at || "") >= planningStartedMs)
: timeoutErrors;
for (const message of timeoutErrors) {
if (!visibleTimeoutErrors.includes(message)) hidden.add(message.id);
}
for (const message of visibleTimeoutErrors.slice(0, -1)) hidden.add(message.id);
return hidden.size ? list.filter((message) => !hidden.has(message.id)) : list;
}
function ProcessCard({
payload,
onPreview,
}: {
payload: Record<string, unknown>;
onPreview?: (src: string, kind: "image" | "video", name: string) => void;
}) {
const isSegmentedVideo = payload.kind === "video_segments";
const isMerge = payload.kind === "video_merge";
const isVideo = payload.kind === "video" || isSegmentedVideo || isMerge;
const segmentCount = Array.isArray(payload.segments) ? payload.segments.length : 0;
const assets = (payload.assets as Array<Record<string, string>> | undefined) || [];
const completedSegments = Number(payload.completed_segment_count || 0);
const waitingSegments = isSegmentedVideo ? Math.max(1, segmentCount - completedSegments) : 1;
const totalTiles = assets.length + waitingSegments;
const multiClass = totalTiles > 1 || isSegmentedVideo ? "has-multiple" : "has-single";
const ratio = String(payload.ratio || "").trim();
const ratioClass = ratio === "16:9" ? "is-ratio-16-9" : ratio === "1:1" ? "is-ratio-1-1" : "is-ratio-9-16";
return (
<section className={`omni-result-card omni-process-card ${multiClass} ${ratioClass}`}>
<div className={`omni-result-media${multiClass === "has-multiple" ? " is-grid" : ""}`}>
{assets.map((asset, index) => {
const cover = asset.cover || asset.url || "";
const url = asset.url || cover;
const video = asset.type === "video";
const label = String(asset.label || `第 ${index + 1} 段`);
return (
<figure className="omni-result-tile" key={asset.id || `${url}-${index}`}>
<button
type="button"
className="omni-result-preview"
onClick={() => url && onPreview?.(url, video ? "video" : "image", label)}
>
<img src={cover} alt={label} />
{video ? (
<span className="omni-result-play" aria-hidden="true">
<Play />
</span>
) : null}
</button>
</figure>
);
})}
{Array.from({ length: waitingSegments }).map((_, index) => (
<figure className="omni-result-tile" key={`generating-${index}`}>
<div className="omni-process-frame" aria-hidden="true">
<span className="omni-process-ring" />
<strong>{isVideo ? "视频生成中" : "图片生成中"}</strong>
</div>
</figure>
))}
<section className={`omni-result-card omni-process-card has-single ${ratioClass}`}>
<div className="omni-result-media">
<figure className="omni-result-tile">
<div className="omni-process-frame" aria-hidden="true">
<span className="omni-process-ring" />
<strong>{isVideo ? "视频生成中" : "图片生成中"}</strong>
</div>
</figure>
</div>
<div className="omni-result-info">
<div>
<strong>{
isMerge
? "正在合并成片"
: isSegmentedVideo && completedSegments
? `第 ${completedSegments} 段已生成,正在生成第 ${completedSegments + 1} 段`
: isSegmentedVideo
? `正在生成 ${segmentCount || 2} 段视频`
: isVideo ? "正在生成视频" : "正在出图"
? "成片收尾中"
: isSegmentedVideo
? "正在生成长视频"
: isVideo ? "正在生成视频" : "正在出图"
}</strong>
<small>{
isMerge
? "正在拼接已确认的片段"
: isSegmentedVideo && completedSegments
? "已生成的片段可以先点击预览"
: isSegmentedVideo ? "片段完成后会先展示给你确认" : isVideo ? "方案编译中,请稍候" : "画面生成中,请稍候"
? "马上就好,请稍候"
: isSegmentedVideo
? "时长较长,生成会多花一点时间,请耐心等待"
: isVideo ? "方案编译中,请稍候" : "画面生成中,请稍候"
}</small>
</div>
</div>
@@ -2460,6 +2546,17 @@ export function OmniSessionPage({
() => Boolean(getMentionFreeText(prompt, pendingRefs)),
[pendingRefs, prompt],
);
/**
* @提及会在编辑器里渲染成带缩略图的 token;但首页带入和「编辑」重放的引用正文里没有 @,
* 只靠编辑器就完全看不见。这一排只补这类「没写进正文」的引用,避免 @ 流程里一张图出现两次。
*/
const looseComposerRefs = useMemo(
() => pendingRefs.filter((ref) => {
const name = shortRefName(ref.name);
return !name || !prompt.includes(`@${name}`);
}),
[pendingRefs, prompt],
);
const [sessionUploads, setSessionUploads] = useState<CreationRef[]>(firstUploads || []);
const [uploading, setUploading] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
@@ -2474,6 +2571,7 @@ export function OmniSessionPage({
/** 商品闸门点「上传商品图」后,上传完成要回填 elicit_answer,而不是只塞进输入框。 */
const productGateMessageIdRef = useRef<string | null>(null);
const composerRef = useRef<RichMentionEditorHandle>(null);
const thinkingLogRef = useRef<HTMLDivElement>(null);
const [streaming, setStreaming] = useState(false);
const [liveText, setLiveText] = useState("");
const [liveReasoning, setLiveReasoning] = useState("");
@@ -2488,7 +2586,6 @@ export function OmniSessionPage({
const [sessionAssetModalOpen, setSessionAssetModalOpen] = useState(false);
const [sessionAssetModalType, setSessionAssetModalType] = useState<AssetModalType>("product");
const [confirming, setConfirming] = useState(false);
const [mergingMessageIds, setMergingMessageIds] = useState<string[]>([]);
const [stopping, setStopping] = useState(false);
const [promptView, setPromptView] = useState<{ title: string; body: string } | null>(null);
const [assetsOpen, setAssetsOpen] = useState(false);
@@ -2600,6 +2697,10 @@ export function OmniSessionPage({
if (waitingForFreshTurn) return;
streamingRef.current = planning;
setStreaming(planning);
if (planning) {
setActiveTool(detail.agent_progress?.label || "");
setLiveReasoning(detail.agent_progress?.detail || "");
}
})
.catch((error) => notify("error", (error as Error).message));
return () => {
@@ -2692,7 +2793,21 @@ export function OmniSessionPage({
() => messages.some((message) => message.kind === "generating"),
[messages]
);
const visibleMessages = useMemo(() => withoutLegacyGateArtifacts(messages), [messages]);
const visibleMessages = useMemo(
() => withoutLegacyGateArtifacts(
messages,
conversation?.agent_status === "planning" ? conversation.agent_started_at : null,
),
[conversation?.agent_started_at, conversation?.agent_status, messages],
);
const currentPlanningHasError = Boolean(
conversation && hasCurrentPlanningError(conversation)
);
const visibleProgressHistory = conversation?.agent_progress?.history || [];
useEffect(() => {
const log = thinkingLogRef.current;
if (log) log.scrollTop = log.scrollHeight;
}, [liveReasoning, visibleProgressHistory]);
const sessionAssets = useMemo(
() => collectSessionAssets(messages, conversation?.pinned_refs || [], sessionUploads),
[messages, conversation?.pinned_refs, sessionUploads],
@@ -2825,15 +2940,7 @@ export function OmniSessionPage({
return;
}
// 本轮已落 error:不要继续挂「正在整理方案」
const msgs = detail.messages || [];
let lastUserIdx = -1;
for (let i = msgs.length - 1; i >= 0; i -= 1) {
if (msgs[i].role === "user") {
lastUserIdx = i;
break;
}
}
if (lastUserIdx !== -1 && msgs.slice(lastUserIdx + 1).some((m) => m.kind === "error")) {
if (hasCurrentPlanningError(detail)) {
stalePlanningNotifiedRef.current = false;
clearThinking();
setConversation((prev) => (prev ? { ...prev, agent_status: "idle" } : prev));
@@ -2858,6 +2965,10 @@ export function OmniSessionPage({
holdPlanningUntilRef.current = Math.max(holdPlanningUntilRef.current, Date.now() + 2500);
streamingRef.current = true;
setStreaming(true);
// 后台 Agent 不直接连着浏览器 SSE;通过轮询拿到受控进度,
// 展示「正在做什么」而非模型原始 thinking,避免长方案看起来像卡住。
setActiveTool(detail.agent_progress?.label || "");
setLiveReasoning(detail.agent_progress?.detail || "");
return;
}
@@ -2876,14 +2987,33 @@ export function OmniSessionPage({
clearThinking();
}, [conversationId, notify]);
// awaiting_user 时不要被短暂 streaming 闪回拖进 planning 轮询,否则闸门按钮会跟着灰/闪
// Agent 整理方案中才算「规划中」。后台出片/出图(hasGenerating)不占用 Agent,
// 不能因为残留 streaming 把发送钮收成终止、或把输入灰掉。
const isPlanning =
conversation?.agent_status === "planning"
|| (Boolean(streaming) && conversation?.agent_status !== "awaiting_user");
|| (
Boolean(streaming)
&& conversation?.agent_status !== "awaiting_user"
&& !hasGenerating
);
// 若状态已是等待用户而 streaming 仍残留,立刻收起,避免 send 被 streamingRef 卡住、按钮被灰掉
// 输入区锁定:仅上传中 / Agent 规划中;出片生成中允许继续发消息
const composerBlocked =
uploading
|| conversation?.agent_status === "planning"
|| (
Boolean(streaming)
&& conversation?.agent_status !== "awaiting_user"
&& !hasGenerating
);
// 若状态已是等待用户,或后台出片中,streaming 残留立刻收起,解开输入
useEffect(() => {
if (conversation?.agent_status !== "awaiting_user") return;
const status = conversation?.agent_status;
const shouldClear =
status === "awaiting_user"
|| (hasGenerating && status !== "planning");
if (!shouldClear) return;
if (!streaming && !streamingRef.current) return;
holdPlanningUntilRef.current = 0;
streamingRef.current = false;
@@ -2891,7 +3021,7 @@ export function OmniSessionPage({
setLiveText("");
setLiveReasoning("");
setActiveTool("");
}, [conversation?.agent_status, streaming]);
}, [conversation?.agent_status, streaming, hasGenerating]);
// 整理方案在 Celery:靠 poll 续进度。刷新/重进只要 agent_status=planning 就会接着转。
useEffect(() => {
@@ -2918,8 +3048,13 @@ export function OmniSessionPage({
const send = useCallback(
async (payload: Parameters<typeof api.creationSend>[1]) => {
// awaiting_user 下允许回答闸门;其它状态仍禁止并发发送
if (streamingRef.current && conversation?.agent_status !== "awaiting_user") return false;
// awaiting_user 下允许回答闸门;后台出片中也允许继续发消息;仅 Agent 规划中禁止并发
if (conversation?.agent_status === "planning") return false;
if (
streamingRef.current
&& conversation?.agent_status !== "awaiting_user"
&& !messagesRef.current.some((message) => message.kind === "generating")
) return false;
// 先占位用户气泡,再亮「正在整理方案」—— 顺序不能反
const isTextTurn = payload.kind !== "elicit_answer";
let localId: string | null = null;
@@ -3148,29 +3283,16 @@ export function OmniSessionPage({
}
};
const handleMergeSegments = async (message: CreationMessage) => {
if (mergingMessageIds.includes(message.id)) return;
setMergingMessageIds((prev) => [...prev, message.id]);
try {
const result = await api.mergeCreationVideoSegments(conversationId, message.id);
setMessages((prev) => [
...prev.map((item) => item.id === message.id
? { ...item, payload: { ...item.payload, merge_state: "queued" } }
: item),
result.message,
]);
notify("info", "正在合并成片");
} catch (error) {
notify("error", (error as Error).message || "合并失败,请重试");
setMergingMessageIds((prev) => prev.filter((id) => id !== message.id));
}
};
const handleSend = () => {
// 顺序要紧:先判 streaming 再清输入框。反过来的话,流式期间敲一次回车
// 顺序要紧:先判锁定再清输入框。反过来的话,锁定期间敲一次回车
// 会把已经打好的内容清空,消息却没发出去。
if (uploading) return;
if (streaming && conversation?.agent_status !== "awaiting_user") return;
if (conversation?.agent_status === "planning") return;
if (
streaming
&& conversation?.agent_status !== "awaiting_user"
&& !hasGenerating
) return;
const text = prompt.trim();
if (!getMentionFreeText(text, pendingRefs)) {
notify("info", "请先输入具体的创作描述,@引用不能单独发送");
@@ -3344,6 +3466,17 @@ export function OmniSessionPage({
!item.payload?.submitted
)
: false;
const confirmIndex = messages.findIndex((item) => item.id === message.id);
const later = confirmIndex >= 0 ? messages.slice(confirmIndex + 1) : [];
const generationPhase = !message.payload?.submitted
? "idle"
: later.some((item) => item.kind === "generating")
? "running"
: later.some((item) => item.kind === "result")
? "done"
: later.some((item) => item.kind === "error")
? "failed"
: "submitted";
return (
<ConfirmCard
key={message.clientKey || message.id}
@@ -3351,6 +3484,7 @@ export function OmniSessionPage({
sessionParams={params}
isVideo={isVideo}
catalogModels={modelConfigs}
generationPhase={generationPhase}
disabled={
confirming
|| pendingStep
@@ -3365,7 +3499,6 @@ export function OmniSessionPage({
<ProcessCard
key={message.clientKey || message.id}
payload={message.payload}
onPreview={(src, kind, name) => setAssetPreview({ src, kind, name })}
/>
);
case "result":
@@ -3373,8 +3506,6 @@ export function OmniSessionPage({
<ResultCard
key={message.clientKey || message.id}
payload={message.payload}
merging={mergingMessageIds.includes(message.id)}
onMerge={message.payload?.needs_merge ? () => void handleMergeSegments(message) : undefined}
/>
);
default: {
@@ -3512,7 +3643,11 @@ export function OmniSessionPage({
{/* 流式中的临时气泡。挂 is-live 关掉入场动画 —— 它每来一个字符都会
重渲染,带动画的话整条会一直在闪;真消息落地时它被替换掉,
那一下也不该再演一次入场。 */}
{(conversation?.agent_status !== "awaiting_user") && (streaming || conversation?.agent_status === "planning") && !hasGenerating ? (
{(conversation?.agent_status !== "awaiting_user")
&& (streaming || conversation?.agent_status === "planning")
&& !hasGenerating
&& Boolean(conversation?.agent_progress)
&& !currentPlanningHasError ? (
/生成图片|生成视频/.test(activeTool) && !liveText ? (
<ProcessCard payload={{ kind: /生成视频/.test(activeTool) ? "video" : "image" }} />
) : liveText ? (
@@ -3532,9 +3667,26 @@ export function OmniSessionPage({
<div className={`omni-chat-bubble${liveReasoning ? " is-reasoning" : " is-thinking"}`}>
<div className="omni-think-head">
<span className="omni-typing" aria-hidden="true"><i /><i /><i /></span>
<span>{conversation?.agent_status === "planning" || streaming ? (isVideo ? "正在整理方案" : "正在整理画面") : activeTool ? `${activeTool}…` : liveReasoning ? "思考中" : isVideo ? "正在整理方案" : "正在整理画面"}</span>
<span>{activeTool || (liveReasoning ? "正在思考" : isVideo ? "正在整理方案" : "正在整理画面")}</span>
</div>
{liveReasoning ? <p className="omni-think-text">{liveReasoning}</p> : null}
{liveReasoning ? (
<div className="omni-think-log" ref={thinkingLogRef} aria-label="创作过程">
{(visibleProgressHistory.length
? visibleProgressHistory
: [{
label: activeTool || "正在思考",
detail: liveReasoning,
}]
).map((progress, index, history) => (
<div
className={`omni-think-log-row${index === history.length - 1 ? " is-current" : ""}`}
key={`${progress.label}-${progress.detail}-${index}`}
>
<p>{progress.detail}</p>
</div>
))}
</div>
) : null}
</div>
</div>
)
@@ -3542,13 +3694,18 @@ export function OmniSessionPage({
</main>
<footer className={`omni-session-composer${composerHint ? " is-revise-hint" : ""}`}>
<MentionChips
refs={looseComposerRefs}
tone="composer"
onRemove={(id) => setPendingRefs((prev) => prev.filter((ref) => ref.id !== id))}
/>
<RichMentionEditor
id="omniSessionPrompt"
ref={composerRef}
placeholder={composerHint || "继续补充图片或创作要求……"}
value={prompt}
refs={pendingRefs}
disabled={uploading || (streaming && conversation?.agent_status !== "awaiting_user")}
disabled={composerBlocked}
submitOnEnter
onChange={setPrompt}
onAtTrigger={() => void openMentions()}
@@ -3562,9 +3719,9 @@ export function OmniSessionPage({
className="omni-icon-tool"
aria-label={uploading ? "上传审核中" : "添加素材"}
title={uploading ? "上传并审核中…" : "添加素材"}
disabled={uploading || streaming}
disabled={composerBlocked}
onClick={() => {
if (uploading || streaming) return;
if (composerBlocked) return;
personSourceRequestRef.current = null;
quickUploadReplyRef.current = null;
setUploadMenuOpen((open) => !open);
@@ -3793,11 +3950,7 @@ export function OmniSessionPage({
disabled={
isPlanning
? stopping
: (
uploading
|| (streaming && conversation?.agent_status !== "awaiting_user")
|| !hasComposerDescription
)
: (composerBlocked || !hasComposerDescription)
}
onClick={() => {
if (isPlanning) void handleStop();
-1
View File
@@ -4783,7 +4783,6 @@ export function PipelinePage(props: {
<ConfirmModal
open={chargeConfirm !== null}
title="确认生成视频"
subtitle="// 失败不扣 · 成功后结算"
icon={<Sparkles size={16} />}
detail={(
<>