fix: stabilize auth sync and stream recovery for ji

This commit is contained in:
吉鑫升 committed 2026-08-17 19:27:39 +08:00
1 parent 5bf08c83a6
commit 68c4896088
6 files changed
+97 -29

No files matched your search

+58
View File
@@ -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<string, unknown> | 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<string, unknown>;
}
}
} 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) { export function setStoredAuth(auth: LoginByUsernameResponse) {
if (!isBrowser()) { if (!isBrowser()) {
return; return;
+8 -8
View File
@@ -3,15 +3,15 @@
import { useEffect, useRef } from "react"; import { useEffect, useRef } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { getStoredAuth } from "@/core/auth"; import { getAccountIdentityKey } from "@/core/auth";
import { isEmbedActive, isIsolatedSession } from "@/core/embed/embed-session"; import { isEmbedActive, isIsolatedSession } from "@/core/embed/embed-session";
const AUTH_STORAGE_KEY = "deerflow.auth"; const AUTH_STORAGE_KEY = "deerflow.auth";
const SETTLE_DELAY_MS = 1200; const SETTLE_DELAY_MS = 1200;
const IDENTITY_TOAST_ID = "auth-identity-changed"; const IDENTITY_TOAST_ID = "auth-identity-changed";
function readUserId(): string | null { function readIdentityKey(): string | null {
return getStoredAuth()?.user_id ?? null; return getAccountIdentityKey();
} }
/** /**
@@ -19,7 +19,7 @@ function readUserId(): string | null {
* tabs and lets the user decide when to refresh on account switches. * tabs and lets the user decide when to refresh on account switches.
*/ */
export function AuthSyncWatcher() { export function AuthSyncWatcher() {
const lastUserIdRef = useRef<string | null>(readUserId()); const lastIdentityRef = useRef<string | null>(readIdentityKey());
useEffect(() => { useEffect(() => {
// 嵌入(iframe)态 / 独立会话态(?isolate=1):鉴权走按标签页的 sessionStorage,本就不与其它标签页 // 嵌入(iframe)态 / 独立会话态(?isolate=1):鉴权走按标签页的 sessionStorage,本就不与其它标签页
@@ -29,14 +29,14 @@ export function AuthSyncWatcher() {
let settleTimer: ReturnType<typeof setTimeout> | null = null; let settleTimer: ReturnType<typeof setTimeout> | null = null;
function evaluateSettledIdentity() { function evaluateSettledIdentity() {
const nextUserId = readUserId(); const nextIdentity = readIdentityKey();
if (nextUserId === lastUserIdRef.current) { if (nextIdentity === lastIdentityRef.current) {
return; return;
} }
lastUserIdRef.current = nextUserId; lastIdentityRef.current = nextIdentity;
if (nextUserId === null) { if (nextIdentity === null) {
return; return;
} }
@@ -5,6 +5,7 @@ import { useEffect, useRef, useState } from "react";
import { import {
AUTH_STATE_CHANGED_EVENT, AUTH_STATE_CHANGED_EVENT,
getAccountDisplayName, getAccountDisplayName,
getAccountIdentityKey,
getStoredAuth, getStoredAuth,
} from "@/core/auth"; } from "@/core/auth";
import { isEmbedActive } from "@/core/embed/embed-session"; import { isEmbedActive } from "@/core/embed/embed-session";
@@ -36,11 +37,12 @@ export interface LiveAccount {
* 账号时给出提示 —— 否则会出现「用 A 账号创建却归到 B 账号名下」。 * 账号时给出提示 —— 否则会出现「用 A 账号创建却归到 B 账号名下」。
*/ */
export function useLiveAccount(): LiveAccount { 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) { if (mountRef.current === null) {
const auth = getStoredAuth(); const auth = getStoredAuth();
mountRef.current = { mountRef.current = {
id: auth?.user_id ?? null, id: auth?.user_id ?? null,
key: getAccountIdentityKey(auth),
name: getAccountDisplayName(auth), name: getAccountDisplayName(auth),
}; };
} }
@@ -50,9 +52,10 @@ export function useLiveAccount(): LiveAccount {
function refresh() { function refresh() {
const auth = getStoredAuth(); const auth = getStoredAuth();
const id = auth?.user_id ?? null; const id = auth?.user_id ?? null;
const key = getAccountIdentityKey(auth);
const name = getAccountDisplayName(auth); const name = getAccountDisplayName(auth);
setLive((prev) => 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 事件 // 嵌入(iframe)态鉴权走按标签页的 sessionStorage、不跨标签,收不到 storage 事件
@@ -83,6 +86,9 @@ export function useLiveAccount(): LiveAccount {
displayName: live.name, displayName: live.name,
mountUserId: mount.id, mountUserId: mount.id,
mountDisplayName: mount.name, 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,
}; };
} }
+16 -13
View File
@@ -472,13 +472,11 @@ export function useThreadStream({
const lastStreamActivityAtRef = useRef(0); const lastStreamActivityAtRef = useRef(0);
const streamReconciliationInFlightRef = useRef(false); const streamReconciliationInFlightRef = useRef(false);
const streamReconnectAttemptsRef = useRef(0); const streamReconnectAttemptsRef = useRef(0);
const streamReconnectToastShownRef = useRef(false);
const streamReconnectFailureToastShownRef = useRef(false); const streamReconnectFailureToastShownRef = useRef(false);
const markStreamActivity = useCallback(() => { const markStreamActivity = useCallback(() => {
lastStreamActivityAtRef.current = Date.now(); lastStreamActivityAtRef.current = Date.now();
streamReconnectAttemptsRef.current = 0; streamReconnectAttemptsRef.current = 0;
streamReconnectToastShownRef.current = false;
streamReconnectFailureToastShownRef.current = false; streamReconnectFailureToastShownRef.current = false;
}, []); }, []);
@@ -659,8 +657,9 @@ export function useThreadStream({
typeof event.message === "string" && typeof event.message === "string" &&
event.message.trim() event.message.trim()
) { ) {
const e = event as { type: "llm_retry"; message: string }; // The backend is already handling this with bounded retry/backoff.
toast(e.message); // Surfacing every transient model retry as a toast made normal model
// stalls look like repeated "network fluctuation" failures.
return; return;
} }
@@ -752,7 +751,7 @@ export function useThreadStream({
const [optimisticMessages, setOptimisticMessages] = useState<Message[]>([]); const [optimisticMessages, setOptimisticMessages] = useState<Message[]>([]);
const [isUploading, setIsUploading] = useState(false); const [isUploading, setIsUploading] = useState(false);
const [threadValueOverrides, setThreadValueOverrides] = useState< const [threadValueOverrides, setThreadValueOverrides] = useState<
Partial<ThreadTitleRefreshState> Partial<AgentThreadState>
>({}); >({});
const sendInFlightRef = useRef(false); const sendInFlightRef = useRef(false);
// Carries the ask time of an in-flight send until the server-assigned human // 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 // enough time to receive its first event. A genuine event resets
// the attempt counter through ``markStreamActivity``. // the attempt counter through ``markStreamActivity``.
lastStreamActivityAtRef.current = Date.now(); lastStreamActivityAtRef.current = Date.now();
if (!streamReconnectToastShownRef.current) {
streamReconnectToastShownRef.current = true;
toast.info("回答连接出现波动,正在自动恢复…");
}
const stream = threadStreamRef.current; const stream = threadStreamRef.current;
void Promise.resolve(stream.stop()) void Promise.resolve(stream.stop())
@@ -858,12 +853,14 @@ export function useThreadStream({
storage?.removeItem(`lg:stream:${activeThreadId}`); storage?.removeItem(`lg:stream:${activeThreadId}`);
streamReconnectAttemptsRef.current = 0; streamReconnectAttemptsRef.current = 0;
streamReconnectToastShownRef.current = false;
streamReconnectFailureToastShownRef.current = false; streamReconnectFailureToastShownRef.current = false;
if (run.status === "success") { if (run.status === "success") {
setOptimisticMessages([]);
setThreadValueOverrides(finalState.values as Partial<AgentThreadState>);
listeners.current.onFinish?.(finalState.values as AgentThreadState); listeners.current.onFinish?.(finalState.values as AgentThreadState);
void queryClient.invalidateQueries({ queryKey: ["threads", "search"] }); void queryClient.invalidateQueries({ queryKey: ["threads", "search"] });
} else { } else {
setThreadValueOverrides(finalState.values as Partial<AgentThreadState>);
toast.error("本轮生成未能完成,已同步当前对话内容,可重新发起请求。"); toast.error("本轮生成未能完成,已同步当前对话内容,可重新发起请求。");
} }
@@ -960,10 +957,11 @@ export function useThreadStream({
return; return;
} }
setThreadValueOverrides({ setThreadValueOverrides((prev) => ({
...prev,
title: nextState.title, title: nextState.title,
title_provisional: nextState.title_provisional, title_provisional: nextState.title_provisional,
}); }));
if (nextState.title) { if (nextState.title) {
updateCachedThreadLists(queryClient, (threads) => updateCachedThreadLists(queryClient, (threads) =>
@@ -1329,12 +1327,17 @@ export function useThreadStream({
} }
: thread.values; : thread.values;
const mergedMessages =
Array.isArray(mergedValues.messages)
? (mergedValues.messages as Message[])
: thread.messages;
const mergedThread = const mergedThread =
optimisticMessages.length > 0 || mergedValues !== thread.values optimisticMessages.length > 0 || mergedValues !== thread.values
? ({ ? ({
...thread, ...thread,
values: mergedValues, values: mergedValues,
messages: [...thread.messages, ...optimisticMessages], messages: [...mergedMessages, ...optimisticMessages],
} as typeof thread) } as typeof thread)
: thread; : thread;
@@ -107,7 +107,8 @@ export function useCanvasThreadStream() {
"message" in event && "message" in event &&
typeof (event as { message: unknown }).message === "string" 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) { onError(error) {
@@ -36,8 +36,8 @@ LLM_ERROR_MARKER = "deerflow_llm_error"
_USER_MESSAGES: dict[str, str] = { _USER_MESSAGES: dict[str, str] = {
"quota": "大模型因账户额度不足、账单异常或使用受限而拒绝请求,请检查模型账户后再试。", "quota": "大模型因账户额度不足、账单异常或使用受限而拒绝请求,请检查模型账户后再试。",
"auth": "大模型因鉴权失败或无访问权限而拒绝请求,请检查模型的 API Key 与权限配置后再试。", "auth": "大模型因鉴权失败或无访问权限而拒绝请求,请检查模型的 API Key 与权限配置后再试。",
"busy": "大模型因服务繁忙或网络异常,多次重试后仍未恢复,请稍候再继续。", "busy": "大模型服务暂时繁忙,多次自动重试后仍未恢复,请稍候再继续。",
"transient": "大模型因服务繁忙或网络异常,多次重试后仍未恢复,请稍候再继续。", "transient": "大模型响应暂时中断或超时,多次自动重试后仍未恢复,请稍候再继续。",
"upstream": "大模型当前响应异常(上游模型或网关临时故障),多次重试后仍未恢复,请稍后再试或更换模型。", "upstream": "大模型当前响应异常(上游模型或网关临时故障),多次重试后仍未恢复,请稍后再试或更换模型。",
"generic": "大模型本次调用未能成功完成,请稍后再试或更换模型。", "generic": "大模型本次调用未能成功完成,请稍后再试或更换模型。",
} }
@@ -254,8 +254,8 @@ class LLMErrorHandlingMiddleware(AgentMiddleware[AgentState]):
elif reason == "upstream": elif reason == "upstream":
reason_text = "上游响应异常" reason_text = "上游响应异常"
else: else:
reason_text = "网络/连接异常" reason_text = "响应中断或超时"
return f"大模型因{reason_text}调用失败,正在第 {attempt}/{self.retry_max_attempts} 次重试,{seconds} 秒后继续…" return f"大模型因{reason_text}暂未返回,正在第 {attempt}/{self.retry_max_attempts} 次自动重试,{seconds} 秒后继续…"
def _build_circuit_breaker_message(self) -> str: def _build_circuit_breaker_message(self) -> str:
return _CIRCUIT_MESSAGE return _CIRCUIT_MESSAGE