From 496907a1c4634826b6e30a28c7e22358983c1080 Mon Sep 17 00:00:00 2001 From: mbeaulne Date: Wed, 27 May 2026 16:20:50 -0400 Subject: [PATCH] Add Agent Settings UI for AI search --- src/components/ui/typography.tsx | 3 + .../Settings/sections/AgentSettings.test.tsx | 131 +++++++++ .../Settings/sections/AgentSettings.tsx | 276 ++++++++++++++++++ 3 files changed, 410 insertions(+) create mode 100644 src/routes/Settings/sections/AgentSettings.test.tsx create mode 100644 src/routes/Settings/sections/AgentSettings.tsx diff --git a/src/components/ui/typography.tsx b/src/components/ui/typography.tsx index 47663d0c2..69fdddbe4 100644 --- a/src/components/ui/typography.tsx +++ b/src/components/ui/typography.tsx @@ -67,6 +67,9 @@ interface TextProps */ className?: string; + /** HTML id used to associate text with form controls and ARIA descriptions. */ + id?: string; + /** Native browser tooltip text */ title?: string; } diff --git a/src/routes/Settings/sections/AgentSettings.test.tsx b/src/routes/Settings/sections/AgentSettings.test.tsx new file mode 100644 index 000000000..6884c14d0 --- /dev/null +++ b/src/routes/Settings/sections/AgentSettings.test.tsx @@ -0,0 +1,131 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { AgentSettings } from "./AgentSettings"; + +const STORAGE_KEY = "tangle.componentSearchV2.config"; +const mockNotify = vi.fn(); +const mockFetch = vi.fn(); + +vi.mock("@/hooks/useToastNotification", () => ({ + default: () => mockNotify, +})); + +describe("AgentSettings", () => { + beforeEach(() => { + window.localStorage.clear(); + mockNotify.mockClear(); + mockFetch.mockReset(); + global.fetch = mockFetch; + }); + + afterEach(() => { + window.localStorage.clear(); + }); + + it("shows inline feedback instead of saving when model is blank", () => { + render(); + + fireEvent.change(screen.getByLabelText("API base URL"), { + target: { value: "https://api.example.com/v1" }, + }); + fireEvent.change(screen.getByLabelText("API key"), { + target: { value: "sk-test" }, + }); + fireEvent.change(screen.getByLabelText("Model id"), { + target: { value: " " }, + }); + + fireEvent.click(screen.getByRole("button", { name: "Save" })); + + expect( + screen.getByText("Enter a model id before saving."), + ).toBeInTheDocument(); + expect(screen.getByLabelText("Model id")).toHaveAttribute( + "aria-invalid", + "true", + ); + expect(window.localStorage.getItem(STORAGE_KEY)).toBeNull(); + expect(mockNotify).not.toHaveBeenCalledWith( + "Agent settings saved", + "success", + ); + }); + + it("validates that the configured model exists when testing connection", async () => { + mockFetch.mockResolvedValue( + new Response( + JSON.stringify({ data: [{ id: "gpt-4o-mini" }, { id: "o3-mini" }] }), + { status: 200 }, + ), + ); + render(); + + fireEvent.change(screen.getByLabelText("API base URL"), { + target: { value: "https://api.example.com/v1" }, + }); + fireEvent.change(screen.getByLabelText("API key"), { + target: { value: "sk-test" }, + }); + fireEvent.change(screen.getByLabelText("Model id"), { + target: { value: "gpt-4o-mini" }, + }); + + fireEvent.click(screen.getByRole("button", { name: "Test connection" })); + + await waitFor(() => { + expect(mockNotify).toHaveBeenCalledWith( + "Connected. Model “gpt-4o-mini” is available.", + "success", + ); + }); + }); + + it("reports an error when the configured model is missing from provider models", async () => { + mockFetch.mockResolvedValue( + new Response(JSON.stringify({ data: [{ id: "gpt-4o-mini" }] }), { + status: 200, + }), + ); + render(); + + fireEvent.change(screen.getByLabelText("API base URL"), { + target: { value: "https://api.example.com/v1" }, + }); + fireEvent.change(screen.getByLabelText("API key"), { + target: { value: "sk-test" }, + }); + fireEvent.change(screen.getByLabelText("Model id"), { + target: { value: "missing-model" }, + }); + + fireEvent.click(screen.getByRole("button", { name: "Test connection" })); + + await waitFor(() => { + expect(mockNotify).toHaveBeenCalledWith( + "Connected, but model “missing-model” was not found.", + "error", + ); + }); + }); + + it("allows clearing partially configured settings", () => { + window.localStorage.setItem( + STORAGE_KEY, + JSON.stringify({ + apiBase: "https://api.example.com/v1", + apiKey: "sk-test", + }), + ); + + render(); + + fireEvent.click(screen.getByRole("button", { name: "Clear" })); + + expect(window.localStorage.getItem(STORAGE_KEY)).toBeNull(); + expect(mockNotify).toHaveBeenCalledWith( + "Agent settings cleared", + "success", + ); + }); +}); diff --git a/src/routes/Settings/sections/AgentSettings.tsx b/src/routes/Settings/sections/AgentSettings.tsx new file mode 100644 index 000000000..70666eb53 --- /dev/null +++ b/src/routes/Settings/sections/AgentSettings.tsx @@ -0,0 +1,276 @@ +import { type FormEvent, useEffect, useRef, useState } from "react"; + +import { Button } from "@/components/ui/button"; +import { Icon } from "@/components/ui/icon"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { BlockStack, InlineStack } from "@/components/ui/layout"; +import { Separator } from "@/components/ui/separator"; +import { Heading, Paragraph, Text } from "@/components/ui/typography"; +import { useComponentSearchSettings } from "@/hooks/useComponentSearchSettings"; +import useToastNotification from "@/hooks/useToastNotification"; +import { isRecord } from "@/utils/typeGuards"; + +/** + * Bring-your-own-key configuration UI for in-app agent features (currently + * the Components V2 natural-language search; more to come). Credentials live + * in localStorage on the user's machine — no shared key is bundled into the + * app. + */ +export function AgentSettings() { + const { config, update, clear, isConfigured } = useComponentSearchSettings(); + const notify = useToastNotification(); + + const [apiBase, setApiBase] = useState(config.apiBase); + const [apiKey, setApiKey] = useState(config.apiKey); + const [model, setModel] = useState(config.model); + const [modelError, setModelError] = useState(null); + const [showKey, setShowKey] = useState(false); + const [testing, setTesting] = useState(false); + + // Keep the form in sync if `config` changes externally (other tab, etc.). + // We snapshot the saved values and rehydrate the inputs whenever they + // differ — not on every render — so the user can keep editing. + const savedRef = useRef({ + apiBase: config.apiBase, + apiKey: config.apiKey, + model: config.model, + }); + useEffect(() => { + if ( + savedRef.current.apiBase !== config.apiBase || + savedRef.current.apiKey !== config.apiKey || + savedRef.current.model !== config.model + ) { + savedRef.current = { + apiBase: config.apiBase, + apiKey: config.apiKey, + model: config.model, + }; + setApiBase(config.apiBase); + setApiKey(config.apiKey); + setModel(config.model); + setModelError(null); + } + }, [config.apiBase, config.apiKey, config.model]); + + // Abort in-flight test connections if the user navigates away. + const testAbortRef = useRef(null); + useEffect(() => { + return () => { + testAbortRef.current?.abort(); + }; + }, []); + + const handleSave = (event: FormEvent) => { + event.preventDefault(); + const trimmedBase = apiBase.trim(); + const trimmedKey = apiKey.trim(); + const trimmedModel = model.trim(); + // Reflect the trimmed values back into the inputs so what the user sees + // matches what's stored. + setApiBase(trimmedBase); + setApiKey(trimmedKey); + setModel(trimmedModel); + + if (!trimmedModel) { + setModelError("Enter a model id before saving."); + return; + } + + setModelError(null); + update({ + apiBase: trimmedBase, + apiKey: trimmedKey, + model: trimmedModel, + }); + notify("Agent settings saved", "success"); + }; + + const handleClear = () => { + clear(); + setApiBase(""); + setApiKey(""); + setModel(""); + setModelError(null); + setShowKey(false); + notify("Agent settings cleared", "success"); + }; + + const handleTest = async () => { + const trimmedBase = apiBase.trim().replace(/\/+$/, ""); + const trimmedKey = apiKey.trim(); + const trimmedModel = model.trim(); + if (!trimmedBase || !trimmedKey) { + notify("Enter an API base URL and key first", "error"); + return; + } + if (!trimmedModel) { + setModelError("Enter a model id before testing."); + return; + } + // Cancel any prior in-flight test before starting a new one. + testAbortRef.current?.abort(); + const controller = new AbortController(); + testAbortRef.current = controller; + setTesting(true); + try { + const response = await fetch(`${trimmedBase}/models`, { + headers: { authorization: `Bearer ${trimmedKey}` }, + signal: controller.signal, + }); + if (!response.ok) { + notify( + `Test failed: ${response.status} ${response.statusText}`, + "error", + ); + return; + } + const payload: unknown = await response.json(); + const modelIds = + isRecord(payload) && Array.isArray(payload.data) + ? payload.data + .map((item) => + isRecord(item) && typeof item.id === "string" ? item.id : null, + ) + .filter((id): id is string => id !== null) + : []; + if (!modelIds.includes(trimmedModel)) { + notify( + `Connected, but model “${trimmedModel}” was not found.`, + "error", + ); + return; + } + notify(`Connected. Model “${trimmedModel}” is available.`, "success"); + } catch (err) { + if (controller.signal.aborted) return; // user navigated away + notify( + err instanceof Error ? `Test failed: ${err.message}` : "Test failed", + "error", + ); + } finally { + if (testAbortRef.current === controller) { + testAbortRef.current = null; + } + setTesting(false); + } + }; + + return ( + + + Agent Configuration + + In-app agent features (such as Components V2 natural-language search) + use an OpenAI-compatible API of your choice. Your key is stored in + this browser only — it is never sent to Tangle servers. + + + {isConfigured + ? "Status: configured ✅" + : "Status: not configured. Search is disabled until you save credentials."} + + + + + +
+ + + + setApiBase(e.target.value)} + aria-label="API base URL" + autoComplete="off" + /> + + Any OpenAI-compatible base URL, such as https://api.openai.com/v1. + Do not include /chat/completions. + + + + + + + setApiKey(e.target.value)} + aria-label="API key" + autoComplete="off" + spellCheck={false} + className="flex-1" + /> + + + + Stored in browser localStorage. Clear it when sharing this device. + + + + + + { + setModel(e.target.value); + if (modelError) setModelError(null); + }} + aria-label="Model id" + aria-invalid={modelError ? true : undefined} + aria-describedby={ + modelError + ? "agent-settings-model-error agent-settings-model-hint" + : "agent-settings-model-hint" + } + autoComplete="off" + spellCheck={false} + /> + {modelError && ( + + {modelError} + + )} + + Model id sent to the provider for AI search reranking. Must be + available on the provider above. + + + + + + + + + +
+
+ ); +}