Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion react-compiler.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ export const REACT_COMPILER_ENABLED_DIRS = [
"src/components/shared/HighlightText.tsx",
"src/components/shared/AnnouncementBanners.tsx",
"src/components/shared/ReactFlow/FlowCanvas/TaskNode/TaskOverview/IOSection",
"src/components/ui/typography.tsx",

"src/providers/DialogProvider",
"src/routes/EditorV2",
Expand All @@ -83,5 +84,5 @@ export const REACT_COMPILER_ENABLED_DIRS = [

// Convert to glob patterns for ESLint
export const REACT_COMPILER_ENABLED_GLOBS = REACT_COMPILER_ENABLED_DIRS.map(
(dir) => `${dir}/**/*.{ts,tsx}`,
(path) => (/\.[cm]?[jt]sx?$/.test(path) ? path : `${path}/**/*.{ts,tsx}`),
);
3 changes: 3 additions & 0 deletions src/components/ui/typography.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
127 changes: 127 additions & 0 deletions src/routes/Settings/sections/AgentSettings.test.tsx
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",
);
});
});
246 changes: 246 additions & 0 deletions src/routes/Settings/sections/AgentSettings.tsx
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);
}
Comment thread
Mbeaulne marked this conversation as resolved.
};

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>
);
}
Loading