import type { WebCanvasWorkflow, WebCanvasWorkflowNode, WebEnterpriseUsageSummary, WebProjectSummary, WebUsageSummary, WebUserSession, } from "../types"; import { getErrorMessage, getServerBaseUrl, isRecord, isServerRequestError, readStoredSession, serverRequest, unwrapApiPayload, writeStoredSession, } from "./serverConnection"; interface LoginInput { username: string; password: string; } interface RegisterInput extends LoginInput { betaCode: string; } 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; password?: string; betaCode?: string; } interface UpdateProfileInput { avatarUrl?: string | null; avatarOssKey?: string | null; bio?: string | null; backgroundUrl?: string | null; profileBackgroundUrl?: string | null; } 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; state?: string; message?: string; } export interface WechatLoginSessionStatus { status: "pending" | "completed" | "failed" | "expired" | "missing" | "consumed" | string; error?: string; session?: WebUserSession; } const getBaseUrl = getServerBaseUrl; const request = serverRequest; const isHttpError = isServerRequestError; const PROJECT_CONTENT_ENRICH_CONCURRENCY = 1; let projectContentEnrichmentDisabled = false; function toNumber(value: unknown, fallback = 0): number { const numberValue = typeof value === "number" ? value : Number(value); return Number.isFinite(numberValue) ? numberValue : fallback; } function toStringValue(value: unknown, fallback = ""): string { if (typeof value === "string") { const trimmed = value.trim(); return trimmed || fallback; } if (typeof value === "number" && Number.isFinite(value)) { return String(value); } return fallback; } function toNullableString(value: unknown): string | null { if (typeof value !== "string") return null; const trimmed = value.trim(); return trimmed || null; } function toNullableId(value: unknown): number | string | null { if (typeof value === "number" && Number.isFinite(value)) return value; return toNullableString(value); } function toIdValue(value: unknown, fallback: string): number | string { if (typeof value === "number" && Number.isFinite(value)) return value; return toStringValue(value, fallback); } function isPlaceholderProjectText(value: string | null | undefined): boolean { if (!value) return true; return /^(新建项目|新建创作|未命名项目|Untitled project)$/i.test(value.trim()); } function isPlaceholderProjectDescription(value: string | null | undefined): boolean { if (!value) return true; return /^(从空白画布开始|从空白画布开始,直接进入节点式创作。|最近更新的项目)$/i.test(value.trim()); } function chooseProjectText(contentText: string | null, currentText: string | null, fallback: string): string { if (contentText && (!isPlaceholderProjectText(contentText) || isPlaceholderProjectText(currentText))) { return contentText; } return currentText || contentText || fallback; } function chooseProjectDescription(contentText: string | null, currentText: string | null): string | null { if (contentText && (!isPlaceholderProjectDescription(contentText) || isPlaceholderProjectDescription(currentText))) { return contentText; } return currentText || contentText || null; } function pickFirstString(...values: unknown[]): string | null { for (const value of values) { const next = toNullableString(value); if (next) return next; } return null; } function toProjectSummary( raw: Record, source: WebProjectSummary["source"], ): WebProjectSummary { return { id: toStringValue(raw.id), name: toStringValue(raw.name, "未命名项目"), description: toNullableString(raw.description), thumbnailUrl: toNullableString(raw.thumbnail_url ?? raw.thumbnailUrl), updatedAt: toStringValue(raw.updated_at ?? raw.updatedAt, "刚刚"), storyboardCount: toNumber(raw.storyboard_count ?? raw.storyboardCount), imageCount: toNumber(raw.image_count ?? raw.imageCount), videoCount: toNumber(raw.video_count ?? raw.videoCount), source, }; } function buildProjectSummaryFromWorkflow( workflow: WebCanvasWorkflow, source: WebProjectSummary["source"], errorMessage?: string, ): WebProjectSummary { const previewNode = workflow.nodes.find((node) => node.previewUrl); return { id: workflow.id, name: workflow.title, description: workflow.description, thumbnailUrl: previewNode?.previewUrl ?? null, updatedAt: "刚刚", storyboardCount: workflow.nodes.length, imageCount: workflow.nodes.filter((node) => node.kind === "image").length, videoCount: workflow.nodes.filter((node) => node.kind === "video").length, source, ...(errorMessage ? { errorMessage } : {}), }; } function unwrapProjectContentPayload(payload: unknown): unknown { const unwrapped = unwrapApiPayload(payload); return isRecord(unwrapped) && "content" in unwrapped ? unwrapped.content : unwrapped; } function getContentWorkflowRecord(content: unknown): Record | null { if (!isRecord(content)) return null; const workflow = content.workflowData ?? content.workflow ?? content.workflow_data; return isRecord(workflow) ? workflow : null; } function getContentNodes(content: unknown): Record[] { const workflow = getContentWorkflowRecord(content); const nodeSource = (workflow && Array.isArray(workflow.nodes) ? workflow.nodes : null) ?? (isRecord(content) && Array.isArray(content.nodes) ? content.nodes : null); return Array.isArray(nodeSource) ? nodeSource.filter(isRecord) : []; } function getContentArray(content: unknown, key: string): Record[] { if (!isRecord(content)) return []; const value = content[key]; return Array.isArray(value) ? value.filter(isRecord) : []; } function countNodesByKind(nodes: Record[], kind: string): number { return nodes.filter((node) => node.kind === kind || node.type === kind).length; } function pickProjectPreviewUrl(content: unknown): string | null { if (!isRecord(content)) return null; const workflow = getContentWorkflowRecord(content); const nodes = getContentNodes(content); const storyboards = getContentArray(content, "storyboards"); const videos = getContentArray(content, "videos"); return ( pickFirstString( content.thumbnailUrl, content.thumbnail_url, content.coverUrl, content.cover_url, workflow?.thumbnailUrl, workflow?.thumbnail_url, nodes.find((node) => pickFirstString(node.previewUrl, node.preview_url, node.imageUrl, node.image_url)) ?.previewUrl, nodes.find((node) => pickFirstString(node.previewUrl, node.preview_url, node.imageUrl, node.image_url)) ?.preview_url, nodes.find((node) => pickFirstString(node.previewUrl, node.preview_url, node.imageUrl, node.image_url)) ?.imageUrl, nodes.find((node) => pickFirstString(node.previewUrl, node.preview_url, node.imageUrl, node.image_url)) ?.image_url, storyboards.find((item) => pickFirstString(item.imageUrl, item.image_url, item.coverUrl, item.cover_url)) ?.imageUrl, storyboards.find((item) => pickFirstString(item.imageUrl, item.image_url, item.coverUrl, item.cover_url)) ?.image_url, storyboards.find((item) => pickFirstString(item.imageUrl, item.image_url, item.coverUrl, item.cover_url)) ?.coverUrl, storyboards.find((item) => pickFirstString(item.imageUrl, item.image_url, item.coverUrl, item.cover_url)) ?.cover_url, videos.find((item) => pickFirstString(item.coverUrl, item.cover_url, item.thumbnailUrl, item.thumbnail_url)) ?.coverUrl, videos.find((item) => pickFirstString(item.coverUrl, item.cover_url, item.thumbnailUrl, item.thumbnail_url)) ?.cover_url, videos.find((item) => pickFirstString(item.coverUrl, item.cover_url, item.thumbnailUrl, item.thumbnail_url)) ?.thumbnailUrl, videos.find((item) => pickFirstString(item.coverUrl, item.cover_url, item.thumbnailUrl, item.thumbnail_url)) ?.thumbnail_url, ) || null ); } function mergeProjectSummaryWithContent(project: WebProjectSummary, payload: unknown): WebProjectSummary { const content = unwrapProjectContentPayload(payload); if (!isRecord(content)) return project; const workflow = getContentWorkflowRecord(content); const nodes = getContentNodes(content); const storyboards = getContentArray(content, "storyboards"); const videos = getContentArray(content, "videos"); const contentTitle = pickFirstString(content.name, content.projectName, content.title, workflow?.title); const contentDescription = pickFirstString( content.description, content.projectDescription, content.summary, workflow?.description, ); const imageCount = countNodesByKind(nodes, "image") || storyboards.filter((item) => pickFirstString(item.imageUrl, item.image_url, item.coverUrl, item.cover_url)).length; const videoCount = countNodesByKind(nodes, "video") || videos.length || storyboards.filter((item) => pickFirstString(item.videoUrl, item.video_url)).length; const storyboardCount = nodes.length || storyboards.length; return { ...project, name: chooseProjectText(contentTitle, project.name, project.name || "未命名项目"), description: chooseProjectDescription(contentDescription, project.description ?? null), thumbnailUrl: pickProjectPreviewUrl(content) || project.thumbnailUrl || null, storyboardCount: storyboardCount || project.storyboardCount, imageCount: imageCount || project.imageCount, videoCount: videoCount || project.videoCount, }; } async function enrichProjectSummariesWithContent(projects: WebProjectSummary[]): Promise { if (projectContentEnrichmentDisabled) return projects; const enriched = [...projects]; for (let index = 0; index < projects.length; index += PROJECT_CONTENT_ENRICH_CONCURRENCY) { if (projectContentEnrichmentDisabled) break; const batch = projects.slice(index, index + PROJECT_CONTENT_ENRICH_CONCURRENCY); const results = await Promise.allSettled( batch.map(async (project) => { const payload = await request(`/projects/${encodeURIComponent(project.id)}/content?resolveMedia=1`); return mergeProjectSummaryWithContent(project, payload); }), ); results.forEach((result, offset) => { const targetIndex = index + offset; if (result.status === "fulfilled") { enriched[targetIndex] = result.value; } else { const status = isHttpError(result.reason) ? result.reason.status : undefined; if (status === 404 || (typeof status === "number" && status >= 500)) { projectContentEnrichmentDisabled = true; } enriched[targetIndex] = { ...enriched[targetIndex], errorMessage: getErrorMessage(result.reason), }; } }); } return enriched; } function createWorkflowFingerprint(workflow: WebCanvasWorkflow): string { const payload = JSON.stringify({ title: workflow.title, description: workflow.description, settings: workflow.settings, nodes: workflow.nodes.map((node) => ({ id: node.id, kind: node.kind, label: node.label, detail: node.detail, previewUrl: node.previewUrl || "", })), edges: workflow.edges.map((edge) => ({ id: edge.id, source: edge.source, target: edge.target, label: edge.label || "", })), }); let hash = 0x811c9dc5; for (let i = 0; i < payload.length; i += 1) { hash ^= payload.charCodeAt(i); hash = Math.imul(hash, 0x01000193); } return `wf-${(hash >>> 0).toString(16).padStart(8, "0")}`; } function toActivePackages(value: unknown): WebUserSession["user"]["activePackages"] { if (!Array.isArray(value)) return undefined; const packages: NonNullable = []; for (const entry of value) { if (!isRecord(entry)) continue; packages.push({ name: toStringValue(entry.name, "Preview package"), expiresAt: toStringValue(entry.expiresAt ?? entry.expires_at, ""), remainingImage: toNumber(entry.remainingImage ?? entry.remaining_image), remainingVideo: toNumber(entry.remainingVideo ?? entry.remaining_video), remainingText: toNumber(entry.remainingText ?? entry.remaining_text), }); } return packages; } function normalizeUser(raw: unknown): WebUserSession["user"] | null { const payload = unwrapApiPayload(raw); const candidate = isRecord(payload) && isRecord(payload.user) ? payload.user : payload; if (!isRecord(candidate)) return null; const id = candidate.id ?? candidate.userId ?? candidate.user_id; const username = toStringValue(candidate.username ?? candidate.name, "预览用户"); if (id === undefined || !username) return null; return { id: typeof id === "number" && Number.isFinite(id) ? id : toStringValue(id), username, displayName: toNullableString(candidate.displayName ?? candidate.display_name ?? candidate.nickname ?? candidate.name), bio: toNullableString(candidate.bio ?? candidate.profileBio ?? candidate.profile_bio ?? candidate.description ?? candidate.intro), avatarUrl: toNullableString(candidate.avatarUrl ?? candidate.avatar_url), backgroundUrl: toNullableString( candidate.profileBackgroundUrl ?? candidate.profile_background_url ?? candidate.backgroundUrl ?? candidate.background_url ?? candidate.coverUrl ?? candidate.cover_url, ), email: toNullableString(candidate.email), emailVerified: candidate.emailVerified === true || candidate.email_verified === true || candidate.email_verified === 1, phone: toNullableString(candidate.phone), authProvider: toNullableString(candidate.authProvider ?? candidate.auth_provider), sessionId: toNullableString(candidate.sessionId ?? candidate.session_id), sessionStartedAt: toNullableString(candidate.sessionStartedAt ?? candidate.session_started_at), role: toNullableString(candidate.role) ?? undefined, accountType: toNullableString(candidate.accountType ?? candidate.account_type) ?? undefined, enterpriseId: toNullableString(candidate.enterpriseId ?? candidate.enterprise_id), enterpriseName: toNullableString(candidate.enterpriseName ?? candidate.enterprise_name), enterpriseRole: toNullableString(candidate.enterpriseRole ?? candidate.enterprise_role) ?? undefined, enterpriseAdminUserId: toNullableId(candidate.enterpriseAdminUserId ?? candidate.enterprise_admin_user_id), balanceCents: toNumber( candidate.balanceCents ?? candidate.balance_cents ?? candidate.userBalanceCents ?? candidate.user_balance_cents, ), enterpriseBalanceCents: toNumber( candidate.enterpriseBalanceCents ?? candidate.enterprise_balance_cents ?? candidate.enterpriseBalance ?? candidate.enterprise_balance, ), maxConcurrency: toNumber(candidate.maxConcurrency ?? candidate.max_concurrency), activePackages: toActivePackages(candidate.activePackages ?? candidate.active_packages), }; } function extractProjectRows(payload: unknown): Record[] { const unwrapped = unwrapApiPayload(payload); if (Array.isArray(unwrapped)) { return unwrapped.filter(isRecord); } if (!isRecord(unwrapped)) return []; const rows = unwrapped.projects ?? unwrapped.items; return Array.isArray(rows) ? rows.filter(isRecord) : []; } function isCanvasWorkflow(value: unknown): value is WebCanvasWorkflow { if (!isRecord(value)) return false; return ( typeof value.id === "string" && typeof value.title === "string" && typeof value.description === "string" && typeof value.version === "number" && value.version >= 1 && isRecord(value.settings) && Array.isArray(value.nodes) && Array.isArray(value.edges) ); } function isLegacyWorkflowData(value: unknown): value is Record { if (!isRecord(value)) return false; return ( typeof value.version === "number" && Array.isArray(value.nodes) && Array.isArray(value.edges) ); } function migrateLegacyWorkflowData(old: Record, wrapper: Record, projectId: string): WebCanvasWorkflow { const viewport = isRecord(old.viewport) ? old.viewport : {}; return { id: old.id as string || projectId, version: 1, title: String(wrapper.name || wrapper.title || "未命名项目"), description: String(wrapper.description || ""), source: (wrapper.source as WebCanvasWorkflow["source"]) || "blank", settings: { model: String(isRecord(old.settings) ? old.settings.model || "omni-水果 Pro" : "omni-水果 Pro"), ratio: String(isRecord(old.settings) ? old.settings.ratio || "1:1" : "1:1"), duration: String(isRecord(old.settings) ? old.settings.duration || "0s" : "0s"), resolution: String(isRecord(old.settings) ? old.settings.resolution || "2K" : "2K"), }, viewport: { x: Number(viewport.x || 0), y: Number(viewport.y || 0), zoom: Number(viewport.zoom || viewport.scale || 1), }, nodes: (Array.isArray(old.nodes) ? old.nodes : []).map((n: unknown) => isRecord(n) ? { ...n, position: isRecord(n.position) ? { ...n.position } : { x: 0, y: 0 } } : n) as WebCanvasWorkflowNode[], edges: Array.isArray(old.edges) ? old.edges : [], packages: Array.isArray(old.packages) ? old.packages : [], }; } function cloneWorkflow(workflow: WebCanvasWorkflow): WebCanvasWorkflow { return { ...workflow, settings: { ...workflow.settings }, nodes: workflow.nodes.map((node) => ({ ...node, position: { ...node.position } })), edges: workflow.edges.map((edge) => ({ ...edge })), }; } function normalizeProjectContent(payload: unknown, projectId: string): WebCanvasWorkflow { const unwrapped = unwrapApiPayload(payload); const content = isRecord(unwrapped) && "content" in unwrapped ? unwrapped.content : unwrapped; // New format: content.workflowData is a full WebCanvasWorkflow if (isRecord(content) && isCanvasWorkflow(content.workflowData)) { return cloneWorkflow({ ...content.workflowData, id: content.workflowData.id || projectId }); } // New format: content.workflow is a full WebCanvasWorkflow if (isRecord(content) && isCanvasWorkflow(content.workflow)) { return cloneWorkflow({ ...content.workflow, id: content.workflow.id || projectId }); } // Content itself is a WebCanvasWorkflow if (isCanvasWorkflow(content)) { return cloneWorkflow({ ...content, id: content.id || projectId }); } // Legacy format: wrapper has name/description, workflowData has nodes/edges/viewport if (isRecord(content) && isLegacyWorkflowData(content.workflowData)) { return migrateLegacyWorkflowData(content.workflowData, content, projectId); } throw new Error("Project content did not include a canvas workflow"); } function normalizeLoginResult(payload: unknown): WebUserSession | null { const unwrapped = unwrapApiPayload(payload); if (!isRecord(unwrapped) || typeof unwrapped.token !== "string") return null; const user = normalizeUser(unwrapped.user ?? unwrapped); if (!user) return null; return { token: unwrapped.token, user, source: "server", }; } function updateStoredSessionUser(user: WebUserSession["user"]): WebUserSession | null { const stored = readStoredSession(); if (!stored) return null; const session: WebUserSession = { ...stored, user: { ...stored.user, ...user, }, }; writeStoredSession(session); return session; } function normalizeUsageSummary(payload: unknown): WebUsageSummary { const unwrapped = unwrapApiPayload(payload); const raw = isRecord(unwrapped) ? unwrapped : {}; return { balanceCents: toNumber( raw.balanceCents ?? raw.balance_cents ?? raw.currentBalanceCents ?? raw.current_balance_cents, ), enterpriseBalanceCents: toOptionalNumber( raw.enterpriseBalanceCents ?? raw.enterprise_balance_cents ?? raw.enterpriseBalance ?? raw.enterprise_balance, ) ?? undefined, imageUsed: toNumber(raw.imageUsed ?? raw.image_used ?? raw.imageCount ?? raw.image_count), videoUsed: toNumber(raw.videoUsed ?? raw.video_used ?? raw.videoCount ?? raw.video_count), textUsed: toNumber( raw.textUsed ?? raw.text_used ?? raw.textTokens ?? raw.text_tokens ?? raw.total_prompt_tokens, ), source: "server", }; } function toOptionalNumber(value: unknown): number | null { if (value === undefined || value === null || value === "") return null; const numberValue = typeof value === "number" ? value : Number(value); return Number.isFinite(numberValue) ? numberValue : null; } function normalizeEnterpriseUsageSummary(payload: unknown): WebEnterpriseUsageSummary { const unwrapped = unwrapApiPayload(payload); const raw = isRecord(unwrapped) ? unwrapped : {}; const memberRows = Array.isArray(raw.members) ? raw.members.filter(isRecord) : []; const modelRows = Array.isArray(raw.modelBreakdown) ? raw.modelBreakdown.filter(isRecord) : Array.isArray(raw.model_breakdown) ? raw.model_breakdown.filter(isRecord) : []; const recordRows = Array.isArray(raw.records) ? raw.records.filter(isRecord) : Array.isArray(raw.items) ? raw.items.filter(isRecord) : []; const trendRows = Array.isArray(raw.dailyTrend) ? raw.dailyTrend.filter(isRecord) : Array.isArray(raw.daily_trend) ? raw.daily_trend.filter(isRecord) : []; return { enterpriseId: toStringValue(raw.enterpriseId ?? raw.enterprise_id), enterpriseName: toStringValue(raw.enterpriseName ?? raw.enterprise_name, "Enterprise"), balanceCents: toNumber(raw.balanceCents ?? raw.balance_cents ?? raw.enterpriseBalanceCents ?? raw.enterprise_balance_cents), totalUsedCents: toNumber(raw.totalUsedCents ?? raw.total_used_cents ?? raw.usedCents ?? raw.used_cents), members: memberRows.map((member, index) => ({ userId: toIdValue(member.userId ?? member.user_id ?? member.id, `member-${index + 1}`), username: toStringValue(member.username ?? member.userName ?? member.user_name ?? member.name, "employee"), displayName: toNullableString(member.displayName ?? member.display_name ?? member.nickname ?? member.name), role: toStringValue(member.role ?? member.enterpriseRole ?? member.enterprise_role, "employee"), usedCents: toNumber( member.usedCents ?? member.used_cents ?? member.amountCents ?? member.amount_cents ?? member.totalUsedCents ?? member.total_used_cents, ), taskCount: toNumber(member.taskCount ?? member.task_count ?? member.calls ?? member.count), lastUsedAt: toNullableString(member.lastUsedAt ?? member.last_used_at ?? member.updatedAt ?? member.updated_at), })), modelBreakdown: modelRows.map((model) => ({ model: toStringValue(model.model ?? model.modelId ?? model.model_id, "unknown"), usedCents: toNumber(model.usedCents ?? model.used_cents ?? model.amountCents ?? model.amount_cents), taskCount: toNumber(model.taskCount ?? model.task_count ?? model.calls ?? model.count), })), dailyTrend: trendRows.map((row) => ({ date: toStringValue(row.date ?? row.day), usedCents: toNumber(row.usedCents ?? row.used_cents ?? row.amountCents ?? row.amount_cents), taskCount: toNumber(row.taskCount ?? row.task_count ?? row.count), })), records: recordRows.map((record, index) => ({ id: toStringValue(record.id ?? record.ledgerId ?? record.ledger_id ?? record.taskId ?? record.task_id, `record-${index + 1}`), userId: toIdValue(record.userId ?? record.user_id ?? record.memberId ?? record.member_id, "unknown"), username: toStringValue(record.username ?? record.userName ?? record.user_name ?? record.name, "employee"), model: toStringValue(record.model ?? record.modelId ?? record.model_id, "unknown"), taskType: toStringValue(record.taskType ?? record.task_type ?? record.type, "image"), resolution: toNullableString(record.resolution ?? record.quality), durationSeconds: toOptionalNumber(record.durationSeconds ?? record.duration_seconds ?? record.duration), amountCents: toNumber(record.amountCents ?? record.amount_cents ?? record.usedCents ?? record.used_cents), prompt: toNullableString(record.prompt), status: toStringValue(record.status, "completed"), createdAt: toStringValue(record.createdAt ?? record.created_at ?? record.updatedAt ?? record.updated_at), })), source: "server", }; } 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(); const ossKey = `users/${userId}/projects/${projectId}/current/project.json`; return { id: projectId, name: workflow.title.trim() || "未命名项目", description: workflow.description.trim() || null, ossKey, thumbnailUrl: workflow.nodes.find((node) => node.previewUrl)?.previewUrl || null, storyboardCount: workflow.nodes.length, imageCount: workflow.nodes.filter((node) => node.kind === "image").length, videoCount: workflow.nodes.filter((node) => node.kind === "video").length, fileSize: JSON.stringify(workflow).length, fingerprint: createWorkflowFingerprint(workflow), deviceId: "web", baseRevision: null, forceOverwrite: true, saveReason: "create", }; } export const keyServerClient = { getBaseUrl, getStoredSession: readStoredSession, updateStoredSessionUser, clearSession() { writeStoredSession(null); }, async login(input: LoginInput): Promise { const session = normalizeLoginResult( await request("/auth/login", { method: "POST", body: { username: input.username.trim(), password: input.password, }, }), ); if (!session) { throw new Error("Login response did not include a token and user"); } writeStoredSession(session); return session; }, async register(input: RegisterInput): Promise { const session = normalizeLoginResult( await request("/auth/register", { method: "POST", body: { username: input.username.trim(), password: input.password, betaCode: input.betaCode.trim(), }, }), ); if (!session) { throw new Error("Register response did not include a token and user"); } writeStoredSession(session); return session; }, async loginEmail(input: EmailAuthInput): Promise { const session = normalizeLoginResult( await request("/auth/login-email", { method: "POST", body: { email: input.email.trim(), password: input.password, }, }), ); if (!session) { throw new Error("Email login response did not include a token and user"); } writeStoredSession(session); return session; }, async registerEmail(input: EmailAuthInput): Promise { const session = normalizeLoginResult( await request("/auth/register-email", { method: "POST", body: { email: input.email.trim(), username: input.username?.trim() || undefined, password: input.password, code: input.code?.trim() || undefined, betaCode: input.betaCode?.trim() || undefined, }, }), ); if (!session) { throw new Error("Email register response did not include a token and user"); } writeStoredSession(session); return session; }, async sendSmsCode(phone: string, purpose: "login" | "register", betaCode?: string): Promise<{ cooldownSeconds?: number; ttlSeconds?: number }> { return request<{ cooldownSeconds?: number; ttlSeconds?: number }>("/auth/sms/send", { method: "POST", 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", { method: "POST", body: { phone: input.phone.trim(), code: input.code.trim(), }, }), ); if (!session) { throw new Error("Phone login response did not include a token and user"); } writeStoredSession(session); return session; }, async registerPhone(input: PhoneAuthInput): Promise { const session = normalizeLoginResult( await request("/auth/register-phone", { method: "POST", body: { phone: input.phone.trim(), code: input.code.trim(), password: input.password || "", betaCode: input.betaCode?.trim() || undefined, }, }), ); if (!session) { throw new Error("Phone register response did not include a token and user"); } writeStoredSession(session); return session; }, async getWechatLoginTicket(): Promise { const browserCrypto = typeof globalThis !== "undefined" ? globalThis.crypto : undefined; const state = browserCrypto && "randomUUID" in browserCrypto ? browserCrypto.randomUUID().replace(/-/g, "") : `${Date.now().toString(36)}${Math.random().toString(36).slice(2)}`; return request(`/auth/wechat/login-url?state=${encodeURIComponent(state)}`); }, async getWechatLoginSession(state: string): Promise { const response = await request(`/auth/wechat/session?state=${encodeURIComponent(state)}`); const raw = isRecord(response) ? response : {}; const session = normalizeLoginResult(raw); if (session) { writeStoredSession(session); return { status: "completed", session }; } const status = toStringValue(raw.status, "pending"); return { status, error: toNullableString(raw.error) ?? undefined, }; }, async updateProfile(input: UpdateProfileInput): Promise { const user = normalizeUser( await request("/auth/profile", { method: "PUT", body: { ...input, profileBackgroundUrl: input.profileBackgroundUrl ?? input.backgroundUrl ?? undefined, }, }), ); if (!user) { throw new Error("Profile response did not include a user"); } updateStoredSessionUser(user); return user; }, async getCurrentSession(): Promise { const stored = readStoredSession(); if (!stored) { return null; } try { const user = normalizeUser(await request("/auth/me", { token: stored.token })); if (!user) { throw new Error("Current-user response did not include a user"); } const session: WebUserSession = { ...stored, user, source: "server", errorMessage: undefined }; writeStoredSession(session); return session; } catch (error) { if (isHttpError(error) && (error.status === 401 || error.status === 403)) { writeStoredSession(null); return null; } return { ...stored, source: "server", errorMessage: getErrorMessage(error), }; } }, async listProjects(): Promise { const summaries = extractProjectRows(await request("/projects")).map((project) => toProjectSummary(project, "server"), ); return enrichProjectSummariesWithContent(summaries); }, async getProjectContent(projectId: string): Promise { const stored = readStoredSession(); if (!stored) { throw new Error("需要先登录"); } const safeProjectId = encodeURIComponent(projectId.trim()); if (!safeProjectId) { throw new Error("Project id is required"); } const response = await request(`/projects/${safeProjectId}/content?resolveMedia=1`); return normalizeProjectContent(response, projectId); }, async getUsageSummary(): Promise { const stored = readStoredSession(); return normalizeUsageSummary(await request("/user/usage/summary", { token: stored?.token })); }, async getEnterpriseUsageSummary(): Promise { const stored = readStoredSession(); return normalizeEnterpriseUsageSummary(await request("/enterprise/usage/summary", { token: stored?.token })); }, async getPersonalUsageSummary(): Promise { 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(); if (!stored) { const error = new Error("需要先登录"); throw error; } const payload = buildProjectUpsertPayload(workflow, stored); const response = await request("/projects/upsert", { method: "POST", body: payload, }); const projectPayload = isRecord(response) ? response.project ?? response : response; if (!isRecord(projectPayload)) { throw new Error("Project response did not include a project"); } return toProjectSummary(projectPayload, "server"); }, async saveProjectContent(projectId: string, workflow: WebCanvasWorkflow): Promise { const stored = readStoredSession(); if (!stored) { throw new Error("需要先登录"); } const response = await request(`projects/${encodeURIComponent(projectId)}/content`, { method: "PUT", body: { content: { name: workflow.title, description: workflow.description, workflowData: workflow, nodes: workflow.nodes, edges: workflow.edges, }, meta: { name: workflow.title, description: workflow.description, thumbnailUrl: workflow.nodes.find((node) => node.previewUrl)?.previewUrl || null, storyboardCount: workflow.nodes.length, imageCount: workflow.nodes.filter((node) => node.kind === "image").length, videoCount: workflow.nodes.filter((node) => node.kind === "video").length, }, saveReason: "web-create", deviceId: "web", forceOverwrite: true, }, }); const rawProject = isRecord(response) && isRecord(response.project) ? response.project : response; if (!isRecord(rawProject)) { return buildProjectSummaryFromWorkflow(workflow, "server"); } return toProjectSummary(rawProject, "server"); }, async deleteProject(projectId: string, options?: DeleteProjectOptions): Promise { const stored = readStoredSession(); if (!stored) { throw new Error("需要先登录"); } const path = options?.cleanupUserData ? `projects/${encodeURIComponent(projectId)}?cleanupUserData=1` : `projects/${encodeURIComponent(projectId)}`; await request(path, { method: "DELETE", }); }, 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; }, };