From 68c48960882f6dd20b74d3a9c078514e9c5f059d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=89=E9=91=AB=E5=8D=87?= Date: Mon, 17 Aug 2026 19:27:39 +0800 Subject: [PATCH] fix: stabilize auth sync and stream recovery for ji --- frontend-web/src/core/auth/index.ts | 58 +++++++++++++++++++ frontend-web/src/core/auth/sync.tsx | 16 ++--- .../src/core/auth/use-live-account.ts | 12 +++- frontend-web/src/core/threads/hooks.ts | 49 ++++++++-------- .../hooks/useCanvasThreadStream.ts | 3 +- .../llm_error_handling_middleware.py | 30 +++++----- 6 files changed, 118 insertions(+), 50 deletions(-) diff --git a/frontend-web/src/core/auth/index.ts b/frontend-web/src/core/auth/index.ts index 268e2818..36eb5542 100644 --- a/frontend-web/src/core/auth/index.ts +++ b/frontend-web/src/core/auth/index.ts @@ -45,6 +45,64 @@ export function getStoredAuth(): LoginByUsernameResponse | null { } } +function normalizeIdentityPart(value: unknown): string { + return typeof value === "string" ? value.trim().toLowerCase() : ""; +} + +function readUserInfoIdentity(): string | null { + let info: Record | null = null; + try { + const raw = authStorageGet("userInfo"); + if (raw) { + const parsed = JSON.parse(raw); + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { + info = parsed as Record; + } + } + } catch { + info = null; + } + if (!info) return null; + + const candidates = [ + ["yUserId", info.yUserId], + ["userName", info.userName], + ["userId", info.userId], + ] as const; + + for (const [prefix, value] of candidates) { + const normalized = normalizeIdentityPart(value); + if (normalized) return `${prefix}:${normalized}`; + } + + return 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. + */ +export function getAccountIdentityKey( + auth: LoginByUsernameResponse | null = getStoredAuth(), +): string | null { + if (!auth) return null; + + const email = normalizeIdentityPart(auth.email); + if (email) return `email:${email}`; + + const username = normalizeIdentityPart(auth.username); + if (username) return `username:${username}`; + + const userInfoIdentity = readUserInfoIdentity(); + if (userInfoIdentity) return `userInfo:${userInfoIdentity}`; + + const userId = normalizeIdentityPart(auth.user_id); + return userId ? `user_id:${userId}` : null; +} + export function setStoredAuth(auth: LoginByUsernameResponse) { if (!isBrowser()) { return; diff --git a/frontend-web/src/core/auth/sync.tsx b/frontend-web/src/core/auth/sync.tsx index f4c1020d..65321bd3 100644 --- a/frontend-web/src/core/auth/sync.tsx +++ b/frontend-web/src/core/auth/sync.tsx @@ -3,15 +3,15 @@ import { useEffect, useRef } from "react"; import { toast } from "sonner"; -import { getStoredAuth } from "@/core/auth"; +import { getAccountIdentityKey } from "@/core/auth"; import { isEmbedActive, isIsolatedSession } from "@/core/embed/embed-session"; const AUTH_STORAGE_KEY = "deerflow.auth"; const SETTLE_DELAY_MS = 1200; const IDENTITY_TOAST_ID = "auth-identity-changed"; -function readUserId(): string | null { - return getStoredAuth()?.user_id ?? null; +function readIdentityKey(): string | null { + return getAccountIdentityKey(); } /** @@ -19,7 +19,7 @@ function readUserId(): string | null { * tabs and lets the user decide when to refresh on account switches. */ export function AuthSyncWatcher() { - const lastUserIdRef = useRef(readUserId()); + const lastIdentityRef = useRef(readIdentityKey()); useEffect(() => { // 嵌入(iframe)态 / 独立会话态(?isolate=1):鉴权走按标签页的 sessionStorage,本就不与其它标签页 @@ -29,14 +29,14 @@ export function AuthSyncWatcher() { let settleTimer: ReturnType | null = null; function evaluateSettledIdentity() { - const nextUserId = readUserId(); - if (nextUserId === lastUserIdRef.current) { + const nextIdentity = readIdentityKey(); + if (nextIdentity === lastIdentityRef.current) { return; } - lastUserIdRef.current = nextUserId; + lastIdentityRef.current = nextIdentity; - if (nextUserId === null) { + if (nextIdentity === null) { return; } diff --git a/frontend-web/src/core/auth/use-live-account.ts b/frontend-web/src/core/auth/use-live-account.ts index e0c316b9..8cfedd8c 100644 --- a/frontend-web/src/core/auth/use-live-account.ts +++ b/frontend-web/src/core/auth/use-live-account.ts @@ -5,6 +5,7 @@ import { useEffect, useRef, useState } from "react"; import { AUTH_STATE_CHANGED_EVENT, getAccountDisplayName, + getAccountIdentityKey, getStoredAuth, } from "@/core/auth"; import { isEmbedActive } from "@/core/embed/embed-session"; @@ -36,11 +37,12 @@ export interface LiveAccount { * 账号时给出提示 —— 否则会出现「用 A 账号创建却归到 B 账号名下」。 */ export function useLiveAccount(): LiveAccount { - const mountRef = useRef<{ id: string | null; name: string } | null>(null); + const mountRef = useRef<{ id: string | null; key: string | null; name: string } | null>(null); if (mountRef.current === null) { const auth = getStoredAuth(); mountRef.current = { id: auth?.user_id ?? null, + key: getAccountIdentityKey(auth), name: getAccountDisplayName(auth), }; } @@ -50,9 +52,10 @@ export function useLiveAccount(): LiveAccount { function refresh() { const auth = getStoredAuth(); const id = auth?.user_id ?? null; + const key = getAccountIdentityKey(auth); const name = getAccountDisplayName(auth); setLive((prev) => - prev.id === id && prev.name === name ? prev : { id, name }, + prev.id === id && prev.key === key && prev.name === name ? prev : { id, key, name }, ); } // 嵌入(iframe)态鉴权走按标签页的 sessionStorage、不跨标签,收不到 storage 事件 @@ -83,6 +86,9 @@ export function useLiveAccount(): LiveAccount { displayName: live.name, mountUserId: mount.id, mountDisplayName: mount.name, - switched: live.id !== null && mount.id !== null && live.id !== mount.id, + switched: + live.key !== null && mount.key !== null + ? live.key !== mount.key + : live.id !== null && mount.id !== null && live.id !== mount.id, }; } diff --git a/frontend-web/src/core/threads/hooks.ts b/frontend-web/src/core/threads/hooks.ts index 9e616af9..5c235bb1 100644 --- a/frontend-web/src/core/threads/hooks.ts +++ b/frontend-web/src/core/threads/hooks.ts @@ -472,13 +472,11 @@ export function useThreadStream({ const lastStreamActivityAtRef = useRef(0); const streamReconciliationInFlightRef = useRef(false); const streamReconnectAttemptsRef = useRef(0); - const streamReconnectToastShownRef = useRef(false); const streamReconnectFailureToastShownRef = useRef(false); const markStreamActivity = useCallback(() => { lastStreamActivityAtRef.current = Date.now(); streamReconnectAttemptsRef.current = 0; - streamReconnectToastShownRef.current = false; streamReconnectFailureToastShownRef.current = false; }, []); @@ -650,19 +648,20 @@ export function useThreadStream({ return; } - if ( - typeof event === "object" && - event !== null && - "type" in event && - event.type === "llm_retry" && + if ( + typeof event === "object" && + event !== null && + "type" in event && + event.type === "llm_retry" && "message" in event && - typeof event.message === "string" && - event.message.trim() - ) { - const e = event as { type: "llm_retry"; message: string }; - toast(e.message); - return; - } + typeof event.message === "string" && + event.message.trim() + ) { + // The backend is already handling this with bounded retry/backoff. + // Surfacing every transient model retry as a toast made normal model + // stalls look like repeated "network fluctuation" failures. + return; + } // Context compression about to run a (blocking) summarization LLM call — // surface a transient hint so the wait before the answer streams isn't a @@ -752,7 +751,7 @@ export function useThreadStream({ const [optimisticMessages, setOptimisticMessages] = useState([]); const [isUploading, setIsUploading] = useState(false); const [threadValueOverrides, setThreadValueOverrides] = useState< - Partial + Partial >({}); const sendInFlightRef = useRef(false); // Carries the ask time of an in-flight send until the server-assigned human @@ -824,10 +823,6 @@ export function useThreadStream({ // enough time to receive its first event. A genuine event resets // the attempt counter through ``markStreamActivity``. lastStreamActivityAtRef.current = Date.now(); - if (!streamReconnectToastShownRef.current) { - streamReconnectToastShownRef.current = true; - toast.info("回答连接出现波动,正在自动恢复…"); - } const stream = threadStreamRef.current; void Promise.resolve(stream.stop()) @@ -858,12 +853,14 @@ export function useThreadStream({ storage?.removeItem(`lg:stream:${activeThreadId}`); streamReconnectAttemptsRef.current = 0; - streamReconnectToastShownRef.current = false; streamReconnectFailureToastShownRef.current = false; if (run.status === "success") { + setOptimisticMessages([]); + setThreadValueOverrides(finalState.values as Partial); listeners.current.onFinish?.(finalState.values as AgentThreadState); void queryClient.invalidateQueries({ queryKey: ["threads", "search"] }); } else { + setThreadValueOverrides(finalState.values as Partial); toast.error("本轮生成未能完成,已同步当前对话内容,可重新发起请求。"); } @@ -960,10 +957,11 @@ export function useThreadStream({ return; } - setThreadValueOverrides({ + setThreadValueOverrides((prev) => ({ + ...prev, title: nextState.title, title_provisional: nextState.title_provisional, - }); + })); if (nextState.title) { updateCachedThreadLists(queryClient, (threads) => @@ -1329,12 +1327,17 @@ export function useThreadStream({ } : thread.values; + const mergedMessages = + Array.isArray(mergedValues.messages) + ? (mergedValues.messages as Message[]) + : thread.messages; + const mergedThread = optimisticMessages.length > 0 || mergedValues !== thread.values ? ({ ...thread, values: mergedValues, - messages: [...thread.messages, ...optimisticMessages], + messages: [...mergedMessages, ...optimisticMessages], } as typeof thread) : thread; diff --git a/frontend-web/src/open-canvas/hooks/useCanvasThreadStream.ts b/frontend-web/src/open-canvas/hooks/useCanvasThreadStream.ts index 52576708..6fdae2bc 100644 --- a/frontend-web/src/open-canvas/hooks/useCanvasThreadStream.ts +++ b/frontend-web/src/open-canvas/hooks/useCanvasThreadStream.ts @@ -107,7 +107,8 @@ export function useCanvasThreadStream() { "message" in event && typeof (event as { message: unknown }).message === "string" ) { - toast((event as { message: string }).message); + // Model retry events are self-healing. Keep them off the user-facing + // toast surface so a short model stall does not look like a failure. } }, onError(error) { diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/agents/middlewares/llm_error_handling_middleware.py b/offline-backend-20260512/backend/packages/harness/deerflow/agents/middlewares/llm_error_handling_middleware.py index 2dcc8200..ead93c4c 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/agents/middlewares/llm_error_handling_middleware.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/agents/middlewares/llm_error_handling_middleware.py @@ -33,14 +33,14 @@ logger = logging.getLogger(__name__) LLM_ERROR_MARKER = "deerflow_llm_error" # 各 reason 对应的用户可见兜底文案(单一数据源:构造与识别都用它)。 -_USER_MESSAGES: dict[str, str] = { - "quota": "大模型因账户额度不足、账单异常或使用受限而拒绝请求,请检查模型账户后再试。", - "auth": "大模型因鉴权失败或无访问权限而拒绝请求,请检查模型的 API Key 与权限配置后再试。", - "busy": "大模型因服务繁忙或网络异常,多次重试后仍未恢复,请稍候再继续。", - "transient": "大模型因服务繁忙或网络异常,多次重试后仍未恢复,请稍候再继续。", - "upstream": "大模型当前响应异常(上游模型或网关临时故障),多次重试后仍未恢复,请稍后再试或更换模型。", - "generic": "大模型本次调用未能成功完成,请稍后再试或更换模型。", -} +_USER_MESSAGES: dict[str, str] = { + "quota": "大模型因账户额度不足、账单异常或使用受限而拒绝请求,请检查模型账户后再试。", + "auth": "大模型因鉴权失败或无访问权限而拒绝请求,请检查模型的 API Key 与权限配置后再试。", + "busy": "大模型服务暂时繁忙,多次自动重试后仍未恢复,请稍候再继续。", + "transient": "大模型响应暂时中断或超时,多次自动重试后仍未恢复,请稍候再继续。", + "upstream": "大模型当前响应异常(上游模型或网关临时故障),多次重试后仍未恢复,请稍后再试或更换模型。", + "generic": "大模型本次调用未能成功完成,请稍后再试或更换模型。", +} _CIRCUIT_MESSAGE = "大模型因连续多次调用失败已被熔断保护,请稍候再试。" # 全部兜底文案集合,供 ``is_llm_error_message`` 在 additional_kwargs 被剥离时按正文回退识别。 _ALL_ERROR_MESSAGES: frozenset[str] = frozenset({*_USER_MESSAGES.values(), _CIRCUIT_MESSAGE}) @@ -249,13 +249,13 @@ class LLMErrorHandlingMiddleware(AgentMiddleware[AgentState]): def _build_retry_message(self, attempt: int, wait_ms: int, reason: str) -> str: seconds = max(1, round(wait_ms / 1000)) - if reason == "busy": - reason_text = "服务繁忙" - elif reason == "upstream": - reason_text = "上游响应异常" - else: - reason_text = "网络/连接异常" - return f"大模型因{reason_text}调用失败,正在第 {attempt}/{self.retry_max_attempts} 次重试,{seconds} 秒后继续…" + if reason == "busy": + reason_text = "服务繁忙" + elif reason == "upstream": + reason_text = "上游响应异常" + else: + reason_text = "响应中断或超时" + return f"大模型因{reason_text}暂未返回,正在第 {attempt}/{self.retry_max_attempts} 次自动重试,{seconds} 秒后继续…" def _build_circuit_breaker_message(self) -> str: return _CIRCUIT_MESSAGE