feat: add fixed questions and improve authentication

This commit is contained in:
吉鑫升 committed 2026-08-21 17:58:39 +08:00
1 parent 8af4fed89d
commit 4c9f75f31e
42 files changed
+4008 -200

No files matched your search

+22 -7
View File
@@ -49,6 +49,17 @@ function normalizeIdentityPart(value: unknown): string {
return typeof value === "string" ? value.trim().toLowerCase() : "";
}
function identityFromLoginName(value: unknown): string | null {
const normalized = normalizeIdentityPart(value);
if (!normalized) return null;
// Explicit email login is domain-specific. Bare username login is the
// stable business principal even if the backend's synthetic email domain or
// internal UUID changes.
return normalized.includes("@")
? `email:${normalized}`
: `account:${normalized}`;
}
function readUserInfoIdentity(): string | null {
let info: Record<string, unknown> | null = null;
try {
@@ -81,20 +92,24 @@ function readUserInfoIdentity(): string | null {
/**
* A stable cross-tab identity key for "did the account really change?" checks.
*
* Some external login paths can re-issue a backend ``user_id`` while resolving
* to the same business account. Prefer business-facing identifiers
* (email/username/userInfo) and keep ``user_id`` only as the final fallback.
* Some external login paths can re-issue a backend ``user_id`` or use a
* different synthetic email domain while resolving to the same business
* account. Prefer the raw login username returned by the backend, then the
* email local-part, and keep ``user_id`` only as the final fallback.
*/
export function getAccountIdentityKey(
auth: LoginByUsernameResponse | null = getStoredAuth(),
): string | null {
if (!auth) return null;
const email = normalizeIdentityPart(auth.email);
if (email) return `email:${email}`;
const loginIdentity = identityFromLoginName(auth.username);
if (loginIdentity) return loginIdentity;
const username = normalizeIdentityPart(auth.username);
if (username) return `username:${username}`;
const email = normalizeIdentityPart(auth.email);
if (email) {
const localPart = email.split("@", 1)[0];
return localPart ? `account:${localPart}` : `email:${email}`;
}
const userInfoIdentity = readUserInfoIdentity();
if (userInfoIdentity) return `userInfo:${userInfoIdentity}`;
+62 -14
View File
@@ -1,9 +1,15 @@
"use client";
import { useEffect, useRef } from "react";
import { Fragment, useEffect, useRef, useState, type ReactNode } from "react";
import { useLocation, useNavigate } from "react-router-dom";
import { useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { getAccountIdentityKey } from "@/core/auth";
import {
AUTH_STATE_CHANGED_EVENT,
getAccountDisplayName,
getAccountIdentityKey,
} from "@/core/auth";
import { isEmbedActive, isIsolatedSession } from "@/core/embed/embed-session";
const AUTH_STORAGE_KEY = "deerflow.auth";
@@ -14,14 +20,37 @@ function readIdentityKey(): string | null {
return getAccountIdentityKey();
}
function freshRouteForAccount(pathname: string): string {
// A thread URL belongs to the old account. Keep its workspace/agent context
// but move to a valid new conversation before the user can submit into the
// stale thread. Other pages can stay in place and are remounted below.
const chatMatch = pathname.match(/^(.*\/chats)\/[^/]+\/?$/);
return chatMatch ? `${chatMatch[1]}/new` : pathname;
}
/**
* Mount once at the app root. Watches localStorage auth changes from other
* tabs and lets the user decide when to refresh on account switches.
* Cross-tab auth boundary for normal pages.
*
* Same-account token re-issuance is ignored. A genuine account change clears
* account-scoped query state, remounts the page, and moves an open chat to its
* fresh-chat route without a full browser reload. Embedded/isolated tabs keep
* their sessionStorage identity and never participate in this synchronization.
*/
export function AuthSyncWatcher() {
export function AuthSyncWatcher({
children,
disabled = false,
}: {
children?: ReactNode;
disabled?: boolean;
}) {
const lastIdentityRef = useRef<string | null>(readIdentityKey());
const [revision, setRevision] = useState(0);
const queryClient = useQueryClient();
const navigate = useNavigate();
const location = useLocation();
useEffect(() => {
if (disabled) return;
// 嵌入(iframe)态 / 独立会话态(?isolate=1):鉴权走按标签页的 sessionStorage,本就不与其它标签页
// 共享,且不应被其它标签页的 localStorage 账号变更打扰 —— 直接不挂跨标签监听。
if (isEmbedActive() || isIsolatedSession()) return;
@@ -36,18 +65,27 @@ export function AuthSyncWatcher() {
lastIdentityRef.current = nextIdentity;
void queryClient.cancelQueries();
queryClient.clear();
window.dispatchEvent(new Event(AUTH_STATE_CHANGED_EVENT));
setRevision((value) => value + 1);
if (nextIdentity === null) {
toast.info("账号已在其它页面退出", {
id: IDENTITY_TOAST_ID,
description: "当前页面已同步退出状态。",
});
navigate("/login", { replace: true });
return;
}
toast.warning("检测到账号在其它标签页发生变化", {
const freshRoute = freshRouteForAccount(location.pathname);
if (freshRoute !== location.pathname) {
navigate(freshRoute, { replace: true });
}
toast.info("账号已在页面间自动同步", {
id: IDENTITY_TOAST_ID,
description: "当前标签页仍是原会话。如需与新账号同步,请刷新页面。",
duration: Infinity,
action: {
label: "刷新同步",
onClick: () => window.location.reload(),
},
description: `当前账号:${getAccountDisplayName()}。无需刷新页面。`,
});
}
@@ -68,14 +106,24 @@ export function AuthSyncWatcher() {
settleTimer = setTimeout(evaluateSettledIdentity, SETTLE_DELAY_MS);
}
function handleSameTabAuthChange() {
// `storage` never fires in the tab that called setStoredAuth/clearStoredAuth.
// Keep the comparison baseline current so a later same-account token
// renewal from another normal page is not compared with the pre-login
// `null` identity and falsely classified as an account switch.
lastIdentityRef.current = readIdentityKey();
}
window.addEventListener("storage", handleStorage);
window.addEventListener(AUTH_STATE_CHANGED_EVENT, handleSameTabAuthChange);
return () => {
window.removeEventListener("storage", handleStorage);
window.removeEventListener(AUTH_STATE_CHANGED_EVENT, handleSameTabAuthChange);
if (settleTimer) {
clearTimeout(settleTimer);
}
};
}, []);
}, [disabled, location.pathname, navigate, queryClient]);
return null;
return children ? <Fragment key={revision}>{children}</Fragment> : null;
}
@@ -8,7 +8,7 @@ import {
getAccountIdentityKey,
getStoredAuth,
} from "@/core/auth";
import { isEmbedActive } from "@/core/embed/embed-session";
import { isEmbedActive, isIsolatedSession } from "@/core/embed/embed-session";
const AUTH_STORAGE_KEY = "deerflow.auth";
@@ -65,7 +65,9 @@ export function useLiveAccount(): LiveAccount {
refresh();
}
}
if (!isEmbedActive()) {
// Normal pages share localStorage identity across tabs. Embedded and
// isolated pages deliberately keep their per-tab sessionStorage identity.
if (!isEmbedActive() && !isIsolatedSession()) {
window.addEventListener("storage", onStorage);
}
window.addEventListener(AUTH_STATE_CHANGED_EVENT, refresh);
+10 -23
View File
@@ -696,17 +696,15 @@ export function useThreadStream({
}
return;
}
// A "thread not found" 404 almost always means the URL points at a
// thread from a previous login session — either this tab outlived a
// logout/re-login or the thread genuinely belongs to a different user.
// Re-frame the error around the *current* logged-in identity and bounce
// the user to a fresh chat instead of leaving them stuck on the stale
// route looking at a raw HTTP 404.
if (isThreadNotAccessibleError(message)) {
const userLabel = buildCurrentUserLabel();
toast.info(
`当前登录用户为 ${userLabel},原对话不存在或无权访问,正在为你刷新页面…`,
);
// A "thread not found" 404 means this route cannot accept another turn.
// Move to a fresh chat without a full browser reload; the old reload path
// made every click look like a crash and could loop indefinitely when a
// different tab's cookie kept overriding this tab's Bearer token.
if (isThreadNotAccessibleError(message)) {
const userLabel = buildCurrentUserLabel();
toast.info(
`当前登录用户为 ${userLabel},原对话不存在或无权访问,已为你打开新对话。`,
);
// Prefer staying inside an agent chat context when the user is on an
// agent thread URL — otherwise fall back to the global new chat.
const hash = typeof window !== "undefined" ? window.location.hash : "";
@@ -717,18 +715,7 @@ export function useThreadStream({
: iframeMatch
? "/page/workspace/chats_iframe/new"
: "/page/workspace/chats/new";
// First navigate to a safe URL so the post-reload page lands on a
// valid route. Then full-reload so TanStack Query cache, context
// providers and any other state captured under the previous user's
// identity get rebuilt against the current login.
if (typeof window !== "undefined") {
window.location.hash = target;
window.setTimeout(() => {
window.location.reload();
}, 900);
} else {
navigate(target, { replace: true });
}
navigate(target, { replace: true });
return;
}
// 上一轮还没结束又发了新问题(409)或并发达到上限(429):
@@ -13,8 +13,9 @@ import { getStoredAuth } from "@/core/auth";
* 404 just leaves the user staring at a scary error. Both the chat stream
* (``core/threads/hooks.ts``) and the AI-writing stream
* (``open-canvas/contexts/AIWritingContext.tsx``) funnel their stream errors
* through here so the UX is identical: detect the login-change 404 and prompt
* the user to refresh against the current identity instead of erroring out.
* through here so the UX is identical: detect the login-change 404 and reset
* the stale in-memory session against the current identity instead of showing
* a raw error or reloading the browser page.
*/
/** Normalize an unknown stream error into a human-readable message string. */
@@ -77,22 +78,15 @@ export function buildCurrentUserLabel(): string {
}
/**
* Re-frame a login-change 404 around the current identity and reload the page.
* Re-frame a login-change 404 around the current identity and reset its view.
*
* Shows a friendly toast naming the current login, then full-reloads after a
* short delay so the toast is visible and so TanStack Query cache / context
* providers / any state captured under the previous user get rebuilt against
* the current login. Used by streams whose session id lives in component state
* (not the URL) — a plain reload lands them back on a fresh, valid page.
* Streams whose session id lives in component state pass a local reset callback
* so they can discard the inaccessible session without a browser reload.
*/
export function notifyThreadAccessChangedAndReload(): void {
export function notifyThreadAccessChanged(onReset: () => void): void {
const userLabel = buildCurrentUserLabel();
toast.info(
`当前登录用户为 ${userLabel},原会话不存在或无权访问,正在为你刷新页面…`,
`当前登录用户为 ${userLabel},原会话不存在或无权访问,已为你重置为新会话。`,
);
if (typeof window !== "undefined") {
window.setTimeout(() => {
window.location.reload();
}, 900);
}
onReset();
}