mirror of
https://github.com/OpenHands/OpenHands.git
synced 2026-10-07 16:38:34 +08:00
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:
@@ -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"),
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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
@@ -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": "接続中",
|
||||
|
||||
@@ -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" },
|
||||
),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user