diff --git a/server-patches/patch-email-verification.js b/server-patches/patch-email-verification.js
new file mode 100644
index 0000000..bfc1967
--- /dev/null
+++ b/server-patches/patch-email-verification.js
@@ -0,0 +1,291 @@
+const fs = require("fs");
+
+// ── Patch 1: context.js ──────────────────────────────────────
+const ctxPath = "/opt/omniai-server/src/routes/context.js";
+let ctx = fs.readFileSync(ctxPath, "utf8");
+
+const smsMaxLine = "const SMS_CODE_MAX_ATTEMPTS = Math.max(1, Number(process.env.SMS_CODE_MAX_ATTEMPTS) || 5);";
+const emailConsts = `
+const EMAIL_PURPOSES = new Set(["register", "login", "reset"]);
+const EMAIL_CODE_TTL_MINUTES = Math.max(1, Number(process.env.EMAIL_CODE_TTL_MINUTES) || 10);
+const EMAIL_CODE_COOLDOWN_SECONDS = Math.max(10, Number(process.env.EMAIL_CODE_COOLDOWN_SECONDS) || 60);
+const EMAIL_CODE_MAX_ATTEMPTS = Math.max(1, Number(process.env.EMAIL_CODE_MAX_ATTEMPTS) || 5);`;
+
+if (!ctx.includes("EMAIL_PURPOSES")) {
+ ctx = ctx.replace(smsMaxLine, smsMaxLine + emailConsts);
+ console.log("[ctx] added EMAIL_PURPOSES");
+}
+
+const afterConsume = ' await pool.query("UPDATE sms_verification_codes SET consumed_at = NOW() WHERE id = $1", [row.id]);\n return true;\n}';
+const emailFuncs = `
+function hashEmailCode(email, code) {
+ const secret = process.env.EMAIL_CODE_SECRET || process.env.JWT_SECRET || "omniai-dev-email-secret";
+ return crypto.createHash("sha256").update(email + ":" + code + ":" + secret).digest("hex");
+}
+
+async function sendEmailCode(email, code, purpose) {
+ const provider = String(process.env.EMAIL_PROVIDER || "mock").trim().toLowerCase();
+
+ if (provider === "smtp") {
+ const nodemailer = require("nodemailer");
+ const transporter = nodemailer.createTransport({
+ host: process.env.SMTP_HOST,
+ port: Number(process.env.SMTP_PORT) || 587,
+ secure: process.env.SMTP_SECURE === "1",
+ auth: { user: process.env.SMTP_USER, pass: process.env.SMTP_PASS },
+ });
+
+ const purposeText = purpose === "register" ? "\u6ce8\u518c" : purpose === "login" ? "\u767b\u5f55" : "\u91cd\u7f6e\u5bc6\u7801";
+ await transporter.sendMail({
+ from: process.env.SMTP_FROM || process.env.SMTP_USER,
+ to: email,
+ subject: "[OmniAI] \u90ae\u7bb1\u9a8c\u8bc1\u7801",
+ text: "\u60a8\u7684\u9a8c\u8bc1\u7801\u662f\uff1a" + code + "\n\u7528\u9014\uff1a" + purposeText + "\n\u6709\u6548\u671f\uff1a" + String(process.env.EMAIL_CODE_TTL_MINUTES || 10) + " \u5206\u949f\n\u5982\u679c\u4e0d\u662f\u60a8\u672c\u4eba\u64cd\u4f5c\uff0c\u8bf7\u5ffd\u7565\u6b64\u90ae\u4ef6\u3002",
+ html: '
OmniAI \u90ae\u7bb1\u9a8c\u8bc1
\u60a8\u7684\u9a8c\u8bc1\u7801\u662f\uff1a
' + code + '
\u7528\u9014\uff1a' + purposeText + '
\u6709\u6548\u671f\uff1a' + String(process.env.EMAIL_CODE_TTL_MINUTES || 10) + ' \u5206\u949f
\u5982\u679c\u4e0d\u662f\u60a8\u672c\u4eba\u64cd\u4f5c\uff0c\u8bf7\u5ffd\u7565\u6b64\u90ae\u4ef6\u3002
',
+ });
+ return { provider: "smtp" };
+ }
+
+ console.log("[email:" + purpose + "] " + email + " verification code: " + code + " (mock provider)");
+ return { provider: "mock", devCode: process.env.EMAIL_DEV_RETURN_CODE === "1" ? code : undefined };
+}
+
+async function consumeEmailCode(email, code, purpose) {
+ const { rows } = await pool.query(
+ "SELECT id, code_hash, attempts FROM email_verification_codes WHERE email = $1 AND purpose = $2 AND consumed_at IS NULL AND expires_at > NOW() ORDER BY created_at DESC LIMIT 1",
+ [email, purpose]
+ );
+ const row = rows[0];
+ if (!row) return false;
+ if (Number(row.attempts || 0) >= EMAIL_CODE_MAX_ATTEMPTS) return false;
+
+ const expectedHash = hashEmailCode(email, String(code || "").trim());
+ if (row.code_hash !== expectedHash) {
+ await pool.query("UPDATE email_verification_codes SET attempts = attempts + 1 WHERE id = $1", [row.id]);
+ return false;
+ }
+ await pool.query("UPDATE email_verification_codes SET consumed_at = NOW() WHERE id = $1", [row.id]);
+ return true;
+}`;
+
+if (!ctx.includes("hashEmailCode")) {
+ ctx = ctx.replace(afterConsume, afterConsume + emailFuncs);
+ console.log("[ctx] added email functions");
+}
+
+// Update exports
+if (!ctx.includes("EMAIL_PURPOSES,")) {
+ ctx = ctx.replace(" EMAIL_PATTERN,\n SMS_PURPOSES,", " EMAIL_PATTERN,\n EMAIL_PURPOSES,\n EMAIL_CODE_TTL_MINUTES,\n EMAIL_CODE_COOLDOWN_SECONDS,\n EMAIL_CODE_MAX_ATTEMPTS,\n SMS_PURPOSES,");
+}
+if (!ctx.includes("hashEmailCode,")) {
+ ctx = ctx.replace(" sendSmsCode,\n createLoginResultForUserId,", " sendSmsCode,\n hashEmailCode,\n sendEmailCode,\n consumeEmailCode,\n createLoginResultForUserId,");
+}
+
+fs.writeFileSync(ctxPath, ctx, "utf8");
+console.log("[ctx] written");
+
+// ── Patch 2: auth.js ─────────────────────────────────────────
+const authPath = "/opt/omniai-server/src/routes/auth.js";
+let auth = fs.readFileSync(authPath, "utf8");
+
+// 2a. Add imports inside context.js destructuring
+if (!auth.includes("hashEmailCode,")) {
+ auth = auth.replace(
+ '} = require("./context");',
+ ' EMAIL_PURPOSES,\n EMAIL_CODE_TTL_MINUTES,\n EMAIL_CODE_COOLDOWN_SECONDS,\n EMAIL_CODE_MAX_ATTEMPTS,\n hashEmailCode,\n sendEmailCode,\n consumeEmailCode,\n} = require("./context");'
+ );
+ console.log("[auth] added imports");
+}
+
+// 2b. Insert new routes before module.exports
+const newRoutes = `
+ // ============================================================
+ // Email verification routes
+ // ============================================================
+
+ router.post("/auth/email/send-code", async (req, res) => {
+ const email = normalizeEmail(req.body?.email);
+ const purpose = String(req.body?.purpose || "register");
+ const emailError = validateEmail(email);
+ if (emailError) return res.status(400).json({ error: emailError });
+ if (!EMAIL_PURPOSES.has(purpose)) return res.status(400).json({ error: "\u9a8c\u8bc1\u7801\u7528\u9014\u65e0\u6548" });
+
+ if (purpose === "register") {
+ const inviteOk = await ensureBetaInviteCode(req, res);
+ if (!inviteOk) return;
+ }
+
+ try {
+ const { rows: recentCodes } = await pool.query(
+ "SELECT created_at FROM email_verification_codes WHERE email = $1 AND purpose = $2 AND created_at > NOW() - ($3::text || ' seconds')::interval ORDER BY created_at DESC LIMIT 1",
+ [email, purpose, EMAIL_CODE_COOLDOWN_SECONDS]
+ );
+ if (recentCodes.length > 0) {
+ return res.status(429).json({ error: "\u9a8c\u8bc1\u7801\u53d1\u9001\u592a\u9891\u7e41\uff0c\u8bf7 " + EMAIL_CODE_COOLDOWN_SECONDS + " \u79d2\u540e\u518d\u8bd5" });
+ }
+
+ if (purpose === "register") {
+ const { rows: existing } = await pool.query("SELECT id FROM users WHERE LOWER(email) = LOWER($1) LIMIT 1", [email]);
+ if (existing.length > 0) return res.status(409).json({ error: "\u8be5\u90ae\u7bb1\u5df2\u6ce8\u518c" });
+ }
+
+ if (purpose === "login" || purpose === "reset") {
+ const { rows: existing } = await pool.query("SELECT id FROM users WHERE LOWER(email) = LOWER($1) AND enabled = 1 LIMIT 1", [email]);
+ if (existing.length === 0) return res.status(404).json({ error: "\u8be5\u90ae\u7bb1\u5c1a\u672a\u6ce8\u518c" });
+ }
+
+ const code = generateSmsCode();
+ const codeHash = hashEmailCode(email, code);
+ await pool.query(
+ "INSERT INTO email_verification_codes (email, purpose, code_hash, expires_at) VALUES ($1, $2, $3, NOW() + ($4::text || ' minutes')::interval)",
+ [email, purpose, codeHash, EMAIL_CODE_TTL_MINUTES]
+ );
+
+ const sendResult = await sendEmailCode(email, code, purpose);
+ res.json({
+ success: true,
+ provider: sendResult.provider,
+ ttlSeconds: EMAIL_CODE_TTL_MINUTES * 60,
+ cooldownSeconds: EMAIL_CODE_COOLDOWN_SECONDS,
+ ...(sendResult.devCode ? { devCode: sendResult.devCode } : {}),
+ });
+ } catch (error) {
+ console.error("[auth/email/send-code] failed", error);
+ res.status(500).json({ error: "\u9a8c\u8bc1\u7801\u53d1\u9001\u5931\u8d25" });
+ }
+ });
+
+ router.post("/auth/email/verify", async (req, res) => {
+ const email = normalizeEmail(req.body?.email);
+ const code = String(req.body?.code || "").trim();
+ const purpose = String(req.body?.purpose || "register");
+ const emailError = validateEmail(email);
+ if (emailError) return res.status(400).json({ error: emailError });
+ if (!code) return res.status(400).json({ error: "\u7f3a\u5c11\u9a8c\u8bc1\u7801" });
+ if (!EMAIL_PURPOSES.has(purpose)) return res.status(400).json({ error: "\u9a8c\u8bc1\u7801\u7528\u9014\u65e0\u6548" });
+
+ try {
+ const verified = await consumeEmailCode(email, code, purpose);
+ if (!verified) return res.status(400).json({ error: "\u9a8c\u8bc1\u7801\u9519\u8bef\u6216\u5df2\u8fc7\u671f" });
+ if (purpose === "register" || purpose === "login") {
+ await pool.query("UPDATE users SET email_verified = 1 WHERE LOWER(email) = LOWER($1)", [email]);
+ }
+ res.json({ success: true });
+ } catch (error) {
+ console.error("[auth/email/verify] failed", error);
+ res.status(500).json({ error: "\u9a8c\u8bc1\u5931\u8d25" });
+ }
+ });
+
+ router.post("/auth/forgot-password", async (req, res) => {
+ const email = normalizeEmail(req.body?.email);
+ const emailError = validateEmail(email);
+ if (emailError) return res.status(400).json({ error: emailError });
+
+ try {
+ const { rows } = await pool.query("SELECT id FROM users WHERE LOWER(email) = LOWER($1) AND enabled = 1 LIMIT 1", [email]);
+ if (rows.length === 0) {
+ return res.json({ success: true, message: "\u5982\u679c\u8be5\u90ae\u7bb1\u5df2\u6ce8\u518c\uff0c\u91cd\u7f6e\u94fe\u63a5\u5df2\u53d1\u9001" });
+ }
+
+ const { rows: recentCodes } = await pool.query(
+ "SELECT created_at FROM email_verification_codes WHERE email = $1 AND purpose = 'reset' AND created_at > NOW() - ($2::text || ' seconds')::interval ORDER BY created_at DESC LIMIT 1",
+ [email, EMAIL_CODE_COOLDOWN_SECONDS]
+ );
+ if (recentCodes.length > 0) {
+ return res.status(429).json({ error: "\u53d1\u9001\u592a\u9891\u7e41\uff0c\u8bf7 " + EMAIL_CODE_COOLDOWN_SECONDS + " \u79d2\u540e\u518d\u8bd5" });
+ }
+
+ const code = generateSmsCode();
+ const codeHash = hashEmailCode(email, code);
+ await pool.query(
+ "INSERT INTO email_verification_codes (email, purpose, code_hash, expires_at) VALUES ($1, 'reset', $2, NOW() + ($3::text || ' minutes')::interval)",
+ [email, codeHash, EMAIL_CODE_TTL_MINUTES]
+ );
+ await sendEmailCode(email, code, "reset");
+ res.json({ success: true, message: "\u91cd\u7f6e\u9a8c\u8bc1\u7801\u5df2\u53d1\u9001\u5230\u60a8\u7684\u90ae\u7bb1" });
+ } catch (error) {
+ console.error("[auth/forgot-password] failed", error);
+ res.status(500).json({ error: "\u53d1\u9001\u5931\u8d25" });
+ }
+ });
+
+ router.post("/auth/reset-password", async (req, res) => {
+ const email = normalizeEmail(req.body?.email);
+ const code = String(req.body?.code || "").trim();
+ const newPassword = String(req.body?.newPassword || "");
+ const emailError = validateEmail(email);
+ if (emailError) return res.status(400).json({ error: emailError });
+ if (!code) return res.status(400).json({ error: "\u7f3a\u5c11\u9a8c\u8bc1\u7801" });
+ const passwordError = validatePassword(newPassword);
+ if (passwordError) return res.status(400).json({ error: passwordError });
+
+ try {
+ const verified = await consumeEmailCode(email, code, "reset");
+ if (!verified) return res.status(400).json({ error: "\u9a8c\u8bc1\u7801\u9519\u8bef\u6216\u5df2\u8fc7\u671f" });
+ const hash = await bcrypt.hash(newPassword, 10);
+ await pool.query("UPDATE users SET password_hash = $1 WHERE LOWER(email) = LOWER($2)", [hash, email]);
+ res.json({ success: true, message: "\u5bc6\u7801\u91cd\u7f6e\u6210\u529f\uff0c\u8bf7\u91cd\u65b0\u767b\u5f55" });
+ } catch (error) {
+ console.error("[auth/reset-password] failed", error);
+ res.status(500).json({ error: "\u5bc6\u7801\u91cd\u7f6e\u5931\u8d25" });
+ }
+ });
+
+`;
+
+if (!auth.includes("/auth/email/send-code")) {
+ const endMarker = "\n}\n\nmodule.exports = {";
+ auth = auth.replace(endMarker, "\n" + newRoutes + "}\n\nmodule.exports = {");
+ console.log("[auth] added new routes");
+}
+
+// 2c. Update register-email to require verification code
+// Replace: router.post("/auth/register-email" ... without code check
+// With: router.post("/auth/register-email" ... with code verification
+
+const oldRegisterEmail = ` router.post("/auth/register-email", async (req, res) => {
+ const email = normalizeEmail(req.body?.email);
+ const usernameInput = String(req.body?.username || "").trim();
+ const password = String(req.body?.password || "");
+
+ const emailError = validateEmail(email);
+ if (emailError) return res.status(400).json({ error: emailError });
+ const passwordError = validatePassword(password);
+ if (passwordError) return res.status(400).json({ error: passwordError });
+ const registrationInvite = await ensureRegistrationInvite(req, res);
+ if (!registrationInvite) return;
+
+ try {
+ const { rows: existingEmail }`;
+
+const newRegisterEmail = ` router.post("/auth/register-email", async (req, res) => {
+ const email = normalizeEmail(req.body?.email);
+ const usernameInput = String(req.body?.username || "").trim();
+ const password = String(req.body?.password || "");
+ const code = String(req.body?.code || "").trim();
+
+ const emailError = validateEmail(email);
+ if (emailError) return res.status(400).json({ error: emailError });
+ if (!code) return res.status(400).json({ error: "\u7f3a\u5c11\u9a8c\u8bc1\u7801" });
+ const passwordError = validatePassword(password);
+ if (passwordError) return res.status(400).json({ error: passwordError });
+ const registrationInvite = await ensureRegistrationInvite(req, res);
+ if (!registrationInvite) return;
+
+ try {
+ const verified = await consumeEmailCode(email, code, "register");
+ if (!verified) return res.status(400).json({ error: "\u9a8c\u8bc1\u7801\u9519\u8bef\u6216\u5df2\u8fc7\u671f" });
+
+ const { rows: existingEmail }`;
+
+if (auth.includes(oldRegisterEmail)) {
+ auth = auth.replace(oldRegisterEmail, newRegisterEmail);
+ console.log("[auth] updated register-email with verification");
+} else {
+ console.log("[auth] WARNING: register-email pattern not found, skipping");
+}
+
+fs.writeFileSync(authPath, auth, "utf8");
+console.log("[auth] written");
+console.log("\nDone.");
diff --git a/src/App.tsx b/src/App.tsx
index 353238b..062e150 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -20,6 +20,7 @@ import { reportError } from "./utils/errorReporting";
import { initNotificationPermission } from "./utils/generationNotifier";
import PageTransition from "./components/PageTransition";
import ToastContainer from "./components/toast/ToastContainer";
+import { toast } from "./components/toast/toastStore";
import { aiGenerationClient } from "./api/aiGenerationClient";
import { keyServerClient } from "./api/keyServerClient";
import { notificationClient } from "./api/notificationClient";
@@ -32,8 +33,10 @@ import {
} from "./api/serverConnection";
import { webGenerationGateway, type CreatePreviewTaskInput } from "./api/webGenerationGateway";
import { translateTaskError } from "./utils/translateTaskError";
+import { recoverAndResumeTasks } from "./services/backgroundTaskRunner";
import AppShell from "./components/AppShell";
const NotFoundPage = lazy(() => import("./components/NotFoundPage"));
+const CompliancePage = lazy(() => import("./features/compliance/CompliancePage"));
import { cloneWorkflow, createBlankWorkflow } from "./data/workflows";
const AgentPage = lazy(() => import("./features/agent/AgentPage"));
const AssetsPage = lazy(() => import("./features/assets/AssetsPage"));
@@ -56,7 +59,6 @@ const WatermarkRemovalPage = lazy(() => import("./features/watermark-removal/Wat
const SubtitleRemovalPage = lazy(() => import("./features/subtitle-removal/SubtitleRemovalPage"));
const ScriptTokensPage = lazy(() => import("./features/script-tokens/ScriptTokensPage"));
const TokenUsagePage = lazy(() => import("./features/script-tokens/TokenUsagePage"));
-const SettingsPage = lazy(() => import("./features/settings/SettingsPage"));
const WorkbenchPage = lazy(() => import("./features/workbench/WorkbenchPage"));
import type { WorkbenchResultActionPayload } from "./features/workbench/WorkbenchPage";
import {
@@ -103,7 +105,6 @@ const VIEW_KEYS = new Set([
"ecommerce",
"scriptTokens",
"tokenUsage",
- "settings",
"imageWorkbench",
"resolutionUpscale",
"watermarkRemoval",
@@ -116,17 +117,23 @@ const VIEW_KEYS = new Set([
"communityCaseAdd",
"report",
"providerHealth",
+ "userAgreement",
+ "privacyPolicy",
"not-found",
]);
-const PUBLIC_VIEW_SET = new Set(["home", "login", "community", "more", "not-found"]);
+const PUBLIC_VIEW_SET = new Set(["home", "login", "community", "more", "userAgreement", "privacyPolicy", "not-found"]);
function normalizeViewKey(rawView: string): WebViewKey {
const normalized =
rawView === "profile" || rawView === "auth"
? "login"
- : rawView === "ecommerceHub"
- ? "ecommerce"
+ : rawView === "ecommerceHub"
+ ? "ecommerce"
+ : rawView === "terms" || rawView === "agreement" || rawView === "user-agreement"
+ ? "userAgreement"
+ : rawView === "privacy" || rawView === "privacy-policy"
+ ? "privacyPolicy"
: rawView === "community-review"
? "communityReview"
: rawView === "community-case-add"
@@ -321,6 +328,11 @@ function App() {
}
}, []); // eslint-disable-line react-hooks/exhaustive-deps
+ // ── Recover background tasks on app start ──────────
+ useEffect(() => {
+ recoverAndResumeTasks();
+ }, []);
+
const navItems = useMemo(
() => [
{ key: "home", label: "首页", hint: "项目入口", icon: },
@@ -838,6 +850,10 @@ function App() {
setSession(nextSession);
await hydrateAccountData(nextSession);
+ if (nextSession.user.email && !nextSession.user.emailVerified) {
+ toast.info("邮箱尚未验证,部分功能可能受限,请在登录页通过邮箱验证码完成验证");
+ }
+
const action = pendingAction;
closeLoginPrompt();
if (action) {
@@ -1112,8 +1128,6 @@ function App() {
onSelectView={handleSetView}
/>
);
- case "settings":
- return ;
case "imageWorkbench":
return (
;
case "providerHealth":
return ;
+ case "userAgreement":
+ return ;
+ case "privacyPolicy":
+ return ;
case "communityReview":
return (
(res, "Subtitle removal response failed");
},
+ async createVideoEditTask(input: VideoEditInput): Promise<{ taskId: string }> {
+ const res = await fetch(buildApiUrl("ai/video/edit"), {
+ method: "POST",
+ headers: buildAuthHeaders(),
+ body: JSON.stringify({ ...input, model: input.model || "happyhorse-1.0-video-edit" }),
+ });
+ if (!res.ok) {
+ await throwResponseError(res, "Video edit request failed");
+ }
+ return readJsonResponse<{ taskId: string }>(res, "Video edit response failed");
+ },
+
async createImageSuperResolveTask(input: ImageSuperResolveInput): Promise<{ taskId: string }> {
const res = await fetch(buildApiUrl("ai/image/super-resolve"), {
method: "POST",
diff --git a/src/api/keyServerClient.ts b/src/api/keyServerClient.ts
index 16b02e0..1acf077 100644
--- a/src/api/keyServerClient.ts
+++ b/src/api/keyServerClient.ts
@@ -30,9 +30,26 @@ interface EmailAuthInput {
email: string;
password: string;
username?: string;
+ code?: string;
betaCode?: string;
}
+interface EmailCodeInput {
+ email: string;
+ code: string;
+ purpose?: "register" | "login";
+}
+
+interface ForgotPasswordInput {
+ email: string;
+}
+
+interface ResetPasswordInput {
+ email: string;
+ code: string;
+ newPassword: string;
+}
+
interface PhoneAuthInput {
phone: string;
code: string;
@@ -52,6 +69,19 @@ interface DeleteProjectOptions {
cleanupUserData?: boolean;
}
+export interface RechargeOrderInput {
+ planId: string;
+ paymentMethod: "wechat" | "alipay" | "bank";
+}
+
+export interface RechargeOrderResult {
+ orderId: string;
+ status: string;
+ payUrl?: string | null;
+ qrCodeUrl?: string | null;
+ message?: string | null;
+}
+
export interface WechatLoginTicket {
configured: boolean;
url?: string;
@@ -624,6 +654,21 @@ function normalizeEnterpriseUsageSummary(payload: unknown): WebEnterpriseUsageSu
};
}
+function normalizeRechargeOrder(payload: unknown): RechargeOrderResult {
+ const raw = unwrapApiPayload(payload);
+ if (!isRecord(raw)) {
+ return { orderId: `local-${Date.now()}`, status: "pending", message: "订单已提交,请联系客服确认到账。" };
+ }
+
+ return {
+ orderId: toStringValue(raw.orderId ?? raw.order_id ?? raw.id, `local-${Date.now()}`),
+ status: toStringValue(raw.status, "pending"),
+ payUrl: toNullableString(raw.payUrl ?? raw.pay_url ?? raw.checkoutUrl ?? raw.checkout_url),
+ qrCodeUrl: toNullableString(raw.qrCodeUrl ?? raw.qr_code_url ?? raw.qrcodeUrl),
+ message: toNullableString(raw.message ?? raw.notice),
+ };
+}
+
function buildProjectUpsertPayload(workflow: WebCanvasWorkflow, session: WebUserSession): Record {
const userId = String(session.user.id).replace(/[^a-zA-Z0-9_-]/g, "");
const projectId = workflow.id.trim();
@@ -714,6 +759,7 @@ export const keyServerClient = {
email: input.email.trim(),
username: input.username?.trim() || undefined,
password: input.password,
+ code: input.code?.trim() || undefined,
betaCode: input.betaCode?.trim() || undefined,
},
}),
@@ -731,6 +777,30 @@ export const keyServerClient = {
body: { phone: phone.trim(), purpose, betaCode: betaCode?.trim() || undefined },
});
},
+ async sendEmailCode(email: string, purpose: "login" | "register" | "reset", betaCode?: string): Promise<{ cooldownSeconds?: number; ttlSeconds?: number; devCode?: string }> {
+ return request<{ cooldownSeconds?: number; ttlSeconds?: number; devCode?: string }>("/auth/email/send-code", {
+ method: "POST",
+ body: { email: email.trim(), purpose, betaCode: betaCode?.trim() || undefined },
+ });
+ },
+ async verifyEmail(input: EmailCodeInput): Promise<{ success: boolean }> {
+ return request<{ success: boolean }>("/auth/email/verify", {
+ method: "POST",
+ body: { email: input.email.trim(), code: input.code.trim(), purpose: input.purpose || "register" },
+ });
+ },
+ async forgotPassword(input: ForgotPasswordInput): Promise<{ success: boolean; message?: string }> {
+ return request<{ success: boolean; message?: string }>("/auth/forgot-password", {
+ method: "POST",
+ body: { email: input.email.trim() },
+ });
+ },
+ async resetPassword(input: ResetPasswordInput): Promise<{ success: boolean; message?: string }> {
+ return request<{ success: boolean; message?: string }>("/auth/reset-password", {
+ method: "POST",
+ body: { email: input.email.trim(), code: input.code.trim(), newPassword: input.newPassword },
+ });
+ },
async loginPhone(input: PhoneAuthInput): Promise {
const session = normalizeLoginResult(
await request("/auth/login-phone", {
@@ -855,13 +925,23 @@ export const keyServerClient = {
return normalizeProjectContent(response, projectId);
},
async getUsageSummary(): Promise {
- return normalizeUsageSummary(await request("/user/usage/summary"));
+ const stored = readStoredSession();
+ return normalizeUsageSummary(await request("/user/usage/summary", { token: stored?.token }));
},
async getEnterpriseUsageSummary(): Promise {
- return normalizeEnterpriseUsageSummary(await request("/enterprise/usage/summary"));
+ const stored = readStoredSession();
+ return normalizeEnterpriseUsageSummary(await request("/enterprise/usage/summary", { token: stored?.token }));
},
async getPersonalUsageSummary(): Promise {
- return normalizeEnterpriseUsageSummary(await request("/user/usage/credits"));
+ const stored = readStoredSession();
+ return normalizeEnterpriseUsageSummary(await request("/user/usage/credits", { token: stored?.token }));
+ },
+ async createRechargeOrder(input: RechargeOrderInput): Promise {
+ const response = await request("/payments/recharge-orders", {
+ method: "POST",
+ body: input,
+ });
+ return normalizeRechargeOrder(response);
},
async createProjectSpace(workflow: WebCanvasWorkflow): Promise {
const stored = readStoredSession();
@@ -929,8 +1009,8 @@ export const keyServerClient = {
});
},
- async getClientErrors(page = 1): Promise<{ items: unknown[]; total: number }> {
- const data = await request<{ items: unknown[]; total: number }>(`/client-errors?page=${page}`);
+ async getClientErrors(page = 1): Promise<{ items: import("../components/AdminMonitor").ClientErrorItem[]; total: number }> {
+ const data = await request<{ items: import("../components/AdminMonitor").ClientErrorItem[]; total: number }>(`/client-errors?page=${page}`);
return data;
},
};
diff --git a/src/api/scriptEvalClient.ts b/src/api/scriptEvalClient.ts
index 0516508..9a8f836 100644
--- a/src/api/scriptEvalClient.ts
+++ b/src/api/scriptEvalClient.ts
@@ -1,3 +1,5 @@
+import { buildApiUrl, buildAuthHeaders } from "./serverConnection";
+
export interface ScriptEvalResult {
totalScore: number;
grade: string;
@@ -8,7 +10,6 @@ export interface ScriptEvalResult {
suggestions: string[];
}
-const DASHSCOPE_ENDPOINT = "/dashscope-api/chat/completions";
const MODEL = "qwen3.7-max";
const EVAL_SYSTEM_PROMPT = `你是一位资深影视剧本评审专家,拥有二十年以上的编剧、制片和剧本医生经验。你精通各类影视叙事理论(三幕式、英雄之旅、起承转合、序列法),同时深度跟踪AIGC短剧/漫剧行业最新趋势。你的任务是对用户提供的剧本进行严谨、系统、多维度的量化评分。
@@ -68,11 +69,9 @@ function extractJson(text: string): unknown {
}
export async function evaluateScript(script: string, signal?: AbortSignal): Promise {
- const res = await fetch(DASHSCOPE_ENDPOINT, {
+ const res = await fetch(buildApiUrl("ai/chat"), {
method: "POST",
- headers: {
- "Content-Type": "application/json",
- },
+ headers: buildAuthHeaders(),
body: JSON.stringify({
model: MODEL,
messages: [
@@ -92,11 +91,7 @@ export async function evaluateScript(script: string, signal?: AbortSignal): Prom
}
const payload = await res.json();
- const content: string = payload?.choices?.[0]?.message?.content
- ?? payload?.result?.content
- ?? payload?.content
- ?? payload?.text
- ?? (typeof payload === "string" ? payload : "");
+ const content: string = payload?.content ?? payload?.choices?.[0]?.message?.content ?? payload?.text ?? "";
if (!content) throw new Error("模型未返回有效内容");
diff --git a/src/components/AdminMonitor.tsx b/src/components/AdminMonitor.tsx
index 4fc41f1..d5a7bbc 100644
--- a/src/components/AdminMonitor.tsx
+++ b/src/components/AdminMonitor.tsx
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { keyServerClient } from "../api/keyServerClient";
-interface ClientErrorItem {
+export interface ClientErrorItem {
id: number;
message: string;
stack?: string;
diff --git a/src/components/AppShell.tsx b/src/components/AppShell.tsx
index 3457460..700938b 100644
--- a/src/components/AppShell.tsx
+++ b/src/components/AppShell.tsx
@@ -22,6 +22,7 @@ import NotificationCenter from "./NotificationCenter";
import { RechargeModal } from "./RechargeModal/RechargeModal";
import { AnimatedPanel } from "./AnimatedPanel";
import AdminMonitor from "./AdminMonitor";
+import CookieConsentBanner from "./CookieConsentBanner";
interface AppShellProps {
activeView: WebViewKey;
@@ -40,6 +41,7 @@ interface AppShellProps {
}
const BRAND_LOGO_URL = "https://stringtest.oss-cn-hangzhou.aliyuncs.com/logo.png";
+const CLIENT_ERROR_MONITOR_ENABLED = import.meta.env.VITE_ENABLE_CLIENT_ERROR_MONITOR === "1";
function formatBalance(cents: number): string {
const value = Math.max(0, cents) / 100;
@@ -344,8 +346,8 @@ function AppShell({
15155073618
@@ -356,7 +358,7 @@ function AppShell({
onClick={() => setRechargeOpen(true)}
>
- {displayedBalanceLabel}
+ {displayedBalanceLabel}
- {session?.user.role === "admin" ? : null}
+ {CLIENT_ERROR_MONITOR_ENABLED && session?.user.role === "admin" ? : null}
setRechargeOpen(false)} currentBalance={displayedBalanceCents} />
+
);
}
diff --git a/src/components/PageTransition.tsx b/src/components/PageTransition.tsx
index 3f6a491..6f66283 100644
--- a/src/components/PageTransition.tsx
+++ b/src/components/PageTransition.tsx
@@ -27,7 +27,6 @@ const NAV_ORDER: string[] = [
"avatarConsole",
"characterMix",
"agent",
- "settings",
"login",
"profile",
"report",
diff --git a/src/components/RechargeModal/RechargeModal.tsx b/src/components/RechargeModal/RechargeModal.tsx
index 2254028..95be2eb 100644
--- a/src/components/RechargeModal/RechargeModal.tsx
+++ b/src/components/RechargeModal/RechargeModal.tsx
@@ -1,7 +1,10 @@
import { CheckCircleOutlined, CloseOutlined, CrownOutlined, RocketOutlined } from "@ant-design/icons";
import { useMemo, useState, type ReactNode } from "react";
+import { keyServerClient, type RechargeOrderResult } from "../../api/keyServerClient";
+import { toast } from "../toast/toastStore";
type RechargeAudience = "personal" | "enterprise";
+type PaymentMethod = "wechat" | "alipay" | "bank";
interface MembershipPlan {
id: string;
@@ -107,6 +110,12 @@ const rechargeRules = [
"退费规则:充值积分到账后不支持退换、折现,仅限平台内消费",
];
+const paymentMethods: Array<{ id: PaymentMethod; label: string; hint: string }> = [
+ { id: "wechat", label: "微信支付", hint: "生成支付链接或二维码" },
+ { id: "alipay", label: "支付宝", hint: "生成支付链接或二维码" },
+ { id: "bank", label: "对公转账", hint: "企业客户可联系客服确认" },
+];
+
interface RechargeModalProps {
open: boolean;
onClose: () => void;
@@ -116,14 +125,43 @@ interface RechargeModalProps {
export function RechargeModal({ open, onClose, currentBalance }: RechargeModalProps) {
const [activeAudience, setActiveAudience] = useState("personal");
const [selectedPlanIds, setSelectedPlanIds] = useState>(defaultSelectedPlanIds);
+ const [paymentMethod, setPaymentMethod] = useState("wechat");
+ const [submitting, setSubmitting] = useState(false);
+ const [order, setOrder] = useState(null);
const visiblePlans = useMemo(() => membershipPlans.filter((plan) => plan.audience === activeAudience), [activeAudience]);
const selectedPlanId = selectedPlanIds[activeAudience];
+ const selectedPlan = membershipPlans.find((plan) => plan.id === selectedPlanId) ?? visiblePlans[0];
const handlePlanSelect = (plan: MembershipPlan) => {
setSelectedPlanIds((current) => ({
...current,
[plan.audience]: plan.id,
}));
+ setOrder(null);
+ };
+
+ const handleCreateOrder = async () => {
+ if (!selectedPlan || submitting) return;
+
+ setSubmitting(true);
+ try {
+ const nextOrder = await keyServerClient.createRechargeOrder({ planId: selectedPlan.id, paymentMethod });
+ setOrder(nextOrder);
+ if (nextOrder.payUrl) {
+ window.open(nextOrder.payUrl, "_blank", "noopener,noreferrer");
+ }
+ toast.success("充值订单已创建");
+ } catch (error) {
+ const message = error instanceof Error ? error.message : "订单创建失败,请联系客服处理。";
+ toast.error(message);
+ setOrder({
+ orderId: `support-${Date.now()}`,
+ status: "manual-review",
+ message: "支付接口暂不可用,请通过页面联系方式联系客服完成充值。",
+ });
+ } finally {
+ setSubmitting(false);
+ }
};
if (!open) return null;
@@ -224,6 +262,44 @@ export function RechargeModal({ open, onClose, currentBalance }: RechargeModalPr
))}
+
+
+
+
支付确认
+
{selectedPlan.name} · {selectedPlan.period}
+
{selectedPlan.price},{selectedPlan.grant}
+
+
+ {paymentMethods.map((method) => (
+
+ ))}
+
+
+ {order ? (
+
+
订单号:{order.orderId}
+
状态:{order.status}
+ {order.qrCodeUrl ?

: null}
+ {order.payUrl ?
打开支付链接 : null}
+
{order.message || "支付完成后积分将自动入账,如长时间未到账请联系客服。"}
+
+ ) : null}
+
);
diff --git a/src/features/assets/AssetsPage.tsx b/src/features/assets/AssetsPage.tsx
index 5c94c07..e3cf85c 100644
--- a/src/features/assets/AssetsPage.tsx
+++ b/src/features/assets/AssetsPage.tsx
@@ -100,14 +100,14 @@ function AssetsPage({ isAuthenticated, onOpenLogin }: AssetsPageProps) {
setContextMenu({ x: e.clientX, y: e.clientY, asset });
}, []);
- const handleDeleteAsset = useCallback(async () => {
- if (!contextMenu) return;
- const { asset } = contextMenu;
+ const handleDeleteAsset = useCallback(async (asset?: LibraryAssetItem) => {
+ const target = asset || contextMenu?.asset;
+ if (!target) return;
setContextMenu(null);
try {
- await assetClient.delete(asset.id);
- setServerAssets((prev) => prev.filter((a) => a.id !== asset.id));
- setServerNotice(`已删除 ${asset.name}`);
+ await assetClient.delete(target.id);
+ setServerAssets((prev) => prev.filter((a) => a.id !== target.id));
+ setServerNotice(`已删除 ${target.name}`);
} catch (err) {
setServerNotice(err instanceof Error ? err.message : "删除失败");
}
@@ -287,32 +287,42 @@ function AssetsPage({ isAuthenticated, onOpenLogin }: AssetsPageProps) {
{visibleAssets.length ? (
{visibleAssets.map((asset) => (
-
+ >
+ ) : null}
diff --git a/src/features/script-tokens/ScriptTokensPage.tsx b/src/features/script-tokens/ScriptTokensPage.tsx
index fc3a849..9572dd3 100644
--- a/src/features/script-tokens/ScriptTokensPage.tsx
+++ b/src/features/script-tokens/ScriptTokensPage.tsx
@@ -33,6 +33,8 @@ interface HistoryEntry {
timestamp: number;
score: number;
grade: string;
+ script?: string;
+ result?: EvalResult;
}
function getGrade(score: number): string {
@@ -54,6 +56,8 @@ const TEXT_FILE_EXTENSIONS = [
".fountain",
".fdx",
".rtf",
+ ".docx",
+ ".doc",
".csv",
".tsv",
".json",
@@ -99,7 +103,7 @@ const TEXT_FILE_EXTENSIONS = [
] as const;
const TEXT_FILE_EXTENSION_SET = new Set(TEXT_FILE_EXTENSIONS);
const TEXT_FILE_ACCEPT = TEXT_FILE_EXTENSIONS.join(",");
-const TEXT_FILE_HINT = "支持常见文本格式:TXT / MD / Fountain / FDX / RTF / JSON / CSV / XML / HTML / YAML / LOG / 字幕等";
+const TEXT_FILE_HINT = "支持常见文本格式:TXT / MD / DOCX / Fountain / FDX / RTF / JSON / CSV / XML / HTML / YAML / LOG / 字幕等";
function loadHistory(): HistoryEntry[] {
try {
@@ -168,6 +172,69 @@ function normalizeUploadedText(raw: string, ext: string): string {
return raw;
}
+async function extractDocxText(bytes: Uint8Array): Promise {
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
+ const entries: Array<{ name: string; offset: number; size: number; compressed: boolean }> = [];
+ let pos = 0;
+ while (pos < bytes.length - 30) {
+ if (view.getUint32(pos, true) !== 0x04034b50) break;
+ const compressed = view.getUint16(pos + 10, true) !== 0;
+ const compressedSize = view.getUint32(pos + 18, true);
+ const fileNameLen = view.getUint16(pos + 26, true);
+ const extraLen = view.getUint16(pos + 28, true);
+ const name = new TextDecoder().decode(bytes.slice(pos + 30, pos + 30 + fileNameLen));
+ const dataStart = pos + 30 + fileNameLen + extraLen;
+ entries.push({ name, offset: dataStart, size: compressedSize, compressed });
+ pos = dataStart + compressedSize;
+ }
+ const docEntry = entries.find((e) => e.name === "word/document.xml");
+ if (!docEntry) return "";
+ const xmlBytes = bytes.slice(docEntry.offset, docEntry.offset + docEntry.size);
+ let xmlText: string;
+ if (docEntry.compressed) {
+ try {
+ const ds = new DecompressionStream("deflate-raw");
+ const writer = ds.writable.getWriter();
+ writer.write(xmlBytes);
+ writer.close();
+ const reader = ds.readable.getReader();
+ const chunks: Uint8Array[] = [];
+ while (true) {
+ const { done, value } = await reader.read();
+ if (done) break;
+ chunks.push(value);
+ }
+ const totalLen = chunks.reduce((s, c) => s + c.length, 0);
+ const combined = new Uint8Array(totalLen);
+ let offset = 0;
+ for (const c of chunks) { combined.set(c, offset); offset += c.length; }
+ xmlText = new TextDecoder().decode(combined);
+ } catch {
+ xmlText = new TextDecoder().decode(xmlBytes);
+ }
+ } else {
+ xmlText = new TextDecoder().decode(xmlBytes);
+ }
+ const textMatches = xmlText.match(/]*>([\s\S]*?)<\/w:t>/g);
+ if (!textMatches) return "";
+ const paragraphs: string[] = [];
+ let currentLine = "";
+ for (const match of textMatches) {
+ const content = match.replace(/<[^>]+>/g, "").replace(/</g, "<").replace(/>/g, ">").replace(/&/g, "&").replace(/"/g, "\"");
+ currentLine += content;
+ }
+ // Try to find paragraph breaks
+ const paraMatches = xmlText.match(/][\s\S]*?<\/w:p>/g);
+ if (paraMatches) {
+ return paraMatches.map((p) => {
+ const tMatches = p.match(/]*>([\s\S]*?)<\/w:t>/g);
+ if (!tMatches) return "";
+ return tMatches.map((m) => m.replace(/<[^>]+>/g, "").replace(/</g, "<").replace(/>/g, ">").replace(/&/g, "&").replace(/"/g, "\"")).join("");
+ }).filter(Boolean).join("\n").trim();
+ }
+ return currentLine.trim();
+}
+
const SCORE_DIMENSIONS: ScoreDimension[] = [
{ key: "hook", label: "钩子设计", maxScore: 20, hint: "开篇吸引力·悬念设置·黄金三秒", detail: "开篇即抛出高概念钩子,悬念设置紧凑有力。" },
{ key: "character", label: "角色塑造", maxScore: 15, hint: "人物立体度·动机合理性·弧光设计", detail: "主角动机有铺垫,配角功能性较强,人物弧光尚可进一步深化。" },
@@ -222,6 +289,7 @@ function ScriptTokensPage() {
const [copied, setCopied] = useState(false);
const [activeDim, setActiveDim] = useState(null);
const [animatedScore, setAnimatedScore] = useState(0);
+ const [activeHistoryIndex, setActiveHistoryIndex] = useState(0);
const [history, setHistory] = useState(loadHistory);
const fileInputRef = useRef(null);
const scoreFrameRef = useRef(null);
@@ -251,7 +319,23 @@ function ScriptTokensPage() {
const ext = getFileExtension(file.name);
const readable = isReadableTextFile(file, ext);
setUploadedFile({ name: file.name, size: file.size });
- if (readable) {
+ if (ext === ".docx") {
+ try {
+ const bytes = new Uint8Array(await file.arrayBuffer());
+ const text = await extractDocxText(bytes);
+ if (text) {
+ setScript(text);
+ } else {
+ setScript(`[已上传文件:${file.name}]\n\n无法从 DOCX 文件中提取文本,请尝试另存为 TXT 格式后重新上传。`);
+ }
+ } catch {
+ setScript(`[已上传文件:${file.name}]\n\n解析 DOCX 文件失败,请尝试另存为 TXT 格式后重新上传。`);
+ }
+ } else if (ext === ".doc") {
+ const text = await decodeTextFile(file);
+ const cleaned = text.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f]/g, "").replace(/\s{3,}/g, "\n\n").trim();
+ setScript(cleaned || `[已上传文件:${file.name}]\n\n无法从 .doc 文件中提取文本,请另存为 .docx 或 .txt 格式。`);
+ } else if (readable) {
const text = normalizeUploadedText(await decodeTextFile(file), ext);
setScript(text);
} else {
@@ -277,6 +361,8 @@ function ScriptTokensPage() {
timestamp: Date.now(),
score: aiResult.totalScore,
grade: g,
+ script,
+ result: aiResult,
};
const updated = [entry, ...loadHistory().filter((h) => h.name !== entry.name || h.score !== entry.score)].sort(
(a, b) => b.timestamp - a.timestamp,
@@ -289,6 +375,20 @@ function ScriptTokensPage() {
setLoading(false);
};
+ const handleHistoryClick = (item: HistoryEntry, index: number) => {
+ setActiveHistoryIndex(index);
+ if (item.script) {
+ setScript(item.script);
+ setUploadedFile({ name: `${item.name}.txt`, size: item.script.length });
+ }
+ if (item.result) {
+ setResult(item.result);
+ } else {
+ setResult(null);
+ }
+ setEvalError(null);
+ };
+
const handleReset = () => {
setScript("");
setResult(null);
@@ -420,7 +520,9 @@ function ScriptTokensPage() {
暂无评测记录
) : (
history.map((item, i) => (
-
+
handleHistoryClick(item, i)} role="button" tabIndex={0}
+ onKeyDown={(e) => { if ((e as React.KeyboardEvent).key === "Enter") handleHistoryClick(item, i); }}>
{item.name}
{item.date}
diff --git a/src/features/script-tokens/TokenUsagePage.tsx b/src/features/script-tokens/TokenUsagePage.tsx
index 8cf5578..6b26f6b 100644
--- a/src/features/script-tokens/TokenUsagePage.tsx
+++ b/src/features/script-tokens/TokenUsagePage.tsx
@@ -6,7 +6,6 @@ import {
LineChartOutlined,
ReloadOutlined,
RightOutlined,
- SettingOutlined,
TeamOutlined,
UserOutlined,
WarningOutlined,
@@ -143,29 +142,22 @@ function TokenUsagePage({
onSelectView,
}: TokenUsagePageProps) {
const [enterpriseUsage, setEnterpriseUsage] = useState
(null);
- const [enterpriseUsageLoading, setEnterpriseUsageLoading] = useState(false);
- const [enterpriseUsageError, setEnterpriseUsageError] = useState(null);
const isEnterpriseAdmin = session?.user.enterpriseRole === "admin";
const isEnterpriseAccount = Boolean(session?.user.enterpriseId || session?.user.accountType === "enterprise");
const refreshEnterpriseUsage = useCallback(async () => {
+ if (!session) return;
const loader = isEnterpriseAdmin ? loadEnterpriseUsage : loadPersonalUsage;
if (!loader) {
setEnterpriseUsage(null);
- setEnterpriseUsageError(null);
return;
}
- setEnterpriseUsageLoading(true);
- setEnterpriseUsageError(null);
try {
setEnterpriseUsage(await loader());
} catch (error) {
setEnterpriseUsage(null);
- setEnterpriseUsageError(error instanceof Error ? error.message : "用量数据暂时不可用");
- } finally {
- setEnterpriseUsageLoading(false);
}
- }, [isEnterpriseAdmin, loadEnterpriseUsage, loadPersonalUsage]);
+ }, [session, isEnterpriseAdmin, loadEnterpriseUsage, loadPersonalUsage]);
useEffect(() => {
void refreshEnterpriseUsage();
@@ -241,9 +233,6 @@ function TokenUsagePage({
管理中心
-
- {enterpriseUsageLoading ? "正在同步企业用量" : enterpriseUsageError || "服务器已连接"}
-
刷新数据
@@ -252,17 +241,12 @@ function TokenUsagePage({
成员管理
-
onSelectView?.("settings")}>
-
- 服务设置
-
{isLowBalance ? (
当前余额 {formatCredits(availableBalanceCents)},可能不足以完成下一次生成,请及时充值。
- onSelectView?.("settings")}>去充值
) : null}
diff --git a/src/features/workbench/WorkbenchPage.tsx b/src/features/workbench/WorkbenchPage.tsx
index 9777ba8..138f295 100644
--- a/src/features/workbench/WorkbenchPage.tsx
+++ b/src/features/workbench/WorkbenchPage.tsx
@@ -41,6 +41,7 @@ import { preUploadReference, resolvePreUploadedUrl } from "../../api/referenceUp
import { assetClient } from "../../api/assetClient";
import { communityClient } from "../../api/communityClient";
import { RechargeModal } from "../../components/RechargeModal/RechargeModal";
+import { useGenerationTasks } from "../../hooks/useGenerationTasks";
import { conversationClient, type ConversationSummary } from "../../api/conversationClient";
import { modelCapabilitiesClient } from "../../api/modelCapabilitiesClient";
@@ -238,6 +239,7 @@ function WorkbenchPage({
const lastScrollTopRef = useRef(0);
const shouldFollowNewMessagesRef = useRef(true);
const pendingScrollToLatestRef = useRef(true);
+ const genTracker = useGenerationTasks({ sourceView: "workbench" });
const renderedMessageIdsRef = useRef
([]);
const hasHandledInitialMessagesRef = useRef(false);
@@ -1851,6 +1853,7 @@ function WorkbenchPage({
referenceUrls: refUrls.length ? refUrls : undefined,
});
taskId = result.taskId;
+ genTracker.submitTask({ title: trimmedPrompt.slice(0, 60), type: "image", status: "running", progress: 5, prompt: trimmedPrompt, sourceView: "workbench", taskId });
} else {
let requestModel = resolveVideoRequestModel({
model: taskInput.params?.model || ENTERPRISE_DEFAULT_VIDEO_MODEL,
@@ -1870,6 +1873,7 @@ function WorkbenchPage({
hasReferenceVideo: requestReferenceItems.some((item) => item.kind === "video"),
});
taskId = result.taskId;
+ genTracker.submitTask({ title: trimmedPrompt.slice(0, 60), type: "video", status: "running", progress: 5, prompt: trimmedPrompt, sourceView: "workbench", taskId });
}
onRefreshUsage?.();
diff --git a/src/stores/index.ts b/src/stores/index.ts
index 7c3ef7b..85cae04 100644
--- a/src/stores/index.ts
+++ b/src/stores/index.ts
@@ -3,6 +3,8 @@ export { useSessionStore } from './useSessionStore';
export { useProjectStore } from './useProjectStore';
export { useTaskStore } from './useTaskStore';
export { useAppStore } from './useAppStore';
+export { useGenerationStore } from './useGenerationStore';
+export type { GenerationQueueItem, QueueItemStatus } from './useGenerationStore';
// Type exports
export type { PendingAction } from './useSessionStore';
diff --git a/src/styles/components/recharge-modal.css b/src/styles/components/recharge-modal.css
index 94e1bdc..593813f 100644
--- a/src/styles/components/recharge-modal.css
+++ b/src/styles/components/recharge-modal.css
@@ -365,11 +365,113 @@
line-height: 1.55;
}
+.recharge-modal__checkout {
+ display: grid;
+ gap: 14px;
+ border: 1px solid rgba(var(--accent-rgb), 0.26);
+ border-radius: 14px;
+ background: linear-gradient(180deg, rgba(var(--accent-rgb), 0.12), rgba(var(--accent-rgb), 0.05));
+ padding: 18px;
+}
+
+.recharge-modal__checkout-eyebrow {
+ color: var(--accent, #34d399);
+ font-size: 12px;
+ font-weight: 900;
+}
+
+.recharge-modal__checkout h3,
+.recharge-modal__checkout p {
+ margin: 0;
+}
+
+.recharge-modal__checkout h3 {
+ margin-top: 4px;
+ color: var(--fg-body, #edf2f7);
+ font-size: 18px;
+}
+
+.recharge-modal__checkout p {
+ color: var(--fg-muted, #9ba7b7);
+ font-size: 13px;
+ line-height: 1.6;
+}
+
+.recharge-modal__payment-methods {
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: 10px;
+}
+
+.recharge-modal__payment-methods button {
+ display: grid;
+ gap: 5px;
+ min-height: 68px;
+ padding: 12px;
+ border: 1px solid var(--border-subtle, rgb(255 255 255 / 10%));
+ border-radius: 12px;
+ background: var(--bg-inset, rgb(0 0 0 / 18%));
+ color: var(--fg-body, #edf2f7);
+ cursor: pointer;
+ text-align: left;
+}
+
+.recharge-modal__payment-methods button.is-active {
+ border-color: rgba(var(--accent-rgb), 0.56);
+ background: rgba(var(--accent-rgb), 0.14);
+}
+
+.recharge-modal__payment-methods span {
+ color: var(--fg-muted, #9ba7b7);
+ font-size: 12px;
+}
+
+.recharge-modal__pay {
+ min-height: 42px;
+ border: 0;
+ border-radius: 12px;
+ background: var(--accent, #34d399);
+ color: #07110d;
+ cursor: pointer;
+ font-weight: 950;
+}
+
+.recharge-modal__pay:disabled {
+ cursor: wait;
+ opacity: 0.7;
+}
+
+.recharge-modal__order {
+ display: grid;
+ gap: 8px;
+ padding: 12px;
+ border: 1px solid var(--border-subtle, rgb(255 255 255 / 10%));
+ border-radius: 12px;
+ background: var(--bg-inset, rgb(0 0 0 / 18%));
+ color: var(--fg-muted, #9ba7b7);
+ font-size: 13px;
+}
+
+.recharge-modal__order strong,
+.recharge-modal__order a {
+ color: var(--accent, #34d399);
+}
+
+.recharge-modal__order img {
+ width: 160px;
+ max-width: 100%;
+ border-radius: 10px;
+}
+
@media (max-width: 980px) {
.recharge-modal__grid[data-audience="personal"],
.recharge-modal__grid[data-audience="enterprise"] {
grid-template-columns: 1fr;
}
+
+ .recharge-modal__payment-methods {
+ grid-template-columns: 1fr;
+ }
}
@media (max-width: 640px) {
diff --git a/src/styles/pages/assets.css b/src/styles/pages/assets.css
index 9c3fd3e..2ed2c44 100644
--- a/src/styles/pages/assets.css
+++ b/src/styles/pages/assets.css
@@ -189,6 +189,40 @@
cursor: zoom-in;
}
+.asset-card-wrapper {
+ position: relative;
+ display: inline-block;
+}
+
+.asset-card__delete {
+ position: absolute;
+ top: 6px;
+ right: 6px;
+ z-index: 2;
+ display: none;
+ align-items: center;
+ justify-content: center;
+ width: 28px;
+ height: 28px;
+ border: 0;
+ border-radius: 6px;
+ background: var(--bg-panel);
+ color: var(--fg-muted);
+ cursor: pointer;
+ font-size: 14px;
+ opacity: 0.85;
+ transition: opacity 150ms, color 150ms;
+}
+
+.asset-card-wrapper:hover .asset-card__delete {
+ display: flex;
+}
+
+.asset-card__delete:hover {
+ opacity: 1;
+ color: var(--fg-danger);
+}
+
@media (max-width: 720px) {
.asset-preview-modal {
padding: 14px;
diff --git a/src/styles/pages/compliance.css b/src/styles/pages/compliance.css
index cf51f51..ab3e1f3 100644
--- a/src/styles/pages/compliance.css
+++ b/src/styles/pages/compliance.css
@@ -725,9 +725,110 @@
font-size: 42px;
}
+.compliance-page {
+ min-height: 100%;
+ background: #0d0d0f;
+ color: var(--fg-body);
+}
+
+.compliance-page__inner {
+ width: min(940px, calc(100% - 48px));
+ margin: 0 auto;
+ padding: 40px 0 56px;
+}
+
+.compliance-hero {
+ display: flex;
+ align-items: flex-start;
+ gap: 16px;
+ margin-bottom: 18px;
+}
+
+.compliance-hero__icon {
+ display: grid;
+ place-items: center;
+ flex: 0 0 54px;
+ width: 54px;
+ height: 54px;
+ border: 1px solid rgba(var(--accent-rgb), 0.28);
+ border-radius: 16px;
+ background: rgba(var(--accent-rgb), 0.12);
+ color: var(--accent);
+ font-size: 24px;
+}
+
+.compliance-hero__eyebrow {
+ color: var(--accent);
+ font-size: 12px;
+ font-weight: 900;
+}
+
+.compliance-hero h1 {
+ margin: 4px 0 8px;
+ font-size: clamp(26px, 4vw, 38px);
+}
+
+.compliance-hero p,
+.compliance-section p,
+.compliance-contact span {
+ color: var(--fg-muted);
+ line-height: 1.7;
+}
+
+.compliance-card,
+.compliance-contact {
+ border: 1px solid var(--border-subtle);
+ border-radius: 18px;
+ background: var(--bg-panel);
+ box-shadow: var(--shadow-tight);
+}
+
+.compliance-card {
+ display: grid;
+ overflow: hidden;
+}
+
+.compliance-section {
+ display: grid;
+ grid-template-columns: 52px minmax(0, 1fr);
+ gap: 16px;
+ padding: 22px;
+ border-bottom: 1px solid var(--border-weak);
+}
+
+.compliance-section:last-child {
+ border-bottom: 0;
+}
+
+.compliance-section > span {
+ color: var(--accent);
+ font-size: 13px;
+ font-weight: 950;
+}
+
+.compliance-section h2,
+.compliance-section p {
+ margin: 0;
+}
+
+.compliance-section h2 {
+ margin-bottom: 8px;
+ font-size: 18px;
+}
+
+.compliance-contact {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 10px 16px;
+ margin-top: 16px;
+ padding: 16px 18px;
+ font-size: 13px;
+}
+
@media (max-width: 900px) {
.community-review-page__inner,
- .report-page__inner {
+ .report-page__inner,
+ .compliance-page__inner {
width: min(100% - 28px, 720px);
padding-top: 24px;
}
@@ -786,4 +887,9 @@
display: grid;
grid-template-columns: 1fr 1fr;
}
+
+ .compliance-hero,
+ .compliance-section {
+ grid-template-columns: 1fr;
+ }
}
diff --git a/src/styles/pages/ecommerce.css b/src/styles/pages/ecommerce.css
index 417d3d6..181cc00 100644
--- a/src/styles/pages/ecommerce.css
+++ b/src/styles/pages/ecommerce.css
@@ -2809,6 +2809,26 @@
line-height: 1.6;
}
+.clone-ai-retry-btn {
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ min-height: 40px;
+ padding: 0 20px;
+ border: 1px solid rgba(var(--accent-rgb), 0.32);
+ border-radius: 12px;
+ background: rgba(var(--accent-rgb), 0.12);
+ color: var(--accent);
+ font-weight: 900;
+ cursor: pointer;
+ transition: background 0.15s, transform 0.15s;
+}
+
+.clone-ai-retry-btn:hover {
+ background: rgba(var(--accent-rgb), 0.22);
+ transform: translateY(-1px);
+}
+
.product-clone-page[data-tool="clone"] .clone-ai-preview-showcase {
display: grid;
grid-template-columns: minmax(210px, 300px) 54px minmax(330px, 560px);
@@ -7933,3 +7953,31 @@
.clone-ai-adwizard__risk.is-medium { color: #faad14; }
.clone-ai-adwizard__risk.is-high { color: #ff4d4f; }
.clone-ai-adwizard__issues { margin: 0; padding-left: 16px; font-size: 12px; display: flex; flex-direction: column; gap: 4px; }
+
+.clone-ai-video-outfit-upload {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ margin-top: 6px;
+}
+
+.clone-ai-video-outfit-upload-btn {
+ padding: 7px 16px;
+ border: 1px solid var(--border-subtle);
+ border-radius: 8px;
+ background: var(--bg-inset);
+ color: var(--fg-body);
+ font-size: 13px;
+ cursor: pointer;
+ transition: border-color 150ms, background 150ms;
+}
+
+.clone-ai-video-outfit-upload-btn:hover {
+ border-color: var(--border-default);
+ background: var(--bg-hover);
+}
+
+.clone-ai-video-outfit-info {
+ font-size: 12px;
+ color: var(--accent);
+}
diff --git a/src/styles/pages/home.css b/src/styles/pages/home.css
index 528ea84..d373993 100644
--- a/src/styles/pages/home.css
+++ b/src/styles/pages/home.css
@@ -316,7 +316,8 @@
left: 50%;
display: grid;
width: clamp(214px, 17vw, 286px);
- height: clamp(122px, 9.8vw, 162px);
+ height: auto;
+ aspect-ratio: 16 / 9;
place-items: center;
overflow: hidden;
border: 0;
@@ -388,7 +389,8 @@
.omni-home__carousel-card.is-active {
width: clamp(390px, 37vw, 620px);
- height: clamp(220px, 20.8vw, 350px);
+ height: auto;
+ aspect-ratio: 16 / 9;
border-radius: clamp(16px, 1.8vw, 24px);
box-shadow:
0 18px 40px rgb(0 0 0 / 26%),
diff --git a/src/styles/shell/app-shell.css b/src/styles/shell/app-shell.css
index da19b01..78610a9 100644
--- a/src/styles/shell/app-shell.css
+++ b/src/styles/shell/app-shell.css
@@ -826,3 +826,149 @@
border-color: var(--accent);
color: var(--accent);
}
+
+.cookie-consent {
+ position: fixed;
+ right: 18px;
+ bottom: 18px;
+ z-index: 1300;
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto;
+ gap: 16px;
+ width: min(640px, calc(100vw - 36px));
+ padding: 16px;
+ border: 1px solid rgba(var(--accent-rgb), 0.28);
+ border-radius: 16px;
+ background: var(--bg-panel);
+ color: var(--fg-body);
+ box-shadow: 0 18px 54px rgba(0, 0, 0, 0.34);
+}
+
+.cookie-consent strong,
+.cookie-consent p {
+ margin: 0;
+}
+
+.cookie-consent p {
+ margin-top: 5px;
+ color: var(--fg-muted);
+ font-size: 13px;
+ line-height: 1.55;
+}
+
+.cookie-consent__actions {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+}
+
+.cookie-consent__actions a,
+.cookie-consent__actions button {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ min-height: 36px;
+ padding: 0 12px;
+ border-radius: 999px;
+ font-size: 12px;
+ font-weight: 900;
+ white-space: nowrap;
+}
+
+.cookie-consent__actions a {
+ color: var(--accent);
+ text-decoration: none;
+}
+
+.cookie-consent__actions button {
+ border: 0;
+ background: var(--accent);
+ color: #07100b;
+ cursor: pointer;
+}
+
+@media (max-width: 900px) {
+ .web-shell {
+ overflow: hidden;
+ }
+
+ .web-topbar {
+ flex: 0 0 auto;
+ gap: 10px;
+ padding: 10px 12px;
+ }
+
+ .brand-lockup__tone,
+ .profile-button span:not(.profile-button__avatar),
+ .member-button__label {
+ display: none;
+ }
+
+ .web-topbar__actions {
+ gap: 6px;
+ }
+
+ .member-button,
+ .profile-button,
+ .info-button {
+ width: 36px;
+ padding: 0;
+ }
+
+ .floating-nav {
+ left: 50%;
+ top: auto;
+ bottom: max(10px, env(safe-area-inset-bottom));
+ flex-direction: row;
+ width: min(calc(100vw - 20px), 560px);
+ overflow-x: auto;
+ justify-content: flex-start;
+ border-radius: 18px;
+ transform: translateX(-50%);
+ scrollbar-width: none;
+ }
+
+ .floating-nav::-webkit-scrollbar {
+ display: none;
+ }
+
+ .floating-nav__item {
+ flex: 0 0 44px;
+ }
+
+ .floating-nav__label,
+ .floating-nav__submenu,
+ .floating-page-scroll-actions {
+ display: none;
+ }
+
+ .web-shell__page {
+ padding-bottom: 78px;
+ }
+
+ .info-popover,
+ .profile-popover {
+ right: -8px;
+ max-width: calc(100vw - 24px);
+ }
+}
+
+@media (max-width: 640px) {
+ .brand-lockup__name {
+ font-size: 14px;
+ }
+
+ .web-topbar__actions {
+ flex: 1;
+ }
+
+ .cookie-consent {
+ right: 12px;
+ bottom: 12px;
+ grid-template-columns: 1fr;
+ }
+
+ .cookie-consent__actions {
+ justify-content: space-between;
+ }
+}
diff --git a/src/styles/themes/dark-green.css b/src/styles/themes/dark-green.css
index 24ae5b1..78cbdf1 100644
--- a/src/styles/themes/dark-green.css
+++ b/src/styles/themes/dark-green.css
@@ -3782,21 +3782,12 @@
.web-shell[data-ui-theme="dark-green"] .community-page .project-card:not(.project-card--new) > .project-card__empty--dark {
display: grid;
place-items: center;
- background: #202020;
- color: rgba(255, 255, 255, 0.22);
+ background: var(--bg-inset, transparent);
+ color: rgba(255, 255, 255, 0.14);
}
.web-shell[data-ui-theme="dark-green"] .community-page .project-card:not(.project-card--new) > .project-card__empty--dark .anticon {
- font-size: 34px;
-}
-
-.web-shell[data-ui-theme="dark-green"] .community-page .project-card:not(.project-card--new)::after {
- content: "";
- grid-area: 1 / 1;
- align-self: end;
- height: 58%;
- background: rgba(0, 0, 0, 0.86);
- pointer-events: none;
+ font-size: 40px;
}
.web-shell[data-ui-theme="dark-green"] .community-page .project-card:not(.project-card--new) .project-card__caption,
@@ -3812,18 +3803,35 @@
padding-block: 0;
background: none;
color: rgba(255, 255, 255, 0.78);
+ text-shadow: 0 1px 4px rgba(0, 0, 0, 0.6);
+ opacity: 0;
+ transform: translateY(8px);
+ transition: opacity 220ms ease, transform 220ms ease;
+ pointer-events: none;
+}
+
+.web-shell[data-ui-theme="dark-green"] .community-page .project-card:not(.project-card--new):hover .project-card__caption {
opacity: 1;
- transform: none;
+ transform: translateY(0);
}
.web-shell[data-ui-theme="dark-green"] .community-page .project-card:not(.project-card--new) .project-card__meta {
align-self: end;
padding-block: 0 16px;
+ opacity: 0;
+ transform: translateY(8px);
+ transition: opacity 220ms ease, transform 220ms ease;
+}
+
+.web-shell[data-ui-theme="dark-green"] .community-page .project-card:not(.project-card--new):hover .project-card__meta {
+ opacity: 1;
+ transform: translateY(0);
}
.web-shell[data-ui-theme="dark-green"] .community-page .project-card:not(.project-card--new) .project-card__meta strong {
color: #fff;
font-size: 15px;
+ text-shadow: 0 1px 6px rgba(0, 0, 0, 0.72);
}
.web-shell[data-ui-theme="dark-green"] .community-filter-bar {
diff --git a/src/types.ts b/src/types.ts
index 4eeaf26..67555b0 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -14,7 +14,6 @@ export type WebViewKey =
| "sizeTemplate"
| "scriptTokens"
| "tokenUsage"
- | "settings"
| "imageWorkbench"
| "resolutionUpscale"
| "digitalHuman"
@@ -27,6 +26,8 @@ export type WebViewKey =
| "communityCaseAdd"
| "report"
| "providerHealth"
+ | "userAgreement"
+ | "privacyPolicy"
| "not-found";
export type WebImageWorkbenchTool = "workbench" | "inpaint" | "camera";
diff --git a/src/utils/errorReporting.ts b/src/utils/errorReporting.ts
index fb62355..3f2ec52 100644
--- a/src/utils/errorReporting.ts
+++ b/src/utils/errorReporting.ts
@@ -1,4 +1,5 @@
const ERROR_REPORT_ENDPOINT = "/api/client-errors";
+const CLIENT_ERROR_REPORTING_ENABLED = import.meta.env.VITE_ENABLE_CLIENT_ERROR_REPORTING === "1";
interface ErrorReport {
message: string;
@@ -44,6 +45,8 @@ function scheduleFlush() {
}
export function reportError(error: unknown, source: ErrorReport["source"] = "manual") {
+ if (!CLIENT_ERROR_REPORTING_ENABLED) return;
+
const err = error instanceof Error ? error : new Error(String(error));
const report: ErrorReport = {
message: err.message,