import { getFeatureAvailability, type AuthSessionPayload } from "@/services/api/auth"; import { getModelCatalog, type CapabilitySpec, type ModelCatalogResponse, type OptionConstraint, type PublicChannelCatalog } from "@/services/api/logical-models"; import { localForageStorage } from "@/lib/localforage-storage"; import { appQueryClient } from "@/lib/query-client"; import { scopedLocalStorage, setActiveUserScope, withUserScopedPersistenceSuppressed } from "@/lib/user-scope"; import { CANVAS_STORE_KEY, flushCanvasStorePersistence, useCanvasStore, withCanvasStorePersistenceSuppressed } from "@/stores/canvas/use-canvas-store"; import { CANVAS_HISTORY_STORE_KEY, useCanvasHistoryStore } from "@/stores/canvas/use-canvas-history-store"; import { ASSET_STORE_KEY, flushAssetStorePersistence, useAssetStore, withAssetStorePersistenceSuppressed } from "@/stores/use-asset-store"; import { CONFIG_STORE_KEY, defaultConfig, normalizeConfigSnapshot, useConfigStore, type ModelCapability, type ModelChannel } from "@/stores/use-config-store"; import { CREATION_PREFERENCES_STORE_KEY, useCreationPreferencesStore } from "@/stores/use-creation-preferences-store"; import { defaultModelCapabilityConfig, STANDARD_IMAGE_SIZE_VALUES, type ModelCapabilityConfig } from "@/lib/model-capabilities"; import { imageSizeConfigWithPresets } from "@/lib/image-size-presets"; import { useUserStore } from "@/stores/use-user-store"; import { PLUGIN_STORE_KEY, usePluginStore } from "@/stores/use-plugin-store"; import { initializeRemoteUserDataSession, installRemoteUserDataAutoSync, resetRemoteUserDataSync, withRemoteUserDataSyncExclusive } from "@/services/user-data-sync"; import { clearResourceAccessCache } from "@/services/api/resources"; import { clearResourceBlobCache } from "@/services/resource-blob-cache"; import { withGenerationConsumersPaused } from "@/services/generation-consumer-lifecycle"; import { recordDiagnosticEvent } from "@/services/diagnostics/client-diagnostics"; export async function switchUserStorageScope(userId?: string | null) { await withGenerationConsumersPaused(async () => { await withRemoteUserDataSyncExclusive(async () => { await Promise.all([flushCanvasStorePersistence(), flushAssetStorePersistence()]); resetRemoteUserDataSync(); clearResourceAccessCache(); clearResourceBlobCache(); setActiveUserScope(userId); }); }); } let sessionGeneration = 0; export async function applyUserSession(payload: AuthSessionPayload) { const generation = ++sessionGeneration; const previousUserId = useUserStore.getState().user?.id || ""; const nextUserId = payload.user?.id || ""; useUserStore.getState().setHydrated(false); try { // Query key 不携带用户 ID;身份变化时必须取消并清空旧账号请求,避免跨账号复用内存数据。 if (previousUserId !== nextUserId) appQueryClient.clear(); await switchUserStorageScope(payload.user?.id); // 切换存储 scope 时 user store 仍保留旧身份;此处只能先检查 generation, // 否则登录后的新用户会被误判为过期会话,hydrated 永远无法解除。 if (!isCurrentGeneration(generation)) return; withCanvasStorePersistenceSuppressed(() => withAssetStorePersistenceSuppressed(() => withUserScopedPersistenceSuppressed(() => { resetUserScopedMemory(); }))); useUserStore.getState().setUser(payload.user); useUserStore.getState().setRuntimeLimits(payload.runtimeLimits); useUserStore.getState().setDrawingEngine(payload.drawingEngine); useUserStore.getState().setFeatures(payload.features); installRemoteUserDataAutoSync(); useUserStore.getState().setHydrated(true); void hydrateUserSessionData(payload, generation).catch((error) => { if (isCurrentSession(generation, nextUserId)) console.warn("用户工作区后台初始化失败,基础页面仍可继续", error); }); } finally { if (useUserStore.getState().hydrated === false && isCurrentGeneration(generation)) useUserStore.getState().setHydrated(true); } } function resetUserScopedMemory() { useCanvasStore.setState({ projects: [], hydrated: false }); useCanvasHistoryStore.setState({ deletedProjects: [] }); useAssetStore.setState({ assets: [], hydrated: false }); useConfigStore.setState({ config: normalizeConfigSnapshot({ config: defaultConfig }).config }); usePluginStore.setState({ hydrated: false, installations: [], runtimeStatuses: {}, pluginStates: {} }); useCreationPreferencesStore.setState({ hydrated: false, preferences: {} }); } async function hydrateUserSessionData(payload: AuthSessionPayload, generation: number) { const startedAt = performance.now(); const userId = payload.user?.id || ""; const [canvas, canvasHistory, assets, plugins] = await Promise.allSettled([ localForageStorage.getItem(CANVAS_STORE_KEY), localForageStorage.getItem(CANVAS_HISTORY_STORE_KEY), localForageStorage.getItem(ASSET_STORE_KEY), localForageStorage.getItem(PLUGIN_STORE_KEY), ]); if (!isCurrentSession(generation, userId)) return; const persistedConfig = safeScopedStorageGet(CONFIG_STORE_KEY); const persistedCreationPreferences = safeScopedStorageGet(CREATION_PREFERENCES_STORE_KEY); await Promise.allSettled([ useCanvasStore.persist.rehydrate(), useCanvasHistoryStore.persist.rehydrate(), useAssetStore.persist.rehydrate(), useConfigStore.persist.rehydrate(), usePluginStore.persist.rehydrate(), useCreationPreferencesStore.persist.rehydrate(), ]); if (!isCurrentSession(generation, userId)) return; // Zustand 在目标 scope 没有快照时会保留旧内存,必须显式恢复该 scope 的空状态。 if (!hasPersistedValue(canvas)) useCanvasStore.setState({ projects: [] }); if (!hasPersistedValue(canvasHistory)) useCanvasHistoryStore.setState({ deletedProjects: [] }); if (!hasPersistedValue(assets)) useAssetStore.setState({ assets: [] }); if (!hasPersistedValue(plugins)) usePluginStore.setState({ installations: [], runtimeStatuses: {}, pluginStates: {} }); if (!persistedCreationPreferences) useCreationPreferencesStore.setState({ preferences: {} }); try { const catalog = await getModelCatalog(); if (!isCurrentSession(generation, userId)) return; if (!persistedConfig) { // 只有首次配置缺失时才生成能力推荐;已有配置中的空数组代表用户明确清空。 const initialSystemConfig = { ...defaultConfig, channels: modelCatalogChannels(catalog), imageModels: undefined, videoModels: undefined, textModels: undefined, audioModels: undefined, }; useConfigStore.getState().replaceConfig(normalizeConfigSnapshot({ config: initialSystemConfig }).config); } else { useConfigStore.getState().mergeSystemChannels(modelCatalogChannels(catalog)); } } catch (error) { if (isCurrentSession(generation, userId)) console.warn("模型目录后台刷新失败,保留本地配置", error); } if (userId) { try { await initializeRemoteUserDataSession(userId); } catch (error) { if (isCurrentSession(generation, userId)) console.warn("远端用户数据后台同步初始化失败", error); } } else { resetRemoteUserDataSync(); } if (isCurrentSession(generation, userId)) { recordDiagnosticEvent({ category: "navigation", level: "info", code: "startup.workspace_background_ready", message: "工作区后台状态初始化完成", durationMs: performance.now() - startedAt, }); } } function isCurrentSession(generation: number, userId: string) { return generation === sessionGeneration && (useUserStore.getState().user?.id || "") === userId; } function isCurrentGeneration(generation: number) { return generation === sessionGeneration; } function hasPersistedValue(result: PromiseSettledResult) { return result.status === "fulfilled" && result.value !== null && result.value !== undefined; } function safeScopedStorageGet(key: string) { try { return scopedLocalStorage.getItem(key); } catch (error) { console.warn("读取用户本地配置失败", { key, error }); return null; } } export async function refreshSystemChannels() { const catalog = await getModelCatalog(); useConfigStore.getState().mergeSystemChannels(modelCatalogChannels(catalog)); } // 目录仅接受系统渠道模型,避免畸形响应被当成空目录写入配置。 function modelCatalogChannels(catalog: ModelCatalogResponse): ModelChannel[] { if (catalog.source === "system") { if (!Array.isArray(catalog.channels)) throw new Error("模型目录响应缺少系统渠道列表"); return systemChannelModelChannels(catalog.channels); } throw new Error("模型目录响应来源无效"); } // 系统渠道模型转换为前端配置格式 export function systemChannelModelChannels(channels: PublicChannelCatalog[]): ModelChannel[] { return channels.map((channel) => { const availableModels = channel.models.filter((m) => m.available); return { id: channel.id, name: channel.displayName, sortOrder: channel.sortOrder, // 系统渠道必须走带渠道 ID 的站内代理;/api 只是业务 API 根路径, // 不能作为模型请求的运行时 Base URL 传给 channelRequest。 baseUrl: `/api/${channel.id}`, apiKey: "system", apiFormat: "openai", scope: "system" as const, enabled: true, models: availableModels.map((m) => m.modelKey), modelAliases: {}, modelCosts: availableModels.map((model) => { // 标量价格仅用于旧配置兼容;创作端按完整 SKU 档位展示和匹配价格。 const firstTier = model.priceTiers?.[0]; const unitPrice = firstTier?.unitPriceMicrocredits || 0; const inputPrice = firstTier?.inputTokenPriceMicrocredits || 0; const outputPrice = firstTier?.outputTokenPriceMicrocredits || 0; const cachedPrice = firstTier?.cachedTokenPriceMicrocredits || 0; const billingMode = firstTier?.billingMode || "fixed_request"; const logicalPriceTiers = (model.priceTiers || []).map((tier) => ({ selector: tier.selector || {}, resolution: tier.resolution || "*", videoSeconds: tier.videoSeconds || 0, billingMode: tier.billingMode as "fixed_request" | "per_second" | "token", unitPriceMicrocredits: tier.unitPriceMicrocredits || 0, inputTokenPriceMicrocredits: tier.inputTokenPriceMicrocredits || 0, outputTokenPriceMicrocredits: tier.outputTokenPriceMicrocredits || 0, cachedTokenPriceMicrocredits: tier.cachedTokenPriceMicrocredits || 0, })); return { model: model.modelKey, displayName: model.displayName, channelLabel: model.channelLabel, tags: model.tags || [], description: model.description || "", icon: model.icon || "", capability: model.capability as ModelCapability, protocol: model.protocol as any, pricePolicy: "channel" as const, billingMode: billingMode as any, unitPriceMicrocredits: unitPrice, inputTokenPriceMicrocredits: inputPrice, outputTokenPriceMicrocredits: outputPrice, cachedTokenPriceMicrocredits: cachedPrice, capabilityConfig: (model.capabilityConfig as ModelCapabilityConfig | undefined) || defaultModelCapabilityConfig(), channelModelId: model.id, channelId: channel.id, modelKey: model.modelKey, logicalPriceTiers, }; }), }; }); } export function projectLogicalCapability(spec: CapabilitySpec, defaults: Record): ModelCapabilityConfig { const projected = defaultModelCapabilityConfig(); if (spec.capability === "image" && projected.image) { projected.image.references.maxImages = spec.inputs?.image?.max ?? 0; projected.image.references.maskSupported = (spec.inputs?.mask?.max ?? 0) > 0; projected.image.size = { parameter: "none", values: [], default: "auto", allowCustom: false }; projected.image.quality = { supported: false, values: [], default: "auto" }; projected.image.transparentBackground = { supported: false, default: false }; const sizeOption = spec.options?.size || spec.options?.aspectRatio; const sizeValues = stringValues(sizeOption); const sizeAllowsCustom = sizeValues.includes("*") || Boolean(spec.imageSize?.allowCustom); const concreteSizeValues = sizeValues.filter((value) => value !== "*"); const sizePresets = concreteSizeValues.length ? concreteSizeValues : sizeAllowsCustom ? [...STANDARD_IMAGE_SIZE_VALUES] : []; if (sizePresets.length || sizeAllowsCustom || spec.imageSize?.presets?.length) { const parameter = spec.imageSize?.parameter === "aspect_ratio" || spec.imageSize?.parameter === "size" ? spec.imageSize.parameter : "size"; projected.image.size = { parameter, values: sizePresets, default: concreteDefault(defaults.size, sizePresets, "1:1"), allowCustom: sizeAllowsCustom }; if (spec.imageSize?.presets?.length) projected.image.size = imageSizeConfigWithPresets(projected.image, spec.imageSize.presets); } applyStringOption(spec.options?.quality, defaults.quality, (values, initial) => { projected.image!.quality = { supported: true, values, default: initial }; }); projected.image.maxOutputs = maxNumericOption(spec.options?.count, 1); projected.image.transparentBackground = booleanOption(spec.options?.transparentBackground, defaults.transparentBackground); } if (spec.capability === "video" && projected.video) { projected.video.references.minImages = spec.inputs?.image?.min ?? 0; projected.video.references.maxImages = spec.inputs?.image?.max ?? 0; projected.video.references.maxVideos = spec.inputs?.video?.max ?? 0; projected.video.references.maxAudios = spec.inputs?.audio?.max ?? 0; projected.video.operations = spec.operations || []; projected.video.defaultOperation = spec.operations?.[0] || ""; const duration = spec.options?.videoSeconds || spec.options?.duration; if (duration?.values?.length) projected.video.duration = { selection: "enum", values: duration.values.map(Number).filter(Number.isFinite), default: Number(defaults.videoSeconds ?? duration.values[0]) }; else if (duration?.min !== undefined && duration.max !== undefined) projected.video.duration = { selection: "range", min: duration.min, max: duration.max, step: duration.step || 1, default: Number(defaults.videoSeconds ?? duration.min) }; projected.video.ratios = stringValues(spec.options?.size || spec.options?.aspectRatio); projected.video.defaultRatio = concreteDefault(defaults.size, projected.video.ratios, ""); projected.video.resolutions = stringValues(spec.options?.vquality || spec.options?.resolution); projected.video.defaultResolution = String(defaults.vquality ?? projected.video.resolutions[0] ?? ""); projected.video.generateAudio = booleanOption(spec.options?.videoGenerateAudio, defaults.videoGenerateAudio); projected.video.watermark = booleanOption(spec.options?.videoWatermark, defaults.videoWatermark); } return projected; } function applyStringOption(option: OptionConstraint | undefined, fallback: unknown, apply: (values: string[], initial: string) => void) { const values = stringValues(option); if (values.length) apply(values, concreteDefault(fallback, values, values[0])); } function stringValues(option?: OptionConstraint) { return (option?.values || []) .map(String) .map((value) => value.trim()) .filter(Boolean); } function concreteDefault(value: unknown, values: string[], fallback: string) { const candidate = String(value ?? "").trim(); return candidate && candidate !== "*" && values.includes(candidate) ? candidate : values.find((item) => item !== "*") || fallback; } function maxNumericOption(option: OptionConstraint | undefined, fallback: number) { if (option?.max !== undefined) return option.max; const values = (option?.values || []).map(Number).filter(Number.isFinite); return values.length ? Math.max(...values) : fallback; } function booleanOption(option: OptionConstraint | undefined, fallback: unknown) { const supported = (option?.values || []).some((value) => value === true || value === "true"); return { supported, default: supported && String(fallback) === "true" }; } export async function refreshFeatureAvailability() { const payload = await getFeatureAvailability(); useUserStore.getState().setFeatures(payload.features); return payload.features; }