mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-09-13 15:40:00 +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 { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
|
||||||
import { CommandPalette } from "@/components/workspace/command-palette";
|
import { CommandPalette } from "@/components/workspace/command-palette";
|
||||||
import { GatewayOfflineBanner } from "@/components/workspace/gateway-offline-banner";
|
import { GatewayOfflineBanner } from "@/components/workspace/gateway-offline-banner";
|
||||||
|
import { ModelLoadErrorBanner } from "@/components/workspace/model-load-error-banner";
|
||||||
import { SettingsDialogHost } from "@/components/workspace/settings";
|
import { SettingsDialogHost } from "@/components/workspace/settings";
|
||||||
import { WorkspaceSettingsDeepLink } from "@/components/workspace/workspace-settings-deep-link";
|
import { WorkspaceSettingsDeepLink } from "@/components/workspace/workspace-settings-deep-link";
|
||||||
import { WorkspaceSidebar } from "@/components/workspace/workspace-sidebar";
|
import { WorkspaceSidebar } from "@/components/workspace/workspace-sidebar";
|
||||||
@ -35,6 +36,7 @@ export async function WorkspaceContent({
|
|||||||
<WorkspaceSidebar />
|
<WorkspaceSidebar />
|
||||||
<SidebarInset className="min-w-0">
|
<SidebarInset className="min-w-0">
|
||||||
<GatewayOfflineBanner gatewayUnavailable={gatewayUnavailable} />
|
<GatewayOfflineBanner gatewayUnavailable={gatewayUnavailable} />
|
||||||
|
<ModelLoadErrorBanner gatewayUnavailable={gatewayUnavailable} />
|
||||||
{children}
|
{children}
|
||||||
</SidebarInset>
|
</SidebarInset>
|
||||||
</SidebarProvider>
|
</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.
|
* Throw an Error from a failed Gateway REST response.
|
||||||
*
|
*
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
import { buildLoginUrl } from "@/core/auth/types";
|
import { buildLoginUrl } from "@/core/auth/types";
|
||||||
|
|
||||||
|
import { UnauthorizedError } from "./errors";
|
||||||
|
|
||||||
/** HTTP methods that the gateway's CSRFMiddleware checks. */
|
/** HTTP methods that the gateway's CSRFMiddleware checks. */
|
||||||
export type StateChangingMethod = "POST" | "PUT" | "DELETE" | "PATCH";
|
export type StateChangingMethod = "POST" | "PUT" | "DELETE" | "PATCH";
|
||||||
|
|
||||||
@ -82,7 +84,7 @@ export async function fetch(
|
|||||||
|
|
||||||
if (res.status === 401) {
|
if (res.status === 401) {
|
||||||
window.location.href = buildLoginUrl(window.location.pathname);
|
window.location.href = buildLoginUrl(window.location.pathname);
|
||||||
throw new Error("Unauthorized");
|
throw new UnauthorizedError();
|
||||||
}
|
}
|
||||||
|
|
||||||
return res;
|
return res;
|
||||||
|
|||||||
@ -528,6 +528,10 @@ export const enUS: Translations = {
|
|||||||
logout: "Log out",
|
logout: "Log out",
|
||||||
gatewayUnavailable: "Gateway is temporarily unavailable.",
|
gatewayUnavailable: "Gateway is temporarily unavailable.",
|
||||||
gatewayUnavailableRetrying: "Retrying in the background…",
|
gatewayUnavailableRetrying: "Retrying in the background…",
|
||||||
|
modelLoadFailed:
|
||||||
|
"Models couldn't be loaded. Model selection and token usage may be unavailable.",
|
||||||
|
modelLoadRetry: "Retry",
|
||||||
|
modelLoadRetrying: "Retrying…",
|
||||||
},
|
},
|
||||||
|
|
||||||
// Conversation
|
// Conversation
|
||||||
|
|||||||
@ -421,6 +421,9 @@ export interface Translations {
|
|||||||
logout: string;
|
logout: string;
|
||||||
gatewayUnavailable: string;
|
gatewayUnavailable: string;
|
||||||
gatewayUnavailableRetrying: string;
|
gatewayUnavailableRetrying: string;
|
||||||
|
modelLoadFailed: string;
|
||||||
|
modelLoadRetry: string;
|
||||||
|
modelLoadRetrying: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Conversation
|
// Conversation
|
||||||
|
|||||||
@ -503,6 +503,10 @@ export const zhCN: Translations = {
|
|||||||
logout: "退出登录",
|
logout: "退出登录",
|
||||||
gatewayUnavailable: "网关暂时不可用。",
|
gatewayUnavailable: "网关暂时不可用。",
|
||||||
gatewayUnavailableRetrying: "正在后台重试…",
|
gatewayUnavailableRetrying: "正在后台重试…",
|
||||||
|
modelLoadFailed:
|
||||||
|
"模型列表加载失败,模型选择和 Token 用量信息可能暂时不可用。",
|
||||||
|
modelLoadRetry: "重试",
|
||||||
|
modelLoadRetrying: "正在重试…",
|
||||||
},
|
},
|
||||||
|
|
||||||
// Conversation
|
// Conversation
|
||||||
|
|||||||
@ -2,9 +2,11 @@ import { useQuery } from "@tanstack/react-query";
|
|||||||
|
|
||||||
import { loadModels } from "./api";
|
import { loadModels } from "./api";
|
||||||
|
|
||||||
|
export const MODELS_QUERY_KEY = ["models"] as const;
|
||||||
|
|
||||||
export function useModels({ enabled = true }: { enabled?: boolean } = {}) {
|
export function useModels({ enabled = true }: { enabled?: boolean } = {}) {
|
||||||
const { data, isLoading, error } = useQuery({
|
const { data, isLoading, error, refetch } = useQuery({
|
||||||
queryKey: ["models"],
|
queryKey: MODELS_QUERY_KEY,
|
||||||
queryFn: () => loadModels(),
|
queryFn: () => loadModels(),
|
||||||
enabled,
|
enabled,
|
||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
@ -20,5 +22,6 @@ export function useModels({ enabled = true }: { enabled?: boolean } = {}) {
|
|||||||
tokenUsageEnabled: data?.token_usage.enabled ?? false,
|
tokenUsageEnabled: data?.token_usage.enabled ?? false,
|
||||||
isLoading,
|
isLoading,
|
||||||
error,
|
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 { afterEach, expect, test, rs } from "@rstest/core";
|
||||||
|
|
||||||
|
import { UnauthorizedError } from "@/core/api/errors";
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
rs.unstubAllGlobals();
|
rs.unstubAllGlobals();
|
||||||
});
|
});
|
||||||
@ -53,6 +55,20 @@ test("loadModels rejects unsuccessful gateway responses", async () => {
|
|||||||
await expect(loadModels()).rejects.toThrow("Model registry unavailable");
|
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 () => {
|
test("loadModels includes the status code when statusText is empty", async () => {
|
||||||
rs.stubGlobal(
|
rs.stubGlobal(
|
||||||
"fetch",
|
"fetch",
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user