feat(webui): show the actual fallback model (#5017)

This commit is contained in:
chengyongru
2026-07-23 15:57:13 +08:00
committed by GitHub
parent 4188ffc88d
commit 96eb965aae
15 changed files with 352 additions and 8 deletions
@@ -170,6 +170,7 @@ interface ThreadComposerProps {
modelProvider?: string | null;
modelProviderLabel?: string | null;
modelNeedsSetup?: boolean;
fallbackModelName?: string | null;
onModelBadgeClick?: () => void;
variant?: "thread" | "hero";
slashCommands?: SlashCommand[];
@@ -815,6 +816,7 @@ export function ThreadComposer({
modelProvider = null,
modelProviderLabel = null,
modelNeedsSetup = false,
fallbackModelName = null,
onModelBadgeClick,
variant = "thread",
slashCommands = [],
@@ -2073,6 +2075,7 @@ export function ThreadComposer({
provider={modelProvider}
providerLabel={modelProviderLabel}
needsSetup={modelNeedsSetup}
fallbackModelName={fallbackModelName}
isHero={isHero}
onClick={modelNeedsSetup ? onModelBadgeClick : undefined}
/>
@@ -2361,6 +2364,7 @@ function ComposerModelBadge({
provider,
providerLabel,
needsSetup,
fallbackModelName,
isHero,
onClick,
}: {
@@ -2368,6 +2372,7 @@ function ComposerModelBadge({
provider?: string | null;
providerLabel?: string | null;
needsSetup?: boolean;
fallbackModelName?: string | null;
isHero: boolean;
onClick?: () => void;
}) {
@@ -2381,11 +2386,12 @@ function ComposerModelBadge({
return (
<Container
title={title}
data-fallback={fallbackModelName ? "true" : undefined}
title={fallbackModelName || title}
type={interactive ? "button" : undefined}
onClick={onClick}
className={cn(
"inline-flex min-w-0 items-center rounded-full border border-border/55 bg-card font-medium text-foreground/82",
"composer-model-badge inline-flex min-w-0 items-center rounded-full border border-border/55 bg-card font-medium text-foreground/82",
"shadow-[0_2px_8px_rgba(15,23,42,0.045)]",
interactive && "cursor-pointer hover:bg-accent/55 hover:text-foreground",
needsSetup && "border-amber-500/35 bg-amber-50/70 text-amber-900 dark:bg-amber-500/10 dark:text-amber-200",
@@ -333,6 +333,7 @@ export function ThreadShell({
forkBoundaryMessageCount,
} = useSessionHistory(historyKey);
const { client, ingressLimits, modelName, token } = useClient();
const [fallbackModelName, setFallbackModelName] = useState<string | null>(null);
const [booting, setBooting] = useState(false);
const [slashCommands, setSlashCommands] = useState<SlashCommand[]>([]);
const cliApps = useInstalledSettingItems({
@@ -379,6 +380,7 @@ export function ThreadShell({
return messageCacheRef.current.get(chatId) ?? historical;
}, [chatId, historical]);
const handleTurnEnd = useCallback(() => {
setFallbackModelName(null);
onTurnEnd?.();
}, [onTurnEnd]);
const {
@@ -519,6 +521,18 @@ export function ThreadShell({
});
}, [client, refreshModelSettings]);
useEffect(() => {
if (!chatId) {
setFallbackModelName(null);
return;
}
setFallbackModelName(null);
return client.onChat(chatId, (event) => {
if (event.event !== "turn_model_updated") return;
setFallbackModelName(event.model_name);
});
}, [chatId, client]);
useEffect(() => {
if (!chatId || loading) return;
const cached = messageCacheRef.current.get(chatId);
@@ -680,6 +694,7 @@ export function ThreadShell({
const handleThreadSend = useCallback(
(content: string, images?: SendAttachment[], options?: SendOptions) => {
setFallbackModelName(null);
setScrollToLatestUserPromptSignal((value) => value + 1);
send(content, images, withWorkspaceScope(options));
},
@@ -808,6 +823,7 @@ export function ThreadShell({
modelProvider={modelBadge.provider}
modelProviderLabel={modelBadge.providerLabel}
modelNeedsSetup={modelBadge.needsSetup}
fallbackModelName={fallbackModelName}
onModelBadgeClick={modelBadge.needsSetup ? onOpenModelSettings : undefined}
variant={showHeroComposer ? "hero" : "thread"}
slashCommands={slashCommands}
@@ -845,6 +861,7 @@ export function ThreadShell({
modelProvider={modelBadge.provider}
modelProviderLabel={modelBadge.providerLabel}
modelNeedsSetup={modelBadge.needsSetup}
fallbackModelName={fallbackModelName}
onModelBadgeClick={modelBadge.needsSetup ? onOpenModelSettings : undefined}
variant="hero"
slashCommands={slashCommands}
+32
View File
@@ -124,6 +124,38 @@
}
@layer utilities {
.composer-model-badge {
position: relative;
isolation: isolate;
overflow: hidden;
}
.composer-model-badge::before {
content: "";
position: absolute;
inset: 0;
z-index: 0;
pointer-events: none;
background-color: rgb(236 141 49);
opacity: 0;
transition: opacity 600ms ease-in-out;
}
.composer-model-badge[data-fallback="true"]::before {
opacity: 1;
}
.composer-model-badge > * {
position: relative;
z-index: 1;
}
@media (prefers-reduced-motion: reduce) {
.composer-model-badge::before {
transition-duration: 150ms;
}
}
.host-drag-region {
-webkit-app-region: drag;
}
+5
View File
@@ -1087,6 +1087,11 @@ export type InboundEvent =
model_name: string;
model_preset?: string | null;
}
| {
event: "turn_model_updated";
chat_id: string;
model_name: string;
}
| ({
event: "turn_end";
chat_id: string;
+24
View File
@@ -357,6 +357,30 @@ describe("NanobotClient", () => {
expect(handler).toHaveBeenCalledWith("openai/gpt-4.1", "fast");
});
it("dispatches turn model updates to the active chat", () => {
const client = new NanobotClient({
url: "ws://test",
reconnect: false,
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
});
const chatHandler = vi.fn();
client.onChat("chat-a", chatHandler);
client.connect();
lastSocket().fakeOpen();
lastSocket().fakeMessage({
event: "turn_model_updated",
chat_id: "chat-a",
model_name: "deepseek/deepseek-chat",
});
expect(chatHandler).toHaveBeenCalledWith({
event: "turn_model_updated",
chat_id: "chat-a",
model_name: "deepseek/deepseek-chat",
});
});
it("dispatches session updates globally", () => {
const client = new NanobotClient({
url: "ws://test",
+74 -1
View File
@@ -14,13 +14,23 @@ const HERO_GREETING_PATTERN =
function makeClient() {
const errorHandlers = new Set<(err: { kind: string }) => void>();
const chatHandlers = new Map<string, Set<(ev: import("@/lib/types").InboundEvent) => void>>();
const runtimeModelHandlers = new Set<
(modelName: string | null, modelPreset?: string | null) => void
>();
const sessionUpdateHandlers = new Set<(chatId: string, scope?: string) => void>();
const goalStateByChatId = new Map<string, import("@/lib/types").GoalStateWsPayload>();
return {
status: "open" as const,
defaultChatId: null as string | null,
onStatus: () => () => {},
onRuntimeModelUpdate: () => () => {},
onRuntimeModelUpdate: (
handler: (modelName: string | null, modelPreset?: string | null) => void,
) => {
runtimeModelHandlers.add(handler);
return () => {
runtimeModelHandlers.delete(handler);
};
},
getRunStartedAt: () => null,
getGoalState: (chatId: string) => goalStateByChatId.get(chatId),
onChat: (chatId: string, handler: (ev: import("@/lib/types").InboundEvent) => void) => {
@@ -55,6 +65,9 @@ function makeClient() {
}
for (const h of chatHandlers.get(chatId) ?? []) h(ev);
},
_emitRuntimeModelUpdate(modelName: string | null, modelPreset?: string | null) {
for (const h of runtimeModelHandlers) h(modelName, modelPreset);
},
_emitSessionUpdate(chatId: string, scope?: string) {
for (const h of sessionUpdateHandlers) h(chatId, scope);
},
@@ -411,6 +424,66 @@ describe("ThreadShell", () => {
expect(screen.queryByRole("button", { name: "Model not configured" })).not.toBeInTheDocument();
});
it("highlights the configured model badge without replacing the preset label", async () => {
const client = makeClient();
render(wrap(
client,
<ThreadShell
session={session("fallback-model")}
title="Fallback model"
onToggleSidebar={() => {}}
settingsSnapshot={modelSettings("openai-codex/gpt-5.5", "openai_codex")}
/>,
"openai-codex/gpt-5.5",
));
expect(await screen.findByText("gpt-5.5")).toBeInTheDocument();
const configuredBadge = screen.getByTestId("composer-model-logo-openai_codex").parentElement;
expect(configuredBadge).not.toBeNull();
expect(configuredBadge).toHaveClass("composer-model-badge");
expect(configuredBadge).not.toHaveAttribute("data-fallback");
act(() => {
client._emitChat("fallback-model", {
event: "turn_model_updated",
chat_id: "fallback-model",
model_name: "deepseek/deepseek-chat",
});
});
const logo = screen.getByTestId("composer-model-logo-openai_codex");
const badge = logo.parentElement;
expect(badge).not.toBeNull();
expect(badge).toBe(configuredBadge);
expect(screen.getByText("gpt-5.5")).toBeInTheDocument();
expect(screen.queryByText("deepseek-chat")).not.toBeInTheDocument();
expect(badge).toHaveAttribute("data-fallback", "true");
expect(badge).toHaveAttribute(
"title",
"deepseek/deepseek-chat",
);
expect(logo).not.toHaveAttribute("data-fallback");
act(() => {
client._emitChat("fallback-model", {
event: "turn_end",
chat_id: "fallback-model",
});
});
await waitFor(() => {
expect(
screen.getByTestId("composer-model-logo-openai_codex").parentElement,
).not.toHaveAttribute("data-fallback");
});
expect(
screen.getByTestId("composer-model-logo-openai_codex").parentElement,
).toHaveAttribute("title", "gpt-5.5 · OpenAI Codex");
expect(
screen.getByTestId("composer-model-logo-openai_codex").parentElement,
).toBe(badge);
});
it("opens model settings from the unconfigured model badge", async () => {
const client = makeClient();
const settings = modelSettings("openai-codex/gpt-5.1-codex", "openai_codex");