Initial ecommerce standalone package

This commit is contained in:
2026-06-10 14:06:16 +08:00
commit 3d98933e24
241 changed files with 135283 additions and 0 deletions
+355
View File
@@ -0,0 +1,355 @@
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"];
export interface AdVideoUserConfig {
platform: string;
aspectRatio: string;
durationSeconds: number;
style: string;
language: string;
market: string;
needVoiceover: boolean;
needSubtitle: boolean;
conversionFocus: "conversion" | "brand";
}
export interface ProductSummary {
product_name: string;
category: string;
appearance: string;
materials: string[];
colors: string[];
core_features: string[];
target_users: string[];
usage_scenarios: string[];
selling_points: string[];
risk_notes: string[];
}
export interface SellingPoint {
point: string;
evidence: string;
ad_expression: string;
}
export interface SellingPointResult {
primary_selling_points: SellingPoint[];
secondary_selling_points: SellingPoint[];
unsupported_claims: string[];
compliance_warnings: string[];
}
export interface CreativeOption {
creative_id: string;
creative_type: string;
hook: string;
target_user: string;
main_message: string;
emotional_tone: string;
recommended_platform: string;
reason: string;
}
export interface StoryboardScene {
scene_id: number;
duration: string;
scene_goal: string;
visual_description: string;
product_focus: string;
camera_movement: string;
background: string;
lighting: string;
subtitle: string;
voiceover: string;
transition: string;
}
export interface Storyboard {
video_title: string;
duration: string;
aspect_ratio: string;
target_platform: string;
language: string;
scenes: StoryboardScene[];
}
export interface VideoPrompt {
scene_id: number;
positive_prompt: string;
negative_prompt: string;
reference_requirements: string;
consistency_rules: string;
text_overlay: string;
}
export interface ComplianceCheck {
risk_level: "low" | "medium" | "high";
issues: Array<{ field: string; problem: string; suggestion: string }>;
allow_video_generation: boolean;
}
function extractJson(text: string): unknown {
const fenced = text.match(/```(?:json)?\s*([\s\S]*?)```/);
const raw = fenced ? fenced[1].trim() : text.trim();
const start = raw.search(/[[{]/);
const slice = start >= 0 ? raw.slice(start) : raw;
try {
return JSON.parse(slice);
} catch {
throw new Error("AI 返回内容不是有效的 JSON");
}
}
interface ChatMessage {
role: "system" | "user";
content: string;
}
const MAX_RETRIES = 3;
const RETRY_BASE_MS = 2000;
const CHAT_TIMEOUT_MS = 180_000; // 3 minutes per AI call (server times out at 120s + network slack)
// 5xx, 429, network failures, timeouts, and AbortError-from-timeout are all retryable
function isTransientError(err: unknown): boolean {
if (!(err instanceof Error)) return false;
const msg = err.message.toLowerCase();
if (/\b(429|500|502|503|504|520|521|522|524)\b/.test(msg)) return true;
if (msg.includes("signal timed out") || msg.includes("timeout")) return true;
if (msg.includes("failed to fetch") || msg.includes("networkerror") || msg.includes("network error")) return true;
if (msg.includes("ai 调用失败") || msg.includes("图片理解调用失败")) return true; // generic upstream failures
return false;
}
async function retryOnTransient<T>(fn: () => Promise<T>, signal?: AbortSignal): Promise<T> {
let lastErr: unknown;
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
try {
return await fn();
} catch (err) {
lastErr = err;
if (signal?.aborted) throw err;
// External AbortError caused by our timeoutSignal — retryable
if (err instanceof Error && err.name === "AbortError" && !signal?.aborted) {
if (attempt === MAX_RETRIES) throw err;
const delay = RETRY_BASE_MS * 2 ** attempt + Math.random() * 1000;
await new Promise((r) => setTimeout(r, delay));
continue;
}
if (attempt === MAX_RETRIES) throw err;
if (!isTransientError(err)) throw err;
const delay = RETRY_BASE_MS * 2 ** attempt + Math.random() * 1000;
await new Promise((r) => setTimeout(r, delay));
}
}
throw lastErr instanceof Error ? lastErr : new Error("AI 调用失败:已重试多次");
}
async function chat(
systemPrompt: string,
userContent: string,
options?: { model?: string; signal?: AbortSignal },
): Promise<string> {
const candidateModels = options?.model ? [options.model] : TEXT_MODELS;
let lastError: Error | null = null;
for (const model of candidateModels) {
try {
return await retryOnTransient(async () => {
const messages: ChatMessage[] = [
{ role: "system", content: systemPrompt },
{ role: "user", content: userContent },
];
const timeoutSignal = AbortSignal.timeout(CHAT_TIMEOUT_MS);
const combinedSignal = options?.signal
? AbortSignal.any([options.signal, timeoutSignal])
: timeoutSignal;
const res = await fetch(buildApiUrl("ai/chat"), {
method: "POST",
headers: buildAuthHeaders(),
body: JSON.stringify({ model, messages, stream: false, temperature: 0.4 }),
signal: combinedSignal,
});
if (!res.ok) {
const errBody = await res.text().catch(() => "");
throw new Error(`AI 调用失败 (${res.status})${errBody ? `: ${errBody.slice(0, 120)}` : ""}`);
}
const payload = await res.json();
const content: string =
payload?.content ?? payload?.choices?.[0]?.message?.content ?? payload?.text ?? "";
if (!content) throw new Error("模型未返回有效内容");
return content;
}, 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(
systemPrompt: string,
text: string,
imageUrls: string[],
signal?: AbortSignal,
): Promise<string> {
const content = [
...imageUrls.map((url) => ({ type: "image_url", image_url: { url } })),
{ type: "text", text },
];
const messages = [
{ role: "system", content: systemPrompt },
{ role: "user", content },
];
let lastError: Error | null = null;
for (const model of VISION_MODELS) {
const timeoutSignal = AbortSignal.timeout(CHAT_TIMEOUT_MS);
const combinedSignal = signal
? AbortSignal.any([signal, timeoutSignal])
: timeoutSignal;
try {
const out = await retryOnTransient(async () => {
const res = await fetch(buildApiUrl("ai/chat"), {
method: "POST",
headers: buildAuthHeaders(),
body: JSON.stringify({ model, messages, stream: false, temperature: 0.3 }),
signal: combinedSignal,
});
if (!res.ok) {
const errBody = await res.text().catch(() => "");
if (errBody.includes("image format")) throw new Error("IMAGE_FORMAT_FALLBACK");
throw new Error(`图片理解调用失败 (${res.status})${errBody ? `: ${errBody.slice(0, 120)}` : ""}`);
}
const payload = await res.json();
const result: string =
payload?.content ?? payload?.choices?.[0]?.message?.content ?? payload?.text ?? "";
if (!result) throw new Error("图片理解未返回有效内容");
return result;
}, 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 = `你是电商产品图片分析专家。请分析用户提供的产品图片,识别产品主体、外观、颜色、材质、形状、尺寸感、品牌标识、关键部件、可视化卖点和适合展示的镜头角度。请用简洁的中文段落描述,不要编造图片中看不到的信息。`;
export async function analyzeProductImages(
imageUrls: string[],
signal?: AbortSignal,
): Promise<string> {
if (imageUrls.length === 0) return "";
return visionChat(IMAGE_UNDERSTANDING_PROMPT, "请分析这些产品图片的视觉特征。", imageUrls, signal);
}
const PRODUCT_SUMMARY_PROMPT = `你是商品信息理解专家。根据产品图片理解结果和说明书文本,输出结构化的商品信息。严格按以下 JSON 格式返回,不要任何额外解释:
{"product_name":"","category":"","appearance":"","materials":[],"colors":[],"core_features":[],"target_users":[],"usage_scenarios":[],"selling_points":[],"risk_notes":[]}
要求:只描述资料中真实存在的信息,不要编造说明书或图片中不存在的功能。risk_notes 列出可能涉及夸大、医疗功效、绝对化用语等风险点。`;
export async function buildProductSummary(
imageDescription: string,
manualText: string,
signal?: AbortSignal,
): Promise<ProductSummary> {
const userContent = `【产品图片理解结果】\n${imageDescription || "(无图片)"}\n\n【产品说明书/详情文本】\n${manualText || "(无文本)"}`;
const text = await chat(PRODUCT_SUMMARY_PROMPT, userContent, { signal });
return extractJson(text) as ProductSummary;
}
const SELLING_POINT_PROMPT = `你是电商卖点提炼专家。将商品信息拆分为不同层级卖点。严格按以下 JSON 格式返回,不要任何额外解释:
{"primary_selling_points":[{"point":"","evidence":"","ad_expression":""}],"secondary_selling_points":[{"point":"","evidence":"","ad_expression":""}],"unsupported_claims":[],"compliance_warnings":[]}
要求:每个卖点必须有来源依据(evidence),依据来自输入的商品信息。不得凭空增加功能。无依据的卖点放入 unsupported_claims。涉及夸大、医疗、绝对化用语的放入 compliance_warnings。`;
export async function extractSellingPoints(
summary: ProductSummary,
signal?: AbortSignal,
): Promise<SellingPointResult> {
const text = await chat(SELLING_POINT_PROMPT, `【商品结构化信息】\n${JSON.stringify(summary, null, 2)}`, { signal });
return extractJson(text) as SellingPointResult;
}
function configBlock(config: AdVideoUserConfig): string {
return `【用户配置】\n目标平台:${config.platform}\n视频比例:${config.aspectRatio}\n时长:${config.durationSeconds}\n广告风格:${config.style}\n语言:${config.language}\n目标市场:${config.market}\n旁白:${config.needVoiceover ? "需要" : "不需要"}\n字幕:${config.needSubtitle ? "需要" : "不需要"}\n侧重:${config.conversionFocus === "conversion" ? "强转化" : "品牌展示"}`;
}
const CREATIVE_PROMPT = `你是电商广告创意专家。根据商品卖点和用户配置,生成至少 3 个差异化的广告创意方向。严格按以下 JSON 格式返回,不要任何额外解释:
{"creative_options":[{"creative_id":"A","creative_type":"","hook":"","target_user":"","main_message":"","emotional_tone":"","recommended_platform":"","reason":""}]}
要求:每个方向围绕真实卖点,有清晰广告逻辑,方向之间有明显差异。`;
export async function generateCreativeOptions(
selling: SellingPointResult,
config: AdVideoUserConfig,
signal?: AbortSignal,
): Promise<CreativeOption[]> {
const userContent = `【卖点】\n${JSON.stringify(selling.primary_selling_points, null, 2)}\n\n${configBlock(config)}`;
const text = await chat(CREATIVE_PROMPT, userContent, { signal });
const parsed = extractJson(text) as { creative_options?: CreativeOption[] };
return Array.isArray(parsed.creative_options) ? parsed.creative_options : [];
}
const STORYBOARD_PROMPT = `你是电商短视频分镜师。根据选定的广告创意方向、商品信息和用户配置,输出结构化视频分镜。严格按以下 JSON 格式返回,不要任何额外解释:
{"video_title":"","duration":"","aspect_ratio":"","target_platform":"","language":"","scenes":[{"scene_id":1,"duration":"3s","scene_goal":"","visual_description":"","product_focus":"","camera_movement":"","background":"","lighting":"","subtitle":"","voiceover":"","transition":""}]}
要求:开头3秒有吸引点,中段展示核心卖点,结尾有行动号召。各镜头时长之和等于配置总时长。不要出现说明书中不存在的功能,不要设计视频模型难以稳定生成的复杂动作。`;
export async function generateStoryboard(
creative: CreativeOption,
summary: ProductSummary,
config: AdVideoUserConfig,
signal?: AbortSignal,
): Promise<Storyboard> {
const userContent = `【选定创意方向】\n${JSON.stringify(creative, null, 2)}\n\n【商品信息】\n${JSON.stringify(summary, null, 2)}\n\n${configBlock(config)}`;
const text = await chat(STORYBOARD_PROMPT, userContent, { signal });
return extractJson(text) as Storyboard;
}
const VIDEO_PROMPT_PROMPT = `你是 AI 视频模型提示词工程师。为每个分镜生成视频模型提示词。严格按以下 JSON 格式返回一个数组,不要任何额外解释:
[{"scene_id":1,"positive_prompt":"","negative_prompt":"","reference_requirements":"","consistency_rules":"","text_overlay":""}]
正向提示词需包含:产品主体、外观、颜色、材质、使用场景、镜头构图、镜头运动、光线风格、背景环境、广告质感、画面节奏。
负面提示词需包含:不改变产品外观/颜色、不添加不存在的部件、不生成错误Logo、不生成模糊文字、不生成虚假功能演示、不生成畸形手部、不生成夸张功效、不生成医学暗示。
字幕和文字建议后期叠加(text_overlay),不要让视频模型直接生成文字。`;
export async function generateVideoPrompts(
storyboard: Storyboard,
summary: ProductSummary,
signal?: AbortSignal,
): Promise<VideoPrompt[]> {
const userContent = `【分镜脚本】\n${JSON.stringify(storyboard.scenes, null, 2)}\n\n【产品外观特征(一致性参考)】\n外观:${summary.appearance}\n颜色:${summary.colors.join("、")}\n材质:${summary.materials.join("、")}`;
const text = await chat(VIDEO_PROMPT_PROMPT, userContent, { signal });
const parsed = extractJson(text);
return Array.isArray(parsed) ? (parsed as VideoPrompt[]) : [];
}
const COMPLIANCE_PROMPT = `你是电商广告合规质检专家。检查文案和卖点是否存在虚假宣传、绝对化用语(如"最""第一""100%")、医疗功效暗示、高风险品类违规表达。严格按以下 JSON 格式返回,不要任何额外解释:
{"risk_level":"low","issues":[{"field":"","problem":"","suggestion":""}],"allow_video_generation":true}
risk_level 取值 low/medium/high。存在高风险违规时 allow_video_generation 设为 false。`;
export async function checkCompliance(
summary: ProductSummary,
selling: SellingPointResult,
storyboard: Storyboard,
signal?: AbortSignal,
): Promise<ComplianceCheck> {
const userContent = `【卖点】\n${JSON.stringify(selling, null, 2)}\n\n【分镜文案/旁白/字幕】\n${JSON.stringify(storyboard.scenes.map((s) => ({ subtitle: s.subtitle, voiceover: s.voiceover })), null, 2)}\n\n【风险点】\n${summary.risk_notes.join("、")}`;
const text = await chat(COMPLIANCE_PROMPT, userContent, { signal });
return extractJson(text) as ComplianceCheck;
}
+559
View File
@@ -0,0 +1,559 @@
import {
buildApiUrl,
buildAuthHeaders,
isRecord,
readJsonResponse,
serverRequest,
throwResponseError,
} from "./serverConnection";
import { isOptionalApiRouteMissing } from "./apiErrorUtils";
import type { WebGenerationPreviewTask } from "../types";
export interface ImageGenInput {
projectId?: string;
conversationId?: number;
model: string;
prompt: string;
ratio?: string;
quality?: string;
gridMode?: string;
referenceUrls?: string[];
}
export interface ImageProviderDebug {
requestedModel?: string;
effectiveModel?: string;
primaryProvider?: string;
fallbackProviders?: string[];
route?: string[];
candidates?: Array<{
provider?: string;
transport?: string;
model?: string;
requestedModel?: string;
billingProvider?: string;
fallbackOf?: string;
}>;
}
export interface ImageTaskCreateResponse {
taskId: string;
providerDebug?: ImageProviderDebug;
}
type ImageRouteDebugEntry = Record<string, unknown> & {
at: string;
label: string;
};
export interface VideoGenInput {
projectId?: string;
conversationId?: number;
model: string;
prompt: string;
ratio?: string;
duration?: number;
quality?: string;
resolution?: string;
frameMode?: string;
referenceUrls?: string[];
imageUrl?: string;
audioUrl?: string;
muted?: boolean;
hasReferenceVideo?: boolean;
style?: "speech" | "sing" | "performance" | string;
}
export interface VideoEditInput {
projectId?: string;
conversationId?: number;
videoUrl: string;
referenceUrls: string[];
prompt?: string;
model?: string;
ratio?: string;
resolution?: string;
}
export interface VideoSuperResolveInput {
projectId?: string;
conversationId?: number;
videoUrl: string;
bitRate?: number;
provider?: string;
style?: number;
videoFps?: number;
minLen?: 540 | 720;
useSR?: boolean;
animateEmotion?: boolean;
}
export interface EraseSubtitlesInput {
videoUrl: string;
bx?: number;
by?: number;
bw?: number;
bh?: number;
}
export interface ImageEditInput {
imageUrl: string;
function: string;
prompt?: string;
n?: number;
}
export interface ImageSuperResolveInput {
projectId?: string;
conversationId?: number;
imageUrl: string;
scale?: "2x" | "4x" | number;
}
export interface UploadAssetInput {
dataUrl: string;
name?: string;
mimeType?: string;
scope?: "profile-avatar" | "profile-background" | string;
}
export interface UploadAssetByUrlInput {
sourceUrl: string;
name?: string;
mimeType?: string;
scope?: string;
}
export type ChatMessageContent =
| string
| Array<{ type: "text"; text: string } | { type: "image_url"; image_url: { url: string } }>;
export interface ChatInput {
model: string;
messages: Array<{ role: string; content: ChatMessageContent }>;
stream?: boolean;
temperature?: number;
}
export interface ChatUsage {
promptTokens?: number;
completionTokens?: number;
totalTokens?: number;
}
export interface AiTaskStatus {
taskId: string;
projectId?: string;
conversationId?: number | null;
clientQueueId?: string | null;
type: "image" | "video";
status: "pending" | "running" | "completed" | "failed" | "cancelled";
progress: number;
resultUrl: string | null;
error: string | null;
params?: Record<string, unknown>;
createdAt: string;
updatedAt: string;
completedAt?: string | null;
}
function normalizeTaskStatus(status: AiTaskStatus["status"]): WebGenerationPreviewTask["status"] {
if (status === "running" || status === "completed" || status === "failed") return status;
if (status === "cancelled") return "failed";
return "queued";
}
function taskTitle(task: AiTaskStatus): string {
const prompt = typeof task.params?.prompt === "string" ? task.params.prompt.trim() : "";
if (prompt) return prompt.length > 20 ? `${prompt.slice(0, 20)}...` : prompt;
return task.type === "video" ? "\u89c6\u9891\u751f\u6210\u4efb\u52a1" : "\u56fe\u50cf\u751f\u6210\u4efb\u52a1";
}
function toPreviewTask(task: AiTaskStatus): WebGenerationPreviewTask {
return {
id: task.taskId,
title: taskTitle(task),
type: task.type,
status: normalizeTaskStatus(task.status),
progress: Math.max(0, Math.min(100, Math.trunc(task.progress || 0))),
prompt: typeof task.params?.prompt === "string" ? task.params.prompt : taskTitle(task),
createdAt: task.createdAt,
projectId: task.projectId || undefined,
outputUrl: task.resultUrl || undefined,
source: "server",
errorMessage: task.error || undefined,
};
}
function parseContentDispositionFilename(value: string | null): string | undefined {
if (!value) return undefined;
const utf8Match = value.match(/filename\*=UTF-8''([^;]+)/i);
if (utf8Match?.[1]) {
try {
return decodeURIComponent(utf8Match[1].trim());
} catch {
return utf8Match[1].trim();
}
}
const plainMatch = value.match(/filename="?([^";]+)"?/i);
return plainMatch?.[1]?.trim() || undefined;
}
function extractTaskList(payload: unknown): AiTaskStatus[] {
if (Array.isArray(payload)) return payload as AiTaskStatus[];
if (!isRecord(payload)) return [];
const rows = payload.tasks ?? payload.items;
return Array.isArray(rows) ? (rows as AiTaskStatus[]) : [];
}
function getStoredSessionRole(): string {
try {
if (typeof window === "undefined") return "";
const raw = window.localStorage.getItem("omniai-web-session");
if (!raw) return "";
const session = JSON.parse(raw);
return String(session?.user?.role || "").trim().toLowerCase();
} catch {
return "";
}
}
function emitImageRouteDebug(label: string, payload: Record<string, unknown>): void {
// Only emit console logs for admin users — hides enterprise routing details
if (getStoredSessionRole() === "admin") {
const entry: ImageRouteDebugEntry = {
at: new Date().toISOString(),
label,
...payload,
};
try {
console.log(`${label} ${JSON.stringify(entry)}`);
} catch {
console.log(label, entry);
}
}
if (typeof window === "undefined") return;
const debugWindow = window as Window & { __OMNIAI_IMAGE_ROUTE_DEBUG__?: ImageRouteDebugEntry[] };
const previousEntries = Array.isArray(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];
}
let taskHistoryRouteMissing = false;
const TASK_SUBMIT_TIMEOUT_MS = 90_000;
const TASK_STATUS_TIMEOUT_MS = 20_000;
const NON_RETRYING_REQUEST = { maxRetries: 0 };
export const aiGenerationClient = {
async createImageTask(input: ImageGenInput): Promise<ImageTaskCreateResponse> {
const requestUrl = buildApiUrl("ai/image");
emitImageRouteDebug("[ai/image-request]", {
url: requestUrl,
model: input.model,
ratio: input.ratio,
quality: input.quality,
gridMode: input.gridMode,
referenceCount: input.referenceUrls?.length || 0,
projectId: input.projectId,
conversationId: input.conversationId,
});
const payload = await serverRequest<ImageTaskCreateResponse>("ai/image", {
method: "POST",
body: input,
timeoutMs: TASK_SUBMIT_TIMEOUT_MS,
maxRetries: NON_RETRYING_REQUEST.maxRetries,
fallbackMessage: "Image generation request failed",
});
if (payload.providerDebug) {
emitImageRouteDebug("[ai/image-provider-debug]", payload.providerDebug as Record<string, unknown>);
}
return payload;
},
async createVideoTask(input: VideoGenInput): Promise<{ taskId: string }> {
return serverRequest<{ taskId: string }>("ai/video", {
method: "POST",
body: input,
timeoutMs: TASK_SUBMIT_TIMEOUT_MS,
maxRetries: NON_RETRYING_REQUEST.maxRetries,
fallbackMessage: "Video generation request failed",
});
},
async createVideoSuperResolveTask(input: VideoSuperResolveInput): Promise<{ taskId: string }> {
return serverRequest<{ taskId: string }>("ai/video/super-resolve", {
method: "POST",
body: input,
timeoutMs: TASK_SUBMIT_TIMEOUT_MS,
maxRetries: NON_RETRYING_REQUEST.maxRetries,
fallbackMessage: "Video super-resolution request failed",
});
},
async createEraseSubtitlesTask(input: EraseSubtitlesInput): Promise<{ taskId: string }> {
return serverRequest<{ taskId: string }>("ai/video/erase-subtitles", {
method: "POST",
body: input,
timeoutMs: TASK_SUBMIT_TIMEOUT_MS,
maxRetries: NON_RETRYING_REQUEST.maxRetries,
fallbackMessage: "Subtitle removal request failed",
});
},
async createVideoEditTask(input: VideoEditInput): Promise<{ taskId: string }> {
return serverRequest<{ taskId: string }>("ai/video/edit", {
method: "POST",
body: { ...input, model: input.model || "happyhorse-1.0-video-edit" },
timeoutMs: TASK_SUBMIT_TIMEOUT_MS,
maxRetries: NON_RETRYING_REQUEST.maxRetries,
fallbackMessage: "Video edit request failed",
});
},
async createImageSuperResolveTask(input: ImageSuperResolveInput): Promise<{ taskId: string }> {
return serverRequest<{ taskId: string }>("ai/image/super-resolve", {
method: "POST",
body: input,
timeoutMs: TASK_SUBMIT_TIMEOUT_MS,
maxRetries: NON_RETRYING_REQUEST.maxRetries,
fallbackMessage: "Image super-resolution request failed",
});
},
async createImageEditTask(input: ImageEditInput): Promise<{ taskId: string }> {
return serverRequest<{ taskId: string }>("ai/image/edit", {
method: "POST",
body: input,
timeoutMs: TASK_SUBMIT_TIMEOUT_MS,
maxRetries: NON_RETRYING_REQUEST.maxRetries,
fallbackMessage: "Image edit request failed",
});
},
async cancelTask(taskId: string): Promise<void> {
try {
await serverRequest<void>(`ai/tasks/${taskId}/cancel`, {
method: "PATCH",
maxRetries: NON_RETRYING_REQUEST.maxRetries,
fallbackMessage: "Task cancel failed",
});
} catch (error) {
if (isOptionalApiRouteMissing(error)) return;
throw error;
}
},
async getTaskStatus(taskId: string): Promise<AiTaskStatus> {
return serverRequest<AiTaskStatus>(`ai/tasks/${taskId}`, {
timeoutMs: TASK_STATUS_TIMEOUT_MS,
fallbackMessage: "Task status request failed",
});
},
async downloadTaskResult(taskId: string): Promise<{ blob: Blob; filename?: string; contentType?: string }> {
const res = await fetch(buildApiUrl(`ai/tasks/${encodeURIComponent(taskId)}/download`), {
method: "GET",
headers: buildAuthHeaders(),
});
if (!res.ok) {
await throwResponseError(res, "Task result download failed");
}
const blob = await res.blob();
return {
blob,
filename: parseContentDispositionFilename(res.headers.get("content-disposition")),
contentType: res.headers.get("content-type") || blob.type || undefined,
};
},
async listTasks(params?: { limit?: number; status?: string; type?: string; projectId?: string }): Promise<WebGenerationPreviewTask[]> {
if (taskHistoryRouteMissing) return [];
const search = new URLSearchParams();
if (params?.limit) search.set("limit", String(params.limit));
if (params?.status) search.set("status", params.status);
if (params?.type) search.set("type", params.type);
if (params?.projectId) search.set("projectId", params.projectId);
try {
const payload = await serverRequest<unknown>(`ai/tasks${search.toString() ? `?${search}` : ""}`, {
fallbackMessage: "Task history request failed",
});
return extractTaskList(payload).map(toPreviewTask);
} catch (error) {
if (isOptionalApiRouteMissing(error)) {
taskHistoryRouteMissing = true;
return [];
}
throw error;
}
},
async bindTaskToConversation(taskId: string, conversationId: number): Promise<void> {
try {
await serverRequest<void>(`ai/tasks/${taskId}/conversation`, {
method: "PATCH",
body: { conversationId },
maxRetries: NON_RETRYING_REQUEST.maxRetries,
fallbackMessage: "Task conversation binding failed",
});
} catch (error) {
if (isOptionalApiRouteMissing(error)) return;
throw error;
}
},
async uploadAsset(input: UploadAssetInput): Promise<{ url: string; signedUrl?: string; ossKey?: string }> {
return serverRequest<{ url: string; signedUrl?: string; ossKey?: string }>("oss/upload", {
method: "POST",
body: input,
maxRetries: NON_RETRYING_REQUEST.maxRetries,
fallbackMessage: "Asset upload failed",
});
},
async uploadAssetBinary(blob: Blob, options?: { name?: string; mimeType?: string; scope?: string }): Promise<{ url: string; signedUrl?: string; ossKey?: string }> {
const form = new FormData();
form.append("file", blob, options?.name || "upload.png");
if (options?.scope) form.append("scope", options.scope);
if (options?.mimeType) form.append("mimeType", options.mimeType);
// Exclude Content-Type so browser auto-sets multipart/form-data with boundary
const { "Content-Type": _ct, ...authHeaders } = buildAuthHeaders();
const res = await fetch(buildApiUrl("oss/upload-binary"), {
method: "POST",
headers: authHeaders,
body: form,
});
if (!res.ok) {
await throwResponseError(res, "Binary asset upload failed");
}
return readJsonResponse<{ url: string; ossKey?: string }>(res, "Binary asset upload response failed");
},
async uploadAssetByUrl(input: UploadAssetByUrlInput): Promise<{ url: string; signedUrl?: string; ossKey?: string }> {
return serverRequest<{ url: string; signedUrl?: string; ossKey?: string }>("oss/upload-by-url", {
method: "POST",
body: input,
maxRetries: NON_RETRYING_REQUEST.maxRetries,
fallbackMessage: "Asset upload by URL failed",
});
},
subscribeTaskStatus(
taskId: string,
onUpdate: (task: Pick<AiTaskStatus, "taskId" | "status" | "progress" | "resultUrl" | "error">) => void,
): () => void {
const url = buildApiUrl(`ai/tasks/${taskId}/stream`);
const controller = new AbortController();
(async () => {
try {
const res = await fetch(url, {
headers: { ...buildAuthHeaders(), Accept: "text/event-stream" },
signal: controller.signal,
});
if (!res.ok || !res.body) return;
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() || "";
for (const line of lines) {
if (!line.startsWith("data: ")) continue;
try {
const data = JSON.parse(line.slice(6));
onUpdate(data);
} catch { /* ignore */ }
}
}
} catch { /* aborted or network error */ }
})();
return () => controller.abort();
},
async streamChat(
input: ChatInput,
onChunk: (text: string) => void,
signal?: AbortSignal,
onUsage?: (usage: ChatUsage) => void,
): Promise<void> {
const res = await fetch(buildApiUrl("ai/chat"), {
method: "POST",
headers: buildAuthHeaders(),
body: JSON.stringify({ ...input, stream: true }),
signal,
});
if (!res.ok) {
await throwResponseError(res, "Chat request failed");
}
const reader = res.body?.getReader();
if (!reader) throw new Error("\u65e0\u6cd5\u8bfb\u53d6\u54cd\u5e94\u6d41");
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() || "";
for (const line of lines) {
if (!line.startsWith("data: ")) continue;
const payload = line.slice(6).trim();
if (!payload) continue;
try {
const chunk = JSON.parse(payload) as {
delta?: string;
done?: boolean;
error?: string;
usage?: ChatUsage & {
prompt_tokens?: number;
completion_tokens?: number;
total_tokens?: number;
};
};
if (chunk.error) throw new Error(chunk.error);
if (chunk.usage) {
onUsage?.({
promptTokens: chunk.usage.promptTokens ?? chunk.usage.prompt_tokens,
completionTokens: chunk.usage.completionTokens ?? chunk.usage.completion_tokens,
totalTokens: chunk.usage.totalTokens ?? chunk.usage.total_tokens,
});
}
if (chunk.delta) onChunk(chunk.delta);
if (chunk.done) return;
} catch (e) {
if (e instanceof SyntaxError) continue;
throw e;
}
}
}
},
async chatCompletion(input: ChatInput, signal?: AbortSignal): Promise<string> {
const res = await fetch(buildApiUrl("ai/chat"), {
method: "POST",
headers: buildAuthHeaders(),
body: JSON.stringify({ ...input, stream: false }),
signal,
});
if (!res.ok) {
await throwResponseError(res, "Chat completion failed");
}
const json = await readJsonResponse<{ content?: string }>(res, "Chat completion response failed");
return (json as { content?: string }).content || "";
},
};
+3
View File
@@ -0,0 +1,3 @@
export function isOptionalApiRouteMissing(error: unknown): boolean {
return typeof error === "object" && error !== null && "status" in error && Number(error.status) === 404;
}
+129
View File
@@ -0,0 +1,129 @@
import type { WebAssetItem } from "../types";
import { isRecord, serverRequest } from "./serverConnection";
export interface ServerAssetItem extends WebAssetItem {
id: string;
url?: string | null;
ossKey?: string | null;
tags: string[];
sourceTaskId?: string | null;
sourceProjectId?: string | null;
metadata?: Record<string, unknown>;
createdAt?: string;
}
export interface CreateAssetInput {
type: WebAssetItem["type"] | "image" | "asset" | "other";
name: string;
description?: string;
url?: string;
imageUrl?: string;
ossKey?: string;
tags?: string[];
status?: WebAssetItem["status"] | "pending" | "failed";
sourceTaskId?: string;
sourceProjectId?: string;
metadata?: Record<string, unknown>;
}
export interface DeleteAssetOptions {
cleanupUserData?: boolean;
}
function toStringValue(value: unknown, fallback = ""): string {
if (typeof value === "string") return value.trim() || fallback;
if (typeof value === "number" && Number.isFinite(value)) return String(value);
return fallback;
}
function normalizeAssetType(value: unknown): WebAssetItem["type"] {
const type = toStringValue(value);
if (
type === "character" ||
type === "scene" ||
type === "prop" ||
type === "video" ||
type === "image" ||
type === "asset" ||
type === "other"
) {
return type;
}
return "other";
}
function normalizeAssetStatus(value: unknown): WebAssetItem["status"] {
const status = toStringValue(value);
if (
status === "ready" ||
status === "draft" ||
status === "reviewing" ||
status === "pending" ||
status === "failed"
) {
return status;
}
return "ready";
}
function normalizeTags(value: unknown): string[] {
if (!Array.isArray(value)) return [];
const tags: string[] = [];
for (const item of value) {
const tag = toStringValue(item);
if (tag) tags.push(tag);
}
return tags;
}
function normalizeAsset(raw: unknown): ServerAssetItem {
const item = isRecord(raw) ? raw : {};
const url = toStringValue(item.url ?? item.imageUrl) || null;
return {
id: toStringValue(item.id, `asset-${Date.now()}`),
type: normalizeAssetType(item.type),
name: toStringValue(item.name, "未命名素材"),
description: toStringValue(item.description, "从服务器资产库同步的素材。"),
imageUrl: url || "",
url,
ossKey: toStringValue(item.ossKey ?? item.oss_key) || null,
tags: normalizeTags(item.tags),
status: normalizeAssetStatus(item.status),
sourceTaskId: toStringValue(item.sourceTaskId ?? item.source_task_id) || null,
sourceProjectId: toStringValue(item.sourceProjectId ?? item.source_project_id) || null,
metadata: isRecord(item.metadata) ? item.metadata : {},
createdAt: toStringValue(item.createdAt ?? item.created_at),
updatedAt: toStringValue(item.updatedAt ?? item.updated_at, "刚刚"),
};
}
function extractAssets(payload: unknown): ServerAssetItem[] {
if (Array.isArray(payload)) return payload.map(normalizeAsset);
if (!isRecord(payload)) return [];
const rows = payload.assets ?? payload.items;
return Array.isArray(rows) ? rows.map(normalizeAsset) : [];
}
export const assetClient = {
async list(params?: { type?: string; q?: string; status?: string }): Promise<ServerAssetItem[]> {
const search = new URLSearchParams();
if (params?.type && params.type !== "all") search.set("type", params.type);
if (params?.q) search.set("q", params.q);
if (params?.status) search.set("status", params.status);
const query = search.toString();
return extractAssets(await serverRequest<unknown>(`assets${query ? `?${query}` : ""}`));
},
async create(input: CreateAssetInput): Promise<ServerAssetItem> {
const payload = await serverRequest<{ asset: unknown }>("assets", {
method: "POST",
body: input,
});
return normalizeAsset(payload.asset ?? payload);
},
async delete(id: string, options?: DeleteAssetOptions): Promise<void> {
const path = options?.cleanupUserData ? `assets/${encodeURIComponent(id)}?cleanupUserData=1` : `assets/${encodeURIComponent(id)}`;
await serverRequest(path, { method: "DELETE" });
},
};
+139
View File
@@ -0,0 +1,139 @@
import { serverRequest } from "./serverConnection";
export interface BetaApplicationInput {
name: string;
email: string;
phone: string;
wechat: string;
industry: string;
company: string;
city: string;
aiTools: string;
aiDuration: string;
aiTrack: string;
aiDirection: string[];
weeklyUsage: string;
feedbackWilling: string;
wantFeature: string[];
selfStatement: string;
signature: string;
applicationDate: string;
agreeRules: boolean;
}
export type BetaApplicationStatus = "pending" | "approved" | "rejected";
export interface BetaApplicationItem extends BetaApplicationInput {
id: number;
userId: number | null;
username: string | null;
status: BetaApplicationStatus;
inviteCode: string | null;
reviewNote: string | null;
reviewedBy: number | null;
reviewerUsername: string | null;
reviewedAt: string | null;
ipAddress: string | null;
userAgent: string | null;
createdAt: string;
updatedAt: string;
}
export interface BetaApplicationSubmitResult {
id: number;
status: BetaApplicationStatus;
createdAt: string;
}
function readString(value: unknown): string {
return typeof value === "string" ? value : "";
}
function readNullableString(value: unknown): string | null {
return typeof value === "string" && value ? value : null;
}
function readNumberOrNull(value: unknown): number | null {
if (value === null || value === undefined || value === "") return null;
const next = Number(value);
return Number.isFinite(next) ? next : null;
}
function readStringArray(value: unknown): string[] {
return Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : [];
}
function normalizeStatus(value: unknown): BetaApplicationStatus {
return value === "approved" || value === "rejected" ? value : "pending";
}
function normalizeApplication(raw: unknown): BetaApplicationItem {
const item = raw && typeof raw === "object" && !Array.isArray(raw) ? (raw as Record<string, unknown>) : {};
return {
id: Number(item.id) || 0,
userId: readNumberOrNull(item.userId),
username: readNullableString(item.username),
name: readString(item.name),
email: readString(item.email),
phone: readString(item.phone),
wechat: readString(item.wechat),
industry: readString(item.industry),
company: readString(item.company),
city: readString(item.city),
aiTools: readString(item.aiTools),
aiDuration: readString(item.aiDuration),
aiTrack: readString(item.aiTrack),
aiDirection: readStringArray(item.aiDirection),
weeklyUsage: readString(item.weeklyUsage),
feedbackWilling: readString(item.feedbackWilling),
wantFeature: readStringArray(item.wantFeature),
selfStatement: readString(item.selfStatement),
signature: readString(item.signature),
applicationDate: readString(item.applicationDate),
agreeRules: item.agreeRules === true,
status: normalizeStatus(item.status),
inviteCode: readNullableString(item.inviteCode),
reviewNote: readNullableString(item.reviewNote),
reviewedBy: readNumberOrNull(item.reviewedBy),
reviewerUsername: readNullableString(item.reviewerUsername),
reviewedAt: readNullableString(item.reviewedAt),
ipAddress: readNullableString(item.ipAddress),
userAgent: readNullableString(item.userAgent),
createdAt: readString(item.createdAt),
updatedAt: readString(item.updatedAt),
};
}
export const betaApplicationClient = {
async submit(input: BetaApplicationInput): Promise<BetaApplicationSubmitResult> {
const payload = await serverRequest<{ application: BetaApplicationSubmitResult }>("beta-applications", {
method: "POST",
body: input,
maxRetries: 0,
fallbackMessage: "提交内测申请失败",
});
return payload.application;
},
async listAdminApplications(status?: BetaApplicationStatus | ""): Promise<BetaApplicationItem[]> {
const query = status ? `?status=${encodeURIComponent(status)}` : "";
const payload = await serverRequest<{ applications?: unknown[] }>(`admin/beta-applications${query}`, {
fallbackMessage: "读取内测申请失败",
});
return Array.isArray(payload.applications) ? payload.applications.map(normalizeApplication) : [];
},
async reviewApplication(
id: number,
action: "approve" | "reject",
reviewNote?: string,
): Promise<BetaApplicationItem> {
const payload = await serverRequest<{ application: unknown }>(`admin/beta-applications/${id}`, {
method: "PATCH",
body: { action, reviewNote },
maxRetries: 0,
fallbackMessage: "审核内测申请失败",
});
return normalizeApplication(payload.application);
},
};
+207
View File
@@ -0,0 +1,207 @@
import { isRecord, serverRequest } from "./serverConnection";
export interface ServerCommunityAsset {
id?: number;
assetType: "image" | "video" | "project" | "workflow" | "asset" | "cover" | "other";
title?: string | null;
url?: string | null;
ossKey?: string | null;
metadata?: Record<string, unknown>;
sortOrder?: number;
}
export interface ServerCommunityCase {
id: number;
userId?: number;
username?: string | null;
projectId?: string | null;
title: string;
description?: string | null;
coverUrl?: string | null;
tags: string[];
metadata: Record<string, unknown>;
status: "pending" | "approved" | "rejected";
reviewNote?: string | null;
publishedAt?: string | null;
copyCount: number;
favoriteCount: number;
likeCount: number;
isFavorited: boolean;
isLiked: boolean;
createdAt: string;
updatedAt: string;
assets: ServerCommunityAsset[];
}
export interface PublishCommunityCaseInput {
projectId?: string | null;
title: string;
description?: string | null;
coverUrl?: string | null;
tags?: string[];
metadata?: Record<string, unknown>;
assets?: Array<{
assetType?: ServerCommunityAsset["assetType"];
title?: string;
url?: string;
ossKey?: string;
metadata?: Record<string, unknown>;
sortOrder?: number;
}>;
}
function toNumber(value: unknown, fallback = 0): number {
const numeric = typeof value === "number" ? value : Number(value);
return Number.isFinite(numeric) ? numeric : fallback;
}
function toStringValue(value: unknown, fallback = ""): string {
if (typeof value === "string") return value.trim() || fallback;
if (typeof value === "number" && Number.isFinite(value)) return String(value);
return fallback;
}
function toStringArray(value: unknown): string[] {
if (!Array.isArray(value)) return [];
const result: string[] = [];
for (const item of value) {
const text = toStringValue(item);
if (text) result.push(text);
}
return result;
}
function toMetadata(value: unknown): Record<string, unknown> {
return isRecord(value) ? value : {};
}
function normalizeAsset(raw: unknown): ServerCommunityAsset {
const asset = isRecord(raw) ? raw : {};
const assetType = toStringValue(asset.assetType ?? asset.asset_type ?? asset.type, "other");
return {
id: Number.isFinite(Number(asset.id)) ? Number(asset.id) : undefined,
assetType:
assetType === "image" ||
assetType === "video" ||
assetType === "image" ||
assetType === "project" ||
assetType === "workflow" ||
assetType === "asset" ||
assetType === "cover"
? assetType
: "other",
title: toStringValue(asset.title) || null,
url: toStringValue(asset.url) || null,
ossKey: toStringValue(asset.ossKey ?? asset.oss_key) || null,
metadata: toMetadata(asset.metadata),
sortOrder: toNumber(asset.sortOrder ?? asset.sort_order),
};
}
function normalizeCase(raw: unknown): ServerCommunityCase {
const item = isRecord(raw) ? raw : {};
const status = toStringValue(item.status, "pending");
return {
id: toNumber(item.id),
userId: Number.isFinite(Number(item.userId ?? item.user_id)) ? Number(item.userId ?? item.user_id) : undefined,
username: toStringValue(item.username) || null,
projectId: toStringValue(item.projectId ?? item.project_id) || null,
title: toStringValue(item.title, "未命名案例"),
description: toStringValue(item.description) || null,
coverUrl: toStringValue(item.coverUrl ?? item.cover_url) || null,
tags: toStringArray(item.tags),
metadata: toMetadata(item.metadata),
status: status === "approved" || status === "rejected" ? status : "pending",
reviewNote: toStringValue(item.reviewNote ?? item.review_note) || null,
publishedAt: toStringValue(item.publishedAt ?? item.published_at) || null,
copyCount: toNumber(item.copyCount ?? item.copy_count),
favoriteCount: toNumber(item.favoriteCount ?? item.favorite_count),
likeCount: toNumber(item.likeCount ?? item.like_count),
isFavorited: Boolean(item.isFavorited ?? item.is_favorited),
isLiked: Boolean(item.isLiked ?? item.is_liked),
createdAt: toStringValue(item.createdAt ?? item.created_at, new Date().toISOString()),
updatedAt: toStringValue(item.updatedAt ?? item.updated_at, new Date().toISOString()),
assets: Array.isArray(item.assets) ? item.assets.map(normalizeAsset) : [],
};
}
function extractCases(payload: unknown): ServerCommunityCase[] {
if (Array.isArray(payload)) return payload.map(normalizeCase);
if (!isRecord(payload)) return [];
const rows = payload.cases ?? payload.items;
return Array.isArray(rows) ? rows.map(normalizeCase) : [];
}
export const communityClient = {
async listApprovedCases(
params: number | { limit?: number; q?: string; category?: string; tag?: string; sort?: string } = 30,
): Promise<ServerCommunityCase[]> {
const search = new URLSearchParams();
if (typeof params === "number") {
search.set("limit", String(params));
} else {
search.set("limit", String(params.limit ?? 30));
if (params.q) search.set("q", params.q);
if (params.category && params.category !== "全部") search.set("category", params.category);
if (params.tag) search.set("tag", params.tag);
if (params.sort) search.set("sort", params.sort);
}
return extractCases(await serverRequest<unknown>(`community/cases?${search.toString()}`));
},
async listMyCases(): Promise<ServerCommunityCase[]> {
return extractCases(await serverRequest<unknown>("community/me/cases"));
},
async listCasesForReview(status: "" | ServerCommunityCase["status"] = "pending"): Promise<ServerCommunityCase[]> {
const search = new URLSearchParams();
if (status) search.set("status", status);
const query = search.toString();
return extractCases(await serverRequest<unknown>(`admin/community/cases${query ? `?${query}` : ""}`));
},
async publishCase(input: PublishCommunityCaseInput): Promise<ServerCommunityCase> {
const payload = await serverRequest<{ case: unknown }>("community/cases", {
method: "POST",
body: input,
});
return normalizeCase(payload.case);
},
async updateReviewStatus(
caseId: number | string,
status: Exclude<ServerCommunityCase["status"], "pending">,
reviewNote: string,
): Promise<ServerCommunityCase> {
const payload = await serverRequest<{ case: unknown }>(`admin/community/cases/${encodeURIComponent(String(caseId))}/status`, {
method: "PATCH",
body: { status, reviewNote, review_note: reviewNote },
});
return normalizeCase(payload.case);
},
async copyCase(caseId: number, input?: { projectId?: string; name?: string; ossKey?: string }): Promise<void> {
await serverRequest(`community/cases/${caseId}/copy`, {
method: "POST",
body: input || {},
});
},
async setReaction(
caseId: number,
reactionType: "favorite" | "like",
active: boolean,
): Promise<{ favoriteCount: number; likeCount: number; isFavorited: boolean; isLiked: boolean }> {
const payload = await serverRequest<{ stats: unknown }>(`community/cases/${caseId}/reactions`, {
method: "POST",
body: { reactionType, active },
});
const stats = isRecord(payload.stats) ? payload.stats : {};
return {
favoriteCount: toNumber(stats.favoriteCount),
likeCount: toNumber(stats.likeCount),
isFavorited: Boolean(stats.isFavorited),
isLiked: Boolean(stats.isLiked),
};
},
};
+67
View File
@@ -0,0 +1,67 @@
import { serverRequest } from "./serverConnection";
export interface ConversationSummary {
id: number;
title: string;
mode: string;
createdAt: string;
updatedAt: string;
}
export interface ConversationDetail extends ConversationSummary {
messages: ConversationMessage[];
}
export interface ConversationMessage {
id: string;
role: "user" | "assistant";
author: string;
mode: string;
body: string;
createdAt: string;
status?: string;
taskId?: string;
conversationId?: number;
taskProgress?: number;
taskStatusLabel?: string;
attachments?: Array<{ kind: string; name: string; token: string; previewUrl?: string; remoteUrl?: string }>;
resultUrl?: string;
resultType?: string;
resultOriginalUrl?: string;
resultOssKey?: string;
resultMimeType?: string;
result?: { title: string; summary: string; specs: string[] };
}
export interface DeleteConversationOptions {
cleanupUserData?: boolean;
}
export const conversationClient = {
async list(): Promise<ConversationSummary[]> {
const data = await serverRequest<{ conversations: ConversationSummary[] }>("conversations");
return data.conversations || [];
},
async create(title: string, mode: string, messages?: ConversationMessage[]): Promise<ConversationSummary> {
const data = await serverRequest<{ conversation: ConversationSummary }>("conversations", {
method: "POST",
body: { title, mode, messages },
});
return data.conversation;
},
async get(id: number): Promise<ConversationDetail> {
const data = await serverRequest<{ conversation: ConversationDetail }>(`conversations/${id}`);
return data.conversation;
},
async update(id: number, data: { title?: string; messages?: ConversationMessage[] }): Promise<void> {
await serverRequest(`conversations/${id}`, { method: "PUT", body: data });
},
async delete(id: number, options?: DeleteConversationOptions): Promise<void> {
const path = options?.cleanupUserData ? `conversations/${id}?cleanupUserData=1` : `conversations/${id}`;
await serverRequest(path, { method: "DELETE" });
},
};
+53
View File
@@ -0,0 +1,53 @@
import { isRecord, serverRequest } from "./serverConnection";
export interface WebDraft<TPayload = unknown> {
id: string;
scope: string;
targetId: string;
payload: TPayload;
createdAt: string;
updatedAt: string;
}
function toStringValue(value: unknown, fallback = ""): string {
if (typeof value === "string") return value.trim() || fallback;
if (typeof value === "number" && Number.isFinite(value)) return String(value);
return fallback;
}
function normalizeDraft(raw: unknown): WebDraft {
const item = isRecord(raw) ? raw : {};
return {
id: toStringValue(item.id, `draft-${Date.now()}`),
scope: toStringValue(item.scope),
targetId: toStringValue(item.targetId ?? item.target_id, "default"),
payload: item.payload,
createdAt: toStringValue(item.createdAt ?? item.created_at),
updatedAt: toStringValue(item.updatedAt ?? item.updated_at),
};
}
function extractDrafts(payload: unknown): WebDraft[] {
if (Array.isArray(payload)) return payload.map(normalizeDraft);
if (!isRecord(payload)) return [];
const rows = payload.drafts ?? payload.items;
return Array.isArray(rows) ? rows.map(normalizeDraft) : [];
}
export const draftClient = {
async list(params?: { scope?: string; targetId?: string }): Promise<WebDraft[]> {
const search = new URLSearchParams();
if (params?.scope) search.set("scope", params.scope);
if (params?.targetId) search.set("targetId", params.targetId);
const query = search.toString();
return extractDrafts(await serverRequest<unknown>(`drafts${query ? `?${query}` : ""}`));
},
async save<TPayload>(input: { scope: string; targetId?: string; payload: TPayload }): Promise<WebDraft<TPayload>> {
const payload = await serverRequest<{ draft: unknown }>("drafts", {
method: "PUT",
body: input,
});
return normalizeDraft(payload.draft ?? payload) as WebDraft<TPayload>;
},
};
+73
View File
@@ -0,0 +1,73 @@
type GenerationKind = "image" | "video";
interface GenerationSlot {
id: string;
userKey: string;
kind: GenerationKind;
createdAt: number;
}
const DEFAULT_MAX_ACTIVE_GENERATION_TASKS = 3;
const STALE_SLOT_MS = 6 * 60 * 60 * 1000;
const activeSlots = new Map<string, GenerationSlot>();
let userMaxConcurrency: number | null = null;
export function setUserMaxConcurrency(limit: number | null | undefined): void {
userMaxConcurrency = typeof limit === "number" && limit > 0 ? limit : null;
}
function getEffectiveLimit(): number {
return userMaxConcurrency ?? DEFAULT_MAX_ACTIVE_GENERATION_TASKS;
}
export function getGenerationUserKey(userId?: string | number | null): string {
return userId === undefined || userId === null || userId === "" ? "anonymous" : String(userId);
}
function pruneStaleSlots(now = Date.now()): void {
activeSlots.forEach((slot, id) => {
if (now - slot.createdAt > STALE_SLOT_MS) {
activeSlots.delete(id);
}
});
}
export function getActiveGenerationTaskCount(userKey: string): number {
pruneStaleSlots();
let count = 0;
activeSlots.forEach((slot) => {
if (slot.userKey === userKey) count += 1;
});
return count;
}
export function claimGenerationSlot(input: {
userKey: string;
kind: GenerationKind;
id?: string;
}): () => void {
pruneStaleSlots();
const activeCount = getActiveGenerationTaskCount(input.userKey);
const effectiveLimit = getEffectiveLimit();
if (activeCount >= effectiveLimit) {
throw new Error(`当前账号同时最多生成 ${effectiveLimit} 个图片/视频任务,请等待已有任务完成后再提交。`);
}
const id = input.id || `generation-slot-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
activeSlots.set(id, {
id,
userKey: input.userKey,
kind: input.kind,
createdAt: Date.now(),
});
return () => {
activeSlots.delete(id);
};
}
export function releaseGenerationSlot(id: string | undefined | null): void {
if (!id) return;
activeSlots.delete(id);
}
File diff suppressed because it is too large Load Diff
+104
View File
@@ -0,0 +1,104 @@
import { isOptionalApiRouteMissing } from "./apiErrorUtils";
import { isRecord, serverRequest } from "./serverConnection";
export interface ModelCapabilityOption {
value: string;
label: string;
description?: string;
badge?: string;
enabled?: boolean;
status?: "available" | "maintenance" | "disabled" | string;
}
export interface WebModelCapabilities {
source: "server" | "fallback";
imageModels: ModelCapabilityOption[];
videoModels: ModelCapabilityOption[];
chatModels: ModelCapabilityOption[];
updatedAt?: string;
}
function toStringValue(value: unknown, fallback = ""): string {
if (typeof value === "string") return value.trim() || fallback;
if (typeof value === "number" && Number.isFinite(value)) return String(value);
return fallback;
}
function normalizeModelOption(raw: unknown): ModelCapabilityOption | null {
if (typeof raw === "string") {
const value = raw.trim();
return value ? { value, label: value } : null;
}
if (!isRecord(raw)) return null;
const value = toStringValue(raw.value ?? raw.id ?? raw.model ?? raw.modelKey ?? raw.model_key);
if (!value) return null;
const status = toStringValue(raw.status);
const enabled = raw.enabled === undefined ? status !== "maintenance" && status !== "disabled" : Boolean(raw.enabled);
if (!enabled) return null;
const label = toStringValue(raw.label ?? raw.displayName ?? raw.display_name ?? raw.name, value);
return {
value,
label:
value === "wan2.7-image-pro"
? label.replace(/\s*4k\b/i, "").trim() || "wan 2.7 Pro"
: label,
description: toStringValue(raw.description) || undefined,
badge: toStringValue(raw.badge) || undefined,
enabled,
status: status || "available",
};
}
function normalizeModelList(value: unknown): ModelCapabilityOption[] {
if (!Array.isArray(value)) return [];
const options: ModelCapabilityOption[] = [];
for (const item of value) {
const option = normalizeModelOption(item);
if (option) options.push(option);
}
return options;
}
function createFallbackCapabilities(): WebModelCapabilities {
return {
source: "fallback",
imageModels: [],
videoModels: [],
chatModels: [],
};
}
let modelCapabilitiesRouteMissing = false;
export const modelCapabilitiesClient = {
async get(name = "web-model-capabilities"): Promise<WebModelCapabilities> {
if (modelCapabilitiesRouteMissing) return createFallbackCapabilities();
let payload: unknown;
try {
payload = await serverRequest<unknown>(`public/config/profile?name=${encodeURIComponent(name)}`);
} catch (error) {
if (isOptionalApiRouteMissing(error)) {
modelCapabilitiesRouteMissing = true;
return createFallbackCapabilities();
}
throw error;
}
const raw = isRecord(payload) && isRecord(payload.config) ? payload.config : payload;
const config = isRecord(raw) ? raw : {};
const models = isRecord(config.models) ? config.models : {};
return {
source: "server",
imageModels: normalizeModelList(config.imageModels ?? config.image_models ?? models.image),
videoModels: normalizeModelList(config.videoModels ?? config.video_models ?? models.video),
chatModels: normalizeModelList(config.chatModels ?? config.chat_models ?? models.chat ?? models.agent ?? models.text),
updatedAt: toStringValue((payload as { updatedAt?: unknown; updated_at?: unknown })?.updatedAt ?? (payload as { updated_at?: unknown })?.updated_at),
};
},
};
+173
View File
@@ -0,0 +1,173 @@
import type { WebNotification, WebNotificationType, WebViewKey } from "../types";
import { isOptionalApiRouteMissing } from "./apiErrorUtils";
import { isRecord, isServerRequestError, serverRequest, writeStoredSession } from "./serverConnection";
interface CreateNotificationInput {
type: WebNotificationType;
title: string;
description?: string;
targetType?: string;
targetId?: string;
metadata?: Record<string, unknown>;
}
const NOTIFICATION_VIEW_BY_TARGET: Record<string, WebViewKey> = {
task: "workbench",
generation_task: "workbench",
community_case: "login",
asset: "assets",
project: "canvas",
draft: "workbench",
};
let notificationsRouteMissing = false;
let notificationsUnauthorized = false;
function toStringValue(value: unknown, fallback = ""): string {
if (typeof value === "string") return value.trim() || fallback;
if (typeof value === "number" && Number.isFinite(value)) return String(value);
return fallback;
}
function normalizeType(value: unknown): WebNotificationType {
const type = toStringValue(value);
if (
type === "task_completed" ||
type === "task_failed" ||
type === "review_pending" ||
type === "review_passed" ||
type === "review_rejected" ||
type === "credits_low" ||
type === "session_expired"
) {
return type;
}
return "info";
}
function normalizeNotification(raw: unknown): WebNotification {
const item = isRecord(raw) ? raw : {};
const targetType = toStringValue(item.targetType ?? item.target_type) || null;
const targetId = toStringValue(item.targetId ?? item.target_id) || undefined;
const readAt = toStringValue(item.readAt ?? item.read_at) || null;
return {
id: toStringValue(item.id, `notice-${Date.now()}`),
type: normalizeType(item.type),
title: toStringValue(item.title, "通知"),
description: toStringValue(item.description),
createdAt: toStringValue(item.createdAt ?? item.created_at, new Date().toISOString()),
isRead: Boolean(item.isRead ?? item.is_read ?? readAt),
targetType,
targetId,
targetView: targetType ? NOTIFICATION_VIEW_BY_TARGET[targetType] : undefined,
readAt,
metadata: isRecord(item.metadata) ? item.metadata : {},
};
}
function extractNotifications(payload: unknown): WebNotification[] {
if (Array.isArray(payload)) return payload.map(normalizeNotification);
if (!isRecord(payload)) return [];
const rows = payload.notifications ?? payload.items;
return Array.isArray(rows) ? rows.map(normalizeNotification) : [];
}
function isUnauthorized(error: unknown): boolean {
return isServerRequestError(error) && (error.status === 401 || error.status === 403);
}
function handleUnauthorizedNotifications(): void {
notificationsUnauthorized = true;
writeStoredSession(null);
}
export const notificationClient = {
async list(): Promise<WebNotification[]> {
if (notificationsRouteMissing || notificationsUnauthorized) return [];
try {
return extractNotifications(await serverRequest<unknown>("notifications"));
} catch (error) {
if (isOptionalApiRouteMissing(error)) {
notificationsRouteMissing = true;
return [];
}
if (isUnauthorized(error)) {
handleUnauthorizedNotifications();
return [];
}
throw error;
}
},
async create(input: CreateNotificationInput): Promise<WebNotification> {
if (notificationsRouteMissing || notificationsUnauthorized) {
return normalizeNotification({
...input,
id: `local-notice-${Date.now()}`,
createdAt: new Date().toISOString(),
});
}
try {
const payload = await serverRequest<{ notification: unknown }>("notifications", {
method: "POST",
body: input,
});
return normalizeNotification(payload.notification ?? payload);
} catch (error) {
if (isOptionalApiRouteMissing(error)) {
notificationsRouteMissing = true;
return normalizeNotification({
...input,
id: `local-notice-${Date.now()}`,
createdAt: new Date().toISOString(),
});
}
if (isUnauthorized(error)) {
handleUnauthorizedNotifications();
return normalizeNotification({
...input,
id: `local-notice-${Date.now()}`,
createdAt: new Date().toISOString(),
});
}
throw error;
}
},
async markRead(id: string, isRead = true): Promise<void> {
if (notificationsRouteMissing || notificationsUnauthorized) return;
try {
await serverRequest(`notifications/${id}/read`, {
method: "PATCH",
body: { isRead },
});
} catch (error) {
if (isOptionalApiRouteMissing(error)) {
notificationsRouteMissing = true;
return;
}
if (isUnauthorized(error)) {
handleUnauthorizedNotifications();
return;
}
throw error;
}
},
async markAllRead(): Promise<void> {
if (notificationsRouteMissing || notificationsUnauthorized) return;
try {
await serverRequest("notifications/read-all", { method: "POST" });
} catch (error) {
if (isOptionalApiRouteMissing(error)) {
notificationsRouteMissing = true;
return;
}
if (isUnauthorized(error)) {
handleUnauthorizedNotifications();
return;
}
throw error;
}
},
};
+154
View File
@@ -0,0 +1,154 @@
import type { WebGenerationPreviewTask } from "../types";
import { isRecord, serverRequest } from "./serverConnection";
type ServerTaskStatus = "pending" | "running" | "completed" | "failed" | "cancelled";
interface ServerProjectTask {
id: string;
projectId?: string | null;
clientQueueId?: string | null;
type: "image" | "video";
status: ServerTaskStatus;
params?: Record<string, unknown>;
resultUrl?: string | null;
progress?: number;
error?: string | null;
createdAt?: string;
updatedAt?: string;
}
export interface ProjectTaskUpsertInput {
clientQueueId: string;
type: "image" | "video";
status: ServerTaskStatus;
params?: Record<string, unknown>;
providerTaskId?: string | null;
resultUrl?: string | null;
progress?: number;
error?: string | null;
dedupeKey?: string | null;
sourceDeviceId?: string | null;
createdAt?: string | null;
completedAt?: string | null;
}
function toStringValue(value: unknown, fallback = ""): string {
if (typeof value === "string") return value.trim() || fallback;
if (typeof value === "number" && Number.isFinite(value)) return String(value);
return fallback;
}
function toNumber(value: unknown, fallback = 0): number {
const numeric = Number(value);
return Number.isFinite(numeric) ? numeric : fallback;
}
function normalizeStatus(value: unknown): WebGenerationPreviewTask["status"] {
const status = toStringValue(value);
if (status === "running" || status === "completed" || status === "failed") return status;
if (status === "cancelled") return "failed";
return "queued";
}
function normalizeTask(raw: unknown): ServerProjectTask | null {
if (!isRecord(raw)) return null;
const type = toStringValue(raw.type);
if (type !== "image" && type !== "video") return null;
return {
id: toStringValue(raw.id),
projectId: toStringValue(raw.projectId ?? raw.project_id) || null,
clientQueueId: toStringValue(raw.clientQueueId ?? raw.client_queue_id) || null,
type,
status: toStringValue(raw.status, "pending") as ServerTaskStatus,
params: isRecord(raw.params) ? raw.params : {},
resultUrl: toStringValue(raw.resultUrl ?? raw.result_url) || null,
progress: toNumber(raw.progress),
error: toStringValue(raw.error) || null,
createdAt: toStringValue(raw.createdAt ?? raw.created_at),
updatedAt: toStringValue(raw.updatedAt ?? raw.updated_at),
};
}
function extractTasks(payload: unknown): ServerProjectTask[] {
const normalizeTasks = (rows: unknown[]): ServerProjectTask[] => {
const tasks: ServerProjectTask[] = [];
for (const row of rows) {
const task = normalizeTask(row);
if (task) tasks.push(task);
}
return tasks;
};
if (Array.isArray(payload)) return normalizeTasks(payload);
if (!isRecord(payload)) return [];
const rows = payload.tasks ?? payload.items;
return Array.isArray(rows) ? normalizeTasks(rows) : [];
}
function taskTitle(task: ServerProjectTask): string {
const prompt = toStringValue(task.params?.prompt);
if (prompt) return prompt.length > 20 ? `${prompt.slice(0, 20)}...` : prompt;
return task.type === "video" ? "视频生成任务" : "图像生成任务";
}
function toPreviewTask(task: ServerProjectTask): WebGenerationPreviewTask {
return {
id: task.clientQueueId || task.id,
title: taskTitle(task),
type: task.type,
status: normalizeStatus(task.status),
progress: Math.max(0, Math.min(100, Math.trunc(task.progress || 0))),
prompt: toStringValue(task.params?.prompt, taskTitle(task)),
createdAt: task.createdAt || task.updatedAt || "",
projectId: task.projectId || undefined,
outputUrl: task.resultUrl || undefined,
source: "server",
errorMessage: task.error || undefined,
};
}
async function listProjectTasks(projectId: string): Promise<WebGenerationPreviewTask[]> {
const payload = await serverRequest<unknown>(`projects/${encodeURIComponent(projectId)}/tasks`);
return extractTasks(payload).map(toPreviewTask);
}
export const projectTaskClient = {
async list(projectId: string): Promise<WebGenerationPreviewTask[]> {
return listProjectTasks(projectId);
},
async listForProjects(projectIds: string[]): Promise<WebGenerationPreviewTask[]> {
const uniqueIds = new Set<string>();
for (const projectId of projectIds) {
const id = projectId.trim();
if (id) uniqueIds.add(id);
}
const results = await Promise.all(Array.from(uniqueIds, (id) => listProjectTasks(id)));
return results.flat();
},
async upsert(projectId: string, input: ProjectTaskUpsertInput): Promise<WebGenerationPreviewTask> {
const payload = await serverRequest<{ task: unknown }>(
`projects/${encodeURIComponent(projectId)}/tasks/upsert`,
{
method: "POST",
body: input,
},
);
const task = normalizeTask(payload.task ?? payload);
if (!task) throw new Error("Project task response did not include a task");
return toPreviewTask(task);
},
async batchUpsert(projectId: string, tasks: ProjectTaskUpsertInput[]): Promise<WebGenerationPreviewTask[]> {
const payload = await serverRequest<{ tasks: unknown }>(
`projects/${encodeURIComponent(projectId)}/tasks/batch-upsert`,
{
method: "POST",
body: { tasks },
},
);
return extractTasks(payload).map(toPreviewTask);
},
};
+39
View File
@@ -0,0 +1,39 @@
import { serverRequest } from "./serverConnection";
export interface ProviderHealthEntry {
status: string;
lastCheck: string | null;
lastError: string | null;
details: Record<string, unknown> | null;
}
export interface CallStatRow {
provider: string;
model: string;
status: string;
count: string;
avg_ms: string | null;
total_cost: string | null;
}
export interface KeyStatRow {
provider: string;
total_keys: string;
active_keys: string;
current_load: string;
}
export interface ProviderHealthResponse {
health: Record<string, ProviderHealthEntry>;
callStats: CallStatRow[];
keyStats: KeyStatRow[];
checkedAt: string;
}
export const providerHealthClient = {
async getStatus(): Promise<ProviderHealthResponse> {
return serverRequest<ProviderHealthResponse>("admin/providers/status", {
fallbackMessage: "Provider health request failed",
});
},
};
+51
View File
@@ -0,0 +1,51 @@
import { isOptionalApiRouteMissing } from "./apiErrorUtils";
import { isRecord, serverRequest } from "./serverConnection";
export interface WebPublicConfig {
contactEmail?: string;
contactPhone?: string;
companyAddress?: string;
icpRecord?: string;
}
function readString(config: Record<string, unknown>, keys: string[]): string | undefined {
for (const key of keys) {
const value = config[key];
if (typeof value === "string" && value.trim()) return value.trim();
}
return undefined;
}
function normalizePublicConfig(raw: unknown): WebPublicConfig {
const config = isRecord(raw) && isRecord(raw.config) ? raw.config : raw;
if (!isRecord(config)) return {};
return {
contactEmail: readString(config, ["contactEmail", "contact_email", "supportEmail", "support_email"]),
contactPhone: readString(config, ["contactPhone", "contact_phone", "supportPhone", "support_phone"]),
companyAddress: readString(config, ["companyAddress", "company_address", "address"]),
icpRecord: readString(config, ["icpRecord", "icp_record", "filingInfo", "filing_info"]),
};
}
let cachedPublicConfig: WebPublicConfig | null = null;
let publicConfigRouteMissing = false;
export const publicConfigClient = {
async get(): Promise<WebPublicConfig> {
if (cachedPublicConfig) return cachedPublicConfig;
if (publicConfigRouteMissing) return {};
try {
const payload = await serverRequest<unknown>("public/config/profile?name=web-public-config");
cachedPublicConfig = normalizePublicConfig(payload);
return cachedPublicConfig;
} catch (error) {
if (isOptionalApiRouteMissing(error)) {
publicConfigRouteMissing = true;
return {};
}
throw error;
}
},
};
+84
View File
@@ -0,0 +1,84 @@
import { aiGenerationClient } from "./aiGenerationClient";
interface UploadEntry {
promise: Promise<string | null>;
url: string | null;
status: "pending" | "done" | "failed";
}
const uploadCache = new Map<string, UploadEntry>();
function fileToDataUrl(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result || ""));
reader.onerror = () => reject(reader.error || new Error("文件读取失败"));
reader.readAsDataURL(file);
});
}
function buildCacheKey(file: File, fingerprint?: string): string {
if (fingerprint) return fingerprint;
return `${file.name}__${file.size}__${file.lastModified}`;
}
export function preUploadReference(
file: File,
name: string,
fingerprint?: string,
): Promise<string | null> {
const key = buildCacheKey(file, fingerprint);
const cached = uploadCache.get(key);
if (cached) return cached.promise;
const scope = file.type.startsWith("video/") ? "reference-video" : "reference-image";
const promise = (async () => {
try {
const dataUrl = await fileToDataUrl(file);
const uploaded = await aiGenerationClient.uploadAsset({
dataUrl,
name,
mimeType: file.type,
scope,
});
const entry = uploadCache.get(key);
if (entry) {
entry.url = uploaded.url;
entry.status = "done";
}
return uploaded.url;
} catch (error) {
const entry = uploadCache.get(key);
if (entry) entry.status = "failed";
console.warn("[referenceUpload] pre-upload failed:", error);
return null;
}
})();
uploadCache.set(key, { promise, url: null, status: "pending" });
return promise;
}
export function getPreUploadedUrl(
file: File,
fingerprint?: string,
): string | null {
const key = buildCacheKey(file, fingerprint);
return uploadCache.get(key)?.url ?? null;
}
export async function resolvePreUploadedUrl(
file: File,
name: string,
fingerprint?: string,
): Promise<string | null> {
const key = buildCacheKey(file, fingerprint);
const cached = uploadCache.get(key);
if (cached) return cached.promise;
return preUploadReference(file, name, fingerprint);
}
export function clearUploadCache(): void {
uploadCache.clear();
}
+71
View File
@@ -0,0 +1,71 @@
import { serverRequest } from "./serverConnection";
export interface ReportInput {
reportType: string;
targetType?: string;
targetId?: string;
contactName?: string;
contactEmail?: string;
contactPhone?: string;
title: string;
description: string;
pageUrl?: string;
}
export interface AdminReportItem {
id: number;
userId?: number | null;
username?: string | null;
reportType?: string | null;
targetType?: string | null;
targetId?: string | null;
contactName?: string | null;
contactEmail?: string | null;
contactPhone?: string | null;
title: string;
description: string;
pageUrl?: string | null;
status: string;
ipAddress?: string | null;
userAgent?: string | null;
createdAt: string;
updatedAt?: string | null;
}
function normalizeReport(raw: unknown): AdminReportItem {
const item = raw && typeof raw === "object" && !Array.isArray(raw) ? (raw as Record<string, unknown>) : {};
return {
id: Number(item.id) || 0,
userId: item.userId == null ? null : Number(item.userId),
username: typeof item.username === "string" ? item.username : null,
reportType: typeof item.reportType === "string" ? item.reportType : null,
targetType: typeof item.targetType === "string" ? item.targetType : null,
targetId: typeof item.targetId === "string" ? item.targetId : null,
contactName: typeof item.contactName === "string" ? item.contactName : null,
contactEmail: typeof item.contactEmail === "string" ? item.contactEmail : null,
contactPhone: typeof item.contactPhone === "string" ? item.contactPhone : null,
title: typeof item.title === "string" && item.title.trim() ? item.title : "未命名举报",
description: typeof item.description === "string" ? item.description : "",
pageUrl: typeof item.pageUrl === "string" ? item.pageUrl : null,
status: typeof item.status === "string" && item.status.trim() ? item.status : "pending",
ipAddress: typeof item.ipAddress === "string" ? item.ipAddress : null,
userAgent: typeof item.userAgent === "string" ? item.userAgent : null,
createdAt: typeof item.createdAt === "string" ? item.createdAt : "",
updatedAt: typeof item.updatedAt === "string" ? item.updatedAt : null,
};
}
export const reportClient = {
async submit(input: ReportInput): Promise<{ id: number; status: string; createdAt: string }> {
const payload = await serverRequest<{ report: { id: number; status: string; createdAt: string } }>("reports", {
method: "POST",
body: input,
});
return payload.report;
},
async listAdminReports(): Promise<AdminReportItem[]> {
const payload = await serverRequest<{ reports?: unknown[] }>("admin/reports");
return Array.isArray(payload.reports) ? payload.reports.map(normalizeReport) : [];
},
};
+204
View File
@@ -0,0 +1,204 @@
import { serverRequest } from "./serverConnection";
export interface ScriptEvalResult {
totalScore: number;
grade: string;
dimensionScores: Record<string, number>;
subScores?: Record<string, Record<string, number>>;
evidence?: Record<string, string[]>;
summary: string;
issues: string[];
highlights: string[];
suggestions: string[];
}
const MODEL = "qwen3.7-max";
const EVAL_OUTPUT_CONTRACT = `
强制输出 JSON,主维度键名必须严格为:
hook(20), plot(20), character(15), logic(15), visual(15), content(15)。
不要把 dialogue 作为主维度返回;台词对白作为 character/plot/content 的证据和子项分析。
同时返回 subScores 和 evidence
- subScores:每个主维度 3-5 个细分参数,分值按该维度满分拆分。
- evidence:每个主维度 1-3 条具体证据,必须指向场景、台词、设定、冲突或段落。
返回结构:
{
"dimensionScores": { "hook": 数字, "plot": 数字, "character": 数字, "logic": 数字, "visual": 数字, "content": 数字 },
"subScores": {
"hook": { "openingImpact": 数字, "suspenseChain": 数字, "sceneHook": 数字 },
"plot": { "structure": 数字, "rhythm": 数字, "conflict": 数字, "reversal": 数字 },
"character": { "motivation": 数字, "arc": 数字, "voice": 数字, "relationship": 数字 },
"logic": { "causality": 数字, "worldRules": 数字, "foreshadowing": 数字, "continuity": 数字 },
"visual": { "sceneDetail": 数字, "shotPotential": 数字, "aigcFeasibility": 数字 },
"content": { "theme": 数字, "emotion": 数字, "marketFit": 数字, "originality": 数字 }
},
"evidence": { "hook": ["..."], "plot": ["..."], "character": ["..."], "logic": ["..."], "visual": ["..."], "content": ["..."] },
"summary": "200-300字综合评价",
"issues": ["具体扣分点,带维度和证据", ...],
"highlights": ["具体亮点,带维度和证据", ...],
"suggestions": ["按优先级排列的改稿建议", ...]
}`;
const EVAL_SYSTEM_PROMPT = `你是一位资深影视剧本评审专家,拥有二十年以上的编剧、制片和剧本医生经验。你精通各类影视叙事理论(三幕式、英雄之旅、起承转合、序列法),同时深度跟踪AIGC短剧/漫剧行业最新趋势。你的任务是对用户提供的剧本进行严谨、系统、多维度的量化评分。
【剧本类型识别】
收到剧本后,首先判断类型:AIGC短剧/漫剧(单集5-30分钟,竖屏平台,高密度反转、强节奏)或传统影视剧本(单集40分钟以上,长视频平台,完整起承转合)。类型判定将影响各维度的评价侧重点。
【评分体系(100分制,六个维度)】
1. hook 钩子设计(20分):开篇钩子、集末钩子、场景内钩子、悬念链完整性。短剧前3秒须有即时爆点;长剧第一幕结束前须建立核心悬念。
2. plot 剧情结构(20分):结构框架、节奏控制、冲突设计、逻辑自洽。短剧"每分钟有事件",反转密度加分;长剧需处理好B线C线与主线交织。
3. character 角色塑造(18分):主角弧光、角色辨识度、角色动机、配角质量。短剧角色须在前2分钟建立;长剧需要内在矛盾和多阶段成长。
4. dialogue 台词对白(15分):角色语言区分度、信息传递效率、潜台词与留白、金句与记忆点。
5. visual 画面表现(15分):场景描写质量、视觉叙事技巧、镜头感与节奏、制作可行性。AIGC需考虑AI生成技术边界与一致性。
6. content 内容深度(12分):主题表达、情感共鸣、社会/人性洞察。
【评分铁律】
- 扣分必须明确指出剧本中的具体段落/场景/台词。
- 严禁给出任何维度的满分,必须有扣分理由。
- 优缺点都要充分展开,不可只批不夸或只夸不批。
- 不因题材类型偏见降低评分,不因某一方面出色而抬高其他维度(避免光环效应)。
- 敢于拉开各维度分数差距,避免全部给中等分数。
【等级标准】按总分百分比:S≥90 | A 80-89 | B 70-79 | C 60-69 | D<60。
请严格按以下 JSON 格式返回(不要包含任何其他文字,不要用代码块包裹以外的说明):
{
"dimensionScores": { "hook": 数字, "plot": 数字, "character": 数字, "dialogue": 数字, "visual": 数字, "content": 数字 },
"summary": "200-300字综合评价,概括整体质量、市场潜力与目标受众匹配度",
"issues": ["每条指出具体维度的扣分点并引用剧本原文位置", ...],
"highlights": ["核心亮点,引用剧本具体场景", ...],
"suggestions": ["按优先级排列的改进建议(最优先/次优先/可优化)", ...]
}`;
const DIMENSION_WEIGHTS: Record<string, { maxScore: number }> = {
hook: { maxScore: 20 },
plot: { maxScore: 20 },
character: { maxScore: 15 },
logic: { maxScore: 15 },
visual: { maxScore: 15 },
content: { maxScore: 15 },
};
function computeTotalAndGrade(scores: Record<string, number>): { totalScore: number; grade: string } {
const totalScore = Math.round(
Object.entries(DIMENSION_WEIGHTS).reduce((sum, [key, dim]) => {
return sum + Math.max(0, Math.min(dim.maxScore, scores[key] ?? 0));
}, 0),
);
const grade = totalScore >= 90 ? "S" : totalScore >= 80 ? "A" : totalScore >= 70 ? "B" : totalScore >= 60 ? "C" : "D";
return { totalScore, grade };
}
function extractJson(text: string): unknown {
const fenced = text.match(/```(?:json)?\s*([\s\S]*?)```/);
const raw = fenced ? fenced[1].trim() : text.trim();
return JSON.parse(raw);
}
function normalizeScoreValue(value: unknown, maxScore: number): number {
const score = Number(value);
if (!Number.isFinite(score)) return 0;
return Math.max(0, Math.min(maxScore, Math.round(score * 10) / 10));
}
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value && typeof value === "object" && !Array.isArray(value));
}
function normalizeEvidenceItems(source: unknown[], limit: number): string[] {
const items: string[] = [];
for (const item of source) {
const value = String(item).trim();
if (!value) continue;
items.push(value);
if (items.length >= limit) break;
}
return items;
}
function normalizeNestedScores(value: unknown): Record<string, Record<string, number>> {
if (!isRecord(value)) return {};
const normalized: Record<string, Record<string, number>> = {};
for (const [dimensionKey, dimension] of Object.entries(DIMENSION_WEIGHTS)) {
const source = value[dimensionKey] ?? (dimensionKey === "logic" ? value.dialogue : undefined);
if (!isRecord(source)) continue;
const entries = Object.entries(source)
.map(([key, score]) => [key, normalizeScoreValue(score, dimension.maxScore)] as const)
.filter(([, score]) => score > 0);
if (entries.length > 0) normalized[dimensionKey] = Object.fromEntries(entries);
}
return normalized;
}
function normalizeEvidence(value: unknown): Record<string, string[]> {
if (!isRecord(value)) return {};
const normalized: Record<string, string[]> = {};
for (const dimensionKey of Object.keys(DIMENSION_WEIGHTS)) {
const source = value[dimensionKey] ?? (dimensionKey === "logic" ? value.dialogue : undefined);
if (!Array.isArray(source)) continue;
const items = normalizeEvidenceItems(source, 3);
if (items.length > 0) normalized[dimensionKey] = items;
}
return normalized;
}
export async function evaluateScript(script: string, signal?: AbortSignal): Promise<ScriptEvalResult> {
const payload = await serverRequest<{
content?: string;
choices?: Array<{ message?: { content?: string } }>;
text?: string;
}>("ai/chat", {
method: "POST",
body: {
model: MODEL,
messages: [
{ role: "system", content: EVAL_SYSTEM_PROMPT },
{ role: "system", content: EVAL_OUTPUT_CONTRACT },
{ role: "user", content: `请评测以下剧本:\n\n${script.slice(0, 8000)}` },
],
stream: false,
temperature: 0.3,
max_tokens: 4096,
},
signal,
timeoutMs: 180_000,
maxRetries: 0,
fallbackMessage: "评测请求失败",
});
const content: string = payload?.content ?? payload?.choices?.[0]?.message?.content ?? payload?.text ?? "";
if (!content) throw new Error("模型未返回有效内容");
const parsed = extractJson(content) as Record<string, unknown>;
const dimensionScores: Record<string, number> = {};
const rawScores = parsed.dimensionScores as Record<string, number> | undefined;
if (!rawScores || typeof rawScores !== "object") throw new Error("评分格式异常");
for (const key of Object.keys(DIMENSION_WEIGHTS)) {
const rawValue = key === "logic" ? rawScores.logic ?? rawScores.dialogue : rawScores[key];
dimensionScores[key] = normalizeScoreValue(rawValue, DIMENSION_WEIGHTS[key].maxScore);
}
const { totalScore, grade } = computeTotalAndGrade(dimensionScores);
return {
totalScore,
grade,
dimensionScores,
subScores: normalizeNestedScores(parsed.subScores),
evidence: normalizeEvidence(parsed.evidence),
summary: String(parsed.summary || ""),
issues: Array.isArray(parsed.issues) ? parsed.issues.map(String) : [],
highlights: Array.isArray(parsed.highlights) ? parsed.highlights.map(String) : [],
suggestions: Array.isArray(parsed.suggestions) ? parsed.suggestions.map(String) : [],
};
}
+425
View File
@@ -0,0 +1,425 @@
import type { WebUserSession } from "../types";
export const SERVER_SESSION_STORAGE_KEY = "omniai-web-session";
export const SERVER_SESSION_REPLACED_EVENT = "omniai:session-replaced";
export const SERVER_SESSION_EXPIRED_EVENT = "omniai:session-expired";
export type ServerConnectionState = "checking" | "connected" | "degraded";
export interface ServerConnectionHealth {
state: ServerConnectionState;
baseUrl: string;
checkedAt: string;
errorMessage?: string;
}
export interface ServerRequestOptions {
method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
body?: unknown;
token?: string;
headers?: Record<string, string>;
raw?: boolean;
signal?: AbortSignal;
/** Per-request timeout in ms. Defaults to DEFAULT_REQUEST_TIMEOUT_MS. Pass 0 to disable. */
timeoutMs?: number;
/** Defaults to 2. Use 0 for non-idempotent task submission endpoints. */
maxRetries?: number;
fallbackMessage?: string;
}
export const DEFAULT_REQUEST_TIMEOUT_MS = 30_000;
export interface ServerSessionReplacedDetail {
status?: number;
code?: string;
message: string;
}
export class ServerRequestError extends Error {
status?: number;
code?: string;
payload?: unknown;
constructor(message: string, status?: number, payload?: unknown) {
super(message);
this.name = "ServerRequestError";
this.status = status;
this.payload = payload;
if (isRecord(payload) && typeof payload.code === "string") {
this.code = payload.code;
}
}
}
export function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
export function compactMessage(value: string): string {
return value.replace(/\s+/g, " ").trim().slice(0, 240);
}
export function getServerBaseUrl(): string {
return "";
}
export function buildApiUrl(path: string): string {
const cleanPath = path.replace(/^\/+/, "");
return `/api/${cleanPath}`;
}
export function canUseSessionStorage(): boolean {
return typeof window !== "undefined" && typeof window.sessionStorage !== "undefined";
}
function canUseLocalStorage(): boolean {
return typeof window !== "undefined" && typeof window.localStorage !== "undefined";
}
function parseStoredSession(raw: string | null): WebUserSession | null {
if (!raw) return null;
try {
const parsed = JSON.parse(raw) as unknown;
return isRecord(parsed) && typeof parsed.token === "string" && isRecord(parsed.user)
? (parsed as unknown as WebUserSession)
: null;
} catch {
return null;
}
}
export function readStoredSession(): WebUserSession | null {
let fallbackSession: WebUserSession | null = null;
try {
if (canUseLocalStorage()) {
const localSession = parseStoredSession(window.localStorage.getItem(SERVER_SESSION_STORAGE_KEY));
if (localSession) return localSession;
}
} catch {
// Fall through to the legacy session-scoped copy.
}
try {
if (canUseSessionStorage()) {
fallbackSession = parseStoredSession(window.sessionStorage.getItem(SERVER_SESSION_STORAGE_KEY));
}
} catch {
fallbackSession = null;
}
if (fallbackSession && canUseLocalStorage()) {
try {
window.localStorage.setItem(SERVER_SESSION_STORAGE_KEY, JSON.stringify(fallbackSession));
} catch {
// Migrating the legacy session is best-effort.
}
}
return fallbackSession;
}
export function writeStoredSession(session: WebUserSession | null): void {
try {
if (canUseLocalStorage()) {
if (session) {
window.localStorage.setItem(SERVER_SESSION_STORAGE_KEY, JSON.stringify(session));
} else {
window.localStorage.removeItem(SERVER_SESSION_STORAGE_KEY);
}
}
} catch {
// Browser persistence is a convenience layer, not a hard dependency.
}
try {
if (canUseSessionStorage()) {
if (session) {
window.sessionStorage.setItem(SERVER_SESSION_STORAGE_KEY, JSON.stringify(session));
} else {
window.sessionStorage.removeItem(SERVER_SESSION_STORAGE_KEY);
}
}
} catch {
// Keep the local copy as the primary persistence layer.
}
}
export function clearAllUserStorage(): void {
writeStoredSession(null);
try {
if (typeof window === "undefined") return;
const legacyKeys = ["omniai:token", "omniai:session"];
for (const key of legacyKeys) {
window.localStorage.removeItem(key);
window.sessionStorage.removeItem(key);
}
const prefixKeys = [
"omniai-web-profile-ui",
"omniai:more-recent-tools",
"omniai:generation-queue",
"omniai-canvas-saved-assets",
];
for (let i = window.localStorage.length - 1; i >= 0; i--) {
const key = window.localStorage.key(i);
if (key && prefixKeys.some((p) => key.startsWith(p))) {
window.localStorage.removeItem(key);
}
}
for (let i = window.sessionStorage.length - 1; i >= 0; i--) {
const key = window.sessionStorage.key(i);
if (key && prefixKeys.some((p) => key.startsWith(p))) {
window.sessionStorage.removeItem(key);
}
}
} catch {
// best-effort cleanup
}
}
export function getStoredToken(): string | null {
return readStoredSession()?.token ?? null;
}
export function buildAuthHeaders(tokenOverride?: string, extraHeaders?: Record<string, string>): Record<string, string> {
const token = (tokenOverride ?? getStoredToken() ?? "").trim();
return {
"Content-Type": "application/json",
...(token ? { Authorization: `Bearer ${token}` } : {}),
...(extraHeaders || {}),
};
}
export function parseResponseBody(text: string): unknown {
const trimmed = text.trim();
if (!trimmed) return null;
try {
return JSON.parse(trimmed);
} catch {
return trimmed;
}
}
export function unwrapApiPayload(payload: unknown): unknown {
if (!isRecord(payload)) return payload;
const nested = payload.data ?? payload.result ?? payload.payload;
return nested === undefined ? payload : nested;
}
export function getPayloadMessage(payload: unknown): string | null {
if (typeof payload === "string") {
const message = compactMessage(payload);
if (/^<!doctype html|<html[\s>]|<head[\s>]|<body[\s>]|^<\?xml|<Error[\s>]/i.test(message)) {
return null;
}
return message || null;
}
if (!isRecord(payload)) return null;
const message = payload.error ?? payload.message ?? payload.errorMessage;
if (typeof message !== "string") return null;
const compacted = compactMessage(message);
if (/^<!doctype html|<html[\s>]|<head[\s>]|<body[\s>]|^<\?xml|<Error[\s>]/i.test(compacted)) {
return null;
}
return compacted || null;
}
function getPayloadCode(payload: unknown): string | undefined {
return isRecord(payload) && typeof payload.code === "string" ? payload.code : undefined;
}
let lastSessionReplacedEventAt = 0;
let lastSessionExpiredEventAt = 0;
function isNonAuthErrorCode(code: string | undefined): boolean {
if (!code) return false;
return [
"ENTERPRISE_VIDEO_MODEL_NOT_ALLOWED",
"INSUFFICIENT_BALANCE",
"INSUFFICIENT_ENTERPRISE_BALANCE",
].includes(code);
}
function isAuthFailureResponse(status: number, payload: unknown): boolean {
if (status === 401) return true;
if (status !== 403) return false;
const code = getPayloadCode(payload);
if (code === "SESSION_REPLACED" || code === "TOKEN_EXPIRED" || code === "ACCOUNT_DISABLED") return true;
const message = getPayloadMessage(payload) || "";
return /账号已禁用|登录已过期|登录状态|session|token|企业信息不存在/i.test(message);
}
function notifySessionExpired(status: number, response: Response, payload: unknown): void {
if (status !== 401 && status !== 403) return;
if (typeof window === "undefined") return;
// Auth endpoints (login/register/me) surface their own errors — a wrong
// password must not be mistaken for an expired session.
if (/\/auth\//i.test(response.url)) return;
// SESSION_REPLACED has its own dedicated handling/modal.
if (getPayloadCode(payload) === "SESSION_REPLACED") return;
// If the user never had a session, a 401 is expected — not a session expiry.
if (!readStoredSession()) return;
// Deliberate early-exit for unauthenticated users — not a real auth failure.
if (getPayloadCode(payload) === "NOT_LOGGED_IN") return;
// Non-auth 403 errors (enterprise model access, insufficient balance) must
// not trigger session expiry.
if (status === 403 && isNonAuthErrorCode(getPayloadCode(payload))) return;
if (!isAuthFailureResponse(status, payload)) return;
const now = Date.now();
if (now - lastSessionExpiredEventAt < 1500) return;
lastSessionExpiredEventAt = now;
window.dispatchEvent(
new CustomEvent<ServerSessionReplacedDetail>(SERVER_SESSION_EXPIRED_EVENT, {
detail: { status, code: getPayloadCode(payload), message: "登录状态已失效,请重新登录。" },
}),
);
}
function notifySessionReplaced(status: number, payload: unknown, fallbackMessage: string): void {
const code = getPayloadCode(payload);
const message = getPayloadMessage(payload) || fallbackMessage || "您已在别处登录";
const isSessionReplaced = code === "SESSION_REPLACED" || message.includes("您已在别处登录");
if (!isSessionReplaced || typeof window === "undefined") return;
if (!readStoredSession()) return;
const now = Date.now();
if (now - lastSessionReplacedEventAt < 1500) return;
lastSessionReplacedEventAt = now;
window.dispatchEvent(
new CustomEvent<ServerSessionReplacedDetail>(SERVER_SESSION_REPLACED_EVENT, {
detail: { status, code, message },
}),
);
}
export function getErrorMessage(error: unknown): string {
if (error instanceof Error) return error.message;
return String(error || "Unknown API error");
}
export function isServerRequestError(error: unknown): error is ServerRequestError {
return error instanceof ServerRequestError;
}
export async function readJsonResponse<T>(response: Response, fallbackMessage: string): Promise<T> {
const payload = parseResponseBody(await response.text().catch(() => ""));
if (!response.ok) {
const message =
getPayloadMessage(payload) ||
compactMessage(response.statusText) ||
`${fallbackMessage} (${response.status})`;
notifySessionReplaced(response.status, payload, message);
notifySessionExpired(response.status, response, payload);
throw new ServerRequestError(message, response.status, payload);
}
return unwrapApiPayload(payload) as T;
}
export async function throwResponseError(response: Response, fallbackMessage: string): Promise<never> {
const payload = parseResponseBody(await response.text().catch(() => ""));
const message =
getPayloadMessage(payload) ||
compactMessage(response.statusText) ||
`${fallbackMessage} (${response.status})`;
notifySessionReplaced(response.status, payload, message);
notifySessionExpired(response.status, response, payload);
throw new ServerRequestError(message, response.status, payload);
}
function isRetryable(error: unknown): boolean {
if (error instanceof ServerRequestError) {
const s = error.status;
return s === 429 || (s !== undefined && s >= 500);
}
return error instanceof TypeError || (error instanceof DOMException && error.name !== "AbortError");
}
function getRetryDelay(attempt: number, error: unknown): number {
if (error instanceof ServerRequestError && error.status === 429) {
return Math.min(5000, 2000 * attempt);
}
return Math.min(4000, 300 * 2 ** attempt);
}
const MAX_RETRIES = 2;
export async function serverRequest<T>(path: string, options?: ServerRequestOptions): Promise<T> {
let lastError: unknown;
const timeoutMs = options?.timeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
const maxRetries = options?.maxRetries ?? MAX_RETRIES;
const fallbackMessage = options?.fallbackMessage || "Request failed";
for (let attempt = 0; attempt <= maxRetries; attempt++) {
const controller = timeoutMs > 0 ? new AbortController() : null;
const timeoutId =
controller && typeof window !== "undefined"
? window.setTimeout(() => controller.abort(new DOMException("Request timed out", "TimeoutError")), timeoutMs)
: null;
const onCallerAbort = () => controller?.abort((options?.signal as AbortSignal)?.reason);
if (controller && options?.signal) {
if (options.signal.aborted) controller.abort(options.signal.reason);
else options.signal.addEventListener("abort", onCallerAbort, { once: true });
}
try {
const headers = buildAuthHeaders(options?.token, options?.headers);
const response = await fetch(buildApiUrl(path), {
method: options?.method || "GET",
headers,
body: options?.body === undefined ? undefined : JSON.stringify(options.body),
signal: controller ? controller.signal : options?.signal,
credentials: "include",
});
const payload = await readJsonResponse<unknown>(response, fallbackMessage);
return (options?.raw ? payload : unwrapApiPayload(payload)) as T;
} catch (error) {
lastError = error;
if (attempt < maxRetries && isRetryable(error) && !options?.signal?.aborted) {
await new Promise((r) => setTimeout(r, getRetryDelay(attempt, error)));
continue;
}
throw error;
} finally {
if (timeoutId !== null) window.clearTimeout(timeoutId);
options?.signal?.removeEventListener("abort", onCallerAbort);
}
}
throw lastError;
}
export async function checkServerHealth(timeoutMs = 4500): Promise<ServerConnectionHealth> {
const controller = new AbortController();
const timeoutId = window.setTimeout(() => controller.abort(), timeoutMs);
try {
await serverRequest<unknown>("/health", { signal: controller.signal });
return {
state: "connected",
baseUrl: getServerBaseUrl(),
checkedAt: new Date().toISOString(),
};
} catch (error) {
return {
state: "degraded",
baseUrl: getServerBaseUrl(),
checkedAt: new Date().toISOString(),
errorMessage: getErrorMessage(error),
};
} finally {
window.clearTimeout(timeoutId);
}
}
+128
View File
@@ -0,0 +1,128 @@
import { aiGenerationClient } from "./aiGenerationClient";
import {
buildLocalTimeoutMessage,
getTaskTimeoutPolicy,
isTaskLocallyTimedOut,
} from "../utils/taskLifecycle";
export interface TaskProgressEvent {
taskId: string;
status: string;
progress: number;
resultUrl?: string | null;
error?: string | null;
}
export interface WaitForTaskOptions {
onProgress?: (event: TaskProgressEvent) => void;
abortRef?: { current: boolean };
timeoutMs?: number;
noProgressTimeoutMs?: number;
startedAt?: number;
kind?: "image" | "video" | "text";
model?: string | null;
operation?: string | null;
}
const POLL_INTERVAL = 3000;
export function waitForTask(
taskId: string,
options: WaitForTaskOptions = {},
): Promise<string | null> {
const { onProgress, abortRef } = options;
const timeoutPolicy = getTaskTimeoutPolicy({
kind: options.kind,
model: options.model,
operation: options.operation,
});
const timeoutMs = options.timeoutMs ?? timeoutPolicy.maxRuntimeMs;
const noProgressTimeoutMs = options.noProgressTimeoutMs ?? timeoutPolicy.noProgressTimeoutMs;
const startedAt = options.startedAt ?? Date.now();
return new Promise((resolve, reject) => {
let settled = false;
let cleanup: (() => void) | null = null;
let timeoutId: ReturnType<typeof setTimeout> | null = null;
let fallbackTimerId: ReturnType<typeof setTimeout> | null = null;
let lastProgress = 0;
let lastProgressAt = startedAt;
const settle = (fn: () => void) => {
if (settled) return;
settled = true;
if (timeoutId) clearTimeout(timeoutId);
if (fallbackTimerId) clearTimeout(fallbackTimerId);
if (cleanup) cleanup();
fn();
};
timeoutId = setTimeout(
() => settle(() => reject(new Error(buildLocalTimeoutMessage(options.kind || "video")))),
timeoutMs,
);
const handleUpdate = (event: TaskProgressEvent) => {
if (settled) return;
if (abortRef?.current) {
settle(() => resolve(null));
return;
}
const progress = Number(event.progress || 0);
if (progress > lastProgress || event.status === "completed") {
lastProgress = Math.max(lastProgress, progress);
lastProgressAt = Date.now();
}
onProgress?.(event);
if (event.status === "completed") {
settle(() => resolve(event.resultUrl || null));
} else if (event.status === "failed" || event.status === "cancelled") {
settle(() => reject(new Error(event.error || "任务失败,请稍后重试")));
}
};
cleanup = aiGenerationClient.subscribeTaskStatus(taskId, handleUpdate);
fallbackTimerId = setTimeout(() => {
if (settled) return;
if (cleanup) cleanup();
startPolling();
}, 5000);
function startPolling() {
const poll = async () => {
while (!settled) {
if (abortRef?.current) {
settle(() => resolve(null));
return;
}
await new Promise((r) => setTimeout(r, POLL_INTERVAL));
if (settled || abortRef?.current) return;
const timeoutReason = isTaskLocallyTimedOut({
startedAt,
lastProgressAt,
progress: lastProgress,
policy: { ...timeoutPolicy, noProgressTimeoutMs },
});
if (timeoutReason) {
settle(() => reject(new Error(buildLocalTimeoutMessage(options.kind || "video"))));
return;
}
try {
const task = await aiGenerationClient.getTaskStatus(taskId);
handleUpdate({
taskId,
status: task.status,
progress: task.progress || 0,
resultUrl: task.resultUrl,
error: task.error,
});
} catch (e) {
if (!settled) settle(() => reject(e));
}
}
};
void poll();
}
});
}
+57
View File
@@ -0,0 +1,57 @@
import { buildApiUrl, buildAuthHeaders, readJsonResponse, throwResponseError } from "./serverConnection";
export interface UploadProgressOptions {
onProgress?: (percent: number) => void;
signal?: AbortSignal;
}
export async function uploadAssetWithProgress(
input: { dataUrl: string; name?: string; mimeType?: string; scope?: string },
options?: UploadProgressOptions,
): Promise<{ url: string; signedUrl?: string; ossKey?: string }> {
const { onProgress, signal } = options || {};
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
const url = buildApiUrl("oss/upload");
const headers = buildAuthHeaders();
xhr.open("POST", url);
Object.entries(headers).forEach(([k, v]) => xhr.setRequestHeader(k, v));
xhr.upload.addEventListener("progress", (e) => {
if (e.lengthComputable && onProgress) {
onProgress(Math.round((e.loaded / e.total) * 100));
}
});
xhr.addEventListener("load", async () => {
const fakeResponse = new Response(xhr.responseText, {
status: xhr.status,
statusText: xhr.statusText,
headers: { "Content-Type": "application/json" },
});
try {
if (!fakeResponse.ok) {
await throwResponseError(fakeResponse, "Asset upload failed");
}
const result = await readJsonResponse<{ url: string; ossKey?: string }>(
fakeResponse.clone(),
"Asset upload response failed",
);
resolve(result);
} catch (e) {
reject(e);
}
});
xhr.addEventListener("error", () => reject(new Error("上传失败,请检查网络连接")));
xhr.addEventListener("abort", () => reject(new Error("上传已取消")));
if (signal) {
signal.addEventListener("abort", () => xhr.abort());
}
xhr.send(JSON.stringify(input));
});
}
+110
View File
@@ -0,0 +1,110 @@
import type { WebGenerationPreviewTask } from "../types";
import { aiGenerationClient } from "./aiGenerationClient";
import { resolveVideoRequestModel } from "../utils/resolveVideoModel";
import { ENTERPRISE_DEFAULT_VIDEO_MODEL } from "../utils/enterpriseVideoPolicy";
function formatPreviewTaskTimestamp(date = new Date()): string {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
const hours = String(date.getHours()).padStart(2, "0");
const minutes = String(date.getMinutes()).padStart(2, "0");
return `${year}-${month}-${day} ${hours}:${minutes}`;
}
export interface CreatePreviewTaskInput {
title: string;
type: WebGenerationPreviewTask["type"];
prompt: string;
params?: {
existingTaskId?: string;
projectId?: string;
conversationId?: number;
model?: string;
ratio?: string;
quality?: string;
resolution?: string;
gridMode?: string;
duration?: number;
frameMode?: string;
referenceUrls?: string[];
audioUrl?: string;
muted?: boolean;
hasReferenceVideo?: boolean;
};
}
export const webGenerationGateway = {
async createPreviewTask(input: CreatePreviewTaskInput): Promise<WebGenerationPreviewTask> {
const { type, params } = input;
const prompt = input.prompt.trim();
const title = input.title.trim() || "未命名任务";
const createdAt = formatPreviewTaskTimestamp();
try {
let taskId: string;
if (params?.existingTaskId) {
taskId = params.existingTaskId;
} else if (type === "image") {
const result = await aiGenerationClient.createImageTask({
projectId: params?.projectId,
conversationId: params?.conversationId,
model: params?.model || "nano-banana-pro",
prompt,
ratio: params?.ratio || "16:9",
quality: params?.quality || "1K",
gridMode: params?.gridMode || "single",
referenceUrls: params?.referenceUrls,
});
taskId = result.taskId;
} else if (type === "video") {
const refs = params?.referenceUrls;
let model: string = params?.model || ENTERPRISE_DEFAULT_VIDEO_MODEL;
model = resolveVideoRequestModel({ model, referenceUrls: refs });
const result = await aiGenerationClient.createVideoTask({
projectId: params?.projectId,
conversationId: params?.conversationId,
model,
prompt,
ratio: params?.ratio || "16:9",
duration: params?.duration || 5,
quality: params?.quality || params?.resolution || "1080P",
resolution: params?.resolution || params?.quality || "1080P",
frameMode: params?.frameMode || "start-end",
referenceUrls: params?.referenceUrls,
audioUrl: params?.audioUrl,
muted: params?.muted ?? false,
hasReferenceVideo: params?.hasReferenceVideo ?? false,
});
taskId = result.taskId;
} else {
taskId = `web-task-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
}
return {
id: taskId,
title,
type,
status: "queued",
progress: 5,
prompt,
createdAt,
source: "server",
projectId: params?.projectId,
};
} catch (err) {
return {
id: `web-task-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
title,
type,
status: "failed",
progress: 0,
prompt,
createdAt,
source: "server",
errorMessage: err instanceof Error ? err.message : "请求失败,请稍后重试",
};
}
},
};