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

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