fix(*): 平台稳定性 - 修复会话隔离、运行时缓存与部署镜像更新链路

This commit is contained in:
ddcat666
2026-09-24 06:05:04 +08:00
parent 10c8e19d94
commit a79b54ada5
32 changed files with 788 additions and 116 deletions

View File

@@ -7,6 +7,7 @@ import "streamdown/styles.css";
type AIMessageMarkdownProps = {
children: string;
isStreaming?: boolean;
streamingAnimation?: "word" | "char" | "none";
className?: string;
};
@@ -36,7 +37,7 @@ function buildComponents(isStreaming: boolean): Components {
const staticComponents = buildComponents(false);
const streamingComponents = buildComponents(true);
export const AIMessageMarkdown = memo(function AIMessageMarkdown({ children, isStreaming = false, className = "" }: AIMessageMarkdownProps) {
export const AIMessageMarkdown = memo(function AIMessageMarkdown({ children, isStreaming = false, streamingAnimation = "word", className = "" }: AIMessageMarkdownProps) {
if (!children.trim()) return null;
return (
<Streamdown
@@ -44,7 +45,7 @@ export const AIMessageMarkdown = memo(function AIMessageMarkdown({ children, isS
mode="streaming"
dir="auto"
isAnimating={isStreaming}
animated={isStreaming ? { animation: "fadeIn", duration: 140, sep: "word", stagger: 8 } : false}
animated={isStreaming && streamingAnimation !== "none" ? { animation: "fadeIn", duration: streamingAnimation === "char" ? 120 : 140, sep: streamingAnimation, stagger: streamingAnimation === "char" ? 16 : 8 } : false}
parseIncompleteMarkdown
skipHtml
lineNumbers={false}

View File

@@ -5,27 +5,60 @@ import { getAuthSession, type AuthSessionPayload } from "@/services/api/auth";
import { FullScreenLoader } from "@/components/ui/aceternity/full-screen-loader";
import { preloadWorkspaceRoute } from "@/lib/workspace-route-modules";
import { useUserStore } from "@/stores/use-user-store";
import { recordDiagnosticEvent } from "@/services/diagnostics/client-diagnostics";
export function AuthSessionHydrator({ children }: { children: ReactNode }) {
const hydrated = useUserStore((state) => state.hydrated);
useEffect(() => {
let cancelled = false;
const startedAt = performance.now();
recordDiagnosticEvent({ category: "navigation", level: "info", code: "startup.auth_session_started", message: "开始恢复认证会话" });
getAuthSession()
.then(async (payload) => {
if (cancelled) return;
recordDiagnosticEvent({
category: "navigation",
level: "info",
code: "startup.auth_session_ready",
message: payload.user ? "认证会话已恢复" : "匿名会话已确认",
durationMs: performance.now() - startedAt,
});
if (!payload.user) {
applyAnonymousSession(payload);
recordDiagnosticEvent({
category: "navigation",
level: "info",
code: "startup.anonymous_ready",
message: "匿名页面已解除启动阻塞",
durationMs: performance.now() - startedAt,
});
return;
}
// 账号数据、画布和素材持久化只属于已登录工作区,登录页不下载这些模块。
const { applyUserSession } = await import("@/lib/user-session");
if (cancelled) return;
await applyUserSession(payload);
recordDiagnosticEvent({
category: "navigation",
level: "info",
code: "startup.workspace_ready",
message: "登录工作区已解除启动阻塞",
durationMs: performance.now() - startedAt,
});
preloadWorkspaceRoute(window.location.pathname);
})
.catch(() => {
if (!cancelled) applyAnonymousSession({ user: null, logicalModels: [] });
if (!cancelled) {
applyAnonymousSession({ user: null });
recordDiagnosticEvent({
category: "navigation",
level: "warning",
code: "startup.auth_session_failed",
message: "认证会话恢复失败,已降级为匿名页面",
durationMs: performance.now() - startedAt,
});
}
});
return () => {
cancelled = true;

View File

@@ -100,6 +100,7 @@ export function AgentChatMessage({
}) {
const isUser = item.role === "user";
const isSystem = item.role === "system";
const displayedText = useTypewriterText(item.text, item.role === "assistant" && isStreaming);
const color = item.role === "error" ? "#ef4444" : theme.node.text;
if (item.reasoning) {
return (
@@ -183,8 +184,8 @@ export function AgentChatMessage({
</span>
) : null}
{item.role === "assistant" ? (
<AIMessageMarkdown className="text-left" isStreaming={isStreaming}>
{item.text}
<AIMessageMarkdown className="text-left" isStreaming={isStreaming} streamingAnimation="none">
{displayedText}
</AIMessageMarkdown>
) : (
<AgentMessageText text={item.text} references={references} />
@@ -196,6 +197,71 @@ export function AgentChatMessage({
);
}
/**
* Agent SSE events contain text chunks. Keep the full text in the message
* state, but reveal one code point at a time so a chunk never appears as a
* whole block. The loop continues briefly after the stream ends to drain any
* text that was buffered by the network.
*/
function useTypewriterText(targetText: string, shouldAnimate: boolean) {
const targetRef = useRef(targetText);
const visibleRef = useRef(shouldAnimate ? "" : targetText);
const hasAnimatedRef = useRef(shouldAnimate);
const runningRef = useRef(false);
const timerRef = useRef<number | null>(null);
const [visibleText, setVisibleText] = useState(visibleRef.current);
targetRef.current = targetText;
const startLoop = useCallback(() => {
if (runningRef.current) return;
runningRef.current = true;
const step = () => {
const target = targetRef.current;
const targetCharacters = Array.from(target);
const visibleCharacters = Array.from(visibleRef.current);
const visibleIsPrefix = target.startsWith(visibleRef.current);
if (!visibleIsPrefix || visibleCharacters.length > targetCharacters.length) {
visibleRef.current = "";
setVisibleText("");
}
const currentCharacters = Array.from(visibleRef.current);
if (currentCharacters.length >= targetCharacters.length) {
runningRef.current = false;
timerRef.current = null;
return;
}
const nextText = targetCharacters.slice(0, currentCharacters.length + 1).join("");
visibleRef.current = nextText;
setVisibleText(nextText);
timerRef.current = window.setTimeout(step, 16);
};
step();
}, []);
useEffect(() => {
if (shouldAnimate) hasAnimatedRef.current = true;
if (!hasAnimatedRef.current && !shouldAnimate) {
visibleRef.current = targetText;
setVisibleText(targetText);
return;
}
startLoop();
}, [shouldAnimate, startLoop, targetText]);
useEffect(() => () => {
if (timerRef.current !== null) window.clearTimeout(timerRef.current);
runningRef.current = false;
}, []);
return visibleText;
}
function AgentMessageText({ text, references }: { text: string; references: CanvasResourceReference[] }) {
const parts = splitAgentMessageMentions(text, references);
return (

View File

@@ -19,7 +19,7 @@ export function useWorkspaceLogout() {
setLoggingOut(true);
try {
await logout();
await applyUserSession({ user: null, logicalModels: [] });
await applyUserSession({ user: null });
message.success("已退出登录");
navigate("/login", { replace: true });
} catch (error) {

View File

@@ -1,7 +1,7 @@
import localforage from "localforage";
import type { StateStorage } from "zustand/middleware";
import { scopedStorageKey } from "@/lib/user-scope";
import { isUserScopedPersistenceSuppressed, scopedStorageKey } from "@/lib/user-scope";
localforage.config({
name: "infinite-canvas",
@@ -24,10 +24,12 @@ export function localForageStorageForScope(scope?: string): StateStorage {
},
setItem: async (name, value) => {
if (!browserStorageAvailable()) return;
if (isUserScopedPersistenceSuppressed()) return;
await localforage.setItem(keyFor(name), value);
},
removeItem: async (name) => {
if (!browserStorageAvailable()) return;
if (isUserScopedPersistenceSuppressed()) return;
await localforage.removeItem(keyFor(name));
},
};

View File

@@ -1,5 +1,19 @@
const ACTIVE_USER_SCOPE_KEY = "infinite-canvas:active-user-scope";
const GUEST_SCOPE = "guest";
let userScopedPersistenceSuppressionDepth = 0;
export function withUserScopedPersistenceSuppressed<T>(operation: () => T) {
userScopedPersistenceSuppressionDepth += 1;
try {
return operation();
} finally {
userScopedPersistenceSuppressionDepth -= 1;
}
}
export function isUserScopedPersistenceSuppressed() {
return userScopedPersistenceSuppressionDepth > 0;
}
export function getActiveUserScope() {
if (typeof window === "undefined") return GUEST_SCOPE;
@@ -22,10 +36,12 @@ export const scopedLocalStorage = {
},
setItem: (name: string, value: string) => {
if (typeof window === "undefined") return;
if (isUserScopedPersistenceSuppressed()) return;
window.localStorage.setItem(scopedStorageKey(name), value);
},
removeItem: (name: string) => {
if (typeof window === "undefined") return;
if (isUserScopedPersistenceSuppressed()) return;
window.localStorage.removeItem(scopedStorageKey(name));
},
};

View File

@@ -2,10 +2,10 @@ import { getFeatureAvailability, type AuthSessionPayload } from "@/services/api/
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 } from "@/lib/user-scope";
import { CANVAS_STORE_KEY, flushCanvasStorePersistence, useCanvasStore } from "@/stores/canvas/use-canvas-store";
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 } from "@/stores/use-asset-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";
@@ -16,6 +16,7 @@ import { initializeRemoteUserDataSession, installRemoteUserDataAutoSync, resetRe
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 () => {
@@ -29,7 +30,10 @@ export async function switchUserStorageScope(userId?: string | null) {
});
}
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);
@@ -37,36 +41,68 @@ export async function applyUserSession(payload: AuthSessionPayload) {
// Query key 不携带用户 ID;身份变化时必须取消并清空旧账号请求,避免跨账号复用内存数据。
if (previousUserId !== nextUserId) appQueryClient.clear();
await switchUserStorageScope(payload.user?.id);
const [persistedCanvas, persistedCanvasHistory, persistedAssets, persistedPlugins] = await Promise.all([
localForageStorage.getItem(CANVAS_STORE_KEY),
localForageStorage.getItem(CANVAS_HISTORY_STORE_KEY),
localForageStorage.getItem(ASSET_STORE_KEY),
localForageStorage.getItem(PLUGIN_STORE_KEY),
]);
const persistedConfig = scopedLocalStorage.getItem(CONFIG_STORE_KEY);
const persistedCreationPreferences = scopedLocalStorage.getItem(CREATION_PREFERENCES_STORE_KEY);
usePluginStore.setState({ hydrated: false, runtimeStatuses: {}, pluginStates: {} });
// 切换存储 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);
await Promise.all([
useCanvasStore.persist.rehydrate(),
useCanvasHistoryStore.persist.rehydrate(),
useAssetStore.persist.rehydrate(),
useConfigStore.persist.rehydrate(),
usePluginStore.persist.rehydrate(),
useCreationPreferencesStore.persist.rehydrate(),
]);
// Zustand 在目标 scope 没有快照时会保留旧内存,必须显式恢复该 scope 的空状态。
if (!persistedCanvas) useCanvasStore.setState({ projects: [] });
if (!persistedCanvasHistory) useCanvasHistoryStore.setState({ deletedProjects: [] });
if (!persistedAssets) useAssetStore.setState({ assets: [] });
if (!persistedPlugins) usePluginStore.setState({ installations: [], runtimeStatuses: {}, pluginStates: {} });
if (!persistedCreationPreferences) useCreationPreferencesStore.setState({ preferences: {} });
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 catalog = await getModelCatalog();
const initialSystemConfig = {
...defaultConfig,
channels: modelCatalogChannels(catalog),
@@ -77,15 +113,50 @@ export async function applyUserSession(payload: AuthSessionPayload) {
};
useConfigStore.getState().replaceConfig(normalizeConfigSnapshot({ config: initialSystemConfig }).config);
} else {
const catalog = await getModelCatalog();
useConfigStore.getState().mergeSystemChannels(modelCatalogChannels(catalog));
}
installRemoteUserDataAutoSync();
if (payload.user?.id) {
await initializeRemoteUserDataSession(payload.user.id);
} else resetRemoteUserDataSync();
} finally {
useUserStore.getState().setHydrated(true);
} 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;
}
}

View File

@@ -11,5 +11,6 @@ if (/^\/welcome\/?$/.test(window.location.pathname)) void import("./welcome-appl
else {
// The backend-free DEV lab must not make requests before AppProviders isolates it.
const appearanceReady = isIsolatedDirectorRepro(import.meta.env.DEV, window.location.pathname) ? Promise.resolve() : bootstrapAppearance();
void appearanceReady.then(() => import("./application"));
void import("./application");
void appearanceReady.catch(() => undefined);
}

View File

@@ -4,7 +4,6 @@ import type { GenerationTask, TaskStatus } from "@/services/api/task-center";
import type { CanvasDrawingEngineSetting } from "@/lib/canvas/canvas-drawing-engine";
import type { FeatureAvailability } from "@/stores/use-user-store";
import { http, apiBaseURL } from "@/services/api/request";
import type { PublicLogicalModel } from "@/services/api/logical-models";
import type { OSSConnectionTestInput, OSSConnectionTestResult, OSSProvider, S3Preset } from "@/lib/oss-settings";
import type { VerificationPolicy } from "./verification";
@@ -42,7 +41,6 @@ export type AdminUser = LocalUser & {
export type AuthSessionPayload = {
user: LocalUser | null;
logicalModels?: PublicLogicalModel[];
runtimeLimits?: RuntimeLimits;
drawingEngine?: CanvasDrawingEngineSetting;
features?: FeatureAvailability;

View File

@@ -111,8 +111,9 @@ export function initializeClientDiagnostics() {
export function setDiagnosticUserScope(userId: string) {
const next = userId.trim();
if (next === scopedUserId) return;
const preserveStartupEvents = !scopedUserId && Boolean(next);
scopedUserId = next;
events.length = 0;
if (!preserveStartupEvents) events.length = 0;
activeTraceId = createDiagnosticId("trace");
}
@@ -153,6 +154,7 @@ export function getDiagnosticRuntime() {
return {
appVersion: String(import.meta.env.VITE_APP_VERSION || "dev"),
buildCommit: String(import.meta.env.VITE_BUILD_COMMIT || "unknown"),
buildTime: String(import.meta.env.VITE_BUILD_TIME || "unknown"),
browser: typeof navigator !== "undefined" ? navigator.userAgent : "",
os: typeof navigator !== "undefined" ? navigator.platform : "",
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone || "",

View File

@@ -10,6 +10,7 @@ export type DiagnosticExportInput = {
runtime: {
appVersion?: string;
buildCommit?: string;
buildTime?: string;
browser?: string;
os?: string;
timezone?: string;

View File

@@ -96,7 +96,7 @@ function recordAssetStorageDocument(scope: string, document: AssetStorageDocumen
});
}
function withAssetStorePersistenceSuppressed<T>(operation: () => T) {
export function withAssetStorePersistenceSuppressed<T>(operation: () => T) {
suppressAssetStorePersistence += 1;
try {
return operation();

View File

@@ -2,3 +2,9 @@
declare const __APP_VERSION__: string;
declare const __APP_CHANGELOG__: string;
interface ImportMetaEnv {
readonly VITE_APP_VERSION?: string;
readonly VITE_BUILD_COMMIT?: string;
readonly VITE_BUILD_TIME?: string;
}