feat/fix(profiles): per-conversation /switch_llm in chat (#575)

* feat(profiles): per-conversation /switch_llm in chat, global activate on the home page

* chore(profiles): address feedbacks

* chore(profiles): fix lints
This commit is contained in:
Vasco Schiavo
2026-05-18 14:11:11 -04:00
committed by GitHub
parent 0444b7df1a
commit c8bf569d90
19 changed files with 724 additions and 75 deletions
@@ -1,6 +1,7 @@
import {
ConversationClient,
FileClient,
ProfilesClient,
SettingsClient,
} from "@openhands/typescript-client/clients";
import axios from "axios";
@@ -25,6 +26,9 @@ const {
mockSwitchProfile,
mockGetSettings,
mockGetSettingsForConversation,
mockGetProfile,
mockActivateProfile,
mockSdkHttpPost,
} = vi.hoisted(() => ({
mockHttpGet: vi.fn(),
mockHttpPost: vi.fn(),
@@ -35,6 +39,9 @@ const {
mockSwitchProfile: vi.fn(),
mockGetSettings: vi.fn(),
mockGetSettingsForConversation: vi.fn(),
mockGetProfile: vi.fn(),
mockActivateProfile: vi.fn(),
mockSdkHttpPost: vi.fn(),
}));
vi.mock("@openhands/typescript-client/clients", async () => {
@@ -49,6 +56,12 @@ vi.mock("@openhands/typescript-client/clients", async () => {
FileClient: vi.fn(function FileClientMock() {
return mockFileClient();
}),
ProfilesClient: vi.fn(function ProfilesClientMock() {
return {
getProfile: mockGetProfile,
activateProfile: mockActivateProfile,
};
}),
SettingsClient: vi.fn(function SettingsClientMock() {
return mockSettingsClient();
}),
@@ -58,6 +71,12 @@ vi.mock("@openhands/typescript-client/clients", async () => {
};
});
vi.mock("@openhands/typescript-client/client/http-client", () => ({
HttpClient: vi.fn(function HttpClientMock() {
return { post: mockSdkHttpPost };
}),
}));
vi.mock("#/api/agent-server-config", () => ({
DEFAULT_WORKING_DIR: "workspace/project",
getAgentServerBaseUrl: vi.fn(() => "http://localhost:54928"),
@@ -84,8 +103,12 @@ describe("AgentServerConversationService", () => {
mockHttpGet.mockReset();
mockHttpPost.mockReset();
mockHttpDelete.mockReset();
mockGetProfile.mockReset();
mockActivateProfile.mockReset();
mockSdkHttpPost.mockReset();
vi.mocked(ConversationClient).mockClear();
vi.mocked(FileClient).mockClear();
vi.mocked(ProfilesClient).mockClear();
vi.mocked(SettingsClient).mockClear();
mockConversationClient.mockReturnValue({
@@ -407,7 +430,8 @@ describe("AgentServerConversationService", () => {
searchConversations: searchSpy,
});
const result = await AgentServerConversationService.searchConversations(10);
const result =
await AgentServerConversationService.searchConversations(10);
expect(result.items[0]?.sandbox_status).toBe("PAUSED");
});
@@ -431,7 +455,7 @@ describe("AgentServerConversationService", () => {
expect(conversation?.sandbox_status).toBeNull();
});
it("sanitizes malformed optional conversation fields", async () => {
it("sanitizes malformed optional conversation fields", async () => {
mockHttpGet.mockResolvedValue({
data: [
{
@@ -486,20 +510,48 @@ describe("AgentServerConversationService", () => {
__resetActiveStoreForTests();
});
it("switches profiles through the local agent-server client", async () => {
mockSwitchProfile.mockResolvedValue(undefined);
it("swaps the conversation's LLM via /switch_llm when a conversationId is provided", async () => {
const llmConfig = {
model: "litellm_proxy/claude-haiku",
api_key: "encrypted-key",
base_url: "encrypted-url",
};
mockGetProfile.mockResolvedValue({
name: "haiku",
config: llmConfig,
api_key_set: true,
});
mockSdkHttpPost.mockResolvedValue({ data: undefined });
await AgentServerConversationService.switchProfile("conv-1", "haiku");
expect(mockSwitchProfile).toHaveBeenCalledWith("conv-1", "haiku");
expect(ConversationClient).toHaveBeenCalledWith({
host: "http://localhost:54928",
apiKey: "test-api-key",
workingDir: "/workspace/project/agent-canvas",
expect(mockGetProfile).toHaveBeenCalledWith("haiku", {
exposeSecrets: "encrypted",
});
expect(mockSdkHttpPost).toHaveBeenCalledWith(
"/api/conversations/conv-1/switch_llm",
{ llm: llmConfig },
);
// Per-convo path: global default is left untouched.
expect(mockActivateProfile).not.toHaveBeenCalled();
});
it("rejects profile switching on cloud backends before creating a client", async () => {
it("activates the profile globally when called without a conversationId", async () => {
mockActivateProfile.mockResolvedValue({
name: "haiku",
message: "ok",
llm_applied: true,
});
await AgentServerConversationService.switchProfile(null, "haiku");
expect(mockActivateProfile).toHaveBeenCalledWith("haiku");
// Home-page path: don't touch any conversation's LLM.
expect(mockGetProfile).not.toHaveBeenCalled();
expect(mockSdkHttpPost).not.toHaveBeenCalled();
});
it("rejects profile switching on cloud backends before any network call", async () => {
const cloudBackend: Backend = {
id: "prod",
name: "Production",
@@ -515,7 +567,9 @@ describe("AgentServerConversationService", () => {
).rejects.toThrow(
"LLM profile switching is only supported for local agent-server backends.",
);
expect(mockSwitchProfile).not.toHaveBeenCalled();
expect(mockActivateProfile).not.toHaveBeenCalled();
expect(mockGetProfile).not.toHaveBeenCalled();
expect(mockSdkHttpPost).not.toHaveBeenCalled();
});
});
@@ -28,6 +28,10 @@ vi.mock("#/components/features/chat/change-agent-button", () => ({
ChangeAgentButton: () => <div data-testid="change-agent-button-stub" />,
}));
vi.mock("#/components/features/chat/switch-profile-button", () => ({
SwitchProfileButton: () => <div data-testid="switch-profile-button-stub" />,
}));
vi.mock("#/hooks/query/use-active-conversation", () => ({
useActiveConversation: () => useActiveConversationMock(),
}));
@@ -59,24 +63,54 @@ describe("ChatInputActions", () => {
useActiveConversationMock.mockReturnValue({ data: undefined });
});
it("renders the active conversation model when one is available", () => {
it("renders the SwitchProfileButton on a local backend", () => {
useActiveConversationMock.mockReturnValue({
data: { conversation_id: "test-conversation-id", llm_model: "gpt-4o" },
});
renderWithProviders(<ChatInputActions disabled={false} />);
expect(
screen.getByTestId("switch-profile-button-stub"),
).toBeInTheDocument();
expect(
screen.queryByTestId("chat-input-llm-model"),
).not.toBeInTheDocument();
});
it("renders the active conversation model on a cloud backend", () => {
setRegisteredBackends([cloudBackend]);
setActiveSelection({ backendId: cloudBackend.id });
useActiveConversationMock.mockReturnValue({
data: { conversation_id: "test-conversation-id", llm_model: "gpt-4o" },
});
renderWithProviders(
<ActiveBackendProvider>
<ChatInputActions disabled={false} />
</ActiveBackendProvider>,
);
expect(screen.getByTestId("chat-input-llm-model")).toHaveTextContent(
"gpt-4o",
);
expect(
screen.queryByTestId("switch-profile-button-stub"),
).not.toBeInTheDocument();
});
it("omits the model label when the active conversation has no llm_model", () => {
it("omits the model label on cloud when the active conversation has no llm_model", () => {
setRegisteredBackends([cloudBackend]);
setActiveSelection({ backendId: cloudBackend.id });
useActiveConversationMock.mockReturnValue({
data: { conversation_id: "test-conversation-id", llm_model: null },
});
renderWithProviders(<ChatInputActions disabled={false} />);
renderWithProviders(
<ActiveBackendProvider>
<ChatInputActions disabled={false} />
</ActiveBackendProvider>,
);
expect(
screen.queryByTestId("chat-input-llm-model"),
@@ -0,0 +1,114 @@
import React from "react";
import { fireEvent, screen } from "@testing-library/react";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { renderWithProviders } from "test-utils";
const useLlmProfilesMock = vi.fn();
const useActiveConversationMock = vi.fn();
const useSwitchLlmProfileAndLogMock = vi.fn();
const useOptionalConversationIdMock = vi.fn();
vi.mock("#/hooks/query/use-llm-profiles", () => ({
useLlmProfiles: () => useLlmProfilesMock(),
}));
vi.mock("#/hooks/query/use-active-conversation", () => ({
useActiveConversation: () => useActiveConversationMock(),
}));
vi.mock("#/hooks/mutation/use-switch-llm-profile-and-log", () => ({
useSwitchLlmProfileAndLog: () => useSwitchLlmProfileAndLogMock(),
}));
vi.mock("#/hooks/use-conversation-id", () => ({
useOptionalConversationId: () => useOptionalConversationIdMock(),
}));
// eslint-disable-next-line import/first
import { SwitchProfileButton } from "#/components/features/chat/switch-profile-button";
const profiles = [
{ name: "haiku", model: "anthropic/claude-haiku", base_url: null, api_key_set: true },
{ name: "gpt", model: "openai/gpt-4o", base_url: null, api_key_set: true },
];
describe("SwitchProfileButton", () => {
const switchAndLog = vi.fn();
beforeEach(() => {
switchAndLog.mockReset();
useLlmProfilesMock.mockReset();
useActiveConversationMock.mockReset();
useSwitchLlmProfileAndLogMock.mockReset();
useOptionalConversationIdMock.mockReset();
useLlmProfilesMock.mockReturnValue({
data: { profiles, active_profile: "haiku" },
});
useActiveConversationMock.mockReturnValue({ data: undefined });
useSwitchLlmProfileAndLogMock.mockReturnValue({
switchAndLog,
isPending: false,
});
useOptionalConversationIdMock.mockReturnValue({ conversationId: "conv-1" });
});
it("renders nothing when no profiles are available", () => {
useLlmProfilesMock.mockReturnValue({
data: { profiles: [], active_profile: null },
});
renderWithProviders(<SwitchProfileButton />);
expect(
screen.queryByTestId("switch-profile-button"),
).not.toBeInTheDocument();
});
it("labels the button with the user-level active profile on the home page (no conversation)", () => {
useOptionalConversationIdMock.mockReturnValue({ conversationId: null });
renderWithProviders(<SwitchProfileButton />);
expect(screen.getByTestId("switch-profile-button")).toHaveTextContent(
"haiku",
);
});
it("calls switchAndLog with null conversationId when clicked from the home page", () => {
useOptionalConversationIdMock.mockReturnValue({ conversationId: null });
renderWithProviders(<SwitchProfileButton />);
fireEvent.click(screen.getByTestId("switch-profile-button"));
fireEvent.click(screen.getByTestId("switch-profile-option-gpt"));
expect(switchAndLog).toHaveBeenCalledWith(null, "gpt");
});
it("calls switchAndLog with the conversation id when clicked from inside a conversation", () => {
renderWithProviders(<SwitchProfileButton />);
fireEvent.click(screen.getByTestId("switch-profile-button"));
fireEvent.click(screen.getByTestId("switch-profile-option-gpt"));
expect(switchAndLog).toHaveBeenCalledWith("conv-1", "gpt");
});
it("no-ops when the user clicks the already-active profile", () => {
renderWithProviders(<SwitchProfileButton />);
fireEvent.click(screen.getByTestId("switch-profile-button"));
fireEvent.click(screen.getByTestId("switch-profile-option-haiku"));
expect(switchAndLog).not.toHaveBeenCalled();
});
it("disables the button while a switch is in flight", () => {
useSwitchLlmProfileAndLogMock.mockReturnValue({
switchAndLog,
isPending: true,
});
renderWithProviders(<SwitchProfileButton />);
expect(screen.getByTestId("switch-profile-button")).toBeDisabled();
});
});
@@ -20,7 +20,10 @@ const { mockSwitchAndLog, mockDisplayErrorToast } = vi.hoisted(() => ({
}));
vi.mock("#/hooks/mutation/use-switch-llm-profile-and-log", () => ({
useSwitchLlmProfileAndLog: () => mockSwitchAndLog,
useSwitchLlmProfileAndLog: () => ({
switchAndLog: mockSwitchAndLog,
isPending: false,
}),
}));
vi.mock("react-i18next", () => ({
@@ -164,6 +167,32 @@ describe("useModelInterceptor", () => {
expect(mockSwitchAndLog).not.toHaveBeenCalled();
});
it("activates the named profile globally even when no conversation is set", () => {
const onSubmit = vi.fn();
const { result } = renderHook(() => useModelInterceptor(null, onSubmit), {
wrapper: makeWrapper(),
});
act(() => result.current("/model haiku"));
// Message is intercepted (never forwarded to the LLM) and the activate
// call still fires — the home-page `/model NAME` flow.
expect(onSubmit).not.toHaveBeenCalled();
expect(mockSwitchAndLog).toHaveBeenCalledWith(null, "haiku");
});
it("swallows bare /model on the home page (no conversation to anchor to)", () => {
const onSubmit = vi.fn();
const { result } = renderHook(() => useModelInterceptor(null, onSubmit), {
wrapper: makeWrapper(),
});
act(() => result.current("/model"));
expect(onSubmit).not.toHaveBeenCalled();
expect(mockSwitchAndLog).not.toHaveBeenCalled();
});
it("shows an error toast when listing profiles fails", async () => {
vi.mocked(ProfilesService.listProfiles).mockRejectedValue(
new Error("Network error"),
+22 -1
View File
@@ -17,7 +17,10 @@ const profile: ProfileInfo = {
describe("model store", () => {
beforeEach(() => {
useModelStore.setState({ entriesByConversation: {} });
useModelStore.setState({
entriesByConversation: {},
activeProfileByConversation: {},
});
});
it("adds profile-list entries scoped to the conversation", () => {
@@ -48,6 +51,24 @@ describe("model store", () => {
switchedTo: "gpt",
}),
]);
// recordSwitch also tags this as the conversation's optimistic active
// profile so the SwitchProfileButton reflects the new selection instantly.
expect(useModelStore.getState().activeProfileByConversation[CONV_A]).toBe(
"gpt",
);
});
it("clearActiveProfile drops only the optimistic profile entry", () => {
useModelStore.getState().show(CONV_A, "event-1", [profile]);
useModelStore.getState().recordSwitch(CONV_A, "event-2", "gpt");
useModelStore.getState().clearActiveProfile(CONV_A);
expect(
useModelStore.getState().activeProfileByConversation[CONV_A],
).toBeUndefined();
// Chat-history entries for the conversation are preserved.
expect(entriesFor(CONV_A)).toHaveLength(2);
});
it("clears entries for one conversation or all conversations", () => {
+64
View File
@@ -0,0 +1,64 @@
import { QueryClient } from "@tanstack/react-query";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { ActionEvent } from "#/types/agent-server/core/events/action-event";
import { handleActionEventCacheInvalidation } from "#/utils/cache-utils";
import { useModelStore } from "#/stores/model-store";
const makeActionEvent = (overrides: Partial<ActionEvent>): ActionEvent =>
({
id: "ev-1",
timestamp: new Date().toISOString(),
source: "agent",
tool_name: "SwitchLLMTool",
tool_call_id: "call-1",
action: { kind: "SwitchLLMAction" },
...overrides,
}) as unknown as ActionEvent;
describe("handleActionEventCacheInvalidation", () => {
beforeEach(() => {
useModelStore.setState({
entriesByConversation: {},
activeProfileByConversation: {},
});
});
it("refreshes the conversation and drops the optimistic profile when SwitchLLMTool fires", () => {
useModelStore.setState({
activeProfileByConversation: { "conv-1": "haiku" },
});
const queryClient = new QueryClient();
const spy = vi.spyOn(queryClient, "invalidateQueries");
handleActionEventCacheInvalidation(
makeActionEvent({ tool_name: "SwitchLLMTool" }),
"conv-1",
queryClient,
);
expect(spy).toHaveBeenCalledWith({
queryKey: ["user", "conversation", "conv-1"],
});
expect(
useModelStore.getState().activeProfileByConversation["conv-1"],
).toBeUndefined();
});
it("does not touch the conversation cache for unrelated tool events", () => {
const queryClient = new QueryClient();
const spy = vi.spyOn(queryClient, "invalidateQueries");
handleActionEventCacheInvalidation(
makeActionEvent({ tool_name: "terminal" }),
"conv-1",
queryClient,
);
const conversationInvalidations = spy.mock.calls.filter(
([arg]) =>
Array.isArray((arg as { queryKey?: unknown[] })?.queryKey) &&
(arg as { queryKey: unknown[] }).queryKey[0] === "user",
);
expect(conversationInvalidations).toHaveLength(0);
});
});
@@ -2,8 +2,10 @@ import { ConversationSortOrder } from "@openhands/typescript-client";
import {
ConversationClient,
FileClient,
ProfilesClient,
VSCodeClient,
} from "@openhands/typescript-client/clients";
import { HttpClient } from "@openhands/typescript-client/client/http-client";
import { v4 as uuidv4 } from "uuid";
import { Provider } from "#/types/settings";
import { buildHttpBaseUrl } from "#/utils/websocket-url";
@@ -35,7 +37,10 @@ import {
toConversationPage,
} from "../agent-server-adapter";
import { GetVSCodeUrlResponse } from "../open-hands.types";
import { getAgentServerClientOptions } from "../agent-server-client-options";
import {
getAgentServerClientOptions,
getAgentServerHttpClientOptions,
} from "../agent-server-client-options";
import SettingsService from "../settings-service/settings-service.api";
import {
ConversationMetadata,
@@ -580,8 +585,18 @@ class AgentServerConversationService {
return requireAppConversation(conversation, conversationId);
}
/**
* Switches the LLM profile for the running conversation when one is open
* (POST /switch_llm — per-conversation swap, doesn't change the user's
* default profile). When called without a conversationId (home page),
* falls back to POST /activate so the next conversation created picks up
* the chosen profile.
*
* The /switch_llm body needs the LLM config, which we fetch with encrypted
* secrets — same flow as conversation-start.
*/
static async switchProfile(
conversationId: string,
conversationId: string | null,
profileName: string,
): Promise<void> {
if (getActiveBackend().backend.kind === "cloud") {
@@ -590,9 +605,20 @@ class AgentServerConversationService {
);
}
await new ConversationClient(getAgentServerClientOptions()).switchProfile(
conversationId,
profileName,
const profilesClient = new ProfilesClient(getAgentServerClientOptions());
if (!conversationId) {
await profilesClient.activateProfile(profileName);
return;
}
const profile = await profilesClient.getProfile(profileName, {
exposeSecrets: "encrypted",
});
await new HttpClient(getAgentServerHttpClientOptions()).post(
`/api/conversations/${conversationId}/switch_llm`,
{ llm: profile.config },
);
}
}
@@ -6,6 +6,7 @@ import { AgentStatus } from "#/components/features/controls/agent-status";
import { Tools } from "../../controls/tools";
import { ChangeAgentButton } from "../change-agent-button";
import { ChatInputModel } from "./chat-input-model";
import { SwitchProfileButton } from "../switch-profile-button";
import { ChatAddFileButton } from "../chat-add-file-button";
import { ChatSendButton } from "../chat-send-button";
import { NavigationLink } from "#/components/shared/navigation-link";
@@ -506,7 +507,7 @@ export function ChatInputActions({
</div>
)}
<div ref={modelRef} className={cn(!showModelInline && "hidden")}>
<ChatInputModel />
{isCloud ? <ChatInputModel /> : <SwitchProfileButton />}
</div>
{hasOverflowItems && (
@@ -6,6 +6,7 @@ import { useBtwInterceptor } from "#/hooks/chat/use-btw-interceptor";
import { useModelInterceptor } from "#/hooks/chat/use-model-interceptor";
import { AgentState } from "#/types/agent-state";
import { useActiveConversation } from "#/hooks/query/use-active-conversation";
import { useOptionalConversationId } from "#/hooks/use-conversation-id";
import { GitControlBar } from "./git-control-bar";
import { useConversationStore } from "#/stores/conversation-store";
import { useAgentState } from "#/hooks/use-agent-state";
@@ -37,6 +38,11 @@ export function InteractiveChatBox({
} = useConversationStore();
const { curAgentState } = useAgentState();
const { data: conversation } = useActiveConversation();
// URL-based id is available the instant we land on the conversation route;
// `conversation?.id` is `undefined` until the first fetch resolves, which
// would let an early `/model NAME` (first action) fall through unhandled.
const { conversationId: routeConversationId } = useOptionalConversationId();
const conversationId = routeConversationId ?? conversation?.id ?? null;
// Poll sub-conversation task to check if it's loading
const { taskStatus: subConversationTaskStatus } =
@@ -140,24 +146,18 @@ export function InteractiveChatBox({
}
};
const handleAfterModel = useBtwInterceptor(
conversation?.id ?? null,
(message) => {
// When the user opts in via the "upload as file" checkbox, route
// the attached images through the normal file-upload path instead
// of embedding them in the message sent to the LLM.
if (uploadImagesAsFiles) {
onSubmit(message, [], [...files, ...images]);
} else {
onSubmit(message, images, files);
}
clearAllFiles();
},
);
const handleSubmit = useModelInterceptor(
conversation?.id ?? null,
handleAfterModel,
);
const handleAfterModel = useBtwInterceptor(conversationId, (message) => {
// When the user opts in via the "upload as file" checkbox, route
// the attached images through the normal file-upload path instead
// of embedding them in the message sent to the LLM.
if (uploadImagesAsFiles) {
onSubmit(message, [], [...files, ...images]);
} else {
onSubmit(message, images, files);
}
clearAllFiles();
});
const handleSubmit = useModelInterceptor(conversationId, handleAfterModel);
const handleSuggestionsClick = (suggestion: string) => {
handleSubmit(suggestion);
@@ -3,6 +3,7 @@ import { Trans } from "react-i18next";
import { ChevronDown, ChevronRight } from "lucide-react";
import { useModelStore } from "#/stores/model-store";
import { I18nKey } from "#/i18n/declaration";
import InfoCircleIcon from "#/icons/info-circle.svg?react";
import type { ProfileInfo } from "#/api/profiles-service/profiles-service.api";
import { GenericEventMessage } from "./generic-event-message";
@@ -70,7 +71,13 @@ export function ModelMessages({
<GenericEventMessage
key={entry.id}
title={
<span>
<span className="inline-flex items-center gap-1.5">
<InfoCircleIcon
width={14}
height={14}
className="shrink-0 text-neutral-400"
aria-hidden
/>
<Trans
i18nKey={I18nKey.MODEL$SWITCHED_TO_PROFILE}
values={{ name: entry.switchedTo }}
@@ -0,0 +1,94 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { Typography } from "#/ui/typography";
import { I18nKey } from "#/i18n/declaration";
import ChevronDownSmallIcon from "#/icons/chevron-down-small.svg?react";
import { useLlmProfiles } from "#/hooks/query/use-llm-profiles";
import { useSwitchLlmProfileAndLog } from "#/hooks/mutation/use-switch-llm-profile-and-log";
import { useActiveConversation } from "#/hooks/query/use-active-conversation";
import { useOptionalConversationId } from "#/hooks/use-conversation-id";
import { useModelStore } from "#/stores/model-store";
import { SwitchProfileContextMenu } from "./switch-profile-context-menu";
export function SwitchProfileButton() {
const { t } = useTranslation("openhands");
const [contextMenuOpen, setContextMenuOpen] = React.useState(false);
// Null on the home page; `useSwitchLlmProfileAndLog` is fine with that
// because /api/profiles/<name>/activate is a global endpoint.
const { conversationId } = useOptionalConversationId();
const { data } = useLlmProfiles();
const { data: conversation } = useActiveConversation();
const { switchAndLog, isPending } = useSwitchLlmProfileAndLog();
// Optimistic value written by recordSwitch on a successful switch — gives
// instant in-conversation feedback before the conversation refetch lands
// with the new `llm_model`.
const optimisticActiveProfile = useModelStore((s) =>
conversationId ? s.activeProfileByConversation[conversationId] : undefined,
);
const profiles = data?.profiles ?? [];
const conversationModel = conversation?.llm_model ?? null;
// Resolution priority for the active profile name:
// 1. Optimistic (just-clicked) — instant feedback before the refetch.
// 2. Profile whose model matches the running llm_model — cold loads.
// 3. User-level active_profile — home page / before the conversation has
// sent any messages.
const activeProfileName =
optimisticActiveProfile ??
(conversationModel
? (profiles.find((p) => p.model === conversationModel)?.name ?? null)
: (data?.active_profile ?? null));
const activeProfileModel =
profiles.find((p) => p.name === activeProfileName)?.model ??
conversationModel ??
null;
if (profiles.length === 0) {
return null;
}
const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
event.preventDefault();
event.stopPropagation();
setContextMenuOpen((open) => !open);
};
const handleSelect = (profileName: string) => {
if (profileName === activeProfileName) return;
switchAndLog(conversationId, profileName);
};
return (
<div className="relative">
<button
type="button"
onClick={handleClick}
disabled={isPending}
data-testid="switch-profile-button"
title={activeProfileModel ?? undefined}
aria-haspopup="menu"
aria-expanded={contextMenuOpen}
className="flex items-center gap-1 border border-[#4B505F] rounded-[100px] transition-opacity cursor-pointer hover:opacity-80 disabled:opacity-50 disabled:cursor-not-allowed pl-2 max-w-[200px]"
>
<Typography.Text className="text-white text-sm not-italic font-normal leading-5 truncate">
{activeProfileName ?? t(I18nKey.LLM$SELECT_MODEL_PLACEHOLDER)}
</Typography.Text>
<ChevronDownSmallIcon
width={24}
height={24}
color="#ffffff"
className="shrink-0"
/>
</button>
{contextMenuOpen && (
<SwitchProfileContextMenu
profiles={profiles}
activeProfileName={activeProfileName}
onSelect={handleSelect}
onClose={() => setContextMenuOpen(false)}
/>
)}
</div>
);
}
@@ -0,0 +1,111 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
import { ContextMenu } from "#/ui/context-menu";
import { Divider } from "#/ui/divider";
import { Typography } from "#/ui/typography";
import { NavigationLink } from "#/components/shared/navigation-link";
import { ContextMenuListItem } from "../context-menu/context-menu-list-item";
import { useClickOutsideElement } from "#/hooks/use-click-outside-element";
import CircuitIcon from "#/icons/u-circuit.svg?react";
import SettingsIcon from "#/icons/settings.svg?react";
import CheckIcon from "#/icons/checkmark.svg?react";
import { cn } from "#/utils/utils";
import type { ProfileInfo } from "#/api/profiles-service/profiles-service.api";
const rowBaseClassName = cn(
"w-full flex gap-3 px-3 rounded",
"text-start hover:bg-white/10 cursor-pointer text-nowrap",
);
const profileRowClassName = cn(rowBaseClassName, "h-auto items-start py-2.5");
const linkRowClassName = cn(rowBaseClassName, "h-10 items-center");
interface SwitchProfileContextMenuProps {
profiles: ProfileInfo[];
activeProfileName: string | null;
onSelect: (profileName: string) => void;
onClose: () => void;
}
export function SwitchProfileContextMenu({
profiles,
activeProfileName,
onSelect,
onClose,
}: SwitchProfileContextMenuProps) {
const { t } = useTranslation("openhands");
const ref = useClickOutsideElement<HTMLUListElement>(onClose);
React.useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [onClose]);
const handleSelect = (
event: React.MouseEvent<HTMLButtonElement>,
name: string,
) => {
event.preventDefault();
event.stopPropagation();
onSelect(name);
onClose();
};
return (
<ContextMenu
ref={ref}
testId="switch-profile-context-menu"
position="top"
alignment="left"
className="z-[60] left-0 mb-2 bottom-full min-w-[280px] max-h-[60vh] overflow-y-auto p-1"
>
<div className="px-3 pt-1 pb-0.5">
<Typography.Text className="text-[11px] font-medium text-[var(--oh-text-dim)] uppercase tracking-wide leading-4">
{t(I18nKey.SETTINGS$AVAILABLE_PROFILES)}
</Typography.Text>
</div>
{profiles.map((profile) => {
const isActive = profile.name === activeProfileName;
return (
<ContextMenuListItem
key={profile.name}
testId={`switch-profile-option-${profile.name}`}
onClick={(event) => handleSelect(event, profile.name)}
className={cn(profileRowClassName, isActive && "bg-[#5C5D62]")}
>
<CircuitIcon width={16} height={16} className="shrink-0" />
<span
className="flex min-w-0 flex-1 flex-col gap-0.5"
title={profile.model ?? undefined}
>
<span className="text-sm leading-5">{profile.name}</span>
{profile.model && (
<span className="truncate text-xs leading-4 text-[var(--oh-muted)]">
{profile.model}
</span>
)}
</span>
{isActive && (
<CheckIcon width={14} height={14} className="shrink-0" />
)}
</ContextMenuListItem>
);
})}
<Divider />
<NavigationLink
to="/settings"
onClick={onClose}
data-testid="switch-profile-open-settings"
className={linkRowClassName}
>
<SettingsIcon width={16} height={16} className="shrink-0" />
<span className="text-sm leading-5">
{t(I18nKey.MODEL$OPEN_SETTINGS)}
</span>
</NavigationLink>
</ContextMenu>
);
}
@@ -4,6 +4,7 @@ import { useTranslation } from "react-i18next";
import { CustomChatInput } from "#/components/features/chat/custom-chat-input";
import { useActiveBackend } from "#/contexts/active-backend-context";
import { useCreateConversation } from "#/hooks/mutation/use-create-conversation";
import { useModelInterceptor } from "#/hooks/chat/use-model-interceptor";
import { useNavigation } from "#/context/navigation-context";
import { useIsCreatingConversation } from "#/hooks/use-is-creating-conversation";
import { Branch, GitRepository } from "#/types/git";
@@ -84,6 +85,12 @@ export function HomeChatLauncher() {
});
};
// Without this wrapper a `/model NAME` typed here would become the first
// user message of the new conversation. The interceptor activates the
// profile globally (null conversationId path) so the next conversation
// launches with it.
const handleSubmitWithModelGuard = useModelInterceptor(null, handleSubmit);
return (
<div
data-testid="home-chat-launcher"
@@ -94,7 +101,10 @@ export function HomeChatLauncher() {
</div>
<div className="w-full">
<CustomChatInput onSubmit={handleSubmit} disabled={isCreating} />
<CustomChatInput
onSubmit={handleSubmitWithModelGuard}
disabled={isCreating}
/>
</div>
<div className="flex justify-start">
+10 -3
View File
@@ -26,7 +26,7 @@ export const useModelInterceptor = (
) => {
const showProfiles = useModelStore((s) => s.show);
const queryClient = useQueryClient();
const switchAndLog = useSwitchLlmProfileAndLog();
const { switchAndLog } = useSwitchLlmProfileAndLog();
const { backend, orgId } = useActiveBackend();
const isLocal = backend.kind === "local";
const { t } = useTranslation();
@@ -36,7 +36,7 @@ export const useModelInterceptor = (
const trimmed = message.trim();
const isModel =
trimmed === MODEL_COMMAND || trimmed.startsWith(MODEL_PREFIX);
if (!conversationId || !isLocal || !isModel) {
if (!isModel || !isLocal) {
onSubmit(message);
return;
}
@@ -44,10 +44,17 @@ export const useModelInterceptor = (
const arg = trimmed.slice(MODEL_COMMAND.length).trim();
if (arg) {
switchAndLog(conversationId, arg);
// `activateProfile` is global; works whether or not we have an active
// conversation. Pass conversationId through so the inline "Switched
// to" message gets anchored when one is open.
switchAndLog(conversationId ?? null, arg);
return;
}
// Bare `/model` — list profiles inline. Needs a conversation to anchor
// the entry to; swallow silently on the home page.
if (!conversationId) return;
const anchorEventId = getLastRenderableEventId();
// Imperative fetch through the query cache so the result lands on the
@@ -13,19 +13,24 @@ import { I18nKey } from "#/i18n/declaration";
* issued the command.
*/
export function useSwitchLlmProfileAndLog() {
const { mutate } = useSwitchLlmProfile();
const { mutate, isPending } = useSwitchLlmProfile();
const recordSwitch = useModelStore((s) => s.recordSwitch);
const { t } = useTranslation();
return useCallback(
(conversationId: string, profileName: string) => {
const switchAndLog = useCallback(
(conversationId: string | null, profileName: string) => {
const anchorEventId = getLastRenderableEventId();
mutate(
{ conversationId, profileName },
{
onSuccess: () =>
recordSwitch(conversationId, anchorEventId, profileName),
onSuccess: () => {
// The inline "Switched to" message is scoped to a conversation;
// skip it when activating from the home page (no convo yet).
if (conversationId) {
recordSwitch(conversationId, anchorEventId, profileName);
}
},
onError: (err: unknown) => {
const fallback = t(I18nKey.MODEL$SWITCH_FAILED, {
name: profileName,
@@ -39,4 +44,6 @@ export function useSwitchLlmProfileAndLog() {
},
[mutate, recordSwitch, t],
);
return { switchAndLog, isPending };
}
+18 -5
View File
@@ -1,15 +1,23 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import AgentServerConversationService from "#/api/conversation-service/agent-server-conversation-service.api";
import { LLM_PROFILES_QUERY_KEYS } from "#/hooks/query/query-keys";
interface SwitchLlmProfileVars {
conversationId: string;
/**
* When set, the conversation's running LLM is swapped via /switch_llm and
* the user's global default profile is untouched. When null (home page),
* the profile is activated globally instead.
*/
conversationId: string | null;
profileName: string;
}
/**
* Switches the conversation's active LLM profile via the backend.
* Invalidates the conversation query so anything reading the model picks up
* the new value.
* Switches the LLM profile. Per-conversation when called from inside a
* conversation; globally activates the profile when called from the home
* page. Invalidates the conversation query so consumers reading `llm_model`
* pick up the swap, and the profile list so anything reading `active_profile`
* stays in sync.
*/
export const useSwitchLlmProfile = () => {
const queryClient = useQueryClient();
@@ -19,8 +27,13 @@ export const useSwitchLlmProfile = () => {
AgentServerConversationService.switchProfile(conversationId, profileName),
onSuccess: (_data, { conversationId }) => {
queryClient.invalidateQueries({
queryKey: ["user", "conversation", conversationId],
queryKey: LLM_PROFILES_QUERY_KEYS.all,
});
if (conversationId) {
queryClient.invalidateQueries({
queryKey: ["user", "conversation", conversationId],
});
}
},
// Caller renders an inline message + handles error toast manually.
meta: { disableToast: true },
+32 -15
View File
@@ -8449,21 +8449,21 @@
"uk": "Скористайтеся сторінкою налаштувань LLM, щоб створити профіль, а потім виконайте /model <name> для перемикання."
},
"MODEL$SWITCHED_TO_PROFILE": {
"en": "ℹ️ Switched to profile <cmd>{{name}}</cmd>",
"ja": "ℹ️ プロファイル <cmd>{{name}}</cmd> に切り替えました",
"zh-CN": "ℹ️ 已切换到配置文件 <cmd>{{name}}</cmd>",
"zh-TW": "ℹ️ 已切換到設定檔 <cmd>{{name}}</cmd>",
"ko-KR": "ℹ️ <cmd>{{name}}</cmd> 프로필로 전환했습니다",
"no": "ℹ️ Byttet til profilen <cmd>{{name}}</cmd>",
"ar": "ℹ️ تم التبديل إلى الملف الشخصي <cmd>{{name}}</cmd>",
"de": "ℹ️ Zu Profil <cmd>{{name}}</cmd> gewechselt",
"fr": "ℹ️ Passage au profil <cmd>{{name}}</cmd>",
"it": "ℹ️ Passato al profilo <cmd>{{name}}</cmd>",
"pt": "ℹ️ Alternado para o perfil <cmd>{{name}}</cmd>",
"es": "ℹ️ Cambiado al perfil <cmd>{{name}}</cmd>",
"ca": "ℹ️ S'ha canviat al perfil <cmd>{{name}}</cmd>",
"tr": "ℹ️ <cmd>{{name}}</cmd> profiline geçildi",
"uk": "ℹ️ Перемкнено на профіль <cmd>{{name}}</cmd>"
"en": "Switched to profile <cmd>{{name}}</cmd>",
"ja": "プロファイル <cmd>{{name}}</cmd> に切り替えました",
"zh-CN": "已切换到配置文件 <cmd>{{name}}</cmd>",
"zh-TW": "已切換到設定檔 <cmd>{{name}}</cmd>",
"ko-KR": "<cmd>{{name}}</cmd> 프로필로 전환했습니다",
"no": "Byttet til profilen <cmd>{{name}}</cmd>",
"ar": "تم التبديل إلى الملف الشخصي <cmd>{{name}}</cmd>",
"de": "Zu Profil <cmd>{{name}}</cmd> gewechselt",
"fr": "Passage au profil <cmd>{{name}}</cmd>",
"it": "Passato al profilo <cmd>{{name}}</cmd>",
"pt": "Alternado para o perfil <cmd>{{name}}</cmd>",
"es": "Cambiado al perfil <cmd>{{name}}</cmd>",
"ca": "S'ha canviat al perfil <cmd>{{name}}</cmd>",
"tr": "<cmd>{{name}}</cmd> profiline geçildi",
"uk": "Перемкнено на профіль <cmd>{{name}}</cmd>"
},
"MODEL$LIST_FAILED": {
"en": "Failed to list LLM profiles",
@@ -8499,6 +8499,23 @@
"tr": "'{{name}}' profiline geçilemedi",
"uk": "Не вдалося перемкнутися на профіль '{{name}}'"
},
"MODEL$OPEN_SETTINGS": {
"en": "Open LLM settings",
"ja": "LLM 設定を開く",
"zh-CN": "打开 LLM 设置",
"zh-TW": "開啟 LLM 設定",
"ko-KR": "LLM 설정 열기",
"no": "Åpne LLM-innstillinger",
"ar": "فتح إعدادات LLM",
"de": "LLM-Einstellungen öffnen",
"fr": "Ouvrir les paramètres LLM",
"it": "Apri impostazioni LLM",
"pt": "Abrir configurações de LLM",
"es": "Abrir configuración de LLM",
"ca": "Obre la configuració de LLM",
"tr": "LLM ayarlarını aç",
"uk": "Відкрити налаштування LLM"
},
"CHAT_INTERFACE$CONNECTING": {
"en": "Connecting",
"ja": "接続中",
+33 -5
View File
@@ -17,6 +17,13 @@ export interface ModelListEntry {
interface ModelState {
entriesByConversation: Record<string, ModelListEntry[]>;
/**
* Most-recently-switched profile name per conversation. Updated by
* `recordSwitch` so the UI (button label, popover check mark) reflects the
* new selection instantly, before the conversation refetch from the agent
* server lands.
*/
activeProfileByConversation: Record<string, string>;
}
interface ModelActions {
@@ -30,6 +37,8 @@ interface ModelActions {
anchorEventId: string | null,
profileName: string,
) => void;
/** Drops only the optimistic active-profile entry for a conversation. */
clearActiveProfile: (conversationId: string) => void;
clear: (conversationId: string) => void;
clearAll: () => void;
}
@@ -54,6 +63,7 @@ export const useModelStore = create<ModelStore>()(
devtools(
(set) => ({
entriesByConversation: {},
activeProfileByConversation: {},
show: (conversationId, anchorEventId, profiles) =>
set((s) =>
appendEntry(s, conversationId, {
@@ -63,21 +73,39 @@ export const useModelStore = create<ModelStore>()(
}),
),
recordSwitch: (conversationId, anchorEventId, profileName) =>
set((s) =>
appendEntry(s, conversationId, {
set((s) => ({
...appendEntry(s, conversationId, {
id: uuidv4(),
anchorEventId,
profiles: [],
switchedTo: profileName,
}),
),
activeProfileByConversation: {
...s.activeProfileByConversation,
[conversationId]: profileName,
},
})),
clearActiveProfile: (conversationId) =>
set((s) => {
if (!(conversationId in s.activeProfileByConversation)) return s;
const activeProfileByConversation = {
...s.activeProfileByConversation,
};
delete activeProfileByConversation[conversationId];
return { activeProfileByConversation };
}),
clear: (conversationId) =>
set((s) => {
const entriesByConversation = { ...s.entriesByConversation };
delete entriesByConversation[conversationId];
return { entriesByConversation };
const activeProfileByConversation = {
...s.activeProfileByConversation,
};
delete activeProfileByConversation[conversationId];
return { entriesByConversation, activeProfileByConversation };
}),
clearAll: () => set({ entriesByConversation: {} }),
clearAll: () =>
set({ entriesByConversation: {}, activeProfileByConversation: {} }),
}),
{ name: "ModelStore" },
),
+12
View File
@@ -1,5 +1,6 @@
import { QueryClient } from "@tanstack/react-query";
import type { ActionEvent } from "#/types/agent-server/core/events/action-event";
import { useModelStore } from "#/stores/model-store";
import { stripWorkspacePrefix } from "./path-utils";
/**
@@ -46,4 +47,15 @@ export const handleActionEventCacheInvalidation = (
queryKey: ["file_diff", conversationId, strippedPath],
});
}
// When the agent autonomously swaps the LLM via SwitchLLMTool, refresh the
// conversation so SwitchProfileButton / ChatInputModel pick up the new
// `llm_model`, and drop any previously-set optimistic profile name (it
// would now misrepresent the agent's choice).
if (event.tool_name === "SwitchLLMTool") {
queryClient.invalidateQueries({
queryKey: ["user", "conversation", conversationId],
});
useModelStore.getState().clearActiveProfile(conversationId);
}
};