From 0f078bc92fea6d42cbf35caee5a5c0edfc1e9c0d Mon Sep 17 00:00:00 2001 From: mbeaulne Date: Wed, 27 May 2026 16:20:45 -0400 Subject: [PATCH] Add BYOK component search settings hook --- react-compiler.config.js | 1 + src/hooks/useComponentSearchSettings.test.ts | 149 +++++++++++++++++++ src/hooks/useComponentSearchSettings.ts | 137 +++++++++++++++++ src/hooks/useRunSearchParams.ts | 2 +- src/utils/pipelineRunFilterUtils.ts | 5 +- src/utils/typeGuards.ts | 3 + 6 files changed, 292 insertions(+), 5 deletions(-) create mode 100644 src/hooks/useComponentSearchSettings.test.ts create mode 100644 src/hooks/useComponentSearchSettings.ts create mode 100644 src/utils/typeGuards.ts diff --git a/react-compiler.config.js b/react-compiler.config.js index f6bd008cc..130e5d93e 100644 --- a/react-compiler.config.js +++ b/react-compiler.config.js @@ -29,6 +29,7 @@ export const REACT_COMPILER_ENABLED_DIRS = [ "src/hooks/useHandleEdgeSelection.ts", "src/hooks/useEdgeSelectionHighlight.ts", "src/hooks/useRunSearchParams.ts", + "src/hooks/useComponentSearchSettings.ts", "src/hooks/useFavorites.ts", "src/hooks/useRecentlyViewed.ts", "src/components/shared/FavoriteToggle.tsx", diff --git a/src/hooks/useComponentSearchSettings.test.ts b/src/hooks/useComponentSearchSettings.test.ts new file mode 100644 index 000000000..8a1639e23 --- /dev/null +++ b/src/hooks/useComponentSearchSettings.test.ts @@ -0,0 +1,149 @@ +import { act, renderHook } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { useComponentSearchSettings } from "./useComponentSearchSettings"; + +const STORAGE_KEY = "tangle.componentSearchV2.config"; + +describe("useComponentSearchSettings", () => { + beforeEach(() => { + window.localStorage.clear(); + }); + + afterEach(() => { + window.localStorage.clear(); + }); + + it("returns defaults when nothing is stored", () => { + const { result } = renderHook(() => useComponentSearchSettings()); + + expect(result.current.config).toEqual({ + apiBase: "", + apiKey: "", + model: "", + }); + expect(result.current.isConfigured).toBe(false); + }); + + it("reads stored values from localStorage", () => { + window.localStorage.setItem( + STORAGE_KEY, + JSON.stringify({ + apiBase: "https://api.example.com/v1", + apiKey: "sk-test", + model: "gpt-4o-mini", + }), + ); + + const { result } = renderHook(() => useComponentSearchSettings()); + + expect(result.current.config).toEqual({ + apiBase: "https://api.example.com/v1", + apiKey: "sk-test", + model: "gpt-4o-mini", + }); + expect(result.current.isConfigured).toBe(true); + }); + + it("isConfigured requires apiBase, apiKey, and model", () => { + window.localStorage.setItem( + STORAGE_KEY, + JSON.stringify({ + apiBase: "https://api.example.com/v1", + apiKey: "sk-test", + model: "", + }), + ); + + const { result } = renderHook(() => useComponentSearchSettings()); + expect(result.current.isConfigured).toBe(false); + }); + + it("update() writes to localStorage and merges partial values", () => { + const { result } = renderHook(() => useComponentSearchSettings()); + + act(() => { + result.current.update({ + apiBase: "https://api.example.com/v1", + apiKey: "sk-test", + model: "gpt-4o-mini", + }); + }); + + expect(result.current.config.model).toBe("gpt-4o-mini"); + expect(result.current.isConfigured).toBe(true); + + act(() => { + result.current.update({ model: "claude-3-5-haiku" }); + }); + + const storedConfig = window.localStorage.getItem(STORAGE_KEY); + expect(storedConfig).not.toBeNull(); + const stored = JSON.parse(storedConfig ?? ""); + expect(stored).toEqual({ + apiBase: "https://api.example.com/v1", + apiKey: "sk-test", + model: "claude-3-5-haiku", + }); + }); + + it("clear() removes the stored config", () => { + window.localStorage.setItem( + STORAGE_KEY, + JSON.stringify({ + apiBase: "https://api.example.com/v1", + apiKey: "sk-test", + model: "gpt-4o-mini", + }), + ); + + const { result } = renderHook(() => useComponentSearchSettings()); + + act(() => { + result.current.clear(); + }); + + expect(window.localStorage.getItem(STORAGE_KEY)).toBeNull(); + expect(result.current.isConfigured).toBe(false); + }); + + it("migrates legacy `thinkingModel` into `model` when model is unset", () => { + window.localStorage.setItem( + STORAGE_KEY, + JSON.stringify({ + apiBase: "https://api.example.com/v1", + apiKey: "sk-test", + thinkingModel: "gpt-5-mini", + }), + ); + + const { result } = renderHook(() => useComponentSearchSettings()); + expect(result.current.config.model).toBe("gpt-5-mini"); + }); + + it("prefers `model` over legacy `thinkingModel` when both exist", () => { + window.localStorage.setItem( + STORAGE_KEY, + JSON.stringify({ + apiBase: "https://api.example.com/v1", + apiKey: "sk-test", + model: "gpt-4o-mini", + thinkingModel: "gpt-5-mini", + }), + ); + + const { result } = renderHook(() => useComponentSearchSettings()); + expect(result.current.config.model).toBe("gpt-4o-mini"); + }); + + it("falls back to defaults when stored JSON is malformed", () => { + window.localStorage.setItem(STORAGE_KEY, "not json"); + + const { result } = renderHook(() => useComponentSearchSettings()); + expect(result.current.config).toEqual({ + apiBase: "", + apiKey: "", + model: "", + }); + }); +}); diff --git a/src/hooks/useComponentSearchSettings.ts b/src/hooks/useComponentSearchSettings.ts new file mode 100644 index 000000000..64fa3fe18 --- /dev/null +++ b/src/hooks/useComponentSearchSettings.ts @@ -0,0 +1,137 @@ +import { useSyncExternalStore } from "react"; + +import { getStorage } from "@/utils/typedStorage"; +import { isRecord } from "@/utils/typeGuards"; + +/** + * Bring-your-own-key configuration for the Components V2 natural-language + * search. Stored in localStorage so each user holds their own credentials — + * we ship no shared API key in the bundle. + * + * SECURITY NOTE: localStorage is per-origin and readable by any JS running on + * this origin. It is not encrypted. This is the same trust model as every + * other BYOK web tool — users should generate scoped keys with limited + * permissions and rotate them if compromised. + */ + +const STORAGE_KEY = "tangle.componentSearchV2.config"; + +interface ComponentSearchSettingsStorage extends Record< + typeof STORAGE_KEY, + unknown +> {} + +const storage = getStorage< + typeof STORAGE_KEY, + ComponentSearchSettingsStorage +>(); + +export interface ComponentSearchConfig { + apiBase: string; + apiKey: string; + /** Model id used for AI search reranking (any OpenAI-compatible model). */ + model: string; +} + +const DEFAULTS: ComponentSearchConfig = { + apiBase: "", + apiKey: "", + model: "", +}; + +function readStoredConfig(): ComponentSearchConfig { + if (typeof window === "undefined") return DEFAULTS; + try { + const record = storage.getItem(STORAGE_KEY); + if (!isRecord(record)) return DEFAULTS; + const legacyThinking = + "thinkingModel" in record && + typeof record.thinkingModel === "string" && + record.thinkingModel.trim().length > 0 + ? record.thinkingModel + : ""; + const storedModel = + "model" in record && + typeof record.model === "string" && + record.model.trim().length > 0 + ? record.model + : ""; + // Migration: previous versions stored `thinkingModel`. Prefer the current + // `model` value when present, and only fall back to the legacy key. + const model = storedModel || legacyThinking || DEFAULTS.model; + return { + apiBase: + "apiBase" in record && typeof record.apiBase === "string" + ? record.apiBase + : DEFAULTS.apiBase, + apiKey: + "apiKey" in record && typeof record.apiKey === "string" + ? record.apiKey + : DEFAULTS.apiKey, + model, + }; + } catch { + return DEFAULTS; + } +} + +/** + * Subscribe to localStorage changes so multiple tabs (and this same tab via + * `getStorage`) stay in sync. + */ +function subscribe(callback: () => void): () => void { + if (typeof window === "undefined") return () => {}; + const handler = (event: StorageEvent) => { + if (event.key === STORAGE_KEY || event.key === null) callback(); + }; + window.addEventListener("storage", handler); + return () => window.removeEventListener("storage", handler); +} + +/** + * Stable snapshot. We memoize by JSON string so `useSyncExternalStore`'s + * reference equality check doesn't tear; the JSON form changes if and only + * if the parsed config changes. + */ +let cachedJSON = ""; +let cachedConfig: ComponentSearchConfig | null = null; +function getSnapshot(): ComponentSearchConfig { + const fresh = readStoredConfig(); + const json = JSON.stringify(fresh); + if (json !== cachedJSON) { + cachedJSON = json; + cachedConfig = fresh; + } + return cachedConfig ?? fresh; +} + +function getServerSnapshot(): ComponentSearchConfig { + return DEFAULTS; +} + +export function useComponentSearchSettings() { + const config = useSyncExternalStore( + subscribe, + getSnapshot, + getServerSnapshot, + ); + + // The React Compiler memoizes these for us; no useCallback needed. + const update = (partial: Partial) => { + if (typeof window === "undefined") return; + const next: ComponentSearchConfig = { ...config, ...partial }; + storage.setItem(STORAGE_KEY, next); + }; + + const clear = () => { + if (typeof window === "undefined") return; + storage.setItem(STORAGE_KEY, null); + }; + + const isConfigured = + config.apiBase.length > 0 && + config.apiKey.length > 0 && + config.model.length > 0; + + return { config, update, clear, isConfigured }; +} diff --git a/src/hooks/useRunSearchParams.ts b/src/hooks/useRunSearchParams.ts index 88e1f2c86..9f58afa58 100644 --- a/src/hooks/useRunSearchParams.ts +++ b/src/hooks/useRunSearchParams.ts @@ -4,10 +4,10 @@ import { useEffect, useRef } from "react"; import type { PipelineRunFilters } from "@/types/pipelineRunFilters"; import { countActiveFilters, - isRecord, serializeFiltersToUrl, validateFilters, } from "@/utils/pipelineRunFilterUtils"; +import { isRecord } from "@/utils/typeGuards"; const DEBOUNCE_MS = 500; diff --git a/src/utils/pipelineRunFilterUtils.ts b/src/utils/pipelineRunFilterUtils.ts index 99bd5c3c3..8c1324d11 100644 --- a/src/utils/pipelineRunFilterUtils.ts +++ b/src/utils/pipelineRunFilterUtils.ts @@ -5,14 +5,11 @@ import type { SortField, } from "@/types/pipelineRunFilters"; import { isValidExecutionStatus } from "@/utils/executionStatus"; +import { isRecord } from "@/utils/typeGuards"; const VALID_SORT_FIELDS = new Set(["created_at", "pipeline_name"]); const VALID_SORT_DIRECTIONS = new Set(["asc", "desc"]); -export function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - function isValidAnnotationFilter(value: unknown): value is AnnotationFilter { return ( isRecord(value) && diff --git a/src/utils/typeGuards.ts b/src/utils/typeGuards.ts new file mode 100644 index 000000000..35f1d60d9 --- /dev/null +++ b/src/utils/typeGuards.ts @@ -0,0 +1,3 @@ +export function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +}