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:
starslittle 2026-08-17 08:23:07 +08:00 committed by GitHub
parent f0276c9f5a
commit 7e4996eef3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 344 additions and 3 deletions

View File

@ -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>

View File

@ -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>
);
}

View File

@ -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.
*

View File

@ -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;

View File

@ -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

View File

@ -421,6 +421,9 @@ export interface Translations {
logout: string;
gatewayUnavailable: string;
gatewayUnavailableRetrying: string;
modelLoadFailed: string;
modelLoadRetry: string;
modelLoadRetrying: string;
};
// Conversation

View File

@ -503,6 +503,10 @@ export const zhCN: Translations = {
logout: "退出登录",
gatewayUnavailable: "网关暂时不可用。",
gatewayUnavailableRetrying: "正在后台重试…",
modelLoadFailed:
"模型列表加载失败,模型选择和 Token 用量信息可能暂时不可用。",
modelLoadRetry: "重试",
modelLoadRetrying: "正在重试…",
},
// Conversation

View File

@ -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,
};
}

View File

@ -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;
});
});

View File

@ -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",