mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-07-23 20:42:02 +00:00
feat(ui): tenant-level model selection and shared business context for Lighthouse
This commit is contained in:
@@ -2,10 +2,12 @@ import { describe, expect, it } from "vitest";
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
buildLighthouseV2ConfigurationPayload,
|
buildLighthouseV2ConfigurationPayload,
|
||||||
|
buildLighthouseV2TenantConfigurationUpdatePayload,
|
||||||
mapLighthouseV2Configuration,
|
mapLighthouseV2Configuration,
|
||||||
mapLighthouseV2Message,
|
mapLighthouseV2Message,
|
||||||
mapLighthouseV2Model,
|
mapLighthouseV2Model,
|
||||||
mapLighthouseV2Provider,
|
mapLighthouseV2Provider,
|
||||||
|
mapLighthouseV2TenantConfiguration,
|
||||||
validateLighthouseV2ConfigurationInput,
|
validateLighthouseV2ConfigurationInput,
|
||||||
} from "./lighthouse-v2.adapter";
|
} from "./lighthouse-v2.adapter";
|
||||||
|
|
||||||
@@ -20,7 +22,6 @@ describe("lighthouse-v2.adapter", () => {
|
|||||||
provider_type: "bedrock",
|
provider_type: "bedrock",
|
||||||
base_url: null,
|
base_url: null,
|
||||||
default_model: "anthropic.claude",
|
default_model: "anthropic.claude",
|
||||||
business_context: "Production AWS account",
|
|
||||||
connected: true,
|
connected: true,
|
||||||
connection_last_checked_at: "2026-06-24T10:00:00Z",
|
connection_last_checked_at: "2026-06-24T10:00:00Z",
|
||||||
inserted_at: "2026-06-24T09:00:00Z",
|
inserted_at: "2026-06-24T09:00:00Z",
|
||||||
@@ -37,7 +38,6 @@ describe("lighthouse-v2.adapter", () => {
|
|||||||
providerType: "bedrock",
|
providerType: "bedrock",
|
||||||
baseUrl: null,
|
baseUrl: null,
|
||||||
defaultModel: "anthropic.claude",
|
defaultModel: "anthropic.claude",
|
||||||
businessContext: "Production AWS account",
|
|
||||||
connected: true,
|
connected: true,
|
||||||
connectionLastCheckedAt: "2026-06-24T10:00:00Z",
|
connectionLastCheckedAt: "2026-06-24T10:00:00Z",
|
||||||
insertedAt: "2026-06-24T09: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", () => {
|
it("should map message parts from backend names", () => {
|
||||||
// Given
|
// Given
|
||||||
const resource: Parameters<typeof mapLighthouseV2Message>[0] = {
|
const resource: Parameters<typeof mapLighthouseV2Message>[0] = {
|
||||||
@@ -122,8 +153,6 @@ describe("lighthouse-v2.adapter", () => {
|
|||||||
// Given
|
// Given
|
||||||
const input = {
|
const input = {
|
||||||
providerType: "bedrock" as const,
|
providerType: "bedrock" as const,
|
||||||
defaultModel: "anthropic.claude",
|
|
||||||
businessContext: "Production AWS account",
|
|
||||||
credentials: {
|
credentials: {
|
||||||
aws_access_key_id: "AKIA0000000000000000",
|
aws_access_key_id: "AKIA0000000000000000",
|
||||||
aws_secret_access_key: "a".repeat(40),
|
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", () => {
|
it("should require base_url for OpenAI-compatible configurations", () => {
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import {
|
|||||||
type LighthouseV2SupportedModel,
|
type LighthouseV2SupportedModel,
|
||||||
type LighthouseV2SupportedProvider,
|
type LighthouseV2SupportedProvider,
|
||||||
type LighthouseV2Task,
|
type LighthouseV2Task,
|
||||||
|
type LighthouseV2TenantConfiguration,
|
||||||
|
type LighthouseV2TenantConfigurationUpdateInput,
|
||||||
} from "@/app/(prowler)/lighthouse/_types";
|
} from "@/app/(prowler)/lighthouse/_types";
|
||||||
|
|
||||||
export interface JsonApiResource<TAttributes> {
|
export interface JsonApiResource<TAttributes> {
|
||||||
@@ -32,14 +34,19 @@ export interface JsonApiDocument<TData> {
|
|||||||
interface ConfigurationAttributes {
|
interface ConfigurationAttributes {
|
||||||
provider_type: LighthouseV2ProviderType;
|
provider_type: LighthouseV2ProviderType;
|
||||||
base_url: string | null;
|
base_url: string | null;
|
||||||
default_model: string | null;
|
default_model?: string | null;
|
||||||
business_context?: string | null;
|
|
||||||
connected: boolean | null;
|
connected: boolean | null;
|
||||||
connection_last_checked_at: string | null;
|
connection_last_checked_at: string | null;
|
||||||
inserted_at: string;
|
inserted_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface TenantConfigurationAttributes {
|
||||||
|
business_context?: string | null;
|
||||||
|
default_provider?: LighthouseV2ProviderType | "";
|
||||||
|
default_models?: Record<string, string> | null;
|
||||||
|
}
|
||||||
|
|
||||||
interface SupportedProviderAttributes {
|
interface SupportedProviderAttributes {
|
||||||
name: string;
|
name: string;
|
||||||
}
|
}
|
||||||
@@ -114,8 +121,7 @@ export function mapLighthouseV2Configuration(
|
|||||||
id: resource.id,
|
id: resource.id,
|
||||||
providerType: resource.attributes.provider_type,
|
providerType: resource.attributes.provider_type,
|
||||||
baseUrl: resource.attributes.base_url,
|
baseUrl: resource.attributes.base_url,
|
||||||
defaultModel: resource.attributes.default_model,
|
defaultModel: resource.attributes.default_model ?? null,
|
||||||
businessContext: resource.attributes.business_context ?? "",
|
|
||||||
connected: resource.attributes.connected,
|
connected: resource.attributes.connected,
|
||||||
connectionLastCheckedAt: resource.attributes.connection_last_checked_at,
|
connectionLastCheckedAt: resource.attributes.connection_last_checked_at,
|
||||||
insertedAt: resource.attributes.inserted_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(
|
export function mapLighthouseV2Provider(
|
||||||
resource: JsonApiResource<SupportedProviderAttributes>,
|
resource: JsonApiResource<SupportedProviderAttributes>,
|
||||||
): LighthouseV2SupportedProvider {
|
): LighthouseV2SupportedProvider {
|
||||||
@@ -195,8 +212,6 @@ export function buildLighthouseV2ConfigurationPayload(
|
|||||||
provider_type: input.providerType,
|
provider_type: input.providerType,
|
||||||
credentials: input.credentials,
|
credentials: input.credentials,
|
||||||
base_url: input.baseUrl ?? null,
|
base_url: input.baseUrl ?? null,
|
||||||
default_model: input.defaultModel ?? null,
|
|
||||||
business_context: input.businessContext,
|
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -213,8 +228,21 @@ export function buildLighthouseV2ConfigurationUpdatePayload(
|
|||||||
attributes: filterUndefinedAttributes({
|
attributes: filterUndefinedAttributes({
|
||||||
credentials: input.credentials,
|
credentials: input.credentials,
|
||||||
base_url: input.baseUrl,
|
base_url: input.baseUrl,
|
||||||
default_model: input.defaultModel,
|
}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildLighthouseV2TenantConfigurationUpdatePayload(
|
||||||
|
input: LighthouseV2TenantConfigurationUpdateInput,
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
data: {
|
||||||
|
type: "lighthouse-configurations",
|
||||||
|
attributes: filterUndefinedAttributes({
|
||||||
business_context: input.businessContext,
|
business_context: input.businessContext,
|
||||||
|
default_provider: input.defaultProvider,
|
||||||
|
default_models: input.defaultModels,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ vi.mock("@/lib/helper", () => ({
|
|||||||
import {
|
import {
|
||||||
createLighthouseV2Session,
|
createLighthouseV2Session,
|
||||||
updateLighthouseV2Session,
|
updateLighthouseV2Session,
|
||||||
|
updateLighthouseV2TenantConfiguration,
|
||||||
} from "./lighthouse-v2";
|
} from "./lighthouse-v2";
|
||||||
|
|
||||||
function sessionResponse(id = "session-1") {
|
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", () => {
|
describe("Lighthouse v2 session write actions", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
authMock.mockResolvedValue({ accessToken: "token-123" });
|
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.
|
// Proves the test harness would catch a revalidate being (re)introduced.
|
||||||
expect(revalidatePathMock).toHaveBeenCalledWith("/lighthouse");
|
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,
|
LighthouseV2SupportedModel,
|
||||||
LighthouseV2SupportedProvider,
|
LighthouseV2SupportedProvider,
|
||||||
LighthouseV2Task,
|
LighthouseV2Task,
|
||||||
|
LighthouseV2TenantConfiguration,
|
||||||
|
LighthouseV2TenantConfigurationUpdateInput,
|
||||||
} from "@/app/(prowler)/lighthouse/_types";
|
} from "@/app/(prowler)/lighthouse/_types";
|
||||||
import { apiBaseUrl, getAuthHeaders } from "@/lib/helper";
|
import { apiBaseUrl, getAuthHeaders } from "@/lib/helper";
|
||||||
import { handleApiError, handleApiResponse } from "@/lib/server-actions-helper";
|
import { handleApiError, handleApiResponse } from "@/lib/server-actions-helper";
|
||||||
@@ -24,6 +26,7 @@ import {
|
|||||||
buildLighthouseV2MessagePayload,
|
buildLighthouseV2MessagePayload,
|
||||||
buildLighthouseV2SessionCreatePayload,
|
buildLighthouseV2SessionCreatePayload,
|
||||||
buildLighthouseV2SessionUpdatePayload,
|
buildLighthouseV2SessionUpdatePayload,
|
||||||
|
buildLighthouseV2TenantConfigurationUpdatePayload,
|
||||||
getJsonApiArray,
|
getJsonApiArray,
|
||||||
type JsonApiDocument,
|
type JsonApiDocument,
|
||||||
mapLighthouseV2Configuration,
|
mapLighthouseV2Configuration,
|
||||||
@@ -32,6 +35,7 @@ import {
|
|||||||
mapLighthouseV2Provider,
|
mapLighthouseV2Provider,
|
||||||
mapLighthouseV2Session,
|
mapLighthouseV2Session,
|
||||||
mapLighthouseV2Task,
|
mapLighthouseV2Task,
|
||||||
|
mapLighthouseV2TenantConfiguration,
|
||||||
validateLighthouseV2ConfigurationInput,
|
validateLighthouseV2ConfigurationInput,
|
||||||
} from "./lighthouse-v2.adapter";
|
} 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
|
// Starts the backend connection-check task, polls it to completion (reusing the
|
||||||
// shared task poller), then returns the re-fetched configuration so the caller
|
// shared task poller), then returns the re-fetched configuration so the caller
|
||||||
// can render the authoritative `connected` / `connectionLastCheckedAt` status.
|
// can render the authoritative `connected` / `connectionLastCheckedAt` status.
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { CornerDownLeft, Settings } from "lucide-react";
|
import { CornerDownLeft, Settings, TriangleAlert } from "lucide-react";
|
||||||
import Link from "next/link";
|
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 { Button } from "@/components/shadcn/button/button";
|
||||||
import { Spinner } from "@/components/shadcn/spinner/spinner";
|
import { Spinner } from "@/components/shadcn/spinner/spinner";
|
||||||
import { Textarea } from "@/components/shadcn/textarea/textarea";
|
import { Textarea } from "@/components/shadcn/textarea/textarea";
|
||||||
@@ -17,13 +18,15 @@ interface ChatComposerPanelProps {
|
|||||||
feedback: string | null;
|
feedback: string | null;
|
||||||
canRetry: boolean;
|
canRetry: boolean;
|
||||||
onRetry: () => void;
|
onRetry: () => void;
|
||||||
|
onDismissFeedback: () => void;
|
||||||
canSend: boolean;
|
canSend: boolean;
|
||||||
input: string;
|
input: string;
|
||||||
isStreaming: boolean;
|
isStreaming: boolean;
|
||||||
|
modelSelector: ReactNode;
|
||||||
selectedConfigurationConnected: boolean;
|
selectedConfigurationConnected: boolean;
|
||||||
onInputChange: (value: string) => void;
|
onInputChange: (value: string) => void;
|
||||||
onStop: () => void;
|
onStop: () => void;
|
||||||
onSubmit: (event: FormEvent<HTMLFormElement>) => void;
|
onSubmit: (event: SubmitEvent<HTMLFormElement>) => void;
|
||||||
onSubmitText: (text: string) => Promise<void>;
|
onSubmitText: (text: string) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -33,6 +36,7 @@ export function ChatComposerPanel({
|
|||||||
feedback,
|
feedback,
|
||||||
canRetry,
|
canRetry,
|
||||||
onRetry,
|
onRetry,
|
||||||
|
onDismissFeedback,
|
||||||
...composerProps
|
...composerProps
|
||||||
}: ChatComposerPanelProps) {
|
}: ChatComposerPanelProps) {
|
||||||
return (
|
return (
|
||||||
@@ -41,6 +45,7 @@ export function ChatComposerPanel({
|
|||||||
feedback={feedback}
|
feedback={feedback}
|
||||||
canRetry={canRetry}
|
canRetry={canRetry}
|
||||||
onRetry={onRetry}
|
onRetry={onRetry}
|
||||||
|
onDismiss={onDismissFeedback}
|
||||||
/>
|
/>
|
||||||
<ChatComposer {...composerProps} />
|
<ChatComposer {...composerProps} />
|
||||||
</>
|
</>
|
||||||
@@ -51,22 +56,27 @@ function ChatFeedbackBar({
|
|||||||
feedback,
|
feedback,
|
||||||
canRetry,
|
canRetry,
|
||||||
onRetry,
|
onRetry,
|
||||||
|
onDismiss,
|
||||||
}: {
|
}: {
|
||||||
feedback: string | null;
|
feedback: string | null;
|
||||||
canRetry: boolean;
|
canRetry: boolean;
|
||||||
onRetry: () => void;
|
onRetry: () => void;
|
||||||
|
onDismiss: () => void;
|
||||||
}) {
|
}) {
|
||||||
if (!feedback) return null;
|
if (!feedback) return null;
|
||||||
|
|
||||||
return (
|
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">
|
<Alert variant="error" onClose={onDismiss} className="mb-3 pr-10">
|
||||||
<span>{feedback}</span>
|
<TriangleAlert />
|
||||||
{canRetry && (
|
<AlertDescription className="flex items-center justify-between gap-3">
|
||||||
<Button type="button" variant="outline" size="sm" onClick={onRetry}>
|
<span>{feedback}</span>
|
||||||
Retry
|
{canRetry && (
|
||||||
</Button>
|
<Button type="button" variant="outline" size="sm" onClick={onRetry}>
|
||||||
)}
|
Retry
|
||||||
</div>
|
</Button>
|
||||||
|
)}
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,12 +84,13 @@ interface ChatComposerProps {
|
|||||||
canSend: boolean;
|
canSend: boolean;
|
||||||
input: string;
|
input: string;
|
||||||
isStreaming: boolean;
|
isStreaming: boolean;
|
||||||
|
modelSelector: ReactNode;
|
||||||
selectedConfigurationConnected: boolean;
|
selectedConfigurationConnected: boolean;
|
||||||
onInputChange: (value: string) => void;
|
onInputChange: (value: string) => void;
|
||||||
// Kept on the contract but unused for now: the backend can't cancel a run yet,
|
// 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.
|
// so the stop control is replaced by a non-interactive spinner.
|
||||||
onStop: () => void;
|
onStop: () => void;
|
||||||
onSubmit: (event: FormEvent<HTMLFormElement>) => void;
|
onSubmit: (event: SubmitEvent<HTMLFormElement>) => void;
|
||||||
onSubmitText: (text: string) => Promise<void>;
|
onSubmitText: (text: string) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,6 +100,7 @@ function ChatComposer({
|
|||||||
isStreaming,
|
isStreaming,
|
||||||
selectedConfigurationConnected,
|
selectedConfigurationConnected,
|
||||||
onInputChange,
|
onInputChange,
|
||||||
|
modelSelector,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
onSubmitText,
|
onSubmitText,
|
||||||
}: ChatComposerProps) {
|
}: ChatComposerProps) {
|
||||||
@@ -117,12 +129,18 @@ function ChatComposer({
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<div className="flex items-center justify-between px-3 pb-3">
|
<div className="flex items-center justify-between gap-3 px-3 pb-3">
|
||||||
<Button type="button" variant="outline" size="icon-sm" asChild>
|
<div className="flex min-w-0 flex-1 items-center gap-2">
|
||||||
<Link href="/lighthouse/settings" aria-label="Lighthouse AI settings">
|
<Button type="button" variant="outline" size="icon-sm" asChild>
|
||||||
<Settings className="size-4" />
|
<Link
|
||||||
</Link>
|
href="/lighthouse/settings"
|
||||||
</Button>
|
aria-label="Lighthouse AI settings"
|
||||||
|
>
|
||||||
|
<Settings className="size-4" />
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
{modelSelector}
|
||||||
|
</div>
|
||||||
{isStreaming ? (
|
{isStreaming ? (
|
||||||
<div
|
<div
|
||||||
className="flex size-8 items-center justify-center"
|
className="flex size-8 items-center justify-center"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { BookOpen, FileCheck2, Network, ShieldAlert } from "lucide-react";
|
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 { LighthouseIcon } from "@/components/icons/Icons";
|
||||||
import { Button } from "@/components/shadcn/button/button";
|
import { Button } from "@/components/shadcn/button/button";
|
||||||
@@ -36,13 +36,15 @@ interface ChatEmptyStateProps {
|
|||||||
feedback: string | null;
|
feedback: string | null;
|
||||||
canRetry: boolean;
|
canRetry: boolean;
|
||||||
onRetry: () => void;
|
onRetry: () => void;
|
||||||
|
onDismissFeedback: () => void;
|
||||||
canSend: boolean;
|
canSend: boolean;
|
||||||
input: string;
|
input: string;
|
||||||
isStreaming: boolean;
|
isStreaming: boolean;
|
||||||
|
modelSelector: ReactNode;
|
||||||
selectedConfigurationConnected: boolean;
|
selectedConfigurationConnected: boolean;
|
||||||
onInputChange: (value: string) => void;
|
onInputChange: (value: string) => void;
|
||||||
onStop: () => void;
|
onStop: () => void;
|
||||||
onSubmit: (event: FormEvent<HTMLFormElement>) => void;
|
onSubmit: (event: SubmitEvent<HTMLFormElement>) => void;
|
||||||
onSubmitText: (text: string) => Promise<void>;
|
onSubmitText: (text: string) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -57,19 +57,26 @@ function stubEventSource() {
|
|||||||
vi.stubGlobal("EventSource", EventSourceMock);
|
vi.stubGlobal("EventSource", EventSourceMock);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { cancelRunMock, createSessionMock, getMessagesMock, sendMessageMock } =
|
const {
|
||||||
vi.hoisted(() => ({
|
cancelRunMock,
|
||||||
cancelRunMock: vi.fn(),
|
createSessionMock,
|
||||||
createSessionMock: vi.fn(),
|
getMessagesMock,
|
||||||
getMessagesMock: vi.fn(),
|
sendMessageMock,
|
||||||
sendMessageMock: vi.fn(),
|
updateTenantConfigurationMock,
|
||||||
}));
|
} = vi.hoisted(() => ({
|
||||||
|
cancelRunMock: vi.fn(),
|
||||||
|
createSessionMock: vi.fn(),
|
||||||
|
getMessagesMock: vi.fn(),
|
||||||
|
sendMessageMock: vi.fn(),
|
||||||
|
updateTenantConfigurationMock: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock("@/app/(prowler)/lighthouse/_actions", () => ({
|
vi.mock("@/app/(prowler)/lighthouse/_actions", () => ({
|
||||||
cancelLighthouseV2Run: cancelRunMock,
|
cancelLighthouseV2Run: cancelRunMock,
|
||||||
createLighthouseV2Session: createSessionMock,
|
createLighthouseV2Session: createSessionMock,
|
||||||
getLighthouseV2Messages: getMessagesMock,
|
getLighthouseV2Messages: getMessagesMock,
|
||||||
sendLighthouseV2Message: sendMessageMock,
|
sendLighthouseV2Message: sendMessageMock,
|
||||||
|
updateLighthouseV2TenantConfiguration: updateTenantConfigurationMock,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Streamdown pulls in shiki/wasm syntax highlighting that doesn't run under
|
// Streamdown pulls in shiki/wasm syntax highlighting that doesn't run under
|
||||||
@@ -85,7 +92,6 @@ const configurations: LighthouseV2Configuration[] = [
|
|||||||
providerType: "openai",
|
providerType: "openai",
|
||||||
baseUrl: null,
|
baseUrl: null,
|
||||||
defaultModel: "gpt-5.1",
|
defaultModel: "gpt-5.1",
|
||||||
businessContext: "Production account",
|
|
||||||
connected: true,
|
connected: true,
|
||||||
connectionLastCheckedAt: "2026-06-24T10:00:00Z",
|
connectionLastCheckedAt: "2026-06-24T10:00:00Z",
|
||||||
insertedAt: "2026-06-24T09:00:00Z",
|
insertedAt: "2026-06-24T09:00:00Z",
|
||||||
@@ -96,7 +102,6 @@ const configurations: LighthouseV2Configuration[] = [
|
|||||||
providerType: "bedrock",
|
providerType: "bedrock",
|
||||||
baseUrl: null,
|
baseUrl: null,
|
||||||
defaultModel: "anthropic.claude-4",
|
defaultModel: "anthropic.claude-4",
|
||||||
businessContext: "AWS landing zone",
|
|
||||||
connected: true,
|
connected: true,
|
||||||
connectionLastCheckedAt: "2026-06-23T10:00:00Z",
|
connectionLastCheckedAt: "2026-06-23T10:00:00Z",
|
||||||
insertedAt: "2026-06-23T09:00:00Z",
|
insertedAt: "2026-06-23T09:00:00Z",
|
||||||
@@ -105,11 +110,21 @@ const configurations: LighthouseV2Configuration[] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
const modelsByProvider = {
|
const modelsByProvider = {
|
||||||
openai: [model("gpt-5.1")],
|
openai: [model("gpt-5.1"), model("gpt-4.1")],
|
||||||
bedrock: [model("anthropic.claude-4")],
|
bedrock: [model("anthropic.claude-4")],
|
||||||
"openai-compatible": [model("llama-3.3")],
|
"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", () => {
|
describe("LighthouseV2ChatPage", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.stubGlobal(
|
vi.stubGlobal(
|
||||||
@@ -120,10 +135,15 @@ describe("LighthouseV2ChatPage", () => {
|
|||||||
disconnect = vi.fn();
|
disconnect = vi.fn();
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
Object.defineProperty(Element.prototype, "scrollIntoView", {
|
||||||
|
configurable: true,
|
||||||
|
value: vi.fn(),
|
||||||
|
});
|
||||||
cancelRunMock.mockReset();
|
cancelRunMock.mockReset();
|
||||||
createSessionMock.mockReset();
|
createSessionMock.mockReset();
|
||||||
getMessagesMock.mockReset();
|
getMessagesMock.mockReset();
|
||||||
sendMessageMock.mockReset();
|
sendMessageMock.mockReset();
|
||||||
|
updateTenantConfigurationMock.mockReset();
|
||||||
// The mock never fires "open": the client must POST the message without
|
// 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
|
// 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
|
// only happens after the POST). This is the regression guard for the
|
||||||
@@ -150,18 +170,21 @@ describe("LighthouseV2ChatPage", () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
updateTenantConfigurationMock.mockResolvedValue({
|
||||||
|
data: tenantConfiguration,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.unstubAllGlobals();
|
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
|
// Given / When
|
||||||
renderPage();
|
renderPage();
|
||||||
|
|
||||||
// Then
|
// Then
|
||||||
expect(screen.queryByRole("combobox")).not.toBeInTheDocument();
|
expect(screen.getByRole("combobox", { name: "Model" })).toBeInTheDocument();
|
||||||
expect(
|
expect(
|
||||||
screen.getByRole("link", { name: "Lighthouse AI settings" }),
|
screen.getByRole("link", { name: "Lighthouse AI settings" }),
|
||||||
).toHaveAttribute("href", "/lighthouse/settings");
|
).toHaveAttribute("href", "/lighthouse/settings");
|
||||||
@@ -206,11 +229,16 @@ describe("LighthouseV2ChatPage", () => {
|
|||||||
).not.toHaveClass("border-t");
|
).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
|
// Given
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const replaceStateSpy = vi.spyOn(window.history, "replaceState");
|
const replaceStateSpy = vi.spyOn(window.history, "replaceState");
|
||||||
renderPage();
|
renderPage({
|
||||||
|
tenantConfiguration: {
|
||||||
|
...tenantConfiguration,
|
||||||
|
defaultProvider: "bedrock",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
// When
|
// When
|
||||||
await user.type(
|
await user.type(
|
||||||
@@ -223,8 +251,8 @@ describe("LighthouseV2ChatPage", () => {
|
|||||||
expect(sendMessageMock).toHaveBeenCalledWith({
|
expect(sendMessageMock).toHaveBeenCalledWith({
|
||||||
sessionId: "session-1",
|
sessionId: "session-1",
|
||||||
text: "Summarize findings",
|
text: "Summarize findings",
|
||||||
provider: "openai",
|
provider: "bedrock",
|
||||||
model: "gpt-5.1",
|
model: "anthropic.claude-4",
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
expect(createSessionMock).toHaveBeenCalledWith("Summarize findings");
|
expect(createSessionMock).toHaveBeenCalledWith("Summarize findings");
|
||||||
@@ -242,6 +270,84 @@ describe("LighthouseV2ChatPage", () => {
|
|||||||
replaceStateSpy.mockRestore();
|
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 () => {
|
it("updates the URL before notifying session history listeners", async () => {
|
||||||
// Given
|
// Given
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
@@ -364,6 +470,7 @@ function renderPage(props?: RenderPageProps) {
|
|||||||
const componentProps = {
|
const componentProps = {
|
||||||
configurations: props?.configurations ?? configurations,
|
configurations: props?.configurations ?? configurations,
|
||||||
modelsByProvider: props?.modelsByProvider ?? modelsByProvider,
|
modelsByProvider: props?.modelsByProvider ?? modelsByProvider,
|
||||||
|
tenantConfiguration: props?.tenantConfiguration ?? tenantConfiguration,
|
||||||
initialSessionId: props?.initialSessionId,
|
initialSessionId: props?.initialSessionId,
|
||||||
initialMessages: props?.initialMessages ?? [],
|
initialMessages: props?.initialMessages ?? [],
|
||||||
initialPrompt: props?.initialPrompt,
|
initialPrompt: props?.initialPrompt,
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { type FormEvent, useRef, useState } from "react";
|
import { type SubmitEvent, useRef, useState } from "react";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
cancelLighthouseV2Run,
|
cancelLighthouseV2Run,
|
||||||
createLighthouseV2Session,
|
createLighthouseV2Session,
|
||||||
getLighthouseV2Messages,
|
getLighthouseV2Messages,
|
||||||
sendLighthouseV2Message,
|
sendLighthouseV2Message,
|
||||||
|
updateLighthouseV2TenantConfiguration,
|
||||||
} from "@/app/(prowler)/lighthouse/_actions";
|
} from "@/app/(prowler)/lighthouse/_actions";
|
||||||
import {
|
import {
|
||||||
Conversation,
|
Conversation,
|
||||||
@@ -22,6 +23,11 @@ import {
|
|||||||
buildOptimisticMessage,
|
buildOptimisticMessage,
|
||||||
buildSessionTitle,
|
buildSessionTitle,
|
||||||
} from "@/app/(prowler)/lighthouse/_lib/messages";
|
} from "@/app/(prowler)/lighthouse/_lib/messages";
|
||||||
|
import {
|
||||||
|
buildLighthouseV2ModelSelectionValue,
|
||||||
|
type LighthouseV2ModelSelection,
|
||||||
|
parseLighthouseV2ModelSelectionValue,
|
||||||
|
} from "@/app/(prowler)/lighthouse/_lib/model-selection";
|
||||||
import {
|
import {
|
||||||
LIGHTHOUSE_V2_NEW_CHAT_EVENT,
|
LIGHTHOUSE_V2_NEW_CHAT_EVENT,
|
||||||
notifyLighthouseV2SessionsChanged,
|
notifyLighthouseV2SessionsChanged,
|
||||||
@@ -29,15 +35,19 @@ import {
|
|||||||
import { parseStreamEvent } from "@/app/(prowler)/lighthouse/_lib/stream-event-parser";
|
import { parseStreamEvent } from "@/app/(prowler)/lighthouse/_lib/stream-event-parser";
|
||||||
import { buildLighthouseV2StreamUrl } from "@/app/(prowler)/lighthouse/_lib/stream-url";
|
import { buildLighthouseV2StreamUrl } from "@/app/(prowler)/lighthouse/_lib/stream-url";
|
||||||
import {
|
import {
|
||||||
LIGHTHOUSE_V2_PROVIDER_TYPE,
|
|
||||||
LIGHTHOUSE_V2_SSE_EVENT,
|
LIGHTHOUSE_V2_SSE_EVENT,
|
||||||
type LighthouseV2Configuration,
|
type LighthouseV2Configuration,
|
||||||
type LighthouseV2Message,
|
type LighthouseV2Message,
|
||||||
type LighthouseV2ProviderType,
|
type LighthouseV2ProviderType,
|
||||||
type LighthouseV2SSEEvent,
|
type LighthouseV2SSEEvent,
|
||||||
type LighthouseV2SupportedModel,
|
type LighthouseV2SupportedModel,
|
||||||
|
type LighthouseV2TenantConfiguration,
|
||||||
} from "@/app/(prowler)/lighthouse/_types";
|
} from "@/app/(prowler)/lighthouse/_types";
|
||||||
import { Card } from "@/components/shadcn";
|
import { Card } from "@/components/shadcn";
|
||||||
|
import {
|
||||||
|
Combobox,
|
||||||
|
type ComboboxGroup,
|
||||||
|
} from "@/components/shadcn/combobox/combobox";
|
||||||
import { useMountEffect } from "@/hooks/use-mount-effect";
|
import { useMountEffect } from "@/hooks/use-mount-effect";
|
||||||
|
|
||||||
import { ChatComposerPanel } from "./composer";
|
import { ChatComposerPanel } from "./composer";
|
||||||
@@ -51,6 +61,7 @@ interface LighthouseV2ChatPageProps {
|
|||||||
LighthouseV2ProviderType,
|
LighthouseV2ProviderType,
|
||||||
LighthouseV2SupportedModel[]
|
LighthouseV2SupportedModel[]
|
||||||
>;
|
>;
|
||||||
|
tenantConfiguration?: LighthouseV2TenantConfiguration;
|
||||||
initialSessionId?: string;
|
initialSessionId?: string;
|
||||||
initialMessages: LighthouseV2Message[];
|
initialMessages: LighthouseV2Message[];
|
||||||
initialActiveTaskId?: string | null;
|
initialActiveTaskId?: string | null;
|
||||||
@@ -61,6 +72,7 @@ interface LighthouseV2ChatPageProps {
|
|||||||
export function LighthouseV2ChatPage({
|
export function LighthouseV2ChatPage({
|
||||||
configurations,
|
configurations,
|
||||||
modelsByProvider,
|
modelsByProvider,
|
||||||
|
tenantConfiguration,
|
||||||
initialSessionId,
|
initialSessionId,
|
||||||
initialMessages,
|
initialMessages,
|
||||||
initialActiveTaskId,
|
initialActiveTaskId,
|
||||||
@@ -72,13 +84,17 @@ export function LighthouseV2ChatPage({
|
|||||||
const connectedConfigurations = configurations.filter(
|
const connectedConfigurations = configurations.filter(
|
||||||
(configuration) => configuration.connected === true,
|
(configuration) => configuration.connected === true,
|
||||||
);
|
);
|
||||||
const selectedConfiguration = connectedConfigurations[0] ?? configurations[0];
|
const initialModelSelection = resolveInitialModelSelection(
|
||||||
const selectedProvider =
|
connectedConfigurations,
|
||||||
selectedConfiguration?.providerType ?? LIGHTHOUSE_V2_PROVIDER_TYPE.OPENAI;
|
modelsByProvider,
|
||||||
const selectedModel =
|
tenantConfiguration,
|
||||||
selectedConfiguration?.defaultModel ??
|
);
|
||||||
modelsByProvider[selectedProvider]?.[0]?.id ??
|
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>(
|
const [activeSessionId, setActiveSessionId] = useState<string | null>(
|
||||||
initialSessionId ?? null,
|
initialSessionId ?? null,
|
||||||
);
|
);
|
||||||
@@ -93,9 +109,26 @@ export function LighthouseV2ChatPage({
|
|||||||
const [streamState, setStreamState] = useState<LighthouseV2StreamState>(() =>
|
const [streamState, setStreamState] = useState<LighthouseV2StreamState>(() =>
|
||||||
createInitialLighthouseV2StreamState(initialActiveTaskId ?? null),
|
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 =
|
const canSend =
|
||||||
selectedConfiguration?.connected === true &&
|
selectedConfiguration?.connected === true &&
|
||||||
|
Boolean(selectedModelSelection?.modelId) &&
|
||||||
!streamState.activeTaskId &&
|
!streamState.activeTaskId &&
|
||||||
!blockedByConflict &&
|
!blockedByConflict &&
|
||||||
!isSubmitting;
|
!isSubmitting;
|
||||||
@@ -213,7 +246,12 @@ export function LighthouseV2ChatPage({
|
|||||||
|
|
||||||
const submitMessage = async (text: string) => {
|
const submitMessage = async (text: string) => {
|
||||||
const trimmedText = text.trim();
|
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);
|
setIsSubmitting(true);
|
||||||
try {
|
try {
|
||||||
@@ -239,8 +277,8 @@ export function LighthouseV2ChatPage({
|
|||||||
const result = await sendLighthouseV2Message({
|
const result = await sendLighthouseV2Message({
|
||||||
sessionId,
|
sessionId,
|
||||||
text: trimmedText,
|
text: trimmedText,
|
||||||
provider: selectedProvider,
|
provider: selectedModelSelection.providerType,
|
||||||
model: selectedModel || null,
|
model: selectedModelSelection.modelId,
|
||||||
});
|
});
|
||||||
|
|
||||||
if ("error" in result) {
|
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();
|
event.preventDefault();
|
||||||
void submitMessage(input);
|
void submitMessage(input);
|
||||||
};
|
};
|
||||||
@@ -337,9 +416,25 @@ export function LighthouseV2ChatPage({
|
|||||||
streamState.status === "disconnected" && lastSubmittedText !== null,
|
streamState.status === "disconnected" && lastSubmittedText !== null,
|
||||||
onRetry: () =>
|
onRetry: () =>
|
||||||
lastSubmittedText ? void submitMessage(lastSubmittedText) : undefined,
|
lastSubmittedText ? void submitMessage(lastSubmittedText) : undefined,
|
||||||
|
onDismissFeedback: () => setFeedback(null),
|
||||||
canSend,
|
canSend,
|
||||||
input,
|
input,
|
||||||
isStreaming: Boolean(streamState.activeTaskId),
|
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,
|
selectedConfigurationConnected: selectedConfiguration?.connected === true,
|
||||||
onInputChange: setInput,
|
onInputChange: setInput,
|
||||||
onStop: handleStop,
|
onStop: handleStop,
|
||||||
@@ -397,3 +492,101 @@ function replaceLighthouseV2SessionUrl(sessionId: string | null) {
|
|||||||
|
|
||||||
window.history.replaceState(window.history.state, "", url);
|
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";
|
"use client";
|
||||||
|
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import {
|
import { KeyRound, Loader2, PlugZap, Save, Trash2 } from "lucide-react";
|
||||||
Bot,
|
|
||||||
KeyRound,
|
|
||||||
Loader2,
|
|
||||||
PlugZap,
|
|
||||||
Save,
|
|
||||||
Sparkles,
|
|
||||||
Trash2,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Controller, useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
createLighthouseV2Configuration,
|
createLighthouseV2Configuration,
|
||||||
@@ -22,7 +14,6 @@ import {
|
|||||||
import {
|
import {
|
||||||
buildCredentialPayload,
|
buildCredentialPayload,
|
||||||
buildLighthouseV2ConfigFormSchema,
|
buildLighthouseV2ConfigFormSchema,
|
||||||
BUSINESS_CONTEXT_LIMIT,
|
|
||||||
EMPTY_FORM_VALUES,
|
EMPTY_FORM_VALUES,
|
||||||
FEEDBACK_VARIANT,
|
FEEDBACK_VARIANT,
|
||||||
type FeedbackState,
|
type FeedbackState,
|
||||||
@@ -36,21 +27,10 @@ import {
|
|||||||
type LighthouseV2Configuration,
|
type LighthouseV2Configuration,
|
||||||
type LighthouseV2ConfigurationInput,
|
type LighthouseV2ConfigurationInput,
|
||||||
type LighthouseV2ConfigurationUpdateInput,
|
type LighthouseV2ConfigurationUpdateInput,
|
||||||
type LighthouseV2SupportedModel,
|
|
||||||
type LighthouseV2SupportedProvider,
|
type LighthouseV2SupportedProvider,
|
||||||
} from "@/app/(prowler)/lighthouse/_types";
|
} from "@/app/(prowler)/lighthouse/_types";
|
||||||
import { Button } from "@/components/shadcn/button/button";
|
import { Button } from "@/components/shadcn/button/button";
|
||||||
import { Field, FieldError, FieldLabel } from "@/components/shadcn/field/field";
|
|
||||||
import { Modal } from "@/components/shadcn/modal";
|
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 { ConfigurationSection } from "./configuration-section";
|
||||||
import { CredentialFields } from "./credential-fields";
|
import { CredentialFields } from "./credential-fields";
|
||||||
@@ -59,7 +39,6 @@ import { StatusBadge } from "./status-badge";
|
|||||||
|
|
||||||
export function LighthouseV2ConfigurationForm({
|
export function LighthouseV2ConfigurationForm({
|
||||||
configuration,
|
configuration,
|
||||||
models,
|
|
||||||
onConfigurationDeleted,
|
onConfigurationDeleted,
|
||||||
onConfigurationSaved,
|
onConfigurationSaved,
|
||||||
onConfigurationTested,
|
onConfigurationTested,
|
||||||
@@ -67,7 +46,6 @@ export function LighthouseV2ConfigurationForm({
|
|||||||
provider,
|
provider,
|
||||||
}: {
|
}: {
|
||||||
configuration?: LighthouseV2Configuration;
|
configuration?: LighthouseV2Configuration;
|
||||||
models: LighthouseV2SupportedModel[];
|
|
||||||
onConfigurationDeleted: (configurationId: string) => void;
|
onConfigurationDeleted: (configurationId: string) => void;
|
||||||
onConfigurationSaved: (configuration: LighthouseV2Configuration) => void;
|
onConfigurationSaved: (configuration: LighthouseV2Configuration) => void;
|
||||||
onConfigurationTested: (configuration: LighthouseV2Configuration) => void;
|
onConfigurationTested: (configuration: LighthouseV2Configuration) => void;
|
||||||
@@ -87,7 +65,6 @@ export function LighthouseV2ConfigurationForm({
|
|||||||
defaultValues: getFormDefaults(configuration),
|
defaultValues: getFormDefaults(configuration),
|
||||||
mode: "onSubmit",
|
mode: "onSubmit",
|
||||||
});
|
});
|
||||||
const businessContext = form.watch("businessContext");
|
|
||||||
const status = getConnectionStatus(configuration);
|
const status = getConnectionStatus(configuration);
|
||||||
|
|
||||||
const handleSave = async (values: LighthouseV2ConfigFormValues) => {
|
const handleSave = async (values: LighthouseV2ConfigFormValues) => {
|
||||||
@@ -102,8 +79,6 @@ export function LighthouseV2ConfigurationForm({
|
|||||||
|
|
||||||
const basePayload = {
|
const basePayload = {
|
||||||
baseUrl: trimToNullable(values.baseUrl),
|
baseUrl: trimToNullable(values.baseUrl),
|
||||||
defaultModel: trimToNullable(values.defaultModel),
|
|
||||||
businessContext: values.businessContext,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = configuration
|
const result = configuration
|
||||||
@@ -177,7 +152,7 @@ export function LighthouseV2ConfigurationForm({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
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="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="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">
|
<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>
|
</div>
|
||||||
|
|
||||||
<form
|
<form
|
||||||
className="grid gap-0"
|
className="flex h-full min-h-0 w-full flex-1 flex-col"
|
||||||
onSubmit={form.handleSubmit(handleSave)}
|
onSubmit={form.handleSubmit(handleSave)}
|
||||||
noValidate
|
noValidate
|
||||||
>
|
>
|
||||||
<ConfigurationSection
|
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||||
icon={<KeyRound className="size-4" />}
|
<ConfigurationSection
|
||||||
title="Credentials"
|
icon={<KeyRound className="size-4" />}
|
||||||
description={
|
title="Credentials"
|
||||||
configuration
|
description={
|
||||||
? "Leave blank to keep existing credentials."
|
configuration
|
||||||
: "Credentials are required for new configurations."
|
? "Leave blank to keep existing credentials."
|
||||||
}
|
: "Credentials are required for new configurations."
|
||||||
>
|
}
|
||||||
<CredentialFields
|
>
|
||||||
errors={form.formState.errors}
|
<CredentialFields
|
||||||
provider={providerType}
|
errors={form.formState.errors}
|
||||||
register={form.register}
|
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")}
|
|
||||||
/>
|
/>
|
||||||
{form.formState.errors.businessContext?.message && (
|
</ConfigurationSection>
|
||||||
<FieldError>
|
</div>
|
||||||
{form.formState.errors.businessContext.message}
|
|
||||||
</FieldError>
|
|
||||||
)}
|
|
||||||
</Field>
|
|
||||||
</ConfigurationSection>
|
|
||||||
|
|
||||||
<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">
|
<div className="text-text-neutral-secondary text-sm">
|
||||||
{configuration
|
{configuration
|
||||||
? "Saving updates may change chat behavior immediately."
|
? "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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
+71
-106
@@ -4,7 +4,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
|||||||
|
|
||||||
import type {
|
import type {
|
||||||
LighthouseV2Configuration,
|
LighthouseV2Configuration,
|
||||||
LighthouseV2SupportedModel,
|
|
||||||
LighthouseV2SupportedProvider,
|
LighthouseV2SupportedProvider,
|
||||||
} from "@/app/(prowler)/lighthouse/_types";
|
} from "@/app/(prowler)/lighthouse/_types";
|
||||||
|
|
||||||
@@ -15,22 +14,33 @@ const {
|
|||||||
deleteConfigurationMock,
|
deleteConfigurationMock,
|
||||||
testConnectionMock,
|
testConnectionMock,
|
||||||
updateConfigurationMock,
|
updateConfigurationMock,
|
||||||
|
updateTenantConfigurationMock,
|
||||||
|
toastMock,
|
||||||
} = vi.hoisted(() => ({
|
} = vi.hoisted(() => ({
|
||||||
createConfigurationMock: vi.fn(),
|
createConfigurationMock: vi.fn(),
|
||||||
deleteConfigurationMock: vi.fn(),
|
deleteConfigurationMock: vi.fn(),
|
||||||
testConnectionMock: vi.fn(),
|
testConnectionMock: vi.fn(),
|
||||||
updateConfigurationMock: vi.fn(),
|
updateConfigurationMock: vi.fn(),
|
||||||
|
updateTenantConfigurationMock: vi.fn(),
|
||||||
|
toastMock: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("next/navigation", () => ({
|
vi.mock("next/navigation", () => ({
|
||||||
useRouter: () => ({ refresh: vi.fn(), push: vi.fn() }),
|
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", () => ({
|
vi.mock("@/app/(prowler)/lighthouse/_actions", () => ({
|
||||||
createLighthouseV2Configuration: createConfigurationMock,
|
createLighthouseV2Configuration: createConfigurationMock,
|
||||||
deleteLighthouseV2Configuration: deleteConfigurationMock,
|
deleteLighthouseV2Configuration: deleteConfigurationMock,
|
||||||
testLighthouseV2ConfigurationConnection: testConnectionMock,
|
testLighthouseV2ConfigurationConnection: testConnectionMock,
|
||||||
updateLighthouseV2Configuration: updateConfigurationMock,
|
updateLighthouseV2Configuration: updateConfigurationMock,
|
||||||
|
updateLighthouseV2TenantConfiguration: updateTenantConfigurationMock,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const providers: LighthouseV2SupportedProvider[] = [
|
const providers: LighthouseV2SupportedProvider[] = [
|
||||||
@@ -39,31 +49,12 @@ const providers: LighthouseV2SupportedProvider[] = [
|
|||||||
{ id: "openai-compatible", name: "OpenAI-compatible" },
|
{ 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[] = [
|
const configurations: LighthouseV2Configuration[] = [
|
||||||
{
|
{
|
||||||
id: "config-openai",
|
id: "config-openai",
|
||||||
providerType: "openai",
|
providerType: "openai",
|
||||||
baseUrl: null,
|
baseUrl: null,
|
||||||
defaultModel: "gpt-5.1",
|
defaultModel: "gpt-5.1",
|
||||||
businessContext: "Production account",
|
|
||||||
connected: true,
|
connected: true,
|
||||||
connectionLastCheckedAt: "2026-06-24T10:00:00Z",
|
connectionLastCheckedAt: "2026-06-24T10:00:00Z",
|
||||||
insertedAt: "2026-06-24T09:00:00Z",
|
insertedAt: "2026-06-24T09:00:00Z",
|
||||||
@@ -74,7 +65,6 @@ const configurations: LighthouseV2Configuration[] = [
|
|||||||
providerType: "bedrock",
|
providerType: "bedrock",
|
||||||
baseUrl: null,
|
baseUrl: null,
|
||||||
defaultModel: "anthropic.claude-4",
|
defaultModel: "anthropic.claude-4",
|
||||||
businessContext: "AWS landing zone",
|
|
||||||
connected: false,
|
connected: false,
|
||||||
connectionLastCheckedAt: "2026-06-23T10:00:00Z",
|
connectionLastCheckedAt: "2026-06-23T10:00:00Z",
|
||||||
insertedAt: "2026-06-23T09:00:00Z",
|
insertedAt: "2026-06-23T09:00:00Z",
|
||||||
@@ -88,6 +78,16 @@ describe("LighthouseV2ConfigPage", () => {
|
|||||||
deleteConfigurationMock.mockReset();
|
deleteConfigurationMock.mockReset();
|
||||||
testConnectionMock.mockReset();
|
testConnectionMock.mockReset();
|
||||||
updateConfigurationMock.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] });
|
createConfigurationMock.mockResolvedValue({ data: configurations[0] });
|
||||||
deleteConfigurationMock.mockResolvedValue({ data: true });
|
deleteConfigurationMock.mockResolvedValue({ data: true });
|
||||||
@@ -120,12 +120,7 @@ describe("LighthouseV2ConfigPage", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
expect(settingsCard).toHaveAttribute("data-slot", "card");
|
expect(settingsCard).toHaveAttribute("data-slot", "card");
|
||||||
expect(settingsCard).toHaveClass(
|
expect(settingsCard).toHaveClass("w-full", "gap-0", "overflow-hidden");
|
||||||
"min-h-[calc(100dvh-6.5rem)]",
|
|
||||||
"w-full",
|
|
||||||
"gap-0",
|
|
||||||
"overflow-hidden",
|
|
||||||
);
|
|
||||||
expect(settingsCard).not.toHaveClass("mx-auto", "max-w-7xl");
|
expect(settingsCard).not.toHaveClass("mx-auto", "max-w-7xl");
|
||||||
expect(settingsSeparator).toHaveClass(
|
expect(settingsSeparator).toHaveClass(
|
||||||
"border-t",
|
"border-t",
|
||||||
@@ -149,50 +144,50 @@ describe("LighthouseV2ConfigPage", () => {
|
|||||||
).toBeInTheDocument();
|
).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
|
// Given
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
renderPage();
|
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 }));
|
await user.click(screen.getByRole("button", { name: /Amazon Bedrock/i }));
|
||||||
|
|
||||||
// Then
|
// Then
|
||||||
expect(
|
expect(
|
||||||
screen.getByRole("textbox", { name: /Business context/i }),
|
screen.queryByRole("combobox", { name: "Default model" }),
|
||||||
).toHaveValue("AWS landing zone");
|
).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByText("Default model")).not.toBeInTheDocument();
|
||||||
expect(
|
expect(
|
||||||
screen.getByRole("combobox", { name: "Default model" }),
|
screen.getAllByRole("textbox", { name: /Business context/i }),
|
||||||
).toHaveTextContent("anthropic.claude-4");
|
).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
|
// Given
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
updateConfigurationMock.mockResolvedValue({
|
updateConfigurationMock.mockResolvedValue({ data: configurations[0] });
|
||||||
data: {
|
|
||||||
...configurations[0],
|
|
||||||
businessContext: "Updated context",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
renderPage();
|
renderPage();
|
||||||
|
|
||||||
// When
|
// When: save the active provider without touching credentials
|
||||||
await user.clear(
|
await user.click(
|
||||||
screen.getByRole("textbox", { name: /Business context/i }),
|
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
|
// Then
|
||||||
await waitFor(() =>
|
await waitFor(() => expect(updateConfigurationMock).toHaveBeenCalled());
|
||||||
expect(updateConfigurationMock).toHaveBeenCalledWith(
|
expect(updateConfigurationMock.mock.calls[0]?.[0]).toBe("config-openai");
|
||||||
"config-openai",
|
expect(updateConfigurationMock.mock.calls[0]?.[1]).not.toHaveProperty(
|
||||||
expect.not.objectContaining({ credentials: expect.anything() }),
|
"credentials",
|
||||||
),
|
);
|
||||||
|
expect(updateConfigurationMock.mock.calls[0]?.[1]).not.toHaveProperty(
|
||||||
|
"defaultModel",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -204,7 +199,6 @@ describe("LighthouseV2ConfigPage", () => {
|
|||||||
providerType: "openai-compatible",
|
providerType: "openai-compatible",
|
||||||
baseUrl: "https://llm.example.com/v1",
|
baseUrl: "https://llm.example.com/v1",
|
||||||
defaultModel: "llama-3.3",
|
defaultModel: "llama-3.3",
|
||||||
businessContext: "Private model",
|
|
||||||
connected: null,
|
connected: null,
|
||||||
connectionLastCheckedAt: null,
|
connectionLastCheckedAt: null,
|
||||||
insertedAt: "2026-06-24T10:00:00Z",
|
insertedAt: "2026-06-24T10:00:00Z",
|
||||||
@@ -222,22 +216,23 @@ describe("LighthouseV2ConfigPage", () => {
|
|||||||
screen.getByLabelText("Base URL"),
|
screen.getByLabelText("Base URL"),
|
||||||
"https://llm.example.com/v1",
|
"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 }));
|
await user.click(screen.getByRole("button", { name: /^Save$/i }));
|
||||||
|
|
||||||
// Then
|
// Then
|
||||||
await waitFor(() =>
|
await waitFor(() => expect(createConfigurationMock).toHaveBeenCalled());
|
||||||
expect(createConfigurationMock).toHaveBeenCalledWith({
|
expect(createConfigurationMock.mock.calls[0]?.[0]).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
providerType: "openai-compatible",
|
providerType: "openai-compatible",
|
||||||
credentials: { api_key: "provider-key" },
|
credentials: { api_key: "provider-key" },
|
||||||
baseUrl: "https://llm.example.com/v1",
|
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 () => {
|
it("blocks OpenAI-compatible save when base URL is missing", async () => {
|
||||||
@@ -275,32 +270,6 @@ describe("LighthouseV2ConfigPage", () => {
|
|||||||
expect(screen.getByLabelText("AWS region")).toBeInTheDocument();
|
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 () => {
|
it("tests the connection and reports the resulting status", async () => {
|
||||||
// Given
|
// Given
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
@@ -316,7 +285,14 @@ describe("LighthouseV2ConfigPage", () => {
|
|||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(testConnectionMock).toHaveBeenCalledWith("config-openai"),
|
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(
|
expect(
|
||||||
screen.queryByRole("button", { name: /Refresh status/i }),
|
screen.queryByRole("button", { name: /Refresh status/i }),
|
||||||
).not.toBeInTheDocument();
|
).not.toBeInTheDocument();
|
||||||
@@ -337,7 +313,11 @@ describe("LighthouseV2ConfigPage", () => {
|
|||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(deleteConfigurationMock).toHaveBeenCalledWith("config-openai"),
|
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
|
<LighthouseV2ConfigPage
|
||||||
configurations={props?.configurations ?? configurations}
|
configurations={props?.configurations ?? configurations}
|
||||||
providers={props?.providers ?? providers}
|
providers={props?.providers ?? providers}
|
||||||
modelsByProvider={props?.modelsByProvider ?? modelsByProvider}
|
tenantConfiguration={props?.tenantConfiguration}
|
||||||
error={props?.error}
|
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 {
|
import {
|
||||||
type LighthouseV2Configuration,
|
type LighthouseV2Configuration,
|
||||||
type LighthouseV2ProviderType,
|
type LighthouseV2ProviderType,
|
||||||
type LighthouseV2SupportedModel,
|
|
||||||
type LighthouseV2SupportedProvider,
|
type LighthouseV2SupportedProvider,
|
||||||
|
type LighthouseV2TenantConfiguration,
|
||||||
} from "@/app/(prowler)/lighthouse/_types";
|
} from "@/app/(prowler)/lighthouse/_types";
|
||||||
import { Card } from "@/components/shadcn/card/card";
|
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 { LighthouseV2ConfigurationForm } from "./configuration-form";
|
||||||
import { LighthouseV2EmptyState } from "./empty-state";
|
import { LighthouseV2EmptyState } from "./empty-state";
|
||||||
import { ConfigFeedbackAlert } from "./feedback-alert";
|
|
||||||
import { LighthouseV2ProviderRail } from "./provider-rail";
|
import { LighthouseV2ProviderRail } from "./provider-rail";
|
||||||
|
|
||||||
interface LighthouseV2ConfigPageProps {
|
interface LighthouseV2ConfigPageProps {
|
||||||
configurations: LighthouseV2Configuration[];
|
configurations: LighthouseV2Configuration[];
|
||||||
providers: LighthouseV2SupportedProvider[];
|
providers: LighthouseV2SupportedProvider[];
|
||||||
modelsByProvider: Record<
|
tenantConfiguration?: LighthouseV2TenantConfiguration;
|
||||||
LighthouseV2ProviderType,
|
|
||||||
LighthouseV2SupportedModel[]
|
|
||||||
>;
|
|
||||||
error?: string;
|
error?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function LighthouseV2ConfigPage({
|
export function LighthouseV2ConfigPage({
|
||||||
configurations,
|
configurations,
|
||||||
providers,
|
providers,
|
||||||
modelsByProvider,
|
tenantConfiguration,
|
||||||
error,
|
error,
|
||||||
}: LighthouseV2ConfigPageProps) {
|
}: LighthouseV2ConfigPageProps) {
|
||||||
|
const { toast } = useToast();
|
||||||
const [localConfigurations, setLocalConfigurations] =
|
const [localConfigurations, setLocalConfigurations] =
|
||||||
useState(configurations);
|
useState(configurations);
|
||||||
const [selectedProvider, setSelectedProvider] =
|
const [selectedProvider, setSelectedProvider] =
|
||||||
useState<LighthouseV2ProviderType>(providers[0]?.id ?? "openai");
|
useState<LighthouseV2ProviderType>(providers[0]?.id ?? "openai");
|
||||||
const [feedback, setFeedback] = useState<FeedbackState | null>(
|
|
||||||
error
|
const showFeedback = (feedback: FeedbackState) => {
|
||||||
? {
|
toast({
|
||||||
title: "Configuration unavailable",
|
title: feedback.title,
|
||||||
description: error,
|
description: feedback.description,
|
||||||
variant: FEEDBACK_VARIANT.ERROR,
|
variant:
|
||||||
}
|
feedback.variant === FEEDBACK_VARIANT.ERROR ? "destructive" : "default",
|
||||||
: null,
|
});
|
||||||
);
|
};
|
||||||
|
|
||||||
|
// 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(
|
const selectedConfig = localConfigurations.find(
|
||||||
(config) => config.providerType === selectedProvider,
|
(config) => config.providerType === selectedProvider,
|
||||||
@@ -55,11 +67,6 @@ export function LighthouseV2ConfigPage({
|
|||||||
const selectedProviderDefinition =
|
const selectedProviderDefinition =
|
||||||
providers.find((provider) => provider.id === selectedProvider) ??
|
providers.find((provider) => provider.id === selectedProvider) ??
|
||||||
providers[0];
|
providers[0];
|
||||||
const selectedModels =
|
|
||||||
selectedProviderDefinition &&
|
|
||||||
modelsByProvider[selectedProviderDefinition.id]
|
|
||||||
? modelsByProvider[selectedProviderDefinition.id]
|
|
||||||
: [];
|
|
||||||
|
|
||||||
const upsertConfiguration = (configuration: LighthouseV2Configuration) => {
|
const upsertConfiguration = (configuration: LighthouseV2Configuration) => {
|
||||||
setLocalConfigurations((current) => [
|
setLocalConfigurations((current) => [
|
||||||
@@ -73,7 +80,7 @@ export function LighthouseV2ConfigPage({
|
|||||||
) => {
|
) => {
|
||||||
upsertConfiguration(configuration);
|
upsertConfiguration(configuration);
|
||||||
setSelectedProvider(configuration.providerType);
|
setSelectedProvider(configuration.providerType);
|
||||||
setFeedback({
|
showFeedback({
|
||||||
title: "Configuration saved.",
|
title: "Configuration saved.",
|
||||||
description:
|
description:
|
||||||
"Lighthouse AI can use this provider after it tests cleanly.",
|
"Lighthouse AI can use this provider after it tests cleanly.",
|
||||||
@@ -85,7 +92,7 @@ export function LighthouseV2ConfigPage({
|
|||||||
configuration: LighthouseV2Configuration,
|
configuration: LighthouseV2Configuration,
|
||||||
) => {
|
) => {
|
||||||
upsertConfiguration(configuration);
|
upsertConfiguration(configuration);
|
||||||
setFeedback(
|
showFeedback(
|
||||||
configuration.connected
|
configuration.connected
|
||||||
? {
|
? {
|
||||||
title: "Connection successful.",
|
title: "Connection successful.",
|
||||||
@@ -105,7 +112,7 @@ export function LighthouseV2ConfigPage({
|
|||||||
setLocalConfigurations((current) =>
|
setLocalConfigurations((current) =>
|
||||||
current.filter((config) => config.id !== configurationId),
|
current.filter((config) => config.id !== configurationId),
|
||||||
);
|
);
|
||||||
setFeedback({
|
showFeedback({
|
||||||
title: "Configuration removed.",
|
title: "Configuration removed.",
|
||||||
description: "This provider is no longer available for Lighthouse AI.",
|
description: "This provider is no longer available for Lighthouse AI.",
|
||||||
variant: FEEDBACK_VARIANT.INFO,
|
variant: FEEDBACK_VARIANT.INFO,
|
||||||
@@ -122,27 +129,19 @@ export function LighthouseV2ConfigPage({
|
|||||||
padding="none"
|
padding="none"
|
||||||
role="region"
|
role="region"
|
||||||
aria-label="Lighthouse AI settings"
|
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 && (
|
<LighthouseV2BusinessContextForm
|
||||||
<div className="border-border-neutral-secondary border-b px-4 py-4 md:px-5">
|
initialBusinessContext={tenantConfiguration?.businessContext ?? ""}
|
||||||
<ConfigFeedbackAlert
|
/>
|
||||||
feedback={feedback}
|
|
||||||
onClose={() => setFeedback(null)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<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">
|
<div className="min-w-0 p-4 md:p-5">
|
||||||
<LighthouseV2ProviderRail
|
<LighthouseV2ProviderRail
|
||||||
configurations={localConfigurations}
|
configurations={localConfigurations}
|
||||||
providers={providers}
|
providers={providers}
|
||||||
selectedProvider={selectedProvider}
|
selectedProvider={selectedProvider}
|
||||||
onSelectProvider={(provider) => {
|
onSelectProvider={setSelectedProvider}
|
||||||
setSelectedProvider(provider);
|
|
||||||
setFeedback(null);
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -152,16 +151,17 @@ export function LighthouseV2ConfigPage({
|
|||||||
className="border-border-neutral-secondary border-t xl:border-t-0 xl:border-l"
|
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
|
<LighthouseV2ConfigurationForm
|
||||||
key={selectedProvider}
|
key={selectedProvider}
|
||||||
configuration={selectedConfig}
|
configuration={selectedConfig}
|
||||||
models={selectedModels}
|
|
||||||
provider={selectedProviderDefinition}
|
provider={selectedProviderDefinition}
|
||||||
onConfigurationSaved={handleConfigurationSaved}
|
onConfigurationSaved={handleConfigurationSaved}
|
||||||
onConfigurationDeleted={handleConfigurationDeleted}
|
onConfigurationDeleted={handleConfigurationDeleted}
|
||||||
onConfigurationTested={handleConfigurationTested}
|
onConfigurationTested={handleConfigurationTested}
|
||||||
onFeedback={setFeedback}
|
onFeedback={(feedback) => {
|
||||||
|
if (feedback) showFeedback(feedback);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -67,9 +67,6 @@ export function LighthouseV2ProviderRail({
|
|||||||
</span>
|
</span>
|
||||||
<StatusBadge status={status} />
|
<StatusBadge status={status} />
|
||||||
</div>
|
</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">
|
<p className="text-text-neutral-tertiary mt-1 text-xs">
|
||||||
{formatLastChecked(config?.connectionLastCheckedAt)}
|
{formatLastChecked(config?.connectionLastCheckedAt)}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -39,10 +39,6 @@ const lighthouseV2ConfigFormSchemaBase = z.object({
|
|||||||
awsSecretAccessKey: z.string(),
|
awsSecretAccessKey: z.string(),
|
||||||
awsRegionName: z.string(),
|
awsRegionName: z.string(),
|
||||||
baseUrl: 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<
|
export type LighthouseV2ConfigFormValues = z.infer<
|
||||||
@@ -55,8 +51,6 @@ export const EMPTY_FORM_VALUES: LighthouseV2ConfigFormValues = {
|
|||||||
awsSecretAccessKey: "",
|
awsSecretAccessKey: "",
|
||||||
awsRegionName: "",
|
awsRegionName: "",
|
||||||
baseUrl: "",
|
baseUrl: "",
|
||||||
defaultModel: "",
|
|
||||||
businessContext: "",
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export function getFormDefaults(
|
export function getFormDefaults(
|
||||||
@@ -65,8 +59,6 @@ export function getFormDefaults(
|
|||||||
return {
|
return {
|
||||||
...EMPTY_FORM_VALUES,
|
...EMPTY_FORM_VALUES,
|
||||||
baseUrl: configuration?.baseUrl ?? "",
|
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
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -37,7 +37,6 @@ export interface LighthouseV2Configuration {
|
|||||||
providerType: LighthouseV2ProviderType;
|
providerType: LighthouseV2ProviderType;
|
||||||
baseUrl: string | null;
|
baseUrl: string | null;
|
||||||
defaultModel: string | null;
|
defaultModel: string | null;
|
||||||
businessContext: string;
|
|
||||||
connected: boolean | null;
|
connected: boolean | null;
|
||||||
connectionLastCheckedAt: string | null;
|
connectionLastCheckedAt: string | null;
|
||||||
insertedAt: string;
|
insertedAt: string;
|
||||||
@@ -48,15 +47,24 @@ export interface LighthouseV2ConfigurationInput {
|
|||||||
providerType: LighthouseV2ProviderType;
|
providerType: LighthouseV2ProviderType;
|
||||||
credentials: LighthouseV2Credentials;
|
credentials: LighthouseV2Credentials;
|
||||||
baseUrl?: string | null;
|
baseUrl?: string | null;
|
||||||
defaultModel?: string | null;
|
|
||||||
businessContext?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LighthouseV2ConfigurationUpdateInput {
|
export interface LighthouseV2ConfigurationUpdateInput {
|
||||||
credentials?: LighthouseV2Credentials;
|
credentials?: LighthouseV2Credentials;
|
||||||
baseUrl?: string | null;
|
baseUrl?: string | null;
|
||||||
defaultModel?: string | null;
|
}
|
||||||
|
|
||||||
|
export interface LighthouseV2TenantConfiguration {
|
||||||
|
id: string;
|
||||||
|
businessContext: string;
|
||||||
|
defaultProvider: LighthouseV2ProviderType | "";
|
||||||
|
defaultModels: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LighthouseV2TenantConfigurationUpdateInput {
|
||||||
businessContext?: string;
|
businessContext?: string;
|
||||||
|
defaultProvider?: LighthouseV2ProviderType | "";
|
||||||
|
defaultModels?: Record<string, string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LighthouseV2SupportedProvider {
|
export interface LighthouseV2SupportedProvider {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
getLighthouseV2Messages,
|
getLighthouseV2Messages,
|
||||||
getLighthouseV2Session,
|
getLighthouseV2Session,
|
||||||
getLighthouseV2SupportedModels,
|
getLighthouseV2SupportedModels,
|
||||||
|
getLighthouseV2TenantConfiguration,
|
||||||
} from "@/app/(prowler)/lighthouse/_actions";
|
} from "@/app/(prowler)/lighthouse/_actions";
|
||||||
import { LighthouseV2ChatPage } from "@/app/(prowler)/lighthouse/_components/chat";
|
import { LighthouseV2ChatPage } from "@/app/(prowler)/lighthouse/_components/chat";
|
||||||
import { LighthouseV2NavigationModeSync } from "@/app/(prowler)/lighthouse/_components/navigation";
|
import { LighthouseV2NavigationModeSync } from "@/app/(prowler)/lighthouse/_components/navigation";
|
||||||
@@ -36,7 +37,9 @@ export default async function AIChatbot({
|
|||||||
typeof params.session === "string" ? params.session : undefined;
|
typeof params.session === "string" ? params.session : undefined;
|
||||||
|
|
||||||
if (isCloud()) {
|
if (isCloud()) {
|
||||||
const configurationsResult = await getLighthouseV2Configurations();
|
const [configurationsResult, tenantConfigurationResult] = await Promise.all(
|
||||||
|
[getLighthouseV2Configurations(), getLighthouseV2TenantConfiguration()],
|
||||||
|
);
|
||||||
const configurations =
|
const configurations =
|
||||||
"data" in configurationsResult ? configurationsResult.data : [];
|
"data" in configurationsResult ? configurationsResult.data : [];
|
||||||
const connectedConfigurations = configurations.filter(
|
const connectedConfigurations = configurations.filter(
|
||||||
@@ -89,6 +92,11 @@ export default async function AIChatbot({
|
|||||||
key={chatRouteKey}
|
key={chatRouteKey}
|
||||||
configurations={configurations}
|
configurations={configurations}
|
||||||
modelsByProvider={modelsByProvider}
|
modelsByProvider={modelsByProvider}
|
||||||
|
tenantConfiguration={
|
||||||
|
"data" in tenantConfigurationResult
|
||||||
|
? tenantConfigurationResult.data
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
initialSessionId={activeSessionId}
|
initialSessionId={activeSessionId}
|
||||||
initialMessages={
|
initialMessages={
|
||||||
"data" in initialMessages ? initialMessages.data : []
|
"data" in initialMessages ? initialMessages.data : []
|
||||||
|
|||||||
@@ -2,14 +2,10 @@ import { Spacer } from "@heroui/spacer";
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
getLighthouseV2Configurations,
|
getLighthouseV2Configurations,
|
||||||
getLighthouseV2SupportedModels,
|
|
||||||
getLighthouseV2SupportedProviders,
|
getLighthouseV2SupportedProviders,
|
||||||
|
getLighthouseV2TenantConfiguration,
|
||||||
} from "@/app/(prowler)/lighthouse/_actions";
|
} from "@/app/(prowler)/lighthouse/_actions";
|
||||||
import { LighthouseV2ConfigPage } from "@/app/(prowler)/lighthouse/_components/config";
|
import { LighthouseV2ConfigPage } from "@/app/(prowler)/lighthouse/_components/config";
|
||||||
import type {
|
|
||||||
LighthouseV2ProviderType,
|
|
||||||
LighthouseV2SupportedModel,
|
|
||||||
} from "@/app/(prowler)/lighthouse/_types";
|
|
||||||
import {
|
import {
|
||||||
LighthouseSettings,
|
LighthouseSettings,
|
||||||
LLMProvidersTable,
|
LLMProvidersTable,
|
||||||
@@ -21,28 +17,14 @@ export const dynamic = "force-dynamic";
|
|||||||
|
|
||||||
export default async function LighthouseSettingsPage() {
|
export default async function LighthouseSettingsPage() {
|
||||||
if (isCloud()) {
|
if (isCloud()) {
|
||||||
const [configurationsResult, providersResult] = await Promise.all([
|
const [configurationsResult, providersResult, tenantConfigurationResult] =
|
||||||
getLighthouseV2Configurations(),
|
await Promise.all([
|
||||||
getLighthouseV2SupportedProviders(),
|
getLighthouseV2Configurations(),
|
||||||
]);
|
getLighthouseV2SupportedProviders(),
|
||||||
|
getLighthouseV2TenantConfiguration(),
|
||||||
|
]);
|
||||||
|
|
||||||
const providers = "data" in providersResult ? providersResult.data : [];
|
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 =
|
const error =
|
||||||
"error" in configurationsResult
|
"error" in configurationsResult
|
||||||
? configurationsResult.error
|
? configurationsResult.error
|
||||||
@@ -57,7 +39,11 @@ export default async function LighthouseSettingsPage() {
|
|||||||
"data" in configurationsResult ? configurationsResult.data : []
|
"data" in configurationsResult ? configurationsResult.data : []
|
||||||
}
|
}
|
||||||
providers={providers}
|
providers={providers}
|
||||||
modelsByProvider={modelsByProvider}
|
tenantConfiguration={
|
||||||
|
"data" in tenantConfigurationResult
|
||||||
|
? tenantConfigurationResult.data
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
error={error}
|
error={error}
|
||||||
/>
|
/>
|
||||||
</ContentLayout>
|
</ContentLayout>
|
||||||
|
|||||||
Reference in New Issue
Block a user