模特库标签分页与全能创作收口:藏长视频、选择器分页
角色库导入打标签并支持筛选;模特库与全能创作角色/商品选择改为每页 20 条分页。临时限制成片 ≤60 秒,过滤对话里的超长时长选项,并收拢本地全能创作与后台用户相关修复。
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -4783,7 +4783,6 @@ export function PipelinePage(props: {
|
||||
<ConfirmModal
|
||||
open={chargeConfirm !== null}
|
||||
title="确认生成视频"
|
||||
subtitle="// 失败不扣 · 成功后结算"
|
||||
icon={<Sparkles size={16} />}
|
||||
detail={(
|
||||
<>
|
||||
|
||||
Reference in New Issue
Block a user