添加全能创作功能
This commit is contained in:
@@ -31,6 +31,9 @@ import {
|
||||
Dashboard,
|
||||
FreeCreatePage,
|
||||
QuickCreatePage,
|
||||
OmniCreatePage,
|
||||
OmniHistoryPage,
|
||||
OmniSessionPage,
|
||||
VideoRemixPage,
|
||||
VideoReplacePage,
|
||||
ImageWorkbenchPage,
|
||||
@@ -544,7 +547,8 @@ export function App() {
|
||||
if (options.projectId !== undefined) setActiveProjectId(options.projectId);
|
||||
const hash = options.hash?.replace(/^#/, "");
|
||||
const currentPath = `${window.location.pathname}${window.location.hash}`;
|
||||
const path = `${pathForPage(next, { productId, projectId })}${hash ? `#${hash}` : ""}`;
|
||||
const conversationId = options.conversationId ?? route.conversationId;
|
||||
const path = `${pathForPage(next, { productId, projectId, conversationId })}${hash ? `#${hash}` : ""}`;
|
||||
const prevState = readNavState(window.history.state);
|
||||
const leaving: NavHistoryState = {
|
||||
airshelf: 1,
|
||||
@@ -555,7 +559,10 @@ export function App() {
|
||||
if (!options.replace) {
|
||||
window.history.replaceState(leaving, "", currentPath);
|
||||
}
|
||||
setRoute({ page: next, authMode, productId, projectId, hash, tab: options.tab });
|
||||
setRoute({
|
||||
page: next, authMode, productId, projectId, conversationId, hash,
|
||||
tab: options.tab, firstMessage: options.firstMessage, firstRefs: options.firstRefs,
|
||||
});
|
||||
const arriving: NavHistoryState = {
|
||||
airshelf: 1,
|
||||
scrollY: 0,
|
||||
@@ -1065,6 +1072,22 @@ export function App() {
|
||||
navigate={navigate}
|
||||
/>
|
||||
);
|
||||
case "omniCreate":
|
||||
return <OmniCreatePage navigate={navigate} onNotify={(type, text) => setNotice({ type, text })} />;
|
||||
case "omniHistory":
|
||||
return <OmniHistoryPage navigate={navigate} onNotify={(type, text) => setNotice({ type, text })} />;
|
||||
case "omniSession":
|
||||
return route.conversationId ? (
|
||||
<OmniSessionPage
|
||||
conversationId={route.conversationId}
|
||||
firstMessage={route.firstMessage}
|
||||
firstRefs={route.firstRefs}
|
||||
navigate={navigate}
|
||||
onNotify={(type, text) => setNotice({ type, text })}
|
||||
/>
|
||||
) : (
|
||||
<OmniHistoryPage navigate={navigate} onNotify={(type, text) => setNotice({ type, text })} />
|
||||
);
|
||||
case "assetFactory":
|
||||
return <AssetFactoryPage navigate={navigate} />;
|
||||
case "freeCreate":
|
||||
@@ -1252,13 +1275,19 @@ export function App() {
|
||||
<div className="app">
|
||||
<Sidebar page={page} navigate={navigate} user={currentUser} team={currentTeam} canManageBilling={isOwner} products={products} projects={projects} productTotal={productTotal} projectTotal={projectTotal} aiUnread={aiUnread} onOpenAdmin={() => navigateAdmin("")} accountOpen={accountAnchor !== null} onOpenAccount={(rect) => setAccountAnchor((prev) => (prev ? null : rect))} />
|
||||
<header className="topbar">
|
||||
<ModeTabs active={topModule} navigate={navigate} />
|
||||
{page === "omniSession" ? (
|
||||
<div id="omni-session-topbar-slot" className="omni-session-topbar-slot" />
|
||||
) : (
|
||||
<ModeTabs active={topModule} navigate={navigate} />
|
||||
)}
|
||||
<div className="right">
|
||||
{page !== "omniSession" && (
|
||||
<button className="top-search" type="button" onClick={() => openCommandPalette()} aria-label="搜索">
|
||||
<IconKitSvg name="search" />
|
||||
<span>搜索</span>
|
||||
<span className="kbd">{searchKbd}</span>
|
||||
</button>
|
||||
)}
|
||||
<span className="balance-chip" onClick={() => navigate("account")}>
|
||||
<IconKitSvg name="creditCard" />
|
||||
余额 <strong>{money(billing?.account.balance)}</strong>
|
||||
|
||||
@@ -29,6 +29,10 @@ import type {
|
||||
ImageConversation,
|
||||
ImageConversationTrash,
|
||||
ImageConversationTask,
|
||||
CreationConversation,
|
||||
CreationConversationDetail,
|
||||
CreationMessage,
|
||||
CreationRef,
|
||||
ModelConfig,
|
||||
ModelEntity,
|
||||
Notification,
|
||||
@@ -530,6 +534,129 @@ export const api = {
|
||||
}
|
||||
}
|
||||
},
|
||||
// ── 全能创作(契约见仓库根 `全能创作-契约-2026-09-02.md`)
|
||||
|
||||
/** @ 检索:输入框打 @ 或用户说了名字时用。返回的整条 Ref 要原样带进 send 的 refs。 */
|
||||
searchMentions(params: { q?: string; types?: CreationRef["type"][]; limit?: number } = {}) {
|
||||
const query = new URLSearchParams();
|
||||
if (params.q) query.set("q", params.q);
|
||||
if (params.types?.length) query.set("types", params.types.join(","));
|
||||
if (params.limit) query.set("limit", String(params.limit));
|
||||
const suffix = query.toString();
|
||||
return request<{ results: CreationRef[]; type_labels: Record<string, string> }>(
|
||||
`/api/ai/mentions/${suffix ? `?${suffix}` : ""}`
|
||||
);
|
||||
},
|
||||
listCreations(params: { mode?: "video" | "image"; status?: string } = {}) {
|
||||
const query = new URLSearchParams();
|
||||
if (params.mode) query.set("mode", params.mode);
|
||||
if (params.status) query.set("status", params.status);
|
||||
const suffix = query.toString();
|
||||
return request<Paginated<CreationConversation>>(`/api/ai/creations/${suffix ? `?${suffix}` : ""}`);
|
||||
},
|
||||
createCreation(payload: {
|
||||
title?: string;
|
||||
mode: "video" | "image";
|
||||
preset?: string;
|
||||
params?: Record<string, string>;
|
||||
}) {
|
||||
return request<CreationConversation>("/api/ai/creations/", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
},
|
||||
getCreation(id: string) {
|
||||
return request<CreationConversationDetail>(`/api/ai/creations/${id}/`);
|
||||
},
|
||||
renameCreation(id: string, title: string) {
|
||||
return request<CreationConversation>(`/api/ai/creations/${id}/`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ title })
|
||||
});
|
||||
},
|
||||
updateCreation(id: string, payload: { title?: string; params?: Record<string, string> }) {
|
||||
return request<CreationConversation>(`/api/ai/creations/${id}/`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
},
|
||||
deleteCreation(id: string) {
|
||||
return request<void>(`/api/ai/creations/${id}/`, { method: "DELETE" });
|
||||
},
|
||||
/** 增量拉消息:轮询生成结果时只补 after_seq 之后的,不重拉整条会话。 */
|
||||
creationMessages(id: string, afterSeq?: number) {
|
||||
const suffix = afterSeq === undefined ? "" : `?after_seq=${afterSeq}`;
|
||||
return request<CreationMessage[]>(`/api/ai/creations/${id}/messages/${suffix}`);
|
||||
},
|
||||
/**
|
||||
* 点确认闸门 → 直接出片。**这个不是 SSE**:后端不跑模型,按方案卡存好的
|
||||
* video_prompt 直接提交,同步返回「生成中」消息,之后靠轮询转成结果。
|
||||
*/
|
||||
confirmCreationPlan(id: string, replyTo: string, params?: Record<string, string>) {
|
||||
return request<{ message: CreationMessage }>(`/api/ai/creations/${id}/send/`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ kind: "confirm", reply_to: replyTo, params })
|
||||
});
|
||||
},
|
||||
/**
|
||||
* 发一条消息 → SSE 流。事件:tool / reasoning / delta / message / task / credits / done / error。
|
||||
* 和 agentScriptStream 同一套 fetch + ReadableStream(EventSource 只支持 GET,这里要 POST 带 body)。
|
||||
*/
|
||||
async creationSendStream(
|
||||
id: string,
|
||||
payload: {
|
||||
kind?: "text" | "elicit_answer";
|
||||
text?: string;
|
||||
refs?: CreationRef[];
|
||||
reply_to?: string;
|
||||
answers?: Record<string, string | string[]>;
|
||||
model_config_id?: string;
|
||||
params?: Record<string, string>;
|
||||
},
|
||||
onEvent: (evt: { type: string; [k: string]: unknown }) => void,
|
||||
signal?: AbortSignal
|
||||
): Promise<void> {
|
||||
const token = getToken();
|
||||
const headers = new Headers({ "Content-Type": "application/json", Accept: "text/event-stream" });
|
||||
if (token) headers.set("Authorization", `Token ${token}`);
|
||||
const response = await fetch(`${API_BASE}/api/ai/creations/${id}/send/`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify(payload),
|
||||
signal
|
||||
});
|
||||
if (!response.ok || !response.body) {
|
||||
const text = await response.text().catch(() => "");
|
||||
let message = text || "发送失败";
|
||||
try {
|
||||
const data = JSON.parse(text) as Record<string, unknown>;
|
||||
if (typeof data.detail === "string") message = data.detail;
|
||||
} catch {
|
||||
/* 非 JSON 错误体,用原文 */
|
||||
}
|
||||
throw new ApiError(response.status, message);
|
||||
}
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder("utf-8");
|
||||
let buffer = "";
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
let sep: number;
|
||||
while ((sep = buffer.indexOf("\n\n")) !== -1) {
|
||||
const frame = buffer.slice(0, sep);
|
||||
buffer = buffer.slice(sep + 2);
|
||||
const dataLine = frame.split("\n").find((l) => l.startsWith("data:"));
|
||||
if (!dataLine) continue;
|
||||
try {
|
||||
onEvent(JSON.parse(dataLine.slice(5).trim()));
|
||||
} catch {
|
||||
/* 跳过解析失败的帧 */
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
adoptScript(projectId: string, script_version_id: string) {
|
||||
return request<ScriptVersion>(`/api/projects/${projectId}/adopt-script/`, {
|
||||
method: "POST",
|
||||
|
||||
@@ -12,9 +12,11 @@ const SIDEBAR_COLLAPSED_KEY = "airshelf:sidebar-collapsed";
|
||||
type Command = { id: string; group: string; label: string; sub: string; page: Page; icon: string; key?: string };
|
||||
const SHELL_COMMANDS: Command[] = [
|
||||
{ id: "dashboard", group: "导航", label: "工作台", sub: "任务队列、今日消耗、项目进度", page: "dashboard", icon: "dashboard", key: "D" },
|
||||
{ id: "omni-create", group: "导航", label: "全能创作", sub: "预设工作流 + 对话式 Agent", page: "omniCreate", icon: "sparkles", key: "O" },
|
||||
{ id: "omni-history", group: "导航", label: "创作历史", sub: "查看与继续独立会话", page: "omniHistory", icon: "history", key: "H" },
|
||||
{ id: "products", group: "导航", label: "商品库", sub: "管理 SKU、商品图册、卖点信息", page: "products", icon: "package", key: "P" },
|
||||
{ id: "projects", group: "导航", label: "视频创作", sub: "从商品或参考视频出发,选择生产方式", page: "projects", icon: "clapperboard", key: "V" },
|
||||
{ id: "asset-factory", group: "导航", label: "图片生成", sub: "模特上身图、平台套图、图片创作", page: "assetFactory", icon: "sparkles", key: "I" },
|
||||
{ id: "asset-factory", group: "导航", label: "图片生成", sub: "模特上身图、平台套图、自由创作", page: "assetFactory", icon: "sparkles", key: "I" },
|
||||
{ id: "free-create", group: "导航", label: "自由创作", sub: "AI 视频生成 · 全能参考 / 首尾帧", page: "freeCreate", icon: "film", key: "F" },
|
||||
{ id: "quick-create", group: "导航", label: "一键成片", sub: "上传商品图片,自动完成整条视频", page: "quickCreate", icon: "wand", key: "Q" },
|
||||
{ id: "video-remix", group: "导航", label: "提炼提示词", sub: "上传参考视频,提炼可编辑提示词", page: "videoRemix", icon: "scan", key: "R" },
|
||||
@@ -29,7 +31,7 @@ const SHELL_COMMANDS: Command[] = [
|
||||
{ id: "quick-create-action", group: "常用动作", label: "一键成片", sub: "输入商品名称并上传图片,自动生成视频", page: "quickCreate", icon: "wand" },
|
||||
{ id: "model-photo", group: "常用动作", label: "生成模特上身图", sub: "快速生成 3:4 商品展示素材", page: "modelPhoto", icon: "users" },
|
||||
{ id: "platform-cover", group: "常用动作", label: "生成平台套图", sub: "适配电商平台封面与详情图", page: "platformCover", icon: "images" },
|
||||
{ id: "image-optimize", group: "常用动作", label: "图片创作", sub: "对话式生成、编辑", page: "imageOptimize", icon: "images" }
|
||||
{ id: "image-optimize", group: "常用动作", label: "自由创作", sub: "对话式生成、编辑", page: "imageOptimize", icon: "images" }
|
||||
];
|
||||
|
||||
function CommandPalette({ open, onClose, navigate, canManageBilling = true }: { open: boolean; onClose: () => void; navigate: Navigate; canManageBilling?: boolean }) {
|
||||
@@ -256,7 +258,7 @@ function LiquidIcon({ name }: { name: "boxes" | "users-round" | "settings" }) {
|
||||
return <Settings size={20} strokeWidth={1.9} />;
|
||||
}
|
||||
|
||||
export type TopModule = "workbench" | "image" | "video";
|
||||
export type TopModule = "workbench" | "omni" | "image" | "video";
|
||||
const OPEN_PALETTE_EVENT = "airshelf:open-palette";
|
||||
|
||||
export function openCommandPalette() {
|
||||
@@ -265,6 +267,7 @@ export function openCommandPalette() {
|
||||
|
||||
export function topModuleForPage(page: Page): TopModule | null {
|
||||
if (page === "dashboard") return "workbench";
|
||||
if (page === "omniCreate" || page === "omniHistory" || page === "omniSession") return "omni";
|
||||
if (
|
||||
page === "assetFactory"
|
||||
|| page === "imageOptimize"
|
||||
@@ -279,6 +282,7 @@ export function topModuleForPage(page: Page): TopModule | null {
|
||||
|
||||
const MODE_TABS: { id: TopModule; label: string; page: Page }[] = [
|
||||
{ id: "workbench", label: "工作台", page: "dashboard" },
|
||||
{ id: "omni", label: "全能创作", page: "omniCreate" },
|
||||
{ id: "image", label: "图片创作", page: "assetFactory" },
|
||||
{ id: "video", label: "视频创作", page: "projects" },
|
||||
];
|
||||
@@ -364,6 +368,7 @@ export function ModeTabs({ active, navigate }: { active: TopModule | null; navig
|
||||
<rect rx="22" ry="22" x="0" y="0" width="104" height="44" />
|
||||
<rect rx="22" ry="22" x="120" y="0" width="110" height="44" />
|
||||
<rect rx="22" ry="22" x="246" y="0" width="110" height="44" />
|
||||
<rect rx="22" ry="22" x="372" y="0" width="110" height="44" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
@@ -411,7 +416,9 @@ const PAGE_TO_NAV: Partial<Record<Page, Page>> = {
|
||||
account: "account",
|
||||
trash: "trash",
|
||||
settings: "settings",
|
||||
settingsNotify: "settings"
|
||||
settingsNotify: "settings",
|
||||
omniCreate: "omniCreate",
|
||||
omniHistory: "omniHistory"
|
||||
};
|
||||
|
||||
export function Sidebar({ page, navigate, user, team, canManageBilling = true, products, projects, productTotal, projectTotal, aiUnread, onOpenAdmin, onOpenAccount, accountOpen = false }: {
|
||||
|
||||
@@ -90,6 +90,12 @@
|
||||
--klein-hover: #002680;
|
||||
--heat: var(--klein);
|
||||
--heat-hover: var(--klein-hover);
|
||||
|
||||
/* 影擎设计稿(omni-* 页面)沿用的两个前景名。设计稿里是 --black:#101012 / --text:#17181a,
|
||||
与上面的 --accent-black 是同一个近黑 —— 这里做别名而不是新造色值(design.md §8)。
|
||||
缺了它们,omni 页面的 background:var(--black) 会解析失败变透明(白字白底看不见)。 */
|
||||
--black: var(--accent-black);
|
||||
--text: var(--accent-black);
|
||||
--heat-90: rgba(0, 47, 167, .90);
|
||||
--heat-40: rgba(0, 47, 167, .40);
|
||||
--heat-20: rgba(0, 47, 167, .20);
|
||||
@@ -792,40 +798,12 @@ body.sidebar-collapsed .user::after { display: none; }
|
||||
z-index: 0;
|
||||
pointer-events: none;
|
||||
background-image:
|
||||
linear-gradient(rgba(0, 47, 167, 0.16) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(0, 47, 167, 0.16) 1px, transparent 1px);
|
||||
linear-gradient(rgba(24, 31, 42, 0.032) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(24, 31, 42, 0.032) 1px, transparent 1px);
|
||||
background-size: 48px 48px;
|
||||
background-position: -1px -1px;
|
||||
-webkit-mask-image: linear-gradient(
|
||||
90deg,
|
||||
#000 0%,
|
||||
rgba(0, 0, 0, 0.58) 2%,
|
||||
rgba(0, 0, 0, 0.3) 16%,
|
||||
rgba(0, 0, 0, 0.12) 30%,
|
||||
rgba(0, 0, 0, 0.03) 40%,
|
||||
transparent 46%,
|
||||
transparent 54%,
|
||||
rgba(0, 0, 0, 0.03) 60%,
|
||||
rgba(0, 0, 0, 0.12) 70%,
|
||||
rgba(0, 0, 0, 0.3) 84%,
|
||||
rgba(0, 0, 0, 0.58) 98%,
|
||||
#000 100%
|
||||
);
|
||||
mask-image: linear-gradient(
|
||||
90deg,
|
||||
#000 0%,
|
||||
rgba(0, 0, 0, 0.58) 2%,
|
||||
rgba(0, 0, 0, 0.3) 16%,
|
||||
rgba(0, 0, 0, 0.12) 30%,
|
||||
rgba(0, 0, 0, 0.03) 40%,
|
||||
transparent 46%,
|
||||
transparent 54%,
|
||||
rgba(0, 0, 0, 0.03) 60%,
|
||||
rgba(0, 0, 0, 0.12) 70%,
|
||||
rgba(0, 0, 0, 0.3) 84%,
|
||||
rgba(0, 0, 0, 0.58) 98%,
|
||||
#000 100%
|
||||
);
|
||||
-webkit-mask-image: linear-gradient(90deg, #000 0%, rgba(0, 0, 0, 0.78) 17%, transparent 40%, transparent 60%, rgba(0, 0, 0, 0.78) 83%, #000 100%);
|
||||
mask-image: linear-gradient(90deg, #000 0%, rgba(0, 0, 0, 0.78) 17%, transparent 40%, transparent 60%, rgba(0, 0, 0, 0.78) 83%, #000 100%);
|
||||
}
|
||||
.scatter {
|
||||
position: absolute;
|
||||
@@ -2298,6 +2276,7 @@ table.t tbody tr:hover { background: var(--black-alpha-4); }
|
||||
/* ─── Toast ─── */
|
||||
.toast {
|
||||
position: fixed; bottom: 24px; right: 24px;
|
||||
max-width: min(360px, calc(100vw - 32px));
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border-faint);
|
||||
border-radius: var(--r-md);
|
||||
@@ -2319,7 +2298,7 @@ table.t tbody tr:hover { background: var(--black-alpha-4); }
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.toast .ic-t svg { width: 13px; height: 13px; }
|
||||
.toast .txt { font-size: 13px; color: var(--accent-black); font-weight: 500; }
|
||||
.toast .txt { font-size: 13px; color: var(--accent-black); font-weight: 500; overflow-wrap: anywhere; }
|
||||
.toast .txt .mono {
|
||||
font-family: var(--font-mono); font-size: 12px;
|
||||
color: var(--black-alpha-48); display: block; margin-top: 2px;
|
||||
|
||||
@@ -20,5 +20,7 @@ import "./product-create-page.css";
|
||||
import "./project-wizard-page.css";
|
||||
import "./quick-create-page.css";
|
||||
import "./admin-page.css";
|
||||
import "./omni-create-page.css";
|
||||
import "./omni-session-page.css";
|
||||
|
||||
createRoot(document.getElementById("root")!).render(<App />);
|
||||
|
||||
@@ -2,8 +2,9 @@ import type { ModelConfig } from "./types";
|
||||
|
||||
/** 普通用户界面的临时品牌映射。后续接入模型自动反馈/配置化前,只在此处维护。 */
|
||||
const PUBLIC_MODEL_NAME: Record<string, string> = {
|
||||
"gpt-image": "AirShelf Image",
|
||||
"gpt-image-2": "AirShelf Image",
|
||||
volcano: "Seedream-5.0-pro",
|
||||
"gpt-image": "影擎-Image2",
|
||||
"gpt-image-2": "影擎-Image2",
|
||||
"gemini-3.1-pro-preview": "AirShelf Script"
|
||||
};
|
||||
|
||||
@@ -12,7 +13,7 @@ export function publicModelRouteName(routeKey: string, fallback = routeKey) {
|
||||
}
|
||||
|
||||
export const imageModelPickerOptions = [
|
||||
{ id: "volcano", label: publicModelRouteName("volcano", "火山 Seedream") },
|
||||
{ id: "volcano", label: publicModelRouteName("volcano", "Seedream-5.0-pro") },
|
||||
{ id: "gpt-image", label: publicModelRouteName("gpt-image") }
|
||||
];
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -42,12 +42,12 @@ import { productMockCoverUrl } from "./products";
|
||||
import { isLocalLife } from "../product-business";
|
||||
import "../ai-tools-page.css";
|
||||
|
||||
// 工作台生成模式 → 中文标签(优先用它给任务卡命名:能区分模特上身图/平台套图/图片创作,
|
||||
// 工作台生成模式 → 中文标签(优先用它给任务卡命名:能区分模特上身图/平台套图/自由创作,
|
||||
// 而 task_type 区分不了——cover 与 image 都是 product_image)
|
||||
const MODE_LABEL: Record<string, string> = {
|
||||
model: "模特上身图",
|
||||
cover: "平台套图",
|
||||
image: "图片创作"
|
||||
image: "自由创作"
|
||||
};
|
||||
const MODE_TAG: Record<string, string> = {
|
||||
model: "模特上身",
|
||||
@@ -142,19 +142,19 @@ export function AssetFactoryPage({ navigate }: { navigate: (page: Page) => void
|
||||
page: "modelPhoto" as Page,
|
||||
title: "模特上身",
|
||||
desc: "上传商品和模特参考图,生成自然统一的服装、饰品上身效果。",
|
||||
image: "/assets/yz/tool-model.jpg",
|
||||
image: "/assets/prototype/photo-1524504388940-b1c1722653e1.jpg",
|
||||
},
|
||||
{
|
||||
page: "platformCover" as Page,
|
||||
title: "平台套图",
|
||||
desc: "基于商品主图一次生成主图、卖点图、细节图与场景图。",
|
||||
image: "/assets/yz/tool-cover.jpg",
|
||||
image: "/assets/prototype/unbranded-running-shoe-remix.png",
|
||||
},
|
||||
{
|
||||
page: "imageOptimize" as Page,
|
||||
title: "图片创作",
|
||||
title: "自由创作",
|
||||
desc: "使用提示词、参考图和画布比例自由生成或修改视觉素材。",
|
||||
image: "/assets/yz/tool-studio.jpg",
|
||||
image: "/assets/prototype/photo-1557682250-33bd709cbe85.jpg",
|
||||
}
|
||||
];
|
||||
|
||||
@@ -441,7 +441,7 @@ const MODE_META: Record<
|
||||
}
|
||||
> = {
|
||||
image: {
|
||||
title: "图片创作",
|
||||
title: "自由创作",
|
||||
tag: "[ IMAGE · STUDIO ]",
|
||||
desc: "使用提示词与参考图,自由生成或修改电商视觉素材",
|
||||
ratio: "1:1",
|
||||
|
||||
@@ -15,3 +15,5 @@ export { QuickCreatePage } from "./quick-create";
|
||||
export { VideoRemixPage } from "./video-remix";
|
||||
export { VideoReplacePage } from "./video-replace";
|
||||
export { SettingsPage } from "./settings";
|
||||
export { OmniCreatePage, OmniHistoryPage } from "./omni-create";
|
||||
export { OmniSessionPage } from "./omni-session";
|
||||
|
||||
@@ -0,0 +1,766 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
ArrowLeft,
|
||||
Box,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
FolderOpen,
|
||||
History,
|
||||
Image as ImageIcon,
|
||||
Play,
|
||||
Plus,
|
||||
SlidersHorizontal,
|
||||
Sparkles,
|
||||
Trash2,
|
||||
Upload,
|
||||
UserRound,
|
||||
Users,
|
||||
Video,
|
||||
WandSparkles,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { api } from "../api";
|
||||
import { CustomSelect } from "../components/custom-select";
|
||||
import { ConfirmModal } from "../components/overlays";
|
||||
import type { CreationConversation, CreationRef } from "../types";
|
||||
import type { NavigateFn } from "./route-config";
|
||||
|
||||
type OutputMode = "video" | "image";
|
||||
|
||||
type PresetItem = {
|
||||
name: string;
|
||||
category: string;
|
||||
mode: OutputMode;
|
||||
title: string;
|
||||
desc: string;
|
||||
starter: string;
|
||||
cover: string;
|
||||
};
|
||||
|
||||
type Attachment = { name: string; type: string; url?: string; source: "local" | "library" };
|
||||
|
||||
const VIDEO_PRESETS: PresetItem[] = [
|
||||
{ name: "剧情反转带货", category: "story", mode: "video", title: "剧情反转带货", desc: "用人物冲突与意外反转建立记忆点,商品承担剧情中的关键作用。", starter: "创作一条有前后反转的剧情带货视频,让商品自然成为解决问题的关键。", cover: "/assets/prototype/photo-1529139574466-a303027c1d8b.jpg" },
|
||||
{ name: "商品拟人广告", category: "commerce", mode: "video", title: "商品拟人广告", desc: "让商品成为故事主角,通过个性、动作和情绪完成趣味表达。", starter: "把商品塑造成有性格的拟人角色,创作一条轻快、有趣的短广告。", cover: "/assets/prototype/photo-1608248543803-ba4f8c70ae0b.jpg" },
|
||||
{ name: "达人口播种草", category: "speaker", mode: "video", title: "达人口播种草", desc: "用真实体验与生活化表达建立信任,适合电商和本地生活内容。", starter: "创作一条真实自然的达人口播种草视频,重点讲清使用场景和核心卖点。", cover: "/assets/prototype/photo-1494790108377-be9c29b29330.jpg" },
|
||||
{ name: "商品图一键成片", category: "commerce", mode: "video", title: "商品图一键成片", desc: "从商品参考图建立镜头语言,自动补齐场景、动作与转场。", starter: "根据我上传的商品图片生成一条节奏清晰的电商短视频。", cover: "/assets/prototype/photo-1556228578-8c89e6adf883.jpg" },
|
||||
{ name: "鱼眼换装", category: "visual", mode: "video", title: "鱼眼换装", desc: "强调近距离透视与连续变装节奏,适合服饰和人物视觉内容。", starter: "创作一条鱼眼镜头风格的连续换装视频,人物和服装需要保持稳定。", cover: "/assets/prototype/photo-1483985988355-763728e1935b.jpg" },
|
||||
{ name: "点触换款", category: "visual", mode: "video", title: "点触换款", desc: "用统一构图和动作触发商品变化,快速展示多个款式或 SKU。", starter: "创作一条通过点击动作连续切换不同商品款式的短视频。", cover: "/assets/prototype/unbranded-running-shoe-remix.png" },
|
||||
{ name: "探店漫游", category: "story", mode: "video", title: "探店漫游", desc: "以空间动线串联门店环境、服务细节与主推项目。", starter: "根据门店素材创作一条有路线感和空间氛围的探店漫游视频。", cover: "/assets/prototype/photo-1497366754035-f200968a6e72.jpg" },
|
||||
{ name: "品牌质感大片", category: "visual", mode: "video", title: "品牌质感大片", desc: "通过统一的光影、材质与镜头节奏建立更强的品牌识别。", starter: "创作一条强调光影、材质和品牌气质的高级感商品短片。", cover: "/assets/prototype/video-free-cinematic.png" },
|
||||
];
|
||||
|
||||
const IMAGE_PRESETS: PresetItem[] = [
|
||||
{ name: "商品场景套图", category: "product", mode: "image", title: "商品场景套图", desc: "一次生成多张统一视觉的电商图片,覆盖主图、场景和细节表达。", starter: "根据商品参考图生成一组统一风格的主图、场景图和细节图。", cover: "/assets/prototype/photo-1556229010-6c3f2c9ca5f8.jpg" },
|
||||
{ name: "极简棚拍", category: "product", mode: "image", title: "极简棚拍", desc: "干净背景、柔和投影与明确主体,适合商品主图和详情页头图。", starter: "生成一组留白克制、光线干净的极简棚拍商品图。", cover: "/assets/prototype/photo-1523275335684-37898b6baf30.jpg" },
|
||||
{ name: "清透自然光人像", category: "portrait", mode: "image", title: "清透自然光人像", desc: "保留真实肤质与自然光影,适合人物种草和生活方式内容。", starter: "生成一组清透自然光、肤色真实的人物氛围图。", cover: "/assets/prototype/photo-1524504388940-b1c1722653e1.jpg" },
|
||||
{ name: "生活方式场景", category: "scene", mode: "image", title: "生活方式场景", desc: "将商品融入真实空间和使用动作,强调自然、可信的生活气息。", starter: "把商品放入真实、舒适的生活方式场景中,生成自然使用感的图片。", cover: "/assets/prototype/photo-1600210492486-724fe5c67fb0.jpg" },
|
||||
{ name: "高级奢华质感", category: "style", mode: "image", title: "高级奢华质感", desc: "通过深色环境、局部高光和材质细节强化品牌高级感。", starter: "生成一组深色光影、精致材质与高级氛围的品牌视觉图片。", cover: "/assets/prototype/video-quick-cinematic-v2.png" },
|
||||
{ name: "复古胶片风格", category: "style", mode: "image", title: "复古胶片风格", desc: "使用低饱和色彩、胶片颗粒和柔和对比形成怀旧情绪。", starter: "生成一组低饱和、细腻颗粒与复古色调的胶片感图片。", cover: "/assets/prototype/photo-1485846234645-a62644f84728.jpg" },
|
||||
];
|
||||
|
||||
const VIDEO_FILTERS = [
|
||||
{ key: "all", label: "全部" },
|
||||
{ key: "story", label: "剧情" },
|
||||
{ key: "commerce", label: "电商" },
|
||||
{ key: "speaker", label: "口播" },
|
||||
{ key: "visual", label: "视觉" },
|
||||
];
|
||||
|
||||
const IMAGE_FILTERS = [
|
||||
{ key: "all", label: "全部" },
|
||||
{ key: "product", label: "商品" },
|
||||
{ key: "portrait", label: "人像" },
|
||||
{ key: "scene", label: "场景" },
|
||||
{ key: "style", label: "风格" },
|
||||
];
|
||||
|
||||
const VIDEO_MODELS = ["Seedance 2.5", "Seedance 2.0", "Seedance 2.0 Fast", "Seedance 2.0 Mini"];
|
||||
const IMAGE_MODELS = ["Seedream5.0", "YQ image2"];
|
||||
const VIDEO_DURATIONS = ["4 秒", "5 秒", "6 秒", "7 秒", "8 秒", "9 秒", "10 秒", "11 秒", "12 秒", "13 秒", "14 秒", "15 秒", "30 秒"];
|
||||
const IMAGE_COUNTS = ["1 张", "2 张", "4 张", "8 张"];
|
||||
const RATIOS = ["9:16", "16:9", "1:1", "4:3", "3:4"];
|
||||
|
||||
const MENTION_REF_LIMIT = 5;
|
||||
|
||||
const MENTION_TABS: Array<{ type: CreationRef["type"]; label: string; Icon: typeof ImageIcon }> = [
|
||||
{ type: "asset", label: "素材", Icon: ImageIcon },
|
||||
{ type: "character", label: "角色", Icon: UserRound },
|
||||
{ type: "model", label: "模特", Icon: Users },
|
||||
{ type: "product", label: "商品", Icon: Box },
|
||||
{ type: "scene", label: "场景", Icon: FolderOpen },
|
||||
];
|
||||
|
||||
function formatRelativeTime(iso: string) {
|
||||
const then = new Date(iso).getTime();
|
||||
if (Number.isNaN(then)) return "";
|
||||
const minutes = Math.floor((Date.now() - then) / 60000);
|
||||
if (minutes < 1) return "刚刚";
|
||||
if (minutes < 60) return `${minutes} 分钟前`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return `${hours} 小时前`;
|
||||
const days = Math.floor(hours / 24);
|
||||
return days < 30 ? `${days} 天前` : new Date(then).toLocaleDateString("zh-CN");
|
||||
}
|
||||
|
||||
function toOptions(values: string[]) {
|
||||
return values.map((value) => ({ value, label: value }));
|
||||
}
|
||||
|
||||
export function OmniCreatePage({
|
||||
navigate,
|
||||
onNotify,
|
||||
}: {
|
||||
navigate: NavigateFn;
|
||||
onNotify?: (type: "success" | "error" | "info", text: string) => void;
|
||||
}) {
|
||||
const [outputMode, setOutputMode] = useState<OutputMode>("video");
|
||||
const [selectedCase, setSelectedCase] = useState<PresetItem | null>(null);
|
||||
const [prompt, setPrompt] = useState("");
|
||||
const [attachments, setAttachments] = useState<Attachment[]>([]);
|
||||
const [model, setModel] = useState("Seedance 2.5");
|
||||
const [resolution, setResolution] = useState("1080p");
|
||||
const [ratio, setRatio] = useState("9:16");
|
||||
const [duration, setDuration] = useState("智能时长");
|
||||
const [customDurationOn, setCustomDurationOn] = useState(false);
|
||||
const [durationMenuOpen, setDurationMenuOpen] = useState(false);
|
||||
const [uploadMenuOpen, setUploadMenuOpen] = useState(false);
|
||||
const [mentionMenuOpen, setMentionMenuOpen] = useState(false);
|
||||
const [mentionTab, setMentionTab] = useState<CreationRef["type"]>("asset");
|
||||
const [mentionLoading, setMentionLoading] = useState(false);
|
||||
const [mentionResults, setMentionResults] = useState<CreationRef[]>([]);
|
||||
const [typeLabels, setTypeLabels] = useState<Record<string, string>>({});
|
||||
const [pendingRefs, setPendingRefs] = useState<CreationRef[]>([]);
|
||||
const [previewCase, setPreviewCase] = useState<PresetItem | null>(null);
|
||||
const [activeCategory, setActiveCategory] = useState("all");
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const toolsRef = useRef<HTMLDivElement>(null);
|
||||
const [starting, setStarting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (outputMode === "image") {
|
||||
setModel("Seedream5.0");
|
||||
setResolution("模型默认");
|
||||
setRatio("1:1");
|
||||
setDuration("1 张");
|
||||
setCustomDurationOn(true);
|
||||
} else {
|
||||
setModel("Seedance 2.5");
|
||||
setResolution("1080p");
|
||||
setRatio("9:16");
|
||||
setDuration("智能时长");
|
||||
setCustomDurationOn(false);
|
||||
}
|
||||
setActiveCategory("all");
|
||||
setDurationMenuOpen(false);
|
||||
setSelectedCase((prev) => (prev && prev.mode !== outputMode ? null : prev));
|
||||
}, [outputMode]);
|
||||
|
||||
useEffect(() => {
|
||||
const onDown = (event: MouseEvent) => {
|
||||
const target = event.target as Node;
|
||||
if (toolsRef.current?.contains(target)) return;
|
||||
setUploadMenuOpen(false);
|
||||
setMentionMenuOpen(false);
|
||||
setDurationMenuOpen(false);
|
||||
};
|
||||
document.addEventListener("mousedown", onDown);
|
||||
return () => document.removeEventListener("mousedown", onDown);
|
||||
}, []);
|
||||
|
||||
const filters = outputMode === "video" ? VIDEO_FILTERS : IMAGE_FILTERS;
|
||||
const presets = useMemo(() => {
|
||||
const list = outputMode === "video" ? VIDEO_PRESETS : IMAGE_PRESETS;
|
||||
if (activeCategory === "all") return list;
|
||||
return list.filter((item) => item.category === activeCategory);
|
||||
}, [outputMode, activeCategory]);
|
||||
|
||||
const applyPreset = (preset: PresetItem) => {
|
||||
setSelectedCase(preset);
|
||||
setPrompt(preset.starter);
|
||||
setPreviewCase(null);
|
||||
onNotify?.("info", `已选择预设:${preset.name}`);
|
||||
};
|
||||
|
||||
const openMentions = async (query = "", type: CreationRef["type"] = mentionTab) => {
|
||||
setMentionTab(type);
|
||||
setMentionMenuOpen(true);
|
||||
setUploadMenuOpen(false);
|
||||
setDurationMenuOpen(false);
|
||||
setMentionLoading(true);
|
||||
setMentionResults([]);
|
||||
try {
|
||||
const res = await api.searchMentions({ q: query, types: [type], limit: 12 });
|
||||
setMentionResults(res.results);
|
||||
setTypeLabels(res.type_labels);
|
||||
} catch (error) {
|
||||
onNotify?.("error", (error as Error).message);
|
||||
} finally {
|
||||
setMentionLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const insertMention = (ref: CreationRef) => {
|
||||
if (pendingRefs.some((r) => r.id === ref.id)) {
|
||||
setMentionMenuOpen(false);
|
||||
return;
|
||||
}
|
||||
if (pendingRefs.length >= MENTION_REF_LIMIT) {
|
||||
onNotify?.("info", `最多引用 ${MENTION_REF_LIMIT} 个`);
|
||||
setMentionMenuOpen(false);
|
||||
return;
|
||||
}
|
||||
setPendingRefs((prev) => [...prev, ref]);
|
||||
setPrompt((prev) => prev.replace(/@\S*$/, "").replace(/\s+$/, " "));
|
||||
setMentionMenuOpen(false);
|
||||
};
|
||||
|
||||
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = Array.from(event.target.files || []);
|
||||
if (!files.length) return;
|
||||
setAttachments((prev) => [
|
||||
...prev,
|
||||
...files.map((file) => ({
|
||||
name: file.name,
|
||||
type: file.type,
|
||||
url: URL.createObjectURL(file),
|
||||
source: "local" as const,
|
||||
})),
|
||||
]);
|
||||
setUploadMenuOpen(false);
|
||||
event.target.value = "";
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="page-view omni-create-page omni-home-page">
|
||||
<div className="omni-home-shell">
|
||||
<header className="omni-home-hero">
|
||||
<button type="button" className="omni-home-history" onClick={() => navigate("omniHistory")}>
|
||||
<History />
|
||||
创作历史
|
||||
</button>
|
||||
<span className="omni-home-kicker">
|
||||
<Sparkles /> YINGQING CREATIVE AGENT
|
||||
</span>
|
||||
<h1>和全能创作聊聊你的想法</h1>
|
||||
<p>选择一种创作方法,或直接描述你想生成的画面。</p>
|
||||
<div className="omni-output-switch" aria-label="输出类型">
|
||||
<button type="button" className={outputMode === "video" ? "active" : ""} onClick={() => setOutputMode("video")}>视频创作</button>
|
||||
<button type="button" className={outputMode === "image" ? "active" : ""} onClick={() => setOutputMode("image")}>图片创作</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section className="omni-start-composer" aria-label="全能创作输入区">
|
||||
<div className="omni-selected-case" hidden={!selectedCase}>
|
||||
<span>
|
||||
<WandSparkles />
|
||||
<strong>{selectedCase?.name}</strong>
|
||||
</span>
|
||||
<button type="button" aria-label="取消预设" onClick={() => setSelectedCase(null)}>
|
||||
<X />
|
||||
</button>
|
||||
</div>
|
||||
<div className="omni-start-attachments" aria-live="polite">{pendingRefs.map((ref) => (
|
||||
<span className="omni-attachment-chip" key={ref.id}>
|
||||
{ref.cover ? <img src={ref.cover} alt="" /> : <ImageIcon />}
|
||||
<span>{ref.name.split(" · ")[0]}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="omni-attachment-remove"
|
||||
aria-label={`删除 ${ref.name}`}
|
||||
onClick={() => setPendingRefs((prev) => prev.filter((item) => item.id !== ref.id))}
|
||||
>
|
||||
<X />
|
||||
</button>
|
||||
</span>
|
||||
))}{attachments.map((file, index) => (
|
||||
<span className="omni-attachment-chip" key={`${file.name}-${index}`}>
|
||||
{file.type.startsWith("video") ? <Video /> : <ImageIcon />}
|
||||
<span>{file.name}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="omni-attachment-remove"
|
||||
aria-label={`删除 ${file.name}`}
|
||||
onClick={() => setAttachments((prev) => prev.filter((_, i) => i !== index))}
|
||||
>
|
||||
<X />
|
||||
</button>
|
||||
</span>
|
||||
))}</div>
|
||||
<textarea
|
||||
id="omniStartPrompt"
|
||||
rows={3}
|
||||
placeholder="描述你想制作的内容,@ 可引用商品、模特、场景或已有素材……"
|
||||
value={prompt}
|
||||
onChange={(event) => {
|
||||
const value = event.target.value;
|
||||
setPrompt(value);
|
||||
const caret = event.target.selectionStart ?? 0;
|
||||
if (caret > 0 && value.charAt(caret - 1) === "@") void openMentions();
|
||||
}}
|
||||
/>
|
||||
<div className="omni-start-toolbar">
|
||||
<div className="omni-start-tools" ref={toolsRef}>
|
||||
<div className="omni-upload-wrap">
|
||||
<button
|
||||
type="button"
|
||||
className="omni-icon-tool"
|
||||
aria-label="添加参考素材"
|
||||
onClick={() => {
|
||||
setUploadMenuOpen((open) => !open);
|
||||
setMentionMenuOpen(false);
|
||||
setDurationMenuOpen(false);
|
||||
}}
|
||||
>
|
||||
<Plus />
|
||||
</button>
|
||||
<div className="omni-upload-menu" hidden={!uploadMenuOpen}>
|
||||
<strong>添加参考素材</strong>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setUploadMenuOpen(false);
|
||||
void openMentions();
|
||||
}}
|
||||
>
|
||||
<FolderOpen />
|
||||
<span>从资产库选择<small>使用平台已有商品、人物或场景</small></span>
|
||||
</button>
|
||||
<button type="button" onClick={() => fileInputRef.current?.click()}>
|
||||
<Upload />
|
||||
<span>本地上传<small>添加电脑中的图片或视频</small></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*,video/*"
|
||||
multiple
|
||||
hidden
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
<div className="omni-mention-wrap">
|
||||
<button
|
||||
type="button"
|
||||
className="omni-icon-tool"
|
||||
aria-label="引用素材"
|
||||
onClick={() => {
|
||||
if (mentionMenuOpen) setMentionMenuOpen(false);
|
||||
else void openMentions("", mentionTab);
|
||||
setUploadMenuOpen(false);
|
||||
setDurationMenuOpen(false);
|
||||
}}
|
||||
>
|
||||
@
|
||||
</button>
|
||||
<div className="omni-session-mention-menu" hidden={!mentionMenuOpen} role="dialog" aria-label="引用素材">
|
||||
<div className="omni-at-cats" role="tablist">
|
||||
{MENTION_TABS.map((tab) => (
|
||||
<button
|
||||
type="button"
|
||||
key={tab.type}
|
||||
role="tab"
|
||||
aria-selected={mentionTab === tab.type}
|
||||
className={mentionTab === tab.type ? "is-on" : ""}
|
||||
onClick={() => { if (mentionTab !== tab.type) void openMentions("", tab.type); }}
|
||||
>
|
||||
<tab.Icon size={14} />
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="omni-at-list">
|
||||
{mentionLoading ? (
|
||||
<div className="omni-at-loading" aria-live="polite">
|
||||
<span className="omni-send-spinner" />
|
||||
加载中
|
||||
</div>
|
||||
) : mentionResults.length === 0 ? (
|
||||
<strong>这类还没有可引用的内容</strong>
|
||||
) : (
|
||||
mentionResults.map((ref) => (
|
||||
<button type="button" key={ref.id} onClick={() => insertMention(ref)}>
|
||||
{ref.cover ? <img src={ref.cover} alt="" /> : <Play />}
|
||||
<span>
|
||||
{ref.name.split(" · ")[0]}
|
||||
<small>{typeLabels[ref.type] || MENTION_TABS.find((tab) => tab.type === ref.type)?.label}</small>
|
||||
</span>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="omni-parameter omni-parameter-model">
|
||||
<CustomSelect
|
||||
fill
|
||||
size="sm"
|
||||
aria-label={outputMode === "video" ? "视频模型" : "图片模型"}
|
||||
value={model}
|
||||
onChange={setModel}
|
||||
options={toOptions(outputMode === "video" ? VIDEO_MODELS : IMAGE_MODELS)}
|
||||
/>
|
||||
</label>
|
||||
<label className={`omni-parameter${outputMode === "image" ? " is-hidden" : ""}`}>
|
||||
<CustomSelect
|
||||
fill
|
||||
size="sm"
|
||||
aria-label="分辨率"
|
||||
value={resolution}
|
||||
onChange={setResolution}
|
||||
options={toOptions(["1080p", "720p", "480p"])}
|
||||
/>
|
||||
</label>
|
||||
<label className="omni-parameter">
|
||||
<CustomSelect
|
||||
fill
|
||||
size="sm"
|
||||
aria-label="画面比例"
|
||||
value={ratio}
|
||||
onChange={setRatio}
|
||||
options={toOptions(RATIOS)}
|
||||
/>
|
||||
</label>
|
||||
<div className={`omni-duration-control${outputMode === "image" ? " is-image-count" : ""}`}>
|
||||
<button
|
||||
type="button"
|
||||
className="omni-duration-trigger"
|
||||
aria-expanded={durationMenuOpen}
|
||||
onClick={() => {
|
||||
setDurationMenuOpen((open) => !open);
|
||||
setUploadMenuOpen(false);
|
||||
setMentionMenuOpen(false);
|
||||
}}
|
||||
>
|
||||
<span>{duration}</span>
|
||||
<ChevronDown />
|
||||
</button>
|
||||
<div className="omni-duration-menu" hidden={!durationMenuOpen}>
|
||||
<span className="omni-duration-title">{outputMode === "image" ? "生成张数" : "时长"}</span>
|
||||
<div className="omni-duration-modes">
|
||||
<button
|
||||
type="button"
|
||||
className={!customDurationOn ? "active" : ""}
|
||||
onClick={() => {
|
||||
setCustomDurationOn(false);
|
||||
setDuration("智能时长");
|
||||
setDurationMenuOpen(false);
|
||||
}}
|
||||
>
|
||||
<Sparkles />
|
||||
<span>智能时长</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={customDurationOn ? "active" : ""}
|
||||
onClick={() => setCustomDurationOn(true)}
|
||||
>
|
||||
<SlidersHorizontal />
|
||||
<span>自定义时长</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className="omni-duration-values" hidden={outputMode === "video" && !customDurationOn}>
|
||||
{(outputMode === "image" ? IMAGE_COUNTS : VIDEO_DURATIONS).map((value) => (
|
||||
<button
|
||||
type="button"
|
||||
key={value}
|
||||
className={duration === value ? "active" : ""}
|
||||
onClick={() => {
|
||||
setDuration(value);
|
||||
setCustomDurationOn(true);
|
||||
setDurationMenuOpen(false);
|
||||
}}
|
||||
>
|
||||
{value}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="omni-start-generate"
|
||||
disabled={starting}
|
||||
onClick={() => {
|
||||
const text = prompt.trim();
|
||||
if (!text && !selectedCase && attachments.length === 0) {
|
||||
onNotify?.("info", "请先输入你想制作的内容,或选择一个创作预设");
|
||||
return;
|
||||
}
|
||||
if (starting) return;
|
||||
setStarting(true);
|
||||
// 会话 mode 与顶栏参数在这里定死,进对话页后不再改(契约 §0)
|
||||
void api
|
||||
.createCreation({
|
||||
title: (text || selectedCase?.title || "未命名创作").slice(0, 20),
|
||||
mode: outputMode,
|
||||
preset: selectedCase?.name || "",
|
||||
params: {
|
||||
model,
|
||||
ratio,
|
||||
...(outputMode === "video"
|
||||
? { resolution, duration }
|
||||
: { count: duration }),
|
||||
},
|
||||
})
|
||||
.then((conversation) => {
|
||||
// 首条消息交给对话页发,避免这里再复制一份 SSE 消费逻辑
|
||||
navigate("omniSession", { conversationId: conversation.id, firstMessage: text, firstRefs: pendingRefs });
|
||||
})
|
||||
.catch((error) => {
|
||||
onNotify?.("error", (error as Error).message);
|
||||
setStarting(false);
|
||||
});
|
||||
}}
|
||||
>
|
||||
{starting ? "正在创建…" : "开始创作"}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="omni-case-library" aria-labelledby="omniCaseTitle">
|
||||
<div className="omni-case-head">
|
||||
<strong className="omni-case-head-label" id="omniCaseTitle">预设分类</strong>
|
||||
<div className="omni-case-filters" role="tablist" aria-label="预设筛选">
|
||||
{filters.map((filter) => (
|
||||
<button
|
||||
type="button"
|
||||
key={filter.key}
|
||||
className={activeCategory === filter.key ? "active" : ""}
|
||||
onClick={() => setActiveCategory(filter.key)}
|
||||
>
|
||||
{filter.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="omni-case-grid">
|
||||
{presets.map((card) => (
|
||||
<button
|
||||
type="button"
|
||||
className={`omni-case-card${selectedCase?.name === card.name ? " active" : ""}`}
|
||||
data-mode={card.mode}
|
||||
key={card.name}
|
||||
onClick={() => setPreviewCase(card)}
|
||||
>
|
||||
<span className="omni-case-visual">
|
||||
<img src={card.cover} alt="" />
|
||||
<span className="omni-case-play">
|
||||
{card.mode === "video" ? <Play className="lucide-play" /> : <ImageIcon />}
|
||||
</span>
|
||||
</span>
|
||||
<span className="omni-case-copy">
|
||||
<strong>{card.title}</strong>
|
||||
<small>{card.desc}</small>
|
||||
</span>
|
||||
<span
|
||||
className="omni-card-use"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
applyPreset(card);
|
||||
}}
|
||||
>
|
||||
使用
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="omni-preset-modal" hidden={!previewCase}>
|
||||
<button type="button" className="omni-preset-scrim" aria-label="关闭预设详情" onClick={() => setPreviewCase(null)} />
|
||||
{previewCase && (
|
||||
<section className="omni-preset-dialog" role="dialog" aria-modal="true" aria-labelledby="omniPresetDialogTitle">
|
||||
<button type="button" className="omni-preset-close" aria-label="关闭" onClick={() => setPreviewCase(null)}>
|
||||
<X />
|
||||
</button>
|
||||
<div className="omni-preset-media-frame">
|
||||
<img src={previewCase.cover} alt="预设示例" />
|
||||
{previewCase.mode === "video" && (
|
||||
<span className="omni-preset-video-mark">
|
||||
<Play />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="omni-preset-detail">
|
||||
<span className="omni-preset-kind">{previewCase.mode === "video" ? "视频预设" : "图片预设"}</span>
|
||||
<h2 id="omniPresetDialogTitle">{previewCase.title}</h2>
|
||||
<p>{previewCase.desc}</p>
|
||||
<div className="omni-preset-default">
|
||||
<span>默认创作方向</span>
|
||||
<p>{previewCase.starter}</p>
|
||||
</div>
|
||||
<div className="omni-preset-actions">
|
||||
<button type="button" onClick={() => setPreviewCase(null)}>取消</button>
|
||||
<button type="button" className="primary" onClick={() => applyPreset(previewCase)}>使用此预设</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function OmniHistoryPage({
|
||||
navigate,
|
||||
onNotify,
|
||||
}: {
|
||||
navigate: NavigateFn;
|
||||
onNotify?: (type: "success" | "error" | "info", text: string) => void;
|
||||
}) {
|
||||
const [filter, setFilter] = useState<"all" | "running" | "completed">("all");
|
||||
const [items, setItems] = useState<CreationConversation[] | null>(null);
|
||||
const [deleteTarget, setDeleteTarget] = useState<CreationConversation | null>(null);
|
||||
// 同会话页:onNotify 是内联箭头,进依赖会让列表每次 App 重渲染都重拉一遍
|
||||
const notifyRef = useRef(onNotify);
|
||||
notifyRef.current = onNotify;
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setItems(null);
|
||||
api
|
||||
.listCreations(filter === "all" ? {} : { status: filter })
|
||||
.then((page) => {
|
||||
if (!cancelled) setItems(page.results || []);
|
||||
})
|
||||
.catch((error) => {
|
||||
if (cancelled) return;
|
||||
setItems([]);
|
||||
notifyRef.current?.("error", (error as Error).message);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [filter]);
|
||||
|
||||
const remove = async (id: string) => {
|
||||
// 软删:会话没了,已生成的图和视频仍留在资产库里
|
||||
setItems((prev) => (prev || []).filter((item) => item.id !== id));
|
||||
try {
|
||||
await api.deleteCreation(id);
|
||||
} catch (error) {
|
||||
notifyRef.current?.("error", (error as Error).message);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="page-view omni-history-page">
|
||||
<header className="omni-history-header">
|
||||
<div className="omni-history-heading">
|
||||
<span>ALL-IN-ONE CREATION</span>
|
||||
<div className="omni-history-title">
|
||||
<button type="button" className="omni-history-back" aria-label="返回" onClick={() => navigate("omniCreate")}>
|
||||
<ArrowLeft />
|
||||
</button>
|
||||
<h1>创作历史</h1>
|
||||
</div>
|
||||
<p>查看和继续全能创作中的每一次独立会话。</p>
|
||||
</div>
|
||||
<div className="omni-history-tools">
|
||||
<div className="omni-history-toolbar">
|
||||
{([
|
||||
["all", "全部"],
|
||||
["running", "进行中"],
|
||||
["completed", "已完成"],
|
||||
] as const).map(([key, label]) => (
|
||||
<button
|
||||
type="button"
|
||||
key={key}
|
||||
className={filter === key ? "active" : ""}
|
||||
onClick={() => setFilter(key)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<button type="button" className="omni-history-new" onClick={() => navigate("omniCreate")}>
|
||||
<Plus />
|
||||
新建会话
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<div className="omni-history-list">
|
||||
{items === null ? (
|
||||
<div className="omni-history-empty">
|
||||
<p>正在加载创作记录……</p>
|
||||
</div>
|
||||
) : items.length === 0 ? (
|
||||
<div className="omni-history-empty">
|
||||
<p>还没有创作记录,从全能创作开始。</p>
|
||||
<button type="button" onClick={() => navigate("omniCreate")}>
|
||||
去全能创作
|
||||
<ChevronRight size={14} />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
items.map((item) => (
|
||||
<article
|
||||
className="omni-history-item"
|
||||
key={item.id}
|
||||
data-status={item.status}
|
||||
onClick={() => navigate("omniSession", { conversationId: item.id })}
|
||||
>
|
||||
{item.cover_url ? (
|
||||
<img src={item.cover_url} alt={item.title} />
|
||||
) : (
|
||||
<span className="omni-history-placeholder">
|
||||
{item.mode === "video" ? <Video /> : <ImageIcon />}
|
||||
</span>
|
||||
)}
|
||||
<div>
|
||||
<span
|
||||
className={`omni-history-status${item.status === "completed" ? " completed" : ""}`}
|
||||
>
|
||||
{item.status === "completed" ? "已完成" : "进行中"}
|
||||
</span>
|
||||
<h2>{item.title}</h2>
|
||||
<p>
|
||||
{[
|
||||
item.preset || "自由创作",
|
||||
item.mode === "video" ? "视频" : "图片",
|
||||
item.params?.model,
|
||||
item.params?.ratio,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ")}
|
||||
</p>
|
||||
<small>{formatRelativeTime(item.last_active_at)}</small>
|
||||
</div>
|
||||
<div className="omni-history-actions">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="删除项目"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
setDeleteTarget(item);
|
||||
}}
|
||||
>
|
||||
<Trash2 />
|
||||
</button>
|
||||
<ChevronRight />
|
||||
</div>
|
||||
</article>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<ConfirmModal
|
||||
open={Boolean(deleteTarget)}
|
||||
title="删除会话"
|
||||
icon={<Trash2 size={16} />}
|
||||
detail={`确定删除「${deleteTarget?.title || "未命名创作"}」?会话会从创作历史中移除,已生成的图和视频仍留在资产库里。`}
|
||||
confirmText="删除"
|
||||
onCancel={() => setDeleteTarget(null)}
|
||||
onConfirm={() => {
|
||||
const id = deleteTarget?.id;
|
||||
setDeleteTarget(null);
|
||||
if (id) void remove(id);
|
||||
}}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -444,13 +444,13 @@ const PROJ_TABS: Array<{ filter: "all" | "draft" | "wip" | "done" | "fail"; labe
|
||||
type ProjTab = (typeof PROJ_TABS)[number]["filter"];
|
||||
|
||||
const VIDEO_MAKE: Array<{ title: string; desc: string; page: Page; image: string; primary?: boolean }> = [
|
||||
{ title: "一键成片", desc: "输入商品名称并上传图片,自动完成脚本、场景与视频生成。", page: "quickCreate", image: "/assets/yz/video-quick.jpg", primary: true },
|
||||
{ title: "专业创作", desc: "逐步控制脚本结构、商品与人物资产和最终视频。", page: "projectWizard", image: "/assets/yz/video-pro.jpg", primary: true },
|
||||
{ title: "一键成片", desc: "输入商品名称并上传图片,自动完成脚本、场景与视频生成。", page: "quickCreate", image: "/assets/prototype/video-oneclick-film-v3.png", primary: true },
|
||||
{ title: "专业创作", desc: "逐步控制脚本结构、商品与人物资产和最终视频。", page: "projectWizard", image: "/assets/prototype/photo-1485846234645-a62644f84728.jpg", primary: true },
|
||||
];
|
||||
const VIDEO_TOOLS: Array<{ title: string; desc: string; page: Page; image: string }> = [
|
||||
{ title: "自由生成", desc: "使用提示词、参考图片与素材库,自由控制画面和生成参数。", page: "freeCreate", image: "/assets/yz/video-free.jpg" },
|
||||
{ title: "提炼提示词", desc: "从参考视频中提炼可编辑的视频生成提示词。", page: "videoRemix", image: "/assets/yz/video-remix.jpg" },
|
||||
{ title: "视频复刻", desc: "保留参考视频的人物、场景与节奏,复刻出自己的带货内容。", page: "videoReplace", image: "/assets/yz/video-replace.jpg" },
|
||||
const VIDEO_TOOLS: Array<{ title: string; desc: string; page: Page; image: string; style?: React.CSSProperties }> = [
|
||||
{ title: "自由生成", desc: "使用提示词、参考图片与素材库,自由控制画面和生成参数。", page: "freeCreate", image: "/assets/prototype/video-free-film-v3.png" },
|
||||
{ title: "提炼提示词", desc: "从参考视频中提炼可编辑的视频生成提示词。", page: "videoRemix", image: "/assets/prototype/video-prompt-extract-film-v3.png" },
|
||||
{ title: "视频复刻", desc: "保留参考视频的人物、场景与节奏,复刻出自己的带货内容。", page: "videoReplace", image: "/assets/prototype/video-remix-film-v3.png", style: { objectPosition: "center 58%" } },
|
||||
];
|
||||
|
||||
function isQuickCreateProject(project: Project) {
|
||||
@@ -622,7 +622,7 @@ export function ProjectsPage({ products, projects, loading = false, navigate, op
|
||||
<div className="vc-tools three">
|
||||
{VIDEO_TOOLS.map((card) => (
|
||||
<button className="vc-tool" type="button" key={card.title} onClick={() => navigate(card.page)}>
|
||||
<div className="vc-tool-cover"><img src={card.image} alt="" /></div>
|
||||
<div className="vc-tool-cover"><img src={card.image} alt="" style={card.style} /></div>
|
||||
<div className="vc-tool-body">
|
||||
<div className="vc-tool-title">
|
||||
<h3>{card.title}</h3>
|
||||
|
||||
@@ -29,6 +29,9 @@ export type Page =
|
||||
| "assetFactory"
|
||||
| "freeCreate"
|
||||
| "quickCreate"
|
||||
| "omniCreate"
|
||||
| "omniHistory"
|
||||
| "omniSession"
|
||||
| "videoRemix"
|
||||
| "videoReplace"
|
||||
| "imageOptimize"
|
||||
@@ -46,6 +49,12 @@ export type ResolvedRoute = {
|
||||
authMode: AuthMode;
|
||||
productId?: string;
|
||||
projectId?: string;
|
||||
// 全能创作对话页的会话 id(/omni-session/<id>)
|
||||
conversationId?: string;
|
||||
// 从首页「开始创作」带过来的首条消息。**只经 route state 透传,不进 URL** ——
|
||||
// 刷新后它已经在库里了,再发一遍会重复。
|
||||
firstMessage?: string;
|
||||
firstRefs?: import("../types").CreationRef[];
|
||||
hash?: string;
|
||||
// 视频项目页初始 tab(all/wip/done/fail):由 navigate 透传,刷新/前进后退不持久(回默认 all)。
|
||||
tab?: string;
|
||||
@@ -55,6 +64,9 @@ export type ResolvedRoute = {
|
||||
export type NavigateOptions = {
|
||||
productId?: string;
|
||||
projectId?: string;
|
||||
conversationId?: string;
|
||||
firstMessage?: string;
|
||||
firstRefs?: import("../types").CreationRef[];
|
||||
replace?: boolean;
|
||||
hash?: string;
|
||||
// 视频项目页初始 tab(all/wip/done/fail):仅经 route state 透传给 ProjectsPage,不进 URL path。
|
||||
@@ -107,11 +119,14 @@ export const routeLabels: Record<Page, string> = {
|
||||
team: "团队",
|
||||
messages: "消息",
|
||||
assetFactory: "图片工具",
|
||||
omniCreate: "全能创作",
|
||||
omniHistory: "创作历史",
|
||||
omniSession: "全能创作",
|
||||
freeCreate: "自由创作",
|
||||
quickCreate: "一键成片",
|
||||
videoRemix: "提炼提示词",
|
||||
videoReplace: "视频复刻",
|
||||
imageOptimize: "图片创作",
|
||||
imageOptimize: "自由创作",
|
||||
modelPhoto: "模特上身图",
|
||||
modelPhotoDemoA: "模特图方案 A",
|
||||
modelPhotoDemoB: "模特图方案 B",
|
||||
@@ -128,6 +143,8 @@ export function isPage(value: string): value is Page {
|
||||
|
||||
export function parentPage(page: Page): Page {
|
||||
if (["productDetail", "productCreateUpload"].includes(page)) return "products";
|
||||
if (page === "omniHistory") return "omniCreate";
|
||||
if (page === "omniSession") return "omniHistory";
|
||||
if (["projectWizard", "freeCreate", "quickCreate", "videoRemix", "videoReplace", "pipeline"].includes(page)) return "projects";
|
||||
if (["imageOptimize", "modelPhoto", "modelPhotoDemoA", "modelPhotoDemoB", "platformCover"].includes(page)) {
|
||||
return "assetFactory";
|
||||
@@ -170,6 +187,11 @@ export function resolveRoute(): ResolvedRoute {
|
||||
if (path === "/team") return { page: "team", authMode: "login", hash };
|
||||
if (path === "/messages") return { page: "messages", authMode: "login", hash };
|
||||
if (path === "/asset-factory") return { page: "assetFactory", authMode: "login", hash };
|
||||
if (path === "/omni-create") return { page: "omniCreate", authMode: "login", hash };
|
||||
if (path === "/omni-history") return { page: "omniHistory", authMode: "login", hash };
|
||||
if (path.startsWith("/omni-session/")) {
|
||||
return { page: "omniSession", authMode: "login", conversationId: decodeURIComponent(path.slice("/omni-session/".length)), hash };
|
||||
}
|
||||
if (path === "/free-create") return { page: "freeCreate", authMode: "login", hash };
|
||||
if (path === "/quick-create") {
|
||||
return { page: "quickCreate", authMode: "login", productId: search.get("product_id") || undefined, hash };
|
||||
@@ -217,6 +239,12 @@ export function pathForPage(page: Page, options: NavigateOptions = {}) {
|
||||
return "/messages";
|
||||
case "assetFactory":
|
||||
return "/asset-factory";
|
||||
case "omniCreate":
|
||||
return "/omni-create";
|
||||
case "omniHistory":
|
||||
return "/omni-history";
|
||||
case "omniSession":
|
||||
return options.conversationId ? `/omni-session/${encodeURIComponent(options.conversationId)}` : "/omni-history";
|
||||
case "freeCreate":
|
||||
return "/free-create";
|
||||
case "quickCreate":
|
||||
|
||||
@@ -183,40 +183,12 @@ main { position: relative; background: #fff; }
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
background-image:
|
||||
linear-gradient(rgba(0, 47, 167, 0.16) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(0, 47, 167, 0.16) 1px, transparent 1px);
|
||||
linear-gradient(rgba(24, 31, 42, 0.032) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(24, 31, 42, 0.032) 1px, transparent 1px);
|
||||
background-size: 48px 48px;
|
||||
background-position: -1px -1px;
|
||||
-webkit-mask-image: linear-gradient(
|
||||
90deg,
|
||||
#000 0%,
|
||||
rgba(0, 0, 0, 0.58) 2%,
|
||||
rgba(0, 0, 0, 0.3) 16%,
|
||||
rgba(0, 0, 0, 0.12) 30%,
|
||||
rgba(0, 0, 0, 0.03) 40%,
|
||||
transparent 46%,
|
||||
transparent 54%,
|
||||
rgba(0, 0, 0, 0.03) 60%,
|
||||
rgba(0, 0, 0, 0.12) 70%,
|
||||
rgba(0, 0, 0, 0.3) 84%,
|
||||
rgba(0, 0, 0, 0.58) 98%,
|
||||
#000 100%
|
||||
);
|
||||
mask-image: linear-gradient(
|
||||
90deg,
|
||||
#000 0%,
|
||||
rgba(0, 0, 0, 0.58) 2%,
|
||||
rgba(0, 0, 0, 0.3) 16%,
|
||||
rgba(0, 0, 0, 0.12) 30%,
|
||||
rgba(0, 0, 0, 0.03) 40%,
|
||||
transparent 46%,
|
||||
transparent 54%,
|
||||
rgba(0, 0, 0, 0.03) 60%,
|
||||
rgba(0, 0, 0, 0.12) 70%,
|
||||
rgba(0, 0, 0, 0.3) 84%,
|
||||
rgba(0, 0, 0, 0.58) 98%,
|
||||
#000 100%
|
||||
);
|
||||
-webkit-mask-image: linear-gradient(90deg, #000 0%, rgba(0, 0, 0, 0.78) 17%, transparent 40%, transparent 60%, rgba(0, 0, 0, 0.78) 83%, #000 100%);
|
||||
mask-image: linear-gradient(90deg, #000 0%, rgba(0, 0, 0, 0.78) 17%, transparent 40%, transparent 60%, rgba(0, 0, 0, 0.78) 83%, #000 100%);
|
||||
}
|
||||
.scatter { position: absolute; font-family: 'JetBrains Mono', monospace; font-size: 12px; line-height: 1.05; color: var(--ink-4); white-space: pre; pointer-events: none; opacity: .8; letter-spacing: .04em; }
|
||||
.tag-corner { position: absolute; color: var(--ink-3); font-family: 'JetBrains Mono', monospace; font-size: 12px; letter-spacing: .06em; pointer-events: none; opacity: .85; z-index: 1; }
|
||||
@@ -494,6 +466,7 @@ table.t tbody tr:hover { background: var(--bg-soft); }
|
||||
/* ─── Toast ─── */
|
||||
.toast {
|
||||
position: fixed; bottom: 24px; right: 24px;
|
||||
max-width: min(360px, calc(100vw - 32px));
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
padding: 12px 16px;
|
||||
@@ -514,7 +487,7 @@ table.t tbody tr:hover { background: var(--bg-soft); }
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.toast .ic-t svg { width: 12px; height: 12px; }
|
||||
.toast .txt { font-size: 13px; color: var(--ink); }
|
||||
.toast .txt { font-size: 13px; color: var(--ink); overflow-wrap: anywhere; }
|
||||
.toast .txt .mono { font-family: 'JetBrains Mono', monospace; font-size: 12px; color: var(--ink-3); display: block; margin-top: 2px; letter-spacing: .02em; }
|
||||
|
||||
/* ─── Modal ─── */
|
||||
|
||||
@@ -867,3 +867,67 @@ export type BillingConfigInfo = {
|
||||
// 当前请求者团队的价格系数(差异化调价,默认 "1"):预估所见即所扣
|
||||
team_price_multiplier?: string;
|
||||
};
|
||||
|
||||
// ── 全能创作(契约见仓库根 `全能创作-契约-2026-09-02.md`)
|
||||
|
||||
/** @引用的实体。**必须整条存进消息的 refs**,只把 name 拼进文本后端就取不到卖点和参考图。 */
|
||||
export type CreationRef = {
|
||||
type: "product" | "model" | "character" | "scene" | "asset";
|
||||
id: string;
|
||||
name: string;
|
||||
cover?: string;
|
||||
};
|
||||
|
||||
/** 追问卡里的一个控件(后端 ask_user 工具产出)。 */
|
||||
export type CreationField = {
|
||||
key: string;
|
||||
label: string;
|
||||
type: "single" | "multi" | "text" | "asset";
|
||||
required?: boolean;
|
||||
options?: Array<{ value: string; label: string }>;
|
||||
asset_types?: CreationRef["type"][];
|
||||
placeholder?: string;
|
||||
};
|
||||
|
||||
export type CreationMessageKind =
|
||||
| "text"
|
||||
| "elicit"
|
||||
| "strategy"
|
||||
| "plan"
|
||||
| "prompt_file"
|
||||
| "confirm"
|
||||
| "generating"
|
||||
| "result"
|
||||
| "error";
|
||||
|
||||
/** 对话流里的一条消息。**按 kind 分发到不同卡片组件**,结构化内容全在 payload 里。 */
|
||||
export type CreationMessage = {
|
||||
id: string;
|
||||
role: "user" | "assistant" | "system";
|
||||
kind: CreationMessageKind;
|
||||
text: string;
|
||||
payload: Record<string, unknown>;
|
||||
refs: CreationRef[];
|
||||
task: string | null;
|
||||
seq: number;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export type CreationConversation = {
|
||||
id: string;
|
||||
title: string;
|
||||
mode: "video" | "image";
|
||||
preset: string;
|
||||
params: Record<string, string>;
|
||||
status: "running" | "completed" | "failed";
|
||||
message_count: number;
|
||||
cover_url: string;
|
||||
last_active_at: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
export type CreationConversationDetail = CreationConversation & {
|
||||
messages: CreationMessage[];
|
||||
pinned_refs: CreationRef[];
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user