Compare commits
29 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a2875738ce | |||
| 85adcdceef | |||
| ab99e3bf2f | |||
| e3b48e2614 | |||
| 5b316a2399 | |||
| 3f1954b38d | |||
| 96d335db8a | |||
| 307537a7ce | |||
| 0b2d6b901f | |||
| e1fdbe5f9b | |||
| f51dfb17e1 | |||
| 76ae9ab0ac | |||
| 98db427ac5 | |||
| 573cbacbd3 | |||
| 38b513aebf | |||
| 4d5f487a80 | |||
| 4f6e32fb10 | |||
| 1f97167023 | |||
| 9ae5e1f493 | |||
| ad4bca31b1 | |||
| f9e55578b3 | |||
| 7fdaa38504 | |||
| 6378ce7546 | |||
| 3cfcfe70d4 | |||
| 9fbf464dbd | |||
| e1a2e55792 | |||
| e88edbe165 | |||
| 863f1f075e | |||
| aa133d0f5c |
@@ -15,3 +15,4 @@ tmp/
|
|||||||
*.swp
|
*.swp
|
||||||
*.swo
|
*.swo
|
||||||
coverage/
|
coverage/
|
||||||
|
屏幕截图 *.png
|
||||||
|
|||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="zh-CN">
|
<html lang="zh-CN" data-theme="dark" data-ui-theme="dark-green" style="color-scheme: dark">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
|||||||
+21
-40
@@ -1,4 +1,4 @@
|
|||||||
import { Suspense, useCallback, useEffect, useMemo, useState } from "react";
|
import { lazy, Suspense, useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import {
|
import {
|
||||||
BugOutlined,
|
BugOutlined,
|
||||||
CheckCircleFilled,
|
CheckCircleFilled,
|
||||||
@@ -20,9 +20,7 @@ import {
|
|||||||
import ErrorBoundary from "./components/ErrorBoundary";
|
import ErrorBoundary from "./components/ErrorBoundary";
|
||||||
import ToastContainer from "./components/toast/ToastContainer";
|
import ToastContainer from "./components/toast/ToastContainer";
|
||||||
import { toast } from "./components/toast/toastStore";
|
import { toast } from "./components/toast/toastStore";
|
||||||
import EcommercePage from "./features/ecommerce/EcommercePage";
|
|
||||||
import { flushPendingGenerationRecords } from "./api/generationRecordClient";
|
import { flushPendingGenerationRecords } from "./api/generationRecordClient";
|
||||||
import { ossAssets } from "./data/ossAssets";
|
|
||||||
import { keyServerClient } from "./api/keyServerClient";
|
import { keyServerClient } from "./api/keyServerClient";
|
||||||
import { setUserMaxConcurrency } from "./api/generationConcurrency";
|
import { setUserMaxConcurrency } from "./api/generationConcurrency";
|
||||||
import {
|
import {
|
||||||
@@ -38,6 +36,8 @@ import { useAppStore, useSessionStore } from "./stores";
|
|||||||
import type { WebUserSession } from "./types";
|
import type { WebUserSession } from "./types";
|
||||||
import "./styles/ecommerce-standalone.css";
|
import "./styles/ecommerce-standalone.css";
|
||||||
|
|
||||||
|
const EcommercePage = lazy(() => import("./features/ecommerce/EcommercePage"));
|
||||||
|
|
||||||
type AuthMode = "login" | "register";
|
type AuthMode = "login" | "register";
|
||||||
type AuthMethod = "account" | "email" | "phone";
|
type AuthMethod = "account" | "email" | "phone";
|
||||||
|
|
||||||
@@ -51,17 +51,6 @@ interface LocalProfilePageProps {
|
|||||||
onLogout: () => void;
|
onLogout: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const profileWorks = [
|
|
||||||
{ title: "主图套图生成", desc: "电商主图与场景图自动生成", image: ossAssets.ecommerce.templateCases[0], type: "图像", time: "6/9 18:13" },
|
|
||||||
{ title: "A+详情页设计", desc: "产品卖点与长图详情版式", image: ossAssets.ecommerce.templateCases[1], type: "图像", time: "6/9 10:11" },
|
|
||||||
{ title: "短视频广告", desc: "产品展示短视频脚本与画面", image: ossAssets.ecommerce.productSet.hosting, type: "视频", time: "6/9 10:05" },
|
|
||||||
{ title: "模特图生成", desc: "服饰商品真人上身展示", image: ossAssets.ecommerce.tryOn.tryA, type: "图像", time: "6/9 10:03" },
|
|
||||||
{ title: "商品场景图", desc: "按平台比例输出营销素材", image: ossAssets.ecommerce.detail.gridA, type: "图像", time: "6/9 10:01" },
|
|
||||||
{ title: "高度复刻", desc: "参考图结构复刻与商品替换", image: ossAssets.ecommerce.detail.gridB, type: "图像", time: "6/9 09:39" },
|
|
||||||
{ title: "详情模块", desc: "功能卖点、参数和包装模块", image: ossAssets.ecommerce.detail.gridC, type: "图像", time: "6/8 21:20" },
|
|
||||||
{ title: "平台素材", desc: "淘宝/天猫投放图批量生成", image: ossAssets.ecommerce.detail.gridD, type: "图像", time: "6/8 18:26" },
|
|
||||||
];
|
|
||||||
|
|
||||||
function LocalAvatar({ session, size = "md" }: { session: WebUserSession; size?: "sm" | "md" | "lg" }) {
|
function LocalAvatar({ session, size = "md" }: { session: WebUserSession; size?: "sm" | "md" | "lg" }) {
|
||||||
const displayName = session.user.displayName || session.user.username || "用户";
|
const displayName = session.user.displayName || session.user.username || "用户";
|
||||||
const label = displayName.trim().slice(0, 1).toUpperCase() || "用";
|
const label = displayName.trim().slice(0, 1).toUpperCase() || "用";
|
||||||
@@ -75,9 +64,9 @@ function LocalAvatar({ session, size = "md" }: { session: WebUserSession; size?:
|
|||||||
|
|
||||||
function LocalProfilePage({ session, balance, imageCount, videoCount, onBack, onBugFeedback, onLogout }: LocalProfilePageProps) {
|
function LocalProfilePage({ session, balance, imageCount, videoCount, onBack, onBugFeedback, onLogout }: LocalProfilePageProps) {
|
||||||
const displayName = session.user.displayName || session.user.username || "用户";
|
const displayName = session.user.displayName || session.user.username || "用户";
|
||||||
const workCount = Math.max(imageCount + videoCount, profileWorks.length);
|
const workCount = Math.max(imageCount + videoCount, 0);
|
||||||
const projectCount = Math.max(1, Math.round(workCount / 18));
|
const projectCount = 0;
|
||||||
const assetCount = Math.max(1, Math.round(workCount / 20));
|
const assetCount = 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="local-profile-page">
|
<section className="local-profile-page">
|
||||||
@@ -142,22 +131,15 @@ function LocalProfilePage({ session, balance, imageCount, videoCount, onBack, on
|
|||||||
<header>
|
<header>
|
||||||
<div>
|
<div>
|
||||||
<strong>代表作</strong>
|
<strong>代表作</strong>
|
||||||
<span>最近完成的高质量生成内容</span>
|
<span>后续将展示接口返回的真实作品</span>
|
||||||
</div>
|
</div>
|
||||||
<em>{workCount} 项</em>
|
<em>{workCount} 项</em>
|
||||||
</header>
|
</header>
|
||||||
<div className="local-profile-work-grid">
|
<div className="local-profile-work-grid local-profile-work-grid--empty">
|
||||||
{profileWorks.map((work) => (
|
<div className="local-profile-empty">
|
||||||
<article key={`${work.title}-${work.time}`} className="local-profile-work-card">
|
<strong>暂无代表作数据</strong>
|
||||||
<img src={work.image} alt="" />
|
<span>作品接口接入后,这里会显示你的真实生成内容。</span>
|
||||||
<div>
|
|
||||||
<span>{work.type}</span>
|
|
||||||
<strong>{work.title}</strong>
|
|
||||||
<p>{work.desc}</p>
|
|
||||||
<em>已完成 · {work.time}</em>
|
|
||||||
</div>
|
</div>
|
||||||
</article>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
@@ -184,7 +166,6 @@ function App() {
|
|||||||
const [sessionNotice, setSessionNotice] = useState<string | null>(null);
|
const [sessionNotice, setSessionNotice] = useState<string | null>(null);
|
||||||
const [profileMenuOpen, setProfileMenuOpen] = useState(false);
|
const [profileMenuOpen, setProfileMenuOpen] = useState(false);
|
||||||
const [currentPage, setCurrentPage] = useState<"workspace" | "profile">("workspace");
|
const [currentPage, setCurrentPage] = useState<"workspace" | "profile">("workspace");
|
||||||
const [workspaceKey, setWorkspaceKey] = useState(0);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void loadDarkGreenTheme();
|
void loadDarkGreenTheme();
|
||||||
@@ -339,7 +320,7 @@ function App() {
|
|||||||
const balance = Math.max(usage.balanceCents, 0) / 100;
|
const balance = Math.max(usage.balanceCents, 0) / 100;
|
||||||
const displayName = session?.user.displayName || session?.user.username || "用户";
|
const displayName = session?.user.displayName || session?.user.username || "用户";
|
||||||
const actualWorkCount = Math.max(usage.imageUsed + usage.videoUsed, 0);
|
const actualWorkCount = Math.max(usage.imageUsed + usage.videoUsed, 0);
|
||||||
const shownWorkCount = Math.max(actualWorkCount, profileWorks.length);
|
const shownWorkCount = actualWorkCount;
|
||||||
|
|
||||||
const avatarMenuStats = useMemo(
|
const avatarMenuStats = useMemo(
|
||||||
() => [
|
() => [
|
||||||
@@ -360,7 +341,6 @@ function App() {
|
|||||||
const handleOpenWorkspace = () => {
|
const handleOpenWorkspace = () => {
|
||||||
setProfileMenuOpen(false);
|
setProfileMenuOpen(false);
|
||||||
setCurrentPage("workspace");
|
setCurrentPage("workspace");
|
||||||
setWorkspaceKey((k) => k + 1);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleBugFeedback = () => {
|
const handleBugFeedback = () => {
|
||||||
@@ -447,7 +427,8 @@ function App() {
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main className="ecommerce-standalone__content">
|
<main className="ecommerce-standalone__content">
|
||||||
{currentPage === "profile" && session ? (
|
{session ? (
|
||||||
|
<div className="ecommerce-standalone__page" hidden={currentPage !== "profile"}>
|
||||||
<LocalProfilePage
|
<LocalProfilePage
|
||||||
session={session}
|
session={session}
|
||||||
balance={balance}
|
balance={balance}
|
||||||
@@ -457,7 +438,11 @@ function App() {
|
|||||||
onBugFeedback={handleBugFeedback}
|
onBugFeedback={handleBugFeedback}
|
||||||
onLogout={handleLogout}
|
onLogout={handleLogout}
|
||||||
/>
|
/>
|
||||||
) : (
|
</div>
|
||||||
|
) : null}
|
||||||
|
{/* 工作台常驻挂载,仅用 hidden 切换。切到个人中心时不卸载,
|
||||||
|
生成任务、进度动画、已上传图片等本地状态全部保留,切回即继续。 */}
|
||||||
|
<div className="ecommerce-standalone__page" hidden={Boolean(session) && currentPage === "profile"}>
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
<Suspense
|
<Suspense
|
||||||
fallback={
|
fallback={
|
||||||
@@ -468,7 +453,6 @@ function App() {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<EcommercePage
|
<EcommercePage
|
||||||
key={workspaceKey}
|
|
||||||
projects={[]}
|
projects={[]}
|
||||||
isAuthenticated={Boolean(session)}
|
isAuthenticated={Boolean(session)}
|
||||||
onStartCreate={() => undefined}
|
onStartCreate={() => undefined}
|
||||||
@@ -482,7 +466,7 @@ function App() {
|
|||||||
/>
|
/>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
)}
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
{authOpen ? (
|
{authOpen ? (
|
||||||
@@ -503,10 +487,7 @@ function App() {
|
|||||||
<CloseOutlined />
|
<CloseOutlined />
|
||||||
</button>
|
</button>
|
||||||
<span className="ecommerce-auth-modal__logo" aria-hidden="true">
|
<span className="ecommerce-auth-modal__logo" aria-hidden="true">
|
||||||
<i />
|
<img src="https://stringtest.oss-cn-hangzhou.aliyuncs.com/logo.png" alt="" />
|
||||||
<i />
|
|
||||||
<i />
|
|
||||||
<i />
|
|
||||||
</span>
|
</span>
|
||||||
<h2 id="ecommerce-auth-title">{authMode === "login" ? "欢迎回来" : "创建账号"}</h2>
|
<h2 id="ecommerce-auth-title">{authMode === "login" ? "欢迎回来" : "创建账号"}</h2>
|
||||||
<p className="ecommerce-auth-modal__subtitle">{authMode === "login" ? "登录后继续你的 AI 创作之旅" : "注册即可免费体验全部功能"}</p>
|
<p className="ecommerce-auth-modal__subtitle">{authMode === "login" ? "登录后继续你的 AI 创作之旅" : "注册即可免费体验全部功能"}</p>
|
||||||
|
|||||||
@@ -1,8 +1,5 @@
|
|||||||
import { buildApiUrl, buildAuthHeaders } from "./serverConnection";
|
import { buildApiUrl, buildAuthHeaders } from "./serverConnection";
|
||||||
|
|
||||||
const TEXT_MODELS = ["qwen-max", "qwen-plus", "qwen-turbo"];
|
|
||||||
const VISION_MODELS = ["qwen3.7-plus", "qwen-vl-plus", "qwen-vl-max"];
|
|
||||||
|
|
||||||
type AbortSignalConstructorWithAny = typeof AbortSignal & {
|
type AbortSignalConstructorWithAny = typeof AbortSignal & {
|
||||||
any?: (signals: AbortSignal[]) => AbortSignal;
|
any?: (signals: AbortSignal[]) => AbortSignal;
|
||||||
};
|
};
|
||||||
@@ -110,11 +107,45 @@ export interface ComplianceCheck {
|
|||||||
allow_video_generation: boolean;
|
allow_video_generation: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function findJsonSlice(raw: string): string {
|
||||||
|
const start = raw.search(/[\[{]/);
|
||||||
|
if (start < 0) return raw;
|
||||||
|
|
||||||
|
const stack: string[] = [];
|
||||||
|
let inString = false;
|
||||||
|
let escaped = false;
|
||||||
|
|
||||||
|
for (let index = start; index < raw.length; index += 1) {
|
||||||
|
const char = raw[index];
|
||||||
|
|
||||||
|
if (inString) {
|
||||||
|
if (escaped) {
|
||||||
|
escaped = false;
|
||||||
|
} else if (char === "\\") {
|
||||||
|
escaped = true;
|
||||||
|
} else if (char === "\"") {
|
||||||
|
inString = false;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (char === "\"") {
|
||||||
|
inString = true;
|
||||||
|
} else if (char === "{" || char === "[") {
|
||||||
|
stack.push(char === "{" ? "}" : "]");
|
||||||
|
} else if (char === "}" || char === "]") {
|
||||||
|
if (stack.pop() !== char) break;
|
||||||
|
if (stack.length === 0) return raw.slice(start, index + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return raw.slice(start);
|
||||||
|
}
|
||||||
|
|
||||||
function extractJson(text: string): unknown {
|
function extractJson(text: string): unknown {
|
||||||
const fenced = text.match(/```(?:json)?\s*([\s\S]*?)```/);
|
const fenced = text.match(/```(?:json)?\s*([\s\S]*?)```/);
|
||||||
const raw = fenced ? fenced[1].trim() : text.trim();
|
const raw = fenced ? fenced[1].trim() : text.trim();
|
||||||
const start = raw.search(/[[{]/);
|
const slice = findJsonSlice(raw);
|
||||||
const slice = start >= 0 ? raw.slice(start) : raw;
|
|
||||||
try {
|
try {
|
||||||
return JSON.parse(slice);
|
return JSON.parse(slice);
|
||||||
} catch {
|
} catch {
|
||||||
@@ -122,9 +153,16 @@ function extractJson(text: string): unknown {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ChatContent =
|
||||||
|
| string
|
||||||
|
| Array<
|
||||||
|
| { type: "image_url"; image_url: { url: string } }
|
||||||
|
| { type: "text"; text: string }
|
||||||
|
>;
|
||||||
|
|
||||||
interface ChatMessage {
|
interface ChatMessage {
|
||||||
role: "system" | "user";
|
role: "system" | "user";
|
||||||
content: string;
|
content: ChatContent;
|
||||||
}
|
}
|
||||||
|
|
||||||
const MAX_RETRIES = 3;
|
const MAX_RETRIES = 3;
|
||||||
@@ -171,22 +209,20 @@ async function chat(
|
|||||||
userContent: string,
|
userContent: string,
|
||||||
options?: { model?: string; signal?: AbortSignal },
|
options?: { model?: string; signal?: AbortSignal },
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const candidateModels = options?.model ? [options.model] : TEXT_MODELS;
|
return retryOnTransient(async () => {
|
||||||
let lastError: Error | null = null;
|
|
||||||
|
|
||||||
for (const model of candidateModels) {
|
|
||||||
try {
|
|
||||||
return await retryOnTransient(async () => {
|
|
||||||
const messages: ChatMessage[] = [
|
const messages: ChatMessage[] = [
|
||||||
{ role: "system", content: systemPrompt },
|
{ role: "system", content: systemPrompt },
|
||||||
{ role: "user", content: userContent },
|
{ role: "user", content: userContent },
|
||||||
];
|
];
|
||||||
const timeoutSignal = AbortSignal.timeout(CHAT_TIMEOUT_MS);
|
const timeoutSignal = AbortSignal.timeout(CHAT_TIMEOUT_MS);
|
||||||
const combinedSignal = combineAbortSignals(options?.signal, timeoutSignal);
|
const combinedSignal = combineAbortSignals(options?.signal, timeoutSignal);
|
||||||
|
const body: Record<string, unknown> = { messages, stream: false, temperature: 0.4 };
|
||||||
|
if (options?.model) body.model = options.model;
|
||||||
|
|
||||||
const res = await fetch(buildApiUrl("ai/chat"), {
|
const res = await fetch(buildApiUrl("ai/chat"), {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: buildAuthHeaders(),
|
headers: buildAuthHeaders(),
|
||||||
body: JSON.stringify({ model, messages, stream: false, temperature: 0.4 }),
|
body: JSON.stringify(body),
|
||||||
signal: combinedSignal,
|
signal: combinedSignal,
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
@@ -199,15 +235,6 @@ async function chat(
|
|||||||
if (!content) throw new Error("模型未返回有效内容");
|
if (!content) throw new Error("模型未返回有效内容");
|
||||||
return content;
|
return content;
|
||||||
}, options?.signal);
|
}, options?.signal);
|
||||||
} catch (err) {
|
|
||||||
lastError = err instanceof Error ? err : new Error(String(err));
|
|
||||||
if (options?.signal?.aborted) throw lastError;
|
|
||||||
// If user pinned a specific model, don't fall back to others
|
|
||||||
if (options?.model) throw lastError;
|
|
||||||
// Try next model in fallback chain
|
|
||||||
}
|
|
||||||
}
|
|
||||||
throw lastError ?? new Error("所有候选模型均不可用");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function visionChat(
|
async function visionChat(
|
||||||
@@ -216,30 +243,28 @@ async function visionChat(
|
|||||||
imageUrls: string[],
|
imageUrls: string[],
|
||||||
signal?: AbortSignal,
|
signal?: AbortSignal,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const content = [
|
const content: ChatContent = [
|
||||||
...imageUrls.map((url) => ({ type: "image_url", image_url: { url } })),
|
...imageUrls.map((url) => ({ type: "image_url" as const, image_url: { url } })),
|
||||||
{ type: "text", text },
|
{ type: "text", text },
|
||||||
];
|
];
|
||||||
const messages = [
|
const messages = [
|
||||||
{ role: "system", content: systemPrompt },
|
{ role: "system", content: systemPrompt },
|
||||||
{ role: "user", content },
|
{ role: "user", content },
|
||||||
];
|
] satisfies ChatMessage[];
|
||||||
|
|
||||||
let lastError: Error | null = null;
|
return retryOnTransient(async () => {
|
||||||
for (const model of VISION_MODELS) {
|
|
||||||
const timeoutSignal = AbortSignal.timeout(CHAT_TIMEOUT_MS);
|
const timeoutSignal = AbortSignal.timeout(CHAT_TIMEOUT_MS);
|
||||||
const combinedSignal = combineAbortSignals(signal, timeoutSignal);
|
const combinedSignal = combineAbortSignals(signal, timeoutSignal);
|
||||||
try {
|
|
||||||
const out = await retryOnTransient(async () => {
|
|
||||||
const res = await fetch(buildApiUrl("ai/chat"), {
|
const res = await fetch(buildApiUrl("ai/chat"), {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: buildAuthHeaders(),
|
headers: buildAuthHeaders(),
|
||||||
body: JSON.stringify({ model, messages, stream: false, temperature: 0.3 }),
|
body: JSON.stringify({ messages, stream: false, temperature: 0.3 }),
|
||||||
signal: combinedSignal,
|
signal: combinedSignal,
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const errBody = await res.text().catch(() => "");
|
const errBody = await res.text().catch(() => "");
|
||||||
if (errBody.includes("image format")) throw new Error("IMAGE_FORMAT_FALLBACK");
|
if (errBody.includes("image format")) throw new Error("图片格式不受支持,请更换图片后重试");
|
||||||
throw new Error(`图片理解调用失败 (${res.status})${errBody ? `: ${errBody.slice(0, 120)}` : ""}`);
|
throw new Error(`图片理解调用失败 (${res.status})${errBody ? `: ${errBody.slice(0, 120)}` : ""}`);
|
||||||
}
|
}
|
||||||
const payload = await res.json();
|
const payload = await res.json();
|
||||||
@@ -248,18 +273,6 @@ async function visionChat(
|
|||||||
if (!result) throw new Error("图片理解未返回有效内容");
|
if (!result) throw new Error("图片理解未返回有效内容");
|
||||||
return result;
|
return result;
|
||||||
}, signal);
|
}, signal);
|
||||||
return out;
|
|
||||||
} catch (err) {
|
|
||||||
lastError = err instanceof Error ? err : new Error(String(err));
|
|
||||||
if (signal?.aborted) throw lastError;
|
|
||||||
// Continue trying next vision model on transient failures, image format errors, or upstream errors
|
|
||||||
if (lastError.message === "IMAGE_FORMAT_FALLBACK") continue;
|
|
||||||
if (lastError.message.includes("图片理解调用失败")) continue;
|
|
||||||
if (isTransientError(lastError)) continue;
|
|
||||||
throw lastError;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
throw lastError ?? new Error("图片理解调用失败,所有模型均不可用");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const IMAGE_UNDERSTANDING_PROMPT = `你是电商产品图片分析专家。请分析用户提供的产品图片,识别产品主体、外观、颜色、材质、形状、尺寸感、品牌标识、关键部件、可视化卖点和适合展示的镜头角度。请用简洁的中文段落描述,不要编造图片中看不到的信息。`;
|
const IMAGE_UNDERSTANDING_PROMPT = `你是电商产品图片分析专家。请分析用户提供的产品图片,识别产品主体、外观、颜色、材质、形状、尺寸感、品牌标识、关键部件、可视化卖点和适合展示的镜头角度。请用简洁的中文段落描述,不要编造图片中看不到的信息。`;
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import type { WebGenerationPreviewTask } from "../types";
|
|||||||
export interface ImageGenInput {
|
export interface ImageGenInput {
|
||||||
projectId?: string;
|
projectId?: string;
|
||||||
conversationId?: number;
|
conversationId?: number;
|
||||||
model: string;
|
model?: string;
|
||||||
prompt: string;
|
prompt: string;
|
||||||
ratio?: string;
|
ratio?: string;
|
||||||
quality?: string;
|
quality?: string;
|
||||||
@@ -89,6 +89,8 @@ export interface ImageEditInput {
|
|||||||
imageUrl: string;
|
imageUrl: string;
|
||||||
function: string;
|
function: string;
|
||||||
prompt?: string;
|
prompt?: string;
|
||||||
|
maskUrl?: string;
|
||||||
|
ratio?: string;
|
||||||
n?: number;
|
n?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -208,8 +210,9 @@ function getStoredSessionRole(): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function emitImageRouteDebug(label: string, payload: Record<string, unknown>): void {
|
function emitImageRouteDebug(label: string, payload: Record<string, unknown>): void {
|
||||||
// Only emit console logs for admin users — hides enterprise routing details
|
// Only emit route debug for admin users; provider routing is operational data.
|
||||||
if (getStoredSessionRole() === "admin") {
|
if (getStoredSessionRole() !== "admin") return;
|
||||||
|
|
||||||
const entry: ImageRouteDebugEntry = {
|
const entry: ImageRouteDebugEntry = {
|
||||||
at: new Date().toISOString(),
|
at: new Date().toISOString(),
|
||||||
label,
|
label,
|
||||||
@@ -220,14 +223,12 @@ function emitImageRouteDebug(label: string, payload: Record<string, unknown>): v
|
|||||||
} catch {
|
} catch {
|
||||||
console.log(label, entry);
|
console.log(label, entry);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof window === "undefined") return;
|
if (typeof window === "undefined") return;
|
||||||
const debugWindow = window as Window & { __OMNIAI_IMAGE_ROUTE_DEBUG__?: ImageRouteDebugEntry[] };
|
const debugWindow = window as Window & { __OMNIAI_IMAGE_ROUTE_DEBUG__?: ImageRouteDebugEntry[] };
|
||||||
const previousEntries = Array.isArray(debugWindow.__OMNIAI_IMAGE_ROUTE_DEBUG__)
|
const previousEntries = Array.isArray(debugWindow.__OMNIAI_IMAGE_ROUTE_DEBUG__)
|
||||||
? debugWindow.__OMNIAI_IMAGE_ROUTE_DEBUG__
|
? debugWindow.__OMNIAI_IMAGE_ROUTE_DEBUG__
|
||||||
: [];
|
: [];
|
||||||
const entry: ImageRouteDebugEntry = { at: new Date().toISOString(), label, ...payload };
|
|
||||||
debugWindow.__OMNIAI_IMAGE_ROUTE_DEBUG__ = [...previousEntries.slice(-19), entry];
|
debugWindow.__OMNIAI_IMAGE_ROUTE_DEBUG__ = [...previousEntries.slice(-19), entry];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -38,6 +38,20 @@ export interface SaveGenerationRecordResult {
|
|||||||
id: string;
|
id: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 同一 clientRecordId 的保存去重:套图主流程、backgroundTaskRunner、useGenerationTasks
|
||||||
|
// 三处都可能对同一条终态任务调用 saveGenerationRecord,SSE 重复推送 completed 时
|
||||||
|
// 单个 poller 内也会重复触发。这里做客户端幂等:in-flight 合流 + 成功后短期拦截,
|
||||||
|
// 避免后端在缺少去重时插入重复记录。
|
||||||
|
const inFlightSaves = new Map<string, Promise<SaveGenerationRecordResult>>();
|
||||||
|
const recentlySavedAt = new Map<string, number>();
|
||||||
|
const SAVE_DEDUPE_WINDOW_MS = 60_000;
|
||||||
|
|
||||||
|
function pruneRecentlySaved(now: number): void {
|
||||||
|
for (const [id, savedAt] of recentlySavedAt) {
|
||||||
|
if (now - savedAt > SAVE_DEDUPE_WINDOW_MS) recentlySavedAt.delete(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function readPendingRecords(): SaveGenerationRecordInput[] {
|
function readPendingRecords(): SaveGenerationRecordInput[] {
|
||||||
try {
|
try {
|
||||||
const raw = window.localStorage.getItem(PENDING_RECORDS_KEY);
|
const raw = window.localStorage.getItem(PENDING_RECORDS_KEY);
|
||||||
@@ -60,6 +74,36 @@ function writePendingRecord(input: SaveGenerationRecordInput): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function saveGenerationRecord(input: SaveGenerationRecordInput): Promise<SaveGenerationRecordResult> {
|
export async function saveGenerationRecord(input: SaveGenerationRecordInput): Promise<SaveGenerationRecordResult> {
|
||||||
|
const now = Date.now();
|
||||||
|
pruneRecentlySaved(now);
|
||||||
|
|
||||||
|
const recordId = input.clientRecordId;
|
||||||
|
if (recordId) {
|
||||||
|
const inFlight = inFlightSaves.get(recordId);
|
||||||
|
if (inFlight) return inFlight;
|
||||||
|
const savedAt = recentlySavedAt.get(recordId);
|
||||||
|
if (savedAt !== undefined && now - savedAt <= SAVE_DEDUPE_WINDOW_MS) {
|
||||||
|
// 终态记录只需落库一次;窗口内的重复调用直接视为已保存。
|
||||||
|
return { source: "server", id: recordId };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const promise = saveGenerationRecordInternal(input);
|
||||||
|
if (recordId) {
|
||||||
|
inFlightSaves.set(recordId, promise);
|
||||||
|
void promise
|
||||||
|
.then((result) => {
|
||||||
|
if (result.source === "server") recentlySavedAt.set(recordId, Date.now());
|
||||||
|
})
|
||||||
|
.catch(() => undefined)
|
||||||
|
.finally(() => {
|
||||||
|
inFlightSaves.delete(recordId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return promise;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveGenerationRecordInternal(input: SaveGenerationRecordInput): Promise<SaveGenerationRecordResult> {
|
||||||
try {
|
try {
|
||||||
const response = await serverRequest<{ id?: string | number }>("ai/generation-records", {
|
const response = await serverRequest<{ id?: string | number }>("ai/generation-records", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|||||||
@@ -161,7 +161,11 @@ export function clearAllUserStorage(): void {
|
|||||||
"omniai-web-profile-ui",
|
"omniai-web-profile-ui",
|
||||||
"omniai:more-recent-tools",
|
"omniai:more-recent-tools",
|
||||||
"omniai:generation-queue",
|
"omniai:generation-queue",
|
||||||
|
"omniai:generation-records.pending",
|
||||||
|
"omniai:ecommerce-video-workspace",
|
||||||
"omniai-canvas-saved-assets",
|
"omniai-canvas-saved-assets",
|
||||||
|
"omniai.clone-ai.",
|
||||||
|
"omniai.ecommerce.",
|
||||||
];
|
];
|
||||||
for (let i = window.localStorage.length - 1; i >= 0; i--) {
|
for (let i = window.localStorage.length - 1; i >= 0; i--) {
|
||||||
const key = window.localStorage.key(i);
|
const key = window.localStorage.key(i);
|
||||||
|
|||||||
@@ -50,7 +50,6 @@ export const webGenerationGateway = {
|
|||||||
const result = await aiGenerationClient.createImageTask({
|
const result = await aiGenerationClient.createImageTask({
|
||||||
projectId: params?.projectId,
|
projectId: params?.projectId,
|
||||||
conversationId: params?.conversationId,
|
conversationId: params?.conversationId,
|
||||||
model: "gpt-image-2",
|
|
||||||
prompt,
|
prompt,
|
||||||
ratio: params?.ratio || "16:9",
|
ratio: params?.ratio || "16:9",
|
||||||
quality: params?.quality || "1K",
|
quality: params?.quality || "1K",
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,10 +1,11 @@
|
|||||||
import { useSmoothedProgress } from "../../hooks/useSmoothedProgress";
|
import { useSmoothedProgress } from "../../hooks/useSmoothedProgress";
|
||||||
import type { ReactNode } from "react";
|
|
||||||
|
|
||||||
interface EcommerceProgressBarProps {
|
interface EcommerceProgressBarProps {
|
||||||
status: "idle" | "generating" | "done" | "failed" | string;
|
status: "idle" | "generating" | "done" | "failed" | string;
|
||||||
label?: string;
|
label?: string;
|
||||||
onCancel?: () => void;
|
onCancel?: () => void;
|
||||||
|
/** 0-100 真实进度。传入时进度条按真实值推进;省略时按状态做平滑蠕动。 */
|
||||||
|
progress?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
function mapStatus(status: string): "running" | "completed" | "failed" {
|
function mapStatus(status: string): "running" | "completed" | "failed" {
|
||||||
@@ -14,9 +15,13 @@ function mapStatus(status: string): "running" | "completed" | "failed" {
|
|||||||
return "running";
|
return "running";
|
||||||
}
|
}
|
||||||
|
|
||||||
export function EcommerceProgressBar({ status, label, onCancel }: EcommerceProgressBarProps) {
|
export function EcommerceProgressBar({ status, label, onCancel, progress }: EcommerceProgressBarProps) {
|
||||||
const progress = mapStatus(status) === "running" ? 50 : 100;
|
const mapped = mapStatus(status);
|
||||||
const smoothed = useSmoothedProgress(progress, mapStatus(status));
|
// running 时目标取「真实进度」与兜底值 88 的较大者:有真实进度则跟随推进,
|
||||||
|
// 后端不推中间进度时也由平滑器持续蠕动到高位,不再卡死在 75%。
|
||||||
|
const realProgress = typeof progress === "number" ? Math.max(0, Math.min(100, progress)) : 0;
|
||||||
|
const target = mapped === "running" ? Math.max(realProgress, 88) : 100;
|
||||||
|
const smoothed = useSmoothedProgress(target, mapped);
|
||||||
|
|
||||||
if (status === "idle") return null;
|
if (status === "idle") return null;
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,14 @@ import {
|
|||||||
SendOutlined,
|
SendOutlined,
|
||||||
StopOutlined,
|
StopOutlined,
|
||||||
} from "@ant-design/icons";
|
} from "@ant-design/icons";
|
||||||
import { runVideoPlan, renderSceneImage, renderScene, buildSceneTasks, saveVideoHistory } from "./ecommerceVideoService";
|
import {
|
||||||
|
runVideoPlan,
|
||||||
|
renderSceneImage,
|
||||||
|
renderScene,
|
||||||
|
buildSceneTasks,
|
||||||
|
saveVideoHistory,
|
||||||
|
buildComplianceFailureMessage,
|
||||||
|
} from "./ecommerceVideoService";
|
||||||
import {
|
import {
|
||||||
PLAN_STEP_LABELS,
|
PLAN_STEP_LABELS,
|
||||||
PLAN_STEPS_DISPLAY,
|
PLAN_STEPS_DISPLAY,
|
||||||
@@ -70,9 +77,11 @@ function buildInputFingerprint(input: {
|
|||||||
durationSeconds: number;
|
durationSeconds: number;
|
||||||
resolution: string;
|
resolution: string;
|
||||||
}): string {
|
}): string {
|
||||||
const imageCount = input.productImageDataUrls.length;
|
const imageSignature = input.productImageDataUrls
|
||||||
|
.map((source) => `${source.length}:${hashString(source)}`)
|
||||||
|
.join("|");
|
||||||
return hashString([
|
return hashString([
|
||||||
String(imageCount),
|
imageSignature,
|
||||||
input.requirement.trim(),
|
input.requirement.trim(),
|
||||||
input.platform,
|
input.platform,
|
||||||
input.aspectRatio,
|
input.aspectRatio,
|
||||||
@@ -81,6 +90,10 @@ function buildInputFingerprint(input: {
|
|||||||
].join("::"));
|
].join("::"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function planAllowsVideoGeneration(plan: EcommerceVideoPlanResult | null): boolean {
|
||||||
|
return plan?.compliance.allow_video_generation !== false;
|
||||||
|
}
|
||||||
|
|
||||||
function mapResolutionToQuality(res: string): "720P" | "1080P" {
|
function mapResolutionToQuality(res: string): "720P" | "1080P" {
|
||||||
return res.includes("720") ? "720P" : "1080P";
|
return res.includes("720") ? "720P" : "1080P";
|
||||||
}
|
}
|
||||||
@@ -163,6 +176,10 @@ export default function EcommerceVideoWorkspace({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const delay = 600;
|
const delay = 600;
|
||||||
if (stage === "planned" && planResult && scenes.length > 0) {
|
if (stage === "planned" && planResult && scenes.length > 0) {
|
||||||
|
if (!planAllowsVideoGeneration(planResult)) {
|
||||||
|
setError(buildComplianceFailureMessage(planResult.compliance));
|
||||||
|
return;
|
||||||
|
}
|
||||||
const timer = setTimeout(() => { void handleGenerateImages(); }, delay);
|
const timer = setTimeout(() => { void handleGenerateImages(); }, delay);
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}
|
}
|
||||||
@@ -468,6 +485,7 @@ export default function EcommerceVideoWorkspace({
|
|||||||
let liveCompletedSteps: PlanStep[] = resume
|
let liveCompletedSteps: PlanStep[] = resume
|
||||||
? ALL_STEPS.filter((s) => stepCompletedFromProgress(s, resume))
|
? ALL_STEPS.filter((s) => stepCompletedFromProgress(s, resume))
|
||||||
: [];
|
: [];
|
||||||
|
let liveCurrentStep: PlanStep | null = null;
|
||||||
const persist = (stageNow: EcommerceVideoStage) => {
|
const persist = (stageNow: EcommerceVideoStage) => {
|
||||||
saveEcommerceVideoState({
|
saveEcommerceVideoState({
|
||||||
inputFingerprint,
|
inputFingerprint,
|
||||||
@@ -484,7 +502,10 @@ export default function EcommerceVideoWorkspace({
|
|||||||
const result = await runVideoPlan(
|
const result = await runVideoPlan(
|
||||||
productImageSources, requirement, buildConfig(),
|
productImageSources, requirement, buildConfig(),
|
||||||
{
|
{
|
||||||
onStepStart: (step) => setCurrentStep(step),
|
onStepStart: (step) => {
|
||||||
|
liveCurrentStep = step;
|
||||||
|
setCurrentStep(step);
|
||||||
|
},
|
||||||
onStepDone: (step) => {
|
onStepDone: (step) => {
|
||||||
liveCompletedSteps = [...liveCompletedSteps, step];
|
liveCompletedSteps = [...liveCompletedSteps, step];
|
||||||
setCompletedSteps((prev) => [...prev, step]);
|
setCompletedSteps((prev) => [...prev, step]);
|
||||||
@@ -517,7 +538,7 @@ export default function EcommerceVideoWorkspace({
|
|||||||
const message = err instanceof Error ? err.message : "策划失败";
|
const message = err instanceof Error ? err.message : "策划失败";
|
||||||
setError(message);
|
setError(message);
|
||||||
// Mark the step that was in-progress as failed so user can resume
|
// Mark the step that was in-progress as failed so user can resume
|
||||||
setFailedStep((prev) => prev || currentStep);
|
setFailedStep((prev) => prev || liveCurrentStep);
|
||||||
setStage("idle");
|
setStage("idle");
|
||||||
// Persist partial progress so the user can resume after a page switch
|
// Persist partial progress so the user can resume after a page switch
|
||||||
persist("idle");
|
persist("idle");
|
||||||
@@ -526,8 +547,8 @@ export default function EcommerceVideoWorkspace({
|
|||||||
|
|
||||||
const handlePlan = async () => {
|
const handlePlan = async () => {
|
||||||
if (!isAuthenticated) { onRequestLogin?.(); return; }
|
if (!isAuthenticated) { onRequestLogin?.(); return; }
|
||||||
if (!productImageDataUrls.length && !requirement.trim()) {
|
if (!productImageDataUrls.length) {
|
||||||
setError("请先上传产品图片或填写商品说明"); return;
|
setError("请先上传商品图片"); return;
|
||||||
}
|
}
|
||||||
await runPlanFlow(null);
|
await runPlanFlow(null);
|
||||||
};
|
};
|
||||||
@@ -542,6 +563,10 @@ export default function EcommerceVideoWorkspace({
|
|||||||
|
|
||||||
const handleGenerateImages = async () => {
|
const handleGenerateImages = async () => {
|
||||||
if (!planResult || !scenes.length) return;
|
if (!planResult || !scenes.length) return;
|
||||||
|
if (!planAllowsVideoGeneration(planResult)) {
|
||||||
|
setError(buildComplianceFailureMessage(planResult.compliance));
|
||||||
|
return;
|
||||||
|
}
|
||||||
setStage("imaging"); setError(null);
|
setStage("imaging"); setError(null);
|
||||||
renderAbortRef.current = { current: false };
|
renderAbortRef.current = { current: false };
|
||||||
const ratio = aspectRatio.includes("9:16") || aspectRatio.includes("9:16") ? "9:16"
|
const ratio = aspectRatio.includes("9:16") || aspectRatio.includes("9:16") ? "9:16"
|
||||||
@@ -555,7 +580,11 @@ export default function EcommerceVideoWorkspace({
|
|||||||
};
|
};
|
||||||
// Only redo scenes missing imageUrl — preserves successfully generated images on partial retry
|
// Only redo scenes missing imageUrl — preserves successfully generated images on partial retry
|
||||||
const scenesToProcess = currentScenes.filter((s) => !s.imageUrl);
|
const scenesToProcess = currentScenes.filter((s) => !s.imageUrl);
|
||||||
if (!scenesToProcess.length) { setStage("imaged"); return; }
|
if (!scenesToProcess.length) {
|
||||||
|
setStage("imaged");
|
||||||
|
saveEcommerceVideoState({ inputFingerprint, stage: "imaged", completedSteps, planResult, scenes: currentScenes, sourceImageUrls });
|
||||||
|
return;
|
||||||
|
}
|
||||||
for (const scene of scenesToProcess) {
|
for (const scene of scenesToProcess) {
|
||||||
if (renderAbortRef.current.current) break;
|
if (renderAbortRef.current.current) break;
|
||||||
persistScenes(currentScenes.map((s) => s.sceneId === scene.sceneId ? { ...s, status: "pending", error: undefined } : s));
|
persistScenes(currentScenes.map((s) => s.sceneId === scene.sceneId ? { ...s, status: "pending", error: undefined } : s));
|
||||||
@@ -597,6 +626,10 @@ export default function EcommerceVideoWorkspace({
|
|||||||
|
|
||||||
const handleRenderVideos = async () => {
|
const handleRenderVideos = async () => {
|
||||||
if (!scenes.length) return;
|
if (!scenes.length) return;
|
||||||
|
if (!planAllowsVideoGeneration(planResult)) {
|
||||||
|
setError(planResult ? buildComplianceFailureMessage(planResult.compliance) : "合规检查未通过,已停止生成。");
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!scenes.some((s) => s.imageUrl)) { setError("请先生成分镜图片"); return; }
|
if (!scenes.some((s) => s.imageUrl)) { setError("请先生成分镜图片"); return; }
|
||||||
setStage("rendering"); setError(null);
|
setStage("rendering"); setError(null);
|
||||||
renderAbortRef.current = { current: false };
|
renderAbortRef.current = { current: false };
|
||||||
@@ -609,7 +642,12 @@ export default function EcommerceVideoWorkspace({
|
|||||||
};
|
};
|
||||||
// Only render scenes that haven't completed yet — preserves successful videos on partial retry
|
// Only render scenes that haven't completed yet — preserves successful videos on partial retry
|
||||||
const scenesToProcess = currentScenes.filter((s) => s.imageUrl && s.status !== "completed");
|
const scenesToProcess = currentScenes.filter((s) => s.imageUrl && s.status !== "completed");
|
||||||
if (!scenesToProcess.length) { setStage(currentScenes.every((s) => s.status === "completed") ? "completed" : "partial_failed"); return; }
|
if (!scenesToProcess.length) {
|
||||||
|
const finalStage = currentScenes.every((s) => s.status === "completed") ? "completed" as const : "partial_failed" as const;
|
||||||
|
setStage(finalStage);
|
||||||
|
saveEcommerceVideoState({ inputFingerprint, stage: finalStage, completedSteps, planResult, scenes: currentScenes, sourceImageUrls });
|
||||||
|
return;
|
||||||
|
}
|
||||||
for (const scene of scenesToProcess) {
|
for (const scene of scenesToProcess) {
|
||||||
if (renderAbortRef.current.current) break;
|
if (renderAbortRef.current.current) break;
|
||||||
if (!scene.imageUrl) continue;
|
if (!scene.imageUrl) continue;
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
import { aiGenerationClient } from "../../api/aiGenerationClient";
|
import { aiGenerationClient } from "../../api/aiGenerationClient";
|
||||||
import { serverRequest } from "../../api/serverConnection";
|
import { serverRequest } from "../../api/serverConnection";
|
||||||
import { waitForTask } from "../../api/taskSubscription";
|
import { waitForTask } from "../../api/taskSubscription";
|
||||||
|
import { ENTERPRISE_DEFAULT_VIDEO_MODEL } from "../../utils/enterpriseVideoPolicy";
|
||||||
import { resolveVideoRequestModel } from "../../utils/resolveVideoModel";
|
import { resolveVideoRequestModel } from "../../utils/resolveVideoModel";
|
||||||
import { ecommerceOssScopes } from "./ecommerceGenerationPersistence";
|
import { ecommerceOssScopes } from "./ecommerceGenerationPersistence";
|
||||||
import { normalizeEcommerceImageMime } from "./ecommerceImageValidation";
|
import { normalizeEcommerceImageMime } from "./ecommerceImageValidation";
|
||||||
@@ -130,6 +131,18 @@ export interface PlanCallbacks {
|
|||||||
|
|
||||||
const LOCAL_PREVIEW_MISSING_FILE_MESSAGE = "Please re-upload the product image before generating the short video.";
|
const LOCAL_PREVIEW_MISSING_FILE_MESSAGE = "Please re-upload the product image before generating the short video.";
|
||||||
|
|
||||||
|
export function buildComplianceFailureMessage(compliance: NonNullable<EcommerceVideoPlanProgress["compliance"]>): string {
|
||||||
|
const issues = compliance.issues
|
||||||
|
.slice(0, 3)
|
||||||
|
.map((issue) => [issue.field, issue.problem, issue.suggestion].filter(Boolean).join(":"))
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(";");
|
||||||
|
|
||||||
|
return issues
|
||||||
|
? `合规检查未通过,已停止生成。${issues}`
|
||||||
|
: "合规检查未通过,已停止生成。请修改商品说明或广告文案后重试。";
|
||||||
|
}
|
||||||
|
|
||||||
function readBlobAsDataUrl(blob: Blob): Promise<string> {
|
function readBlobAsDataUrl(blob: Blob): Promise<string> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const reader = new FileReader();
|
const reader = new FileReader();
|
||||||
@@ -271,6 +284,10 @@ export async function runVideoPlan(
|
|||||||
emit();
|
emit();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (progress.compliance.allow_video_generation === false) {
|
||||||
|
throw new Error(buildComplianceFailureMessage(progress.compliance));
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
imageUrls: progress.imageUrls!,
|
imageUrls: progress.imageUrls!,
|
||||||
imageDescription: progress.imageDescription,
|
imageDescription: progress.imageDescription,
|
||||||
@@ -303,7 +320,6 @@ export async function renderSceneImage(
|
|||||||
abortRef: { current: boolean },
|
abortRef: { current: boolean },
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const { taskId } = await aiGenerationClient.createImageTask({
|
const { taskId } = await aiGenerationClient.createImageTask({
|
||||||
model: "gpt-image-2",
|
|
||||||
prompt: input.prompt,
|
prompt: input.prompt,
|
||||||
ratio: input.aspectRatio,
|
ratio: input.aspectRatio,
|
||||||
quality: "2K",
|
quality: "2K",
|
||||||
@@ -315,7 +331,6 @@ export async function renderSceneImage(
|
|||||||
const resultUrl = await waitForTask(taskId, {
|
const resultUrl = await waitForTask(taskId, {
|
||||||
abortRef,
|
abortRef,
|
||||||
kind: "image",
|
kind: "image",
|
||||||
model: "gpt-image-2",
|
|
||||||
onProgress: (e) => callbacks.onSceneImageProgress(input.sceneId, e.progress),
|
onProgress: (e) => callbacks.onSceneImageProgress(input.sceneId, e.progress),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -351,7 +366,7 @@ export async function renderScene(
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const allReferenceUrls = [...input.productImageUrls, input.imageUrl];
|
const allReferenceUrls = [...input.productImageUrls, input.imageUrl];
|
||||||
const model = resolveVideoRequestModel({
|
const model = resolveVideoRequestModel({
|
||||||
model: input.model || "happyhorse-1.0",
|
model: input.model || ENTERPRISE_DEFAULT_VIDEO_MODEL,
|
||||||
referenceUrls: allReferenceUrls,
|
referenceUrls: allReferenceUrls,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -2,15 +2,6 @@ import React from "react";
|
|||||||
import ReactDOM from "react-dom/client";
|
import ReactDOM from "react-dom/client";
|
||||||
import "./styles/index.css";
|
import "./styles/index.css";
|
||||||
import App from "./App";
|
import App from "./App";
|
||||||
import { reportError } from "./utils/errorReporting";
|
|
||||||
|
|
||||||
window.addEventListener("unhandledrejection", (event) => {
|
|
||||||
reportError(event.reason, "rejection");
|
|
||||||
});
|
|
||||||
|
|
||||||
window.addEventListener("error", (event) => {
|
|
||||||
if (event.error) reportError(event.error, "unhandled");
|
|
||||||
});
|
|
||||||
|
|
||||||
const root = document.getElementById("root");
|
const root = document.getElementById("root");
|
||||||
|
|
||||||
|
|||||||
@@ -24,17 +24,33 @@ interface PersistedQueueSnapshot {
|
|||||||
savedAt: number;
|
savedAt: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const STORAGE_KEY = "omniai:generation-queue";
|
const STORAGE_KEY_PREFIX = "omniai:generation-queue";
|
||||||
const MAX_ITEMS = 80;
|
const MAX_ITEMS = 80;
|
||||||
const STALE_MS = 2 * 60 * 60 * 1000; // 2 hours
|
const STALE_MS = 2 * 60 * 60 * 1000; // 2 hours
|
||||||
|
|
||||||
|
function hashUserId(): string {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem("omniai-web-session");
|
||||||
|
if (!raw) return "anon";
|
||||||
|
const parsed = JSON.parse(raw) as { user?: { id?: number | string } };
|
||||||
|
return String(parsed?.user?.id || "anon");
|
||||||
|
} catch {
|
||||||
|
return "anon";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 队列按用户分桶持久化:不同账号读写不同 key,避免登出再登他人账号时读到上一个用户的队列。
|
||||||
|
function getStorageKey(): string {
|
||||||
|
return `${STORAGE_KEY_PREFIX}:${hashUserId()}`;
|
||||||
|
}
|
||||||
|
|
||||||
function loadPersistedQueue(): GenerationQueueItem[] {
|
function loadPersistedQueue(): GenerationQueueItem[] {
|
||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem(STORAGE_KEY);
|
const raw = localStorage.getItem(getStorageKey());
|
||||||
if (!raw) return [];
|
if (!raw) return [];
|
||||||
const snapshot = JSON.parse(raw) as PersistedQueueSnapshot;
|
const snapshot = JSON.parse(raw) as PersistedQueueSnapshot;
|
||||||
if (Date.now() - (snapshot.savedAt || 0) > STALE_MS) {
|
if (Date.now() - (snapshot.savedAt || 0) > STALE_MS) {
|
||||||
localStorage.removeItem(STORAGE_KEY);
|
localStorage.removeItem(getStorageKey());
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
return snapshot.items.filter(
|
return snapshot.items.filter(
|
||||||
@@ -48,7 +64,7 @@ function loadPersistedQueue(): GenerationQueueItem[] {
|
|||||||
function persistQueue(items: GenerationQueueItem[]): void {
|
function persistQueue(items: GenerationQueueItem[]): void {
|
||||||
try {
|
try {
|
||||||
const snapshot: PersistedQueueSnapshot = { version: 1, items, savedAt: Date.now() };
|
const snapshot: PersistedQueueSnapshot = { version: 1, items, savedAt: Date.now() };
|
||||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(snapshot));
|
localStorage.setItem(getStorageKey(), JSON.stringify(snapshot));
|
||||||
} catch { /* quota exceeded */ }
|
} catch { /* quota exceeded */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,17 +79,6 @@ interface GenerationStoreState {
|
|||||||
clearTerminal: () => void;
|
clearTerminal: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function hashUserId(): string {
|
|
||||||
try {
|
|
||||||
const raw = localStorage.getItem("omniai-web-session");
|
|
||||||
if (!raw) return "anon";
|
|
||||||
const parsed = JSON.parse(raw) as { user?: { id?: number | string } };
|
|
||||||
return String(parsed?.user?.id || "anon");
|
|
||||||
} catch {
|
|
||||||
return "anon";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const initialQueue = loadPersistedQueue();
|
const initialQueue = loadPersistedQueue();
|
||||||
|
|
||||||
export const useGenerationStore = create<GenerationStoreState>((set, get) => ({
|
export const useGenerationStore = create<GenerationStoreState>((set, get) => ({
|
||||||
|
|||||||
+4971
-88
File diff suppressed because it is too large
Load Diff
@@ -2930,12 +2930,68 @@
|
|||||||
height: auto;
|
height: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.product-clone-page[data-tool="clone"] .clone-ai-canvas-node .clone-ai-main-result {
|
||||||
|
width: 150px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-clone-page[data-tool="clone"] .clone-ai-source-stack {
|
||||||
|
position: relative;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
.product-clone-page[data-tool="clone"] .clone-ai-result-grid {
|
.product-clone-page[data-tool="clone"] .clone-ai-result-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
gap: 14px;
|
gap: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.product-clone-page[data-tool="clone"] .clone-ai-result-stack {
|
||||||
|
position: relative;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-clone-page[data-tool="clone"] .clone-ai-result-stack > .clone-ai-node-label {
|
||||||
|
position: absolute;
|
||||||
|
top: -6px;
|
||||||
|
left: 50%;
|
||||||
|
z-index: 5;
|
||||||
|
transform: translate(-50%, -100%);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-clone-page[data-tool="clone"] .clone-ai-source-corner-action {
|
||||||
|
position: absolute;
|
||||||
|
top: -6px;
|
||||||
|
left: 50%;
|
||||||
|
z-index: 5;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 28px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
border: 1px solid rgba(0, 255, 136, 0.35);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(21, 23, 28, 0.92);
|
||||||
|
box-shadow: 0 8px 22px rgba(0, 0, 0, 0.28);
|
||||||
|
color: #d8deed;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 900;
|
||||||
|
line-height: 1;
|
||||||
|
white-space: nowrap;
|
||||||
|
cursor: pointer;
|
||||||
|
transform: translate(-50%, -100%);
|
||||||
|
transition:
|
||||||
|
border-color 200ms ease,
|
||||||
|
transform 200ms ease,
|
||||||
|
box-shadow 200ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-clone-page[data-tool="clone"] .clone-ai-source-corner-action:hover {
|
||||||
|
border-color: #00ff88;
|
||||||
|
transform: translate(-50%, -106%);
|
||||||
|
box-shadow: 0 10px 26px rgba(0, 255, 136, 0.14), 0 8px 22px rgba(0, 0, 0, 0.28);
|
||||||
|
}
|
||||||
|
|
||||||
.product-clone-page[data-tool="clone"] .clone-ai-result-grid button {
|
.product-clone-page[data-tool="clone"] .clone-ai-result-grid button {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: auto;
|
height: auto;
|
||||||
@@ -12046,3 +12102,110 @@ html body #root .ecommerce-standalone.ecommerce-standalone .product-clone-page[d
|
|||||||
grid-row: auto !important;
|
grid-row: auto !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Composer menu anchors: place option popovers under the clicked control, not under the whole composer. */
|
||||||
|
html body #root .ecommerce-standalone.ecommerce-standalone .product-clone-page[data-tool="clone"][data-tool="clone"] .clone-ai-input-wrapper.ecom-command-composer > .ecom-command-popover.ecom-command-popover {
|
||||||
|
position: absolute !important;
|
||||||
|
inset: var(--composer-popover-top, 48px) auto auto var(--composer-popover-left, 0px) !important;
|
||||||
|
right: auto !important;
|
||||||
|
bottom: auto !important;
|
||||||
|
margin: 0 !important;
|
||||||
|
transform: none !important;
|
||||||
|
translate: none !important;
|
||||||
|
z-index: 160 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
html body #root .ecommerce-standalone.ecommerce-standalone .product-clone-page[data-tool="clone"][data-tool="clone"] .clone-ai-input-wrapper.ecom-command-composer > .ecom-command-popover.ecom-command-popover--platform {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr)) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 平台弹窗宽度仅桌面/平板固定;≤640px 由移动端断点的全宽规则接管。 */
|
||||||
|
@media (min-width: 641px) {
|
||||||
|
html body #root .ecommerce-standalone.ecommerce-standalone .product-clone-page[data-tool="clone"][data-tool="clone"] .clone-ai-input-wrapper.ecom-command-composer > .ecom-command-popover.ecom-command-popover--platform {
|
||||||
|
width: min(460px, calc(100% - 24px)) !important;
|
||||||
|
max-width: min(460px, calc(100% - 24px)) !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 平台选项:logo + 名称横排,名称过长省略,避免在窄网格里溢出弹窗。 */
|
||||||
|
html body #root .ecommerce-standalone.ecommerce-standalone .product-clone-page[data-tool="clone"][data-tool="clone"] .ecom-command-popover--platform button {
|
||||||
|
display: flex !important;
|
||||||
|
align-items: center !important;
|
||||||
|
justify-content: flex-start !important;
|
||||||
|
gap: 8px !important;
|
||||||
|
min-width: 0 !important;
|
||||||
|
text-align: left !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
html body #root .ecommerce-standalone.ecommerce-standalone .product-clone-page[data-tool="clone"][data-tool="clone"] .ecom-command-popover--platform button .ecom-platform-name {
|
||||||
|
min-width: 0 !important;
|
||||||
|
overflow: hidden !important;
|
||||||
|
text-overflow: ellipsis !important;
|
||||||
|
white-space: nowrap !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 641px) {
|
||||||
|
html body #root .ecommerce-standalone.ecommerce-standalone .product-clone-page[data-tool="clone"][data-tool="clone"] .clone-ai-input-wrapper.ecom-command-composer > .ecom-command-popover.ecom-command-popover--languages,
|
||||||
|
html body #root .ecommerce-standalone.ecommerce-standalone .product-clone-page[data-tool="clone"][data-tool="clone"] .clone-ai-input-wrapper.ecom-command-composer > .ecom-command-popover.ecom-command-popover--ratio-picker {
|
||||||
|
width: max-content !important;
|
||||||
|
min-width: 200px !important;
|
||||||
|
max-width: min(420px, calc(100% - var(--composer-popover-left, 0px))) !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 宽设置面板:固定宽度并靠右对齐 composer,避免从靠右的"设置"按钮左对齐展开时顶出右边缘被裁。
|
||||||
|
仅桌面/平板生效;≤640px 由移动端断点的全宽规则接管。 */
|
||||||
|
@media (min-width: 641px) {
|
||||||
|
html body #root .ecommerce-standalone.ecommerce-standalone .product-clone-page[data-tool="clone"][data-tool="clone"] .clone-ai-input-wrapper.ecom-command-composer > .ecom-command-popover.ecom-command-popover--settings {
|
||||||
|
width: min(520px, calc(100% - 24px)) !important;
|
||||||
|
max-width: min(520px, calc(100% - 24px)) !important;
|
||||||
|
left: auto !important;
|
||||||
|
inset: var(--composer-popover-top, 48px) 12px auto auto !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Uploaded assets stay as compact attachments inside the composer hierarchy. */
|
||||||
|
html body #root .ecommerce-standalone.ecommerce-standalone .product-clone-page[data-tool="clone"][data-tool="clone"] .clone-ai-input-wrapper.ecom-command-composer:has(.ecom-command-asset-popover) {
|
||||||
|
min-height: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
html body #root .ecommerce-standalone.ecommerce-standalone .product-clone-page[data-tool="clone"][data-tool="clone"] .ecom-command-asset-popover,
|
||||||
|
html body #root .ecommerce-standalone.ecommerce-standalone .product-clone-page[data-tool="clone"][data-tool="clone"] .ecom-command-composer-wrap:not(.has-generated.is-compact) .clone-ai-input-wrapper.ecom-command-composer:has(.ecom-command-asset-popover) .ecom-command-asset-popover {
|
||||||
|
position: static !important;
|
||||||
|
grid-column: 1 !important;
|
||||||
|
display: flex !important;
|
||||||
|
flex-wrap: wrap !important;
|
||||||
|
align-items: center !important;
|
||||||
|
justify-content: flex-start !important;
|
||||||
|
justify-self: start !important;
|
||||||
|
gap: 10px !important;
|
||||||
|
width: auto !important;
|
||||||
|
max-width: 100% !important;
|
||||||
|
min-height: 0 !important;
|
||||||
|
max-height: none !important;
|
||||||
|
padding: 2px 2px 0 !important;
|
||||||
|
overflow: visible !important;
|
||||||
|
border: 0 !important;
|
||||||
|
border-radius: 0 !important;
|
||||||
|
background: transparent !important;
|
||||||
|
box-shadow: none !important;
|
||||||
|
transform: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
html body #root .ecommerce-standalone.ecommerce-standalone .product-clone-page[data-tool="clone"][data-tool="clone"] .ecom-command-asset-thumb,
|
||||||
|
html body #root .ecommerce-standalone.ecommerce-standalone .product-clone-page[data-tool="clone"][data-tool="clone"] .ecom-command-composer-wrap:not(.has-generated.is-compact) .clone-ai-input-wrapper.ecom-command-composer:has(.ecom-command-asset-popover) .ecom-command-asset-thumb {
|
||||||
|
flex: 0 0 64px !important;
|
||||||
|
width: 64px !important;
|
||||||
|
height: 64px !important;
|
||||||
|
border-radius: 14px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
html body #root .ecommerce-standalone.ecommerce-standalone .product-clone-page[data-tool="clone"][data-tool="clone"] .ecom-command-asset-add,
|
||||||
|
html body #root .ecommerce-standalone.ecommerce-standalone .product-clone-page[data-tool="clone"][data-tool="clone"] .ecom-command-composer-wrap:not(.has-generated.is-compact) .clone-ai-input-wrapper.ecom-command-composer:has(.ecom-command-asset-popover) .ecom-command-asset-add {
|
||||||
|
flex: 0 0 44px !important;
|
||||||
|
width: 44px !important;
|
||||||
|
height: 64px !important;
|
||||||
|
min-height: 44px !important;
|
||||||
|
margin: 0 !important;
|
||||||
|
font-size: 24px !important;
|
||||||
|
}
|
||||||
|
|||||||
@@ -15,7 +15,11 @@ let flushTimer: ReturnType<typeof setTimeout> | null = null;
|
|||||||
|
|
||||||
function getSessionId(): string | undefined {
|
function getSessionId(): string | undefined {
|
||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem("omniai:session") || sessionStorage.getItem("omniai:session");
|
const raw =
|
||||||
|
localStorage.getItem("omniai-web-session") ||
|
||||||
|
sessionStorage.getItem("omniai-web-session") ||
|
||||||
|
localStorage.getItem("omniai:session") ||
|
||||||
|
sessionStorage.getItem("omniai:session");
|
||||||
if (!raw) return undefined;
|
if (!raw) return undefined;
|
||||||
const parsed = JSON.parse(raw);
|
const parsed = JSON.parse(raw);
|
||||||
return parsed?.user?.sessionId ?? undefined;
|
return parsed?.user?.sessionId ?? undefined;
|
||||||
|
|||||||
+18
-16
@@ -2,8 +2,22 @@ import react from "@vitejs/plugin-react";
|
|||||||
import { compression } from "vite-plugin-compression2";
|
import { compression } from "vite-plugin-compression2";
|
||||||
import { defineConfig } from "vite";
|
import { defineConfig } from "vite";
|
||||||
|
|
||||||
export default defineConfig(() => {
|
export default defineConfig(({ command }) => {
|
||||||
const devApiTarget = process.env.OMNIAI_DEV_API_TARGET;
|
// dev 模式下默认把 /api 代理到线上电商后端,本地 `npm run dev` 即可直接登录/生成。
|
||||||
|
// 想连本地或 SSH 隧道的后端时,用环境变量覆盖:
|
||||||
|
// $env:OMNIAI_DEV_API_TARGET="http://127.0.0.1:3601"; npm run dev
|
||||||
|
// 仅 dev 代理用途,不会打进生产构建产物。
|
||||||
|
const devApiTarget =
|
||||||
|
process.env.OMNIAI_DEV_API_TARGET?.trim() ||
|
||||||
|
(command === "serve" ? "https://omniai.com.cn" : "");
|
||||||
|
const apiProxy = devApiTarget
|
||||||
|
? {
|
||||||
|
"/api": {
|
||||||
|
target: devApiTarget,
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: undefined;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
plugins: [
|
plugins: [
|
||||||
@@ -13,25 +27,13 @@ export default defineConfig(() => {
|
|||||||
server: {
|
server: {
|
||||||
port: 5173,
|
port: 5173,
|
||||||
host: "127.0.0.1",
|
host: "127.0.0.1",
|
||||||
proxy: devApiTarget ? {
|
...(apiProxy ? { proxy: apiProxy } : {}),
|
||||||
"/api": {
|
|
||||||
target: devApiTarget,
|
|
||||||
changeOrigin: true,
|
|
||||||
},
|
|
||||||
} : {
|
|
||||||
"/api": {
|
|
||||||
target: "http://47.110.225.76:3601",
|
|
||||||
changeOrigin: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
preview: {
|
preview: {
|
||||||
port: 4174,
|
port: 4174,
|
||||||
host: "127.0.0.1",
|
host: "127.0.0.1",
|
||||||
},
|
},
|
||||||
esbuild: {
|
...(command === "build" ? { esbuild: { drop: ["console", "debugger"] } } : {}),
|
||||||
drop: ["console", "debugger"],
|
|
||||||
},
|
|
||||||
build: {
|
build: {
|
||||||
sourcemap: false,
|
sourcemap: false,
|
||||||
rollupOptions: {
|
rollupOptions: {
|
||||||
|
|||||||
Reference in New Issue
Block a user