fix(*): 模型与数据同步 - 完善鉴权、供应商模型和本地持久化能力
This commit is contained in:
@@ -6,14 +6,11 @@ import { WorkspaceCommandPalette } from "@/components/layout/workspace-command-p
|
||||
import { WorkspaceSidebarNav } from "@/components/layout/workspace-sidebar-nav";
|
||||
import { WorkspaceTopBar } from "@/components/layout/workspace-top-bar";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { refreshFeatureAvailability } from "@/lib/user-session";
|
||||
import { isSpatialWorkbenchPath } from "@/lib/workspace-routes";
|
||||
import { useUserStore } from "@/stores/use-user-store";
|
||||
|
||||
export function AppWorkspaceShell({ children }: { children: ReactNode }) {
|
||||
const { pathname } = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const user = useUserStore((state) => state.user);
|
||||
const [mobileSidebarExpanded, setMobileSidebarExpanded] = useState(false);
|
||||
const [desktopSidebarCollapsed, setDesktopSidebarCollapsed] = useState(false);
|
||||
const [paletteOpen, setPaletteOpen] = useState(false);
|
||||
@@ -60,17 +57,6 @@ export function AppWorkspaceShell({ children }: { children: ReactNode }) {
|
||||
return () => window.removeEventListener("workspace:navigate", handleWorkspaceNavigation);
|
||||
}, [navigate]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) return;
|
||||
const refresh = () => void refreshFeatureAvailability().catch((error) => console.warn("功能开放状态刷新失败", error));
|
||||
const timer = window.setInterval(refresh, 30_000);
|
||||
window.addEventListener("focus", refresh);
|
||||
return () => {
|
||||
window.clearInterval(timer);
|
||||
window.removeEventListener("focus", refresh);
|
||||
};
|
||||
}, [user]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={cn("app-workspace-shell flex h-dvh min-h-0 w-full flex-col overflow-hidden", spatialWorkbench && "is-spatial", creationWorkspace && "is-creation-workspace")}>
|
||||
|
||||
@@ -8,12 +8,13 @@ import { useLocalDreaminaModelBootstrap } from "@/stores/use-local-dreamina-mode
|
||||
import { useLocalRuntimeBootstrap } from "@/stores/use-local-runtime-store";
|
||||
|
||||
export function ClientRootInit({ children }: { children: ReactNode }) {
|
||||
useLocalRuntimeBootstrap();
|
||||
const config = useConfigStore((state) => state.config);
|
||||
const localRuntimeConfigured = config.channels.some((channel) => channel.transport === "local-runtime" && channel.enabled !== false);
|
||||
useLocalRuntimeBootstrap(localRuntimeConfigured);
|
||||
useLocalDreaminaModelBootstrap();
|
||||
const { message } = App.useApp();
|
||||
const handledConfigParams = useRef(false);
|
||||
const updateConfig = useConfigStore((state) => state.updateConfig);
|
||||
const config = useConfigStore((state) => state.config);
|
||||
|
||||
useEffect(() => {
|
||||
const interactiveSelector = 'button, [role="button"], a, [class*="card"], [class*="Card"]';
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
|
||||
import { Tag } from "antd";
|
||||
import { Bell } from "lucide-react";
|
||||
import { useCallback, useEffect, useRef, useState, type CSSProperties } from "react";
|
||||
import { useState, type CSSProperties } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { AnnouncementTimelineModal } from "@/components/ui/aceternity/announcement-timeline-modal";
|
||||
import { aceternityMotion } from "@/lib/aceternity-motion";
|
||||
import { getAnnouncementFeed, markAnnouncementsRead, type SystemAnnouncement } from "@/services/api/announcements";
|
||||
import { getAnnouncementFeed, markAnnouncementsRead } from "@/services/api/announcements";
|
||||
|
||||
const ANNOUNCEMENT_REFRESH_INTERVAL_MS = 60_000;
|
||||
const ANNOUNCEMENT_REFRESH_INTERVAL_MS = 5 * 60_000;
|
||||
const ANNOUNCEMENT_CACHE_TTL_MS = 60_000;
|
||||
|
||||
type AnnouncementFeed = Awaited<ReturnType<typeof getAnnouncementFeed>>;
|
||||
|
||||
type SystemAnnouncementCenterProps = {
|
||||
userId: string;
|
||||
@@ -20,63 +24,31 @@ type SystemAnnouncementCenterProps = {
|
||||
|
||||
export function SystemAnnouncementCenter({ userId, className, style, showLabel = false, labelClassName, staticMotion = false }: SystemAnnouncementCenterProps) {
|
||||
const reducedMotion = useReducedMotion();
|
||||
const activeUserIdRef = useRef(userId);
|
||||
activeUserIdRef.current = userId;
|
||||
const queryClient = useQueryClient();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [announcements, setAnnouncements] = useState<SystemAnnouncement[]>([]);
|
||||
const [unreadCount, setUnreadCount] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const refresh = useCallback(async (showLoading = false) => {
|
||||
if (!userId) return null;
|
||||
const requestUserId = userId;
|
||||
if (showLoading) setLoading(true);
|
||||
try {
|
||||
const feed = await getAnnouncementFeed();
|
||||
if (activeUserIdRef.current !== requestUserId) return null;
|
||||
setAnnouncements(feed.announcements || []);
|
||||
setUnreadCount(Math.max(0, feed.unreadCount || 0));
|
||||
setError("");
|
||||
return feed;
|
||||
} catch (requestError) {
|
||||
if (activeUserIdRef.current !== requestUserId) return null;
|
||||
setError(requestError instanceof Error ? requestError.message : "读取公告失败");
|
||||
return null;
|
||||
} finally {
|
||||
if (showLoading && activeUserIdRef.current === requestUserId) setLoading(false);
|
||||
}
|
||||
}, [userId]);
|
||||
|
||||
useEffect(() => {
|
||||
setAnnouncements([]);
|
||||
setUnreadCount(0);
|
||||
setError("");
|
||||
if (!userId) return;
|
||||
void refresh();
|
||||
const timer = window.setInterval(() => void refresh(), ANNOUNCEMENT_REFRESH_INTERVAL_MS);
|
||||
const onFocus = () => void refresh();
|
||||
const onVisibilityChange = () => {
|
||||
if (document.visibilityState === "visible") void refresh();
|
||||
};
|
||||
window.addEventListener("focus", onFocus);
|
||||
document.addEventListener("visibilitychange", onVisibilityChange);
|
||||
return () => {
|
||||
window.clearInterval(timer);
|
||||
window.removeEventListener("focus", onFocus);
|
||||
document.removeEventListener("visibilitychange", onVisibilityChange);
|
||||
};
|
||||
}, [refresh, userId]);
|
||||
const queryKey = ["system-announcements", userId] as const;
|
||||
const feedQuery = useQuery({
|
||||
queryKey,
|
||||
queryFn: getAnnouncementFeed,
|
||||
enabled: Boolean(userId),
|
||||
staleTime: ANNOUNCEMENT_CACHE_TTL_MS,
|
||||
refetchInterval: ANNOUNCEMENT_REFRESH_INTERVAL_MS,
|
||||
// 公告在打开面板时会显式 refetch;不把浏览器 focus 变成每个工作区实例的请求触发器。
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
const announcements = feedQuery.data?.announcements || [];
|
||||
const unreadCount = Math.max(0, feedQuery.data?.unreadCount || 0);
|
||||
const error = feedQuery.error instanceof Error ? feedQuery.error.message : feedQuery.error ? "读取公告失败" : "";
|
||||
|
||||
const openAnnouncements = async () => {
|
||||
setOpen(true);
|
||||
const feed = await refresh(announcements.length === 0);
|
||||
const feed = (await feedQuery.refetch()).data;
|
||||
if (!feed?.unreadCount) return;
|
||||
try {
|
||||
const result = await markAnnouncementsRead(feed.announcements.map((announcement) => announcement.id));
|
||||
const nextUnreadCount = Math.max(0, result.unreadCount || 0);
|
||||
setUnreadCount(nextUnreadCount);
|
||||
if (nextUnreadCount > 0) void refresh();
|
||||
queryClient.setQueryData<AnnouncementFeed>(queryKey, (current) => current ? { ...current, unreadCount: nextUnreadCount } : current);
|
||||
if (nextUnreadCount > 0) void queryClient.invalidateQueries({ queryKey });
|
||||
} catch {
|
||||
// 已读状态是辅助读路径,失败时保留角标,下一次打开或轮询会继续尝试同步。
|
||||
}
|
||||
@@ -118,7 +90,7 @@ export function SystemAnnouncementCenter({ userId, className, style, showLabel =
|
||||
</span>
|
||||
) : null}
|
||||
</motion.button>
|
||||
<AnnouncementTimelineModal open={open} announcements={announcements} loading={loading} error={announcements.length ? "" : error} onClose={() => setOpen(false)} onRetry={() => void refresh(true)} />
|
||||
<AnnouncementTimelineModal open={open} announcements={announcements} loading={feedQuery.isFetching} error={announcements.length ? "" : error} onClose={() => setOpen(false)} onRetry={() => void feedQuery.refetch()} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMemo, useState, type ComponentType, type SVGProps } from "react";
|
||||
import { useEffect, useMemo, useState, type ComponentType, type SVGProps } from "react";
|
||||
import { Input, Popover } from "antd";
|
||||
import { Cpu, Search, X } from "lucide-react";
|
||||
|
||||
@@ -8,22 +8,53 @@ import { cn } from "@/lib/utils";
|
||||
|
||||
type LobeIconComponent = ComponentType<SVGProps<SVGSVGElement> & { size?: number | string }>;
|
||||
|
||||
// 只加载各图标的 Mono 组件;完整 icons barrel 会经由 Avatar/Combine 与 @lobehub/ui 形成循环依赖,Bun 测试环境会触发未初始化导出。
|
||||
const iconModules = typeof import.meta.glob === "function"
|
||||
? import.meta.glob("../../node_modules/@lobehub/icons/es/*/components/Mono.js", { eager: true, import: "default" })
|
||||
: {};
|
||||
const iconRegistry = Object.fromEntries(
|
||||
// 只允许按需加载 Mono 组件。这里不能使用 eager glob:模型 Logo 目录有数百个 provider 模块,
|
||||
// eager 会让每次进入工作区都发起数百个开发模块请求,即使用户从未打开 Logo 选择器。
|
||||
const iconModules = import.meta.glob("../../node_modules/@lobehub/icons/es/*/components/Mono.js", { import: "default" });
|
||||
const iconLoaders = Object.fromEntries(
|
||||
Object.entries(iconModules)
|
||||
.map(([path, icon]) => [path.match(/\/([^/]+)\/components\/Mono\.js$/)?.[1], icon])
|
||||
.filter((entry): entry is [string, LobeIconComponent] => Boolean(entry[0] && entry[1])),
|
||||
) as Record<string, LobeIconComponent>;
|
||||
.map(([path, loader]) => [path.match(/\/([^/]+)\/components\/Mono\.js$/)?.[1], loader])
|
||||
.filter((entry): entry is [string, () => Promise<LobeIconComponent>] => Boolean(entry[0] && entry[1])),
|
||||
) as Record<string, () => Promise<LobeIconComponent>>;
|
||||
const iconRegistry = new Map<string, LobeIconComponent>();
|
||||
const iconLoadPromises = new Map<string, Promise<LobeIconComponent | undefined>>();
|
||||
const iconOptions = toc
|
||||
.filter((item) => item.group === "model" || item.group === "provider")
|
||||
.map((item) => ({ id: item.id, title: item.fullTitle || item.title }))
|
||||
.filter((item) => Boolean(iconRegistry[item.id]));
|
||||
.filter((item) => Boolean(iconLoaders[item.id]));
|
||||
|
||||
function loadIcon(icon?: string) {
|
||||
if (!icon) return Promise.resolve(undefined);
|
||||
const cached = iconRegistry.get(icon);
|
||||
if (cached) return Promise.resolve(cached);
|
||||
const existing = iconLoadPromises.get(icon);
|
||||
if (existing) return existing;
|
||||
const loader = iconLoaders[icon];
|
||||
if (!loader) return Promise.resolve(undefined);
|
||||
const promise = loader()
|
||||
.then((module) => {
|
||||
const loaded = (module as unknown as { default?: LobeIconComponent }).default || module;
|
||||
iconRegistry.set(icon, loaded);
|
||||
return loaded;
|
||||
})
|
||||
.catch(() => undefined);
|
||||
iconLoadPromises.set(icon, promise);
|
||||
return promise;
|
||||
}
|
||||
|
||||
export function ModelLogo({ icon, size = 18, className }: { icon?: string; size?: number; className?: string }) {
|
||||
const Icon = icon ? iconRegistry[icon] : undefined;
|
||||
const [Icon, setIcon] = useState<LobeIconComponent | undefined>(() => icon ? iconRegistry.get(icon) : undefined);
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setIcon(icon ? iconRegistry.get(icon) : undefined);
|
||||
if (!icon || !iconLoaders[icon]) return () => { cancelled = true; };
|
||||
void loadIcon(icon).then((loaded) => {
|
||||
if (!cancelled) setIcon(loaded);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [icon]);
|
||||
if (!Icon) return <Cpu className={cn("shrink-0 text-foreground/45", className)} size={size} aria-hidden />;
|
||||
return <Icon size={size} className={cn("shrink-0", className)} aria-hidden />;
|
||||
}
|
||||
|
||||
@@ -3,7 +3,9 @@ import { useEffect } from "react";
|
||||
|
||||
import { getWallet } from "@/services/api/wallet";
|
||||
|
||||
const WALLET_REFRESH_INTERVAL_MS = 30_000;
|
||||
// 余额不是页面心跳数据。工作区内多个位置共享同一个 Query,只有首次进入、显式
|
||||
// wallet:updated 事件或低频后台刷新才需要访问服务端,避免每个窗口 focus 都打一次列表接口。
|
||||
const WALLET_REFRESH_INTERVAL_MS = 60_000;
|
||||
const walletBalanceQueryKey = (userId: string) => ["wallet-balance", userId] as const;
|
||||
|
||||
export function useWalletBalance(userId?: string, enabled = true) {
|
||||
@@ -18,9 +20,9 @@ export function useWalletBalance(userId?: string, enabled = true) {
|
||||
if (wallet.account.userId !== activeUserId) throw new Error("积分账户与当前用户不一致");
|
||||
return wallet.account.availableMicrocredits;
|
||||
},
|
||||
staleTime: 15_000,
|
||||
staleTime: WALLET_REFRESH_INTERVAL_MS,
|
||||
refetchInterval: WALLET_REFRESH_INTERVAL_MS,
|
||||
refetchOnWindowFocus: true,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -31,6 +31,8 @@ export function generationErrorMessage(error: unknown) {
|
||||
const providerMessage = extractStructuredProviderMessage(raw) || extractWrappedProviderMessage(raw);
|
||||
const displayMessage = providerMessage || raw;
|
||||
if (isContentModerationError(displayMessage)) return CONTENT_MODERATION_MESSAGE;
|
||||
const resourceStorageMessage = resourceStorageFailureMessage(raw) || resourceStorageFailureMessage(displayMessage);
|
||||
if (resourceStorageMessage) return resourceStorageMessage;
|
||||
if (isNetworkFailure(displayMessage)) return NETWORK_ERROR_MESSAGE;
|
||||
if (!providerMessage) {
|
||||
if (hasHttpStatus(raw, 429)) return "服务当前繁忙,请稍后重试。";
|
||||
@@ -133,3 +135,12 @@ function hasHttpStatus(value: string, ...statuses: number[]) {
|
||||
function containsInfrastructureDetails(value: string) {
|
||||
return /(?:接口请求失败|Request failed with status code|https?:\/\/|\b(?:GET|POST|PUT|PATCH|DELETE)\s+["']?|Bad Gateway|Service Unavailable|Gateway Timeout|upstream_error)/i.test(value);
|
||||
}
|
||||
|
||||
function resourceStorageFailureMessage(value: string) {
|
||||
if (!value) return "";
|
||||
if (/\bUserDisable\b/i.test(value)) return "对象存储账号已停用,请检查或更换对象存储配置。";
|
||||
if (/(?:参考(?:图片|媒体)上传失败|OSS 上传失败|对象存储|腾讯云 COS|七牛云)/i.test(value)) {
|
||||
return "参考素材上传到对象存储失败,请检查对象存储配置后重试。";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
@@ -301,6 +301,21 @@ export function defaultModelCapabilityConfig(protocol?: ModelProtocol, model = "
|
||||
video.resolutions = ["1080p"];
|
||||
video.defaultResolution = "1080p";
|
||||
}
|
||||
if (protocol === "minimax-video") {
|
||||
video.references.maxImages = 9;
|
||||
video.references.maxImageBytes = 30 * 1024 * 1024;
|
||||
video.references.maxVideos = 3;
|
||||
video.references.maxVideoBytes = 50 * 1024 * 1024;
|
||||
video.references.maxVideoDurationSeconds = 15;
|
||||
video.references.maxAudios = 3;
|
||||
video.references.maxAudioBytes = 15 * 1024 * 1024;
|
||||
video.references.maxAudioDurationSeconds = 15;
|
||||
video.duration = { selection: "enum", values: [4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15], default: 5 };
|
||||
video.ratios = ["adaptive", "21:9", "16:9", "4:3", "1:1", "3:4", "9:16"];
|
||||
video.resolutions = ["768P", "2K"];
|
||||
video.defaultResolution = "768P";
|
||||
video.watermark = { supported: true, default: false };
|
||||
}
|
||||
return { version: 1, text, image: defaultImageCapabilityConfig(protocol, model), video };
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,8 @@ export type ModelProtocol =
|
||||
| "volcengine-ark-video"
|
||||
| "volcengine-jimeng-video"
|
||||
| "gemini-veo"
|
||||
| "novita-video";
|
||||
| "novita-video"
|
||||
| "minimax-video";
|
||||
|
||||
export type ProtocolCapability = "text" | "image" | "video" | "audio";
|
||||
|
||||
@@ -63,6 +64,7 @@ export const MODEL_PROTOCOLS: ModelProtocolDefinition[] = [
|
||||
{ value: "volcengine-jimeng-video", label: "即梦官方视频", capability: "video", create: "POST CVSync2AsyncSubmitTask", poll: "POST CVSync2AsyncGetResult", contentType: "application/json + AK/SK 签名", media: "文本或一张首帧图,模型标识填写 req_key" },
|
||||
{ value: "gemini-veo", label: "Gemini Veo", capability: "video", create: "POST /v1beta/models/{model}:predictLongRunning", poll: "GET /v1beta/{operation_name}", contentType: "application/json", media: "文本与单张起始图" },
|
||||
{ value: "novita-video", label: "Novita 视频", capability: "video", create: "POST /v3/video/create", poll: "GET /v3/async/task-result?task_id={id}", contentType: "application/json", media: "文本或单张起始图" },
|
||||
{ value: "minimax-video", label: "MiniMax 视频", capability: "video", create: "POST /v2/video_generation", poll: "GET /v2/query/video_generation/{task_id}", contentType: "application/json", media: "文本、图片、视频、音频参考素材" },
|
||||
];
|
||||
|
||||
export const MODEL_PROTOCOL_OPTIONS = protocolGroups(MODEL_PROTOCOLS.filter((item) => !item.value.startsWith("volcengine-jimeng-")));
|
||||
|
||||
@@ -54,8 +54,10 @@ export async function applyUserSession(payload: AuthSessionPayload) {
|
||||
useConfigStore.getState().mergeSystemChannels(managedModelChannels(payload.logicalModels || []));
|
||||
}
|
||||
installRemoteUserDataAutoSync();
|
||||
if (payload.user?.id) await syncRemoteUserData(payload.user.id);
|
||||
else resetRemoteUserDataSync();
|
||||
if (payload.user?.id) {
|
||||
// 认证状态先完成,云端数据在后台合并;远端同步失败不能伪装成登录失败。
|
||||
void syncRemoteUserData(payload.user.id).catch((error) => console.warn("登录后云端数据同步失败,保留本地数据等待重试", error));
|
||||
} else resetRemoteUserDataSync();
|
||||
} finally {
|
||||
useUserStore.getState().setHydrated(true);
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import localforage from "localforage";
|
||||
|
||||
import { getActiveUserScope } from "@/lib/user-scope";
|
||||
import { getResource, getResourceBlob, resourceIdFromStorageKey, type RemoteResource } from "@/services/api/resources";
|
||||
import { getResourceBlob, resourceIdFromStorageKey } from "@/services/api/resources";
|
||||
|
||||
type ResourceCacheMeta = {
|
||||
key: string;
|
||||
@@ -147,25 +147,22 @@ async function readCachedObjectUrl(target: ResourceCacheMeta) {
|
||||
async function cacheTarget(storageKey: string): Promise<ResourceCacheMeta | null> {
|
||||
const resourceId = resourceIdFromStorageKey(storageKey);
|
||||
if (!resourceId) return null;
|
||||
const resource = await getResource(resourceId);
|
||||
const userScope = getActiveUserScope();
|
||||
if (userScope === "guest" || resource.userId !== userScope) throw new Error("当前用户不能读取该媒体缓存");
|
||||
const version = resourceVersion(resource);
|
||||
if (userScope === "guest") throw new Error("游客不能读取远程媒体缓存");
|
||||
// resource ID 是不可变资源的稳定标识,文件接口本身负责鉴权。
|
||||
// 缓存初始化不能先对每个资源读取一遍元数据,否则首屏会重新形成 N+1 请求。
|
||||
const version = "file";
|
||||
return {
|
||||
key: `${userScope}:${resourceId}:${version}`,
|
||||
userScope,
|
||||
resourceId,
|
||||
version,
|
||||
size: resource.size || 0,
|
||||
mimeType: resource.mimeType || "application/octet-stream",
|
||||
size: 0,
|
||||
mimeType: "application/octet-stream",
|
||||
lastAccessedAt: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
function resourceVersion(resource: RemoteResource) {
|
||||
return (resource.etag || `${resource.size}:${resource.updatedAt}`).replace(/[^a-zA-Z0-9:._-]/g, "_");
|
||||
}
|
||||
|
||||
async function touchCacheMeta(target: ResourceCacheMeta) {
|
||||
const current = await metaStore.getItem<ResourceCacheMeta>(target.key);
|
||||
if (!current || Date.now() - current.lastAccessedAt < TOUCH_INTERVAL_MS) return;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { getMediaBlob } from "@/services/file-storage";
|
||||
import { getImageBlob, resolveImageUrl } from "@/services/image-storage";
|
||||
import { deleteRemoteAsset, deleteRemoteCanvasProject, getRemoteAsset, getRemoteCanvasProject, listRemoteAssets, listRemoteCanvasProjects, upsertRemoteAsset, upsertRemoteCanvasProject, type RemoteUserDataSummary } from "@/services/api/user-data";
|
||||
import { getImageBlob } from "@/services/image-storage";
|
||||
import { deleteRemoteAsset, deleteRemoteCanvasProject, getRemoteUserDataSnapshot, upsertRemoteAsset, upsertRemoteCanvasProject } from "@/services/api/user-data";
|
||||
import { resourceFileUrl, resourceIdFromStorageKey, resourceStorageKey, uploadResourceFile } from "@/services/api/resources";
|
||||
import type { Asset } from "@/stores/use-asset-store";
|
||||
import { useAssetStore } from "@/stores/use-asset-store";
|
||||
@@ -29,17 +29,16 @@ export async function syncRemoteUserData(userId?: string | null) {
|
||||
if (!activeRemoteUserId) return;
|
||||
applyingRemoteState = true;
|
||||
try {
|
||||
const [remoteCanvas, remoteAssets] = await Promise.all([listRemoteCanvasProjects(), listRemoteAssets()]);
|
||||
remoteProjectVersions = versionMap(remoteCanvas.projects);
|
||||
remoteAssetVersions = versionMap(remoteAssets.assets);
|
||||
// 登录只拉一次聚合快照。摘要列表再逐条请求详情会把 N 条数据放大成 2N+2 个请求,
|
||||
// 并且会在登录阶段同时触发大量媒体解析,任何一项失败都会污染登录结果。
|
||||
const snapshot = await getRemoteUserDataSnapshot();
|
||||
remoteProjectVersions = versionMap(snapshot.projects);
|
||||
remoteAssetVersions = versionMap(snapshot.assets);
|
||||
const localProjects = useCanvasStore.getState().projects;
|
||||
const localAssets = useAssetStore.getState().assets;
|
||||
const [changedProjects, changedAssets] = await Promise.all([
|
||||
fetchNewerRemoteItems(localProjects, remoteCanvas.projects, async (id) => (await getRemoteCanvasProject(id)).project),
|
||||
fetchNewerRemoteItems(localAssets, remoteAssets.assets, async (id) => (await getRemoteAsset(id)).asset),
|
||||
]);
|
||||
const mergedProjects = mergeById(localProjects, changedProjects);
|
||||
const mergedAssets = mergeById(localAssets, await hydrateAssets(changedAssets));
|
||||
const mergedProjects = mergeById(localProjects, snapshot.projects);
|
||||
// 这里只合并结构化素材数据,不在登录阶段解析图片/视频/音频 URL;媒体由实际使用方按需解析。
|
||||
const mergedAssets = mergeById(localAssets, snapshot.assets);
|
||||
useCanvasStore.getState().replaceProjects(mergedProjects);
|
||||
useAssetStore.getState().replaceAssets(mergedAssets);
|
||||
} finally {
|
||||
@@ -242,30 +241,6 @@ async function saveRemoteUserDataBatch() {
|
||||
}
|
||||
}
|
||||
|
||||
async function hydrateAssets(assets: Asset[]): Promise<Asset[]> {
|
||||
return Promise.all(
|
||||
assets.map(async (asset) => {
|
||||
if (asset.kind === "image" && asset.data.storageKey) {
|
||||
const dataUrl = await resolveImageUrl(asset.data.storageKey, asset.data.dataUrl);
|
||||
return { ...asset, coverUrl: shouldReplaceEphemeralUrl(asset.coverUrl) ? dataUrl : asset.coverUrl, data: { ...asset.data, dataUrl } };
|
||||
}
|
||||
if (asset.kind === "video" && asset.data.storageKey) {
|
||||
const url = await resolveResourceOrMediaUrl(asset.data.storageKey, asset.data.url);
|
||||
return { ...asset, data: { ...asset.data, url } };
|
||||
}
|
||||
if (asset.kind === "audio" && asset.data.storageKey) {
|
||||
const url = await resolveResourceOrMediaUrl(asset.data.storageKey, asset.data.url);
|
||||
return { ...asset, data: { ...asset.data, url } };
|
||||
}
|
||||
if (asset.kind === "model" && asset.data.storageKey) {
|
||||
const url = await resolveResourceOrMediaUrl(asset.data.storageKey, asset.data.url);
|
||||
return { ...asset, data: { ...asset.data, url } };
|
||||
}
|
||||
return asset;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function prepareRemoteAssets(assets: Asset[], uploaded: Map<string, string>) {
|
||||
const result: Asset[] = [];
|
||||
for (const asset of assets) result.push(await ensureRemoteResourceReferences(asset, uploaded));
|
||||
@@ -358,15 +333,6 @@ function mergeById<T extends { id?: string; updatedAt?: string }>(local: T[], re
|
||||
return Array.from(items.values()).sort((a, b) => timeValue(b.updatedAt) - timeValue(a.updatedAt));
|
||||
}
|
||||
|
||||
async function fetchNewerRemoteItems<T extends { id: string; updatedAt?: string }>(local: T[], remote: RemoteUserDataSummary[], fetchItem: (id: string) => Promise<T>) {
|
||||
const localById = new Map(local.map((item) => [item.id, item]));
|
||||
const pending = remote.filter((item) => {
|
||||
const current = localById.get(item.id);
|
||||
return !current || timeValue(item.updatedAt) > timeValue(current.updatedAt);
|
||||
});
|
||||
return Promise.all(pending.map((item) => fetchItem(item.id)));
|
||||
}
|
||||
|
||||
function versionMap(items: Array<{ id: string; updatedAt?: string }>) {
|
||||
return new Map<string, string>(items.map((item) => [item.id, item.updatedAt || ""]));
|
||||
}
|
||||
@@ -394,17 +360,6 @@ function isLocalStorageKey(value: string) {
|
||||
return LOCAL_STORAGE_KEY_PATTERN.test(value) && !resourceIdFromStorageKey(value);
|
||||
}
|
||||
|
||||
function shouldReplaceEphemeralUrl(value: string) {
|
||||
return !value || value.startsWith("blob:") || value.startsWith("data:");
|
||||
}
|
||||
|
||||
async function resolveResourceOrMediaUrl(storageKey: string, fallback: string) {
|
||||
const resourceId = resourceIdFromStorageKey(storageKey);
|
||||
if (resourceId) return resourceFileUrl(resourceId);
|
||||
const { resolveMediaUrl } = await import("@/services/file-storage");
|
||||
return resolveMediaUrl(storageKey, fallback);
|
||||
}
|
||||
|
||||
function numberValue(value: unknown) {
|
||||
const number = Number(value);
|
||||
return Number.isFinite(number) && number > 0 ? number : undefined;
|
||||
|
||||
@@ -6,6 +6,7 @@ import { parseAssetStorageDocument, rebaseAssetSnapshot, serializeAssetStorageDo
|
||||
import { parseCanvasStorageDocument } from "@/lib/canvas/canvas-storage-revision";
|
||||
import { localForageStorageForScope } from "@/lib/localforage-storage";
|
||||
import { getActiveUserScope } from "@/lib/user-scope";
|
||||
import { resourceFileUrl, resourceIdFromStorageKey } from "@/services/api/resources";
|
||||
import { cleanupUnusedImages, collectImageStorageKeys, resolveImageUrl, uploadImage } from "@/services/image-storage";
|
||||
import { cleanupUnusedMedia, collectMediaStorageKeys, resolveMediaUrl } from "@/services/file-storage";
|
||||
import { flushGenerationAssetStorageLocks, insertOrReturnGenerationAsset, withGenerationArtifactCommitLock, withGenerationAssetStorageLock } from "@/services/generation-asset-repository";
|
||||
@@ -213,24 +214,9 @@ const assetStorage: PersistStorage<AssetStore> = {
|
||||
return null;
|
||||
}
|
||||
const document = parseAssetStorageDocument(value);
|
||||
const assets = await Promise.all(
|
||||
document.state.assets.map(async (asset) => {
|
||||
// 视频和音频的数据结构不同,分别缩窄以保持 Asset 判别联合关系。
|
||||
if (asset.kind === "video" && asset.data.storageKey) return { ...asset, data: { ...asset.data, url: await resolveMediaUrl(asset.data.storageKey, asset.data.url) } };
|
||||
if (asset.kind === "audio" && asset.data.storageKey) return { ...asset, data: { ...asset.data, url: await resolveMediaUrl(asset.data.storageKey, asset.data.url) } };
|
||||
if (asset.kind === "model" && asset.data.storageKey) return { ...asset, data: { ...asset.data, url: await resolveMediaUrl(asset.data.storageKey, asset.data.url) } };
|
||||
if (asset.kind !== "image") return asset;
|
||||
if (asset.data.storageKey)
|
||||
return {
|
||||
...asset,
|
||||
coverUrl: asset.coverUrl.startsWith("blob:") ? await resolveImageUrl(asset.data.storageKey, asset.coverUrl) : asset.coverUrl,
|
||||
data: { ...asset.data, dataUrl: await resolveImageUrl(asset.data.storageKey, asset.data.dataUrl) },
|
||||
};
|
||||
if (!asset.data.dataUrl.startsWith("data:image/")) return asset;
|
||||
const image = await uploadImage(asset.data.dataUrl);
|
||||
return { ...asset, coverUrl: asset.coverUrl.startsWith("data:image/") ? image.url : asset.coverUrl, data: { ...asset.data, dataUrl: image.url, storageKey: image.storageKey, bytes: image.bytes, mimeType: image.mimeType } };
|
||||
}),
|
||||
);
|
||||
// 持久化恢复只恢复结构化记录,不能为每个 resource: key 逐条读取资源元数据或 Blob。
|
||||
// 远程资源直接使用受鉴权的 file URL;本地 legacy key 仍恢复为 Blob URL,避免兼容性回归。
|
||||
const assets = await Promise.all(document.state.assets.map((asset) => normalizePersistedAsset(asset)));
|
||||
const hydratedDocument = { ...document, state: { assets } };
|
||||
assetMemoryStates.set(scope, { assets });
|
||||
recordAssetStorageDocument(scope, hydratedDocument);
|
||||
@@ -243,6 +229,33 @@ const assetStorage: PersistStorage<AssetStore> = {
|
||||
},
|
||||
};
|
||||
|
||||
async function normalizePersistedAsset(asset: Asset): Promise<Asset> {
|
||||
const storageKey = "data" in asset && asset.data && "storageKey" in asset.data ? asset.data.storageKey : undefined;
|
||||
const resourceId = resourceIdFromStorageKey(storageKey);
|
||||
if (resourceId) {
|
||||
const url = resourceFileUrl(resourceId);
|
||||
if (asset.kind === "video" || asset.kind === "audio" || asset.kind === "model") return { ...asset, data: { ...asset.data, url } } as Asset;
|
||||
if (asset.kind === "image") return { ...asset, coverUrl: asset.coverUrl.startsWith("blob:") ? url : asset.coverUrl, data: { ...asset.data, dataUrl: url } };
|
||||
}
|
||||
|
||||
// 非 resource: key 是早期本地存储格式,必须继续从 localForage 恢复,
|
||||
// 但只在确有本地 key 时读取,不让远程资源重新走逐条网络查询。
|
||||
if (asset.kind === "video" && storageKey) return { ...asset, data: { ...asset.data, url: await resolveMediaUrl(storageKey, asset.data.url) } };
|
||||
if (asset.kind === "audio" && storageKey) return { ...asset, data: { ...asset.data, url: await resolveMediaUrl(storageKey, asset.data.url) } };
|
||||
if (asset.kind === "model" && storageKey) return { ...asset, data: { ...asset.data, url: await resolveMediaUrl(storageKey, asset.data.url) } };
|
||||
if (asset.kind !== "image") return asset;
|
||||
if (storageKey) {
|
||||
return {
|
||||
...asset,
|
||||
coverUrl: asset.coverUrl.startsWith("blob:") ? await resolveImageUrl(storageKey, asset.coverUrl) : asset.coverUrl,
|
||||
data: { ...asset.data, dataUrl: await resolveImageUrl(storageKey, asset.data.dataUrl) },
|
||||
};
|
||||
}
|
||||
if (!asset.data.dataUrl.startsWith("data:image/")) return asset;
|
||||
const image = await uploadImage(asset.data.dataUrl);
|
||||
return { ...asset, coverUrl: asset.coverUrl.startsWith("data:image/") ? image.url : asset.coverUrl, data: { ...asset.data, dataUrl: image.url, storageKey: image.storageKey, bytes: image.bytes, mimeType: image.mimeType } };
|
||||
}
|
||||
|
||||
async function generationAssetId(effectKey: string) {
|
||||
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(effectKey));
|
||||
return `generation_${Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("")}`;
|
||||
|
||||
@@ -564,6 +564,7 @@ export function defaultBaseUrlForChannelInterface(interfaceType?: ChannelInterfa
|
||||
if (interfaceType === "novita-video") return "https://api.novita.ai/v3";
|
||||
if (interfaceType === "volcengine-ark-image" || interfaceType === "volcengine-ark-video") return "https://ark.cn-beijing.volces.com/api/v3";
|
||||
if (interfaceType === "volcengine-jimeng-image" || interfaceType === "volcengine-jimeng-video") return "https://visual.volcengineapi.com";
|
||||
if (interfaceType === "minimax-video") return "https://api.minimaxi.com";
|
||||
if (interfaceType === "grok-image" || interfaceType === "newapi" || interfaceType === "newapi-channel-1" || interfaceType === "newapi-channel-2" || interfaceType === "xai-video") return "";
|
||||
return OPENAI_BASE_URL;
|
||||
}
|
||||
|
||||
@@ -155,9 +155,12 @@ export function startLocalRuntimeBootstrap(
|
||||
};
|
||||
}
|
||||
|
||||
export function useLocalRuntimeBootstrap() {
|
||||
export function useLocalRuntimeBootstrap(enabled = true) {
|
||||
const connect = useLocalRuntimeStore((state) => state.connect);
|
||||
useEffect(() => startLocalRuntimeBootstrap(connect), [connect]);
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
return startLocalRuntimeBootstrap(connect);
|
||||
}, [connect, enabled]);
|
||||
}
|
||||
|
||||
function connectionFailure(error: unknown, timedOut: boolean) {
|
||||
|
||||
Reference in New Issue
Block a user