模特库标签分页与全能创作收口:藏长视频、选择器分页
角色库导入打标签并支持筛选;模特库与全能创作角色/商品选择改为每页 20 条分页。临时限制成片 ≤60 秒,过滤对话里的超长时长选项,并收拢本地全能创作与后台用户相关修复。
This commit is contained in:
@@ -934,7 +934,6 @@ export function App() {
|
||||
<ConfirmModal
|
||||
open={sessionInvalidated}
|
||||
title="当前账号已在其他设备登录"
|
||||
subtitle="// SESSION REPLACED"
|
||||
icon={<MonitorOff size={16} />}
|
||||
detail="为保护账号安全,当前设备的登录已失效。点击确认后返回登录页面。"
|
||||
confirmText="确认并返回登录"
|
||||
|
||||
@@ -323,9 +323,16 @@ export const api = {
|
||||
revokeInvitation(id: string) {
|
||||
return request<Invitation>(`/api/auth/team/invitations/${id}/revoke/`, { method: "POST" });
|
||||
},
|
||||
products(pageSize?: number) {
|
||||
const query = pageSize ? `?page_size=${pageSize}` : "";
|
||||
return request<Paginated<Product>>(`/api/products/${query}`);
|
||||
products(pageSizeOrParams?: number | { page?: number; pageSize?: number; q?: string }) {
|
||||
const params = typeof pageSizeOrParams === "number"
|
||||
? { pageSize: pageSizeOrParams }
|
||||
: (pageSizeOrParams || {});
|
||||
const qs = new URLSearchParams();
|
||||
if (params.page) qs.set("page", String(params.page));
|
||||
if (params.pageSize) qs.set("page_size", String(params.pageSize));
|
||||
if (params.q?.trim()) qs.set("search", params.q.trim());
|
||||
const query = qs.toString();
|
||||
return request<Paginated<Product>>(`/api/products/${query ? `?${query}` : ""}`);
|
||||
},
|
||||
product(id: string) {
|
||||
return request<Product>(`/api/products/${id}/`);
|
||||
@@ -875,14 +882,22 @@ export const api = {
|
||||
return request<Asset>(`/api/assets/${id}/`, { method: "PATCH", body: JSON.stringify(payload) });
|
||||
},
|
||||
// 模特库:列出(本团队 ∪ 官方模板);tab=official 只看官方、mine 只看自建
|
||||
listModels(params: { tab?: "official" | "mine"; q?: string; portraitAsset?: string; pageSize?: number } = {}) {
|
||||
listModels(params: { tab?: "official" | "mine"; q?: string; tags?: string[]; portraitAsset?: string; page?: number; pageSize?: number } = {}) {
|
||||
const qs = new URLSearchParams();
|
||||
if (params.tab) qs.set("tab", params.tab);
|
||||
if (params.q) qs.set("q", params.q);
|
||||
if (params.portraitAsset) qs.set("portrait_asset", params.portraitAsset);
|
||||
for (const tag of params.tags || []) qs.append("tag", tag);
|
||||
if (params.page) qs.set("page", String(params.page));
|
||||
qs.set("page_size", String(params.pageSize ?? 200));
|
||||
return request<Paginated<ModelEntity>>(`/api/models/?${qs.toString()}`);
|
||||
},
|
||||
listModelTags(params: { tab?: "official" | "mine" } = {}) {
|
||||
const qs = new URLSearchParams();
|
||||
if (params.tab) qs.set("tab", params.tab);
|
||||
const q = qs.toString();
|
||||
return request<{ results: { name: string; label?: string; count: number }[] }>(`/api/models/tags/${q ? `?${q}` : ""}`);
|
||||
},
|
||||
// 真人上传:一张人像图 → 建 model_portrait 资产 + Model 实体
|
||||
uploadModel(formData: FormData) {
|
||||
return request<ModelEntity>("/api/models/upload/", { method: "POST", body: formData });
|
||||
@@ -1216,7 +1231,7 @@ export const adminApi = {
|
||||
const q = qs.toString();
|
||||
return request<Paginated<AdminUser>>(`/api/admin/users/${q ? `?${q}` : ""}`);
|
||||
},
|
||||
createUser(payload: { username: string; password: string; initial_credits?: string }) {
|
||||
createUser(payload: { username: string; display_name: string; password: string; initial_credits?: string }) {
|
||||
return request<AdminUser>("/api/admin/users/", { method: "POST", body: JSON.stringify(payload) });
|
||||
},
|
||||
adjustUserCredit(id: string, payload: { amount: string; reason?: string }) {
|
||||
|
||||
@@ -372,3 +372,12 @@
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.omni-asset-picker-pager {
|
||||
margin-top: 14px;
|
||||
padding-bottom: 2px;
|
||||
}
|
||||
.omni-asset-picker-pager .list-pager {
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { api } from "../api";
|
||||
import { Pager } from "./pager";
|
||||
import type { CreationRef } from "../types";
|
||||
import "./asset-select-modal.css";
|
||||
|
||||
@@ -24,11 +25,7 @@ interface AssetSelectModalProps {
|
||||
onNotify?: (type: "success" | "error" | "info", text: string) => void;
|
||||
}
|
||||
|
||||
const assetLibraryCache = new Map<string, CreationRef[]>();
|
||||
|
||||
function assetLibraryKey(type: AssetModalType, query: string) {
|
||||
return `${type}:${query.trim().toLocaleLowerCase("zh-CN")}`;
|
||||
}
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
export function AssetSelectModal({
|
||||
open,
|
||||
@@ -40,7 +37,8 @@ export function AssetSelectModal({
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [debouncedQuery, setDebouncedQuery] = useState("");
|
||||
const [items, setItems] = useState<CreationRef[]>([]);
|
||||
const [loadedKey, setLoadedKey] = useState("");
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [selectedRef, setSelectedRef] = useState<CreationRef | null>(null);
|
||||
const [sortOrder, setSortOrder] = useState<"recent" | "name">("recent");
|
||||
@@ -57,34 +55,31 @@ export function AssetSelectModal({
|
||||
|
||||
const titleText = type === "product" ? "商品" : "角色";
|
||||
const title = type === "product" ? "商品库" : "角色库";
|
||||
const subtitle = type === "product" ? "[ PRODUCT · SELECT ]" : "[ CHARACTER · SELECT ]";
|
||||
const requestKey = assetLibraryKey(type, debouncedQuery);
|
||||
const cachedItems = assetLibraryCache.get(requestKey);
|
||||
const currentItems = loadedKey === requestKey ? items : (cachedItems || []);
|
||||
const hasCurrentData = loadedKey === requestKey || Boolean(cachedItems);
|
||||
|
||||
const visibleItems = useMemo(() => {
|
||||
if (sortOrder === "recent") return currentItems;
|
||||
return [...currentItems].sort((left, right) => left.name.localeCompare(right.name, "zh-CN"));
|
||||
}, [currentItems, sortOrder]);
|
||||
if (sortOrder === "recent") return items;
|
||||
return [...items].sort((left, right) => left.name.localeCompare(right.name, "zh-CN"));
|
||||
}, [items, sortOrder]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setIsAddingNew(false);
|
||||
setSearchQuery("");
|
||||
setDebouncedQuery("");
|
||||
setSelectedRef(null);
|
||||
setSortOrder("recent");
|
||||
setNewFile(null);
|
||||
setNewPreview("");
|
||||
setNewName("");
|
||||
setPage(1);
|
||||
setItems([]);
|
||||
setTotal(0);
|
||||
return;
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setDebouncedQuery("");
|
||||
return;
|
||||
}
|
||||
if (!open) return;
|
||||
const query = searchQuery.trim();
|
||||
if (!query) {
|
||||
setDebouncedQuery("");
|
||||
@@ -94,45 +89,68 @@ export function AssetSelectModal({
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [open, searchQuery]);
|
||||
|
||||
// 换库 / 搜索时回到第 1 页
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setPage(1);
|
||||
setSelectedRef(null);
|
||||
}, [type, debouncedQuery]);
|
||||
|
||||
const cached = assetLibraryCache.get(requestKey);
|
||||
if (cached) {
|
||||
setItems(cached);
|
||||
setLoadedKey(requestKey);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
useEffect(() => {
|
||||
if (!open || isAddingNew) return;
|
||||
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setSelectedRef(null);
|
||||
const types: CreationRef["type"][] = type === "product" ? ["product"] : ["model", "character"];
|
||||
void api
|
||||
.searchMentions({ q: debouncedQuery, types, limit: 20 })
|
||||
.then((response) => {
|
||||
if (cancelled) return;
|
||||
const results = response.results || [];
|
||||
assetLibraryCache.set(requestKey, results);
|
||||
setItems(results);
|
||||
setLoadedKey(requestKey);
|
||||
})
|
||||
.catch((error) => {
|
||||
if (cancelled) return;
|
||||
assetLibraryCache.set(requestKey, []);
|
||||
setItems([]);
|
||||
setLoadedKey(requestKey);
|
||||
notifyRef.current?.("error", (error as Error).message || "加载失败");
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
|
||||
const load = async () => {
|
||||
try {
|
||||
if (type === "character") {
|
||||
const response = await api.listModels({
|
||||
q: debouncedQuery || undefined,
|
||||
page,
|
||||
pageSize: PAGE_SIZE,
|
||||
});
|
||||
if (cancelled) return;
|
||||
const mapped: CreationRef[] = (response.results || [])
|
||||
.filter((m) => m.portrait)
|
||||
.map((m) => ({
|
||||
type: "model",
|
||||
id: m.id,
|
||||
name: m.name,
|
||||
cover: m.portrait,
|
||||
}));
|
||||
setItems(mapped);
|
||||
setTotal(response.count ?? mapped.length);
|
||||
} else {
|
||||
const response = await api.products({
|
||||
q: debouncedQuery || undefined,
|
||||
page,
|
||||
pageSize: PAGE_SIZE,
|
||||
});
|
||||
if (cancelled) return;
|
||||
const mapped: CreationRef[] = (response.results || []).map((p) => ({
|
||||
type: "product",
|
||||
id: p.id,
|
||||
name: p.title,
|
||||
cover: p.cover_preview_url || "",
|
||||
}));
|
||||
setItems(mapped);
|
||||
setTotal(response.count ?? mapped.length);
|
||||
}
|
||||
} catch (error) {
|
||||
if (cancelled) return;
|
||||
setItems([]);
|
||||
setTotal(0);
|
||||
notifyRef.current?.("error", (error as Error).message || "加载失败");
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
void load();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [debouncedQuery, open, requestKey, type]);
|
||||
}, [debouncedQuery, open, page, type, isAddingNew]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
@@ -199,9 +217,6 @@ export function AssetSelectModal({
|
||||
}
|
||||
|
||||
notifyRef.current?.("success", `已新增${titleText}:${finalRef.name}`);
|
||||
for (const key of assetLibraryCache.keys()) {
|
||||
if (key.startsWith(`${type}:`)) assetLibraryCache.delete(key);
|
||||
}
|
||||
onSelect(finalRef);
|
||||
onClose();
|
||||
} catch (error) {
|
||||
@@ -234,7 +249,6 @@ export function AssetSelectModal({
|
||||
</span>
|
||||
<div className="ti" id={titleId}>
|
||||
{isAddingNew ? `新增${titleText}` : title}
|
||||
<span>{isAddingNew ? "[ UPLOAD · CREATE ]" : subtitle}</span>
|
||||
</div>
|
||||
<div className="omni-asset-picker-head-actions">
|
||||
{isAddingNew ? (
|
||||
@@ -328,8 +342,8 @@ export function AssetSelectModal({
|
||||
<option value="name">按名称</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="modal-b omni-asset-picker-body" aria-busy={loading || !hasCurrentData}>
|
||||
{!hasCurrentData ? (
|
||||
<div className="modal-b omni-asset-picker-body" aria-busy={loading}>
|
||||
{loading && items.length === 0 ? (
|
||||
<div className="omni-asset-picker-grid is-loading" role="status" aria-label={`正在加载${titleText}库`}>
|
||||
{Array.from({ length: 10 }, (_, index) => (
|
||||
<div className="omni-asset-picker-skeleton" key={index} aria-hidden="true">
|
||||
@@ -353,38 +367,42 @@ export function AssetSelectModal({
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<div className="omni-asset-picker-grid">
|
||||
{visibleItems.map((item) => {
|
||||
const isSelected = selectedRef?.type === item.type && selectedRef.id === item.id;
|
||||
const displayName = item.name.split(" · ")[0];
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={`${item.type}:${item.id}`}
|
||||
className={`omni-asset-picker-card${isSelected ? " is-selected" : ""}`}
|
||||
aria-pressed={isSelected}
|
||||
onClick={() => setSelectedRef(item)}
|
||||
onDoubleClick={() => {
|
||||
onSelect(item);
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
<span className="omni-asset-picker-thumb">
|
||||
{item.cover ? (
|
||||
<img src={item.cover} alt="" />
|
||||
) : (
|
||||
<span className="omni-asset-picker-fallback">
|
||||
{type === "product" ? <Box /> : <UserRound />}
|
||||
</span>
|
||||
)}
|
||||
{isSelected ? <span className="omni-asset-picker-check"><Check /></span> : null}
|
||||
</span>
|
||||
<span className="omni-asset-picker-card-name" title={displayName}>{displayName}</span>
|
||||
<span className="mono">// {item.type === "product" ? "商品" : item.type === "model" ? "角色库" : "角色素材"}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<>
|
||||
<div className={`omni-asset-picker-grid${loading ? " is-loading" : ""}`}>
|
||||
{visibleItems.map((item) => {
|
||||
const isSelected = selectedRef?.type === item.type && selectedRef.id === item.id;
|
||||
const displayName = item.name.split(" · ")[0];
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={`${item.type}:${item.id}`}
|
||||
className={`omni-asset-picker-card${isSelected ? " is-selected" : ""}`}
|
||||
aria-pressed={isSelected}
|
||||
onClick={() => setSelectedRef(item)}
|
||||
onDoubleClick={() => {
|
||||
onSelect(item);
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
<span className="omni-asset-picker-thumb">
|
||||
{item.cover ? (
|
||||
<img src={item.cover} alt="" />
|
||||
) : (
|
||||
<span className="omni-asset-picker-fallback">
|
||||
{type === "product" ? <Box /> : <UserRound />}
|
||||
</span>
|
||||
)}
|
||||
{isSelected ? <span className="omni-asset-picker-check"><Check /></span> : null}
|
||||
</span>
|
||||
<span className="omni-asset-picker-card-name" title={displayName}>{displayName}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="omni-asset-picker-pager">
|
||||
<Pager page={page} total={total} pageSize={PAGE_SIZE} onChange={setPage} alwaysShow />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<footer className="modal-f">
|
||||
|
||||
@@ -28,8 +28,7 @@ export function SystemLoading({
|
||||
<span className="system-loading-corner corner-br" aria-hidden="true">+</span>
|
||||
|
||||
<div className="system-loading-meta mono">
|
||||
<span>[ AIRSHELF / SYSTEM ]</span>
|
||||
<span>{state === "error" ? "[ CONNECTION ERROR ]" : "[ SYNCING ]"}</span>
|
||||
<span>{state === "error" ? "连接异常" : "同步中"}</span>
|
||||
</div>
|
||||
|
||||
<div className="system-loading-main">
|
||||
|
||||
@@ -18,7 +18,7 @@ const modelToAsset = (m: ModelEntity): Asset => ({
|
||||
source: m.source === "upload" ? "upload" : "ai_generated",
|
||||
category: "model_portrait",
|
||||
description: "",
|
||||
metadata: { kind: "model", model_entity_id: m.id, is_official: m.is_official },
|
||||
metadata: { kind: "model", model_entity_id: m.id, is_official: m.is_official, tags: Array.isArray(m.metadata?.tags) ? m.metadata.tags : [] },
|
||||
files: m.portrait ? [{ id: m.portrait_asset as string, object_key: "", bucket: "", content_type: "image/png", size_bytes: 0, preview_url: m.portrait, is_primary: true }] : [],
|
||||
created_at: m.created_at,
|
||||
updated_at: m.updated_at
|
||||
@@ -61,20 +61,34 @@ export function ModelLibrary({ open, mode, initialStudio, onClose, onPick, onGen
|
||||
|
||||
// 模特选择器只拉 Model;不再把普通 person 资产并入可选列表。
|
||||
const [fetched, setFetched] = useState<Asset[]>([]);
|
||||
const [tagFilter, setTagFilter] = useState<string[]>([]);
|
||||
const [tagOptions, setTagOptions] = useState<{ name: string; label?: string; count: number }[]>([]);
|
||||
const reload = useCallback(async () => {
|
||||
const models = await api.listModels({ pageSize: 200 }).catch(() => null);
|
||||
const models = await api.listModels({ pageSize: 200, tags: tagFilter }).catch(() => null);
|
||||
const mapped = (models?.results ?? []).filter((m) => m.portrait_asset).map(modelToAsset);
|
||||
setFetched(mapped);
|
||||
}, []);
|
||||
}, [tagFilter]);
|
||||
useEffect(() => { if (open) void reload(); }, [open, reload]);
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
let alive = true;
|
||||
api.listModelTags()
|
||||
.then((r) => { if (alive) setTagOptions(r.results || []); })
|
||||
.catch(() => { if (alive) setTagOptions([]); });
|
||||
return () => { alive = false; };
|
||||
}, [open]);
|
||||
useEffect(() => { if (open) setTagFilter([]); }, [open]);
|
||||
|
||||
const list = useMemo(() => fetched.filter((a) => previewOf(a)), [fetched]);
|
||||
function toggleTag(name: string) {
|
||||
setTagFilter((prev) => prev.includes(name) ? prev.filter((t) => t !== name) : [...prev, name]);
|
||||
}
|
||||
|
||||
// 模特库分页(客户端):一页 10 个(5 列 × 2 行)。
|
||||
const PAGE_SIZE = 10;
|
||||
// 模特库分页(客户端):一页 20 个。
|
||||
const PAGE_SIZE = 20;
|
||||
const [page, setPage] = useState(1);
|
||||
const pageCount = Math.max(1, Math.ceil(list.length / PAGE_SIZE));
|
||||
useEffect(() => { setPage(1); }, [open]);
|
||||
useEffect(() => { setPage(1); }, [open, tagFilter]);
|
||||
useEffect(() => { setPage((p) => Math.min(p, pageCount)); }, [pageCount]);
|
||||
const pageList = list.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE);
|
||||
|
||||
@@ -222,6 +236,24 @@ export function ModelLibrary({ open, mode, initialStudio, onClose, onPick, onGen
|
||||
添加模特
|
||||
</button>
|
||||
</div>
|
||||
{tagOptions.length > 0 && (
|
||||
<div className="actorlib-tag-bar" role="toolbar" aria-label="按标签筛选" style={{ display: "flex", flexWrap: "wrap", gap: 8, marginBottom: 12 }}>
|
||||
<button
|
||||
type="button"
|
||||
className={`btn btn-sm${tagFilter.length === 0 ? " btn-primary" : ""}`}
|
||||
onClick={() => setTagFilter([])}
|
||||
>全部</button>
|
||||
{tagOptions.slice(0, 24).map((tag) => (
|
||||
<button
|
||||
key={tag.name}
|
||||
type="button"
|
||||
className={`btn btn-sm${tagFilter.includes(tag.name) ? " btn-primary" : ""}`}
|
||||
onClick={() => toggleTag(tag.name)}
|
||||
title={`${tag.count} 个`}
|
||||
>{tag.label || tag.name}<span className="mono" style={{ marginLeft: 4, opacity: 0.65 }}>{tag.count}</span></button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{list.length ? (
|
||||
<>
|
||||
<div className="actorlib-grid">
|
||||
|
||||
@@ -10,6 +10,8 @@ export const OMNI_VIDEO_MODELS = ["Seedance 2.5", "Seedance 2.0", "Seedance 2.0
|
||||
export const OMNI_IMAGE_MODELS = ["Seedream5.0", "YQ image2"];
|
||||
export const OMNI_RESOLUTIONS = ["1080p", "720p", "480p"];
|
||||
export const OMNI_RATIOS = ["9:16", "16:9", "1:1", "4:3", "3:4"];
|
||||
/** 临时只开放 ≤60 秒;90/120/180 长视频入口先隐藏。 */
|
||||
export const OMNI_MAX_VIDEO_DURATION = 60;
|
||||
export const OMNI_VIDEO_DURATIONS = [
|
||||
"智能时长", "4 秒", "5 秒", "6 秒", "7 秒", "8 秒", "9 秒", "10 秒",
|
||||
"11 秒", "12 秒", "13 秒", "14 秒", "15 秒", "30 秒", "45 秒", "60 秒",
|
||||
@@ -84,20 +86,22 @@ function catalogModelLabels(
|
||||
}
|
||||
|
||||
function canCreateSegmentedVideo(config: ModelConfig | undefined, model: string): boolean {
|
||||
// 31–60 秒不是单次 API 时长,而是平台会自动拆成 <=30 秒的两段。
|
||||
// 只有 Seedance 2.5(或目录里声明了 30 秒能力的模型)可选这个总时长。
|
||||
// 31–60 秒总时长会拆成 ≤30 秒片段(长视频 >60 秒入口已临时关闭)。
|
||||
// 只有 Seedance 2.5(或目录里声明了 30 秒能力的模型)可选这些总时长。
|
||||
return modelDurations(config).some((seconds) => seconds >= 30)
|
||||
|| /seedance\s*2\.5/i.test(model || "");
|
||||
}
|
||||
|
||||
function durationLabelsForModel(config: ModelConfig | undefined, model: string, isVideo: boolean): string[] {
|
||||
if (!isVideo) return [...OMNI_IMAGE_COUNTS];
|
||||
const seconds = modelDurations(config);
|
||||
const seconds = modelDurations(config).filter((n) => n <= OMNI_MAX_VIDEO_DURATION);
|
||||
if (!seconds.length) return [...OMNI_VIDEO_DURATIONS];
|
||||
const segmented = canCreateSegmentedVideo(config, model) ? [45, 60] : [];
|
||||
const segmented = canCreateSegmentedVideo(config, model)
|
||||
? [45, 60].filter((n) => n <= OMNI_MAX_VIDEO_DURATION)
|
||||
: [];
|
||||
return ["智能时长", ...seconds, ...segmented]
|
||||
.filter((seconds, index, values) => values.indexOf(seconds) === index)
|
||||
.map((seconds) => typeof seconds === "number" ? `${seconds} 秒` : seconds);
|
||||
.filter((value, index, values) => values.indexOf(value) === index)
|
||||
.map((value) => typeof value === "number" ? `${value} 秒` : value);
|
||||
}
|
||||
|
||||
export function OmniParamBar({
|
||||
@@ -198,8 +202,13 @@ export function OmniParamBar({
|
||||
}
|
||||
const nextDur = durationLabelsForModel(selected, model, true);
|
||||
const seconds = Number(String(duration || "").replace(/\D/g, ""));
|
||||
const isPlannedSegmentedDuration = seconds > 30 && seconds <= 60 && canCreateSegmentedVideo(selected, model);
|
||||
// 60 秒是总时长,生成时会拆段;不要因为单段目录最高 30 秒就把它偷偷改回 8 秒/智能时长。
|
||||
// 已选 >60 秒的旧会话:收回长视频入口后压回 60 秒。
|
||||
if (seconds > OMNI_MAX_VIDEO_DURATION) {
|
||||
onDuration(`${OMNI_MAX_VIDEO_DURATION} 秒`);
|
||||
return;
|
||||
}
|
||||
const isPlannedSegmentedDuration = seconds > 30 && seconds <= OMNI_MAX_VIDEO_DURATION && canCreateSegmentedVideo(selected, model);
|
||||
// 31–60 秒是总时长,生成时会拆段;不要因为单段目录最高 30 秒就偷偷改回 8 秒/智能时长。
|
||||
if (duration && duration !== "智能时长" && !includesDuration(nextDur, duration) && !isPlannedSegmentedDuration) {
|
||||
onDuration(nextDur.includes("8 秒") ? "8 秒" : (nextDur.find((x) => x !== "智能时长") || "智能时长"));
|
||||
}
|
||||
|
||||
@@ -351,3 +351,59 @@
|
||||
outline: 1.5px solid var(--heat);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.models-page .ml-tag-bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin: 0 0 18px;
|
||||
}
|
||||
.models-page .ml-tag-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
height: 30px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid rgba(34, 42, 54, .10);
|
||||
border-radius: 999px;
|
||||
background: #fff;
|
||||
color: #414750;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
transition: background .15s ease, color .15s ease, border-color .15s ease;
|
||||
}
|
||||
.models-page .ml-tag-chip i {
|
||||
font-style: normal;
|
||||
color: #9aa0a8;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
}
|
||||
.models-page .ml-tag-chip:hover {
|
||||
border-color: rgba(0, 47, 167, .28);
|
||||
color: var(--klein);
|
||||
}
|
||||
.models-page .ml-tag-chip.is-on {
|
||||
border-color: transparent;
|
||||
background: var(--ml-black, #1b2028);
|
||||
color: #fff;
|
||||
}
|
||||
.models-page .ml-tag-chip.is-on i { color: rgba(255,255,255,.72); }
|
||||
|
||||
|
||||
.models-page .ml-tag-chip.ml-tag-scope {
|
||||
font-weight: 600;
|
||||
}
|
||||
.models-page .ml-tag-sep {
|
||||
width: 1px;
|
||||
align-self: stretch;
|
||||
min-height: 22px;
|
||||
background: rgba(0, 0, 0, 0.12);
|
||||
margin: 0 4px;
|
||||
}
|
||||
|
||||
.models-page .ml-pager {
|
||||
margin-top: 18px;
|
||||
}
|
||||
.models-page .ml-pager .list-pager {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
@@ -303,12 +303,21 @@
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* 小云雀式跟随追问:答案留在问题附近,尺寸不抢占对话空间。 */
|
||||
/* 小云雀式跟随追问:与上方选项同宽;与选项拉开一点距离。 */
|
||||
.omni-reply-guide:has(.omni-chat-choice-actions) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.omni-chat-question-input {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
width: min(300px, 100%);
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.omni-chat-question-input .input {
|
||||
@@ -1247,6 +1256,10 @@
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.omni-result-card.has-many .omni-result-media.is-grid {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.omni-result-tile {
|
||||
position: relative;
|
||||
margin: 0;
|
||||
@@ -1433,6 +1446,10 @@
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.omni-result-card.has-many .omni-result-media.is-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.omni-direction-custom button {
|
||||
width: 100%;
|
||||
}
|
||||
@@ -2228,7 +2245,8 @@
|
||||
position: absolute;
|
||||
bottom: calc(100% + 10px);
|
||||
left: 0;
|
||||
z-index: 40;
|
||||
/* 高于顶栏(--z-topbar:50),避免往上展开时被 sticky 顶栏盖住(同层仍可能被裁,配合 is-preview-below) */
|
||||
z-index: calc(var(--z-topbar) + 20);
|
||||
width: 244px;
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
@@ -2250,6 +2268,18 @@
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
/* 靠近顶栏时改为向下展开 */
|
||||
.omni-mention-chip.has-thumb.is-preview-below .omni-mention-hover-preview {
|
||||
top: calc(100% + 10px);
|
||||
bottom: auto;
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
|
||||
.omni-mention-chip.has-thumb.is-preview-below:hover .omni-mention-hover-preview,
|
||||
.omni-mention-chip.has-thumb.is-preview-below:focus-within .omni-mention-hover-preview {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.omni-mention-chips.is-user .omni-mention-hover-preview {
|
||||
right: 0;
|
||||
left: auto;
|
||||
@@ -2408,7 +2438,7 @@
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px 10px;
|
||||
color: #5c6570;
|
||||
color: var(--black-alpha-64);
|
||||
}
|
||||
|
||||
.omni-chat-bubble.is-reasoning {
|
||||
@@ -2416,7 +2446,13 @@
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 8px;
|
||||
color: #5c6570;
|
||||
color: var(--black-alpha-64);
|
||||
}
|
||||
|
||||
/* 规划过程不是一行 loading:给过程本身足够横向空间,历史在内部滚动。 */
|
||||
.omni-chat-row.agent.is-live .omni-chat-bubble.is-reasoning {
|
||||
width: min(620px, calc(100% - 44px));
|
||||
max-width: min(620px, calc(100% - 44px));
|
||||
}
|
||||
|
||||
.omni-think-head {
|
||||
@@ -2424,20 +2460,48 @@
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
color: #5c6570;
|
||||
color: var(--black-alpha-64);
|
||||
}
|
||||
|
||||
.omni-think-text {
|
||||
.omni-think-log {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
max-height: 248px;
|
||||
overflow-y: auto;
|
||||
padding: 10px 8px 2px 0;
|
||||
border-top: 1px solid var(--border-faint);
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
|
||||
.omni-think-log-row {
|
||||
padding-left: 10px;
|
||||
border-left: 1px solid var(--border-muted);
|
||||
}
|
||||
|
||||
.omni-think-log-row.is-current {
|
||||
position: relative;
|
||||
border-left-color: transparent;
|
||||
}
|
||||
|
||||
.omni-think-log-row.is-current::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: -1px;
|
||||
width: 2px;
|
||||
border-radius: var(--r-sm);
|
||||
background: var(--klein);
|
||||
animation: omniCurrentStepPulse 1.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.omni-think-log-row p {
|
||||
margin: 0;
|
||||
min-width: 0;
|
||||
color: var(--black-alpha-64);
|
||||
font-size: 12px;
|
||||
line-height: 1.65;
|
||||
color: #8a93a0;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
max-height: 168px;
|
||||
overflow-y: auto;
|
||||
border-left: 1px solid rgba(15, 23, 42, 0.08);
|
||||
padding-left: 10px;
|
||||
}
|
||||
|
||||
.omni-typing {
|
||||
@@ -2463,6 +2527,11 @@
|
||||
40% { opacity: 1; transform: translateY(-2px); }
|
||||
}
|
||||
|
||||
@keyframes omniCurrentStepPulse {
|
||||
0%, 100% { opacity: .32; }
|
||||
50% { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes omniSendPulse {
|
||||
0%, 100% {
|
||||
background: #5b6572;
|
||||
|
||||
@@ -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={(
|
||||
<>
|
||||
|
||||
@@ -79,6 +79,8 @@ export type AdminTeamDetail = AdminTeam & { members: AdminTeamMember[] };
|
||||
export type AdminUser = {
|
||||
id: string;
|
||||
username: string;
|
||||
/** 展示用用户名(可中文);登录账号是 username */
|
||||
first_name?: string;
|
||||
status: string;
|
||||
is_platform_admin: boolean;
|
||||
date_joined: string;
|
||||
@@ -944,6 +946,13 @@ export type CreationConversation = {
|
||||
status: "running" | "completed" | "failed";
|
||||
agent_status: CreationAgentStatus;
|
||||
agent_started_at?: string | null;
|
||||
/** 后台整理时的用户可见进度;服务端只返回受控阶段文案,不含模型原始 thinking。 */
|
||||
agent_progress?: {
|
||||
label: string;
|
||||
detail: string;
|
||||
/** 当前轮可滚动查看的受控创作过程,不包含模型原始 thinking。 */
|
||||
history?: Array<{ label: string; detail: string }>;
|
||||
} | null;
|
||||
message_count: number;
|
||||
cover_url: string;
|
||||
last_active_at: string;
|
||||
|
||||
Reference in New Issue
Block a user