Files
ttstd-infinite-canvas/web/src/lib/user-session.ts

316 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<unknown>) {
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<string, unknown>): 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;
}