mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-09-09 13:39:26 +00:00
fix(frontend): surface model loading failures (#4840)
* fix(frontend): surface model loading failures * refactor(frontend): reuse model error UI primitives * fix(frontend): address model banner review feedback * refactor(frontend): remove unused model fetch state
This commit is contained in:
parent
f0276c9f5a
commit
7e4996eef3
@ -5,6 +5,7 @@ import { QueryClientProvider } from "@/components/query-client-provider";
|
||||
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
|
||||
import { CommandPalette } from "@/components/workspace/command-palette";
|
||||
import { GatewayOfflineBanner } from "@/components/workspace/gateway-offline-banner";
|
||||
import { ModelLoadErrorBanner } from "@/components/workspace/model-load-error-banner";
|
||||
import { SettingsDialogHost } from "@/components/workspace/settings";
|
||||
import { WorkspaceSettingsDeepLink } from "@/components/workspace/workspace-settings-deep-link";
|
||||
import { WorkspaceSidebar } from "@/components/workspace/workspace-sidebar";
|
||||
@ -35,6 +36,7 @@ export async function WorkspaceContent({
|
||||
<WorkspaceSidebar />
|
||||
<SidebarInset className="min-w-0">
|
||||
<GatewayOfflineBanner gatewayUnavailable={gatewayUnavailable} />
|
||||
<ModelLoadErrorBanner gatewayUnavailable={gatewayUnavailable} />
|
||||
{children}
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
|
||||
@ -0,0 +1,73 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { UnauthorizedError } from "@/core/api/errors";
|
||||
import { useAuth } from "@/core/auth/AuthProvider";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { useModels } from "@/core/models/hooks";
|
||||
|
||||
import { shouldShowOfflineBanner } from "./gateway-offline-banner-helpers";
|
||||
|
||||
interface ModelLoadErrorBannerProps {
|
||||
gatewayUnavailable?: boolean;
|
||||
}
|
||||
|
||||
export function ModelLoadErrorBanner({
|
||||
gatewayUnavailable = false,
|
||||
}: ModelLoadErrorBannerProps) {
|
||||
const { t } = useI18n();
|
||||
const { user } = useAuth();
|
||||
const [isRetrying, setIsRetrying] = useState(false);
|
||||
// Observe the shared query without starting it. Model consumers remain in
|
||||
// charge of loading; this single observer only centralizes their feedback.
|
||||
const { error, refetch } = useModels({ enabled: false });
|
||||
|
||||
const retry = async () => {
|
||||
setIsRetrying(true);
|
||||
try {
|
||||
await refetch();
|
||||
} finally {
|
||||
setIsRetrying(false);
|
||||
}
|
||||
};
|
||||
|
||||
// The shared fetcher has already started a login redirect for this error.
|
||||
// Rendering a model-specific warning during navigation would be duplicate
|
||||
// and misleading feedback.
|
||||
if (
|
||||
(!error && !isRetrying) ||
|
||||
error instanceof UnauthorizedError ||
|
||||
shouldShowOfflineBanner(user, gatewayUnavailable)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Alert
|
||||
variant="destructive"
|
||||
className="border-destructive/20 bg-destructive/10 rounded-none border-x-0 border-t-0 px-4 py-2"
|
||||
>
|
||||
<AlertDescription className="text-destructive flex w-full items-center justify-between gap-3">
|
||||
<span className="min-w-0">{t.workspace.modelLoadFailed}</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={isRetrying}
|
||||
aria-busy={isRetrying}
|
||||
onClick={() => {
|
||||
void retry();
|
||||
}}
|
||||
className="border-destructive/30 text-destructive hover:bg-destructive/10 hover:text-destructive dark:hover:bg-destructive/10 h-7 bg-transparent px-3 text-xs shadow-none dark:bg-transparent"
|
||||
>
|
||||
{isRetrying
|
||||
? t.workspace.modelLoadRetrying
|
||||
: t.workspace.modelLoadRetry}
|
||||
</Button>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
@ -1,3 +1,16 @@
|
||||
/**
|
||||
* Raised after the shared fetcher has started a login redirect for a 401.
|
||||
*
|
||||
* Callers may use this type to avoid showing a second, misleading API error
|
||||
* while the browser is already navigating to the authentication flow.
|
||||
*/
|
||||
export class UnauthorizedError extends Error {
|
||||
constructor() {
|
||||
super("Unauthorized");
|
||||
this.name = "UnauthorizedError";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Throw an Error from a failed Gateway REST response.
|
||||
*
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
import { buildLoginUrl } from "@/core/auth/types";
|
||||
|
||||
import { UnauthorizedError } from "./errors";
|
||||
|
||||
/** HTTP methods that the gateway's CSRFMiddleware checks. */
|
||||
export type StateChangingMethod = "POST" | "PUT" | "DELETE" | "PATCH";
|
||||
|
||||
@ -82,7 +84,7 @@ export async function fetch(
|
||||
|
||||
if (res.status === 401) {
|
||||
window.location.href = buildLoginUrl(window.location.pathname);
|
||||
throw new Error("Unauthorized");
|
||||
throw new UnauthorizedError();
|
||||
}
|
||||
|
||||
return res;
|
||||
|
||||
@ -528,6 +528,10 @@ export const enUS: Translations = {
|
||||
logout: "Log out",
|
||||
gatewayUnavailable: "Gateway is temporarily unavailable.",
|
||||
gatewayUnavailableRetrying: "Retrying in the background…",
|
||||
modelLoadFailed:
|
||||
"Models couldn't be loaded. Model selection and token usage may be unavailable.",
|
||||
modelLoadRetry: "Retry",
|
||||
modelLoadRetrying: "Retrying…",
|
||||
},
|
||||
|
||||
// Conversation
|
||||
|
||||
@ -421,6 +421,9 @@ export interface Translations {
|
||||
logout: string;
|
||||
gatewayUnavailable: string;
|
||||
gatewayUnavailableRetrying: string;
|
||||
modelLoadFailed: string;
|
||||
modelLoadRetry: string;
|
||||
modelLoadRetrying: string;
|
||||
};
|
||||
|
||||
// Conversation
|
||||
|
||||
@ -503,6 +503,10 @@ export const zhCN: Translations = {
|
||||
logout: "退出登录",
|
||||
gatewayUnavailable: "网关暂时不可用。",
|
||||
gatewayUnavailableRetrying: "正在后台重试…",
|
||||
modelLoadFailed:
|
||||
"模型列表加载失败,模型选择和 Token 用量信息可能暂时不可用。",
|
||||
modelLoadRetry: "重试",
|
||||
modelLoadRetrying: "正在重试…",
|
||||
},
|
||||
|
||||
// Conversation
|
||||
|
||||
@ -2,9 +2,11 @@ import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { loadModels } from "./api";
|
||||
|
||||
export const MODELS_QUERY_KEY = ["models"] as const;
|
||||
|
||||
export function useModels({ enabled = true }: { enabled?: boolean } = {}) {
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ["models"],
|
||||
const { data, isLoading, error, refetch } = useQuery({
|
||||
queryKey: MODELS_QUERY_KEY,
|
||||
queryFn: () => loadModels(),
|
||||
enabled,
|
||||
refetchOnWindowFocus: false,
|
||||
@ -20,5 +22,6 @@ export function useModels({ enabled = true }: { enabled?: boolean } = {}) {
|
||||
tokenUsageEnabled: data?.token_usage.enabled ?? false,
|
||||
isLoading,
|
||||
error,
|
||||
refetch,
|
||||
};
|
||||
}
|
||||
|
||||
@ -0,0 +1,221 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, rs } from "@rstest/core";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import {
|
||||
cleanup,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
} from "@testing-library/react";
|
||||
import type { PropsWithChildren } from "react";
|
||||
|
||||
rs.mock("@/core/models/api", () => ({
|
||||
loadModels: rs.fn(),
|
||||
}));
|
||||
|
||||
rs.mock("@/core/i18n/hooks", () => ({
|
||||
useI18n: () => ({
|
||||
locale: "en-US",
|
||||
t: {
|
||||
workspace: {
|
||||
modelLoadFailed:
|
||||
"Models couldn't be loaded. Model selection and token usage may be unavailable.",
|
||||
modelLoadRetry: "Retry",
|
||||
modelLoadRetrying: "Retrying…",
|
||||
},
|
||||
},
|
||||
changeLocale: rs.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
rs.mock("@/core/auth/AuthProvider", () => ({
|
||||
useAuth: rs.fn(),
|
||||
}));
|
||||
|
||||
import { ModelLoadErrorBanner } from "@/components/workspace/model-load-error-banner";
|
||||
import { UnauthorizedError } from "@/core/api/errors";
|
||||
import { useAuth } from "@/core/auth/AuthProvider";
|
||||
import type { User } from "@/core/auth/types";
|
||||
import { loadModels } from "@/core/models/api";
|
||||
import { MODELS_QUERY_KEY, useModels } from "@/core/models/hooks";
|
||||
import type { ModelsResponse } from "@/core/models/types";
|
||||
|
||||
const mockedLoadModels = rs.mocked(loadModels);
|
||||
const mockedUseAuth = rs.mocked(useAuth);
|
||||
const fakeUser = {} as User;
|
||||
|
||||
function createAuthState(user: User | null): ReturnType<typeof useAuth> {
|
||||
return {
|
||||
user,
|
||||
isAuthenticated: user !== null,
|
||||
isLoading: false,
|
||||
logout: rs.fn(),
|
||||
refreshUser: rs.fn(),
|
||||
applyUser: rs.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
function createDeferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((next) => {
|
||||
resolve = next;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockedUseAuth.mockReturnValue(createAuthState(fakeUser));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
mockedLoadModels.mockReset();
|
||||
mockedUseAuth.mockReset();
|
||||
});
|
||||
|
||||
function createWrapper() {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
},
|
||||
});
|
||||
|
||||
function QueryWrapper({ children }: PropsWithChildren) {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
return { queryClient, QueryWrapper };
|
||||
}
|
||||
|
||||
function ModelConsumer() {
|
||||
useModels();
|
||||
return null;
|
||||
}
|
||||
|
||||
describe("ModelLoadErrorBanner", () => {
|
||||
it("observes model failures without starting an extra request", async () => {
|
||||
const { QueryWrapper } = createWrapper();
|
||||
render(<ModelLoadErrorBanner />, { wrapper: QueryWrapper });
|
||||
|
||||
await Promise.resolve();
|
||||
|
||||
expect(mockedLoadModels).not.toHaveBeenCalled();
|
||||
expect(screen.queryByRole("alert")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows one actionable error for all model consumers and clears after retry", async () => {
|
||||
const retryResult = createDeferred<ModelsResponse>();
|
||||
mockedLoadModels
|
||||
.mockRejectedValueOnce(new Error("Gateway returned 503"))
|
||||
.mockImplementationOnce(() => retryResult.promise);
|
||||
const { QueryWrapper } = createWrapper();
|
||||
|
||||
render(
|
||||
<>
|
||||
<ModelLoadErrorBanner />
|
||||
<ModelConsumer />
|
||||
<ModelConsumer />
|
||||
</>,
|
||||
{ wrapper: QueryWrapper },
|
||||
);
|
||||
|
||||
const alert = await screen.findByRole("alert");
|
||||
expect(alert.textContent).toContain("Models couldn't be loaded");
|
||||
expect(alert.textContent).not.toContain("Gateway returned 503");
|
||||
expect(screen.getAllByRole("alert")).toHaveLength(1);
|
||||
expect(mockedLoadModels).toHaveBeenCalledTimes(1);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Retry" }));
|
||||
|
||||
const retryingButton = await screen.findByRole("button", {
|
||||
name: "Retrying…",
|
||||
});
|
||||
expect((retryingButton as HTMLButtonElement).disabled).toBe(true);
|
||||
|
||||
retryResult.resolve({
|
||||
models: [],
|
||||
token_usage: { enabled: false },
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole("alert")).toBeNull();
|
||||
});
|
||||
expect(mockedLoadModels).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("does not duplicate the login redirect with a model warning", async () => {
|
||||
mockedLoadModels.mockRejectedValueOnce(new UnauthorizedError());
|
||||
const { queryClient, QueryWrapper } = createWrapper();
|
||||
|
||||
render(
|
||||
<>
|
||||
<ModelLoadErrorBanner />
|
||||
<ModelConsumer />
|
||||
</>,
|
||||
{ wrapper: QueryWrapper },
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(queryClient.getQueryState(MODELS_QUERY_KEY)?.status).toBe("error");
|
||||
});
|
||||
expect(screen.queryByRole("alert")).toBeNull();
|
||||
});
|
||||
|
||||
it("suppresses a model symptom only while the gateway banner is visible", async () => {
|
||||
mockedUseAuth.mockReturnValue(createAuthState(null));
|
||||
mockedLoadModels.mockRejectedValueOnce(new Error("Gateway returned 503"));
|
||||
const { queryClient, QueryWrapper } = createWrapper();
|
||||
|
||||
const renderView = () => (
|
||||
<>
|
||||
<ModelLoadErrorBanner gatewayUnavailable />
|
||||
<ModelConsumer />
|
||||
</>
|
||||
);
|
||||
const { rerender } = render(renderView(), { wrapper: QueryWrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(queryClient.getQueryState(MODELS_QUERY_KEY)?.status).toBe("error");
|
||||
});
|
||||
expect(screen.queryByRole("alert")).toBeNull();
|
||||
|
||||
mockedUseAuth.mockReturnValue(createAuthState(fakeUser));
|
||||
rerender(renderView());
|
||||
|
||||
expect(await screen.findByRole("alert")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("does not show manual retry progress for a shared background refetch", async () => {
|
||||
const backgroundResult = createDeferred<ModelsResponse>();
|
||||
mockedLoadModels
|
||||
.mockRejectedValueOnce(new Error("Gateway returned 503"))
|
||||
.mockImplementationOnce(() => backgroundResult.promise);
|
||||
const { queryClient, QueryWrapper } = createWrapper();
|
||||
|
||||
render(
|
||||
<>
|
||||
<ModelLoadErrorBanner />
|
||||
<ModelConsumer />
|
||||
</>,
|
||||
{ wrapper: QueryWrapper },
|
||||
);
|
||||
|
||||
await screen.findByRole("alert");
|
||||
const backgroundRefetch = queryClient.refetchQueries({
|
||||
queryKey: MODELS_QUERY_KEY,
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(mockedLoadModels).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
expect(screen.queryByRole("button", { name: "Retrying…" })).toBeNull();
|
||||
|
||||
backgroundResult.resolve({
|
||||
models: [],
|
||||
token_usage: { enabled: false },
|
||||
});
|
||||
await backgroundRefetch;
|
||||
});
|
||||
});
|
||||
@ -1,5 +1,7 @@
|
||||
import { afterEach, expect, test, rs } from "@rstest/core";
|
||||
|
||||
import { UnauthorizedError } from "@/core/api/errors";
|
||||
|
||||
afterEach(() => {
|
||||
rs.unstubAllGlobals();
|
||||
});
|
||||
@ -53,6 +55,20 @@ test("loadModels rejects unsuccessful gateway responses", async () => {
|
||||
await expect(loadModels()).rejects.toThrow("Model registry unavailable");
|
||||
});
|
||||
|
||||
test("loadModels exposes the typed 401 redirect error", async () => {
|
||||
const location = { href: "", pathname: "/workspace/chats" };
|
||||
rs.stubGlobal("window", { location });
|
||||
rs.stubGlobal(
|
||||
"fetch",
|
||||
rs.fn(async () => new Response(null, { status: 401 })),
|
||||
);
|
||||
|
||||
const { loadModels } = await import("@/core/models/api");
|
||||
|
||||
await expect(loadModels()).rejects.toBeInstanceOf(UnauthorizedError);
|
||||
expect(location.href).toBe("/login?next=%2Fworkspace%2Fchats");
|
||||
});
|
||||
|
||||
test("loadModels includes the status code when statusText is empty", async () => {
|
||||
rs.stubGlobal(
|
||||
"fetch",
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user