feat(ui): tenant-level model selection and shared business context for Lighthouse

This commit is contained in:
alejandrobailo
2026-06-29 16:48:34 +02:00
parent cc0fb9d2a5
commit 58539dced6
21 changed files with 1042 additions and 395 deletions
@@ -2,10 +2,12 @@ import { describe, expect, it } from "vitest";
import {
buildLighthouseV2ConfigurationPayload,
buildLighthouseV2TenantConfigurationUpdatePayload,
mapLighthouseV2Configuration,
mapLighthouseV2Message,
mapLighthouseV2Model,
mapLighthouseV2Provider,
mapLighthouseV2TenantConfiguration,
validateLighthouseV2ConfigurationInput,
} from "./lighthouse-v2.adapter";
@@ -20,7 +22,6 @@ describe("lighthouse-v2.adapter", () => {
provider_type: "bedrock",
base_url: null,
default_model: "anthropic.claude",
business_context: "Production AWS account",
connected: true,
connection_last_checked_at: "2026-06-24T10:00:00Z",
inserted_at: "2026-06-24T09:00:00Z",
@@ -37,7 +38,6 @@ describe("lighthouse-v2.adapter", () => {
providerType: "bedrock",
baseUrl: null,
defaultModel: "anthropic.claude",
businessContext: "Production AWS account",
connected: true,
connectionLastCheckedAt: "2026-06-24T10:00:00Z",
insertedAt: "2026-06-24T09:00:00Z",
@@ -79,6 +79,37 @@ describe("lighthouse-v2.adapter", () => {
});
});
it("should map tenant model defaults by provider", () => {
// Given
const resource: Parameters<typeof mapLighthouseV2TenantConfiguration>[0] =
{
id: "tenant-config-1",
type: "lighthouse-configurations",
attributes: {
business_context: "Production tenant",
default_provider: "bedrock",
default_models: {
openai: "gpt-5.1",
bedrock: "anthropic.claude-4",
},
},
};
// When
const config = mapLighthouseV2TenantConfiguration(resource);
// Then
expect(config).toEqual({
id: "tenant-config-1",
businessContext: "Production tenant",
defaultProvider: "bedrock",
defaultModels: {
openai: "gpt-5.1",
bedrock: "anthropic.claude-4",
},
});
});
it("should map message parts from backend names", () => {
// Given
const resource: Parameters<typeof mapLighthouseV2Message>[0] = {
@@ -122,8 +153,6 @@ describe("lighthouse-v2.adapter", () => {
// Given
const input = {
providerType: "bedrock" as const,
defaultModel: "anthropic.claude",
businessContext: "Production AWS account",
credentials: {
aws_access_key_id: "AKIA0000000000000000",
aws_secret_access_key: "a".repeat(40),
@@ -146,6 +175,36 @@ describe("lighthouse-v2.adapter", () => {
},
},
});
expect(payload.data.attributes).not.toHaveProperty("default_model");
expect(payload.data.attributes).not.toHaveProperty("business_context");
});
it("should build tenant preference payloads with provider model maps", () => {
// Given
const input = {
defaultProvider: "bedrock" as const,
defaultModels: {
openai: "gpt-5.1",
bedrock: "anthropic.claude-4",
},
};
// When
const payload = buildLighthouseV2TenantConfigurationUpdatePayload(input);
// Then
expect(payload).toEqual({
data: {
type: "lighthouse-configurations",
attributes: {
default_provider: "bedrock",
default_models: {
openai: "gpt-5.1",
bedrock: "anthropic.claude-4",
},
},
},
});
});
it("should require base_url for OpenAI-compatible configurations", () => {
@@ -11,6 +11,8 @@ import {
type LighthouseV2SupportedModel,
type LighthouseV2SupportedProvider,
type LighthouseV2Task,
type LighthouseV2TenantConfiguration,
type LighthouseV2TenantConfigurationUpdateInput,
} from "@/app/(prowler)/lighthouse/_types";
export interface JsonApiResource<TAttributes> {
@@ -32,14 +34,19 @@ export interface JsonApiDocument<TData> {
interface ConfigurationAttributes {
provider_type: LighthouseV2ProviderType;
base_url: string | null;
default_model: string | null;
business_context?: string | null;
default_model?: string | null;
connected: boolean | null;
connection_last_checked_at: string | null;
inserted_at: string;
updated_at: string;
}
interface TenantConfigurationAttributes {
business_context?: string | null;
default_provider?: LighthouseV2ProviderType | "";
default_models?: Record<string, string> | null;
}
interface SupportedProviderAttributes {
name: string;
}
@@ -114,8 +121,7 @@ export function mapLighthouseV2Configuration(
id: resource.id,
providerType: resource.attributes.provider_type,
baseUrl: resource.attributes.base_url,
defaultModel: resource.attributes.default_model,
businessContext: resource.attributes.business_context ?? "",
defaultModel: resource.attributes.default_model ?? null,
connected: resource.attributes.connected,
connectionLastCheckedAt: resource.attributes.connection_last_checked_at,
insertedAt: resource.attributes.inserted_at,
@@ -123,6 +129,17 @@ export function mapLighthouseV2Configuration(
};
}
export function mapLighthouseV2TenantConfiguration(
resource: JsonApiResource<TenantConfigurationAttributes>,
): LighthouseV2TenantConfiguration {
return {
id: resource.id,
businessContext: resource.attributes.business_context ?? "",
defaultProvider: resource.attributes.default_provider ?? "",
defaultModels: resource.attributes.default_models ?? {},
};
}
export function mapLighthouseV2Provider(
resource: JsonApiResource<SupportedProviderAttributes>,
): LighthouseV2SupportedProvider {
@@ -195,8 +212,6 @@ export function buildLighthouseV2ConfigurationPayload(
provider_type: input.providerType,
credentials: input.credentials,
base_url: input.baseUrl ?? null,
default_model: input.defaultModel ?? null,
business_context: input.businessContext,
}),
},
};
@@ -213,8 +228,21 @@ export function buildLighthouseV2ConfigurationUpdatePayload(
attributes: filterUndefinedAttributes({
credentials: input.credentials,
base_url: input.baseUrl,
default_model: input.defaultModel,
}),
},
};
}
export function buildLighthouseV2TenantConfigurationUpdatePayload(
input: LighthouseV2TenantConfigurationUpdateInput,
) {
return {
data: {
type: "lighthouse-configurations",
attributes: filterUndefinedAttributes({
business_context: input.businessContext,
default_provider: input.defaultProvider,
default_models: input.defaultModels,
}),
},
};
@@ -27,6 +27,7 @@ vi.mock("@/lib/helper", () => ({
import {
createLighthouseV2Session,
updateLighthouseV2Session,
updateLighthouseV2TenantConfiguration,
} from "./lighthouse-v2";
function sessionResponse(id = "session-1") {
@@ -48,6 +49,26 @@ function sessionResponse(id = "session-1") {
);
}
function tenantConfigurationResponse() {
return Response.json(
{
data: {
id: "tenant-config-1",
type: "lighthouse-configurations",
attributes: {
business_context: "Production tenant",
default_provider: "bedrock",
default_models: {
openai: "gpt-5.1",
bedrock: "anthropic.claude-4",
},
},
},
},
{ status: 200 },
);
}
describe("Lighthouse v2 session write actions", () => {
beforeEach(() => {
authMock.mockResolvedValue({ accessToken: "token-123" });
@@ -77,4 +98,41 @@ describe("Lighthouse v2 session write actions", () => {
// Proves the test harness would catch a revalidate being (re)introduced.
expect(revalidatePathMock).toHaveBeenCalledWith("/lighthouse");
});
it("updates tenant model preferences without revalidating the active chat route", async () => {
// Given
const fetchMock = vi.fn().mockResolvedValue(tenantConfigurationResponse());
vi.stubGlobal("fetch", fetchMock);
// When
const result = await updateLighthouseV2TenantConfiguration({
defaultProvider: "bedrock",
defaultModels: {
openai: "gpt-5.1",
bedrock: "anthropic.claude-4",
},
});
// Then
expect("data" in result && result.data.defaultProvider).toBe("bedrock");
expect(fetchMock).toHaveBeenCalledWith(
new URL("https://api.example.com/api/v1/lighthouse/configuration"),
expect.objectContaining({
method: "PATCH",
body: JSON.stringify({
data: {
type: "lighthouse-configurations",
attributes: {
default_provider: "bedrock",
default_models: {
openai: "gpt-5.1",
bedrock: "anthropic.claude-4",
},
},
},
}),
}),
);
expect(revalidatePathMock).not.toHaveBeenCalled();
});
});
@@ -13,6 +13,8 @@ import type {
LighthouseV2SupportedModel,
LighthouseV2SupportedProvider,
LighthouseV2Task,
LighthouseV2TenantConfiguration,
LighthouseV2TenantConfigurationUpdateInput,
} from "@/app/(prowler)/lighthouse/_types";
import { apiBaseUrl, getAuthHeaders } from "@/lib/helper";
import { handleApiError, handleApiResponse } from "@/lib/server-actions-helper";
@@ -24,6 +26,7 @@ import {
buildLighthouseV2MessagePayload,
buildLighthouseV2SessionCreatePayload,
buildLighthouseV2SessionUpdatePayload,
buildLighthouseV2TenantConfigurationUpdatePayload,
getJsonApiArray,
type JsonApiDocument,
mapLighthouseV2Configuration,
@@ -32,6 +35,7 @@ import {
mapLighthouseV2Provider,
mapLighthouseV2Session,
mapLighthouseV2Task,
mapLighthouseV2TenantConfiguration,
validateLighthouseV2ConfigurationInput,
} from "./lighthouse-v2.adapter";
@@ -102,6 +106,31 @@ export async function deleteLighthouseV2Configuration(
);
}
export async function getLighthouseV2TenantConfiguration(): Promise<
LighthouseV2ActionResult<LighthouseV2TenantConfiguration>
> {
return getSingle(
"/lighthouse/configuration",
mapLighthouseV2TenantConfiguration,
);
}
export async function updateLighthouseV2TenantConfiguration(
input: LighthouseV2TenantConfigurationUpdateInput,
): Promise<LighthouseV2ActionResult<LighthouseV2TenantConfiguration>> {
return mutateSingle(
"/lighthouse/configuration",
{
method: "PATCH",
body: JSON.stringify(
buildLighthouseV2TenantConfigurationUpdatePayload(input),
),
},
mapLighthouseV2TenantConfiguration,
"",
);
}
// Starts the backend connection-check task, polls it to completion (reusing the
// shared task poller), then returns the re-fetched configuration so the caller
// can render the authoritative `connected` / `connectionLastCheckedAt` status.
@@ -1,9 +1,10 @@
"use client";
import { CornerDownLeft, Settings } from "lucide-react";
import { CornerDownLeft, Settings, TriangleAlert } from "lucide-react";
import Link from "next/link";
import { type FormEvent } from "react";
import { type ReactNode, type SubmitEvent } from "react";
import { Alert, AlertDescription } from "@/components/shadcn/alert";
import { Button } from "@/components/shadcn/button/button";
import { Spinner } from "@/components/shadcn/spinner/spinner";
import { Textarea } from "@/components/shadcn/textarea/textarea";
@@ -17,13 +18,15 @@ interface ChatComposerPanelProps {
feedback: string | null;
canRetry: boolean;
onRetry: () => void;
onDismissFeedback: () => void;
canSend: boolean;
input: string;
isStreaming: boolean;
modelSelector: ReactNode;
selectedConfigurationConnected: boolean;
onInputChange: (value: string) => void;
onStop: () => void;
onSubmit: (event: FormEvent<HTMLFormElement>) => void;
onSubmit: (event: SubmitEvent<HTMLFormElement>) => void;
onSubmitText: (text: string) => Promise<void>;
}
@@ -33,6 +36,7 @@ export function ChatComposerPanel({
feedback,
canRetry,
onRetry,
onDismissFeedback,
...composerProps
}: ChatComposerPanelProps) {
return (
@@ -41,6 +45,7 @@ export function ChatComposerPanel({
feedback={feedback}
canRetry={canRetry}
onRetry={onRetry}
onDismiss={onDismissFeedback}
/>
<ChatComposer {...composerProps} />
</>
@@ -51,22 +56,27 @@ function ChatFeedbackBar({
feedback,
canRetry,
onRetry,
onDismiss,
}: {
feedback: string | null;
canRetry: boolean;
onRetry: () => void;
onDismiss: () => void;
}) {
if (!feedback) return null;
return (
<div className="border-border-neutral-secondary bg-bg-neutral-secondary mb-3 flex items-center justify-between gap-3 rounded-[8px] border px-3 py-2 text-sm">
<span>{feedback}</span>
{canRetry && (
<Button type="button" variant="outline" size="sm" onClick={onRetry}>
Retry
</Button>
)}
</div>
<Alert variant="error" onClose={onDismiss} className="mb-3 pr-10">
<TriangleAlert />
<AlertDescription className="flex items-center justify-between gap-3">
<span>{feedback}</span>
{canRetry && (
<Button type="button" variant="outline" size="sm" onClick={onRetry}>
Retry
</Button>
)}
</AlertDescription>
</Alert>
);
}
@@ -74,12 +84,13 @@ interface ChatComposerProps {
canSend: boolean;
input: string;
isStreaming: boolean;
modelSelector: ReactNode;
selectedConfigurationConnected: boolean;
onInputChange: (value: string) => void;
// Kept on the contract but unused for now: the backend can't cancel a run yet,
// so the stop control is replaced by a non-interactive spinner.
onStop: () => void;
onSubmit: (event: FormEvent<HTMLFormElement>) => void;
onSubmit: (event: SubmitEvent<HTMLFormElement>) => void;
onSubmitText: (text: string) => Promise<void>;
}
@@ -89,6 +100,7 @@ function ChatComposer({
isStreaming,
selectedConfigurationConnected,
onInputChange,
modelSelector,
onSubmit,
onSubmitText,
}: ChatComposerProps) {
@@ -117,12 +129,18 @@ function ChatComposer({
}
}}
/>
<div className="flex items-center justify-between px-3 pb-3">
<Button type="button" variant="outline" size="icon-sm" asChild>
<Link href="/lighthouse/settings" aria-label="Lighthouse AI settings">
<Settings className="size-4" />
</Link>
</Button>
<div className="flex items-center justify-between gap-3 px-3 pb-3">
<div className="flex min-w-0 flex-1 items-center gap-2">
<Button type="button" variant="outline" size="icon-sm" asChild>
<Link
href="/lighthouse/settings"
aria-label="Lighthouse AI settings"
>
<Settings className="size-4" />
</Link>
</Button>
{modelSelector}
</div>
{isStreaming ? (
<div
className="flex size-8 items-center justify-center"
@@ -1,7 +1,7 @@
"use client";
import { BookOpen, FileCheck2, Network, ShieldAlert } from "lucide-react";
import { type FormEvent } from "react";
import { type ReactNode, type SubmitEvent } from "react";
import { LighthouseIcon } from "@/components/icons/Icons";
import { Button } from "@/components/shadcn/button/button";
@@ -36,13 +36,15 @@ interface ChatEmptyStateProps {
feedback: string | null;
canRetry: boolean;
onRetry: () => void;
onDismissFeedback: () => void;
canSend: boolean;
input: string;
isStreaming: boolean;
modelSelector: ReactNode;
selectedConfigurationConnected: boolean;
onInputChange: (value: string) => void;
onStop: () => void;
onSubmit: (event: FormEvent<HTMLFormElement>) => void;
onSubmit: (event: SubmitEvent<HTMLFormElement>) => void;
onSubmitText: (text: string) => Promise<void>;
}
@@ -57,19 +57,26 @@ function stubEventSource() {
vi.stubGlobal("EventSource", EventSourceMock);
}
const { cancelRunMock, createSessionMock, getMessagesMock, sendMessageMock } =
vi.hoisted(() => ({
cancelRunMock: vi.fn(),
createSessionMock: vi.fn(),
getMessagesMock: vi.fn(),
sendMessageMock: vi.fn(),
}));
const {
cancelRunMock,
createSessionMock,
getMessagesMock,
sendMessageMock,
updateTenantConfigurationMock,
} = vi.hoisted(() => ({
cancelRunMock: vi.fn(),
createSessionMock: vi.fn(),
getMessagesMock: vi.fn(),
sendMessageMock: vi.fn(),
updateTenantConfigurationMock: vi.fn(),
}));
vi.mock("@/app/(prowler)/lighthouse/_actions", () => ({
cancelLighthouseV2Run: cancelRunMock,
createLighthouseV2Session: createSessionMock,
getLighthouseV2Messages: getMessagesMock,
sendLighthouseV2Message: sendMessageMock,
updateLighthouseV2TenantConfiguration: updateTenantConfigurationMock,
}));
// Streamdown pulls in shiki/wasm syntax highlighting that doesn't run under
@@ -85,7 +92,6 @@ const configurations: LighthouseV2Configuration[] = [
providerType: "openai",
baseUrl: null,
defaultModel: "gpt-5.1",
businessContext: "Production account",
connected: true,
connectionLastCheckedAt: "2026-06-24T10:00:00Z",
insertedAt: "2026-06-24T09:00:00Z",
@@ -96,7 +102,6 @@ const configurations: LighthouseV2Configuration[] = [
providerType: "bedrock",
baseUrl: null,
defaultModel: "anthropic.claude-4",
businessContext: "AWS landing zone",
connected: true,
connectionLastCheckedAt: "2026-06-23T10:00:00Z",
insertedAt: "2026-06-23T09:00:00Z",
@@ -105,11 +110,21 @@ const configurations: LighthouseV2Configuration[] = [
];
const modelsByProvider = {
openai: [model("gpt-5.1")],
openai: [model("gpt-5.1"), model("gpt-4.1")],
bedrock: [model("anthropic.claude-4")],
"openai-compatible": [model("llama-3.3")],
};
const tenantConfiguration = {
id: "tenant-config-1",
businessContext: "Production account",
defaultProvider: "openai",
defaultModels: {
openai: "gpt-5.1",
bedrock: "anthropic.claude-4",
},
} satisfies Parameters<typeof LighthouseV2ChatPage>[0]["tenantConfiguration"];
describe("LighthouseV2ChatPage", () => {
beforeEach(() => {
vi.stubGlobal(
@@ -120,10 +135,15 @@ describe("LighthouseV2ChatPage", () => {
disconnect = vi.fn();
},
);
Object.defineProperty(Element.prototype, "scrollIntoView", {
configurable: true,
value: vi.fn(),
});
cancelRunMock.mockReset();
createSessionMock.mockReset();
getMessagesMock.mockReset();
sendMessageMock.mockReset();
updateTenantConfigurationMock.mockReset();
// The mock never fires "open": the client must POST the message without
// waiting for it (the backend sends no bytes until the worker emits, which
// only happens after the POST). This is the regression guard for the
@@ -150,18 +170,21 @@ describe("LighthouseV2ChatPage", () => {
},
},
});
updateTenantConfigurationMock.mockResolvedValue({
data: tenantConfiguration,
});
});
afterEach(() => {
vi.unstubAllGlobals();
});
it("does not render provider or model selectors in the chat composer", () => {
it("renders the searchable model selector and settings shortcut", () => {
// Given / When
renderPage();
// Then
expect(screen.queryByRole("combobox")).not.toBeInTheDocument();
expect(screen.getByRole("combobox", { name: "Model" })).toBeInTheDocument();
expect(
screen.getByRole("link", { name: "Lighthouse AI settings" }),
).toHaveAttribute("href", "/lighthouse/settings");
@@ -206,11 +229,16 @@ describe("LighthouseV2ChatPage", () => {
).not.toHaveClass("border-t");
});
it("sends messages with the connected default provider and model from configuration", async () => {
it("sends messages with the tenant default provider and its saved model", async () => {
// Given
const user = userEvent.setup();
const replaceStateSpy = vi.spyOn(window.history, "replaceState");
renderPage();
renderPage({
tenantConfiguration: {
...tenantConfiguration,
defaultProvider: "bedrock",
},
});
// When
await user.type(
@@ -223,8 +251,8 @@ describe("LighthouseV2ChatPage", () => {
expect(sendMessageMock).toHaveBeenCalledWith({
sessionId: "session-1",
text: "Summarize findings",
provider: "openai",
model: "gpt-5.1",
provider: "bedrock",
model: "anthropic.claude-4",
}),
);
expect(createSessionMock).toHaveBeenCalledWith("Summarize findings");
@@ -242,6 +270,84 @@ describe("LighthouseV2ChatPage", () => {
replaceStateSpy.mockRestore();
});
it("falls back to the first connected provider when tenant defaults are invalid", async () => {
// Given
const user = userEvent.setup();
renderPage({
tenantConfiguration: {
...tenantConfiguration,
defaultProvider: "bedrock",
defaultModels: {
bedrock: "missing-model",
},
},
});
// When
await user.type(
screen.getByRole("textbox", { name: "Message" }),
["Summarize findings", "{Enter}"].join(""),
);
// Then
await waitFor(() =>
expect(sendMessageMock).toHaveBeenCalledWith(
expect.objectContaining({
provider: "openai",
model: "gpt-5.1",
}),
),
);
});
it("persists the selected chat model without deleting other provider defaults", async () => {
// Given
const user = userEvent.setup();
renderPage();
// When
await user.click(screen.getByRole("combobox", { name: "Model" }));
await user.click(
await screen.findByRole("option", { name: "anthropic.claude-4" }),
);
// Then
await waitFor(() =>
expect(updateTenantConfigurationMock).toHaveBeenCalledWith({
defaultProvider: "bedrock",
defaultModels: {
openai: "gpt-5.1",
bedrock: "anthropic.claude-4",
},
}),
);
});
it("keeps the chosen model applied and surfaces the backend reason when saving the default fails", async () => {
// Given
const user = userEvent.setup();
updateTenantConfigurationMock.mockResolvedValue({
error: "Invalid model 'anthropic.claude-4' for provider 'bedrock'.",
status: 400,
});
renderPage();
// When
await user.click(screen.getByRole("combobox", { name: "Model" }));
await user.click(
await screen.findByRole("option", { name: "anthropic.claude-4" }),
);
// Then: the failed save shows the real backend message as an error alert,
// and the selection stays applied so the connected provider remains usable.
expect(await screen.findByRole("alert")).toHaveTextContent(
"Invalid model 'anthropic.claude-4' for provider 'bedrock'.",
);
expect(screen.getByRole("combobox", { name: "Model" })).toHaveTextContent(
"anthropic.claude-4",
);
});
it("updates the URL before notifying session history listeners", async () => {
// Given
const user = userEvent.setup();
@@ -364,6 +470,7 @@ function renderPage(props?: RenderPageProps) {
const componentProps = {
configurations: props?.configurations ?? configurations,
modelsByProvider: props?.modelsByProvider ?? modelsByProvider,
tenantConfiguration: props?.tenantConfiguration ?? tenantConfiguration,
initialSessionId: props?.initialSessionId,
initialMessages: props?.initialMessages ?? [],
initialPrompt: props?.initialPrompt,
@@ -1,12 +1,13 @@
"use client";
import { type FormEvent, useRef, useState } from "react";
import { type SubmitEvent, useRef, useState } from "react";
import {
cancelLighthouseV2Run,
createLighthouseV2Session,
getLighthouseV2Messages,
sendLighthouseV2Message,
updateLighthouseV2TenantConfiguration,
} from "@/app/(prowler)/lighthouse/_actions";
import {
Conversation,
@@ -22,6 +23,11 @@ import {
buildOptimisticMessage,
buildSessionTitle,
} from "@/app/(prowler)/lighthouse/_lib/messages";
import {
buildLighthouseV2ModelSelectionValue,
type LighthouseV2ModelSelection,
parseLighthouseV2ModelSelectionValue,
} from "@/app/(prowler)/lighthouse/_lib/model-selection";
import {
LIGHTHOUSE_V2_NEW_CHAT_EVENT,
notifyLighthouseV2SessionsChanged,
@@ -29,15 +35,19 @@ import {
import { parseStreamEvent } from "@/app/(prowler)/lighthouse/_lib/stream-event-parser";
import { buildLighthouseV2StreamUrl } from "@/app/(prowler)/lighthouse/_lib/stream-url";
import {
LIGHTHOUSE_V2_PROVIDER_TYPE,
LIGHTHOUSE_V2_SSE_EVENT,
type LighthouseV2Configuration,
type LighthouseV2Message,
type LighthouseV2ProviderType,
type LighthouseV2SSEEvent,
type LighthouseV2SupportedModel,
type LighthouseV2TenantConfiguration,
} from "@/app/(prowler)/lighthouse/_types";
import { Card } from "@/components/shadcn";
import {
Combobox,
type ComboboxGroup,
} from "@/components/shadcn/combobox/combobox";
import { useMountEffect } from "@/hooks/use-mount-effect";
import { ChatComposerPanel } from "./composer";
@@ -51,6 +61,7 @@ interface LighthouseV2ChatPageProps {
LighthouseV2ProviderType,
LighthouseV2SupportedModel[]
>;
tenantConfiguration?: LighthouseV2TenantConfiguration;
initialSessionId?: string;
initialMessages: LighthouseV2Message[];
initialActiveTaskId?: string | null;
@@ -61,6 +72,7 @@ interface LighthouseV2ChatPageProps {
export function LighthouseV2ChatPage({
configurations,
modelsByProvider,
tenantConfiguration,
initialSessionId,
initialMessages,
initialActiveTaskId,
@@ -72,13 +84,17 @@ export function LighthouseV2ChatPage({
const connectedConfigurations = configurations.filter(
(configuration) => configuration.connected === true,
);
const selectedConfiguration = connectedConfigurations[0] ?? configurations[0];
const selectedProvider =
selectedConfiguration?.providerType ?? LIGHTHOUSE_V2_PROVIDER_TYPE.OPENAI;
const selectedModel =
selectedConfiguration?.defaultModel ??
modelsByProvider[selectedProvider]?.[0]?.id ??
"";
const initialModelSelection = resolveInitialModelSelection(
connectedConfigurations,
modelsByProvider,
tenantConfiguration,
);
const [selectedModelSelection, setSelectedModelSelection] =
useState<LighthouseV2ModelSelection | null>(initialModelSelection);
const [tenantModelDefaults, setTenantModelDefaults] = useState<
Record<string, string>
>(tenantConfiguration?.defaultModels ?? {});
const [modelPreferenceSaving, setModelPreferenceSaving] = useState(false);
const [activeSessionId, setActiveSessionId] = useState<string | null>(
initialSessionId ?? null,
);
@@ -93,9 +109,26 @@ export function LighthouseV2ChatPage({
const [streamState, setStreamState] = useState<LighthouseV2StreamState>(() =>
createInitialLighthouseV2StreamState(initialActiveTaskId ?? null),
);
const selectedConfiguration = selectedModelSelection
? connectedConfigurations.find(
(configuration) =>
configuration.providerType === selectedModelSelection.providerType,
)
: undefined;
const modelSelectorGroups = buildModelSelectorGroups(
connectedConfigurations,
modelsByProvider,
);
const selectedModelValue = selectedModelSelection
? buildLighthouseV2ModelSelectionValue(
selectedModelSelection.providerType,
selectedModelSelection.modelId,
)
: "";
const canSend =
selectedConfiguration?.connected === true &&
Boolean(selectedModelSelection?.modelId) &&
!streamState.activeTaskId &&
!blockedByConflict &&
!isSubmitting;
@@ -213,7 +246,12 @@ export function LighthouseV2ChatPage({
const submitMessage = async (text: string) => {
const trimmedText = text.trim();
if (!trimmedText || !canSend) return;
if (!trimmedText) return;
if (!selectedModelSelection) {
setFeedback("Select a model before sending a message.");
return;
}
if (!canSend) return;
setIsSubmitting(true);
try {
@@ -239,8 +277,8 @@ export function LighthouseV2ChatPage({
const result = await sendLighthouseV2Message({
sessionId,
text: trimmedText,
provider: selectedProvider,
model: selectedModel || null,
provider: selectedModelSelection.providerType,
model: selectedModelSelection.modelId,
});
if ("error" in result) {
@@ -265,7 +303,48 @@ export function LighthouseV2ChatPage({
}
};
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
const handleModelValueChange = (value: string) => {
const selection = parseLighthouseV2ModelSelectionValue(value);
if (!selection) return;
void handleModelSelectionChange(selection);
};
const handleModelSelectionChange = async (
selection: LighthouseV2ModelSelection,
) => {
// The selection drives the model used for the next message, so it stays
// applied even if persisting it as the tenant default fails. Only the
// saved-default mirror is rolled back on failure — reverting the active
// selection would make a connected provider unusable when the save 4xxs.
const previousDefaults = tenantModelDefaults;
setSelectedModelSelection(selection);
setFeedback(null);
const nextDefaults = {
...tenantModelDefaults,
[selection.providerType]: selection.modelId,
};
setTenantModelDefaults(nextDefaults);
setModelPreferenceSaving(true);
const result = await updateLighthouseV2TenantConfiguration({
defaultProvider: selection.providerType,
defaultModels: nextDefaults,
});
setModelPreferenceSaving(false);
if ("error" in result) {
setTenantModelDefaults(previousDefaults);
setFeedback(result.error);
return;
}
setTenantModelDefaults(result.data.defaultModels);
};
const handleSubmit = (event: SubmitEvent<HTMLFormElement>) => {
event.preventDefault();
void submitMessage(input);
};
@@ -337,9 +416,25 @@ export function LighthouseV2ChatPage({
streamState.status === "disconnected" && lastSubmittedText !== null,
onRetry: () =>
lastSubmittedText ? void submitMessage(lastSubmittedText) : undefined,
onDismissFeedback: () => setFeedback(null),
canSend,
input,
isStreaming: Boolean(streamState.activeTaskId),
modelSelector: (
<div className="min-w-0 flex-1 sm:max-w-80">
<Combobox
aria-label="Model"
value={selectedModelValue}
onValueChange={handleModelValueChange}
groups={modelSelectorGroups}
disabled={modelSelectorGroups.length === 0 || modelPreferenceSaving}
placeholder="Select model"
searchPlaceholder="Search models..."
emptyMessage="No models found."
triggerClassName="h-8 text-sm"
/>
</div>
),
selectedConfigurationConnected: selectedConfiguration?.connected === true,
onInputChange: setInput,
onStop: handleStop,
@@ -397,3 +492,101 @@ function replaceLighthouseV2SessionUrl(sessionId: string | null) {
window.history.replaceState(window.history.state, "", url);
}
function resolveInitialModelSelection(
connectedConfigurations: LighthouseV2Configuration[],
modelsByProvider: Record<
LighthouseV2ProviderType,
LighthouseV2SupportedModel[]
>,
tenantConfiguration?: LighthouseV2TenantConfiguration,
): LighthouseV2ModelSelection | null {
const tenantDefaultProvider = tenantConfiguration?.defaultProvider;
if (tenantDefaultProvider) {
const defaultModel =
tenantConfiguration.defaultModels[tenantDefaultProvider];
if (
defaultModel &&
hasConnectedModel(
connectedConfigurations,
modelsByProvider,
tenantDefaultProvider,
defaultModel,
)
) {
return {
providerType: tenantDefaultProvider,
modelId: defaultModel,
};
}
}
for (const configuration of connectedConfigurations) {
const providerModels = modelsByProvider[configuration.providerType] ?? [];
const savedModel =
tenantConfiguration?.defaultModels[configuration.providerType];
const model =
providerModels.find((candidate) => candidate.id === savedModel) ??
providerModels[0];
if (model) {
return {
providerType: configuration.providerType,
modelId: model.id,
};
}
}
return null;
}
function hasConnectedModel(
connectedConfigurations: LighthouseV2Configuration[],
modelsByProvider: Record<
LighthouseV2ProviderType,
LighthouseV2SupportedModel[]
>,
providerType: LighthouseV2ProviderType,
modelId: string,
) {
return (
connectedConfigurations.some(
(configuration) => configuration.providerType === providerType,
) &&
(modelsByProvider[providerType] ?? []).some((model) => model.id === modelId)
);
}
function buildModelSelectorGroups(
connectedConfigurations: LighthouseV2Configuration[],
modelsByProvider: Record<
LighthouseV2ProviderType,
LighthouseV2SupportedModel[]
>,
): ComboboxGroup[] {
return connectedConfigurations
.map((configuration) => ({
heading: getLighthouseV2ProviderLabel(configuration.providerType),
options: (modelsByProvider[configuration.providerType] ?? []).map(
(model) => ({
value: buildLighthouseV2ModelSelectionValue(
configuration.providerType,
model.id,
),
label: model.id,
}),
),
}))
.filter((group) => group.options.length > 0);
}
function getLighthouseV2ProviderLabel(providerType: LighthouseV2ProviderType) {
return LIGHTHOUSE_V2_PROVIDER_LABELS[providerType] ?? providerType;
}
const LIGHTHOUSE_V2_PROVIDER_LABELS = {
openai: "OpenAI",
bedrock: "Amazon Bedrock",
"openai-compatible": "OpenAI Compatible",
} as const satisfies Record<LighthouseV2ProviderType, string>;
@@ -0,0 +1,114 @@
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { LighthouseV2BusinessContextForm } from "./business-context-form";
const { updateTenantConfigurationMock } = vi.hoisted(() => ({
updateTenantConfigurationMock: vi.fn(),
}));
vi.mock("@/app/(prowler)/lighthouse/_actions", () => ({
updateLighthouseV2TenantConfiguration: updateTenantConfigurationMock,
}));
function tenantConfig(businessContext: string) {
return {
id: "tenant-config-1",
businessContext,
defaultProvider: "" as const,
defaultModels: {},
};
}
describe("LighthouseV2BusinessContextForm", () => {
beforeEach(() => {
updateTenantConfigurationMock.mockReset();
updateTenantConfigurationMock.mockResolvedValue({
data: tenantConfig("Production context"),
});
});
it("seeds the textarea with the tenant business context and disables save until edited", () => {
// Given / When
render(
<LighthouseV2BusinessContextForm initialBusinessContext="Production context" />,
);
// Then
expect(
screen.getByRole("textbox", { name: /Business context/i }),
).toHaveValue("Production context");
expect(
screen.getByRole("button", { name: "Save business context" }),
).toBeDisabled();
});
it("saves the shared business context to the tenant configuration", async () => {
// Given
const user = userEvent.setup();
render(<LighthouseV2BusinessContextForm initialBusinessContext="" />);
// When
await user.type(
screen.getByRole("textbox", { name: /Business context/i }),
"Production context",
);
await user.click(
screen.getByRole("button", { name: "Save business context" }),
);
// Then
await waitFor(() =>
expect(updateTenantConfigurationMock).toHaveBeenCalledWith({
businessContext: "Production context",
}),
);
});
it("shows the counter and blocks saving over the character limit", async () => {
// Given
const user = userEvent.setup();
render(<LighthouseV2BusinessContextForm initialBusinessContext="" />);
// When: paste in one event instead of 1001 keystrokes
await user.click(
screen.getByRole("textbox", { name: /Business context/i }),
);
await user.paste("a".repeat(1001));
// Then
expect(screen.getByText("1001/1000")).toBeInTheDocument();
expect(
screen.getByText("Business context cannot exceed 1000 characters."),
).toBeInTheDocument();
expect(
screen.getByRole("button", { name: "Save business context" }),
).toBeDisabled();
expect(updateTenantConfigurationMock).not.toHaveBeenCalled();
});
it("surfaces the backend reason when the save fails", async () => {
// Given
const user = userEvent.setup();
updateTenantConfigurationMock.mockResolvedValue({
error: "No active configuration found for 'bedrock'.",
status: 400,
});
render(<LighthouseV2BusinessContextForm initialBusinessContext="" />);
// When
await user.type(
screen.getByRole("textbox", { name: /Business context/i }),
"Production context",
);
await user.click(
screen.getByRole("button", { name: "Save business context" }),
);
// Then
expect(
await screen.findByText("No active configuration found for 'bedrock'."),
).toBeInTheDocument();
});
});
@@ -0,0 +1,117 @@
"use client";
import { Bot, Loader2, Save } from "lucide-react";
import { useState } from "react";
import { updateLighthouseV2TenantConfiguration } from "@/app/(prowler)/lighthouse/_actions";
import { BUSINESS_CONTEXT_LIMIT } from "@/app/(prowler)/lighthouse/_lib/config";
import { Button } from "@/components/shadcn/button/button";
import { Field, FieldError, FieldLabel } from "@/components/shadcn/field/field";
import { Textarea } from "@/components/shadcn/textarea/textarea";
import { cn } from "@/lib/utils";
// Tenant-wide business context. It is shared across every provider and chat, so
// it is edited once here rather than per provider configuration.
export function LighthouseV2BusinessContextForm({
initialBusinessContext,
}: {
initialBusinessContext: string;
}) {
const [businessContext, setBusinessContext] = useState(
initialBusinessContext,
);
const [savedContext, setSavedContext] = useState(initialBusinessContext);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const overLimit = businessContext.length > BUSINESS_CONTEXT_LIMIT;
const isDirty = businessContext !== savedContext;
const canSave = isDirty && !overLimit && !saving;
const handleSave = async () => {
if (!canSave) return;
setSaving(true);
setError(null);
const result = await updateLighthouseV2TenantConfiguration({
businessContext,
});
setSaving(false);
if ("error" in result) {
setError(result.error);
return;
}
setSavedContext(result.data.businessContext);
setBusinessContext(result.data.businessContext);
};
return (
<section
data-slot="lighthouse-v2-business-context"
className="border-border-neutral-secondary border-b"
>
<div className="border-border-neutral-secondary flex items-start gap-3 border-b px-4 py-4 md:px-5">
<div className="border-border-neutral-secondary bg-bg-neutral-tertiary flex size-12 shrink-0 items-center justify-center rounded-[10px] border">
<Bot className="text-text-neutral-secondary size-6" />
</div>
<div className="min-w-0">
<h3 className="text-text-neutral-primary text-xl font-semibold">
Business context
</h3>
<p className="text-text-neutral-secondary mt-1 max-w-2xl text-sm">
Shared context Lighthouse AI considers for every provider and chat.
</p>
</div>
</div>
<div className="flex flex-col gap-4 px-4 py-4 md:px-5">
<Field>
<div className="flex items-center justify-between gap-3">
<FieldLabel htmlFor="lighthouse-v2-business-context">
Business context
</FieldLabel>
<span
className={cn(
"text-xs",
overLimit
? "text-text-error-primary"
: "text-text-neutral-tertiary",
)}
>
{businessContext.length}/{BUSINESS_CONTEXT_LIMIT}
</span>
</div>
<Textarea
id="lighthouse-v2-business-context"
textareaSize="lg"
aria-invalid={overLimit}
value={businessContext}
onChange={(event) => setBusinessContext(event.target.value)}
placeholder="Example: production AWS accounts, PCI workloads, EU data residency, critical internet-facing services..."
/>
{overLimit && (
<FieldError>
Business context cannot exceed {BUSINESS_CONTEXT_LIMIT}{" "}
characters.
</FieldError>
)}
{error && <FieldError>{error}</FieldError>}
</Field>
<div className="flex justify-end">
<Button
type="button"
aria-label="Save business context"
onClick={handleSave}
disabled={!canSave}
>
{saving ? <Loader2 className="animate-spin" /> : <Save />}
{saving ? "Saving…" : "Save"}
</Button>
</div>
</div>
</section>
);
}
@@ -1,17 +1,9 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import {
Bot,
KeyRound,
Loader2,
PlugZap,
Save,
Sparkles,
Trash2,
} from "lucide-react";
import { KeyRound, Loader2, PlugZap, Save, Trash2 } from "lucide-react";
import { useState } from "react";
import { Controller, useForm } from "react-hook-form";
import { useForm } from "react-hook-form";
import {
createLighthouseV2Configuration,
@@ -22,7 +14,6 @@ import {
import {
buildCredentialPayload,
buildLighthouseV2ConfigFormSchema,
BUSINESS_CONTEXT_LIMIT,
EMPTY_FORM_VALUES,
FEEDBACK_VARIANT,
type FeedbackState,
@@ -36,21 +27,10 @@ import {
type LighthouseV2Configuration,
type LighthouseV2ConfigurationInput,
type LighthouseV2ConfigurationUpdateInput,
type LighthouseV2SupportedModel,
type LighthouseV2SupportedProvider,
} from "@/app/(prowler)/lighthouse/_types";
import { Button } from "@/components/shadcn/button/button";
import { Field, FieldError, FieldLabel } from "@/components/shadcn/field/field";
import { Modal } from "@/components/shadcn/modal";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/shadcn/select/select";
import { Textarea } from "@/components/shadcn/textarea/textarea";
import { cn } from "@/lib/utils";
import { ConfigurationSection } from "./configuration-section";
import { CredentialFields } from "./credential-fields";
@@ -59,7 +39,6 @@ import { StatusBadge } from "./status-badge";
export function LighthouseV2ConfigurationForm({
configuration,
models,
onConfigurationDeleted,
onConfigurationSaved,
onConfigurationTested,
@@ -67,7 +46,6 @@ export function LighthouseV2ConfigurationForm({
provider,
}: {
configuration?: LighthouseV2Configuration;
models: LighthouseV2SupportedModel[];
onConfigurationDeleted: (configurationId: string) => void;
onConfigurationSaved: (configuration: LighthouseV2Configuration) => void;
onConfigurationTested: (configuration: LighthouseV2Configuration) => void;
@@ -87,7 +65,6 @@ export function LighthouseV2ConfigurationForm({
defaultValues: getFormDefaults(configuration),
mode: "onSubmit",
});
const businessContext = form.watch("businessContext");
const status = getConnectionStatus(configuration);
const handleSave = async (values: LighthouseV2ConfigFormValues) => {
@@ -102,8 +79,6 @@ export function LighthouseV2ConfigurationForm({
const basePayload = {
baseUrl: trimToNullable(values.baseUrl),
defaultModel: trimToNullable(values.defaultModel),
businessContext: values.businessContext,
};
const result = configuration
@@ -177,7 +152,7 @@ export function LighthouseV2ConfigurationForm({
};
return (
<section className="min-w-0">
<section className="flex h-full w-full min-w-0 flex-col">
<div className="border-border-neutral-secondary flex flex-col gap-4 border-b px-4 py-6 md:flex-row md:items-start md:justify-between md:px-5">
<div className="flex min-w-0 gap-3">
<div className="border-border-neutral-secondary bg-bg-neutral-tertiary flex size-12 shrink-0 items-center justify-center rounded-[10px] border">
@@ -218,97 +193,29 @@ export function LighthouseV2ConfigurationForm({
</div>
<form
className="grid gap-0"
className="flex h-full min-h-0 w-full flex-1 flex-col"
onSubmit={form.handleSubmit(handleSave)}
noValidate
>
<ConfigurationSection
icon={<KeyRound className="size-4" />}
title="Credentials"
description={
configuration
? "Leave blank to keep existing credentials."
: "Credentials are required for new configurations."
}
>
<CredentialFields
errors={form.formState.errors}
provider={providerType}
register={form.register}
/>
</ConfigurationSection>
<ConfigurationSection
icon={<Sparkles className="size-4" />}
title="Default model"
description="Model used when chat does not override provider/model for a turn."
>
<Controller
control={form.control}
name="defaultModel"
render={({ field }) => (
<Field>
<FieldLabel htmlFor="lighthouse-v2-model">
Default model
</FieldLabel>
<Select
value={field.value}
onValueChange={field.onChange}
allowDeselect
>
<SelectTrigger id="lighthouse-v2-model">
<SelectValue placeholder="Select model" />
</SelectTrigger>
<SelectContent width="wide">
{models.map((model) => (
<SelectItem key={model.id} value={model.id}>
{model.id}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
)}
/>
</ConfigurationSection>
<ConfigurationSection
icon={<Bot className="size-4" />}
title="Business context"
description="Short operational context Lighthouse AI should consider while answering."
>
<Field>
<div className="flex items-center justify-between gap-3">
<FieldLabel htmlFor="lighthouse-v2-business-context">
Business context
</FieldLabel>
<span
className={cn(
"text-xs",
businessContext.length > BUSINESS_CONTEXT_LIMIT
? "text-text-error-primary"
: "text-text-neutral-tertiary",
)}
>
{businessContext.length}/{BUSINESS_CONTEXT_LIMIT}
</span>
</div>
<Textarea
id="lighthouse-v2-business-context"
textareaSize="lg"
aria-invalid={Boolean(form.formState.errors.businessContext)}
placeholder="Example: production AWS accounts, PCI workloads, EU data residency, critical internet-facing services..."
{...form.register("businessContext")}
<div className="min-h-0 flex-1 overflow-y-auto">
<ConfigurationSection
icon={<KeyRound className="size-4" />}
title="Credentials"
description={
configuration
? "Leave blank to keep existing credentials."
: "Credentials are required for new configurations."
}
>
<CredentialFields
errors={form.formState.errors}
provider={providerType}
register={form.register}
/>
{form.formState.errors.businessContext?.message && (
<FieldError>
{form.formState.errors.businessContext.message}
</FieldError>
)}
</Field>
</ConfigurationSection>
</ConfigurationSection>
</div>
<div className="flex flex-col gap-4 px-4 py-6 sm:flex-row sm:items-center sm:justify-between md:px-5">
<div className="border-border-neutral-secondary mt-auto flex flex-col gap-4 border-t px-4 py-4 sm:flex-row sm:items-center sm:justify-between md:px-5">
<div className="text-text-neutral-secondary text-sm">
{configuration
? "Saving updates may change chat behavior immediately."
@@ -1,32 +0,0 @@
import { AlertCircle, CheckCircle2, Info } from "lucide-react";
import {
FEEDBACK_VARIANT,
type FeedbackState,
} from "@/app/(prowler)/lighthouse/_lib/config";
import { Alert, AlertDescription, AlertTitle } from "@/components/shadcn/alert";
export function ConfigFeedbackAlert({
feedback,
onClose,
}: {
feedback: FeedbackState;
onClose: () => void;
}) {
const Icon =
feedback.variant === FEEDBACK_VARIANT.ERROR
? AlertCircle
: feedback.variant === FEEDBACK_VARIANT.SUCCESS
? CheckCircle2
: Info;
return (
<Alert variant={feedback.variant} onClose={onClose}>
<Icon className="size-4" />
<AlertTitle>{feedback.title}</AlertTitle>
<AlertDescription>
{feedback.description && <p>{feedback.description}</p>}
</AlertDescription>
</Alert>
);
}
@@ -4,7 +4,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import type {
LighthouseV2Configuration,
LighthouseV2SupportedModel,
LighthouseV2SupportedProvider,
} from "@/app/(prowler)/lighthouse/_types";
@@ -15,22 +14,33 @@ const {
deleteConfigurationMock,
testConnectionMock,
updateConfigurationMock,
updateTenantConfigurationMock,
toastMock,
} = vi.hoisted(() => ({
createConfigurationMock: vi.fn(),
deleteConfigurationMock: vi.fn(),
testConnectionMock: vi.fn(),
updateConfigurationMock: vi.fn(),
updateTenantConfigurationMock: vi.fn(),
toastMock: vi.fn(),
}));
vi.mock("next/navigation", () => ({
useRouter: () => ({ refresh: vi.fn(), push: vi.fn() }),
}));
// Action feedback is delivered through toasts (rendered by the layout Toaster),
// so we assert the dispatched toast rather than in-page banner text.
vi.mock("@/components/ui", () => ({
useToast: () => ({ toast: toastMock, dismiss: vi.fn() }),
}));
vi.mock("@/app/(prowler)/lighthouse/_actions", () => ({
createLighthouseV2Configuration: createConfigurationMock,
deleteLighthouseV2Configuration: deleteConfigurationMock,
testLighthouseV2ConfigurationConnection: testConnectionMock,
updateLighthouseV2Configuration: updateConfigurationMock,
updateLighthouseV2TenantConfiguration: updateTenantConfigurationMock,
}));
const providers: LighthouseV2SupportedProvider[] = [
@@ -39,31 +49,12 @@ const providers: LighthouseV2SupportedProvider[] = [
{ id: "openai-compatible", name: "OpenAI-compatible" },
];
const modelsByProvider = {
openai: [
model("gpt-5.1", {
supportsFunctionCalling: true,
supportsVision: true,
supportsReasoning: true,
}),
],
bedrock: [
model("anthropic.claude-4", {
supportsFunctionCalling: true,
supportsVision: false,
supportsReasoning: true,
}),
],
"openai-compatible": [model("llama-3.3")],
};
const configurations: LighthouseV2Configuration[] = [
{
id: "config-openai",
providerType: "openai",
baseUrl: null,
defaultModel: "gpt-5.1",
businessContext: "Production account",
connected: true,
connectionLastCheckedAt: "2026-06-24T10:00:00Z",
insertedAt: "2026-06-24T09:00:00Z",
@@ -74,7 +65,6 @@ const configurations: LighthouseV2Configuration[] = [
providerType: "bedrock",
baseUrl: null,
defaultModel: "anthropic.claude-4",
businessContext: "AWS landing zone",
connected: false,
connectionLastCheckedAt: "2026-06-23T10:00:00Z",
insertedAt: "2026-06-23T09:00:00Z",
@@ -88,6 +78,16 @@ describe("LighthouseV2ConfigPage", () => {
deleteConfigurationMock.mockReset();
testConnectionMock.mockReset();
updateConfigurationMock.mockReset();
updateTenantConfigurationMock.mockReset();
toastMock.mockReset();
updateTenantConfigurationMock.mockResolvedValue({
data: {
id: "tenant-config-1",
businessContext: "Updated business context",
defaultProvider: "",
defaultModels: {},
},
});
createConfigurationMock.mockResolvedValue({ data: configurations[0] });
deleteConfigurationMock.mockResolvedValue({ data: true });
@@ -120,12 +120,7 @@ describe("LighthouseV2ConfigPage", () => {
);
expect(settingsCard).toHaveAttribute("data-slot", "card");
expect(settingsCard).toHaveClass(
"min-h-[calc(100dvh-6.5rem)]",
"w-full",
"gap-0",
"overflow-hidden",
);
expect(settingsCard).toHaveClass("w-full", "gap-0", "overflow-hidden");
expect(settingsCard).not.toHaveClass("mx-auto", "max-w-7xl");
expect(settingsSeparator).toHaveClass(
"border-t",
@@ -149,50 +144,50 @@ describe("LighthouseV2ConfigPage", () => {
).toBeInTheDocument();
});
it("loads provider-specific form values when switching provider", async () => {
it("renders a single shared business context, not a per-provider default model", async () => {
// Given
const user = userEvent.setup();
renderPage();
// When
// Then: business context is tenant-wide, so there is exactly one editor
expect(
screen.getAllByRole("textbox", { name: /Business context/i }),
).toHaveLength(1);
// When switching providers, no per-provider model/context field appears
await user.click(screen.getByRole("button", { name: /Amazon Bedrock/i }));
// Then
expect(
screen.getByRole("textbox", { name: /Business context/i }),
).toHaveValue("AWS landing zone");
screen.queryByRole("combobox", { name: "Default model" }),
).not.toBeInTheDocument();
expect(screen.queryByText("Default model")).not.toBeInTheDocument();
expect(
screen.getByRole("combobox", { name: "Default model" }),
).toHaveTextContent("anthropic.claude-4");
screen.getAllByRole("textbox", { name: /Business context/i }),
).toHaveLength(1);
});
it("updates an existing configuration without sending blank credentials", async () => {
it("updates an existing configuration without sending blank credentials or model defaults", async () => {
// Given
const user = userEvent.setup();
updateConfigurationMock.mockResolvedValue({
data: {
...configurations[0],
businessContext: "Updated context",
},
});
updateConfigurationMock.mockResolvedValue({ data: configurations[0] });
renderPage();
// When
await user.clear(
screen.getByRole("textbox", { name: /Business context/i }),
// When: save the active provider without touching credentials
await user.click(
within(
screen.getByRole("region", { name: "Lighthouse AI settings" }),
).getByRole("button", { name: /^Save$/i }),
);
await user.type(
screen.getByRole("textbox", { name: /Business context/i }),
"Updated context",
);
await user.click(screen.getByRole("button", { name: /^Save$/i }));
// Then
await waitFor(() =>
expect(updateConfigurationMock).toHaveBeenCalledWith(
"config-openai",
expect.not.objectContaining({ credentials: expect.anything() }),
),
await waitFor(() => expect(updateConfigurationMock).toHaveBeenCalled());
expect(updateConfigurationMock.mock.calls[0]?.[0]).toBe("config-openai");
expect(updateConfigurationMock.mock.calls[0]?.[1]).not.toHaveProperty(
"credentials",
);
expect(updateConfigurationMock.mock.calls[0]?.[1]).not.toHaveProperty(
"defaultModel",
);
});
@@ -204,7 +199,6 @@ describe("LighthouseV2ConfigPage", () => {
providerType: "openai-compatible",
baseUrl: "https://llm.example.com/v1",
defaultModel: "llama-3.3",
businessContext: "Private model",
connected: null,
connectionLastCheckedAt: null,
insertedAt: "2026-06-24T10:00:00Z",
@@ -222,22 +216,23 @@ describe("LighthouseV2ConfigPage", () => {
screen.getByLabelText("Base URL"),
"https://llm.example.com/v1",
);
await user.type(
screen.getByRole("textbox", { name: /Business context/i }),
"Private model",
);
await user.click(screen.getByRole("button", { name: /^Save$/i }));
// Then
await waitFor(() =>
expect(createConfigurationMock).toHaveBeenCalledWith({
await waitFor(() => expect(createConfigurationMock).toHaveBeenCalled());
expect(createConfigurationMock.mock.calls[0]?.[0]).toEqual(
expect.objectContaining({
providerType: "openai-compatible",
credentials: { api_key: "provider-key" },
baseUrl: "https://llm.example.com/v1",
defaultModel: null,
businessContext: "Private model",
}),
);
expect(createConfigurationMock.mock.calls[0]?.[0]).not.toHaveProperty(
"defaultModel",
);
expect(createConfigurationMock.mock.calls[0]?.[0]).not.toHaveProperty(
"businessContext",
);
});
it("blocks OpenAI-compatible save when base URL is missing", async () => {
@@ -275,32 +270,6 @@ describe("LighthouseV2ConfigPage", () => {
expect(screen.getByLabelText("AWS region")).toBeInTheDocument();
});
it("shows the business context counter and blocks values over 1000 characters", async () => {
// Given
const user = userEvent.setup();
renderPage();
// When
const businessContext = screen.getByRole("textbox", {
name: /Business context/i,
});
await user.clear(businessContext);
// Paste the whole string in one event instead of 1001 keystrokes, which
// re-renders the watched form on every character and times out under load.
await user.click(businessContext);
await user.paste("a".repeat(1001));
await user.click(screen.getByRole("button", { name: /^Save$/i }));
// Then
expect(screen.getByText("1001/1000")).toBeInTheDocument();
expect(
await screen.findByText(
"Business context cannot exceed 1000 characters.",
),
).toBeInTheDocument();
expect(updateConfigurationMock).not.toHaveBeenCalled();
});
it("tests the connection and reports the resulting status", async () => {
// Given
const user = userEvent.setup();
@@ -316,7 +285,14 @@ describe("LighthouseV2ConfigPage", () => {
await waitFor(() =>
expect(testConnectionMock).toHaveBeenCalledWith("config-openai"),
);
expect(await screen.findByText("Connection failed.")).toBeInTheDocument();
await waitFor(() =>
expect(toastMock).toHaveBeenCalledWith(
expect.objectContaining({
title: "Connection failed.",
variant: "destructive",
}),
),
);
expect(
screen.queryByRole("button", { name: /Refresh status/i }),
).not.toBeInTheDocument();
@@ -337,7 +313,11 @@ describe("LighthouseV2ConfigPage", () => {
await waitFor(() =>
expect(deleteConfigurationMock).toHaveBeenCalledWith("config-openai"),
);
expect(screen.getByText("Configuration removed.")).toBeInTheDocument();
await waitFor(() =>
expect(toastMock).toHaveBeenCalledWith(
expect.objectContaining({ title: "Configuration removed." }),
),
);
});
});
@@ -348,23 +328,8 @@ function renderPage(
<LighthouseV2ConfigPage
configurations={props?.configurations ?? configurations}
providers={props?.providers ?? providers}
modelsByProvider={props?.modelsByProvider ?? modelsByProvider}
tenantConfiguration={props?.tenantConfiguration}
error={props?.error}
/>,
);
}
function model(
id: string,
overrides: Partial<LighthouseV2SupportedModel> = {},
): LighthouseV2SupportedModel {
return {
id,
maxInputTokens: null,
maxOutputTokens: null,
supportsFunctionCalling: null,
supportsVision: null,
supportsReasoning: null,
...overrides,
};
}
@@ -9,45 +9,57 @@ import {
import {
type LighthouseV2Configuration,
type LighthouseV2ProviderType,
type LighthouseV2SupportedModel,
type LighthouseV2SupportedProvider,
type LighthouseV2TenantConfiguration,
} from "@/app/(prowler)/lighthouse/_types";
import { Card } from "@/components/shadcn/card/card";
import { useToast } from "@/components/ui";
import { useMountEffect } from "@/hooks/use-mount-effect";
import { LighthouseV2BusinessContextForm } from "./business-context-form";
import { LighthouseV2ConfigurationForm } from "./configuration-form";
import { LighthouseV2EmptyState } from "./empty-state";
import { ConfigFeedbackAlert } from "./feedback-alert";
import { LighthouseV2ProviderRail } from "./provider-rail";
interface LighthouseV2ConfigPageProps {
configurations: LighthouseV2Configuration[];
providers: LighthouseV2SupportedProvider[];
modelsByProvider: Record<
LighthouseV2ProviderType,
LighthouseV2SupportedModel[]
>;
tenantConfiguration?: LighthouseV2TenantConfiguration;
error?: string;
}
export function LighthouseV2ConfigPage({
configurations,
providers,
modelsByProvider,
tenantConfiguration,
error,
}: LighthouseV2ConfigPageProps) {
const { toast } = useToast();
const [localConfigurations, setLocalConfigurations] =
useState(configurations);
const [selectedProvider, setSelectedProvider] =
useState<LighthouseV2ProviderType>(providers[0]?.id ?? "openai");
const [feedback, setFeedback] = useState<FeedbackState | null>(
error
? {
title: "Configuration unavailable",
description: error,
variant: FEEDBACK_VARIANT.ERROR,
}
: null,
);
const showFeedback = (feedback: FeedbackState) => {
toast({
title: feedback.title,
description: feedback.description,
variant:
feedback.variant === FEEDBACK_VARIANT.ERROR ? "destructive" : "default",
});
};
// Surface a load-time error (failed fetch) once, since it is not tied to a
// user interaction that could dispatch the toast itself.
useMountEffect(() => {
if (error) {
showFeedback({
title: "Configuration unavailable",
description: error,
variant: FEEDBACK_VARIANT.ERROR,
});
}
});
const selectedConfig = localConfigurations.find(
(config) => config.providerType === selectedProvider,
@@ -55,11 +67,6 @@ export function LighthouseV2ConfigPage({
const selectedProviderDefinition =
providers.find((provider) => provider.id === selectedProvider) ??
providers[0];
const selectedModels =
selectedProviderDefinition &&
modelsByProvider[selectedProviderDefinition.id]
? modelsByProvider[selectedProviderDefinition.id]
: [];
const upsertConfiguration = (configuration: LighthouseV2Configuration) => {
setLocalConfigurations((current) => [
@@ -73,7 +80,7 @@ export function LighthouseV2ConfigPage({
) => {
upsertConfiguration(configuration);
setSelectedProvider(configuration.providerType);
setFeedback({
showFeedback({
title: "Configuration saved.",
description:
"Lighthouse AI can use this provider after it tests cleanly.",
@@ -85,7 +92,7 @@ export function LighthouseV2ConfigPage({
configuration: LighthouseV2Configuration,
) => {
upsertConfiguration(configuration);
setFeedback(
showFeedback(
configuration.connected
? {
title: "Connection successful.",
@@ -105,7 +112,7 @@ export function LighthouseV2ConfigPage({
setLocalConfigurations((current) =>
current.filter((config) => config.id !== configurationId),
);
setFeedback({
showFeedback({
title: "Configuration removed.",
description: "This provider is no longer available for Lighthouse AI.",
variant: FEEDBACK_VARIANT.INFO,
@@ -122,27 +129,19 @@ export function LighthouseV2ConfigPage({
padding="none"
role="region"
aria-label="Lighthouse AI settings"
className="min-h-[calc(100dvh-6.5rem)] w-full gap-0 overflow-hidden"
className="w-full gap-0 overflow-hidden"
>
{feedback && (
<div className="border-border-neutral-secondary border-b px-4 py-4 md:px-5">
<ConfigFeedbackAlert
feedback={feedback}
onClose={() => setFeedback(null)}
/>
</div>
)}
<LighthouseV2BusinessContextForm
initialBusinessContext={tenantConfiguration?.businessContext ?? ""}
/>
<div className="grid min-h-0 gap-0 xl:grid-cols-[320px_auto_minmax(0,1fr)]">
<div className="grid min-h-0 flex-1 gap-0 xl:grid-cols-[320px_auto_minmax(0,1fr)]">
<div className="min-w-0 p-4 md:p-5">
<LighthouseV2ProviderRail
configurations={localConfigurations}
providers={providers}
selectedProvider={selectedProvider}
onSelectProvider={(provider) => {
setSelectedProvider(provider);
setFeedback(null);
}}
onSelectProvider={setSelectedProvider}
/>
</div>
@@ -152,16 +151,17 @@ export function LighthouseV2ConfigPage({
className="border-border-neutral-secondary border-t xl:border-t-0 xl:border-l"
/>
<div className="min-w-0">
<div className="flex min-h-0 w-full min-w-0">
<LighthouseV2ConfigurationForm
key={selectedProvider}
configuration={selectedConfig}
models={selectedModels}
provider={selectedProviderDefinition}
onConfigurationSaved={handleConfigurationSaved}
onConfigurationDeleted={handleConfigurationDeleted}
onConfigurationTested={handleConfigurationTested}
onFeedback={setFeedback}
onFeedback={(feedback) => {
if (feedback) showFeedback(feedback);
}}
/>
</div>
</div>
@@ -67,9 +67,6 @@ export function LighthouseV2ProviderRail({
</span>
<StatusBadge status={status} />
</div>
<p className="text-text-neutral-secondary mt-1 truncate text-xs">
{config?.defaultModel || "No default model"}
</p>
<p className="text-text-neutral-tertiary mt-1 text-xs">
{formatLastChecked(config?.connectionLastCheckedAt)}
</p>
@@ -39,10 +39,6 @@ const lighthouseV2ConfigFormSchemaBase = z.object({
awsSecretAccessKey: z.string(),
awsRegionName: z.string(),
baseUrl: z.string(),
defaultModel: z.string(),
businessContext: z.string().max(BUSINESS_CONTEXT_LIMIT, {
error: "Business context cannot exceed 1000 characters.",
}),
});
export type LighthouseV2ConfigFormValues = z.infer<
@@ -55,8 +51,6 @@ export const EMPTY_FORM_VALUES: LighthouseV2ConfigFormValues = {
awsSecretAccessKey: "",
awsRegionName: "",
baseUrl: "",
defaultModel: "",
businessContext: "",
};
export function getFormDefaults(
@@ -65,8 +59,6 @@ export function getFormDefaults(
return {
...EMPTY_FORM_VALUES,
baseUrl: configuration?.baseUrl ?? "",
defaultModel: configuration?.defaultModel ?? "",
businessContext: configuration?.businessContext ?? "",
};
}
@@ -0,0 +1,46 @@
import { describe, expect, it } from "vitest";
import {
buildLighthouseV2ModelSelectionValue,
parseLighthouseV2ModelSelectionValue,
} from "./model-selection";
describe("model-selection value codec", () => {
it("round-trips a provider and model id", () => {
// Given
const value = buildLighthouseV2ModelSelectionValue("openai", "gpt-5.1");
// When
const selection = parseLighthouseV2ModelSelectionValue(value);
// Then
expect(value).toBe("openai:gpt-5.1");
expect(selection).toEqual({ providerType: "openai", modelId: "gpt-5.1" });
});
it("keeps colons inside Bedrock model ids by splitting on the first colon only", () => {
// Given
const value = buildLighthouseV2ModelSelectionValue(
"bedrock",
"anthropic.claude-3-sonnet-20240229-v1:0",
);
// When
const selection = parseLighthouseV2ModelSelectionValue(value);
// Then
expect(selection).toEqual({
providerType: "bedrock",
modelId: "anthropic.claude-3-sonnet-20240229-v1:0",
});
});
it("rejects values without a known provider or model id", () => {
// Then
expect(parseLighthouseV2ModelSelectionValue("")).toBeNull();
expect(parseLighthouseV2ModelSelectionValue("gpt-5.1")).toBeNull();
expect(parseLighthouseV2ModelSelectionValue(":gpt-5.1")).toBeNull();
expect(parseLighthouseV2ModelSelectionValue("unknown:gpt-5.1")).toBeNull();
expect(parseLighthouseV2ModelSelectionValue("openai:")).toBeNull();
});
});
@@ -0,0 +1,45 @@
import {
LIGHTHOUSE_V2_PROVIDER_TYPE,
type LighthouseV2ProviderType,
} from "@/app/(prowler)/lighthouse/_types";
export interface LighthouseV2ModelSelection {
providerType: LighthouseV2ProviderType;
modelId: string;
}
// Encodes a provider + model into a single combobox option value. The value is
// kept human-readable (no encoding) so the combobox search matches the model id
// the user types. Bedrock model ids can contain ":" (e.g. "...-v1:0"), so the
// parser splits on the FIRST ":" only.
export function buildLighthouseV2ModelSelectionValue(
providerType: LighthouseV2ProviderType,
modelId: string,
) {
return `${providerType}:${modelId}`;
}
export function parseLighthouseV2ModelSelectionValue(
value: string,
): LighthouseV2ModelSelection | null {
const separatorIndex = value.indexOf(":");
if (separatorIndex <= 0) return null;
const providerType = value.slice(0, separatorIndex);
if (!isLighthouseV2ProviderType(providerType)) return null;
const modelId = value.slice(separatorIndex + 1);
if (!modelId) return null;
return { providerType, modelId };
}
function isLighthouseV2ProviderType(
value: string,
): value is LighthouseV2ProviderType {
return (
value === LIGHTHOUSE_V2_PROVIDER_TYPE.OPENAI ||
value === LIGHTHOUSE_V2_PROVIDER_TYPE.BEDROCK ||
value === LIGHTHOUSE_V2_PROVIDER_TYPE.OPENAI_COMPATIBLE
);
}
+12 -4
View File
@@ -37,7 +37,6 @@ export interface LighthouseV2Configuration {
providerType: LighthouseV2ProviderType;
baseUrl: string | null;
defaultModel: string | null;
businessContext: string;
connected: boolean | null;
connectionLastCheckedAt: string | null;
insertedAt: string;
@@ -48,15 +47,24 @@ export interface LighthouseV2ConfigurationInput {
providerType: LighthouseV2ProviderType;
credentials: LighthouseV2Credentials;
baseUrl?: string | null;
defaultModel?: string | null;
businessContext?: string;
}
export interface LighthouseV2ConfigurationUpdateInput {
credentials?: LighthouseV2Credentials;
baseUrl?: string | null;
defaultModel?: string | null;
}
export interface LighthouseV2TenantConfiguration {
id: string;
businessContext: string;
defaultProvider: LighthouseV2ProviderType | "";
defaultModels: Record<string, string>;
}
export interface LighthouseV2TenantConfigurationUpdateInput {
businessContext?: string;
defaultProvider?: LighthouseV2ProviderType | "";
defaultModels?: Record<string, string>;
}
export interface LighthouseV2SupportedProvider {
+9 -1
View File
@@ -9,6 +9,7 @@ import {
getLighthouseV2Messages,
getLighthouseV2Session,
getLighthouseV2SupportedModels,
getLighthouseV2TenantConfiguration,
} from "@/app/(prowler)/lighthouse/_actions";
import { LighthouseV2ChatPage } from "@/app/(prowler)/lighthouse/_components/chat";
import { LighthouseV2NavigationModeSync } from "@/app/(prowler)/lighthouse/_components/navigation";
@@ -36,7 +37,9 @@ export default async function AIChatbot({
typeof params.session === "string" ? params.session : undefined;
if (isCloud()) {
const configurationsResult = await getLighthouseV2Configurations();
const [configurationsResult, tenantConfigurationResult] = await Promise.all(
[getLighthouseV2Configurations(), getLighthouseV2TenantConfiguration()],
);
const configurations =
"data" in configurationsResult ? configurationsResult.data : [];
const connectedConfigurations = configurations.filter(
@@ -89,6 +92,11 @@ export default async function AIChatbot({
key={chatRouteKey}
configurations={configurations}
modelsByProvider={modelsByProvider}
tenantConfiguration={
"data" in tenantConfigurationResult
? tenantConfigurationResult.data
: undefined
}
initialSessionId={activeSessionId}
initialMessages={
"data" in initialMessages ? initialMessages.data : []
+12 -26
View File
@@ -2,14 +2,10 @@ import { Spacer } from "@heroui/spacer";
import {
getLighthouseV2Configurations,
getLighthouseV2SupportedModels,
getLighthouseV2SupportedProviders,
getLighthouseV2TenantConfiguration,
} from "@/app/(prowler)/lighthouse/_actions";
import { LighthouseV2ConfigPage } from "@/app/(prowler)/lighthouse/_components/config";
import type {
LighthouseV2ProviderType,
LighthouseV2SupportedModel,
} from "@/app/(prowler)/lighthouse/_types";
import {
LighthouseSettings,
LLMProvidersTable,
@@ -21,28 +17,14 @@ export const dynamic = "force-dynamic";
export default async function LighthouseSettingsPage() {
if (isCloud()) {
const [configurationsResult, providersResult] = await Promise.all([
getLighthouseV2Configurations(),
getLighthouseV2SupportedProviders(),
]);
const [configurationsResult, providersResult, tenantConfigurationResult] =
await Promise.all([
getLighthouseV2Configurations(),
getLighthouseV2SupportedProviders(),
getLighthouseV2TenantConfiguration(),
]);
const providers = "data" in providersResult ? providersResult.data : [];
const modelsEntries = await Promise.all(
providers.map(async (provider) => {
const result = await getLighthouseV2SupportedModels(provider.id);
return [
provider.id,
"data" in result ? result.data : [],
] as const satisfies readonly [
LighthouseV2ProviderType,
LighthouseV2SupportedModel[],
];
}),
);
const modelsByProvider = Object.fromEntries(modelsEntries) as Record<
LighthouseV2ProviderType,
LighthouseV2SupportedModel[]
>;
const error =
"error" in configurationsResult
? configurationsResult.error
@@ -57,7 +39,11 @@ export default async function LighthouseSettingsPage() {
"data" in configurationsResult ? configurationsResult.data : []
}
providers={providers}
modelsByProvider={modelsByProvider}
tenantConfiguration={
"data" in tenantConfigurationResult
? tenantConfigurationResult.data
: undefined
}
error={error}
/>
</ContentLayout>