fix: stabilize auth sync and stream recovery for ji
This commit is contained in:
1 parent
5bf08c83a6
commit
68c4896088
6 files changed
+118
-50
No files matched your search
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -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;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -650,19 +648,20 @@ export function useThreadStream({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
typeof event === "object" &&
|
typeof event === "object" &&
|
||||||
event !== null &&
|
event !== null &&
|
||||||
"type" in event &&
|
"type" in event &&
|
||||||
event.type === "llm_retry" &&
|
event.type === "llm_retry" &&
|
||||||
"message" in event &&
|
"message" in event &&
|
||||||
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
|
||||||
return;
|
// stalls look like repeated "network fluctuation" failures.
|
||||||
}
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Context compression about to run a (blocking) summarization LLM call —
|
// Context compression about to run a (blocking) summarization LLM call —
|
||||||
// surface a transient hint so the wait before the answer streams isn't a
|
// 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<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) {
|
||||||
|
|||||||
+15
-15
@@ -33,14 +33,14 @@ logger = logging.getLogger(__name__)
|
|||||||
LLM_ERROR_MARKER = "deerflow_llm_error"
|
LLM_ERROR_MARKER = "deerflow_llm_error"
|
||||||
|
|
||||||
# 各 reason 对应的用户可见兜底文案(单一数据源:构造与识别都用它)。
|
# 各 reason 对应的用户可见兜底文案(单一数据源:构造与识别都用它)。
|
||||||
_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": "大模型本次调用未能成功完成,请稍后再试或更换模型。",
|
||||||
}
|
}
|
||||||
_CIRCUIT_MESSAGE = "大模型因连续多次调用失败已被熔断保护,请稍候再试。"
|
_CIRCUIT_MESSAGE = "大模型因连续多次调用失败已被熔断保护,请稍候再试。"
|
||||||
# 全部兜底文案集合,供 ``is_llm_error_message`` 在 additional_kwargs 被剥离时按正文回退识别。
|
# 全部兜底文案集合,供 ``is_llm_error_message`` 在 additional_kwargs 被剥离时按正文回退识别。
|
||||||
_ALL_ERROR_MESSAGES: frozenset[str] = frozenset({*_USER_MESSAGES.values(), _CIRCUIT_MESSAGE})
|
_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:
|
def _build_retry_message(self, attempt: int, wait_ms: int, reason: str) -> str:
|
||||||
seconds = max(1, round(wait_ms / 1000))
|
seconds = max(1, round(wait_ms / 1000))
|
||||||
if reason == "busy":
|
if reason == "busy":
|
||||||
reason_text = "服务繁忙"
|
reason_text = "服务繁忙"
|
||||||
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
|
||||||
|
|||||||
Reference in new issue
Block a user