fix(*): 平台稳定性 - 修复会话隔离、运行时缓存与部署镜像更新链路
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 (
|
||||
|
||||
Reference in New Issue
Block a user