Initial commit: OmniAI Web Frontend
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,284 @@
|
||||
import { buildApiUrl, buildAuthHeaders } from "./serverConnection";
|
||||
|
||||
const TEXT_MODEL = "qwen-max";
|
||||
const VISION_MODEL = "qwen3.6-plus";
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
async function chat(
|
||||
systemPrompt: string,
|
||||
userContent: string,
|
||||
options?: { model?: string; signal?: AbortSignal },
|
||||
): Promise<string> {
|
||||
const messages: ChatMessage[] = [
|
||||
{ role: "system", content: systemPrompt },
|
||||
{ role: "user", content: userContent },
|
||||
];
|
||||
const timeoutSignal = AbortSignal.timeout(60000);
|
||||
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: options?.model ?? TEXT_MODEL,
|
||||
messages,
|
||||
stream: false,
|
||||
temperature: 0.4,
|
||||
}),
|
||||
signal: combinedSignal,
|
||||
});
|
||||
if (!res.ok) throw new Error(`AI 调用失败 (${res.status})`);
|
||||
const payload = await res.json();
|
||||
const content: string =
|
||||
payload?.content ?? payload?.choices?.[0]?.message?.content ?? payload?.text ?? "";
|
||||
if (!content) throw new Error("模型未返回有效内容");
|
||||
return content;
|
||||
}
|
||||
|
||||
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 timeoutSignal = AbortSignal.timeout(60000);
|
||||
const combinedSignal = signal
|
||||
? AbortSignal.any([signal, timeoutSignal])
|
||||
: timeoutSignal;
|
||||
const res = await fetch(buildApiUrl("ai/chat"), {
|
||||
method: "POST",
|
||||
headers: buildAuthHeaders(),
|
||||
body: JSON.stringify({
|
||||
model: VISION_MODEL,
|
||||
messages: [
|
||||
{ role: "system", content: systemPrompt },
|
||||
{ role: "user", content },
|
||||
],
|
||||
stream: false,
|
||||
temperature: 0.3,
|
||||
}),
|
||||
signal: combinedSignal,
|
||||
});
|
||||
if (!res.ok) throw new Error(`图片理解调用失败 (${res.status})`);
|
||||
const payload = await res.json();
|
||||
const out: string =
|
||||
payload?.content ?? payload?.choices?.[0]?.message?.content ?? payload?.text ?? "";
|
||||
if (!out) throw new Error("图片理解未返回有效内容");
|
||||
return out;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,516 @@
|
||||
import {
|
||||
buildApiUrl,
|
||||
buildAuthHeaders,
|
||||
isRecord,
|
||||
readJsonResponse,
|
||||
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 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 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" ? "视频生成任务" : "图像生成任务";
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
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 res = await fetch(requestUrl, {
|
||||
method: "POST",
|
||||
headers: buildAuthHeaders(),
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
if (!res.ok) {
|
||||
await throwResponseError(res, "Image generation request failed");
|
||||
}
|
||||
const payload = await readJsonResponse<ImageTaskCreateResponse>(res, "Image generation response failed");
|
||||
if (payload.providerDebug) {
|
||||
emitImageRouteDebug("[ai/image-provider-debug]", payload.providerDebug as Record<string, unknown>);
|
||||
}
|
||||
return payload;
|
||||
},
|
||||
|
||||
async createVideoTask(input: VideoGenInput): Promise<{ taskId: string }> {
|
||||
const res = await fetch(buildApiUrl("ai/video"), {
|
||||
method: "POST",
|
||||
headers: buildAuthHeaders(),
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
if (!res.ok) {
|
||||
await throwResponseError(res, "Video generation request failed");
|
||||
}
|
||||
return readJsonResponse<{ taskId: string }>(res, "Video generation response failed");
|
||||
},
|
||||
|
||||
async createVideoSuperResolveTask(input: VideoSuperResolveInput): Promise<{ taskId: string }> {
|
||||
const res = await fetch(buildApiUrl("ai/video/super-resolve"), {
|
||||
method: "POST",
|
||||
headers: buildAuthHeaders(),
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
if (!res.ok) {
|
||||
await throwResponseError(res, "Video super-resolution request failed");
|
||||
}
|
||||
return readJsonResponse<{ taskId: string }>(res, "Video super-resolution response failed");
|
||||
},
|
||||
|
||||
async createEraseSubtitlesTask(input: EraseSubtitlesInput): Promise<{ taskId: string }> {
|
||||
const res = await fetch(buildApiUrl("ai/video/erase-subtitles"), {
|
||||
method: "POST",
|
||||
headers: buildAuthHeaders(),
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
if (!res.ok) {
|
||||
await throwResponseError(res, "Subtitle removal request failed");
|
||||
}
|
||||
return readJsonResponse<{ taskId: string }>(res, "Subtitle removal response failed");
|
||||
},
|
||||
|
||||
async createImageSuperResolveTask(input: ImageSuperResolveInput): Promise<{ taskId: string }> {
|
||||
const res = await fetch(buildApiUrl("ai/image/super-resolve"), {
|
||||
method: "POST",
|
||||
headers: buildAuthHeaders(),
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
if (!res.ok) {
|
||||
await throwResponseError(res, "Image super-resolution request failed");
|
||||
}
|
||||
return readJsonResponse<{ taskId: string }>(res, "Image super-resolution response failed");
|
||||
},
|
||||
|
||||
async createImageEditTask(input: ImageEditInput): Promise<{ taskId: string }> {
|
||||
const res = await fetch(buildApiUrl("ai/image/edit"), {
|
||||
method: "POST",
|
||||
headers: buildAuthHeaders(),
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
if (!res.ok) {
|
||||
await throwResponseError(res, "Image edit request failed");
|
||||
}
|
||||
return readJsonResponse<{ taskId: string }>(res, "Image edit response failed");
|
||||
},
|
||||
|
||||
async cancelTask(taskId: string): Promise<void> {
|
||||
const res = await fetch(buildApiUrl(`ai/tasks/${taskId}/cancel`), {
|
||||
method: "PATCH",
|
||||
headers: buildAuthHeaders(),
|
||||
});
|
||||
if (!res.ok && res.status !== 404) {
|
||||
await throwResponseError(res, "Task cancel failed");
|
||||
}
|
||||
},
|
||||
|
||||
async getTaskStatus(taskId: string): Promise<AiTaskStatus> {
|
||||
const res = await fetch(buildApiUrl(`ai/tasks/${taskId}`), {
|
||||
method: "GET",
|
||||
headers: buildAuthHeaders(),
|
||||
});
|
||||
if (!res.ok) {
|
||||
await throwResponseError(res, "Task status request failed");
|
||||
}
|
||||
return readJsonResponse<AiTaskStatus>(res, "Task status response 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);
|
||||
const res = await fetch(buildApiUrl(`ai/tasks${search.toString() ? `?${search}` : ""}`), {
|
||||
method: "GET",
|
||||
headers: buildAuthHeaders(),
|
||||
});
|
||||
if (!res.ok) {
|
||||
try {
|
||||
await throwResponseError(res, "Task history request failed");
|
||||
} catch (error) {
|
||||
if (isOptionalApiRouteMissing(error)) {
|
||||
taskHistoryRouteMissing = true;
|
||||
return [];
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
const payload = await readJsonResponse<unknown>(res, "Task history response failed");
|
||||
return extractTaskList(payload).map(toPreviewTask);
|
||||
},
|
||||
|
||||
async bindTaskToConversation(taskId: string, conversationId: number): Promise<void> {
|
||||
const res = await fetch(buildApiUrl(`ai/tasks/${taskId}/conversation`), {
|
||||
method: "PATCH",
|
||||
headers: buildAuthHeaders(),
|
||||
body: JSON.stringify({ conversationId }),
|
||||
});
|
||||
if (res.status === 404) {
|
||||
return;
|
||||
}
|
||||
if (!res.ok) {
|
||||
await throwResponseError(res, "Task conversation binding failed");
|
||||
}
|
||||
},
|
||||
|
||||
async uploadAsset(input: UploadAssetInput): Promise<{ url: string; signedUrl?: string; ossKey?: string }> {
|
||||
const res = await fetch(buildApiUrl("oss/upload"), {
|
||||
method: "POST",
|
||||
headers: buildAuthHeaders(),
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
if (!res.ok) {
|
||||
await throwResponseError(res, "Asset upload failed");
|
||||
}
|
||||
return readJsonResponse<{ url: string; ossKey?: string }>(res, "Asset upload response failed");
|
||||
},
|
||||
|
||||
async uploadAssetByUrl(input: UploadAssetByUrlInput): Promise<{ url: string; signedUrl?: string; ossKey?: string }> {
|
||||
const res = await fetch(buildApiUrl("oss/upload-by-url"), {
|
||||
method: "POST",
|
||||
headers: buildAuthHeaders(),
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
if (!res.ok) {
|
||||
await throwResponseError(res, "Asset upload by URL failed");
|
||||
}
|
||||
return readJsonResponse<{ url: string; ossKey?: string }>(res, "Asset upload by URL response 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,
|
||||
): 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("无法读取响应流");
|
||||
|
||||
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 };
|
||||
if (chunk.error) throw new Error(chunk.error);
|
||||
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 || "";
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
export function isOptionalApiRouteMissing(error: unknown): boolean {
|
||||
return typeof error === "object" && error !== null && "status" in error && Number(error.status) === 404;
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
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[] {
|
||||
return Array.isArray(value) ? value.map((item) => toStringValue(item)).filter(Boolean) : [];
|
||||
}
|
||||
|
||||
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" });
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,203 @@
|
||||
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[] {
|
||||
return Array.isArray(value)
|
||||
? value.map((item) => toStringValue(item)).filter(Boolean)
|
||||
: [];
|
||||
}
|
||||
|
||||
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),
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -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" });
|
||||
},
|
||||
};
|
||||
@@ -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>;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,62 @@
|
||||
type GenerationKind = "image" | "video";
|
||||
|
||||
interface GenerationSlot {
|
||||
id: string;
|
||||
userKey: string;
|
||||
kind: GenerationKind;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
const MAX_ACTIVE_GENERATION_TASKS = 3;
|
||||
const STALE_SLOT_MS = 6 * 60 * 60 * 1000;
|
||||
const activeSlots = new Map<string, GenerationSlot>();
|
||||
|
||||
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);
|
||||
if (activeCount >= MAX_ACTIVE_GENERATION_TASKS) {
|
||||
throw new Error("当前账号同时最多生成 3 个图片/视频任务,请等待已有任务完成后再提交。");
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,931 @@
|
||||
import type {
|
||||
WebCanvasWorkflow,
|
||||
WebCanvasWorkflowNode,
|
||||
WebEnterpriseUsageSummary,
|
||||
WebProjectSummary,
|
||||
WebUsageSummary,
|
||||
WebUserSession,
|
||||
} from "../types";
|
||||
import {
|
||||
getErrorMessage,
|
||||
getServerBaseUrl,
|
||||
isRecord,
|
||||
isServerRequestError,
|
||||
readStoredSession,
|
||||
serverRequest,
|
||||
unwrapApiPayload,
|
||||
writeStoredSession,
|
||||
} from "./serverConnection";
|
||||
|
||||
interface LoginInput {
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
interface RegisterInput extends LoginInput {
|
||||
betaCode: string;
|
||||
}
|
||||
|
||||
interface EmailAuthInput {
|
||||
email: string;
|
||||
password: string;
|
||||
username?: string;
|
||||
betaCode?: string;
|
||||
}
|
||||
|
||||
interface PhoneAuthInput {
|
||||
phone: string;
|
||||
code: string;
|
||||
password?: string;
|
||||
betaCode?: string;
|
||||
}
|
||||
|
||||
interface UpdateProfileInput {
|
||||
avatarUrl?: string | null;
|
||||
avatarOssKey?: string | null;
|
||||
bio?: string | null;
|
||||
backgroundUrl?: string | null;
|
||||
profileBackgroundUrl?: string | null;
|
||||
}
|
||||
|
||||
interface DeleteProjectOptions {
|
||||
cleanupUserData?: boolean;
|
||||
}
|
||||
|
||||
export interface WechatLoginTicket {
|
||||
configured: boolean;
|
||||
url?: string;
|
||||
state?: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface WechatLoginSessionStatus {
|
||||
status: "pending" | "completed" | "failed" | "expired" | "missing" | "consumed" | string;
|
||||
error?: string;
|
||||
session?: WebUserSession;
|
||||
}
|
||||
|
||||
const getBaseUrl = getServerBaseUrl;
|
||||
const request = serverRequest;
|
||||
const isHttpError = isServerRequestError;
|
||||
const PROJECT_CONTENT_ENRICH_CONCURRENCY = 1;
|
||||
let projectContentEnrichmentDisabled = false;
|
||||
|
||||
function toNumber(value: unknown, fallback = 0): number {
|
||||
const numberValue = typeof value === "number" ? value : Number(value);
|
||||
return Number.isFinite(numberValue) ? numberValue : fallback;
|
||||
}
|
||||
|
||||
function toStringValue(value: unknown, fallback = ""): string {
|
||||
if (typeof value === "string") {
|
||||
const trimmed = value.trim();
|
||||
return trimmed || fallback;
|
||||
}
|
||||
if (typeof value === "number" && Number.isFinite(value)) {
|
||||
return String(value);
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function toNullableString(value: unknown): string | null {
|
||||
if (typeof value !== "string") return null;
|
||||
const trimmed = value.trim();
|
||||
return trimmed || null;
|
||||
}
|
||||
|
||||
function toNullableId(value: unknown): number | string | null {
|
||||
if (typeof value === "number" && Number.isFinite(value)) return value;
|
||||
return toNullableString(value);
|
||||
}
|
||||
|
||||
function toIdValue(value: unknown, fallback: string): number | string {
|
||||
if (typeof value === "number" && Number.isFinite(value)) return value;
|
||||
return toStringValue(value, fallback);
|
||||
}
|
||||
|
||||
function isPlaceholderProjectText(value: string | null | undefined): boolean {
|
||||
if (!value) return true;
|
||||
return /^(新建项目|新建创作|未命名项目|Untitled project)$/i.test(value.trim());
|
||||
}
|
||||
|
||||
function isPlaceholderProjectDescription(value: string | null | undefined): boolean {
|
||||
if (!value) return true;
|
||||
return /^(从空白画布开始|从空白画布开始,直接进入节点式创作。|最近更新的项目)$/i.test(value.trim());
|
||||
}
|
||||
|
||||
function chooseProjectText(contentText: string | null, currentText: string | null, fallback: string): string {
|
||||
if (contentText && (!isPlaceholderProjectText(contentText) || isPlaceholderProjectText(currentText))) {
|
||||
return contentText;
|
||||
}
|
||||
return currentText || contentText || fallback;
|
||||
}
|
||||
|
||||
function chooseProjectDescription(contentText: string | null, currentText: string | null): string | null {
|
||||
if (contentText && (!isPlaceholderProjectDescription(contentText) || isPlaceholderProjectDescription(currentText))) {
|
||||
return contentText;
|
||||
}
|
||||
return currentText || contentText || null;
|
||||
}
|
||||
|
||||
function pickFirstString(...values: unknown[]): string | null {
|
||||
for (const value of values) {
|
||||
const next = toNullableString(value);
|
||||
if (next) return next;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function toProjectSummary(
|
||||
raw: Record<string, unknown>,
|
||||
source: WebProjectSummary["source"],
|
||||
): WebProjectSummary {
|
||||
return {
|
||||
id: toStringValue(raw.id),
|
||||
name: toStringValue(raw.name, "未命名项目"),
|
||||
description: toNullableString(raw.description),
|
||||
thumbnailUrl: toNullableString(raw.thumbnail_url ?? raw.thumbnailUrl),
|
||||
updatedAt: toStringValue(raw.updated_at ?? raw.updatedAt, "刚刚"),
|
||||
storyboardCount: toNumber(raw.storyboard_count ?? raw.storyboardCount),
|
||||
imageCount: toNumber(raw.image_count ?? raw.imageCount),
|
||||
videoCount: toNumber(raw.video_count ?? raw.videoCount),
|
||||
source,
|
||||
};
|
||||
}
|
||||
|
||||
function buildProjectSummaryFromWorkflow(
|
||||
workflow: WebCanvasWorkflow,
|
||||
source: WebProjectSummary["source"],
|
||||
errorMessage?: string,
|
||||
): WebProjectSummary {
|
||||
const previewNode = workflow.nodes.find((node) => node.previewUrl);
|
||||
return {
|
||||
id: workflow.id,
|
||||
name: workflow.title,
|
||||
description: workflow.description,
|
||||
thumbnailUrl: previewNode?.previewUrl ?? null,
|
||||
updatedAt: "刚刚",
|
||||
storyboardCount: workflow.nodes.length,
|
||||
imageCount: workflow.nodes.filter((node) => node.kind === "image").length,
|
||||
videoCount: workflow.nodes.filter((node) => node.kind === "video").length,
|
||||
source,
|
||||
...(errorMessage ? { errorMessage } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function unwrapProjectContentPayload(payload: unknown): unknown {
|
||||
const unwrapped = unwrapApiPayload(payload);
|
||||
return isRecord(unwrapped) && "content" in unwrapped ? unwrapped.content : unwrapped;
|
||||
}
|
||||
|
||||
function getContentWorkflowRecord(content: unknown): Record<string, unknown> | null {
|
||||
if (!isRecord(content)) return null;
|
||||
const workflow = content.workflowData ?? content.workflow ?? content.workflow_data;
|
||||
return isRecord(workflow) ? workflow : null;
|
||||
}
|
||||
|
||||
function getContentNodes(content: unknown): Record<string, unknown>[] {
|
||||
const workflow = getContentWorkflowRecord(content);
|
||||
const nodeSource =
|
||||
(workflow && Array.isArray(workflow.nodes) ? workflow.nodes : null) ??
|
||||
(isRecord(content) && Array.isArray(content.nodes) ? content.nodes : null);
|
||||
return Array.isArray(nodeSource) ? nodeSource.filter(isRecord) : [];
|
||||
}
|
||||
|
||||
function getContentArray(content: unknown, key: string): Record<string, unknown>[] {
|
||||
if (!isRecord(content)) return [];
|
||||
const value = content[key];
|
||||
return Array.isArray(value) ? value.filter(isRecord) : [];
|
||||
}
|
||||
|
||||
function countNodesByKind(nodes: Record<string, unknown>[], kind: string): number {
|
||||
return nodes.filter((node) => node.kind === kind || node.type === kind).length;
|
||||
}
|
||||
|
||||
function pickProjectPreviewUrl(content: unknown): string | null {
|
||||
if (!isRecord(content)) return null;
|
||||
const workflow = getContentWorkflowRecord(content);
|
||||
const nodes = getContentNodes(content);
|
||||
const storyboards = getContentArray(content, "storyboards");
|
||||
const videos = getContentArray(content, "videos");
|
||||
|
||||
return (
|
||||
pickFirstString(
|
||||
content.thumbnailUrl,
|
||||
content.thumbnail_url,
|
||||
content.coverUrl,
|
||||
content.cover_url,
|
||||
workflow?.thumbnailUrl,
|
||||
workflow?.thumbnail_url,
|
||||
nodes.find((node) => pickFirstString(node.previewUrl, node.preview_url, node.imageUrl, node.image_url))
|
||||
?.previewUrl,
|
||||
nodes.find((node) => pickFirstString(node.previewUrl, node.preview_url, node.imageUrl, node.image_url))
|
||||
?.preview_url,
|
||||
nodes.find((node) => pickFirstString(node.previewUrl, node.preview_url, node.imageUrl, node.image_url))
|
||||
?.imageUrl,
|
||||
nodes.find((node) => pickFirstString(node.previewUrl, node.preview_url, node.imageUrl, node.image_url))
|
||||
?.image_url,
|
||||
storyboards.find((item) => pickFirstString(item.imageUrl, item.image_url, item.coverUrl, item.cover_url))
|
||||
?.imageUrl,
|
||||
storyboards.find((item) => pickFirstString(item.imageUrl, item.image_url, item.coverUrl, item.cover_url))
|
||||
?.image_url,
|
||||
storyboards.find((item) => pickFirstString(item.imageUrl, item.image_url, item.coverUrl, item.cover_url))
|
||||
?.coverUrl,
|
||||
storyboards.find((item) => pickFirstString(item.imageUrl, item.image_url, item.coverUrl, item.cover_url))
|
||||
?.cover_url,
|
||||
videos.find((item) => pickFirstString(item.coverUrl, item.cover_url, item.thumbnailUrl, item.thumbnail_url))
|
||||
?.coverUrl,
|
||||
videos.find((item) => pickFirstString(item.coverUrl, item.cover_url, item.thumbnailUrl, item.thumbnail_url))
|
||||
?.cover_url,
|
||||
videos.find((item) => pickFirstString(item.coverUrl, item.cover_url, item.thumbnailUrl, item.thumbnail_url))
|
||||
?.thumbnailUrl,
|
||||
videos.find((item) => pickFirstString(item.coverUrl, item.cover_url, item.thumbnailUrl, item.thumbnail_url))
|
||||
?.thumbnail_url,
|
||||
) || null
|
||||
);
|
||||
}
|
||||
|
||||
function mergeProjectSummaryWithContent(project: WebProjectSummary, payload: unknown): WebProjectSummary {
|
||||
const content = unwrapProjectContentPayload(payload);
|
||||
if (!isRecord(content)) return project;
|
||||
|
||||
const workflow = getContentWorkflowRecord(content);
|
||||
const nodes = getContentNodes(content);
|
||||
const storyboards = getContentArray(content, "storyboards");
|
||||
const videos = getContentArray(content, "videos");
|
||||
const contentTitle = pickFirstString(content.name, content.projectName, content.title, workflow?.title);
|
||||
const contentDescription = pickFirstString(
|
||||
content.description,
|
||||
content.projectDescription,
|
||||
content.summary,
|
||||
workflow?.description,
|
||||
);
|
||||
const imageCount =
|
||||
countNodesByKind(nodes, "image") ||
|
||||
storyboards.filter((item) => pickFirstString(item.imageUrl, item.image_url, item.coverUrl, item.cover_url)).length;
|
||||
const videoCount =
|
||||
countNodesByKind(nodes, "video") ||
|
||||
videos.length ||
|
||||
storyboards.filter((item) => pickFirstString(item.videoUrl, item.video_url)).length;
|
||||
const storyboardCount = nodes.length || storyboards.length;
|
||||
|
||||
return {
|
||||
...project,
|
||||
name: chooseProjectText(contentTitle, project.name, project.name || "未命名项目"),
|
||||
description: chooseProjectDescription(contentDescription, project.description ?? null),
|
||||
thumbnailUrl: pickProjectPreviewUrl(content) || project.thumbnailUrl || null,
|
||||
storyboardCount: storyboardCount || project.storyboardCount,
|
||||
imageCount: imageCount || project.imageCount,
|
||||
videoCount: videoCount || project.videoCount,
|
||||
};
|
||||
}
|
||||
|
||||
async function enrichProjectSummariesWithContent(projects: WebProjectSummary[]): Promise<WebProjectSummary[]> {
|
||||
if (projectContentEnrichmentDisabled) return projects;
|
||||
|
||||
const enriched = [...projects];
|
||||
|
||||
for (let index = 0; index < projects.length; index += PROJECT_CONTENT_ENRICH_CONCURRENCY) {
|
||||
if (projectContentEnrichmentDisabled) break;
|
||||
const batch = projects.slice(index, index + PROJECT_CONTENT_ENRICH_CONCURRENCY);
|
||||
const results = await Promise.allSettled(
|
||||
batch.map(async (project) => {
|
||||
const payload = await request<unknown>(`/projects/${encodeURIComponent(project.id)}/content?resolveMedia=1`);
|
||||
return mergeProjectSummaryWithContent(project, payload);
|
||||
}),
|
||||
);
|
||||
|
||||
results.forEach((result, offset) => {
|
||||
const targetIndex = index + offset;
|
||||
if (result.status === "fulfilled") {
|
||||
enriched[targetIndex] = result.value;
|
||||
} else {
|
||||
const status = isHttpError(result.reason) ? result.reason.status : undefined;
|
||||
if (status === 404 || (typeof status === "number" && status >= 500)) {
|
||||
projectContentEnrichmentDisabled = true;
|
||||
}
|
||||
enriched[targetIndex] = {
|
||||
...enriched[targetIndex],
|
||||
errorMessage: getErrorMessage(result.reason),
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return enriched;
|
||||
}
|
||||
|
||||
function createWorkflowFingerprint(workflow: WebCanvasWorkflow): string {
|
||||
const payload = JSON.stringify({
|
||||
title: workflow.title,
|
||||
description: workflow.description,
|
||||
settings: workflow.settings,
|
||||
nodes: workflow.nodes.map((node) => ({
|
||||
id: node.id,
|
||||
kind: node.kind,
|
||||
label: node.label,
|
||||
detail: node.detail,
|
||||
previewUrl: node.previewUrl || "",
|
||||
})),
|
||||
edges: workflow.edges.map((edge) => ({
|
||||
id: edge.id,
|
||||
source: edge.source,
|
||||
target: edge.target,
|
||||
label: edge.label || "",
|
||||
})),
|
||||
});
|
||||
|
||||
let hash = 0x811c9dc5;
|
||||
for (let i = 0; i < payload.length; i += 1) {
|
||||
hash ^= payload.charCodeAt(i);
|
||||
hash = Math.imul(hash, 0x01000193);
|
||||
}
|
||||
|
||||
return `wf-${(hash >>> 0).toString(16).padStart(8, "0")}`;
|
||||
}
|
||||
|
||||
function toActivePackages(value: unknown): WebUserSession["user"]["activePackages"] {
|
||||
if (!Array.isArray(value)) return undefined;
|
||||
|
||||
return value.filter(isRecord).map((entry) => ({
|
||||
name: toStringValue(entry.name, "Preview package"),
|
||||
expiresAt: toStringValue(entry.expiresAt ?? entry.expires_at, ""),
|
||||
remainingImage: toNumber(entry.remainingImage ?? entry.remaining_image),
|
||||
remainingVideo: toNumber(entry.remainingVideo ?? entry.remaining_video),
|
||||
remainingText: toNumber(entry.remainingText ?? entry.remaining_text),
|
||||
}));
|
||||
}
|
||||
|
||||
function normalizeUser(raw: unknown): WebUserSession["user"] | null {
|
||||
const payload = unwrapApiPayload(raw);
|
||||
const candidate = isRecord(payload) && isRecord(payload.user) ? payload.user : payload;
|
||||
if (!isRecord(candidate)) return null;
|
||||
|
||||
const id = candidate.id ?? candidate.userId ?? candidate.user_id;
|
||||
const username = toStringValue(candidate.username ?? candidate.name, "预览用户");
|
||||
if (id === undefined || !username) return null;
|
||||
|
||||
return {
|
||||
id: typeof id === "number" && Number.isFinite(id) ? id : toStringValue(id),
|
||||
username,
|
||||
displayName: toNullableString(candidate.displayName ?? candidate.display_name ?? candidate.nickname ?? candidate.name),
|
||||
bio: toNullableString(candidate.bio ?? candidate.profileBio ?? candidate.profile_bio ?? candidate.description ?? candidate.intro),
|
||||
avatarUrl: toNullableString(candidate.avatarUrl ?? candidate.avatar_url),
|
||||
backgroundUrl: toNullableString(
|
||||
candidate.profileBackgroundUrl ??
|
||||
candidate.profile_background_url ??
|
||||
candidate.backgroundUrl ??
|
||||
candidate.background_url ??
|
||||
candidate.coverUrl ??
|
||||
candidate.cover_url,
|
||||
),
|
||||
email: toNullableString(candidate.email),
|
||||
emailVerified: candidate.emailVerified === true || candidate.email_verified === true || candidate.email_verified === 1,
|
||||
phone: toNullableString(candidate.phone),
|
||||
authProvider: toNullableString(candidate.authProvider ?? candidate.auth_provider),
|
||||
sessionId: toNullableString(candidate.sessionId ?? candidate.session_id),
|
||||
sessionStartedAt: toNullableString(candidate.sessionStartedAt ?? candidate.session_started_at),
|
||||
role: toNullableString(candidate.role) ?? undefined,
|
||||
accountType: toNullableString(candidate.accountType ?? candidate.account_type) ?? undefined,
|
||||
enterpriseId: toNullableString(candidate.enterpriseId ?? candidate.enterprise_id),
|
||||
enterpriseName: toNullableString(candidate.enterpriseName ?? candidate.enterprise_name),
|
||||
enterpriseRole: toNullableString(candidate.enterpriseRole ?? candidate.enterprise_role) ?? undefined,
|
||||
enterpriseAdminUserId: toNullableId(candidate.enterpriseAdminUserId ?? candidate.enterprise_admin_user_id),
|
||||
balanceCents: toNumber(
|
||||
candidate.balanceCents ?? candidate.balance_cents ?? candidate.userBalanceCents ?? candidate.user_balance_cents,
|
||||
),
|
||||
enterpriseBalanceCents: toNumber(
|
||||
candidate.enterpriseBalanceCents ??
|
||||
candidate.enterprise_balance_cents ??
|
||||
candidate.enterpriseBalance ??
|
||||
candidate.enterprise_balance,
|
||||
),
|
||||
activePackages: toActivePackages(candidate.activePackages ?? candidate.active_packages),
|
||||
};
|
||||
}
|
||||
|
||||
function extractProjectRows(payload: unknown): Record<string, unknown>[] {
|
||||
const unwrapped = unwrapApiPayload(payload);
|
||||
if (Array.isArray(unwrapped)) {
|
||||
return unwrapped.filter(isRecord);
|
||||
}
|
||||
if (!isRecord(unwrapped)) return [];
|
||||
|
||||
const rows = unwrapped.projects ?? unwrapped.items;
|
||||
return Array.isArray(rows) ? rows.filter(isRecord) : [];
|
||||
}
|
||||
|
||||
function isCanvasWorkflow(value: unknown): value is WebCanvasWorkflow {
|
||||
if (!isRecord(value)) return false;
|
||||
return (
|
||||
typeof value.id === "string" &&
|
||||
typeof value.title === "string" &&
|
||||
typeof value.description === "string" &&
|
||||
typeof value.version === "number" && value.version >= 1 &&
|
||||
isRecord(value.settings) &&
|
||||
Array.isArray(value.nodes) &&
|
||||
Array.isArray(value.edges)
|
||||
);
|
||||
}
|
||||
|
||||
function isLegacyWorkflowData(value: unknown): value is Record<string, unknown> {
|
||||
if (!isRecord(value)) return false;
|
||||
return (
|
||||
typeof value.version === "number" &&
|
||||
Array.isArray(value.nodes) &&
|
||||
Array.isArray(value.edges)
|
||||
);
|
||||
}
|
||||
|
||||
function migrateLegacyWorkflowData(old: Record<string, unknown>, wrapper: Record<string, unknown>, projectId: string): WebCanvasWorkflow {
|
||||
const viewport = isRecord(old.viewport) ? old.viewport : {};
|
||||
return {
|
||||
id: old.id as string || projectId,
|
||||
version: 1,
|
||||
title: String(wrapper.name || wrapper.title || "未命名项目"),
|
||||
description: String(wrapper.description || ""),
|
||||
source: (wrapper.source as WebCanvasWorkflow["source"]) || "blank",
|
||||
settings: {
|
||||
model: String(isRecord(old.settings) ? old.settings.model || "Nano Banana Pro" : "Nano Banana Pro"),
|
||||
ratio: String(isRecord(old.settings) ? old.settings.ratio || "1:1" : "1:1"),
|
||||
duration: String(isRecord(old.settings) ? old.settings.duration || "0s" : "0s"),
|
||||
resolution: String(isRecord(old.settings) ? old.settings.resolution || "2K" : "2K"),
|
||||
},
|
||||
viewport: {
|
||||
x: Number(viewport.x || 0),
|
||||
y: Number(viewport.y || 0),
|
||||
zoom: Number(viewport.zoom || viewport.scale || 1),
|
||||
},
|
||||
nodes: (Array.isArray(old.nodes) ? old.nodes : []).map((n: unknown) => isRecord(n) ? { ...n, position: isRecord(n.position) ? { ...n.position } : { x: 0, y: 0 } } : n) as WebCanvasWorkflowNode[],
|
||||
edges: Array.isArray(old.edges) ? old.edges : [],
|
||||
packages: Array.isArray(old.packages) ? old.packages : [],
|
||||
};
|
||||
}
|
||||
|
||||
function cloneWorkflow(workflow: WebCanvasWorkflow): WebCanvasWorkflow {
|
||||
return {
|
||||
...workflow,
|
||||
settings: { ...workflow.settings },
|
||||
nodes: workflow.nodes.map((node) => ({ ...node, position: { ...node.position } })),
|
||||
edges: workflow.edges.map((edge) => ({ ...edge })),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeProjectContent(payload: unknown, projectId: string): WebCanvasWorkflow {
|
||||
const unwrapped = unwrapApiPayload(payload);
|
||||
const content = isRecord(unwrapped) && "content" in unwrapped ? unwrapped.content : unwrapped;
|
||||
|
||||
// New format: content.workflowData is a full WebCanvasWorkflow
|
||||
if (isRecord(content) && isCanvasWorkflow(content.workflowData)) {
|
||||
return cloneWorkflow({ ...content.workflowData, id: content.workflowData.id || projectId });
|
||||
}
|
||||
|
||||
// New format: content.workflow is a full WebCanvasWorkflow
|
||||
if (isRecord(content) && isCanvasWorkflow(content.workflow)) {
|
||||
return cloneWorkflow({ ...content.workflow, id: content.workflow.id || projectId });
|
||||
}
|
||||
|
||||
// Content itself is a WebCanvasWorkflow
|
||||
if (isCanvasWorkflow(content)) {
|
||||
return cloneWorkflow({ ...content, id: content.id || projectId });
|
||||
}
|
||||
|
||||
// Legacy format: wrapper has name/description, workflowData has nodes/edges/viewport
|
||||
if (isRecord(content) && isLegacyWorkflowData(content.workflowData)) {
|
||||
return migrateLegacyWorkflowData(content.workflowData, content, projectId);
|
||||
}
|
||||
|
||||
throw new Error("Project content did not include a canvas workflow");
|
||||
}
|
||||
|
||||
function normalizeLoginResult(payload: unknown): WebUserSession | null {
|
||||
const unwrapped = unwrapApiPayload(payload);
|
||||
if (!isRecord(unwrapped) || typeof unwrapped.token !== "string") return null;
|
||||
|
||||
const user = normalizeUser(unwrapped.user ?? unwrapped);
|
||||
if (!user) return null;
|
||||
|
||||
return {
|
||||
token: unwrapped.token,
|
||||
user,
|
||||
source: "server",
|
||||
};
|
||||
}
|
||||
|
||||
function updateStoredSessionUser(user: WebUserSession["user"]): WebUserSession | null {
|
||||
const stored = readStoredSession();
|
||||
if (!stored) return null;
|
||||
|
||||
const session: WebUserSession = {
|
||||
...stored,
|
||||
user: {
|
||||
...stored.user,
|
||||
...user,
|
||||
},
|
||||
};
|
||||
writeStoredSession(session);
|
||||
return session;
|
||||
}
|
||||
|
||||
function normalizeUsageSummary(payload: unknown): WebUsageSummary {
|
||||
const unwrapped = unwrapApiPayload(payload);
|
||||
const raw = isRecord(unwrapped) ? unwrapped : {};
|
||||
|
||||
return {
|
||||
balanceCents: toNumber(
|
||||
raw.balanceCents ?? raw.balance_cents ?? raw.currentBalanceCents ?? raw.current_balance_cents,
|
||||
),
|
||||
enterpriseBalanceCents:
|
||||
toOptionalNumber(
|
||||
raw.enterpriseBalanceCents ??
|
||||
raw.enterprise_balance_cents ??
|
||||
raw.enterpriseBalance ??
|
||||
raw.enterprise_balance,
|
||||
) ?? undefined,
|
||||
imageUsed: toNumber(raw.imageUsed ?? raw.image_used ?? raw.imageCount ?? raw.image_count),
|
||||
videoUsed: toNumber(raw.videoUsed ?? raw.video_used ?? raw.videoCount ?? raw.video_count),
|
||||
textUsed: toNumber(
|
||||
raw.textUsed ?? raw.text_used ?? raw.textTokens ?? raw.text_tokens ?? raw.total_prompt_tokens,
|
||||
),
|
||||
source: "server",
|
||||
};
|
||||
}
|
||||
|
||||
function toOptionalNumber(value: unknown): number | null {
|
||||
if (value === undefined || value === null || value === "") return null;
|
||||
const numberValue = typeof value === "number" ? value : Number(value);
|
||||
return Number.isFinite(numberValue) ? numberValue : null;
|
||||
}
|
||||
|
||||
function normalizeEnterpriseUsageSummary(payload: unknown): WebEnterpriseUsageSummary {
|
||||
const unwrapped = unwrapApiPayload(payload);
|
||||
const raw = isRecord(unwrapped) ? unwrapped : {};
|
||||
const memberRows = Array.isArray(raw.members) ? raw.members.filter(isRecord) : [];
|
||||
const modelRows = Array.isArray(raw.modelBreakdown)
|
||||
? raw.modelBreakdown.filter(isRecord)
|
||||
: Array.isArray(raw.model_breakdown)
|
||||
? raw.model_breakdown.filter(isRecord)
|
||||
: [];
|
||||
const recordRows = Array.isArray(raw.records)
|
||||
? raw.records.filter(isRecord)
|
||||
: Array.isArray(raw.items)
|
||||
? raw.items.filter(isRecord)
|
||||
: [];
|
||||
const trendRows = Array.isArray(raw.dailyTrend)
|
||||
? raw.dailyTrend.filter(isRecord)
|
||||
: Array.isArray(raw.daily_trend)
|
||||
? raw.daily_trend.filter(isRecord)
|
||||
: [];
|
||||
|
||||
return {
|
||||
enterpriseId: toStringValue(raw.enterpriseId ?? raw.enterprise_id),
|
||||
enterpriseName: toStringValue(raw.enterpriseName ?? raw.enterprise_name, "Enterprise"),
|
||||
balanceCents: toNumber(raw.balanceCents ?? raw.balance_cents ?? raw.enterpriseBalanceCents ?? raw.enterprise_balance_cents),
|
||||
totalUsedCents: toNumber(raw.totalUsedCents ?? raw.total_used_cents ?? raw.usedCents ?? raw.used_cents),
|
||||
members: memberRows.map((member, index) => ({
|
||||
userId: toIdValue(member.userId ?? member.user_id ?? member.id, `member-${index + 1}`),
|
||||
username: toStringValue(member.username ?? member.userName ?? member.user_name ?? member.name, "employee"),
|
||||
displayName: toNullableString(member.displayName ?? member.display_name ?? member.nickname ?? member.name),
|
||||
role: toStringValue(member.role ?? member.enterpriseRole ?? member.enterprise_role, "employee"),
|
||||
usedCents: toNumber(
|
||||
member.usedCents ??
|
||||
member.used_cents ??
|
||||
member.amountCents ??
|
||||
member.amount_cents ??
|
||||
member.totalUsedCents ??
|
||||
member.total_used_cents,
|
||||
),
|
||||
taskCount: toNumber(member.taskCount ?? member.task_count ?? member.calls ?? member.count),
|
||||
lastUsedAt: toNullableString(member.lastUsedAt ?? member.last_used_at ?? member.updatedAt ?? member.updated_at),
|
||||
})),
|
||||
modelBreakdown: modelRows.map((model) => ({
|
||||
model: toStringValue(model.model ?? model.modelId ?? model.model_id, "unknown"),
|
||||
usedCents: toNumber(model.usedCents ?? model.used_cents ?? model.amountCents ?? model.amount_cents),
|
||||
taskCount: toNumber(model.taskCount ?? model.task_count ?? model.calls ?? model.count),
|
||||
})),
|
||||
dailyTrend: trendRows.map((row) => ({
|
||||
date: toStringValue(row.date ?? row.day),
|
||||
usedCents: toNumber(row.usedCents ?? row.used_cents ?? row.amountCents ?? row.amount_cents),
|
||||
taskCount: toNumber(row.taskCount ?? row.task_count ?? row.count),
|
||||
})),
|
||||
records: recordRows.map((record, index) => ({
|
||||
id: toStringValue(record.id ?? record.ledgerId ?? record.ledger_id ?? record.taskId ?? record.task_id, `record-${index + 1}`),
|
||||
userId: toIdValue(record.userId ?? record.user_id ?? record.memberId ?? record.member_id, "unknown"),
|
||||
username: toStringValue(record.username ?? record.userName ?? record.user_name ?? record.name, "employee"),
|
||||
model: toStringValue(record.model ?? record.modelId ?? record.model_id, "unknown"),
|
||||
taskType: toStringValue(record.taskType ?? record.task_type ?? record.type, "image"),
|
||||
resolution: toNullableString(record.resolution ?? record.quality),
|
||||
durationSeconds: toOptionalNumber(record.durationSeconds ?? record.duration_seconds ?? record.duration),
|
||||
amountCents: toNumber(record.amountCents ?? record.amount_cents ?? record.usedCents ?? record.used_cents),
|
||||
prompt: toNullableString(record.prompt),
|
||||
status: toStringValue(record.status, "completed"),
|
||||
createdAt: toStringValue(record.createdAt ?? record.created_at ?? record.updatedAt ?? record.updated_at),
|
||||
})),
|
||||
source: "server",
|
||||
};
|
||||
}
|
||||
|
||||
function buildProjectUpsertPayload(workflow: WebCanvasWorkflow, session: WebUserSession): Record<string, unknown> {
|
||||
const userId = String(session.user.id).replace(/[^a-zA-Z0-9_-]/g, "");
|
||||
const projectId = workflow.id.trim();
|
||||
const ossKey = `users/${userId}/projects/${projectId}/current/project.json`;
|
||||
|
||||
return {
|
||||
id: projectId,
|
||||
name: workflow.title.trim() || "未命名项目",
|
||||
description: workflow.description.trim() || null,
|
||||
ossKey,
|
||||
thumbnailUrl: workflow.nodes.find((node) => node.previewUrl)?.previewUrl || null,
|
||||
storyboardCount: workflow.nodes.length,
|
||||
imageCount: workflow.nodes.filter((node) => node.kind === "image").length,
|
||||
videoCount: workflow.nodes.filter((node) => node.kind === "video").length,
|
||||
fileSize: JSON.stringify(workflow).length,
|
||||
fingerprint: createWorkflowFingerprint(workflow),
|
||||
deviceId: "web",
|
||||
baseRevision: null,
|
||||
forceOverwrite: true,
|
||||
saveReason: "create",
|
||||
};
|
||||
}
|
||||
|
||||
export const keyServerClient = {
|
||||
getBaseUrl,
|
||||
getStoredSession: readStoredSession,
|
||||
updateStoredSessionUser,
|
||||
clearSession() {
|
||||
writeStoredSession(null);
|
||||
},
|
||||
async login(input: LoginInput): Promise<WebUserSession> {
|
||||
const session = normalizeLoginResult(
|
||||
await request<unknown>("/auth/login", {
|
||||
method: "POST",
|
||||
body: {
|
||||
username: input.username.trim(),
|
||||
password: input.password,
|
||||
},
|
||||
}),
|
||||
);
|
||||
if (!session) {
|
||||
throw new Error("Login response did not include a token and user");
|
||||
}
|
||||
|
||||
writeStoredSession(session);
|
||||
return session;
|
||||
},
|
||||
async register(input: RegisterInput): Promise<WebUserSession> {
|
||||
const session = normalizeLoginResult(
|
||||
await request<unknown>("/auth/register", {
|
||||
method: "POST",
|
||||
body: {
|
||||
username: input.username.trim(),
|
||||
password: input.password,
|
||||
betaCode: input.betaCode.trim(),
|
||||
},
|
||||
}),
|
||||
);
|
||||
if (!session) {
|
||||
throw new Error("Register response did not include a token and user");
|
||||
}
|
||||
|
||||
writeStoredSession(session);
|
||||
return session;
|
||||
},
|
||||
async loginEmail(input: EmailAuthInput): Promise<WebUserSession> {
|
||||
const session = normalizeLoginResult(
|
||||
await request<unknown>("/auth/login-email", {
|
||||
method: "POST",
|
||||
body: {
|
||||
email: input.email.trim(),
|
||||
password: input.password,
|
||||
},
|
||||
}),
|
||||
);
|
||||
if (!session) {
|
||||
throw new Error("Email login response did not include a token and user");
|
||||
}
|
||||
|
||||
writeStoredSession(session);
|
||||
return session;
|
||||
},
|
||||
async registerEmail(input: EmailAuthInput): Promise<WebUserSession> {
|
||||
const session = normalizeLoginResult(
|
||||
await request<unknown>("/auth/register-email", {
|
||||
method: "POST",
|
||||
body: {
|
||||
email: input.email.trim(),
|
||||
username: input.username?.trim() || undefined,
|
||||
password: input.password,
|
||||
betaCode: input.betaCode?.trim() || undefined,
|
||||
},
|
||||
}),
|
||||
);
|
||||
if (!session) {
|
||||
throw new Error("Email register response did not include a token and user");
|
||||
}
|
||||
|
||||
writeStoredSession(session);
|
||||
return session;
|
||||
},
|
||||
async sendSmsCode(phone: string, purpose: "login" | "register", betaCode?: string): Promise<{ cooldownSeconds?: number; ttlSeconds?: number }> {
|
||||
return request<{ cooldownSeconds?: number; ttlSeconds?: number }>("/auth/sms/send", {
|
||||
method: "POST",
|
||||
body: { phone: phone.trim(), purpose, betaCode: betaCode?.trim() || undefined },
|
||||
});
|
||||
},
|
||||
async loginPhone(input: PhoneAuthInput): Promise<WebUserSession> {
|
||||
const session = normalizeLoginResult(
|
||||
await request<unknown>("/auth/login-phone", {
|
||||
method: "POST",
|
||||
body: {
|
||||
phone: input.phone.trim(),
|
||||
code: input.code.trim(),
|
||||
},
|
||||
}),
|
||||
);
|
||||
if (!session) {
|
||||
throw new Error("Phone login response did not include a token and user");
|
||||
}
|
||||
|
||||
writeStoredSession(session);
|
||||
return session;
|
||||
},
|
||||
async registerPhone(input: PhoneAuthInput): Promise<WebUserSession> {
|
||||
const session = normalizeLoginResult(
|
||||
await request<unknown>("/auth/register-phone", {
|
||||
method: "POST",
|
||||
body: {
|
||||
phone: input.phone.trim(),
|
||||
code: input.code.trim(),
|
||||
password: input.password || "",
|
||||
betaCode: input.betaCode?.trim() || undefined,
|
||||
},
|
||||
}),
|
||||
);
|
||||
if (!session) {
|
||||
throw new Error("Phone register response did not include a token and user");
|
||||
}
|
||||
|
||||
writeStoredSession(session);
|
||||
return session;
|
||||
},
|
||||
async getWechatLoginTicket(): Promise<WechatLoginTicket> {
|
||||
const browserCrypto = typeof globalThis !== "undefined" ? globalThis.crypto : undefined;
|
||||
const state =
|
||||
browserCrypto && "randomUUID" in browserCrypto
|
||||
? browserCrypto.randomUUID().replace(/-/g, "")
|
||||
: `${Date.now().toString(36)}${Math.random().toString(36).slice(2)}`;
|
||||
return request<WechatLoginTicket>(`/auth/wechat/login-url?state=${encodeURIComponent(state)}`);
|
||||
},
|
||||
async getWechatLoginSession(state: string): Promise<WechatLoginSessionStatus> {
|
||||
const response = await request<unknown>(`/auth/wechat/session?state=${encodeURIComponent(state)}`);
|
||||
const raw = isRecord(response) ? response : {};
|
||||
const session = normalizeLoginResult(raw);
|
||||
if (session) {
|
||||
writeStoredSession(session);
|
||||
return { status: "completed", session };
|
||||
}
|
||||
|
||||
const status = toStringValue(raw.status, "pending");
|
||||
return {
|
||||
status,
|
||||
error: toNullableString(raw.error) ?? undefined,
|
||||
};
|
||||
},
|
||||
async updateProfile(input: UpdateProfileInput): Promise<WebUserSession["user"]> {
|
||||
const user = normalizeUser(
|
||||
await request<unknown>("/auth/profile", {
|
||||
method: "PUT",
|
||||
body: {
|
||||
...input,
|
||||
profileBackgroundUrl: input.profileBackgroundUrl ?? input.backgroundUrl ?? undefined,
|
||||
},
|
||||
}),
|
||||
);
|
||||
if (!user) {
|
||||
throw new Error("Profile response did not include a user");
|
||||
}
|
||||
|
||||
updateStoredSessionUser(user);
|
||||
return user;
|
||||
},
|
||||
async getCurrentSession(): Promise<WebUserSession | null> {
|
||||
const stored = readStoredSession();
|
||||
if (!stored) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const user = normalizeUser(await request<unknown>("/auth/me", { token: stored.token }));
|
||||
if (!user) {
|
||||
throw new Error("Current-user response did not include a user");
|
||||
}
|
||||
|
||||
const session: WebUserSession = { ...stored, user, source: "server", errorMessage: undefined };
|
||||
writeStoredSession(session);
|
||||
return session;
|
||||
} catch (error) {
|
||||
if (isHttpError(error) && (error.status === 401 || error.status === 403)) {
|
||||
writeStoredSession(null);
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
...stored,
|
||||
source: "server",
|
||||
errorMessage: getErrorMessage(error),
|
||||
};
|
||||
}
|
||||
},
|
||||
async listProjects(): Promise<WebProjectSummary[]> {
|
||||
const summaries = extractProjectRows(await request<unknown>("/projects")).map((project) =>
|
||||
toProjectSummary(project, "server"),
|
||||
);
|
||||
return enrichProjectSummariesWithContent(summaries);
|
||||
},
|
||||
async getProjectContent(projectId: string): Promise<WebCanvasWorkflow> {
|
||||
const stored = readStoredSession();
|
||||
if (!stored) {
|
||||
throw new Error("闇€瑕佸厛鐧诲綍");
|
||||
}
|
||||
|
||||
const safeProjectId = encodeURIComponent(projectId.trim());
|
||||
if (!safeProjectId) {
|
||||
throw new Error("Project id is required");
|
||||
}
|
||||
|
||||
const response = await request<unknown>(`/projects/${safeProjectId}/content?resolveMedia=1`);
|
||||
return normalizeProjectContent(response, projectId);
|
||||
},
|
||||
async getUsageSummary(): Promise<WebUsageSummary> {
|
||||
return normalizeUsageSummary(await request<unknown>("/user/usage/summary"));
|
||||
},
|
||||
async getEnterpriseUsageSummary(): Promise<WebEnterpriseUsageSummary> {
|
||||
return normalizeEnterpriseUsageSummary(await request<unknown>("/enterprise/usage/summary"));
|
||||
},
|
||||
async getPersonalUsageSummary(): Promise<WebEnterpriseUsageSummary> {
|
||||
return normalizeEnterpriseUsageSummary(await request<unknown>("/user/usage/credits"));
|
||||
},
|
||||
async createProjectSpace(workflow: WebCanvasWorkflow): Promise<WebProjectSummary> {
|
||||
const stored = readStoredSession();
|
||||
if (!stored) {
|
||||
const error = new Error("需要先登录");
|
||||
throw error;
|
||||
}
|
||||
|
||||
const payload = buildProjectUpsertPayload(workflow, stored);
|
||||
const response = await request<unknown>("/projects/upsert", {
|
||||
method: "POST",
|
||||
body: payload,
|
||||
});
|
||||
const projectPayload = isRecord(response) ? response.project ?? response : response;
|
||||
if (!isRecord(projectPayload)) {
|
||||
throw new Error("Project response did not include a project");
|
||||
}
|
||||
|
||||
return toProjectSummary(projectPayload, "server");
|
||||
},
|
||||
async saveProjectContent(projectId: string, workflow: WebCanvasWorkflow): Promise<WebProjectSummary> {
|
||||
const stored = readStoredSession();
|
||||
if (!stored) {
|
||||
throw new Error("需要先登录");
|
||||
}
|
||||
|
||||
const response = await request<unknown>(`projects/${encodeURIComponent(projectId)}/content`, {
|
||||
method: "PUT",
|
||||
body: {
|
||||
content: {
|
||||
name: workflow.title,
|
||||
description: workflow.description,
|
||||
workflowData: workflow,
|
||||
nodes: workflow.nodes,
|
||||
edges: workflow.edges,
|
||||
},
|
||||
meta: {
|
||||
name: workflow.title,
|
||||
description: workflow.description,
|
||||
thumbnailUrl: workflow.nodes.find((node) => node.previewUrl)?.previewUrl || null,
|
||||
storyboardCount: workflow.nodes.length,
|
||||
imageCount: workflow.nodes.filter((node) => node.kind === "image").length,
|
||||
videoCount: workflow.nodes.filter((node) => node.kind === "video").length,
|
||||
},
|
||||
saveReason: "web-create",
|
||||
deviceId: "web",
|
||||
forceOverwrite: true,
|
||||
},
|
||||
});
|
||||
const rawProject = isRecord(response) && isRecord(response.project) ? response.project : response;
|
||||
if (!isRecord(rawProject)) {
|
||||
return buildProjectSummaryFromWorkflow(workflow, "server");
|
||||
}
|
||||
return toProjectSummary(rawProject, "server");
|
||||
},
|
||||
async deleteProject(projectId: string, options?: DeleteProjectOptions): Promise<void> {
|
||||
const stored = readStoredSession();
|
||||
if (!stored) {
|
||||
throw new Error("闇€瑕佸厛鐧诲綍");
|
||||
}
|
||||
|
||||
const path = options?.cleanupUserData ? `projects/${encodeURIComponent(projectId)}?cleanupUserData=1` : `projects/${encodeURIComponent(projectId)}`;
|
||||
await request(path, {
|
||||
method: "DELETE",
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,96 @@
|
||||
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;
|
||||
|
||||
return {
|
||||
value,
|
||||
label: toStringValue(raw.label ?? raw.displayName ?? raw.display_name ?? raw.name, value),
|
||||
description: toStringValue(raw.description) || undefined,
|
||||
badge: toStringValue(raw.badge) || undefined,
|
||||
enabled,
|
||||
status: status || "available",
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeModelList(value: unknown): ModelCapabilityOption[] {
|
||||
return Array.isArray(value)
|
||||
? value.map(normalizeModelOption).filter((item): item is ModelCapabilityOption => Boolean(item))
|
||||
: [];
|
||||
}
|
||||
|
||||
function createFallbackCapabilities(): WebModelCapabilities {
|
||||
return {
|
||||
source: "fallback",
|
||||
imageModels: [],
|
||||
videoModels: [],
|
||||
chatModels: [],
|
||||
};
|
||||
}
|
||||
|
||||
let modelCapabilitiesRouteMissing = false;
|
||||
|
||||
export const modelCapabilitiesClient = {
|
||||
async get(name = "web-model-capabilities"): Promise<WebModelCapabilities> {
|
||||
if (import.meta.env.DEV && name === "web-model-capabilities") return createFallbackCapabilities();
|
||||
if (modelCapabilitiesRouteMissing) return createFallbackCapabilities();
|
||||
|
||||
let payload: unknown;
|
||||
try {
|
||||
payload = await serverRequest<unknown>(`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),
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,141 @@
|
||||
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[] {
|
||||
if (Array.isArray(payload)) return payload.map(normalizeTask).filter(Boolean) as ServerProjectTask[];
|
||||
if (!isRecord(payload)) return [];
|
||||
const rows = payload.tasks ?? payload.items;
|
||||
return Array.isArray(rows) ? (rows.map(normalizeTask).filter(Boolean) as ServerProjectTask[]) : [];
|
||||
}
|
||||
|
||||
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 = Array.from(new Set(projectIds.map((id) => id.trim()).filter(Boolean)));
|
||||
const results = await Promise.all(uniqueIds.map((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);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,44 @@
|
||||
import { buildApiUrl, buildAuthHeaders } 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> {
|
||||
const res = await fetch(buildApiUrl("admin/providers/status"), {
|
||||
method: "GET",
|
||||
headers: buildAuthHeaders(),
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`Provider health request failed (${res.status})`);
|
||||
}
|
||||
return res.json() as Promise<ProviderHealthResponse>;
|
||||
},
|
||||
};
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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) : [];
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,108 @@
|
||||
import { buildApiUrl, buildAuthHeaders } from "./serverConnection";
|
||||
|
||||
export interface ScriptEvalResult {
|
||||
totalScore: number;
|
||||
grade: string;
|
||||
dimensionScores: Record<string, number>;
|
||||
summary: string;
|
||||
issues: string[];
|
||||
highlights: string[];
|
||||
suggestions: string[];
|
||||
}
|
||||
|
||||
const EVAL_SYSTEM_PROMPT = `你是一位专业的剧本评测专家。请对用户提供的剧本进行六维评分分析,并以严格的 JSON 格式返回结果。
|
||||
|
||||
六个评分维度:
|
||||
1. hook(钩子设计,满分20):开篇吸引力、悬念设置、黄金三秒法则
|
||||
2. character(角色塑造,满分15):人物立体度、动机合理性、弧光设计
|
||||
3. plot(剧情结构,满分20):起承转合、节奏把控、冲突设计
|
||||
4. dialogue(台词对白,满分15):语言质感、角色差异化、潜台词
|
||||
5. visual(画面表现,满分15):镜头感、空间层次、视觉冲击力
|
||||
6. content(内容深度,满分15):主题表达、情感共鸣、思想内核
|
||||
|
||||
请严格按以下 JSON 格式返回(不要包含任何其他文字):
|
||||
{
|
||||
"dimensionScores": { "hook": 数字, "character": 数字, "plot": 数字, "dialogue": 数字, "visual": 数字, "content": 数字 },
|
||||
"summary": "一句话总结评价",
|
||||
"issues": ["问题1", "问题2", ...],
|
||||
"highlights": ["亮点1", "亮点2", ...],
|
||||
"suggestions": ["建议1", "建议2", ...]
|
||||
}`;
|
||||
|
||||
const DIMENSION_WEIGHTS: Record<string, { maxScore: number; weight: number }> = {
|
||||
hook: { maxScore: 20, weight: 0.2 },
|
||||
character: { maxScore: 15, weight: 0.15 },
|
||||
plot: { maxScore: 20, weight: 0.2 },
|
||||
dialogue: { maxScore: 15, weight: 0.15 },
|
||||
visual: { maxScore: 15, weight: 0.15 },
|
||||
content: { maxScore: 15, weight: 0.15 },
|
||||
};
|
||||
|
||||
function computeTotalAndGrade(scores: Record<string, number>): { totalScore: number; grade: string } {
|
||||
const totalScore = Math.round(
|
||||
Object.entries(DIMENSION_WEIGHTS).reduce((sum, [key, dim]) => {
|
||||
const score = Math.max(0, Math.min(dim.maxScore, scores[key] ?? 0));
|
||||
return sum + (score / dim.maxScore) * 100 * dim.weight;
|
||||
}, 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);
|
||||
}
|
||||
|
||||
export async function evaluateScript(script: string, signal?: AbortSignal): Promise<ScriptEvalResult> {
|
||||
const res = await fetch(buildApiUrl("ai/chat"), {
|
||||
method: "POST",
|
||||
headers: buildAuthHeaders(),
|
||||
body: JSON.stringify({
|
||||
model: "qwen3.7-max",
|
||||
messages: [
|
||||
{ role: "system", content: EVAL_SYSTEM_PROMPT },
|
||||
{ role: "user", content: `请评测以下剧本:\n\n${script.slice(0, 8000)}` },
|
||||
],
|
||||
stream: false,
|
||||
temperature: 0.3,
|
||||
}),
|
||||
signal,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`评测请求失败 (${res.status})`);
|
||||
}
|
||||
|
||||
const payload = await res.json();
|
||||
const content: string = payload?.choices?.[0]?.message?.content
|
||||
?? payload?.result?.content
|
||||
?? payload?.content
|
||||
?? payload?.text
|
||||
?? (typeof payload === "string" ? payload : "");
|
||||
|
||||
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 val = Number(rawScores[key] ?? 0);
|
||||
dimensionScores[key] = Math.max(0, Math.min(DIMENSION_WEIGHTS[key].maxScore, val));
|
||||
}
|
||||
|
||||
const { totalScore, grade } = computeTotalAndGrade(dimensionScores);
|
||||
|
||||
return {
|
||||
totalScore,
|
||||
grade,
|
||||
dimensionScores,
|
||||
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) : [],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,380 @@
|
||||
import type { WebUserSession } from "../types";
|
||||
|
||||
export const DEFAULT_SERVER_BASE_URL = import.meta.env.VITE_API_BASE_URL || "";
|
||||
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;
|
||||
}
|
||||
|
||||
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 {
|
||||
const envBaseUrl = String(
|
||||
import.meta.env.VITE_KEY_SERVER_URL ||
|
||||
import.meta.env.VITE_SERVER_BASE_URL ||
|
||||
import.meta.env.VITE_API_BASE_URL ||
|
||||
"",
|
||||
).trim();
|
||||
const shouldUseSameOriginApi =
|
||||
typeof window !== "undefined" &&
|
||||
(window.location.protocol === "https:" ||
|
||||
window.location.hostname === "omniai.net.cn" ||
|
||||
window.location.hostname === "www.omniai.net.cn");
|
||||
const rawBaseUrl = envBaseUrl || (shouldUseSameOriginApi ? "" : DEFAULT_SERVER_BASE_URL);
|
||||
if (!rawBaseUrl || rawBaseUrl.replace(/\/+$/, "").toLowerCase() === "/api") {
|
||||
return "";
|
||||
}
|
||||
return rawBaseUrl.replace(/\/+$/, "").replace(/\/api$/i, "");
|
||||
}
|
||||
|
||||
export function buildApiUrl(path: string): string {
|
||||
const cleanPath = path.replace(/^\/+/, "");
|
||||
const baseUrl = getServerBaseUrl();
|
||||
if (!baseUrl) return `/api/${cleanPath}`;
|
||||
|
||||
try {
|
||||
return new URL(`api/${cleanPath}`, baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`).toString();
|
||||
} catch {
|
||||
return `${baseUrl}/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 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 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;
|
||||
|
||||
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;
|
||||
|
||||
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;
|
||||
|
||||
for (let attempt = 0; attempt <= MAX_RETRIES; 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,
|
||||
});
|
||||
|
||||
const payload = await readJsonResponse<unknown>(response, "Request failed");
|
||||
return (options?.raw ? payload : unwrapApiPayload(payload)) as T;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
if (attempt < MAX_RETRIES && 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { aiGenerationClient } from "./aiGenerationClient";
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
const POLL_INTERVAL = 3000;
|
||||
const DEFAULT_TIMEOUT = 30 * 60 * 1000;
|
||||
|
||||
export function waitForTask(
|
||||
taskId: string,
|
||||
options: WaitForTaskOptions = {},
|
||||
): Promise<string | null> {
|
||||
const { onProgress, abortRef, timeoutMs = DEFAULT_TIMEOUT } = options;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
let settled = false;
|
||||
let cleanup: (() => void) | null = null;
|
||||
let timeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||
let sseConnected = false;
|
||||
let fallbackTimerId: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
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("等待任务结果超时,请稍后在任务历史中查看"))),
|
||||
timeoutMs,
|
||||
);
|
||||
|
||||
const handleUpdate = (event: TaskProgressEvent) => {
|
||||
if (settled) return;
|
||||
if (abortRef?.current) {
|
||||
settle(() => resolve(null));
|
||||
return;
|
||||
}
|
||||
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 || "任务失败")));
|
||||
}
|
||||
};
|
||||
|
||||
// Try SSE first
|
||||
cleanup = aiGenerationClient.subscribeTaskStatus(taskId, handleUpdate);
|
||||
sseConnected = true;
|
||||
|
||||
// Fallback: if SSE doesn't deliver any event within 5s, switch to polling
|
||||
fallbackTimerId = setTimeout(() => {
|
||||
if (settled || !sseConnected) 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;
|
||||
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));
|
||||
}
|
||||
}
|
||||
};
|
||||
poll();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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));
|
||||
});
|
||||
}
|
||||
@@ -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 : "请求失败",
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user