feat: update admin navigation and configuration for local deployment, enhance user experience with direct API connections

This commit is contained in:
HouYunFei
2026-06-16 12:07:35 +08:00
parent 0d90465339
commit 8a66524ea7
83 changed files with 649 additions and 7173 deletions
+21 -43
View File
@@ -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);
});
}