forked from Cloud-Pipelines/pipeline-editor
-
Notifications
You must be signed in to change notification settings - Fork 6
Add Agent Settings UI for AI search #2320
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Draft
Mbeaulne
wants to merge
1
commit into
05-27-agent-settings-ai-rerank-service
Choose a base branch
from
05-27-agent-settings-ui
base: 05-27-agent-settings-ai-rerank-service
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Draft
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,127 @@ | ||
| 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(<AgentSettings />); | ||
|
|
||
| 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.getByRole("alert")).toHaveTextContent( | ||
| "Enter an API base URL, API key, and model before continuing.", | ||
| ); | ||
| expect(window.localStorage.getItem(STORAGE_KEY)).toBeNull(); | ||
| expect(mockNotify).not.toHaveBeenCalledWith( | ||
| "AI provider 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(<AgentSettings />); | ||
|
|
||
| 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(<AgentSettings />); | ||
|
|
||
| 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(<AgentSettings />); | ||
|
|
||
| fireEvent.click(screen.getByRole("button", { name: "Clear" })); | ||
|
|
||
| expect(window.localStorage.getItem(STORAGE_KEY)).toBeNull(); | ||
| expect(mockNotify).toHaveBeenCalledWith( | ||
| "AI provider settings cleared", | ||
| "success", | ||
| ); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,246 @@ | ||
| import { type FormEvent, 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"; | ||
|
|
||
| function readModelIds(payload: unknown): string[] { | ||
| if (!isRecord(payload) || !Array.isArray(payload.data)) return []; | ||
| return payload.data | ||
| .map((item) => | ||
| isRecord(item) && typeof item.id === "string" ? item.id : null, | ||
| ) | ||
| .filter((id): id is string => id !== null); | ||
| } | ||
|
|
||
| /** | ||
| * Bring-your-own-key configuration UI for AI search features. 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 [validationError, setValidationError] = useState<string | null>(null); | ||
| const [showKey, setShowKey] = useState(false); | ||
| const [testing, setTesting] = useState(false); | ||
| const testRunIdRef = useRef(0); | ||
|
|
||
| const getTrimmedConfig = () => ({ | ||
| apiBase: apiBase.trim().replace(/\/+$/, ""), | ||
| apiKey: apiKey.trim(), | ||
| model: model.trim(), | ||
| }); | ||
|
|
||
| const validateRequiredFields = () => { | ||
| const trimmed = getTrimmedConfig(); | ||
| if (!trimmed.apiBase || !trimmed.apiKey || !trimmed.model) { | ||
| setValidationError( | ||
| "Enter an API base URL, API key, and model before continuing.", | ||
| ); | ||
| return null; | ||
| } | ||
| setValidationError(null); | ||
| return trimmed; | ||
| }; | ||
|
|
||
| const handleSave = (event: FormEvent<HTMLFormElement>) => { | ||
| event.preventDefault(); | ||
| const trimmed = validateRequiredFields(); | ||
| if (!trimmed) return; | ||
|
|
||
| setApiBase(trimmed.apiBase); | ||
| setApiKey(trimmed.apiKey); | ||
| setModel(trimmed.model); | ||
| update(trimmed); | ||
| notify("AI provider settings saved", "success"); | ||
| }; | ||
|
|
||
| const handleClear = () => { | ||
| testRunIdRef.current += 1; | ||
| clear(); | ||
| setApiBase(""); | ||
| setApiKey(""); | ||
| setModel(""); | ||
| setValidationError(null); | ||
| setShowKey(false); | ||
| setTesting(false); | ||
| notify("AI provider settings cleared", "success"); | ||
| }; | ||
|
|
||
| const handleTest = async () => { | ||
| if (testing) return; | ||
|
|
||
| const trimmed = validateRequiredFields(); | ||
| if (!trimmed) return; | ||
|
|
||
| const testRunId = testRunIdRef.current + 1; | ||
| testRunIdRef.current = testRunId; | ||
| const isCurrentTest = () => testRunIdRef.current === testRunId; | ||
|
|
||
| setTesting(true); | ||
| try { | ||
| const response = await fetch(`${trimmed.apiBase}/models`, { | ||
| headers: { authorization: `Bearer ${trimmed.apiKey}` }, | ||
| }); | ||
| if (!isCurrentTest()) return; | ||
| if (!response.ok) { | ||
| notify( | ||
| `Test failed: ${response.status} ${response.statusText}`, | ||
| "error", | ||
| ); | ||
| return; | ||
| } | ||
|
|
||
| const modelIds = readModelIds(await response.json()); | ||
| if (!isCurrentTest()) return; | ||
| if (!modelIds.includes(trimmed.model)) { | ||
| notify( | ||
| `Connected, but model “${trimmed.model}” was not found.`, | ||
| "error", | ||
| ); | ||
| return; | ||
| } | ||
| notify(`Connected. Model “${trimmed.model}” is available.`, "success"); | ||
| } catch (err) { | ||
| if (!isCurrentTest()) return; | ||
| notify( | ||
| err instanceof Error ? `Test failed: ${err.message}` : "Test failed", | ||
| "error", | ||
| ); | ||
| } finally { | ||
| if (isCurrentTest()) setTesting(false); | ||
| } | ||
| }; | ||
|
|
||
| return ( | ||
| <BlockStack gap="6"> | ||
| <BlockStack gap="2"> | ||
| <Heading level={2}>AI Provider Settings</Heading> | ||
| <Paragraph size="sm" tone="subdued"> | ||
| AI search features use an OpenAI-compatible API of your choice. Your | ||
| key is stored in this browser only — it is never sent to Tangle | ||
| servers. | ||
| </Paragraph> | ||
| <Paragraph size="xs" tone="subdued"> | ||
| {isConfigured | ||
| ? "Status: configured ✅" | ||
| : "Status: not configured. AI search is disabled until you save credentials."} | ||
| </Paragraph> | ||
| </BlockStack> | ||
|
|
||
| <Separator /> | ||
|
|
||
| <form onSubmit={handleSave}> | ||
| <BlockStack gap="4"> | ||
| <BlockStack gap="1"> | ||
| <Label htmlFor="agent-settings-api-base">API base URL</Label> | ||
| <Input | ||
| id="agent-settings-api-base" | ||
| type="url" | ||
| placeholder="https://api.openai.com/v1" | ||
| value={apiBase} | ||
| onChange={(e) => { | ||
| setApiBase(e.target.value); | ||
| setValidationError(null); | ||
| }} | ||
| aria-label="API base URL" | ||
| aria-describedby="agent-settings-api-base-hint" | ||
| autoComplete="off" | ||
| /> | ||
| <Text id="agent-settings-api-base-hint" size="xs" tone="subdued"> | ||
| Any OpenAI-compatible base URL, such as https://api.openai.com/v1. | ||
| Do not include /chat/completions. | ||
| </Text> | ||
| </BlockStack> | ||
|
|
||
| <BlockStack gap="1"> | ||
| <Label htmlFor="agent-settings-api-key">API key</Label> | ||
| <InlineStack gap="2" blockAlign="center" wrap="nowrap"> | ||
| <Input | ||
| id="agent-settings-api-key" | ||
| type={showKey ? "text" : "password"} | ||
| placeholder="sk-… or provider-specific token" | ||
| value={apiKey} | ||
| onChange={(e) => { | ||
| setApiKey(e.target.value); | ||
| setValidationError(null); | ||
| }} | ||
| aria-label="API key" | ||
| aria-describedby="agent-settings-api-key-hint" | ||
| autoComplete="off" | ||
| spellCheck={false} | ||
| className="flex-1" | ||
| /> | ||
| <Button | ||
| type="button" | ||
| variant="ghost" | ||
| size="sm" | ||
| onClick={() => setShowKey((v) => !v)} | ||
| aria-label={showKey ? "Hide API key" : "Show API key"} | ||
| > | ||
| <Icon name={showKey ? "EyeOff" : "Eye"} size="sm" /> | ||
| </Button> | ||
| </InlineStack> | ||
| <Text id="agent-settings-api-key-hint" size="xs" tone="subdued"> | ||
| Stored in browser localStorage. Clear it when sharing this device. | ||
| </Text> | ||
| </BlockStack> | ||
|
|
||
| <BlockStack gap="1"> | ||
| <Label htmlFor="agent-settings-model">Model</Label> | ||
| <Input | ||
| id="agent-settings-model" | ||
| type="text" | ||
| placeholder="e.g. gpt-4o-mini, gemini-2.5-flash, claude-3-5-haiku" | ||
| value={model} | ||
| onChange={(e) => { | ||
| setModel(e.target.value); | ||
| setValidationError(null); | ||
| }} | ||
| aria-label="Model id" | ||
| aria-describedby="agent-settings-model-hint" | ||
| autoComplete="off" | ||
| spellCheck={false} | ||
| /> | ||
| <Text id="agent-settings-model-hint" size="xs" tone="subdued"> | ||
| Model id sent to the provider for AI search reranking. Must be | ||
| available on the provider above. | ||
| </Text> | ||
| </BlockStack> | ||
|
|
||
| {validationError && ( | ||
| <Text size="xs" tone="critical" role="alert"> | ||
| {validationError} | ||
| </Text> | ||
| )} | ||
|
|
||
| <InlineStack gap="2"> | ||
| <Button type="submit">Save</Button> | ||
| <Button | ||
| type="button" | ||
| variant="secondary" | ||
| onClick={handleTest} | ||
| disabled={testing} | ||
| > | ||
| {testing ? "Testing…" : "Test connection"} | ||
| </Button> | ||
| <Button type="button" variant="ghost" onClick={handleClear}> | ||
| Clear | ||
| </Button> | ||
| </InlineStack> | ||
| </BlockStack> | ||
| </form> | ||
| </BlockStack> | ||
| ); | ||
| } | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.