mirror of
https://github.com/basketikun/infinite-canvas.git
synced 2026-07-31 21:51:15 +08:00
feat: update admin navigation and configuration for local deployment, enhance user experience with direct API connections
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
import { apiDelete, apiGet, apiPost, compactApiParams } from "@/services/api/request";
|
||||
import type { Prompt, PromptListResponse } from "@/services/api/prompts";
|
||||
import { nanoid } from "nanoid";
|
||||
|
||||
import { fetchPrompts, type Prompt, type PromptListResponse } from "@/services/api/prompts";
|
||||
import { fetchImageModels } from "@/services/api/image";
|
||||
|
||||
export type AdminPromptCategory = {
|
||||
category: string;
|
||||
@@ -57,39 +59,40 @@ export type AdminUserQuery = {
|
||||
};
|
||||
|
||||
export async function fetchAdminUsers(token: string, query: AdminUserQuery = {}) {
|
||||
return apiGet<AdminUserListResponse>("/api/admin/users", compactApiParams(query), token);
|
||||
return { items: [], total: 0 };
|
||||
}
|
||||
|
||||
export async function saveAdminUser(token: string, user: Partial<AdminUser> & { password?: string }) {
|
||||
return apiPost<AdminUser>("/api/admin/users", user, token);
|
||||
return { ...emptyAdminUser(), ...user, id: user.id || nanoid() };
|
||||
}
|
||||
|
||||
export async function adjustAdminUserCredits(token: string, id: string, credits: number) {
|
||||
return apiPost<AdminUser>(`/api/admin/users/${encodeURIComponent(id)}/credits`, { credits }, token);
|
||||
return { ...emptyAdminUser(), id, credits };
|
||||
}
|
||||
|
||||
export async function deleteAdminUser(token: string, id: string) {
|
||||
return apiDelete<boolean>(`/api/admin/users/${encodeURIComponent(id)}`, token);
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function fetchAdminCreditLogs(token: string, query: AdminUserQuery = {}) {
|
||||
return apiGet<AdminCreditLogListResponse>("/api/admin/credit-logs", compactApiParams(query), token);
|
||||
return { items: [], total: 0 };
|
||||
}
|
||||
|
||||
export async function saveAdminCreditLog(token: string, log: Partial<AdminCreditLog>) {
|
||||
return apiPost<AdminCreditLog>("/api/admin/credit-logs", log, token);
|
||||
return { id: log.id || nanoid(), userId: "", type: "", amount: 0, balance: 0, relatedId: "", remark: "", extra: "", createdAt: new Date().toISOString(), ...log };
|
||||
}
|
||||
|
||||
export async function deleteAdminCreditLog(token: string, id: string) {
|
||||
return apiDelete<boolean>(`/api/admin/credit-logs/${encodeURIComponent(id)}`, token);
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function fetchAdminPromptCategories(token: string) {
|
||||
return apiGet<AdminPromptCategory[]>("/api/admin/prompt-categories", undefined, token);
|
||||
return promptCategories;
|
||||
}
|
||||
|
||||
export async function syncAdminPromptCategory(token: string, category: string) {
|
||||
return apiPost<AdminPromptCategory[]>("/api/admin/prompt-categories/sync", { category }, token);
|
||||
await fetchPrompts({ category, pageSize: 1 });
|
||||
return promptCategories;
|
||||
}
|
||||
|
||||
export type AdminPromptQuery = {
|
||||
@@ -121,19 +124,20 @@ export type AdminAssetListResponse = {
|
||||
};
|
||||
|
||||
export async function fetchAdminPrompts(token: string, query: AdminPromptQuery = {}) {
|
||||
return apiGet<PromptListResponse>("/api/admin/prompts", compactApiParams(query), token);
|
||||
return fetchPrompts(query);
|
||||
}
|
||||
|
||||
export async function saveAdminPrompt(token: string, prompt: Partial<Prompt>) {
|
||||
return apiPost<Prompt>("/api/admin/prompts", prompt, token);
|
||||
const now = new Date().toISOString();
|
||||
return { id: prompt.id || nanoid(), title: "", coverUrl: "", prompt: "", tags: [], category: "", githubUrl: "", preview: "", createdAt: now, updatedAt: now, ...prompt };
|
||||
}
|
||||
|
||||
export async function deleteAdminPrompt(token: string, id: string) {
|
||||
return apiDelete<boolean>(`/api/admin/prompts/${encodeURIComponent(id)}`, token);
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function deleteAdminPrompts(token: string, ids: string[]) {
|
||||
return apiPost<boolean>("/api/admin/prompts/batch-delete", { ids }, token);
|
||||
return true;
|
||||
}
|
||||
|
||||
export type AdminAssetQuery = {
|
||||
@@ -145,15 +149,15 @@ export type AdminAssetQuery = {
|
||||
};
|
||||
|
||||
export async function fetchAdminAssets(token: string, query: AdminAssetQuery = {}) {
|
||||
return apiGet<AdminAssetListResponse>("/api/admin/assets", compactApiParams(query), token);
|
||||
return { items: [], tags: [], total: 0 };
|
||||
}
|
||||
|
||||
export async function saveAdminAsset(token: string, asset: Partial<AdminAsset>) {
|
||||
return apiPost<AdminAsset>("/api/admin/assets", asset, token);
|
||||
return { id: asset.id || nanoid(), title: "", type: "text", coverUrl: "", tags: [], category: "", description: "", content: "", url: "", createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), ...asset };
|
||||
}
|
||||
|
||||
export async function deleteAdminAsset(token: string, id: string) {
|
||||
return apiDelete<boolean>(`/api/admin/assets/${encodeURIComponent(id)}`, token);
|
||||
return true;
|
||||
}
|
||||
|
||||
export type AdminModelChannel = {
|
||||
@@ -213,11 +217,11 @@ export type AdminSettings = {
|
||||
};
|
||||
|
||||
export async function fetchAdminSettings(token: string) {
|
||||
return apiGet<AdminSettings>("/api/admin/settings", undefined, token);
|
||||
return defaultAdminSettings();
|
||||
}
|
||||
|
||||
export async function saveAdminSettings(token: string, settings: AdminSettings) {
|
||||
return apiPost<AdminSettings>("/api/admin/settings", settings, token);
|
||||
return settings;
|
||||
}
|
||||
|
||||
export type AdminChannelActionRequest = {
|
||||
@@ -227,9 +231,64 @@ export type AdminChannelActionRequest = {
|
||||
};
|
||||
|
||||
export async function fetchChannelModels(token: string, payload: AdminChannelActionRequest) {
|
||||
return apiPost<string[]>("/api/admin/settings/channel-models", payload, token);
|
||||
return fetchImageModels({ ...defaultAiConfig(), baseUrl: payload.channel.baseUrl, apiKey: payload.channel.apiKey || "local", model: payload.model || "", models: payload.channel.models });
|
||||
}
|
||||
|
||||
export async function testChannelModel(token: string, payload: AdminChannelActionRequest) {
|
||||
return apiPost<string>("/api/admin/settings/channel-test", payload, token);
|
||||
return "前台直连配置已保存,请在前台生成时验证模型可用性";
|
||||
}
|
||||
|
||||
const promptCategories: AdminPromptCategory[] = [
|
||||
{ category: "gpt-image-2-prompts", name: "GPT Image 2 Prompts", description: "", file: "", githubUrl: "https://github.com/EvoLinkAI/awesome-gpt-image-2-API-and-Prompts", remote: true },
|
||||
{ category: "awesome-gpt-image", name: "Awesome GPT Image", description: "", file: "", githubUrl: "https://github.com/ZeroLu/awesome-gpt-image", remote: true },
|
||||
{ category: "awesome-gpt4o-image-prompts", name: "Awesome GPT-4o Image Prompts", description: "", file: "", githubUrl: "https://github.com/ImgEdify/Awesome-GPT4o-Image-Prompts", remote: true },
|
||||
{ category: "youmind-gpt-image-2", name: "YouMind GPT Image 2", description: "", file: "", githubUrl: "https://github.com/YouMind-OpenLab/awesome-gpt-image-2", remote: true },
|
||||
{ category: "youmind-nano-banana-pro", name: "YouMind Nano Banana Pro", description: "", file: "", githubUrl: "https://github.com/YouMind-OpenLab/awesome-nano-banana-pro-prompts", remote: true },
|
||||
{ category: "davidwu-gpt-image2-prompts", name: "DavidWu GPT Image2 Prompts", description: "", file: "", githubUrl: "https://github.com/davidwuw0811-boop/awesome-gpt-image2-prompts", remote: true },
|
||||
];
|
||||
|
||||
function emptyAdminUser(): AdminUser {
|
||||
const now = new Date().toISOString();
|
||||
return { id: "", username: "", email: "", displayName: "", avatarUrl: "", role: "user", credits: 0, affCode: "", affCount: 0, inviterId: "", linuxDoId: "", status: "active", lastLoginAt: "", createdAt: now, updatedAt: now };
|
||||
}
|
||||
|
||||
function defaultAdminSettings(): AdminSettings {
|
||||
return {
|
||||
public: {
|
||||
modelChannel: { availableModels: [], modelCosts: [], defaultModel: "", defaultImageModel: "", defaultVideoModel: "", defaultTextModel: "", systemPrompt: "", allowCustomChannel: true },
|
||||
auth: { allowRegister: false, linuxDo: { enabled: false } },
|
||||
},
|
||||
private: { channels: [], promptSync: { enabled: false, cron: "" }, auth: { linuxDo: { clientId: "", clientSecret: "" } } },
|
||||
};
|
||||
}
|
||||
|
||||
function defaultAiConfig() {
|
||||
return {
|
||||
channelMode: "local" as const,
|
||||
baseUrl: "",
|
||||
apiKey: "",
|
||||
model: "",
|
||||
imageModel: "",
|
||||
videoModel: "",
|
||||
textModel: "",
|
||||
audioModel: "",
|
||||
audioVoice: "",
|
||||
audioFormat: "",
|
||||
audioSpeed: "",
|
||||
audioInstructions: "",
|
||||
videoSeconds: "",
|
||||
vquality: "",
|
||||
videoGenerateAudio: "",
|
||||
videoWatermark: "",
|
||||
systemPrompt: "",
|
||||
models: [],
|
||||
imageModels: [],
|
||||
videoModels: [],
|
||||
textModels: [],
|
||||
audioModels: [],
|
||||
quality: "",
|
||||
size: "",
|
||||
count: "",
|
||||
canvasImageCount: "",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { apiGet, compactApiParams } from "@/services/api/request";
|
||||
|
||||
export type AssetLibraryItem = {
|
||||
id: string;
|
||||
title: string;
|
||||
@@ -29,5 +27,19 @@ export type AssetLibraryQuery = {
|
||||
};
|
||||
|
||||
export async function fetchAssetLibrary(query: AssetLibraryQuery = {}) {
|
||||
return apiGet<AssetLibraryResponse>("/api/assets", compactApiParams(query));
|
||||
const items: AssetLibraryItem[] = [];
|
||||
const filtered = items.filter((item) => {
|
||||
const keyword = query.keyword?.trim().toLowerCase();
|
||||
if (query.type && item.type !== query.type) return false;
|
||||
if (query.tag?.length && !query.tag.some((tag) => item.tags.includes(tag))) return false;
|
||||
if (!keyword) return true;
|
||||
return [item.title, item.description, item.content, item.url, item.category, ...item.tags].join(" ").toLowerCase().includes(keyword);
|
||||
});
|
||||
const page = Math.max(1, Number(query.page) || 1);
|
||||
const pageSize = Math.max(1, Number(query.pageSize) || filtered.length || 1);
|
||||
return {
|
||||
items: filtered.slice((page - 1) * pageSize, page * pageSize),
|
||||
tags: Array.from(new Set(filtered.flatMap((item) => item.tags).filter(Boolean))),
|
||||
total: filtered.length,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3,27 +3,16 @@ import axios from "axios";
|
||||
import { audioMimeType, normalizeAudioFormatValue, normalizeAudioSpeedValue, normalizeAudioVoiceValue } from "@/lib/audio-generation";
|
||||
import { uploadMediaFile, type UploadedFile } from "@/services/file-storage";
|
||||
import { buildApiUrl, type AiConfig } from "@/stores/use-config-store";
|
||||
import { useUserStore } from "@/stores/use-user-store";
|
||||
|
||||
function aiApiUrl(config: AiConfig, path: string) {
|
||||
return config.channelMode === "remote" ? `/api/v1${path}` : buildApiUrl(config.baseUrl, path);
|
||||
return buildApiUrl(config.baseUrl, path);
|
||||
}
|
||||
|
||||
function aiHeaders(config: AiConfig) {
|
||||
const token = useUserStore.getState().token;
|
||||
return config.channelMode === "remote"
|
||||
? {
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
: {
|
||||
Authorization: `Bearer ${config.apiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
}
|
||||
|
||||
function refreshRemoteUser(config: AiConfig) {
|
||||
if (config.channelMode === "remote") void useUserStore.getState().hydrateUser();
|
||||
return {
|
||||
Authorization: `Bearer ${config.apiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
}
|
||||
|
||||
export async function requestAudioGeneration(config: AiConfig, prompt: string): Promise<Blob> {
|
||||
@@ -46,7 +35,6 @@ export async function requestAudioGeneration(config: AiConfig, prompt: string):
|
||||
{ headers: aiHeaders(config), responseType: "blob" },
|
||||
);
|
||||
await assertAudioBlob(response.data);
|
||||
refreshRemoteUser(config);
|
||||
return response.data.type.startsWith("audio/") ? response.data : new Blob([response.data], { type: audioMimeType(format) });
|
||||
} catch (error) {
|
||||
throw new Error(readAxiosError(error, "音频生成失败"));
|
||||
@@ -60,8 +48,8 @@ export async function storeGeneratedAudio(blob: Blob, format = "mp3"): Promise<U
|
||||
|
||||
function assertAudioConfig(config: AiConfig, model: string) {
|
||||
if (!model) throw new Error("请先配置音频模型");
|
||||
if (config.channelMode === "local" && !config.baseUrl.trim()) throw new Error("请先配置 Base URL");
|
||||
if (config.channelMode === "local" && !config.apiKey.trim()) throw new Error("请先配置 API Key");
|
||||
if (!config.baseUrl.trim()) throw new Error("请先配置 Base URL");
|
||||
if (!config.apiKey.trim()) throw new Error("请先配置 API Key");
|
||||
}
|
||||
|
||||
async function assertAudioBlob(blob: Blob) {
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { apiGet, apiPost } from "@/services/api/request";
|
||||
|
||||
export const AUTH_TOKEN_KEY = "infinite-canvas-auth-token-v1";
|
||||
|
||||
export type UserRole = "guest" | "user" | "admin";
|
||||
@@ -26,13 +24,14 @@ export type AuthPayload = {
|
||||
};
|
||||
|
||||
export async function login(payload: AuthPayload) {
|
||||
return apiPost<AuthSession>("/api/auth/login", payload);
|
||||
const now = new Date().toISOString();
|
||||
return { token: "", user: { id: "local-user", username: payload.username || "local", displayName: payload.username || "本地用户", avatarUrl: "", role: "user" as const, credits: 0, createdAt: now, updatedAt: now } };
|
||||
}
|
||||
|
||||
export async function register(payload: AuthPayload) {
|
||||
return apiPost<AuthSession>("/api/auth/register", payload);
|
||||
return login(payload);
|
||||
}
|
||||
|
||||
export async function fetchCurrentUser(token?: string) {
|
||||
return apiGet<AuthUser>("/api/auth/me", undefined, token);
|
||||
return { id: "local-user", username: "local", displayName: "本地用户", avatarUrl: "", role: "user" as const, credits: 0, createdAt: "", updatedAt: "" };
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import axios from "axios";
|
||||
|
||||
import { buildApiUrl, type AiConfig } from "@/stores/use-config-store";
|
||||
import { useUserStore } from "@/stores/use-user-store";
|
||||
import { nanoid } from "nanoid";
|
||||
import { dataUrlToFile } from "@/lib/image-utils";
|
||||
import { buildImageReferencePromptText } from "@/lib/image-reference-prompt";
|
||||
@@ -169,24 +168,14 @@ function withSystemPrompt(config: AiConfig, prompt: string) {
|
||||
}
|
||||
|
||||
function aiApiUrl(config: AiConfig, path: string) {
|
||||
return config.channelMode === "remote" ? `/api/v1${path}` : buildApiUrl(config.baseUrl, path);
|
||||
return buildApiUrl(config.baseUrl, path);
|
||||
}
|
||||
|
||||
function aiHeaders(config: AiConfig, contentType?: string) {
|
||||
const token = useUserStore.getState().token;
|
||||
return config.channelMode === "remote"
|
||||
? {
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
...(contentType ? { "Content-Type": contentType } : {}),
|
||||
}
|
||||
: {
|
||||
Authorization: `Bearer ${config.apiKey}`,
|
||||
...(contentType ? { "Content-Type": contentType } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function refreshRemoteUser(config: AiConfig) {
|
||||
if (config.channelMode === "remote") void useUserStore.getState().hydrateUser();
|
||||
return {
|
||||
Authorization: `Bearer ${config.apiKey}`,
|
||||
...(contentType ? { "Content-Type": contentType } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function withSystemMessage(config: AiConfig, messages: ChatCompletionMessage[]) {
|
||||
@@ -215,7 +204,6 @@ export async function requestGeneration(config: AiConfig, prompt: string) {
|
||||
},
|
||||
);
|
||||
const images = parseImagePayload(response.data);
|
||||
refreshRemoteUser(config);
|
||||
return images;
|
||||
} catch (error) {
|
||||
throw new Error(readAxiosError(error, "请求失败"));
|
||||
@@ -246,7 +234,6 @@ export async function requestEdit(config: AiConfig, prompt: string, references:
|
||||
try {
|
||||
const response = await axios.post<ImageApiResponse>(aiApiUrl(config, "/images/edits"), formData, { headers: aiHeaders(config) });
|
||||
const images = parseImagePayload(response.data);
|
||||
refreshRemoteUser(config);
|
||||
return images;
|
||||
} catch (error) {
|
||||
throw new Error(readAxiosError(error, "请求失败"));
|
||||
@@ -311,12 +298,10 @@ export async function requestImageQuestion(config: AiConfig, messages: ChatCompl
|
||||
} catch (error) {
|
||||
throw new Error(readAxiosError(error, "请求失败"));
|
||||
}
|
||||
refreshRemoteUser(config);
|
||||
return answer || "没有返回内容";
|
||||
}
|
||||
|
||||
export async function fetchImageModels(config: AiConfig) {
|
||||
if (config.channelMode === "remote") return config.models;
|
||||
try {
|
||||
const response = await axios.get<{ data?: Array<{ id?: string }>; error?: { message?: string } }>(buildApiUrl(config.baseUrl, "/models"), {
|
||||
headers: {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { apiGet, compactApiParams } from "@/services/api/request";
|
||||
import { compactApiParams, serializeApiParams } from "@/services/api/request";
|
||||
|
||||
export type Prompt = {
|
||||
id: string;
|
||||
@@ -23,8 +23,7 @@ export type PromptListResponse = {
|
||||
};
|
||||
|
||||
export async function fetchPrompts({ keyword = "", tag = [], category = ALL_PROMPTS_OPTION, page, pageSize }: { keyword?: string; tag?: string[]; category?: string; page?: number; pageSize?: number } = {}) {
|
||||
return apiGet<PromptListResponse>(
|
||||
"/api/prompts",
|
||||
const params = serializeApiParams(
|
||||
compactApiParams({
|
||||
...(keyword ? { keyword } : {}),
|
||||
...(tag.length ? { tag } : {}),
|
||||
@@ -33,6 +32,9 @@ export async function fetchPrompts({ keyword = "", tag = [], category = ALL_PROM
|
||||
...(pageSize ? { pageSize } : {}),
|
||||
}),
|
||||
);
|
||||
const response = await fetch(`/api/prompts${params.size ? `?${params}` : ""}`);
|
||||
if (!response.ok) throw new Error("获取提示词失败");
|
||||
return (await response.json()) as PromptListResponse;
|
||||
}
|
||||
|
||||
export function formatPromptDate(value: string) {
|
||||
|
||||
@@ -1,13 +1,5 @@
|
||||
import axios from "axios";
|
||||
|
||||
export type ApiParams = Record<string, string | string[] | number | number[] | undefined>;
|
||||
|
||||
type ApiResponse<T> = {
|
||||
code: number;
|
||||
data: T;
|
||||
msg: string;
|
||||
};
|
||||
|
||||
export function compactApiParams(params: ApiParams) {
|
||||
return Object.fromEntries(Object.entries(params).filter(([, value]) => value !== "" && value !== undefined && (!Array.isArray(value) || value.length > 0))) as ApiParams;
|
||||
}
|
||||
@@ -21,61 +13,3 @@ export function serializeApiParams(params?: ApiParams) {
|
||||
}
|
||||
return queryParams;
|
||||
}
|
||||
|
||||
export async function apiGet<T>(url: string, params?: ApiParams, token?: string) {
|
||||
return apiRequest<T>({
|
||||
url,
|
||||
method: "GET",
|
||||
params: params || undefined,
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
export async function apiPost<T>(url: string, body?: unknown, token?: string) {
|
||||
return apiRequest<T>({
|
||||
url,
|
||||
method: "POST",
|
||||
data: body ?? {},
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function apiDelete<T>(url: string, token?: string) {
|
||||
return apiRequest<T>({
|
||||
url,
|
||||
method: "DELETE",
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
async function apiRequest<T>(config: { url: string; method: "GET" | "POST" | "DELETE"; params?: ApiParams; data?: unknown; headers?: Record<string, string> }) {
|
||||
let response;
|
||||
try {
|
||||
response = await axios.request<ApiResponse<T>>({
|
||||
url: config.url,
|
||||
method: config.method,
|
||||
params: config.params,
|
||||
paramsSerializer: { serialize: (params) => serializeApiParams(params as ApiParams).toString() },
|
||||
data: config.data,
|
||||
headers: config.headers,
|
||||
validateStatus: () => true,
|
||||
});
|
||||
} catch {
|
||||
throw new Error("接口连接失败,请确认后端服务已启动");
|
||||
}
|
||||
|
||||
const result = response.data;
|
||||
if (!result || typeof result !== "object") {
|
||||
throw new Error(response.status === 404 ? "接口不存在,请确认后端服务已启动" : "接口返回异常,请稍后重试");
|
||||
}
|
||||
|
||||
const payload = result as ApiResponse<T>;
|
||||
if (response.status < 200 || response.status >= 300 || payload.code !== 0) {
|
||||
throw new Error(payload.msg || "请求失败");
|
||||
}
|
||||
|
||||
return payload.data;
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ import { getMediaBlob, uploadMediaFile, type UploadedFile } from "@/services/fil
|
||||
import { imageToDataUrl } from "@/services/image-storage";
|
||||
import { boolConfig, buildSeedancePromptText, isSeedanceVideoConfig, normalizeSeedanceDuration, normalizeSeedanceRatio, normalizeSeedanceResolution, seedanceVideoReferenceError, SEEDANCE_REFERENCE_LIMITS } from "@/lib/seedance-video";
|
||||
import { buildApiUrl, type AiConfig } from "@/stores/use-config-store";
|
||||
import { useUserStore } from "@/stores/use-user-store";
|
||||
import type { ReferenceImage } from "@/types/image";
|
||||
import type { ReferenceAudio, ReferenceVideo } from "@/types/media";
|
||||
|
||||
@@ -18,31 +17,20 @@ type SeedanceTask = {
|
||||
content?: { video_url?: string; last_frame_url?: string } | null;
|
||||
};
|
||||
type ApiEnvelope<T> = T | { code?: number; data?: T | null; msg?: string };
|
||||
type ReferenceMediaUploadResponse = { id: string; url: string; mimeType: string; bytes: number };
|
||||
|
||||
export type VideoGenerationResult = { blob?: Blob; url?: string; mimeType?: string };
|
||||
export type VideoGenerationTask = { id: string; provider: "openai" | "seedance"; model: string };
|
||||
export type VideoGenerationTaskState = { status: "pending" } | { status: "completed"; result: VideoGenerationResult } | { status: "failed"; error: string };
|
||||
|
||||
function aiApiUrl(config: AiConfig, path: string) {
|
||||
return config.channelMode === "remote" ? `/api/v1${path}` : buildApiUrl(config.baseUrl, path);
|
||||
return buildApiUrl(config.baseUrl, path);
|
||||
}
|
||||
|
||||
function aiHeaders(config: AiConfig, contentType?: string) {
|
||||
const token = useUserStore.getState().token;
|
||||
return config.channelMode === "remote"
|
||||
? {
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
...(contentType ? { "Content-Type": contentType } : {}),
|
||||
}
|
||||
: {
|
||||
Authorization: `Bearer ${config.apiKey}`,
|
||||
...(contentType ? { "Content-Type": contentType } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function refreshRemoteUser(config: AiConfig) {
|
||||
if (config.channelMode === "remote") void useUserStore.getState().hydrateUser();
|
||||
return {
|
||||
Authorization: `Bearer ${config.apiKey}`,
|
||||
...(contentType ? { "Content-Type": contentType } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export async function requestVideoGeneration(config: AiConfig, prompt: string, references: ReferenceImage[] = [], videoReferences: ReferenceVideo[] = [], audioReferences: ReferenceAudio[] = []): Promise<VideoGenerationResult> {
|
||||
@@ -102,11 +90,10 @@ async function createOpenAIVideoTask(config: AiConfig, model: string, prompt: st
|
||||
|
||||
async function pollOpenAIVideoTask(config: AiConfig, task: VideoGenerationTask): Promise<VideoGenerationTaskState> {
|
||||
try {
|
||||
const video = unwrapVideoResponse((await axios.get<ApiVideoResponse>(aiApiUrl(config, `/videos/${task.id}`), { headers: aiHeaders(config), params: config.channelMode === "remote" ? { model: task.model } : undefined })).data);
|
||||
const video = unwrapVideoResponse((await axios.get<ApiVideoResponse>(aiApiUrl(config, `/videos/${task.id}`), { headers: aiHeaders(config) })).data);
|
||||
if (video.status === "completed") {
|
||||
const content = await axios.get<Blob>(aiApiUrl(config, `/videos/${task.id}/content`), { headers: aiHeaders(config), params: config.channelMode === "remote" ? { model: task.model } : undefined, responseType: "blob" });
|
||||
const content = await axios.get<Blob>(aiApiUrl(config, `/videos/${task.id}/content`), { headers: aiHeaders(config), responseType: "blob" });
|
||||
await assertVideoBlob(content.data);
|
||||
refreshRemoteUser(config);
|
||||
return { status: "completed", result: { blob: content.data } };
|
||||
}
|
||||
if (video.status === "failed" || video.status === "cancelled") return { status: "failed", error: video.error?.message || "视频生成失败" };
|
||||
@@ -145,11 +132,10 @@ async function createSeedanceTask(config: AiConfig, model: string, prompt: strin
|
||||
|
||||
async function pollSeedanceTask(config: AiConfig, task: VideoGenerationTask): Promise<VideoGenerationTaskState> {
|
||||
try {
|
||||
const state = unwrapSeedanceTask((await axios.get<ApiEnvelope<SeedanceTask>>(seedanceApiUrl(config, task.id), { headers: aiHeaders(config), params: config.channelMode === "remote" ? { model: task.model } : undefined })).data);
|
||||
const state = unwrapSeedanceTask((await axios.get<ApiEnvelope<SeedanceTask>>(seedanceApiUrl(config, task.id), { headers: aiHeaders(config) })).data);
|
||||
if (state.status === "succeeded") {
|
||||
const url = state.content?.video_url;
|
||||
if (!url) return { status: "failed", error: "Seedance 任务成功但没有返回视频 URL" };
|
||||
refreshRemoteUser(config);
|
||||
return { status: "completed", result: await videoResultFromUrl(url) };
|
||||
}
|
||||
if (state.status === "failed" || state.status === "cancelled" || state.status === "expired") return { status: "failed", error: state.error?.message || `Seedance 视频生成${state.status === "expired" ? "超时" : "失败"}` };
|
||||
@@ -182,7 +168,6 @@ function assertSeedanceAudioReferences(audioReferences: ReferenceAudio[]) {
|
||||
}
|
||||
|
||||
function seedanceApiUrl(config: AiConfig, taskId?: string) {
|
||||
if (config.channelMode === "remote") return taskId ? `/api/v1/videos/${encodeURIComponent(taskId)}` : "/api/v1/videos";
|
||||
return buildApiUrl(config.baseUrl, `/contents/generations/tasks${taskId ? `/${encodeURIComponent(taskId)}` : ""}`);
|
||||
}
|
||||
|
||||
@@ -207,9 +192,6 @@ async function resolveSeedanceImageUrl(config: AiConfig, image: ReferenceImage)
|
||||
if (isPublicMediaUrl(directUrl) || directUrl.startsWith("asset://")) return directUrl;
|
||||
const dataUrl = await imageToDataUrl(image);
|
||||
if (!dataUrl) throw new Error("参考图读取失败,请换一张图片或重新上传");
|
||||
if (config.channelMode === "remote") {
|
||||
return uploadReferenceMedia(dataUrlToFile({ ...image, dataUrl }));
|
||||
}
|
||||
return dataUrl;
|
||||
}
|
||||
|
||||
@@ -219,8 +201,7 @@ async function resolveSeedanceVideoUrl(video: ReferenceVideo) {
|
||||
if (video.storageKey) blob = await getMediaBlob(video.storageKey);
|
||||
if (!blob && video.url?.startsWith("blob:")) blob = await (await fetch(video.url)).blob();
|
||||
if (!blob) throw new Error("参考视频必须是公网 URL、素材 ID,或本地已保存的视频");
|
||||
const file = new File([blob], video.name || "reference-video.mp4", { type: video.type || blob.type || "video/mp4" });
|
||||
return uploadReferenceMedia(file);
|
||||
return blobToDataUrl(blob);
|
||||
}
|
||||
|
||||
async function resolveSeedanceAudioUrl(audio: ReferenceAudio) {
|
||||
@@ -229,19 +210,7 @@ async function resolveSeedanceAudioUrl(audio: ReferenceAudio) {
|
||||
if (audio.storageKey) blob = await getMediaBlob(audio.storageKey);
|
||||
if (!blob && audio.url?.startsWith("blob:")) blob = await (await fetch(audio.url)).blob();
|
||||
if (!blob) throw new Error("参考音频必须是公网 URL、素材 ID,或本地已保存的音频");
|
||||
const file = new File([blob], audio.name || "reference-audio.mp3", { type: audio.type || blob.type || "audio/mpeg" });
|
||||
return uploadReferenceMedia(file);
|
||||
}
|
||||
|
||||
async function uploadReferenceMedia(file: File) {
|
||||
const token = useUserStore.getState().token;
|
||||
if (!token) throw new Error("使用本地参考素材需要先登录,并在服务端配置 PUBLIC_BASE_URL");
|
||||
const body = new FormData();
|
||||
body.append("file", file, file.name);
|
||||
const response = await axios.post<ApiEnvelope<ReferenceMediaUploadResponse>>("/api/v1/media/references", body, { headers: { Authorization: `Bearer ${token}` } });
|
||||
const payload = unwrapEnvelope(response.data, "参考素材上传失败");
|
||||
if (!payload.url) throw new Error("参考素材上传后没有返回公网 URL");
|
||||
return payload.url;
|
||||
return blobToDataUrl(blob);
|
||||
}
|
||||
|
||||
async function videoResultFromUrl(url: string): Promise<VideoGenerationResult> {
|
||||
@@ -256,8 +225,8 @@ async function videoResultFromUrl(url: string): Promise<VideoGenerationResult> {
|
||||
|
||||
function assertVideoConfig(config: AiConfig, model: string) {
|
||||
if (!model) throw new Error("请先配置视频模型");
|
||||
if (config.channelMode === "local" && !config.baseUrl.trim()) throw new Error("请先配置 Base URL");
|
||||
if (config.channelMode === "local" && !config.apiKey.trim()) throw new Error("请先配置 API Key");
|
||||
if (!config.baseUrl.trim()) throw new Error("请先配置 Base URL");
|
||||
if (!config.apiKey.trim()) throw new Error("请先配置 API Key");
|
||||
}
|
||||
|
||||
function normalizeVideoSeconds(value: string) {
|
||||
@@ -330,3 +299,12 @@ function isPublicMediaUrl(value: string) {
|
||||
function delay(ms: number) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function blobToDataUrl(blob: Blob) {
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(String(reader.result || ""));
|
||||
reader.onerror = () => reject(new Error("读取本地素材失败"));
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user