diff --git a/frontend/__tests__/components/shared/modals/settings/settings-form.test.tsx b/frontend/__tests__/components/shared/modals/settings/settings-form.test.tsx index 099c50c194..b820be5829 100644 --- a/frontend/__tests__/components/shared/modals/settings/settings-form.test.tsx +++ b/frontend/__tests__/components/shared/modals/settings/settings-form.test.tsx @@ -16,7 +16,7 @@ describe("SettingsForm", () => { Component: () => ( ), @@ -33,7 +33,7 @@ describe("SettingsForm", () => { expect(saveSettingsSpy).toHaveBeenCalledWith( expect.objectContaining({ - llm_model: DEFAULT_SETTINGS.LLM_MODEL, + llm_model: DEFAULT_SETTINGS.llm_model, }), ); }); diff --git a/frontend/__tests__/hooks/use-settings-nav-items.test.tsx b/frontend/__tests__/hooks/use-settings-nav-items.test.tsx new file mode 100644 index 0000000000..64bb675341 --- /dev/null +++ b/frontend/__tests__/hooks/use-settings-nav-items.test.tsx @@ -0,0 +1,53 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { renderHook, waitFor } from "@testing-library/react"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { SAAS_NAV_ITEMS, OSS_NAV_ITEMS } from "#/constants/settings-nav"; +import OptionService from "#/api/option-service/option-service.api"; +import { useSettingsNavItems } from "#/hooks/use-settings-nav-items"; + +const queryClient = new QueryClient(); +const wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} +); + +const mockConfig = (appMode: "saas" | "oss", hideLlmSettings = false) => { + vi.spyOn(OptionService, "getConfig").mockResolvedValue({ + APP_MODE: appMode, + FEATURE_FLAGS: { HIDE_LLM_SETTINGS: hideLlmSettings }, + } as Awaited>); +}; + +describe("useSettingsNavItems", () => { + beforeEach(() => { + queryClient.clear(); + }); + + it("should return SAAS_NAV_ITEMS when APP_MODE is 'saas'", async () => { + mockConfig("saas"); + const { result } = renderHook(() => useSettingsNavItems(), { wrapper }); + + await waitFor(() => { + expect(result.current).toEqual(SAAS_NAV_ITEMS); + }); + }); + + it("should return OSS_NAV_ITEMS when APP_MODE is 'oss'", async () => { + mockConfig("oss"); + const { result } = renderHook(() => useSettingsNavItems(), { wrapper }); + + await waitFor(() => { + expect(result.current).toEqual(OSS_NAV_ITEMS); + }); + }); + + it("should filter out '/settings' item when HIDE_LLM_SETTINGS feature flag is enabled", async () => { + mockConfig("saas", true); + const { result } = renderHook(() => useSettingsNavItems(), { wrapper }); + + await waitFor(() => { + expect( + result.current.find((item) => item.to === "/settings"), + ).toBeUndefined(); + }); + }); +}); diff --git a/frontend/__tests__/utils/has-advanced-settings-set.test.ts b/frontend/__tests__/utils/has-advanced-settings-set.test.ts index c6bd94b8f0..36c7a7b609 100644 --- a/frontend/__tests__/utils/has-advanced-settings-set.test.ts +++ b/frontend/__tests__/utils/has-advanced-settings-set.test.ts @@ -12,20 +12,20 @@ describe("hasAdvancedSettingsSet", () => { }); describe("should be true if", () => { - test("LLM_BASE_URL is set", () => { + test("llm_base_url is set", () => { expect( hasAdvancedSettingsSet({ ...DEFAULT_SETTINGS, - LLM_BASE_URL: "test", + llm_base_url: "test", }), ).toBe(true); }); - test("AGENT is not default value", () => { + test("agent is not default value", () => { expect( hasAdvancedSettingsSet({ ...DEFAULT_SETTINGS, - AGENT: "test", + agent: "test", }), ).toBe(true); }); diff --git a/frontend/__tests__/utils/model-name-case-preservation.test.tsx b/frontend/__tests__/utils/model-name-case-preservation.test.tsx index 4af08e127f..f3853ce4a5 100644 --- a/frontend/__tests__/utils/model-name-case-preservation.test.tsx +++ b/frontend/__tests__/utils/model-name-case-preservation.test.tsx @@ -13,7 +13,7 @@ describe("Model name case preservation", () => { const settings = extractSettings(formData); // Test that model names maintain their original casing - expect(settings.LLM_MODEL).toBe("SambaNova/Meta-Llama-3.1-8B-Instruct"); + expect(settings.llm_model).toBe("SambaNova/Meta-Llama-3.1-8B-Instruct"); }); it("should preserve openai model case", () => { @@ -24,7 +24,7 @@ describe("Model name case preservation", () => { formData.set("language", "en"); const settings = extractSettings(formData); - expect(settings.LLM_MODEL).toBe("openai/gpt-4o"); + expect(settings.llm_model).toBe("openai/gpt-4o"); }); it("should preserve anthropic model case", () => { @@ -35,7 +35,7 @@ describe("Model name case preservation", () => { formData.set("language", "en"); const settings = extractSettings(formData); - expect(settings.LLM_MODEL).toBe("anthropic/claude-sonnet-4-20250514"); + expect(settings.llm_model).toBe("anthropic/claude-sonnet-4-20250514"); }); it("should not automatically lowercase model names", () => { @@ -48,7 +48,7 @@ describe("Model name case preservation", () => { const settings = extractSettings(formData); // Test that camelCase and PascalCase are preserved - expect(settings.LLM_MODEL).not.toBe("sambanova/meta-llama-3.1-8b-instruct"); - expect(settings.LLM_MODEL).toBe("SambaNova/Meta-Llama-3.1-8B-Instruct"); + expect(settings.llm_model).not.toBe("sambanova/meta-llama-3.1-8b-instruct"); + expect(settings.llm_model).toBe("SambaNova/Meta-Llama-3.1-8B-Instruct"); }); }); diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 9b45556211..b4425a3c58 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -13,7 +13,7 @@ "@monaco-editor/react": "^4.7.0-rc.0", "@react-router/node": "^7.10.1", "@react-router/serve": "^7.10.1", - "@tailwindcss/vite": "^4.1.17", + "@tailwindcss/vite": "^4.1.18", "@tanstack/react-query": "^5.90.12", "@uidotdev/usehooks": "^2.4.1", "@xterm/addon-fit": "^0.10.0", @@ -28,13 +28,13 @@ "i18next-browser-languagedetector": "^8.2.0", "i18next-http-backend": "^3.0.2", "isbot": "^5.1.32", - "lucide-react": "^0.560.0", + "lucide-react": "^0.561.0", "monaco-editor": "^0.55.1", - "posthog-js": "^1.302.2", - "react": "^19.2.0", - "react-dom": "^19.2.0", + "posthog-js": "^1.306.0", + "react": "^19.2.3", + "react-dom": "^19.2.3", "react-hot-toast": "^2.6.0", - "react-i18next": "^16.4.0", + "react-i18next": "^16.5.0", "react-icons": "^5.5.0", "react-markdown": "^10.1.0", "react-router": "^7.10.1", @@ -58,7 +58,7 @@ "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.0", "@testing-library/user-event": "^14.6.1", - "@types/node": "^25.0.0", + "@types/node": "^25.0.1", "@types/react": "^19.2.7", "@types/react-dom": "^19.2.3", "@types/react-syntax-highlighter": "^15.5.13", @@ -5431,10 +5431,9 @@ } }, "node_modules/@tailwindcss/node": { - "version": "4.1.17", - "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.17.tgz", - "integrity": "sha512-csIkHIgLb3JisEFQ0vxr2Y57GUNYh447C8xzwj89U/8fdW8LhProdxvnVH6U8M2Y73QKiTIH+LWbK3V2BBZsAg==", - "license": "MIT", + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.18.tgz", + "integrity": "sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ==", "dependencies": { "@jridgewell/remapping": "^2.3.4", "enhanced-resolve": "^5.18.3", @@ -5442,40 +5441,38 @@ "lightningcss": "1.30.2", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", - "tailwindcss": "4.1.17" + "tailwindcss": "4.1.18" } }, "node_modules/@tailwindcss/oxide": { - "version": "4.1.17", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.17.tgz", - "integrity": "sha512-F0F7d01fmkQhsTjXezGBLdrl1KresJTcI3DB8EkScCldyKp3Msz4hub4uyYaVnk88BAS1g5DQjjF6F5qczheLA==", - "license": "MIT", + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.18.tgz", + "integrity": "sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A==", "engines": { "node": ">= 10" }, "optionalDependencies": { - "@tailwindcss/oxide-android-arm64": "4.1.17", - "@tailwindcss/oxide-darwin-arm64": "4.1.17", - "@tailwindcss/oxide-darwin-x64": "4.1.17", - "@tailwindcss/oxide-freebsd-x64": "4.1.17", - "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.17", - "@tailwindcss/oxide-linux-arm64-gnu": "4.1.17", - "@tailwindcss/oxide-linux-arm64-musl": "4.1.17", - "@tailwindcss/oxide-linux-x64-gnu": "4.1.17", - "@tailwindcss/oxide-linux-x64-musl": "4.1.17", - "@tailwindcss/oxide-wasm32-wasi": "4.1.17", - "@tailwindcss/oxide-win32-arm64-msvc": "4.1.17", - "@tailwindcss/oxide-win32-x64-msvc": "4.1.17" + "@tailwindcss/oxide-android-arm64": "4.1.18", + "@tailwindcss/oxide-darwin-arm64": "4.1.18", + "@tailwindcss/oxide-darwin-x64": "4.1.18", + "@tailwindcss/oxide-freebsd-x64": "4.1.18", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.18", + "@tailwindcss/oxide-linux-arm64-gnu": "4.1.18", + "@tailwindcss/oxide-linux-arm64-musl": "4.1.18", + "@tailwindcss/oxide-linux-x64-gnu": "4.1.18", + "@tailwindcss/oxide-linux-x64-musl": "4.1.18", + "@tailwindcss/oxide-wasm32-wasi": "4.1.18", + "@tailwindcss/oxide-win32-arm64-msvc": "4.1.18", + "@tailwindcss/oxide-win32-x64-msvc": "4.1.18" } }, "node_modules/@tailwindcss/oxide-android-arm64": { - "version": "4.1.17", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.17.tgz", - "integrity": "sha512-BMqpkJHgOZ5z78qqiGE6ZIRExyaHyuxjgrJ6eBO5+hfrfGkuya0lYfw8fRHG77gdTjWkNWEEm+qeG2cDMxArLQ==", + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.18.tgz", + "integrity": "sha512-dJHz7+Ugr9U/diKJA0W6N/6/cjI+ZTAoxPf9Iz9BFRF2GzEX8IvXxFIi/dZBloVJX/MZGvRuFA9rqwdiIEZQ0Q==", "cpu": [ "arm64" ], - "license": "MIT", "optional": true, "os": [ "android" @@ -5485,13 +5482,12 @@ } }, "node_modules/@tailwindcss/oxide-darwin-arm64": { - "version": "4.1.17", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.17.tgz", - "integrity": "sha512-EquyumkQweUBNk1zGEU/wfZo2qkp/nQKRZM8bUYO0J+Lums5+wl2CcG1f9BgAjn/u9pJzdYddHWBiFXJTcxmOg==", + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.18.tgz", + "integrity": "sha512-Gc2q4Qhs660bhjyBSKgq6BYvwDz4G+BuyJ5H1xfhmDR3D8HnHCmT/BSkvSL0vQLy/nkMLY20PQ2OoYMO15Jd0A==", "cpu": [ "arm64" ], - "license": "MIT", "optional": true, "os": [ "darwin" @@ -5501,13 +5497,12 @@ } }, "node_modules/@tailwindcss/oxide-darwin-x64": { - "version": "4.1.17", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.17.tgz", - "integrity": "sha512-gdhEPLzke2Pog8s12oADwYu0IAw04Y2tlmgVzIN0+046ytcgx8uZmCzEg4VcQh+AHKiS7xaL8kGo/QTiNEGRog==", + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.18.tgz", + "integrity": "sha512-FL5oxr2xQsFrc3X9o1fjHKBYBMD1QZNyc1Xzw/h5Qu4XnEBi3dZn96HcHm41c/euGV+GRiXFfh2hUCyKi/e+yw==", "cpu": [ "x64" ], - "license": "MIT", "optional": true, "os": [ "darwin" @@ -5517,13 +5512,12 @@ } }, "node_modules/@tailwindcss/oxide-freebsd-x64": { - "version": "4.1.17", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.17.tgz", - "integrity": "sha512-hxGS81KskMxML9DXsaXT1H0DyA+ZBIbyG/sSAjWNe2EDl7TkPOBI42GBV3u38itzGUOmFfCzk1iAjDXds8Oh0g==", + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.18.tgz", + "integrity": "sha512-Fj+RHgu5bDodmV1dM9yAxlfJwkkWvLiRjbhuO2LEtwtlYlBgiAT4x/j5wQr1tC3SANAgD+0YcmWVrj8R9trVMA==", "cpu": [ "x64" ], - "license": "MIT", "optional": true, "os": [ "freebsd" @@ -5533,13 +5527,12 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { - "version": "4.1.17", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.17.tgz", - "integrity": "sha512-k7jWk5E3ldAdw0cNglhjSgv501u7yrMf8oeZ0cElhxU6Y2o7f8yqelOp3fhf7evjIS6ujTI3U8pKUXV2I4iXHQ==", + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.18.tgz", + "integrity": "sha512-Fp+Wzk/Ws4dZn+LV2Nqx3IilnhH51YZoRaYHQsVq3RQvEl+71VGKFpkfHrLM/Li+kt5c0DJe/bHXK1eHgDmdiA==", "cpu": [ "arm" ], - "license": "MIT", "optional": true, "os": [ "linux" @@ -5549,13 +5542,12 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { - "version": "4.1.17", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.17.tgz", - "integrity": "sha512-HVDOm/mxK6+TbARwdW17WrgDYEGzmoYayrCgmLEw7FxTPLcp/glBisuyWkFz/jb7ZfiAXAXUACfyItn+nTgsdQ==", + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.18.tgz", + "integrity": "sha512-S0n3jboLysNbh55Vrt7pk9wgpyTTPD0fdQeh7wQfMqLPM/Hrxi+dVsLsPrycQjGKEQk85Kgbx+6+QnYNiHalnw==", "cpu": [ "arm64" ], - "license": "MIT", "optional": true, "os": [ "linux" @@ -5565,13 +5557,12 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm64-musl": { - "version": "4.1.17", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.17.tgz", - "integrity": "sha512-HvZLfGr42i5anKtIeQzxdkw/wPqIbpeZqe7vd3V9vI3RQxe3xU1fLjss0TjyhxWcBaipk7NYwSrwTwK1hJARMg==", + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.18.tgz", + "integrity": "sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==", "cpu": [ "arm64" ], - "license": "MIT", "optional": true, "os": [ "linux" @@ -5581,13 +5572,12 @@ } }, "node_modules/@tailwindcss/oxide-linux-x64-gnu": { - "version": "4.1.17", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.17.tgz", - "integrity": "sha512-M3XZuORCGB7VPOEDH+nzpJ21XPvK5PyjlkSFkFziNHGLc5d6g3di2McAAblmaSUNl8IOmzYwLx9NsE7bplNkwQ==", + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.18.tgz", + "integrity": "sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==", "cpu": [ "x64" ], - "license": "MIT", "optional": true, "os": [ "linux" @@ -5597,13 +5587,12 @@ } }, "node_modules/@tailwindcss/oxide-linux-x64-musl": { - "version": "4.1.17", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.17.tgz", - "integrity": "sha512-k7f+pf9eXLEey4pBlw+8dgfJHY4PZ5qOUFDyNf7SI6lHjQ9Zt7+NcscjpwdCEbYi6FI5c2KDTDWyf2iHcCSyyQ==", + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.18.tgz", + "integrity": "sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==", "cpu": [ "x64" ], - "license": "MIT", "optional": true, "os": [ "linux" @@ -5613,9 +5602,9 @@ } }, "node_modules/@tailwindcss/oxide-wasm32-wasi": { - "version": "4.1.17", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.17.tgz", - "integrity": "sha512-cEytGqSSoy7zK4JRWiTCx43FsKP/zGr0CsuMawhH67ONlH+T79VteQeJQRO/X7L0juEUA8ZyuYikcRBf0vsxhg==", + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.18.tgz", + "integrity": "sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==", "bundleDependencies": [ "@napi-rs/wasm-runtime", "@emnapi/core", @@ -5627,13 +5616,12 @@ "cpu": [ "wasm32" ], - "license": "MIT", "optional": true, "dependencies": { - "@emnapi/core": "^1.6.0", - "@emnapi/runtime": "^1.6.0", + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1", "@emnapi/wasi-threads": "^1.1.0", - "@napi-rs/wasm-runtime": "^1.0.7", + "@napi-rs/wasm-runtime": "^1.1.0", "@tybys/wasm-util": "^0.10.1", "tslib": "^2.4.0" }, @@ -5642,7 +5630,7 @@ } }, "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": { - "version": "1.6.0", + "version": "1.7.1", "inBundle": true, "license": "MIT", "optional": true, @@ -5652,7 +5640,7 @@ } }, "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": { - "version": "1.6.0", + "version": "1.7.1", "inBundle": true, "license": "MIT", "optional": true, @@ -5670,13 +5658,13 @@ } }, "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { - "version": "1.0.7", + "version": "1.1.0", "inBundle": true, "license": "MIT", "optional": true, "dependencies": { - "@emnapi/core": "^1.5.0", - "@emnapi/runtime": "^1.5.0", + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1", "@tybys/wasm-util": "^0.10.1" } }, @@ -5696,13 +5684,12 @@ "optional": true }, "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { - "version": "4.1.17", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.17.tgz", - "integrity": "sha512-JU5AHr7gKbZlOGvMdb4722/0aYbU+tN6lv1kONx0JK2cGsh7g148zVWLM0IKR3NeKLv+L90chBVYcJ8uJWbC9A==", + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.18.tgz", + "integrity": "sha512-HjSA7mr9HmC8fu6bdsZvZ+dhjyGCLdotjVOgLA2vEqxEBZaQo9YTX4kwgEvPCpRh8o4uWc4J/wEoFzhEmjvPbA==", "cpu": [ "arm64" ], - "license": "MIT", "optional": true, "os": [ "win32" @@ -5712,13 +5699,12 @@ } }, "node_modules/@tailwindcss/oxide-win32-x64-msvc": { - "version": "4.1.17", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.17.tgz", - "integrity": "sha512-SKWM4waLuqx0IH+FMDUw6R66Hu4OuTALFgnleKbqhgGU30DY20NORZMZUKgLRjQXNN2TLzKvh48QXTig4h4bGw==", + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.18.tgz", + "integrity": "sha512-bJWbyYpUlqamC8dpR7pfjA0I7vdF6t5VpUGMWRkXVE3AXgIZjYUYAK7II1GNaxR8J1SSrSrppRar8G++JekE3Q==", "cpu": [ "x64" ], - "license": "MIT", "optional": true, "os": [ "win32" @@ -5741,14 +5727,13 @@ } }, "node_modules/@tailwindcss/vite": { - "version": "4.1.17", - "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.1.17.tgz", - "integrity": "sha512-4+9w8ZHOiGnpcGI6z1TVVfWaX/koK7fKeSYF3qlYg2xpBtbteP2ddBxiarL+HVgfSJGeK5RIxRQmKm4rTJJAwA==", - "license": "MIT", + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.1.18.tgz", + "integrity": "sha512-jVA+/UpKL1vRLg6Hkao5jldawNmRo7mQYrZtNHMIVpLfLhDml5nMRUo/8MwoX2vNXvnaXNNMedrMfMugAVX1nA==", "dependencies": { - "@tailwindcss/node": "4.1.17", - "@tailwindcss/oxide": "4.1.17", - "tailwindcss": "4.1.17" + "@tailwindcss/node": "4.1.18", + "@tailwindcss/oxide": "4.1.18", + "tailwindcss": "4.1.18" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7" @@ -6006,9 +5991,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "25.0.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.0.0.tgz", - "integrity": "sha512-rl78HwuZlaDIUSeUKkmogkhebA+8K1Hy7tddZuJ3D0xV8pZSfsYGTsliGUol1JPzu9EKnTxPC4L1fiWouStRew==", + "version": "25.0.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.0.1.tgz", + "integrity": "sha512-czWPzKIAXucn9PtsttxmumiQ9N0ok9FrBwgRWrwmVLlp86BrMExzvXRLFYRJ+Ex3g6yqj+KuaxfX1JTgV2lpfg==", "devOptional": true, "dependencies": { "undici-types": "~7.16.0" @@ -8333,10 +8318,9 @@ } }, "node_modules/enhanced-resolve": { - "version": "5.18.3", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.3.tgz", - "integrity": "sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==", - "license": "MIT", + "version": "5.18.4", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.4.tgz", + "integrity": "sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q==", "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.2.0" @@ -10020,8 +10004,7 @@ "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", - "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "license": "ISC" + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==" }, "node_modules/graphemer": { "version": "1.4.0", @@ -11733,9 +11716,9 @@ } }, "node_modules/lucide-react": { - "version": "0.560.0", - "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.560.0.tgz", - "integrity": "sha512-NwKoUA/aBShsdL8WE5lukV2F/tjHzQRlonQs7fkNGI1sCT0Ay4a9Ap3ST2clUUkcY+9eQ0pBe2hybTQd2fmyDA==", + "version": "0.561.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.561.0.tgz", + "integrity": "sha512-Y59gMY38tl4/i0qewcqohPdEbieBy7SovpBL9IFebhc2mDd8x4PZSOsiFRkpPcOq6bj1r/mjH/Rk73gSlIJP2A==", "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } @@ -13676,10 +13659,9 @@ } }, "node_modules/posthog-js": { - "version": "1.304.0", - "resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.304.0.tgz", - "integrity": "sha512-revqoppmJ5y1Oa9iRUb3P8w1htfxZdrSAe+elSNMxvl7wxY62qWN7Q0kE5Sk81o1qLHa6drPhVKa/dppWOfSUw==", - "license": "SEE LICENSE IN LICENSE", + "version": "1.306.0", + "resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.306.0.tgz", + "integrity": "sha512-sjsy0El4HL6PgbyWiUF0CaKb2d1Q8okbSeT4eajan3QSvkWus6ygHQuW2l4lfvp6NLRQrIZKH/0sUanhASptUQ==", "dependencies": { "@posthog/core": "1.7.1", "core-js": "^3.38.1", @@ -13911,10 +13893,9 @@ } }, "node_modules/react": { - "version": "19.2.1", - "resolved": "https://registry.npmjs.org/react/-/react-19.2.1.tgz", - "integrity": "sha512-DGrYcCWK7tvYMnWh79yrPHt+vdx9tY+1gPZa7nJQtO/p8bLTDaHp4dzwEhQB7pZ4Xe3ok4XKuEPrVuc+wlpkmw==", - "license": "MIT", + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", + "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", "engines": { "node": ">=0.10.0" } @@ -13960,15 +13941,14 @@ "license": "MIT" }, "node_modules/react-dom": { - "version": "19.2.1", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.1.tgz", - "integrity": "sha512-ibrK8llX2a4eOskq1mXKu/TGZj9qzomO+sNfO98M6d9zIPOEhlBkMkBUBLd1vgS0gQsLDBzA+8jJBVXDnfHmJg==", - "license": "MIT", + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { - "react": "^19.2.1" + "react": "^19.2.3" } }, "node_modules/react-hot-toast": { @@ -13989,10 +13969,9 @@ } }, "node_modules/react-i18next": { - "version": "16.4.1", - "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-16.4.1.tgz", - "integrity": "sha512-GzsYomxb1/uE7nlJm0e1qQ8f+W9I3Xirh9VoycZIahk6C8Pmv/9Fd0ek6zjf1FSgtGLElDGqwi/4FOHEGUbsEQ==", - "license": "MIT", + "version": "16.5.0", + "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-16.5.0.tgz", + "integrity": "sha512-IMpPTyCTKxEj8klCrLKUTIUa8uYTd851+jcu2fJuUB9Agkk9Qq8asw4omyeHVnOXHrLgQJGTm5zTvn8HpaPiqw==", "dependencies": { "@babel/runtime": "^7.27.6", "html-parse-stringify": "^3.0.1", @@ -15578,16 +15557,14 @@ } }, "node_modules/tailwindcss": { - "version": "4.1.17", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.17.tgz", - "integrity": "sha512-j9Ee2YjuQqYT9bbRTfTZht9W/ytp5H+jJpZKiYdP/bpnXARAuELt9ofP0lPnmHjbga7SNQIxdTAXCmtKVYjN+Q==", - "license": "MIT" + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz", + "integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==" }, "node_modules/tapable": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==", - "license": "MIT", "engines": { "node": ">=6" }, diff --git a/frontend/package.json b/frontend/package.json index e91e1caa0b..64892afc5c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -12,7 +12,7 @@ "@monaco-editor/react": "^4.7.0-rc.0", "@react-router/node": "^7.10.1", "@react-router/serve": "^7.10.1", - "@tailwindcss/vite": "^4.1.17", + "@tailwindcss/vite": "^4.1.18", "@tanstack/react-query": "^5.90.12", "@uidotdev/usehooks": "^2.4.1", "@xterm/addon-fit": "^0.10.0", @@ -27,13 +27,13 @@ "i18next-browser-languagedetector": "^8.2.0", "i18next-http-backend": "^3.0.2", "isbot": "^5.1.32", - "lucide-react": "^0.560.0", + "lucide-react": "^0.561.0", "monaco-editor": "^0.55.1", - "posthog-js": "^1.302.2", - "react": "^19.2.0", - "react-dom": "^19.2.0", + "posthog-js": "^1.306.0", + "react": "^19.2.3", + "react-dom": "^19.2.3", "react-hot-toast": "^2.6.0", - "react-i18next": "^16.4.0", + "react-i18next": "^16.5.0", "react-icons": "^5.5.0", "react-markdown": "^10.1.0", "react-router": "^7.10.1", @@ -89,7 +89,7 @@ "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.0", "@testing-library/user-event": "^14.6.1", - "@types/node": "^25.0.0", + "@types/node": "^25.0.1", "@types/react": "^19.2.7", "@types/react-dom": "^19.2.3", "@types/react-syntax-highlighter": "^15.5.13", diff --git a/frontend/src/api/event-service/event-service.api.ts b/frontend/src/api/event-service/event-service.api.ts index 3e7a42666b..7464480d5c 100644 --- a/frontend/src/api/event-service/event-service.api.ts +++ b/frontend/src/api/event-service/event-service.api.ts @@ -5,7 +5,6 @@ import type { ConfirmationResponseRequest, ConfirmationResponseResponse, } from "./event-service.types"; -import { openHands } from "../open-hands-axios"; class EventService { /** @@ -38,11 +37,27 @@ class EventService { return data; } - static async getEventCount(conversationId: string): Promise { - const params = new URLSearchParams(); - params.append("conversation_id__eq", conversationId); - const { data } = await openHands.get( - `/api/v1/events/count?${params.toString()}`, + /** + * Get event count for a V1 conversation + * @param conversationId The conversation ID + * @param conversationUrl The conversation URL (e.g., "http://localhost:54928/api/conversations/...") + * @param sessionApiKey Session API key for authentication (required for V1) + * @returns The event count + */ + static async getEventCount( + conversationId: string, + conversationUrl: string, + sessionApiKey?: string | null, + ): Promise { + // Build the runtime URL using the conversation URL + const runtimeUrl = buildHttpBaseUrl(conversationUrl); + + // Build session headers for authentication + const headers = buildSessionHeaders(sessionApiKey); + + const { data } = await axios.get( + `${runtimeUrl}/api/conversations/${conversationId}/events/count`, + { headers }, ); return data; } diff --git a/frontend/src/api/settings-service/settings-service.api.ts b/frontend/src/api/settings-service/settings-service.api.ts index f75e10c3e6..1b0d1d5e0e 100644 --- a/frontend/src/api/settings-service/settings-service.api.ts +++ b/frontend/src/api/settings-service/settings-service.api.ts @@ -1,5 +1,5 @@ import { openHands } from "../open-hands-axios"; -import { ApiSettings, PostApiSettings } from "./settings.types"; +import { Settings } from "#/types/settings"; /** * Settings service for managing application settings @@ -8,8 +8,8 @@ class SettingsService { /** * Get the settings from the server or use the default settings if not found */ - static async getSettings(): Promise { - const { data } = await openHands.get("/api/settings"); + static async getSettings(): Promise { + const { data } = await openHands.get("/api/settings"); return data; } @@ -17,9 +17,7 @@ class SettingsService { * Save the settings to the server. Only valid settings are saved. * @param settings - the settings to save */ - static async saveSettings( - settings: Partial, - ): Promise { + static async saveSettings(settings: Partial): Promise { const data = await openHands.post("/api/settings", settings); return data.status === 200; } diff --git a/frontend/src/api/settings-service/settings.types.ts b/frontend/src/api/settings-service/settings.types.ts deleted file mode 100644 index c6d33a7ee5..0000000000 --- a/frontend/src/api/settings-service/settings.types.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { Provider } from "#/types/settings"; - -export type ApiSettings = { - llm_model: string; - llm_base_url: string; - agent: string; - language: string; - llm_api_key: string | null; - llm_api_key_set: boolean; - search_api_key_set: boolean; - confirmation_mode: boolean; - security_analyzer: string | null; - remote_runtime_resource_factor: number | null; - enable_default_condenser: boolean; - // Max size for condenser in backend settings - condenser_max_size: number | null; - enable_sound_notifications: boolean; - enable_proactive_conversation_starters: boolean; - enable_solvability_analysis: boolean; - user_consents_to_analytics: boolean | null; - search_api_key?: string; - provider_tokens_set: Partial>; - max_budget_per_task: number | null; - mcp_config?: { - sse_servers: (string | { url: string; api_key?: string })[]; - stdio_servers: { - name: string; - command: string; - args?: string[]; - env?: Record; - }[]; - shttp_servers: (string | { url: string; api_key?: string })[]; - }; - email?: string; - email_verified?: boolean; - git_user_name?: string; - git_user_email?: string; - v1_enabled?: boolean; -}; - -export type PostApiSettings = ApiSettings & { - user_consents_to_analytics: boolean | null; - search_api_key?: string; - mcp_config?: { - sse_servers: (string | { url: string; api_key?: string })[]; - stdio_servers: { - name: string; - command: string; - args?: string[]; - env?: Record; - }[]; - shttp_servers: (string | { url: string; api_key?: string })[]; - }; -}; diff --git a/frontend/src/components/features/chat/confirmation-mode-enabled.tsx b/frontend/src/components/features/chat/confirmation-mode-enabled.tsx index 6094d9a4c3..0e7df1afb8 100644 --- a/frontend/src/components/features/chat/confirmation-mode-enabled.tsx +++ b/frontend/src/components/features/chat/confirmation-mode-enabled.tsx @@ -9,7 +9,7 @@ function ConfirmationModeEnabled() { const { data: settings } = useSettings(); - if (!settings?.CONFIRMATION_MODE) { + if (!settings?.confirmation_mode) { return null; } diff --git a/frontend/src/components/features/chat/task-tracking/task-item.tsx b/frontend/src/components/features/chat/task-tracking/task-item.tsx index 72e9e74aac..9dac504769 100644 --- a/frontend/src/components/features/chat/task-tracking/task-item.tsx +++ b/frontend/src/components/features/chat/task-tracking/task-item.tsx @@ -24,7 +24,7 @@ export function TaskItem({ task }: TaskItemProps) { case "todo": return ; case "in_progress": - return ; + return ; case "done": return ; default: diff --git a/frontend/src/components/features/context-menu/account-settings-context-menu.tsx b/frontend/src/components/features/context-menu/account-settings-context-menu.tsx index a30fe5f816..0c2541237d 100644 --- a/frontend/src/components/features/context-menu/account-settings-context-menu.tsx +++ b/frontend/src/components/features/context-menu/account-settings-context-menu.tsx @@ -5,11 +5,10 @@ import { ContextMenu } from "#/ui/context-menu"; import { ContextMenuListItem } from "./context-menu-list-item"; import { Divider } from "#/ui/divider"; import { useClickOutsideElement } from "#/hooks/use-click-outside-element"; -import { useConfig } from "#/hooks/query/use-config"; import { I18nKey } from "#/i18n/declaration"; import LogOutIcon from "#/icons/log-out.svg?react"; import DocumentIcon from "#/icons/document.svg?react"; -import { SAAS_NAV_ITEMS, OSS_NAV_ITEMS } from "#/constants/settings-nav"; +import { useSettingsNavItems } from "#/hooks/use-settings-nav-items"; interface AccountSettingsContextMenuProps { onLogout: () => void; @@ -22,15 +21,8 @@ export function AccountSettingsContextMenu({ }: AccountSettingsContextMenuProps) { const ref = useClickOutsideElement(onClose); const { t } = useTranslation(); - const { data: config } = useConfig(); - - const isSaas = config?.APP_MODE === "saas"; - // Get navigation items and filter out LLM settings if the feature flag is enabled - let items = isSaas ? SAAS_NAV_ITEMS : OSS_NAV_ITEMS; - if (config?.FEATURE_FLAGS?.HIDE_LLM_SETTINGS) { - items = items.filter((item) => item.to !== "/settings"); - } + const items = useSettingsNavItems(); const navItems = items.map((item) => ({ ...item, @@ -39,11 +31,7 @@ export function AccountSettingsContextMenu({ height: 16, } as React.SVGProps), })); - - const handleNavigationClick = () => { - onClose(); - // The Link component will handle the actual navigation - }; + const handleNavigationClick = () => onClose(); return ( ( handleNavigationClick()} + onClick={handleNavigationClick} className="flex items-center gap-2 p-2 hover:bg-[#5C5D62] rounded h-[30px]" > {icon} diff --git a/frontend/src/components/features/guards/email-verification-guard.tsx b/frontend/src/components/features/guards/email-verification-guard.tsx index b212016503..3fbb774842 100644 --- a/frontend/src/components/features/guards/email-verification-guard.tsx +++ b/frontend/src/components/features/guards/email-verification-guard.tsx @@ -20,13 +20,13 @@ export function EmailVerificationGuard({ if (isLoading) return; // If EMAIL_VERIFIED is explicitly false (not undefined or null) - if (settings?.EMAIL_VERIFIED === false) { + if (settings?.email_verified === false) { // Allow access to /settings/user but redirect from any other page if (pathname !== "/settings/user") { navigate("/settings/user", { replace: true }); } } - }, [settings?.EMAIL_VERIFIED, pathname, navigate, isLoading]); + }, [settings?.email_verified, pathname, navigate, isLoading]); return children; } diff --git a/frontend/src/components/features/settings/settings-layout.tsx b/frontend/src/components/features/settings/settings-layout.tsx index 6ac82cf8d0..7d00ab2596 100644 --- a/frontend/src/components/features/settings/settings-layout.tsx +++ b/frontend/src/components/features/settings/settings-layout.tsx @@ -1,16 +1,11 @@ import { useState } from "react"; import { MobileHeader } from "./mobile-header"; import { SettingsNavigation } from "./settings-navigation"; - -interface NavigationItem { - to: string; - icon: React.ReactNode; - text: string; -} +import { SettingsNavItem } from "#/constants/settings-nav"; interface SettingsLayoutProps { children: React.ReactNode; - navigationItems: NavigationItem[]; + navigationItems: SettingsNavItem[]; } export function SettingsLayout({ @@ -19,13 +14,8 @@ export function SettingsLayout({ }: SettingsLayoutProps) { const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false); - const toggleMobileMenu = () => { - setIsMobileMenuOpen(!isMobileMenuOpen); - }; - - const closeMobileMenu = () => { - setIsMobileMenuOpen(false); - }; + const toggleMobileMenu = () => setIsMobileMenuOpen(!isMobileMenuOpen); + const closeMobileMenu = () => setIsMobileMenuOpen(false); return (
@@ -34,7 +24,6 @@ export function SettingsLayout({ isMobileMenuOpen={isMobileMenuOpen} onToggleMenu={toggleMobileMenu} /> - {/* Desktop layout with navigation and main content */}
{/* Navigation */} @@ -43,7 +32,6 @@ export function SettingsLayout({ onCloseMobileMenu={closeMobileMenu} navigationItems={navigationItems} /> - {/* Main content */}
{children} diff --git a/frontend/src/components/features/settings/settings-navigation.tsx b/frontend/src/components/features/settings/settings-navigation.tsx index ce9e49aa09..5a35f01495 100644 --- a/frontend/src/components/features/settings/settings-navigation.tsx +++ b/frontend/src/components/features/settings/settings-navigation.tsx @@ -5,17 +5,12 @@ import { Typography } from "#/ui/typography"; import { I18nKey } from "#/i18n/declaration"; import SettingsIcon from "#/icons/settings-gear.svg?react"; import CloseIcon from "#/icons/close.svg?react"; - -interface NavigationItem { - to: string; - icon: React.ReactNode; - text: string; -} +import { SettingsNavItem } from "#/constants/settings-nav"; interface SettingsNavigationProps { isMobileMenuOpen: boolean; onCloseMobileMenu: () => void; - navigationItems: NavigationItem[]; + navigationItems: SettingsNavItem[]; } export function SettingsNavigation({ @@ -34,7 +29,6 @@ export function SettingsNavigation({ onClick={onCloseMobileMenu} /> )} - {/* Navigation sidebar */}
- +
- settings?.EMAIL_VERIFIED === false + settings?.email_verified === false ? null : setConversationPanelIsOpen((prev) => !prev) } - disabled={settings?.EMAIL_VERIFIED === false} + disabled={settings?.email_verified === false} />
diff --git a/frontend/src/components/shared/modals/settings/settings-form.tsx b/frontend/src/components/shared/modals/settings/settings-form.tsx index e08b59c8e0..b31b04eb53 100644 --- a/frontend/src/components/shared/modals/settings/settings-form.tsx +++ b/frontend/src/components/shared/modals/settings/settings-form.tsx @@ -41,11 +41,11 @@ export function SettingsForm({ settings, models, onClose }: SettingsFormProps) { onClose(); posthog.capture("settings_saved", { - LLM_MODEL: newSettings.LLM_MODEL, - LLM_API_KEY_SET: newSettings.LLM_API_KEY_SET ? "SET" : "UNSET", - SEARCH_API_KEY_SET: newSettings.SEARCH_API_KEY ? "SET" : "UNSET", + LLM_MODEL: newSettings.llm_model, + LLM_API_KEY_SET: newSettings.llm_api_key_set ? "SET" : "UNSET", + SEARCH_API_KEY_SET: newSettings.search_api_key ? "SET" : "UNSET", REMOTE_RUNTIME_RESOURCE_FACTOR: - newSettings.REMOTE_RUNTIME_RESOURCE_FACTOR, + newSettings.remote_runtime_resource_factor, }); }, }); @@ -67,7 +67,7 @@ export function SettingsForm({ settings, models, onClose }: SettingsFormProps) { } }; - const isLLMKeySet = settings.LLM_API_KEY_SET; + const isLLMKeySet = settings.llm_api_key_set; return (
@@ -80,7 +80,7 @@ export function SettingsForm({ settings, models, onClose }: SettingsFormProps) {
diff --git a/frontend/src/components/v1/chat/task-tracking/task-item.tsx b/frontend/src/components/v1/chat/task-tracking/task-item.tsx index b25664a611..a50b6829d3 100644 --- a/frontend/src/components/v1/chat/task-tracking/task-item.tsx +++ b/frontend/src/components/v1/chat/task-tracking/task-item.tsx @@ -20,9 +20,7 @@ export function TaskItem({ task }: TaskItemProps) { case "todo": return ; case "in_progress": - return ( - - ); + return ; case "done": return ; default: diff --git a/frontend/src/contexts/conversation-websocket-context.tsx b/frontend/src/contexts/conversation-websocket-context.tsx index 68c50f9499..8a8a205cd6 100644 --- a/frontend/src/contexts/conversation-websocket-context.tsx +++ b/frontend/src/contexts/conversation-websocket-context.tsx @@ -578,9 +578,13 @@ export function ConversationWebSocketProvider({ removeErrorMessage(); // Clear any previous error messages on successful connection // Fetch expected event count for history loading detection - if (conversationId) { + if (conversationId && conversationUrl) { try { - const count = await EventService.getEventCount(conversationId); + const count = await EventService.getEventCount( + conversationId, + conversationUrl, + sessionApiKey, + ); setExpectedEventCountMain(count); // If no events expected, mark as loaded immediately @@ -618,6 +622,7 @@ export function ConversationWebSocketProvider({ removeErrorMessage, sessionApiKey, conversationId, + conversationUrl, ]); // Separate WebSocket options for planning agent connection @@ -642,10 +647,15 @@ export function ConversationWebSocketProvider({ removeErrorMessage(); // Clear any previous error messages on successful connection // Fetch expected event count for history loading detection - if (planningAgentConversation?.id) { + if ( + planningAgentConversation?.id && + planningAgentConversation.conversation_url + ) { try { const count = await EventService.getEventCount( planningAgentConversation.id, + planningAgentConversation.conversation_url, + planningAgentConversation.session_api_key, ); setExpectedEventCountPlanning(count); diff --git a/frontend/src/hooks/mutation/use-add-mcp-server.ts b/frontend/src/hooks/mutation/use-add-mcp-server.ts index 25581cbdaf..c9aaf4e446 100644 --- a/frontend/src/hooks/mutation/use-add-mcp-server.ts +++ b/frontend/src/hooks/mutation/use-add-mcp-server.ts @@ -24,7 +24,7 @@ export function useAddMcpServer() { mutationFn: async (server: MCPServerConfig): Promise => { if (!settings) return; - const currentConfig = settings.MCP_CONFIG || { + const currentConfig = settings.mcp_config || { sse_servers: [], stdio_servers: [], shttp_servers: [], @@ -57,7 +57,7 @@ export function useAddMcpServer() { const apiSettings = { mcp_config: newConfig, - v1_enabled: settings.V1_ENABLED, + v1_enabled: settings.v1_enabled, }; await SettingsService.saveSettings(apiSettings); diff --git a/frontend/src/hooks/mutation/use-create-conversation.ts b/frontend/src/hooks/mutation/use-create-conversation.ts index 8f6df2c272..85e8dd880c 100644 --- a/frontend/src/hooks/mutation/use-create-conversation.ts +++ b/frontend/src/hooks/mutation/use-create-conversation.ts @@ -51,7 +51,7 @@ export const useCreateConversation = () => { agentType, } = variables; - const useV1 = !!settings?.V1_ENABLED && !createMicroagent; + const useV1 = !!settings?.v1_enabled && !createMicroagent; if (useV1) { // Use V1 API - creates a conversation start task diff --git a/frontend/src/hooks/mutation/use-delete-mcp-server.ts b/frontend/src/hooks/mutation/use-delete-mcp-server.ts index 42ee01601f..43d1b2a7cc 100644 --- a/frontend/src/hooks/mutation/use-delete-mcp-server.ts +++ b/frontend/src/hooks/mutation/use-delete-mcp-server.ts @@ -9,9 +9,9 @@ export function useDeleteMcpServer() { return useMutation({ mutationFn: async (serverId: string): Promise => { - if (!settings?.MCP_CONFIG) return; + if (!settings?.mcp_config) return; - const newConfig: MCPConfig = { ...settings.MCP_CONFIG }; + const newConfig: MCPConfig = { ...settings.mcp_config }; const [serverType, indexStr] = serverId.split("-"); const index = parseInt(indexStr, 10); @@ -25,7 +25,7 @@ export function useDeleteMcpServer() { const apiSettings = { mcp_config: newConfig, - v1_enabled: settings.V1_ENABLED, + v1_enabled: settings.v1_enabled, }; await SettingsService.saveSettings(apiSettings); diff --git a/frontend/src/hooks/mutation/use-save-settings.ts b/frontend/src/hooks/mutation/use-save-settings.ts index 168c1d11f1..f335fd83ec 100644 --- a/frontend/src/hooks/mutation/use-save-settings.ts +++ b/frontend/src/hooks/mutation/use-save-settings.ts @@ -2,43 +2,28 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; import { usePostHog } from "posthog-js/react"; import { DEFAULT_SETTINGS } from "#/services/settings"; import SettingsService from "#/api/settings-service/settings-service.api"; -import { PostSettings } from "#/types/settings"; -import { PostApiSettings } from "#/api/settings-service/settings.types"; +import { Settings } from "#/types/settings"; import { useSettings } from "../query/use-settings"; -const saveSettingsMutationFn = async (settings: Partial) => { - const apiSettings: Partial = { - llm_model: settings.LLM_MODEL, - llm_base_url: settings.LLM_BASE_URL, - agent: settings.AGENT || DEFAULT_SETTINGS.AGENT, - language: settings.LANGUAGE || DEFAULT_SETTINGS.LANGUAGE, - confirmation_mode: settings.CONFIRMATION_MODE, - security_analyzer: settings.SECURITY_ANALYZER, +const saveSettingsMutationFn = async (settings: Partial) => { + const settingsToSave: Partial = { + ...settings, + agent: settings.agent || DEFAULT_SETTINGS.agent, + language: settings.language || DEFAULT_SETTINGS.language, llm_api_key: settings.llm_api_key === "" ? "" : settings.llm_api_key?.trim() || undefined, - remote_runtime_resource_factor: settings.REMOTE_RUNTIME_RESOURCE_FACTOR, - enable_default_condenser: settings.ENABLE_DEFAULT_CONDENSER, condenser_max_size: - settings.CONDENSER_MAX_SIZE ?? DEFAULT_SETTINGS.CONDENSER_MAX_SIZE, - enable_sound_notifications: settings.ENABLE_SOUND_NOTIFICATIONS, - user_consents_to_analytics: settings.user_consents_to_analytics, - provider_tokens_set: settings.PROVIDER_TOKENS_SET, - mcp_config: settings.MCP_CONFIG, - enable_proactive_conversation_starters: - settings.ENABLE_PROACTIVE_CONVERSATION_STARTERS, - enable_solvability_analysis: settings.ENABLE_SOLVABILITY_ANALYSIS, - search_api_key: settings.SEARCH_API_KEY?.trim() || "", - max_budget_per_task: settings.MAX_BUDGET_PER_TASK, + settings.condenser_max_size ?? DEFAULT_SETTINGS.condenser_max_size, + search_api_key: settings.search_api_key?.trim() || "", git_user_name: - settings.GIT_USER_NAME?.trim() || DEFAULT_SETTINGS.GIT_USER_NAME, + settings.git_user_name?.trim() || DEFAULT_SETTINGS.git_user_name, git_user_email: - settings.GIT_USER_EMAIL?.trim() || DEFAULT_SETTINGS.GIT_USER_EMAIL, - v1_enabled: settings.V1_ENABLED, + settings.git_user_email?.trim() || DEFAULT_SETTINGS.git_user_email, }; - await SettingsService.saveSettings(apiSettings); + await SettingsService.saveSettings(settingsToSave); }; export const useSaveSettings = () => { @@ -47,18 +32,18 @@ export const useSaveSettings = () => { const { data: currentSettings } = useSettings(); return useMutation({ - mutationFn: async (settings: Partial) => { + mutationFn: async (settings: Partial) => { const newSettings = { ...currentSettings, ...settings }; // Track MCP configuration changes if ( - settings.MCP_CONFIG && - currentSettings?.MCP_CONFIG !== settings.MCP_CONFIG + settings.mcp_config && + currentSettings?.mcp_config !== settings.mcp_config ) { - const hasMcpConfig = !!settings.MCP_CONFIG; - const sseServersCount = settings.MCP_CONFIG?.sse_servers?.length || 0; + const hasMcpConfig = !!settings.mcp_config; + const sseServersCount = settings.mcp_config?.sse_servers?.length || 0; const stdioServersCount = - settings.MCP_CONFIG?.stdio_servers?.length || 0; + settings.mcp_config?.stdio_servers?.length || 0; // Track MCP configuration usage posthog.capture("mcp_config_updated", { diff --git a/frontend/src/hooks/mutation/use-update-mcp-server.ts b/frontend/src/hooks/mutation/use-update-mcp-server.ts index 7d7b7c9fd4..558997b500 100644 --- a/frontend/src/hooks/mutation/use-update-mcp-server.ts +++ b/frontend/src/hooks/mutation/use-update-mcp-server.ts @@ -28,9 +28,9 @@ export function useUpdateMcpServer() { serverId: string; server: MCPServerConfig; }): Promise => { - if (!settings?.MCP_CONFIG) return; + if (!settings?.mcp_config) return; - const newConfig = { ...settings.MCP_CONFIG }; + const newConfig = { ...settings.mcp_config }; const [serverType, indexStr] = serverId.split("-"); const index = parseInt(indexStr, 10); @@ -59,7 +59,7 @@ export function useUpdateMcpServer() { const apiSettings = { mcp_config: newConfig, - v1_enabled: settings.V1_ENABLED, + v1_enabled: settings.v1_enabled, }; await SettingsService.saveSettings(apiSettings); diff --git a/frontend/src/hooks/query/use-settings.ts b/frontend/src/hooks/query/use-settings.ts index 3f2e57c90d..faf34d5dae 100644 --- a/frontend/src/hooks/query/use-settings.ts +++ b/frontend/src/hooks/query/use-settings.ts @@ -6,37 +6,18 @@ import { Settings } from "#/types/settings"; import { useIsAuthed } from "./use-is-authed"; const getSettingsQueryFn = async (): Promise => { - const apiSettings = await SettingsService.getSettings(); + const settings = await SettingsService.getSettings(); return { - LLM_MODEL: apiSettings.llm_model, - LLM_BASE_URL: apiSettings.llm_base_url, - AGENT: apiSettings.agent, - LANGUAGE: apiSettings.language, - CONFIRMATION_MODE: apiSettings.confirmation_mode, - SECURITY_ANALYZER: apiSettings.security_analyzer, - LLM_API_KEY_SET: apiSettings.llm_api_key_set, - SEARCH_API_KEY_SET: apiSettings.search_api_key_set, - REMOTE_RUNTIME_RESOURCE_FACTOR: apiSettings.remote_runtime_resource_factor, - PROVIDER_TOKENS_SET: apiSettings.provider_tokens_set, - ENABLE_DEFAULT_CONDENSER: apiSettings.enable_default_condenser, - CONDENSER_MAX_SIZE: - apiSettings.condenser_max_size ?? DEFAULT_SETTINGS.CONDENSER_MAX_SIZE, - ENABLE_SOUND_NOTIFICATIONS: apiSettings.enable_sound_notifications, - ENABLE_PROACTIVE_CONVERSATION_STARTERS: - apiSettings.enable_proactive_conversation_starters, - ENABLE_SOLVABILITY_ANALYSIS: apiSettings.enable_solvability_analysis, - USER_CONSENTS_TO_ANALYTICS: apiSettings.user_consents_to_analytics, - SEARCH_API_KEY: apiSettings.search_api_key || "", - MAX_BUDGET_PER_TASK: apiSettings.max_budget_per_task, - EMAIL: apiSettings.email || "", - EMAIL_VERIFIED: apiSettings.email_verified, - MCP_CONFIG: apiSettings.mcp_config, - GIT_USER_NAME: apiSettings.git_user_name || DEFAULT_SETTINGS.GIT_USER_NAME, - GIT_USER_EMAIL: - apiSettings.git_user_email || DEFAULT_SETTINGS.GIT_USER_EMAIL, - IS_NEW_USER: false, - V1_ENABLED: apiSettings.v1_enabled ?? DEFAULT_SETTINGS.V1_ENABLED, + ...settings, + condenser_max_size: + settings.condenser_max_size ?? DEFAULT_SETTINGS.condenser_max_size, + search_api_key: settings.search_api_key || "", + email: settings.email || "", + git_user_name: settings.git_user_name || DEFAULT_SETTINGS.git_user_name, + git_user_email: settings.git_user_email || DEFAULT_SETTINGS.git_user_email, + is_new_user: false, + v1_enabled: settings.v1_enabled ?? DEFAULT_SETTINGS.v1_enabled, }; }; diff --git a/frontend/src/hooks/query/use-start-tasks.ts b/frontend/src/hooks/query/use-start-tasks.ts index 6af56f2296..3fb1e8d47d 100644 --- a/frontend/src/hooks/query/use-start-tasks.ts +++ b/frontend/src/hooks/query/use-start-tasks.ts @@ -15,7 +15,7 @@ import { useSettings } from "#/hooks/query/use-settings"; */ export const useStartTasks = (limit = 10) => { const { data: settings } = useSettings(); - const isV1Enabled = settings?.V1_ENABLED; + const isV1Enabled = settings?.v1_enabled; return useQuery({ queryKey: ["start-tasks", "search", limit], diff --git a/frontend/src/hooks/use-settings-nav-items.ts b/frontend/src/hooks/use-settings-nav-items.ts new file mode 100644 index 0000000000..aa67e8cb9a --- /dev/null +++ b/frontend/src/hooks/use-settings-nav-items.ts @@ -0,0 +1,15 @@ +import { useConfig } from "#/hooks/query/use-config"; +import { SAAS_NAV_ITEMS, OSS_NAV_ITEMS } from "#/constants/settings-nav"; + +export function useSettingsNavItems() { + const { data: config } = useConfig(); + + const shouldHideLlmSettings = !!config?.FEATURE_FLAGS?.HIDE_LLM_SETTINGS; + const isSaasMode = config?.APP_MODE === "saas"; + + const items = isSaasMode ? SAAS_NAV_ITEMS : OSS_NAV_ITEMS; + + return shouldHideLlmSettings + ? items.filter((item) => item.to !== "/settings") + : items; +} diff --git a/frontend/src/hooks/use-sync-posthog-consent.ts b/frontend/src/hooks/use-sync-posthog-consent.ts index 615aa9a1bf..5032122794 100644 --- a/frontend/src/hooks/use-sync-posthog-consent.ts +++ b/frontend/src/hooks/use-sync-posthog-consent.ts @@ -19,7 +19,7 @@ export const useSyncPostHogConsent = () => { return; } - const backendConsent = settings.USER_CONSENTS_TO_ANALYTICS; + const backendConsent = settings.user_consents_to_analytics; // Only sync if there's a backend preference set if (backendConsent !== null) { diff --git a/frontend/src/hooks/use-tracking.ts b/frontend/src/hooks/use-tracking.ts index 0dfc0f0705..d04cdbb81a 100644 --- a/frontend/src/hooks/use-tracking.ts +++ b/frontend/src/hooks/use-tracking.ts @@ -17,7 +17,7 @@ export const useTracking = () => { app_surface: config?.APP_MODE || "unknown", plan_tier: null, current_url: window.location.href, - user_email: settings?.EMAIL || settings?.GIT_USER_EMAIL || null, + user_email: settings?.email || settings?.git_user_email || null, }; const trackLoginButtonClick = ({ provider }: { provider: Provider }) => { diff --git a/frontend/src/hooks/use-user-providers.ts b/frontend/src/hooks/use-user-providers.ts index d60102c2e0..c09130990b 100644 --- a/frontend/src/hooks/use-user-providers.ts +++ b/frontend/src/hooks/use-user-providers.ts @@ -6,8 +6,8 @@ export const useUserProviders = () => { const { data: settings, isLoading: isLoadingSettings } = useSettings(); const providers = React.useMemo( - () => convertRawProvidersToList(settings?.PROVIDER_TOKENS_SET), - [settings?.PROVIDER_TOKENS_SET], + () => convertRawProvidersToList(settings?.provider_tokens_set), + [settings?.provider_tokens_set], ); return { diff --git a/frontend/src/icons/loading.svg b/frontend/src/icons/loading.svg index 2da678957f..a5217fd608 100644 --- a/frontend/src/icons/loading.svg +++ b/frontend/src/icons/loading.svg @@ -1,3 +1,3 @@ - - + + diff --git a/frontend/src/mocks/analytics-handlers.ts b/frontend/src/mocks/analytics-handlers.ts new file mode 100644 index 0000000000..09b3ac0c60 --- /dev/null +++ b/frontend/src/mocks/analytics-handlers.ts @@ -0,0 +1,7 @@ +import { http, HttpResponse } from "msw"; + +export const ANALYTICS_HANDLERS = [ + http.post("https://us.i.posthog.com/e", async () => + HttpResponse.json(null, { status: 200 }), + ), +]; diff --git a/frontend/src/mocks/auth-handlers.ts b/frontend/src/mocks/auth-handlers.ts new file mode 100644 index 0000000000..bb4baf2397 --- /dev/null +++ b/frontend/src/mocks/auth-handlers.ts @@ -0,0 +1,23 @@ +import { http, HttpResponse } from "msw"; +import { GitUser } from "#/types/git"; + +export const AUTH_HANDLERS = [ + http.get("/api/user/info", () => { + const user: GitUser = { + id: "1", + login: "octocat", + avatar_url: "https://avatars.githubusercontent.com/u/583231?v=4", + company: "GitHub", + email: "placeholder@placeholder.placeholder", + name: "monalisa octocat", + }; + + return HttpResponse.json(user); + }), + + http.post("/api/authenticate", async () => + HttpResponse.json({ message: "Authenticated" }), + ), + + http.post("/api/logout", () => HttpResponse.json(null, { status: 200 })), +]; diff --git a/frontend/src/mocks/conversation-handlers.ts b/frontend/src/mocks/conversation-handlers.ts new file mode 100644 index 0000000000..1ec536fd92 --- /dev/null +++ b/frontend/src/mocks/conversation-handlers.ts @@ -0,0 +1,118 @@ +import { http, delay, HttpResponse } from "msw"; +import { Conversation, ResultSet } from "#/api/open-hands.types"; + +const conversations: Conversation[] = [ + { + conversation_id: "1", + title: "My New Project", + selected_repository: null, + git_provider: null, + selected_branch: null, + last_updated_at: new Date().toISOString(), + created_at: new Date().toISOString(), + status: "RUNNING", + runtime_status: "STATUS$READY", + url: null, + session_api_key: null, + }, + { + conversation_id: "2", + title: "Repo Testing", + selected_repository: "octocat/hello-world", + git_provider: "github", + selected_branch: null, + last_updated_at: new Date( + Date.now() - 2 * 24 * 60 * 60 * 1000, + ).toISOString(), + created_at: new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString(), + status: "STOPPED", + runtime_status: null, + url: null, + session_api_key: null, + }, + { + conversation_id: "3", + title: "Another Project", + selected_repository: "octocat/earth", + git_provider: null, + selected_branch: "main", + last_updated_at: new Date( + Date.now() - 5 * 24 * 60 * 60 * 1000, + ).toISOString(), + created_at: new Date(Date.now() - 5 * 24 * 60 * 60 * 1000).toISOString(), + status: "STOPPED", + runtime_status: null, + url: null, + session_api_key: null, + }, +]; + +const CONVERSATIONS = new Map( + conversations.map((c) => [c.conversation_id, c]), +); + +export const CONVERSATION_HANDLERS = [ + http.get("/api/conversations", async () => { + const values = Array.from(CONVERSATIONS.values()); + const results: ResultSet = { + results: values, + next_page_id: null, + }; + return HttpResponse.json(results); + }), + + http.get("/api/conversations/:conversationId", async ({ params }) => { + const conversationId = params.conversationId as string; + const project = CONVERSATIONS.get(conversationId); + if (project) return HttpResponse.json(project); + return HttpResponse.json(null, { status: 404 }); + }), + + http.post("/api/conversations", async () => { + await delay(); + const conversation: Conversation = { + conversation_id: (Math.random() * 100).toString(), + title: "New Conversation", + selected_repository: null, + git_provider: null, + selected_branch: null, + last_updated_at: new Date().toISOString(), + created_at: new Date().toISOString(), + status: "RUNNING", + runtime_status: "STATUS$READY", + url: null, + session_api_key: null, + }; + CONVERSATIONS.set(conversation.conversation_id, conversation); + return HttpResponse.json(conversation, { status: 201 }); + }), + + http.patch( + "/api/conversations/:conversationId", + async ({ params, request }) => { + const conversationId = params.conversationId as string; + const conversation = CONVERSATIONS.get(conversationId); + + if (conversation) { + const body = await request.json(); + if (typeof body === "object" && body?.title) { + CONVERSATIONS.set(conversationId, { + ...conversation, + title: body.title, + }); + return HttpResponse.json(null, { status: 200 }); + } + } + return HttpResponse.json(null, { status: 404 }); + }, + ), + + http.delete("/api/conversations/:conversationId", async ({ params }) => { + const conversationId = params.conversationId as string; + if (CONVERSATIONS.has(conversationId)) { + CONVERSATIONS.delete(conversationId); + return HttpResponse.json(null, { status: 200 }); + } + return HttpResponse.json(null, { status: 404 }); + }), +]; diff --git a/frontend/src/mocks/feedback-handlers.ts b/frontend/src/mocks/feedback-handlers.ts new file mode 100644 index 0000000000..8e4e602b33 --- /dev/null +++ b/frontend/src/mocks/feedback-handlers.ts @@ -0,0 +1,15 @@ +import { http, delay, HttpResponse } from "msw"; + +export const FEEDBACK_HANDLERS = [ + http.post("/api/submit-feedback", async () => { + await delay(1200); + return HttpResponse.json({ + statusCode: 200, + body: { message: "Success", link: "fake-url.com", password: "abc123" }, + }); + }), + + http.post("/api/submit-feedback", async () => + HttpResponse.json({ statusCode: 200 }, { status: 200 }), + ), +]; diff --git a/frontend/src/mocks/handlers.ts b/frontend/src/mocks/handlers.ts index 09053bfc31..999903ba93 100644 --- a/frontend/src/mocks/handlers.ts +++ b/frontend/src/mocks/handlers.ts @@ -1,146 +1,17 @@ -import { delay, http, HttpResponse } from "msw"; -import { GetConfigResponse } from "#/api/option-service/option.types"; -import { Conversation, ResultSet } from "#/api/open-hands.types"; -import { DEFAULT_SETTINGS } from "#/services/settings"; import { STRIPE_BILLING_HANDLERS } from "./billing-handlers"; -import { Provider } from "#/types/settings"; -import { - ApiSettings, - PostApiSettings, -} from "#/api/settings-service/settings.types"; import { FILE_SERVICE_HANDLERS } from "./file-service-handlers"; -import { GitUser } from "#/types/git"; import { TASK_SUGGESTIONS_HANDLERS } from "./task-suggestions-handlers"; import { SECRETS_HANDLERS } from "./secrets-handlers"; import { GIT_REPOSITORY_HANDLERS } from "./git-repository-handlers"; - -export const MOCK_DEFAULT_USER_SETTINGS: ApiSettings | PostApiSettings = { - llm_model: DEFAULT_SETTINGS.LLM_MODEL, - llm_base_url: DEFAULT_SETTINGS.LLM_BASE_URL, - llm_api_key: null, - llm_api_key_set: DEFAULT_SETTINGS.LLM_API_KEY_SET, - search_api_key_set: DEFAULT_SETTINGS.SEARCH_API_KEY_SET, - agent: DEFAULT_SETTINGS.AGENT, - language: DEFAULT_SETTINGS.LANGUAGE, - confirmation_mode: DEFAULT_SETTINGS.CONFIRMATION_MODE, - security_analyzer: DEFAULT_SETTINGS.SECURITY_ANALYZER, - remote_runtime_resource_factor: - DEFAULT_SETTINGS.REMOTE_RUNTIME_RESOURCE_FACTOR, - provider_tokens_set: {}, - enable_default_condenser: DEFAULT_SETTINGS.ENABLE_DEFAULT_CONDENSER, - condenser_max_size: DEFAULT_SETTINGS.CONDENSER_MAX_SIZE, - enable_sound_notifications: DEFAULT_SETTINGS.ENABLE_SOUND_NOTIFICATIONS, - enable_proactive_conversation_starters: - DEFAULT_SETTINGS.ENABLE_PROACTIVE_CONVERSATION_STARTERS, - enable_solvability_analysis: DEFAULT_SETTINGS.ENABLE_SOLVABILITY_ANALYSIS, - user_consents_to_analytics: DEFAULT_SETTINGS.USER_CONSENTS_TO_ANALYTICS, - max_budget_per_task: DEFAULT_SETTINGS.MAX_BUDGET_PER_TASK, -}; - -const MOCK_USER_PREFERENCES: { - settings: ApiSettings | PostApiSettings | null; -} = { - settings: null, -}; - -/** - * Set the user settings to the default settings - * - * Useful for resetting the settings in tests - */ -export const resetTestHandlersMockSettings = () => { - MOCK_USER_PREFERENCES.settings = MOCK_DEFAULT_USER_SETTINGS; -}; - -const conversations: Conversation[] = [ - { - conversation_id: "1", - title: "My New Project", - selected_repository: null, - git_provider: null, - selected_branch: null, - last_updated_at: new Date().toISOString(), - created_at: new Date().toISOString(), - status: "RUNNING", - runtime_status: "STATUS$READY", - url: null, - session_api_key: null, - }, - { - conversation_id: "2", - title: "Repo Testing", - selected_repository: "octocat/hello-world", - git_provider: "github", - selected_branch: null, - // 2 days ago - last_updated_at: new Date( - Date.now() - 2 * 24 * 60 * 60 * 1000, - ).toISOString(), - created_at: new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString(), - status: "STOPPED", - runtime_status: null, - url: null, - session_api_key: null, - }, - { - conversation_id: "3", - title: "Another Project", - selected_repository: "octocat/earth", - git_provider: null, - selected_branch: "main", - // 5 days ago - last_updated_at: new Date( - Date.now() - 5 * 24 * 60 * 60 * 1000, - ).toISOString(), - created_at: new Date(Date.now() - 5 * 24 * 60 * 60 * 1000).toISOString(), - status: "STOPPED", - runtime_status: null, - url: null, - session_api_key: null, - }, -]; - -const CONVERSATIONS = new Map( - conversations.map((conversation) => [ - conversation.conversation_id, - conversation, - ]), -); - -const openHandsHandlers = [ - http.get("/api/options/models", async () => - HttpResponse.json([ - "gpt-3.5-turbo", - "gpt-4o", - "gpt-4o-mini", - "anthropic/claude-3.5", - "anthropic/claude-sonnet-4-20250514", - "anthropic/claude-sonnet-4-5-20250929", - "anthropic/claude-haiku-4-5-20251001", - "openhands/claude-sonnet-4-20250514", - "openhands/claude-sonnet-4-5-20250929", - "openhands/claude-haiku-4-5-20251001", - "sambanova/Meta-Llama-3.1-8B-Instruct", - ]), - ), - - http.get("/api/options/agents", async () => - HttpResponse.json(["CodeActAgent", "CoActAgent"]), - ), - - http.get("/api/options/security-analyzers", async () => - HttpResponse.json(["llm", "none"]), - ), - - http.post("http://localhost:3001/api/submit-feedback", async () => { - await delay(1200); - - return HttpResponse.json({ - statusCode: 200, - body: { message: "Success", link: "fake-url.com", password: "abc123" }, - }); - }), -]; +import { + SETTINGS_HANDLERS, + MOCK_DEFAULT_USER_SETTINGS, + resetTestHandlersMockSettings, +} from "./settings-handlers"; +import { CONVERSATION_HANDLERS } from "./conversation-handlers"; +import { AUTH_HANDLERS } from "./auth-handlers"; +import { FEEDBACK_HANDLERS } from "./feedback-handlers"; +import { ANALYTICS_HANDLERS } from "./analytics-handlers"; export const handlers = [ ...STRIPE_BILLING_HANDLERS, @@ -148,192 +19,11 @@ export const handlers = [ ...TASK_SUGGESTIONS_HANDLERS, ...SECRETS_HANDLERS, ...GIT_REPOSITORY_HANDLERS, - ...openHandsHandlers, - http.get("/api/user/info", () => { - const user: GitUser = { - id: "1", - login: "octocat", - avatar_url: "https://avatars.githubusercontent.com/u/583231?v=4", - company: "GitHub", - email: "placeholder@placeholder.placeholder", - name: "monalisa octocat", - }; - - return HttpResponse.json(user); - }), - http.post("http://localhost:3001/api/submit-feedback", async () => - HttpResponse.json({ statusCode: 200 }, { status: 200 }), - ), - http.post("https://us.i.posthog.com/e", async () => - HttpResponse.json(null, { status: 200 }), - ), - http.get("/api/options/config", () => { - const mockSaas = import.meta.env.VITE_MOCK_SAAS === "true"; - - const config: GetConfigResponse = { - APP_MODE: mockSaas ? "saas" : "oss", - GITHUB_CLIENT_ID: "fake-github-client-id", - POSTHOG_CLIENT_KEY: "fake-posthog-client-key", - FEATURE_FLAGS: { - ENABLE_BILLING: false, - HIDE_LLM_SETTINGS: mockSaas, - ENABLE_JIRA: false, - ENABLE_JIRA_DC: false, - ENABLE_LINEAR: false, - }, - // Uncomment the following to test the maintenance banner - // MAINTENANCE: { - // startTime: "2024-01-15T10:00:00-05:00", // EST timestamp - // }, - }; - - return HttpResponse.json(config); - }), - http.get("/api/settings", async () => { - await delay(); - - const { settings } = MOCK_USER_PREFERENCES; - - if (!settings) return HttpResponse.json(null, { status: 404 }); - - return HttpResponse.json(settings); - }), - http.post("/api/settings", async ({ request }) => { - await delay(); - const body = await request.json(); - - if (body) { - const current = MOCK_USER_PREFERENCES.settings || { - ...MOCK_DEFAULT_USER_SETTINGS, - }; - // Persist new values over current/mock defaults - MOCK_USER_PREFERENCES.settings = { - ...current, - ...(body as Partial), - }; - return HttpResponse.json(null, { status: 200 }); - } - - return HttpResponse.json(null, { status: 400 }); - }), - - http.post("/api/authenticate", async () => - HttpResponse.json({ message: "Authenticated" }), - ), - - http.get("/api/conversations", async () => { - const values = Array.from(CONVERSATIONS.values()); - const results: ResultSet = { - results: values, - next_page_id: null, - }; - - return HttpResponse.json(results, { status: 200 }); - }), - - http.delete("/api/conversations/:conversationId", async ({ params }) => { - const { conversationId } = params; - - if (typeof conversationId === "string") { - CONVERSATIONS.delete(conversationId); - return HttpResponse.json(null, { status: 200 }); - } - - return HttpResponse.json(null, { status: 404 }); - }), - - http.patch( - "/api/conversations/:conversationId", - async ({ params, request }) => { - const { conversationId } = params; - - if (typeof conversationId === "string") { - const conversation = CONVERSATIONS.get(conversationId); - - if (conversation) { - const body = await request.json(); - if (typeof body === "object" && body?.title) { - CONVERSATIONS.set(conversationId, { - ...conversation, - title: body.title, - }); - return HttpResponse.json(null, { status: 200 }); - } - } - } - - return HttpResponse.json(null, { status: 404 }); - }, - ), - - http.post("/api/conversations", async () => { - await delay(); - - const conversation: Conversation = { - conversation_id: (Math.random() * 100).toString(), - title: "New Conversation", - selected_repository: null, - git_provider: null, - selected_branch: null, - last_updated_at: new Date().toISOString(), - created_at: new Date().toISOString(), - status: "RUNNING", - runtime_status: "STATUS$READY", - url: null, - session_api_key: null, - }; - - CONVERSATIONS.set(conversation.conversation_id, conversation); - return HttpResponse.json(conversation, { status: 201 }); - }), - - http.get("/api/conversations/:conversationId", async ({ params }) => { - const { conversationId } = params; - - if (typeof conversationId === "string") { - const project = CONVERSATIONS.get(conversationId); - - if (project) { - return HttpResponse.json(project, { status: 200 }); - } - } - - return HttpResponse.json(null, { status: 404 }); - }), - - http.post("/api/logout", () => HttpResponse.json(null, { status: 200 })), - - http.post("/api/reset-settings", async () => { - await delay(); - MOCK_USER_PREFERENCES.settings = { ...MOCK_DEFAULT_USER_SETTINGS }; - return HttpResponse.json(null, { status: 200 }); - }), - - http.post("/api/add-git-providers", async ({ request }) => { - const body = await request.json(); - - if (typeof body === "object" && body?.provider_tokens) { - const rawTokens = body.provider_tokens as Record< - string, - { token?: string } - >; - - const providerTokensSet: Partial> = - Object.fromEntries( - Object.entries(rawTokens) - .filter(([, val]) => val && val.token) - .map(([provider]) => [provider as Provider, ""]), - ); - - const newSettings = { - ...(MOCK_USER_PREFERENCES.settings ?? MOCK_DEFAULT_USER_SETTINGS), - provider_tokens_set: providerTokensSet, - }; - MOCK_USER_PREFERENCES.settings = newSettings; - - return HttpResponse.json(true, { status: 200 }); - } - - return HttpResponse.json(null, { status: 400 }); - }), + ...SETTINGS_HANDLERS, + ...CONVERSATION_HANDLERS, + ...AUTH_HANDLERS, + ...FEEDBACK_HANDLERS, + ...ANALYTICS_HANDLERS, ]; + +export { MOCK_DEFAULT_USER_SETTINGS, resetTestHandlersMockSettings }; diff --git a/frontend/src/mocks/settings-handlers.ts b/frontend/src/mocks/settings-handlers.ts new file mode 100644 index 0000000000..c08cd8dc36 --- /dev/null +++ b/frontend/src/mocks/settings-handlers.ts @@ -0,0 +1,151 @@ +import { http, delay, HttpResponse } from "msw"; +import { GetConfigResponse } from "#/api/option-service/option.types"; +import { DEFAULT_SETTINGS } from "#/services/settings"; +import { Provider, Settings } from "#/types/settings"; + +export const MOCK_DEFAULT_USER_SETTINGS: Settings = { + llm_model: DEFAULT_SETTINGS.llm_model, + llm_base_url: DEFAULT_SETTINGS.llm_base_url, + llm_api_key: null, + llm_api_key_set: DEFAULT_SETTINGS.llm_api_key_set, + search_api_key_set: DEFAULT_SETTINGS.search_api_key_set, + agent: DEFAULT_SETTINGS.agent, + language: DEFAULT_SETTINGS.language, + confirmation_mode: DEFAULT_SETTINGS.confirmation_mode, + security_analyzer: DEFAULT_SETTINGS.security_analyzer, + remote_runtime_resource_factor: + DEFAULT_SETTINGS.remote_runtime_resource_factor, + provider_tokens_set: {}, + enable_default_condenser: DEFAULT_SETTINGS.enable_default_condenser, + condenser_max_size: DEFAULT_SETTINGS.condenser_max_size, + enable_sound_notifications: DEFAULT_SETTINGS.enable_sound_notifications, + enable_proactive_conversation_starters: + DEFAULT_SETTINGS.enable_proactive_conversation_starters, + enable_solvability_analysis: DEFAULT_SETTINGS.enable_solvability_analysis, + user_consents_to_analytics: DEFAULT_SETTINGS.user_consents_to_analytics, + max_budget_per_task: DEFAULT_SETTINGS.max_budget_per_task, +}; + +const MOCK_USER_PREFERENCES: { + settings: Settings | null; +} = { + settings: null, +}; + +// Reset mock +export const resetTestHandlersMockSettings = () => { + MOCK_USER_PREFERENCES.settings = MOCK_DEFAULT_USER_SETTINGS; +}; + +// --- Handlers for options/config/settings --- + +export const SETTINGS_HANDLERS = [ + http.get("/api/options/models", async () => + HttpResponse.json([ + "gpt-3.5-turbo", + "gpt-4o", + "gpt-4o-mini", + "anthropic/claude-3.5", + "anthropic/claude-sonnet-4-20250514", + "anthropic/claude-sonnet-4-5-20250929", + "anthropic/claude-haiku-4-5-20251001", + "openhands/claude-sonnet-4-20250514", + "openhands/claude-sonnet-4-5-20250929", + "openhands/claude-haiku-4-5-20251001", + "sambanova/Meta-Llama-3.1-8B-Instruct", + ]), + ), + + http.get("/api/options/agents", async () => + HttpResponse.json(["CodeActAgent", "CoActAgent"]), + ), + + http.get("/api/options/security-analyzers", async () => + HttpResponse.json(["llm", "none"]), + ), + + http.get("/api/options/config", () => { + const mockSaas = import.meta.env.VITE_MOCK_SAAS === "true"; + + const config: GetConfigResponse = { + APP_MODE: mockSaas ? "saas" : "oss", + GITHUB_CLIENT_ID: "fake-github-client-id", + POSTHOG_CLIENT_KEY: "fake-posthog-client-key", + FEATURE_FLAGS: { + ENABLE_BILLING: false, + HIDE_LLM_SETTINGS: mockSaas, + ENABLE_JIRA: false, + ENABLE_JIRA_DC: false, + ENABLE_LINEAR: false, + }, + // Uncomment the following to test the maintenance banner + // MAINTENANCE: { + // startTime: "2024-01-15T10:00:00-05:00", // EST timestamp + // }, + }; + + return HttpResponse.json(config); + }), + + http.get("/api/settings", async () => { + await delay(); + const { settings } = MOCK_USER_PREFERENCES; + + if (!settings) return HttpResponse.json(null, { status: 404 }); + + return HttpResponse.json(settings); + }), + + http.post("/api/settings", async ({ request }) => { + await delay(); + const body = await request.json(); + + if (body) { + const current = MOCK_USER_PREFERENCES.settings || { + ...MOCK_DEFAULT_USER_SETTINGS, + }; + + MOCK_USER_PREFERENCES.settings = { + ...current, + ...(body as Partial), + }; + + return HttpResponse.json(null, { status: 200 }); + } + + return HttpResponse.json(null, { status: 400 }); + }), + + http.post("/api/reset-settings", async () => { + await delay(); + MOCK_USER_PREFERENCES.settings = { ...MOCK_DEFAULT_USER_SETTINGS }; + return HttpResponse.json(null, { status: 200 }); + }), + + http.post("/api/add-git-providers", async ({ request }) => { + const body = await request.json(); + + if (typeof body === "object" && body?.provider_tokens) { + const rawTokens = body.provider_tokens as Record< + string, + { token?: string } + >; + + const providerTokensSet: Partial> = + Object.fromEntries( + Object.entries(rawTokens) + .filter(([, val]) => val && val.token) + .map(([provider]) => [provider as Provider, ""]), + ); + + MOCK_USER_PREFERENCES.settings = { + ...(MOCK_USER_PREFERENCES.settings || MOCK_DEFAULT_USER_SETTINGS), + provider_tokens_set: providerTokensSet, + }; + + return HttpResponse.json(true, { status: 200 }); + } + + return HttpResponse.json(null, { status: 400 }); + }), +]; diff --git a/frontend/src/routes/app-settings.tsx b/frontend/src/routes/app-settings.tsx index e825bb3e0f..a8524cc989 100644 --- a/frontend/src/routes/app-settings.tsx +++ b/frontend/src/routes/app-settings.tsx @@ -56,7 +56,7 @@ function AppSettingsScreen() { const languageValue = AvailableLanguages.find( ({ label }) => label === languageLabel, )?.value; - const language = languageValue || DEFAULT_SETTINGS.LANGUAGE; + const language = languageValue || DEFAULT_SETTINGS.language; const enableAnalytics = formData.get("enable-analytics-switch")?.toString() === "on"; @@ -77,21 +77,21 @@ function AppSettingsScreen() { const gitUserName = formData.get("git-user-name-input")?.toString() || - DEFAULT_SETTINGS.GIT_USER_NAME; + DEFAULT_SETTINGS.git_user_name; const gitUserEmail = formData.get("git-user-email-input")?.toString() || - DEFAULT_SETTINGS.GIT_USER_EMAIL; + DEFAULT_SETTINGS.git_user_email; saveSettings( { - LANGUAGE: language, + language, user_consents_to_analytics: enableAnalytics, - ENABLE_SOUND_NOTIFICATIONS: enableSoundNotifications, - ENABLE_PROACTIVE_CONVERSATION_STARTERS: enableProactiveConversations, - ENABLE_SOLVABILITY_ANALYSIS: enableSolvabilityAnalysis, - MAX_BUDGET_PER_TASK: maxBudgetPerTask, - GIT_USER_NAME: gitUserName, - GIT_USER_EMAIL: gitUserEmail, + enable_sound_notifications: enableSoundNotifications, + enable_proactive_conversation_starters: enableProactiveConversations, + enable_solvability_analysis: enableSolvabilityAnalysis, + max_budget_per_task: maxBudgetPerTask, + git_user_name: gitUserName, + git_user_email: gitUserEmail, }, { onSuccess: () => { @@ -120,7 +120,7 @@ function AppSettingsScreen() { ({ label: langValue }) => langValue === value, )?.label; const currentLanguage = AvailableLanguages.find( - ({ value: langValue }) => langValue === settings?.LANGUAGE, + ({ value: langValue }) => langValue === settings?.language, )?.label; setLanguageInputHasChanged(selectedLanguage !== currentLanguage); @@ -128,12 +128,12 @@ function AppSettingsScreen() { const checkIfAnalyticsSwitchHasChanged = (checked: boolean) => { // Treat null as true since analytics is opt-in by default - const currentAnalytics = settings?.USER_CONSENTS_TO_ANALYTICS ?? true; + const currentAnalytics = settings?.user_consents_to_analytics ?? true; setAnalyticsSwitchHasChanged(checked !== currentAnalytics); }; const checkIfSoundNotificationsSwitchHasChanged = (checked: boolean) => { - const currentSoundNotifications = !!settings?.ENABLE_SOUND_NOTIFICATIONS; + const currentSoundNotifications = !!settings?.enable_sound_notifications; setSoundNotificationsSwitchHasChanged( checked !== currentSoundNotifications, ); @@ -141,14 +141,14 @@ function AppSettingsScreen() { const checkIfProactiveConversationsSwitchHasChanged = (checked: boolean) => { const currentProactiveConversations = - !!settings?.ENABLE_PROACTIVE_CONVERSATION_STARTERS; + !!settings?.enable_proactive_conversation_starters; setProactiveConversationsSwitchHasChanged( checked !== currentProactiveConversations, ); }; const checkIfSolvabilityAnalysisSwitchHasChanged = (checked: boolean) => { - const currentSolvabilityAnalysis = !!settings?.ENABLE_SOLVABILITY_ANALYSIS; + const currentSolvabilityAnalysis = !!settings?.enable_solvability_analysis; setSolvabilityAnalysisSwitchHasChanged( checked !== currentSolvabilityAnalysis, ); @@ -156,17 +156,17 @@ function AppSettingsScreen() { const checkIfMaxBudgetPerTaskHasChanged = (value: string) => { const newValue = parseMaxBudgetPerTask(value); - const currentValue = settings?.MAX_BUDGET_PER_TASK; + const currentValue = settings?.max_budget_per_task; setMaxBudgetPerTaskHasChanged(newValue !== currentValue); }; const checkIfGitUserNameHasChanged = (value: string) => { - const currentValue = settings?.GIT_USER_NAME; + const currentValue = settings?.git_user_name; setGitUserNameHasChanged(value !== currentValue); }; const checkIfGitUserEmailHasChanged = (value: string) => { - const currentValue = settings?.GIT_USER_EMAIL; + const currentValue = settings?.git_user_email; setGitUserEmailHasChanged(value !== currentValue); }; @@ -193,14 +193,14 @@ function AppSettingsScreen() {
{t(I18nKey.ANALYTICS$SEND_ANONYMOUS_DATA)} @@ -209,7 +209,7 @@ function AppSettingsScreen() { {t(I18nKey.SETTINGS$SOUND_NOTIFICATIONS)} @@ -220,7 +220,7 @@ function AppSettingsScreen() { testId="enable-proactive-conversations-switch" name="enable-proactive-conversations-switch" defaultIsToggled={ - !!settings.ENABLE_PROACTIVE_CONVERSATION_STARTERS + !!settings.enable_proactive_conversation_starters } onToggle={checkIfProactiveConversationsSwitchHasChanged} > @@ -232,20 +232,20 @@ function AppSettingsScreen() { {t(I18nKey.SETTINGS$SOLVABILITY_ANALYSIS)} )} - {!settings?.V1_ENABLED && ( + {!settings?.v1_enabled && ( ( @@ -111,7 +111,7 @@ function LlmSettingsScreen() { ); // Determine if we should hide the API key input and use OpenHands-managed key (when using OpenHands provider in SaaS mode) - const currentModel = currentSelectedModel || settings?.LLM_MODEL; + const currentModel = currentSelectedModel || settings?.llm_model; const isSaasMode = config?.APP_MODE === "saas"; @@ -124,7 +124,7 @@ function LlmSettingsScreen() { if (dirtyInputs.model) { return currentModel?.startsWith("openhands/"); } - return settings?.LLM_MODEL?.startsWith("openhands/"); + return settings?.llm_model?.startsWith("openhands/"); } return false; @@ -133,13 +133,13 @@ function LlmSettingsScreen() { const shouldUseOpenHandsKey = isOpenHandsProvider() && isSaasMode; // Determine if we should hide the agent dropdown when V1 conversation API is enabled - const isV1Enabled = settings?.V1_ENABLED; + const isV1Enabled = settings?.v1_enabled; React.useEffect(() => { const determineWhetherToToggleAdvancedSettings = () => { if (resources && settings) { return ( - isCustomModel(resources.models, settings.LLM_MODEL) || + isCustomModel(resources.models, settings.llm_model) || hasAdvancedSettingsSet({ ...settings, }) @@ -157,24 +157,24 @@ function LlmSettingsScreen() { // Initialize currentSelectedModel with the current settings React.useEffect(() => { - if (settings?.LLM_MODEL) { - setCurrentSelectedModel(settings.LLM_MODEL); + if (settings?.llm_model) { + setCurrentSelectedModel(settings.llm_model); } - }, [settings?.LLM_MODEL]); + }, [settings?.llm_model]); // Update confirmation mode state when settings change React.useEffect(() => { - if (settings?.CONFIRMATION_MODE !== undefined) { - setConfirmationModeEnabled(settings.CONFIRMATION_MODE); + if (settings?.confirmation_mode !== undefined) { + setConfirmationModeEnabled(settings.confirmation_mode); } - }, [settings?.CONFIRMATION_MODE]); + }, [settings?.confirmation_mode]); // Update selected security analyzer state when settings change React.useEffect(() => { - if (settings?.SECURITY_ANALYZER !== undefined) { - setSelectedSecurityAnalyzer(settings.SECURITY_ANALYZER || "none"); + if (settings?.security_analyzer !== undefined) { + setSelectedSecurityAnalyzer(settings.security_analyzer || "none"); } - }, [settings?.SECURITY_ANALYZER]); + }, [settings?.security_analyzer]); // Handle URL parameters for SaaS subscription redirects React.useEffect(() => { @@ -230,19 +230,19 @@ function LlmSettingsScreen() { saveSettings( { - LLM_MODEL: fullLlmModel, + llm_model: fullLlmModel, llm_api_key: finalApiKey || null, - SEARCH_API_KEY: searchApiKey || "", - CONFIRMATION_MODE: confirmationMode, - SECURITY_ANALYZER: + search_api_key: searchApiKey || "", + confirmation_mode: confirmationMode, + security_analyzer: securityAnalyzer === "none" ? null - : securityAnalyzer || DEFAULT_SETTINGS.SECURITY_ANALYZER, + : securityAnalyzer || DEFAULT_SETTINGS.security_analyzer, // reset advanced settings - LLM_BASE_URL: DEFAULT_SETTINGS.LLM_BASE_URL, - AGENT: DEFAULT_SETTINGS.AGENT, - ENABLE_DEFAULT_CONDENSER: DEFAULT_SETTINGS.ENABLE_DEFAULT_CONDENSER, + llm_base_url: DEFAULT_SETTINGS.llm_base_url, + agent: DEFAULT_SETTINGS.agent, + enable_default_condenser: DEFAULT_SETTINGS.enable_default_condenser, }, { onSuccess: handleSuccessfulMutation, @@ -281,19 +281,19 @@ function LlmSettingsScreen() { saveSettings( { - LLM_MODEL: model, - LLM_BASE_URL: baseUrl, + llm_model: model, + llm_base_url: baseUrl, llm_api_key: finalApiKey || null, - SEARCH_API_KEY: searchApiKey || "", - AGENT: agent, - CONFIRMATION_MODE: confirmationMode, - ENABLE_DEFAULT_CONDENSER: enableDefaultCondenser, - CONDENSER_MAX_SIZE: - condenserMaxSize ?? DEFAULT_SETTINGS.CONDENSER_MAX_SIZE, - SECURITY_ANALYZER: + search_api_key: searchApiKey || "", + agent, + confirmation_mode: confirmationMode, + enable_default_condenser: enableDefaultCondenser, + condenser_max_size: + condenserMaxSize ?? DEFAULT_SETTINGS.condenser_max_size, + security_analyzer: securityAnalyzer === "none" ? null - : securityAnalyzer || DEFAULT_SETTINGS.SECURITY_ANALYZER, + : securityAnalyzer || DEFAULT_SETTINGS.security_analyzer, }, { onSuccess: handleSuccessfulMutation, @@ -323,7 +323,7 @@ function LlmSettingsScreen() { ) => { // openai providers are special case; see ModelSelector // component for details - const modelIsDirty = model !== settings?.LLM_MODEL.replace("openai/", ""); + const modelIsDirty = model !== settings?.llm_model.replace("openai/", ""); setDirtyInputs((prev) => ({ ...prev, model: modelIsDirty, @@ -351,7 +351,7 @@ function LlmSettingsScreen() { }; const handleSearchApiKeyIsDirty = (searchApiKey: string) => { - const searchApiKeyIsDirty = searchApiKey !== settings?.SEARCH_API_KEY; + const searchApiKeyIsDirty = searchApiKey !== settings?.search_api_key; setDirtyInputs((prev) => ({ ...prev, searchApiKey: searchApiKeyIsDirty, @@ -359,7 +359,7 @@ function LlmSettingsScreen() { }; const handleCustomModelIsDirty = (model: string) => { - const modelIsDirty = model !== settings?.LLM_MODEL && model !== ""; + const modelIsDirty = model !== settings?.llm_model && model !== ""; setDirtyInputs((prev) => ({ ...prev, model: modelIsDirty, @@ -370,7 +370,7 @@ function LlmSettingsScreen() { }; const handleBaseUrlIsDirty = (baseUrl: string) => { - const baseUrlIsDirty = baseUrl !== settings?.LLM_BASE_URL; + const baseUrlIsDirty = baseUrl !== settings?.llm_base_url; setDirtyInputs((prev) => ({ ...prev, baseUrl: baseUrlIsDirty, @@ -378,7 +378,7 @@ function LlmSettingsScreen() { }; const handleAgentIsDirty = (agent: string) => { - const agentIsDirty = agent !== settings?.AGENT && agent !== ""; + const agentIsDirty = agent !== settings?.agent && agent !== ""; setDirtyInputs((prev) => ({ ...prev, agent: agentIsDirty, @@ -386,7 +386,7 @@ function LlmSettingsScreen() { }; const handleConfirmationModeIsDirty = (isToggled: boolean) => { - const confirmationModeIsDirty = isToggled !== settings?.CONFIRMATION_MODE; + const confirmationModeIsDirty = isToggled !== settings?.confirmation_mode; setDirtyInputs((prev) => ({ ...prev, confirmationMode: confirmationModeIsDirty, @@ -395,7 +395,7 @@ function LlmSettingsScreen() { // When confirmation mode is enabled, set default security analyzer to "llm" if not already set if (isToggled && !selectedSecurityAnalyzer) { - setSelectedSecurityAnalyzer(DEFAULT_SETTINGS.SECURITY_ANALYZER); + setSelectedSecurityAnalyzer(DEFAULT_SETTINGS.security_analyzer); setDirtyInputs((prev) => ({ ...prev, securityAnalyzer: true, @@ -405,7 +405,7 @@ function LlmSettingsScreen() { const handleEnableDefaultCondenserIsDirty = (isToggled: boolean) => { const enableDefaultCondenserIsDirty = - isToggled !== settings?.ENABLE_DEFAULT_CONDENSER; + isToggled !== settings?.enable_default_condenser; setDirtyInputs((prev) => ({ ...prev, enableDefaultCondenser: enableDefaultCondenserIsDirty, @@ -416,8 +416,8 @@ function LlmSettingsScreen() { const parsed = value ? Number.parseInt(value, 10) : undefined; const bounded = parsed !== undefined ? Math.max(20, parsed) : undefined; const condenserMaxSizeIsDirty = - (bounded ?? DEFAULT_SETTINGS.CONDENSER_MAX_SIZE) !== - (settings?.CONDENSER_MAX_SIZE ?? DEFAULT_SETTINGS.CONDENSER_MAX_SIZE); + (bounded ?? DEFAULT_SETTINGS.condenser_max_size) !== + (settings?.condenser_max_size ?? DEFAULT_SETTINGS.condenser_max_size); setDirtyInputs((prev) => ({ ...prev, condenserMaxSize: condenserMaxSizeIsDirty, @@ -426,7 +426,7 @@ function LlmSettingsScreen() { const handleSecurityAnalyzerIsDirty = (securityAnalyzer: string) => { const securityAnalyzerIsDirty = - securityAnalyzer !== settings?.SECURITY_ANALYZER; + securityAnalyzer !== settings?.security_analyzer; setDirtyInputs((prev) => ({ ...prev, securityAnalyzer: securityAnalyzerIsDirty, @@ -512,12 +512,12 @@ function LlmSettingsScreen() { <> - {(settings.LLM_MODEL?.startsWith("openhands/") || + {(settings.llm_model?.startsWith("openhands/") || currentSelectedModel?.startsWith("openhands/")) && ( )} @@ -532,11 +532,11 @@ function LlmSettingsScreen() { label={t(I18nKey.SETTINGS_FORM$API_KEY)} type="password" className="w-full max-w-[680px]" - placeholder={settings.LLM_API_KEY_SET ? "" : ""} + placeholder={settings.llm_api_key_set ? "" : ""} onChange={handleApiKeyIsDirty} startContent={ - settings.LLM_API_KEY_SET && ( - + settings.llm_api_key_set && ( + ) } /> @@ -561,13 +561,13 @@ function LlmSettingsScreen() { testId="llm-custom-model-input" name="llm-custom-model-input" label={t(I18nKey.SETTINGS$CUSTOM_MODEL)} - defaultValue={settings.LLM_MODEL || DEFAULT_OPENHANDS_MODEL} + defaultValue={settings.llm_model || DEFAULT_OPENHANDS_MODEL} placeholder={DEFAULT_OPENHANDS_MODEL} type="text" className="w-full max-w-[680px]" onChange={handleCustomModelIsDirty} /> - {(settings.LLM_MODEL?.startsWith("openhands/") || + {(settings.llm_model?.startsWith("openhands/") || currentSelectedModel?.startsWith("openhands/")) && ( )} @@ -576,7 +576,7 @@ function LlmSettingsScreen() { testId="base-url-input" name="base-url-input" label={t(I18nKey.SETTINGS$BASE_URL)} - defaultValue={settings.LLM_BASE_URL} + defaultValue={settings.llm_base_url} placeholder="https://api.openai.com" type="text" className="w-full max-w-[680px]" @@ -591,11 +591,11 @@ function LlmSettingsScreen() { label={t(I18nKey.SETTINGS_FORM$API_KEY)} type="password" className="w-full max-w-[680px]" - placeholder={settings.LLM_API_KEY_SET ? "" : ""} + placeholder={settings.llm_api_key_set ? "" : ""} onChange={handleApiKeyIsDirty} startContent={ - settings.LLM_API_KEY_SET && ( - + settings.llm_api_key_set && ( + ) } /> @@ -616,12 +616,12 @@ function LlmSettingsScreen() { label={t(I18nKey.SETTINGS$SEARCH_API_KEY)} type="password" className="w-full max-w-[680px]" - defaultValue={settings.SEARCH_API_KEY || ""} + defaultValue={settings.search_api_key || ""} onChange={handleSearchApiKeyIsDirty} placeholder={t(I18nKey.API$TVLY_KEY_EXAMPLE)} startContent={ - settings.SEARCH_API_KEY_SET && ( - + settings.search_api_key_set && ( + ) } /> @@ -644,7 +644,7 @@ function LlmSettingsScreen() { label: agent, // TODO: Add i18n support for agent names })) || [] } - defaultSelectedKey={settings.AGENT} + defaultSelectedKey={settings.agent} isClearable={false} onInputChange={handleAgentIsDirty} wrapperClassName="w-full max-w-[680px]" @@ -662,11 +662,11 @@ function LlmSettingsScreen() { step={1} label={t(I18nKey.SETTINGS$CONDENSER_MAX_SIZE)} defaultValue={( - settings.CONDENSER_MAX_SIZE ?? - DEFAULT_SETTINGS.CONDENSER_MAX_SIZE + settings.condenser_max_size ?? + DEFAULT_SETTINGS.condenser_max_size )?.toString()} onChange={(value) => handleCondenserMaxSizeIsDirty(value)} - isDisabled={!settings.ENABLE_DEFAULT_CONDENSER} + isDisabled={!settings.enable_default_condenser} />

{t(I18nKey.SETTINGS$CONDENSER_MAX_SIZE_TOOLTIP)} @@ -676,7 +676,7 @@ function LlmSettingsScreen() { {t(I18nKey.SETTINGS$ENABLE_MEMORY_CONDENSATION)} @@ -688,7 +688,7 @@ function LlmSettingsScreen() { testId="enable-confirmation-mode-switch" name="enable-confirmation-mode-switch" onToggle={handleConfirmationModeIsDirty} - defaultIsToggled={settings.CONFIRMATION_MODE} + defaultIsToggled={settings.confirmation_mode} isBeta > {t(I18nKey.SETTINGS$CONFIRMATION_MODE)} diff --git a/frontend/src/routes/mcp-settings.tsx b/frontend/src/routes/mcp-settings.tsx index 0a4224182b..e308b45228 100644 --- a/frontend/src/routes/mcp-settings.tsx +++ b/frontend/src/routes/mcp-settings.tsx @@ -41,7 +41,7 @@ function MCPSettingsScreen() { useState(false); const [serverToDelete, setServerToDelete] = useState(null); - const mcpConfig: MCPConfig = settings?.MCP_CONFIG || { + const mcpConfig: MCPConfig = settings?.mcp_config || { sse_servers: [], stdio_servers: [], shttp_servers: [], diff --git a/frontend/src/routes/root-layout.tsx b/frontend/src/routes/root-layout.tsx index 264ae541c8..876c4d8c11 100644 --- a/frontend/src/routes/root-layout.tsx +++ b/frontend/src/routes/root-layout.tsx @@ -106,16 +106,16 @@ export default function MainApp() { React.useEffect(() => { // Don't change language when on TOS page - if (!isOnTosPage && settings?.LANGUAGE) { - i18n.changeLanguage(settings.LANGUAGE); + if (!isOnTosPage && settings?.language) { + i18n.changeLanguage(settings.language); } - }, [settings?.LANGUAGE, isOnTosPage]); + }, [settings?.language, isOnTosPage]); React.useEffect(() => { // Don't show consent form when on TOS page if (!isOnTosPage) { const consentFormModalIsOpen = - settings?.USER_CONSENTS_TO_ANALYTICS === null; + settings?.user_consents_to_analytics === null; setConsentFormIsOpen(consentFormModalIsOpen); } @@ -134,10 +134,10 @@ export default function MainApp() { }, [isOnTosPage]); React.useEffect(() => { - if (settings?.IS_NEW_USER && config.data?.APP_MODE === "saas") { + if (settings?.is_new_user && config.data?.APP_MODE === "saas") { displaySuccessToast(t(I18nKey.BILLING$YOURE_IN)); } - }, [settings?.IS_NEW_USER, config.data?.APP_MODE]); + }, [settings?.is_new_user, config.data?.APP_MODE]); React.useEffect(() => { // Don't do any redirects when on TOS page @@ -249,7 +249,7 @@ export default function MainApp() { {config.data?.FEATURE_FLAGS.ENABLE_BILLING && config.data?.APP_MODE === "saas" && - settings?.IS_NEW_USER && } + settings?.is_new_user && }

); } diff --git a/frontend/src/routes/settings.tsx b/frontend/src/routes/settings.tsx index 2d7f7cb6b6..4f35595d13 100644 --- a/frontend/src/routes/settings.tsx +++ b/frontend/src/routes/settings.tsx @@ -1,14 +1,13 @@ import { useMemo } from "react"; import { Outlet, redirect, useLocation } from "react-router"; import { useTranslation } from "react-i18next"; -import { useConfig } from "#/hooks/query/use-config"; import { Route } from "./+types/settings"; import OptionService from "#/api/option-service/option-service.api"; import { queryClient } from "#/query-client-config"; import { GetConfigResponse } from "#/api/option-service/option.types"; -import { SAAS_NAV_ITEMS, OSS_NAV_ITEMS } from "#/constants/settings-nav"; -import { Typography } from "#/ui/typography"; import { SettingsLayout } from "#/components/features/settings/settings-layout"; +import { Typography } from "#/ui/typography"; +import { useSettingsNavItems } from "#/hooks/use-settings-nav-items"; const SAAS_ONLY_PATHS = [ "/settings/user", @@ -33,14 +32,10 @@ export const clientLoader = async ({ request }: Route.ClientLoaderArgs) => { // if in OSS mode, do not allow access to saas-only paths return redirect("/settings"); } - // If LLM settings are hidden and user tries to access the LLM settings page if (config?.FEATURE_FLAGS?.HIDE_LLM_SETTINGS && pathname === "/settings") { // Redirect to the first available settings page - if (isSaas) { - return redirect("/settings/user"); - } - return redirect("/settings/mcp"); + return isSaas ? redirect("/settings/user") : redirect("/settings/mcp"); } return null; @@ -48,37 +43,15 @@ export const clientLoader = async ({ request }: Route.ClientLoaderArgs) => { function SettingsScreen() { const { t } = useTranslation(); - const { data: config } = useConfig(); const location = useLocation(); - - const isSaas = config?.APP_MODE === "saas"; - - // Navigation items configuration - const navItems = useMemo(() => { - const items = []; - if (isSaas) { - items.push(...SAAS_NAV_ITEMS); - } else { - items.push(...OSS_NAV_ITEMS); - } - - // Filter out LLM settings if the feature flag is enabled - if (config?.FEATURE_FLAGS?.HIDE_LLM_SETTINGS) { - return items.filter((item) => item.to !== "/settings"); - } - - return items; - }, [isSaas, config?.FEATURE_FLAGS?.HIDE_LLM_SETTINGS]); - + const navItems = useSettingsNavItems(); // Current section title for the main content area const currentSectionTitle = useMemo(() => { const currentItem = navItems.find((item) => item.to === location.pathname); - if (currentItem) { - return currentItem.text; - } - // Default to the first available navigation item if current page is not found - return navItems.length > 0 ? navItems[0].text : "SETTINGS$TITLE"; + return currentItem + ? currentItem.text + : (navItems[0]?.text ?? "SETTINGS$TITLE"); }, [navItems, location.pathname]); return ( diff --git a/frontend/src/routes/user-settings.tsx b/frontend/src/routes/user-settings.tsx index 93366574b0..cddc38466e 100644 --- a/frontend/src/routes/user-settings.tsx +++ b/frontend/src/routes/user-settings.tsx @@ -122,12 +122,12 @@ function UserSettingsScreen() { const prevVerificationStatusRef = useRef(undefined); useEffect(() => { - if (settings?.EMAIL) { - setEmail(settings.EMAIL); - setOriginalEmail(settings.EMAIL); - setIsEmailValid(EMAIL_REGEX.test(settings.EMAIL)); + if (settings?.email) { + setEmail(settings.email); + setOriginalEmail(settings.email); + setIsEmailValid(EMAIL_REGEX.test(settings.email)); } - }, [settings?.EMAIL]); + }, [settings?.email]); useEffect(() => { if (pollingIntervalRef.current) { @@ -137,7 +137,7 @@ function UserSettingsScreen() { if ( prevVerificationStatusRef.current === false && - settings?.EMAIL_VERIFIED === true + settings?.email_verified === true ) { // Display toast notification instead of setting state displaySuccessToast(t("SETTINGS$EMAIL_VERIFIED_SUCCESSFULLY")); @@ -146,9 +146,9 @@ function UserSettingsScreen() { }, 2000); } - prevVerificationStatusRef.current = settings?.EMAIL_VERIFIED; + prevVerificationStatusRef.current = settings?.email_verified; - if (settings?.EMAIL_VERIFIED === false) { + if (settings?.email_verified === false) { pollingIntervalRef.current = window.setInterval(() => { refetch(); }, 5000); @@ -160,7 +160,7 @@ function UserSettingsScreen() { pollingIntervalRef.current = null; } }; - }, [settings?.EMAIL_VERIFIED, refetch, queryClient, t]); + }, [settings?.email_verified, refetch, queryClient, t]); const handleEmailChange = (e: React.ChangeEvent) => { const newEmail = e.target.value; @@ -215,10 +215,10 @@ function UserSettingsScreen() { isSaving={isSaving} isResendingVerification={isResendingVerification} isEmailChanged={isEmailChanged} - emailVerified={settings?.EMAIL_VERIFIED} + emailVerified={settings?.email_verified} isEmailValid={isEmailValid} > - {settings?.EMAIL_VERIFIED === false && } + {settings?.email_verified === false && } )}
diff --git a/frontend/src/services/settings.ts b/frontend/src/services/settings.ts index 7c648247d6..1191e0ea68 100644 --- a/frontend/src/services/settings.ts +++ b/frontend/src/services/settings.ts @@ -3,35 +3,36 @@ import { Settings } from "#/types/settings"; export const LATEST_SETTINGS_VERSION = 5; export const DEFAULT_SETTINGS: Settings = { - LLM_MODEL: "openhands/claude-sonnet-4-20250514", - LLM_BASE_URL: "", - AGENT: "CodeActAgent", - LANGUAGE: "en", - LLM_API_KEY_SET: false, - SEARCH_API_KEY_SET: false, - CONFIRMATION_MODE: false, - SECURITY_ANALYZER: "llm", - REMOTE_RUNTIME_RESOURCE_FACTOR: 1, - PROVIDER_TOKENS_SET: {}, - ENABLE_DEFAULT_CONDENSER: true, - CONDENSER_MAX_SIZE: 120, - ENABLE_SOUND_NOTIFICATIONS: false, - USER_CONSENTS_TO_ANALYTICS: false, - ENABLE_PROACTIVE_CONVERSATION_STARTERS: false, - ENABLE_SOLVABILITY_ANALYSIS: false, - SEARCH_API_KEY: "", - IS_NEW_USER: true, - MAX_BUDGET_PER_TASK: null, - EMAIL: "", - EMAIL_VERIFIED: true, // Default to true to avoid restricting access unnecessarily - MCP_CONFIG: { + llm_model: "openhands/claude-sonnet-4-20250514", + llm_base_url: "", + agent: "CodeActAgent", + language: "en", + llm_api_key: null, + llm_api_key_set: false, + search_api_key_set: false, + confirmation_mode: false, + security_analyzer: "llm", + remote_runtime_resource_factor: 1, + provider_tokens_set: {}, + enable_default_condenser: true, + condenser_max_size: 120, + enable_sound_notifications: false, + user_consents_to_analytics: false, + enable_proactive_conversation_starters: false, + enable_solvability_analysis: false, + search_api_key: "", + is_new_user: true, + max_budget_per_task: null, + email: "", + email_verified: true, // Default to true to avoid restricting access unnecessarily + mcp_config: { sse_servers: [], stdio_servers: [], shttp_servers: [], }, - GIT_USER_NAME: "openhands", - GIT_USER_EMAIL: "openhands@all-hands.dev", - V1_ENABLED: false, + git_user_name: "openhands", + git_user_email: "openhands@all-hands.dev", + v1_enabled: false, }; /** diff --git a/frontend/src/types/settings.ts b/frontend/src/types/settings.ts index 2299288132..e5db0296bd 100644 --- a/frontend/src/types/settings.ts +++ b/frontend/src/types/settings.ts @@ -38,37 +38,31 @@ export type MCPConfig = { }; export type Settings = { - LLM_MODEL: string; - LLM_BASE_URL: string; - AGENT: string; - LANGUAGE: string; - LLM_API_KEY_SET: boolean; - SEARCH_API_KEY_SET: boolean; - CONFIRMATION_MODE: boolean; - SECURITY_ANALYZER: string | null; - REMOTE_RUNTIME_RESOURCE_FACTOR: number | null; - PROVIDER_TOKENS_SET: Partial>; - ENABLE_DEFAULT_CONDENSER: boolean; + llm_model: string; + llm_base_url: string; + agent: string; + language: string; + llm_api_key: string | null; + llm_api_key_set: boolean; + search_api_key_set: boolean; + confirmation_mode: boolean; + security_analyzer: string | null; + remote_runtime_resource_factor: number | null; + provider_tokens_set: Partial>; + enable_default_condenser: boolean; // Maximum number of events before the condenser runs - CONDENSER_MAX_SIZE: number | null; - ENABLE_SOUND_NOTIFICATIONS: boolean; - ENABLE_PROACTIVE_CONVERSATION_STARTERS: boolean; - ENABLE_SOLVABILITY_ANALYSIS: boolean; - USER_CONSENTS_TO_ANALYTICS: boolean | null; - SEARCH_API_KEY?: string; - IS_NEW_USER?: boolean; - MCP_CONFIG?: MCPConfig; - MAX_BUDGET_PER_TASK: number | null; - EMAIL?: string; - EMAIL_VERIFIED?: boolean; - GIT_USER_NAME?: string; - GIT_USER_EMAIL?: string; - V1_ENABLED?: boolean; -}; - -export type PostSettings = Settings & { + condenser_max_size: number | null; + enable_sound_notifications: boolean; + enable_proactive_conversation_starters: boolean; + enable_solvability_analysis: boolean; user_consents_to_analytics: boolean | null; - llm_api_key?: string | null; search_api_key?: string; + is_new_user?: boolean; mcp_config?: MCPConfig; + max_budget_per_task: number | null; + email?: string; + email_verified?: boolean; + git_user_name?: string; + git_user_email?: string; + v1_enabled?: boolean; }; diff --git a/frontend/src/utils/__tests__/settings-utils.test.ts b/frontend/src/utils/__tests__/settings-utils.test.ts index bebdaa0f88..bf2ae794f2 100644 --- a/frontend/src/utils/__tests__/settings-utils.test.ts +++ b/frontend/src/utils/__tests__/settings-utils.test.ts @@ -67,10 +67,10 @@ describe("extractSettings", () => { // Verify that the model name case is preserved const expectedModel = `${provider}/${model}`; - expect(settings.LLM_MODEL).toBe(expectedModel); + expect(settings.llm_model).toBe(expectedModel); // Only test that it's not lowercased if the original has uppercase letters if (expectedModel !== expectedModel.toLowerCase()) { - expect(settings.LLM_MODEL).not.toBe(expectedModel.toLowerCase()); + expect(settings.llm_model).not.toBe(expectedModel.toLowerCase()); } }); }); @@ -85,7 +85,7 @@ describe("extractSettings", () => { const settings = extractSettings(formData); // Custom model should take precedence and preserve case - expect(settings.LLM_MODEL).toBe("Custom-Model-Name"); - expect(settings.LLM_MODEL).not.toBe("custom-model-name"); + expect(settings.llm_model).toBe("Custom-Model-Name"); + expect(settings.llm_model).not.toBe("custom-model-name"); }); }); diff --git a/frontend/src/utils/has-advanced-settings-set.ts b/frontend/src/utils/has-advanced-settings-set.ts index 8cf3f10a39..b873425239 100644 --- a/frontend/src/utils/has-advanced-settings-set.ts +++ b/frontend/src/utils/has-advanced-settings-set.ts @@ -3,4 +3,4 @@ import { Settings } from "#/types/settings"; export const hasAdvancedSettingsSet = (settings: Partial): boolean => Object.keys(settings).length > 0 && - (!!settings.LLM_BASE_URL || settings.AGENT !== DEFAULT_SETTINGS.AGENT); + (!!settings.llm_base_url || settings.agent !== DEFAULT_SETTINGS.agent); diff --git a/frontend/src/utils/settings-utils.ts b/frontend/src/utils/settings-utils.ts index ca56b25170..4259226d77 100644 --- a/frontend/src/utils/settings-utils.ts +++ b/frontend/src/utils/settings-utils.ts @@ -67,9 +67,7 @@ export const parseMaxBudgetPerTask = (value: string): number | null => { : null; }; -export const extractSettings = ( - formData: FormData, -): Partial & { llm_api_key?: string | null } => { +export const extractSettings = (formData: FormData): Partial => { const { LLM_MODEL, LLM_API_KEY, AGENT, LANGUAGE } = extractBasicFormData(formData); @@ -82,14 +80,14 @@ export const extractSettings = ( } = extractAdvancedFormData(formData); return { - LLM_MODEL: CUSTOM_LLM_MODEL || LLM_MODEL, - LLM_API_KEY_SET: !!LLM_API_KEY, - AGENT, - LANGUAGE, - LLM_BASE_URL, - CONFIRMATION_MODE, - SECURITY_ANALYZER, - ENABLE_DEFAULT_CONDENSER, + llm_model: CUSTOM_LLM_MODEL || LLM_MODEL, + llm_api_key_set: !!LLM_API_KEY, + agent: AGENT, + language: LANGUAGE, + llm_base_url: LLM_BASE_URL, + confirmation_mode: CONFIRMATION_MODE, + security_analyzer: SECURITY_ANALYZER, + enable_default_condenser: ENABLE_DEFAULT_CONDENSER, llm_api_key: LLM_API_KEY, }; }; diff --git a/openhands/app_server/app_conversation/app_conversation_service_base.py b/openhands/app_server/app_conversation/app_conversation_service_base.py index f524167524..d5d34bd109 100644 --- a/openhands/app_server/app_conversation/app_conversation_service_base.py +++ b/openhands/app_server/app_conversation/app_conversation_service_base.py @@ -4,7 +4,11 @@ import tempfile from abc import ABC from dataclasses import dataclass from pathlib import Path -from typing import AsyncGenerator +from typing import TYPE_CHECKING, AsyncGenerator +from uuid import UUID + +if TYPE_CHECKING: + import httpx import base62 @@ -29,6 +33,14 @@ from openhands.sdk.context.agent_context import AgentContext from openhands.sdk.context.condenser import LLMSummarizingCondenser from openhands.sdk.context.skills import load_user_skills from openhands.sdk.llm import LLM +from openhands.sdk.security.analyzer import SecurityAnalyzerBase +from openhands.sdk.security.confirmation_policy import ( + AlwaysConfirm, + ConfirmationPolicyBase, + ConfirmRisky, + NeverConfirm, +) +from openhands.sdk.security.llm_analyzer import LLMSecurityAnalyzer from openhands.sdk.workspace.remote.async_remote_workspace import AsyncRemoteWorkspace _logger = logging.getLogger(__name__) @@ -379,3 +391,95 @@ class AppConversationServiceBase(AppConversationService, ABC): condenser = LLMSummarizingCondenser(**condenser_kwargs) return condenser + + def _create_security_analyzer_from_string( + self, security_analyzer_str: str | None + ) -> SecurityAnalyzerBase | None: + """Convert security analyzer string from settings to SecurityAnalyzerBase instance. + + Args: + security_analyzer_str: String value from settings. Valid values: + - "llm" -> LLMSecurityAnalyzer + - "none" or None -> None + - Other values -> None (unsupported analyzers are ignored) + + Returns: + SecurityAnalyzerBase instance or None + """ + if not security_analyzer_str or security_analyzer_str.lower() == 'none': + return None + + if security_analyzer_str.lower() == 'llm': + return LLMSecurityAnalyzer() + + # For unknown values, log a warning and return None + _logger.warning( + f'Unknown security analyzer value: {security_analyzer_str}. ' + 'Supported values: "llm", "none". Defaulting to None.' + ) + return None + + def _select_confirmation_policy( + self, confirmation_mode: bool, security_analyzer: str | None + ) -> ConfirmationPolicyBase: + """Choose confirmation policy using only mode flag and analyzer string.""" + if not confirmation_mode: + return NeverConfirm() + + analyzer_kind = (security_analyzer or '').lower() + if analyzer_kind == 'llm': + return ConfirmRisky() + + return AlwaysConfirm() + + async def _set_security_analyzer_from_settings( + self, + agent_server_url: str, + session_api_key: str | None, + conversation_id: UUID, + security_analyzer_str: str | None, + httpx_client: 'httpx.AsyncClient', + ) -> None: + """Set security analyzer on conversation using only the analyzer string. + + Args: + agent_server_url: URL of the agent server + session_api_key: Session API key for authentication + conversation_id: ID of the conversation to update + security_analyzer_str: String value from settings + httpx_client: HTTP client for making API requests + """ + + if session_api_key is None: + return + + security_analyzer = self._create_security_analyzer_from_string( + security_analyzer_str + ) + + # Only make API call if we have a security analyzer to set + # (None is the default, so we can skip the call if it's None) + if security_analyzer is None: + return + + try: + # Prepare the request payload + payload = {'security_analyzer': security_analyzer.model_dump()} + + # Call agent server API to set security analyzer + response = await httpx_client.post( + f'{agent_server_url}/api/conversations/{conversation_id}/security_analyzer', + json=payload, + headers={'X-Session-API-Key': session_api_key}, + timeout=30.0, + ) + response.raise_for_status() + _logger.info( + f'Successfully set security analyzer for conversation {conversation_id}' + ) + except Exception as e: + # Log error but don't fail conversation creation + _logger.warning( + f'Failed to set security analyzer for conversation {conversation_id}: {e}', + exc_info=True, + ) diff --git a/openhands/app_server/app_conversation/live_status_app_conversation_service.py b/openhands/app_server/app_conversation/live_status_app_conversation_service.py index 0762ceb5f7..6024152218 100644 --- a/openhands/app_server/app_conversation/live_status_app_conversation_service.py +++ b/openhands/app_server/app_conversation/live_status_app_conversation_service.py @@ -13,7 +13,6 @@ from pydantic import Field, SecretStr, TypeAdapter from openhands.agent_server.models import ( ConversationInfo, - NeverConfirm, SendMessageRequest, StartConversationRequest, ) @@ -73,7 +72,6 @@ from openhands.integrations.provider import ProviderType from openhands.sdk import Agent, AgentContext, LocalWorkspace from openhands.sdk.llm import LLM from openhands.sdk.secret import LookupSecret, StaticSecret -from openhands.sdk.security.confirmation_policy import AlwaysConfirm from openhands.sdk.workspace.remote.async_remote_workspace import AsyncRemoteWorkspace from openhands.server.types import AppMode from openhands.tools.preset.default import ( @@ -320,6 +318,16 @@ class LiveStatusAppConversationService(AppConversationServiceBase): ) ) + # Set security analyzer from settings + user = await self.user_context.get_user_info() + await self._set_security_analyzer_from_settings( + agent_server_url, + sandbox.session_api_key, + info.id, + user.security_analyzer, + self.httpx_client, + ) + # Update the start task task.status = AppConversationStartTaskStatus.READY task.app_conversation_id = info.id @@ -884,8 +892,8 @@ class LiveStatusAppConversationService(AppConversationServiceBase): conversation_id=conversation_id, agent=agent, workspace=workspace, - confirmation_policy=( - AlwaysConfirm() if user.confirmation_mode else NeverConfirm() + confirmation_policy=self._select_confirmation_policy( + bool(user.confirmation_mode), user.security_analyzer ), initial_message=initial_message, secrets=secrets, diff --git a/tests/unit/app_server/test_app_conversation_service_base.py b/tests/unit/app_server/test_app_conversation_service_base.py index a179a11c24..356c454fcf 100644 --- a/tests/unit/app_server/test_app_conversation_service_base.py +++ b/tests/unit/app_server/test_app_conversation_service_base.py @@ -1,11 +1,13 @@ -"""Unit tests for git functionality in AppConversationServiceBase. +"""Unit tests for git and security functionality in AppConversationServiceBase. This module tests the git-related functionality, specifically the clone_or_init_git_repo method and the recent bug fixes for git checkout operations. """ import subprocess +from types import MethodType from unittest.mock import AsyncMock, MagicMock, Mock, patch +from uuid import uuid4 import pytest @@ -434,13 +436,298 @@ def test_create_condenser_plan_agent_with_custom_max_size(mock_condenser_class): mock_llm.model_copy.assert_called_once() +# ============================================================================= +# Tests for security analyzer helpers +# ============================================================================= + + +@pytest.mark.parametrize('value', [None, '', 'none', 'NoNe']) +def test_create_security_analyzer_returns_none_for_empty_values(value): + """_create_security_analyzer_from_string returns None for empty/none values.""" + # Arrange + service, _ = _create_service_with_mock_user_context( + MockUserInfo(), bind_methods=('_create_security_analyzer_from_string',) + ) + + # Act + result = service._create_security_analyzer_from_string(value) + + # Assert + assert result is None + + +def test_create_security_analyzer_returns_llm_analyzer(): + """_create_security_analyzer_from_string returns LLMSecurityAnalyzer for llm string.""" + # Arrange + security_analyzer_str = 'llm' + service, _ = _create_service_with_mock_user_context( + MockUserInfo(), bind_methods=('_create_security_analyzer_from_string',) + ) + + # Act + result = service._create_security_analyzer_from_string(security_analyzer_str) + + # Assert + from openhands.sdk.security.llm_analyzer import LLMSecurityAnalyzer + + assert isinstance(result, LLMSecurityAnalyzer) + + +def test_create_security_analyzer_logs_warning_for_unknown_value(): + """_create_security_analyzer_from_string logs warning and returns None for unknown.""" + # Arrange + unknown_value = 'custom' + service, _ = _create_service_with_mock_user_context( + MockUserInfo(), bind_methods=('_create_security_analyzer_from_string',) + ) + + # Act + with patch( + 'openhands.app_server.app_conversation.app_conversation_service_base._logger' + ) as mock_logger: + result = service._create_security_analyzer_from_string(unknown_value) + + # Assert + assert result is None + mock_logger.warning.assert_called_once() + + +def test_select_confirmation_policy_when_disabled_returns_never_confirm(): + """_select_confirmation_policy returns NeverConfirm when confirmation_mode is False.""" + # Arrange + confirmation_mode = False + security_analyzer = 'llm' + service, _ = _create_service_with_mock_user_context( + MockUserInfo(), bind_methods=('_select_confirmation_policy',) + ) + + # Act + policy = service._select_confirmation_policy(confirmation_mode, security_analyzer) + + # Assert + from openhands.sdk.security.confirmation_policy import NeverConfirm + + assert isinstance(policy, NeverConfirm) + + +def test_select_confirmation_policy_llm_returns_confirm_risky(): + """_select_confirmation_policy uses ConfirmRisky when analyzer is llm.""" + # Arrange + confirmation_mode = True + security_analyzer = 'llm' + service, _ = _create_service_with_mock_user_context( + MockUserInfo(), bind_methods=('_select_confirmation_policy',) + ) + + # Act + policy = service._select_confirmation_policy(confirmation_mode, security_analyzer) + + # Assert + from openhands.sdk.security.confirmation_policy import ConfirmRisky + + assert isinstance(policy, ConfirmRisky) + + +@pytest.mark.parametrize('security_analyzer', [None, '', 'none', 'custom']) +def test_select_confirmation_policy_non_llm_returns_always_confirm( + security_analyzer, +): + """_select_confirmation_policy falls back to AlwaysConfirm for non-llm values.""" + # Arrange + confirmation_mode = True + service, _ = _create_service_with_mock_user_context( + MockUserInfo(), bind_methods=('_select_confirmation_policy',) + ) + + # Act + policy = service._select_confirmation_policy(confirmation_mode, security_analyzer) + + # Assert + from openhands.sdk.security.confirmation_policy import AlwaysConfirm + + assert isinstance(policy, AlwaysConfirm) + + +@pytest.mark.asyncio +async def test_set_security_analyzer_skips_when_no_session_key(): + """_set_security_analyzer_from_settings exits early without session_api_key.""" + # Arrange + agent_server_url = 'https://agent.example.com' + conversation_id = uuid4() + httpx_client = AsyncMock() + service, _ = _create_service_with_mock_user_context( + MockUserInfo(), + bind_methods=( + '_create_security_analyzer_from_string', + '_set_security_analyzer_from_settings', + ), + ) + + with patch.object(service, '_create_security_analyzer_from_string') as mock_create: + # Act + await service._set_security_analyzer_from_settings( + agent_server_url=agent_server_url, + session_api_key=None, + conversation_id=conversation_id, + security_analyzer_str='llm', + httpx_client=httpx_client, + ) + + # Assert + mock_create.assert_not_called() + httpx_client.post.assert_not_called() + + +@pytest.mark.asyncio +async def test_set_security_analyzer_skips_when_analyzer_none(): + """_set_security_analyzer_from_settings skips API call when analyzer resolves to None.""" + # Arrange + agent_server_url = 'https://agent.example.com' + session_api_key = 'session-key' + conversation_id = uuid4() + httpx_client = AsyncMock() + service, _ = _create_service_with_mock_user_context( + MockUserInfo(), + bind_methods=( + '_create_security_analyzer_from_string', + '_set_security_analyzer_from_settings', + ), + ) + + with patch.object( + service, '_create_security_analyzer_from_string', return_value=None + ) as mock_create: + # Act + await service._set_security_analyzer_from_settings( + agent_server_url=agent_server_url, + session_api_key=session_api_key, + conversation_id=conversation_id, + security_analyzer_str='none', + httpx_client=httpx_client, + ) + + # Assert + mock_create.assert_called_once_with('none') + httpx_client.post.assert_not_called() + + +class DummyAnalyzer: + """Simple analyzer stub for testing model_dump contract.""" + + def __init__(self, payload: dict): + self._payload = payload + + def model_dump(self) -> dict: + return self._payload + + +@pytest.mark.asyncio +async def test_set_security_analyzer_successfully_calls_agent_server(): + """_set_security_analyzer_from_settings posts analyzer payload when available.""" + # Arrange + agent_server_url = 'https://agent.example.com' + session_api_key = 'session-key' + conversation_id = uuid4() + analyzer_payload = {'type': 'llm'} + httpx_client = AsyncMock() + http_response = MagicMock() + http_response.raise_for_status = MagicMock() + httpx_client.post.return_value = http_response + service, _ = _create_service_with_mock_user_context( + MockUserInfo(), + bind_methods=( + '_create_security_analyzer_from_string', + '_set_security_analyzer_from_settings', + ), + ) + + analyzer = DummyAnalyzer(analyzer_payload) + + with ( + patch.object( + service, + '_create_security_analyzer_from_string', + return_value=analyzer, + ) as mock_create, + patch( + 'openhands.app_server.app_conversation.app_conversation_service_base._logger' + ) as mock_logger, + ): + # Act + await service._set_security_analyzer_from_settings( + agent_server_url=agent_server_url, + session_api_key=session_api_key, + conversation_id=conversation_id, + security_analyzer_str='llm', + httpx_client=httpx_client, + ) + + # Assert + mock_create.assert_called_once_with('llm') + httpx_client.post.assert_awaited_once_with( + f'{agent_server_url}/api/conversations/{conversation_id}/security_analyzer', + json={'security_analyzer': analyzer_payload}, + headers={'X-Session-API-Key': session_api_key}, + timeout=30.0, + ) + http_response.raise_for_status.assert_called_once() + mock_logger.info.assert_called() + + +@pytest.mark.asyncio +async def test_set_security_analyzer_logs_warning_on_failure(): + """_set_security_analyzer_from_settings warns but does not raise on errors.""" + # Arrange + agent_server_url = 'https://agent.example.com' + session_api_key = 'session-key' + conversation_id = uuid4() + analyzer_payload = {'type': 'llm'} + httpx_client = AsyncMock() + httpx_client.post.side_effect = RuntimeError('network down') + service, _ = _create_service_with_mock_user_context( + MockUserInfo(), + bind_methods=( + '_create_security_analyzer_from_string', + '_set_security_analyzer_from_settings', + ), + ) + + analyzer = DummyAnalyzer(analyzer_payload) + + with ( + patch.object( + service, + '_create_security_analyzer_from_string', + return_value=analyzer, + ) as mock_create, + patch( + 'openhands.app_server.app_conversation.app_conversation_service_base._logger' + ) as mock_logger, + ): + # Act + await service._set_security_analyzer_from_settings( + agent_server_url=agent_server_url, + session_api_key=session_api_key, + conversation_id=conversation_id, + security_analyzer_str='llm', + httpx_client=httpx_client, + ) + + # Assert + mock_create.assert_called_once_with('llm') + httpx_client.post.assert_awaited_once() + mock_logger.warning.assert_called() + + # ============================================================================= # Tests for _configure_git_user_settings # ============================================================================= -def _create_service_with_mock_user_context(user_info: MockUserInfo) -> tuple: - """Create a mock service with the actual _configure_git_user_settings method. +def _create_service_with_mock_user_context( + user_info: MockUserInfo, bind_methods: tuple[str, ...] | None = None +) -> tuple: + """Create a mock service with selected real methods bound for testing. Uses MagicMock for the service but binds the real method for testing. @@ -452,13 +739,16 @@ def _create_service_with_mock_user_context(user_info: MockUserInfo) -> tuple: # Create a simple mock service and set required attribute service = MagicMock() service.user_context = mock_user_context + methods_to_bind = ['_configure_git_user_settings'] + if bind_methods: + methods_to_bind.extend(bind_methods) + # Remove potential duplicates while keeping order + methods_to_bind = list(dict.fromkeys(methods_to_bind)) - # Bind the actual method from the real class to test real implementation - service._configure_git_user_settings = ( - lambda workspace: AppConversationServiceBase._configure_git_user_settings( - service, workspace - ) - ) + # Bind actual methods from the real class to test implementations directly + for method_name in methods_to_bind: + real_method = getattr(AppConversationServiceBase, method_name) + setattr(service, method_name, MethodType(real_method, service)) return service, mock_user_context diff --git a/tests/unit/experiments/test_experiment_manager.py b/tests/unit/experiments/test_experiment_manager.py index 1dd32cfa5e..f16ea4e036 100644 --- a/tests/unit/experiments/test_experiment_manager.py +++ b/tests/unit/experiments/test_experiment_manager.py @@ -153,6 +153,7 @@ class TestExperimentManagerIntegration: llm_api_key=None, confirmation_mode=False, condenser_max_size=None, + security_analyzer=None, ) async def get_secrets(self):