fix(*): 模型与数据同步 - 完善鉴权、供应商模型和本地持久化能力

This commit is contained in:
ddCat
2026-08-20 22:58:31 +08:00
parent 2416684126
commit 0071e9416c
30 changed files with 688 additions and 203 deletions

View File

@@ -8,6 +8,13 @@ import type { PublicLogicalModel } from "@/services/api/logical-models";
const api = apiClient;
let authSessionRequest: Promise<AuthSessionPayload> | null = null;
let authSessionCache: { payload: AuthSessionPayload; expiresAt: number } | null = null;
function invalidateAuthSessionCache() {
authSessionCache = null;
}
export type LocalUser = {
id: string;
username: string;
@@ -337,7 +344,18 @@ export function linuxDOLoginURL(next: string) {
}
export function getAuthSession() {
return request<AuthSessionPayload>(api.get("/auth/session"));
const now = Date.now();
if (authSessionCache && authSessionCache.expiresAt > now) return Promise.resolve(authSessionCache.payload);
if (authSessionRequest) return authSessionRequest;
authSessionRequest = request<AuthSessionPayload>(api.get("/auth/session"))
.then((payload) => {
authSessionCache = { payload, expiresAt: Date.now() + 5_000 };
return payload;
})
.finally(() => {
authSessionRequest = null;
});
return authSessionRequest;
}
export function getSystemChannels() {
@@ -356,8 +374,11 @@ export function updateAdminFeatureAvailability(features: Pick<FeatureAvailabilit
return request<{ features: FeatureAvailability }>(api.patch("/admin/settings/features", features));
}
export function login(input: { username: string; password: string }) {
return request<{ user: LocalUser }>(api.post("/auth/login", input));
export async function login(input: { username: string; password: string }) {
const result = await request<{ user: LocalUser }>(api.post("/auth/login", input));
// 登录会改变服务端会话身份,不能让登录前缓存的游客 session 污染后续恢复。
invalidateAuthSessionCache();
return result;
}
export function sendRegistrationEmailCode(email: string) {
@@ -368,8 +389,10 @@ export function register(input: { username: string; email?: string; emailCode?:
return request<{ user: LocalUser }>(api.post("/auth/register", input));
}
export function logout() {
return request<{ ok: boolean }>(api.post("/auth/logout"));
export async function logout() {
const result = await request<{ ok: boolean }>(api.post("/auth/logout"));
invalidateAuthSessionCache();
return result;
}
export type AdminListParams = { keyword?: string; status?: string; role?: string; page?: number; limit?: number };

View File

@@ -145,11 +145,11 @@ function resourceProxyFileUrl(id: string) {
return `${base}/resources/${encodeURIComponent(id)}/file?proxy=1`;
}
export async function resolveResourceUrl(storageKey?: string, fallback = "") {
export function resolveResourceUrl(storageKey?: string, fallback = "") {
const id = resourceIdFromStorageKey(storageKey);
if (!id) return fallback;
const resource = await getResource(id).catch(() => null);
return resource ? resource.publicUrl || resourceFileUrl(id) : fallback;
// 资源引用本身已经包含稳定 ID;恢复/展示阶段不需要再查一遍元数据。
// 需要 publicUrl、mime 或尺寸时必须显式调用 getResource,避免隐式 N+1。
return id ? resourceFileUrl(id) : fallback;
}
export async function getResourceBlob(storageKey: string) {

View File

@@ -1,8 +1,12 @@
import { compactApiParams, serializeApiParams, type ApiParams } from "@/services/api/request";
import { apiClient, request } from "@/services/api/request";
import { getActiveUserScope } from "@/lib/user-scope";
const api = apiClient;
let addedSkillsRequest: { scope: string; promise: Promise<{ skills: Skill[] }> } | null = null;
let addedSkillsCache: { scope: string; value: { skills: Skill[] }; expiresAt: number } | null = null;
export type SkillSort = "popular" | "new" | "updated";
export type SkillScope = "public" | "mine" | "created" | "favorites";
export type SkillMediaType = "image" | "video";
@@ -82,27 +86,44 @@ export function getSkill(id: string) {
}
export function listAddedSkills() {
return request<{ skills: Skill[] }>(api.get("/skills/added"));
const scope = getActiveUserScope();
const now = Date.now();
if (addedSkillsCache?.scope === scope && addedSkillsCache.expiresAt > now) return Promise.resolve(addedSkillsCache.value);
if (addedSkillsRequest?.scope === scope) return addedSkillsRequest.promise;
const promise = request<{ skills: Skill[] }>(api.get("/skills/added"))
.then((value) => {
addedSkillsCache = { scope, value, expiresAt: Date.now() + 15_000 };
return value;
})
.finally(() => {
if (addedSkillsRequest?.promise === promise) addedSkillsRequest = null;
});
addedSkillsRequest = { scope, promise };
return promise;
}
function invalidateAddedSkillsCache() {
addedSkillsCache = null;
}
export function createSkill(input: SkillMutationInput) {
return request<{ skill: Skill }>(api.post("/skills", input));
return request<{ skill: Skill }>(api.post("/skills", input)).finally(invalidateAddedSkillsCache);
}
export function updateSkill(id: string, input: SkillMutationInput) {
return request<{ skill: Skill }>(api.put(`/skills/${encodeURIComponent(id)}`, input));
return request<{ skill: Skill }>(api.put(`/skills/${encodeURIComponent(id)}`, input)).finally(invalidateAddedSkillsCache);
}
export function deleteSkill(id: string) {
return request<{ deleted: boolean }>(api.delete(`/skills/${encodeURIComponent(id)}`));
return request<{ deleted: boolean }>(api.delete(`/skills/${encodeURIComponent(id)}`)).finally(invalidateAddedSkillsCache);
}
export function addSkill(id: string) {
return request<{ skill: Skill }>(api.post(`/skills/${encodeURIComponent(id)}/add`));
return request<{ skill: Skill }>(api.post(`/skills/${encodeURIComponent(id)}/add`)).finally(invalidateAddedSkillsCache);
}
export function removeSkill(id: string) {
return request<{ skill: Skill }>(api.delete(`/skills/${encodeURIComponent(id)}/add`));
return request<{ skill: Skill }>(api.delete(`/skills/${encodeURIComponent(id)}/add`)).finally(invalidateAddedSkillsCache);
}
export function likeSkill(id: string) {

View File

@@ -13,6 +13,8 @@ import type { ReferenceImage } from "@/types/image";
import type { ReferenceAudio, ReferenceVideo } from "@/types/media";
type VideoResponse = { id?: string; request_id?: string; task_id?: string; status?: string; error?: { message?: string }; video?: { url?: string }; video_url?: string; result_url?: string };
type MiniMaxVideoTask = { id?: string; status?: string; content?: { url?: string }; error?: { code?: string | number; message?: string } };
type MiniMaxVideoCreateResponse = { task_id?: string; request_id?: string; data?: { task_id?: string; id?: string } };
type ApiVideoResponse = VideoResponse | { code?: number; data?: VideoResponse | null; msg?: string };
type ResolvedAiConfig = ReturnType<typeof resolveModelRequestConfig>;
type SeedanceTask = {
@@ -28,7 +30,7 @@ type ApiEnvelope<T> = T | { code?: number; data?: T | null; msg?: string };
type RequestOptions = { signal?: AbortSignal };
export type VideoGenerationResult = { blob?: Blob; url?: string; mimeType?: string };
export type VideoGenerationTask = { id: string; provider: "openai" | "seedance" | "video-generations" | "gemini-veo" | "novita"; model: string };
export type VideoGenerationTask = { id: string; provider: "openai" | "seedance" | "video-generations" | "gemini-veo" | "novita" | "minimax"; model: string };
export type VideoGenerationTaskState = { status: "pending" } | { status: "completed"; result: VideoGenerationResult } | { status: "failed"; error: string };
function aiApiUrl(config: AiConfig, path: string) {
@@ -71,6 +73,9 @@ export async function createVideoGenerationTask(config: AiConfig, prompt: string
if (requestConfig.interfaceType === "novita-video") {
return createNovitaVideoTask(requestConfig, selectedModel, prompt, references, videoReferences, audioReferences, options);
}
if (requestConfig.interfaceType === "minimax-video") {
return createMiniMaxVideoTask(requestConfig, selectedModel, prompt, references, videoReferences, audioReferences, options);
}
if (requestConfig.interfaceType === "volcengine-ark-video") {
return createSeedanceTask(requestConfig, selectedModel, prompt, references, videoReferences, audioReferences, options);
}
@@ -105,6 +110,7 @@ export async function pollVideoGenerationTask(config: AiConfig, task: VideoGener
if (task.provider === "video-generations") return pollVideoGenerationsTask(requestConfig, task, options);
if (task.provider === "gemini-veo") return pollGeminiVeoTask(requestConfig, task, options);
if (task.provider === "novita") return pollNovitaVideoTask(requestConfig, task, options);
if (task.provider === "minimax") return pollMiniMaxVideoTask(requestConfig, task, options);
return pollOpenAIVideoTask(requestConfig, task, options);
}
@@ -272,6 +278,98 @@ async function pollNovitaVideoTask(config: ResolvedAiConfig, task: VideoGenerati
}
}
async function createMiniMaxVideoTask(config: ResolvedAiConfig, model: string, prompt: string, references: ReferenceImage[], videoReferences: ReferenceVideo[], audioReferences: ReferenceAudio[], options?: RequestOptions): Promise<VideoGenerationTask> {
const imageUrls = await Promise.all(references.slice(0, 9).map((image) => resolveMiniMaxImageUrl(image)));
const videoUrls = await Promise.all(videoReferences.slice(0, 3).map((video) => resolveMiniMaxMediaUrl(video, "参考视频")));
const audioUrls = await Promise.all(audioReferences.slice(0, 3).map((audio) => resolveMiniMaxMediaUrl(audio, "参考音频")));
const content: Array<Record<string, unknown>> = [{ type: "text", text: prompt.trim() }];
imageUrls.forEach((url, index) => {
// 首尾帧只允许成对出现;三张及以上图片统一作为多模态参考图,避免提交 first/last 与 reference 混合的非法组合。
const role = videoUrls.length || audioUrls.length || imageUrls.length > 2 ? "reference_image" : index === 0 ? "first_frame" : "last_frame";
content.push({ type: "image_url", image_url: { url }, role });
});
videoUrls.forEach((url) => content.push({ type: "video_url", video_url: { url }, role: "reference_video" }));
audioUrls.forEach((url) => content.push({ type: "audio_url", audio_url: { url }, role: "reference_audio" }));
const frameMode = imageUrls.length > 0 &&
imageUrls.length <= 2 &&
videoUrls.length === 0 &&
audioUrls.length === 0;
const payload = {
model: modelOptionName(model),
content,
resolution: normalizeMiniMaxResolution(config.vquality),
duration: normalizeMiniMaxDuration(config.videoSeconds),
ratio: normalizeMiniMaxRatio(config.size, frameMode),
aigc_watermark: boolConfig(config.videoWatermark, false),
};
try {
const created = await channelPost<MiniMaxVideoCreateResponse>(config, miniMaxVideoUrl(config, "/video_generation"), payload, options);
const id = created.task_id || created.request_id || created.data?.task_id || created.data?.id || "";
if (!id) throw new Error("MiniMax 视频接口没有返回任务 ID");
return { id, provider: "minimax", model };
} catch (error) {
throw new Error(readAxiosError(error, "MiniMax 视频任务创建失败"));
}
}
async function pollMiniMaxVideoTask(config: ResolvedAiConfig, task: VideoGenerationTask, options?: RequestOptions): Promise<VideoGenerationTaskState> {
try {
const response = await channelGet<{ task?: MiniMaxVideoTask }>(config, miniMaxVideoUrl(config, `/query/video_generation/${encodeURIComponent(task.id)}`), options);
const state = response.task || {};
const status = String(state.status || "").toLowerCase();
if (status === "succeeded" || status === "completed") {
const url = state.content?.url || "";
if (!url) return { status: "failed", error: "MiniMax 视频任务已完成但没有返回视频地址" };
return { status: "completed", result: await videoResultFromUrl(url, options) };
}
if (status === "failed" || status === "cancelled") {
const code = state.error?.code ? `${state.error.code}:` : "";
return { status: "failed", error: `${code}${state.error?.message || "MiniMax 视频生成失败"}` };
}
return { status: "pending" };
} catch (error) {
throw new Error(readAxiosError(error, "MiniMax 视频任务查询失败"));
}
}
function miniMaxVideoUrl(config: ResolvedAiConfig, path: string) {
const base = config.baseUrl.replace(/\/+$/, "");
if (/\/v2$/i.test(base)) return `${base}${path}`;
return `${base}/v2${path}`;
}
function normalizeMiniMaxResolution(value: string) {
const normalized = value.trim().toLowerCase();
return ["2k", "4k", "high", "1080", "1080p", "1440p", "2160p"].includes(normalized) ? "2K" : "768P";
}
function normalizeMiniMaxDuration(value: string) {
const seconds = Math.floor(Number(value) || 5);
return Math.max(4, Math.min(15, seconds));
}
function normalizeMiniMaxRatio(value: string, frameMode: boolean) {
if (frameMode) return "adaptive";
return ["adaptive", "21:9", "16:9", "4:3", "1:1", "3:4", "9:16"].includes(value) ? value : "16:9";
}
async function resolveMiniMaxImageUrl(image: ReferenceImage) {
const directUrl = image.url || image.dataUrl;
if (isPublicMediaUrl(directUrl) || directUrl.startsWith("data:")) return directUrl;
const dataUrl = await imageToDataUrl(image);
if (!dataUrl) throw new Error("MiniMax 参考图读取失败,请换一张图片或重新上传");
return dataUrl;
}
async function resolveMiniMaxMediaUrl(media: ReferenceVideo | ReferenceAudio, label: string) {
if (isPublicMediaUrl(media.url) || media.url?.startsWith("data:")) return media.url;
let blob: Blob | null = null;
if (media.storageKey) blob = await getMediaBlob(media.storageKey);
if (!blob && media.url?.startsWith("blob:")) blob = await (await fetch(media.url)).blob();
if (!blob) throw new Error(`MiniMax ${label}必须是公网 URL 或本地已保存素材`);
return blobToDataUrl(blob);
}
function novitaVideoUrl(config: ResolvedAiConfig, path: string) {
return `${config.baseUrl.replace(/\/+$/, "")}${path}`;
}