From 58539dced622faa2fb25671ffdac26d8dfd3e1eb Mon Sep 17 00:00:00 2001 From: alejandrobailo Date: Mon, 29 Jun 2026 16:48:34 +0200 Subject: [PATCH] feat(ui): tenant-level model selection and shared business context for Lighthouse --- .../_actions/lighthouse-v2.adapter.test.ts | 67 +++++- .../_actions/lighthouse-v2.adapter.ts | 42 +++- .../lighthouse/_actions/lighthouse-v2.test.ts | 58 +++++ .../lighthouse/_actions/lighthouse-v2.ts | 29 +++ .../lighthouse/_components/chat/composer.tsx | 54 +++-- .../_components/chat/empty-state.tsx | 6 +- .../chat/lighthouse-v2-chat-page.test.tsx | 139 +++++++++-- .../chat/lighthouse-v2-chat-page.tsx | 219 ++++++++++++++++-- .../config/business-context-form.test.tsx | 114 +++++++++ .../config/business-context-form.tsx | 117 ++++++++++ .../_components/config/configuration-form.tsx | 135 ++--------- .../_components/config/feedback-alert.tsx | 32 --- .../config/lighthouse-v2-config-page.test.tsx | 177 ++++++-------- .../config/lighthouse-v2-config-page.tsx | 82 +++---- .../_components/config/provider-rail.tsx | 3 - ui/app/(prowler)/lighthouse/_lib/config.ts | 8 - .../lighthouse/_lib/model-selection.test.ts | 46 ++++ .../lighthouse/_lib/model-selection.ts | 45 ++++ ui/app/(prowler)/lighthouse/_types/config.ts | 16 +- ui/app/(prowler)/lighthouse/page.tsx | 10 +- ui/app/(prowler)/lighthouse/settings/page.tsx | 38 +-- 21 files changed, 1042 insertions(+), 395 deletions(-) create mode 100644 ui/app/(prowler)/lighthouse/_components/config/business-context-form.test.tsx create mode 100644 ui/app/(prowler)/lighthouse/_components/config/business-context-form.tsx delete mode 100644 ui/app/(prowler)/lighthouse/_components/config/feedback-alert.tsx create mode 100644 ui/app/(prowler)/lighthouse/_lib/model-selection.test.ts create mode 100644 ui/app/(prowler)/lighthouse/_lib/model-selection.ts diff --git a/ui/app/(prowler)/lighthouse/_actions/lighthouse-v2.adapter.test.ts b/ui/app/(prowler)/lighthouse/_actions/lighthouse-v2.adapter.test.ts index 03186a1d82..daf122f9cd 100644 --- a/ui/app/(prowler)/lighthouse/_actions/lighthouse-v2.adapter.test.ts +++ b/ui/app/(prowler)/lighthouse/_actions/lighthouse-v2.adapter.test.ts @@ -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[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[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", () => { diff --git a/ui/app/(prowler)/lighthouse/_actions/lighthouse-v2.adapter.ts b/ui/app/(prowler)/lighthouse/_actions/lighthouse-v2.adapter.ts index 60a7c5ba2f..78506cd431 100644 --- a/ui/app/(prowler)/lighthouse/_actions/lighthouse-v2.adapter.ts +++ b/ui/app/(prowler)/lighthouse/_actions/lighthouse-v2.adapter.ts @@ -11,6 +11,8 @@ import { type LighthouseV2SupportedModel, type LighthouseV2SupportedProvider, type LighthouseV2Task, + type LighthouseV2TenantConfiguration, + type LighthouseV2TenantConfigurationUpdateInput, } from "@/app/(prowler)/lighthouse/_types"; export interface JsonApiResource { @@ -32,14 +34,19 @@ export interface JsonApiDocument { 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 | 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, +): 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, ): 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, }), }, }; diff --git a/ui/app/(prowler)/lighthouse/_actions/lighthouse-v2.test.ts b/ui/app/(prowler)/lighthouse/_actions/lighthouse-v2.test.ts index a40fc835a1..ae625a4890 100644 --- a/ui/app/(prowler)/lighthouse/_actions/lighthouse-v2.test.ts +++ b/ui/app/(prowler)/lighthouse/_actions/lighthouse-v2.test.ts @@ -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(); + }); }); diff --git a/ui/app/(prowler)/lighthouse/_actions/lighthouse-v2.ts b/ui/app/(prowler)/lighthouse/_actions/lighthouse-v2.ts index 2cf3c98a7b..a11f635e30 100644 --- a/ui/app/(prowler)/lighthouse/_actions/lighthouse-v2.ts +++ b/ui/app/(prowler)/lighthouse/_actions/lighthouse-v2.ts @@ -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 +> { + return getSingle( + "/lighthouse/configuration", + mapLighthouseV2TenantConfiguration, + ); +} + +export async function updateLighthouseV2TenantConfiguration( + input: LighthouseV2TenantConfigurationUpdateInput, +): Promise> { + 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. diff --git a/ui/app/(prowler)/lighthouse/_components/chat/composer.tsx b/ui/app/(prowler)/lighthouse/_components/chat/composer.tsx index 2dba3a90be..30d929cd2e 100644 --- a/ui/app/(prowler)/lighthouse/_components/chat/composer.tsx +++ b/ui/app/(prowler)/lighthouse/_components/chat/composer.tsx @@ -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) => void; + onSubmit: (event: SubmitEvent) => void; onSubmitText: (text: string) => Promise; } @@ -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} /> @@ -51,22 +56,27 @@ function ChatFeedbackBar({ feedback, canRetry, onRetry, + onDismiss, }: { feedback: string | null; canRetry: boolean; onRetry: () => void; + onDismiss: () => void; }) { if (!feedback) return null; return ( -
- {feedback} - {canRetry && ( - - )} -
+ + + + {feedback} + {canRetry && ( + + )} + + ); } @@ -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) => void; + onSubmit: (event: SubmitEvent) => void; onSubmitText: (text: string) => Promise; } @@ -89,6 +100,7 @@ function ChatComposer({ isStreaming, selectedConfigurationConnected, onInputChange, + modelSelector, onSubmit, onSubmitText, }: ChatComposerProps) { @@ -117,12 +129,18 @@ function ChatComposer({ } }} /> -
- +
+
+ + {modelSelector} +
{isStreaming ? (
void; + onDismissFeedback: () => void; canSend: boolean; input: string; isStreaming: boolean; + modelSelector: ReactNode; selectedConfigurationConnected: boolean; onInputChange: (value: string) => void; onStop: () => void; - onSubmit: (event: FormEvent) => void; + onSubmit: (event: SubmitEvent) => void; onSubmitText: (text: string) => Promise; } diff --git a/ui/app/(prowler)/lighthouse/_components/chat/lighthouse-v2-chat-page.test.tsx b/ui/app/(prowler)/lighthouse/_components/chat/lighthouse-v2-chat-page.test.tsx index 22186d87ef..77c2b2df82 100644 --- a/ui/app/(prowler)/lighthouse/_components/chat/lighthouse-v2-chat-page.test.tsx +++ b/ui/app/(prowler)/lighthouse/_components/chat/lighthouse-v2-chat-page.test.tsx @@ -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[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, diff --git a/ui/app/(prowler)/lighthouse/_components/chat/lighthouse-v2-chat-page.tsx b/ui/app/(prowler)/lighthouse/_components/chat/lighthouse-v2-chat-page.tsx index 4b31bb9760..10575ea83c 100644 --- a/ui/app/(prowler)/lighthouse/_components/chat/lighthouse-v2-chat-page.tsx +++ b/ui/app/(prowler)/lighthouse/_components/chat/lighthouse-v2-chat-page.tsx @@ -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(initialModelSelection); + const [tenantModelDefaults, setTenantModelDefaults] = useState< + Record + >(tenantConfiguration?.defaultModels ?? {}); + const [modelPreferenceSaving, setModelPreferenceSaving] = useState(false); const [activeSessionId, setActiveSessionId] = useState( initialSessionId ?? null, ); @@ -93,9 +109,26 @@ export function LighthouseV2ChatPage({ const [streamState, setStreamState] = useState(() => 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) => { + 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) => { 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: ( +
+ +
+ ), 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; diff --git a/ui/app/(prowler)/lighthouse/_components/config/business-context-form.test.tsx b/ui/app/(prowler)/lighthouse/_components/config/business-context-form.test.tsx new file mode 100644 index 0000000000..a97a9ac69e --- /dev/null +++ b/ui/app/(prowler)/lighthouse/_components/config/business-context-form.test.tsx @@ -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( + , + ); + + // 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(); + + // 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(); + + // 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(); + + // 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(); + }); +}); diff --git a/ui/app/(prowler)/lighthouse/_components/config/business-context-form.tsx b/ui/app/(prowler)/lighthouse/_components/config/business-context-form.tsx new file mode 100644 index 0000000000..e7d6fc6e4a --- /dev/null +++ b/ui/app/(prowler)/lighthouse/_components/config/business-context-form.tsx @@ -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(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 ( +
+
+
+ +
+
+

+ Business context +

+

+ Shared context Lighthouse AI considers for every provider and chat. +

+
+
+ +
+ +
+ + Business context + + + {businessContext.length}/{BUSINESS_CONTEXT_LIMIT} + +
+