From 9817d964e7b064f017583294decdc7b36d530b07 Mon Sep 17 00:00:00 2001 From: alejandrobailo Date: Wed, 24 Jun 2026 18:22:06 +0200 Subject: [PATCH 01/28] feat(ui): connect Lighthouse v2 to Cloud backend --- .../{lighthouse => lighthouse-v1}/index.ts | 0 .../lighthouse.ts | 6 +- .../lighthouse-v2.adapter.test.ts | 168 +++++ .../lighthouse-v2/lighthouse-v2.adapter.ts | 348 ++++++++++ ui/actions/lighthouse-v2/lighthouse-v2.ts | 410 ++++++++++++ .../findings-view/findings-view.ssr.tsx | 2 +- .../config/(connect-llm)/connect/page.tsx | 12 +- .../config/(connect-llm)/layout.tsx | 8 +- .../(connect-llm)/select-model/page.tsx | 10 +- ui/app/(prowler)/lighthouse/config/page.tsx | 60 +- ui/app/(prowler)/lighthouse/page.tsx | 75 ++- ui/app/api/lighthouse/analyst/route.ts | 12 +- .../{lighthouse => }/ai-elements/actions.tsx | 0 .../ai-elements/dropdown-menu.tsx | 0 .../ai-elements/input-group.tsx | 0 .../{lighthouse => }/ai-elements/input.tsx | 0 .../ai-elements/prompt-input.tsx | 0 .../{lighthouse => }/ai-elements/select.tsx | 0 .../{lighthouse => }/ai-elements/textarea.tsx | 0 .../{lighthouse => }/ai-elements/tooltip.tsx | 0 .../banner-client.tsx | 0 .../{lighthouse => lighthouse-v1}/banner.tsx | 2 +- .../chain-of-thought-display.tsx | 2 +- .../chat-utils.ts | 4 +- .../{lighthouse => lighthouse-v1}/chat.tsx | 12 +- .../connect-llm-provider.tsx | 4 +- .../forms/delete-llm-provider-form.tsx | 4 +- .../{lighthouse => lighthouse-v1}/index.ts | 0 .../lighthouse-settings.tsx | 2 +- .../llm-provider-registry.ts | 2 +- .../llm-provider-utils.ts | 4 +- .../llm-providers-table.tsx | 2 +- .../{lighthouse => lighthouse-v1}/loader.tsx | 0 .../message-item.tsx | 8 +- .../select-bedrock-auth-method.tsx | 0 .../select-model.tsx | 4 +- .../workflow/index.ts | 0 .../workflow/workflow-connect-llm.tsx | 2 +- ui/components/lighthouse-v2/chat/index.ts | 1 + .../chat/lighthouse-v2-chat-page.tsx | 633 ++++++++++++++++++ ui/components/lighthouse-v2/config/index.ts | 1 + .../config/lighthouse-v2-config-page.tsx | 454 +++++++++++++ ui/components/lighthouse-v2/history/index.ts | 1 + .../history/lighthouse-v2-session-history.tsx | 128 ++++ .../lighthouse-v2/navigation/index.ts | 1 + .../navigation/lighthouse-v2-sidebar-chat.tsx | 96 +++ ui/components/ui/sidebar/menu.tsx | 158 ++++- ui/hooks/use-sidebar.ts | 14 + .../analyst-stream.ts | 7 +- .../auth-context.ts | 0 .../constants.ts | 0 ui/lib/{lighthouse => lighthouse-v1}/data.ts | 0 .../llm-factory.ts | 0 .../mcp-client.ts | 2 +- .../definitions/attack-path-custom-query.ts | 0 .../skills/index.ts | 0 .../skills/registry.ts | 0 .../skills/types.ts | 0 .../system-prompt.ts | 2 +- .../tools/load-skill.ts | 2 +- .../tools/meta-tool.ts | 4 +- ui/lib/{lighthouse => lighthouse-v1}/types.ts | 2 +- ui/lib/{lighthouse => lighthouse-v1}/utils.ts | 2 +- .../validation.ts | 4 +- .../{lighthouse => lighthouse-v1}/workflow.ts | 20 +- ui/lib/lighthouse-v2/event-reducer.test.ts | 116 ++++ ui/lib/lighthouse-v2/event-reducer.ts | 116 ++++ .../credentials.ts | 0 .../{lighthouse => lighthouse-v1}/index.ts | 0 .../lighthouse-providers.ts | 0 .../model-params.ts | 0 ui/types/lighthouse-v2/config.ts | 74 ++ ui/types/lighthouse-v2/events.ts | 58 ++ ui/types/lighthouse-v2/index.ts | 3 + ui/types/lighthouse-v2/sessions.ts | 78 +++ 75 files changed, 3035 insertions(+), 105 deletions(-) rename ui/actions/{lighthouse => lighthouse-v1}/index.ts (100%) rename ui/actions/{lighthouse => lighthouse-v1}/lighthouse.ts (99%) create mode 100644 ui/actions/lighthouse-v2/lighthouse-v2.adapter.test.ts create mode 100644 ui/actions/lighthouse-v2/lighthouse-v2.adapter.ts create mode 100644 ui/actions/lighthouse-v2/lighthouse-v2.ts rename ui/components/{lighthouse => }/ai-elements/actions.tsx (100%) rename ui/components/{lighthouse => }/ai-elements/dropdown-menu.tsx (100%) rename ui/components/{lighthouse => }/ai-elements/input-group.tsx (100%) rename ui/components/{lighthouse => }/ai-elements/input.tsx (100%) rename ui/components/{lighthouse => }/ai-elements/prompt-input.tsx (100%) rename ui/components/{lighthouse => }/ai-elements/select.tsx (100%) rename ui/components/{lighthouse => }/ai-elements/textarea.tsx (100%) rename ui/components/{lighthouse => }/ai-elements/tooltip.tsx (100%) rename ui/components/{lighthouse => lighthouse-v1}/banner-client.tsx (100%) rename ui/components/{lighthouse => lighthouse-v1}/banner.tsx (78%) rename ui/components/{lighthouse => lighthouse-v1}/chain-of-thought-display.tsx (97%) rename ui/components/{lighthouse => lighthouse-v1}/chat-utils.ts (98%) rename ui/components/{lighthouse => lighthouse-v1}/chat.tsx (98%) rename ui/components/{lighthouse => lighthouse-v1}/connect-llm-provider.tsx (99%) rename ui/components/{lighthouse => lighthouse-v1}/forms/delete-llm-provider-form.tsx (95%) rename ui/components/{lighthouse => lighthouse-v1}/index.ts (100%) rename ui/components/{lighthouse => lighthouse-v1}/lighthouse-settings.tsx (98%) rename ui/components/{lighthouse => lighthouse-v1}/llm-provider-registry.ts (97%) rename ui/components/{lighthouse => lighthouse-v1}/llm-provider-utils.ts (97%) rename ui/components/{lighthouse => lighthouse-v1}/llm-providers-table.tsx (99%) rename ui/components/{lighthouse => lighthouse-v1}/loader.tsx (100%) rename ui/components/{lighthouse => lighthouse-v1}/message-item.tsx (95%) rename ui/components/{lighthouse => lighthouse-v1}/select-bedrock-auth-method.tsx (100%) rename ui/components/{lighthouse => lighthouse-v1}/select-model.tsx (98%) rename ui/components/{lighthouse => lighthouse-v1}/workflow/index.ts (100%) rename ui/components/{lighthouse => lighthouse-v1}/workflow/workflow-connect-llm.tsx (98%) create mode 100644 ui/components/lighthouse-v2/chat/index.ts create mode 100644 ui/components/lighthouse-v2/chat/lighthouse-v2-chat-page.tsx create mode 100644 ui/components/lighthouse-v2/config/index.ts create mode 100644 ui/components/lighthouse-v2/config/lighthouse-v2-config-page.tsx create mode 100644 ui/components/lighthouse-v2/history/index.ts create mode 100644 ui/components/lighthouse-v2/history/lighthouse-v2-session-history.tsx create mode 100644 ui/components/lighthouse-v2/navigation/index.ts create mode 100644 ui/components/lighthouse-v2/navigation/lighthouse-v2-sidebar-chat.tsx rename ui/lib/{lighthouse => lighthouse-v1}/analyst-stream.ts (97%) rename ui/lib/{lighthouse => lighthouse-v1}/auth-context.ts (100%) rename ui/lib/{lighthouse => lighthouse-v1}/constants.ts (100%) rename ui/lib/{lighthouse => lighthouse-v1}/data.ts (100%) rename ui/lib/{lighthouse => lighthouse-v1}/llm-factory.ts (100%) rename ui/lib/{lighthouse => lighthouse-v1}/mcp-client.ts (99%) rename ui/lib/{lighthouse => lighthouse-v1}/skills/definitions/attack-path-custom-query.ts (100%) rename ui/lib/{lighthouse => lighthouse-v1}/skills/index.ts (100%) rename ui/lib/{lighthouse => lighthouse-v1}/skills/registry.ts (100%) rename ui/lib/{lighthouse => lighthouse-v1}/skills/types.ts (100%) rename ui/lib/{lighthouse => lighthouse-v1}/system-prompt.ts (99%) rename ui/lib/{lighthouse => lighthouse-v1}/tools/load-skill.ts (97%) rename ui/lib/{lighthouse => lighthouse-v1}/tools/meta-tool.ts (97%) rename ui/lib/{lighthouse => lighthouse-v1}/types.ts (95%) rename ui/lib/{lighthouse => lighthouse-v1}/utils.ts (96%) rename ui/lib/{lighthouse => lighthouse-v1}/validation.ts (93%) rename ui/lib/{lighthouse => lighthouse-v1}/workflow.ts (89%) create mode 100644 ui/lib/lighthouse-v2/event-reducer.test.ts create mode 100644 ui/lib/lighthouse-v2/event-reducer.ts rename ui/types/{lighthouse => lighthouse-v1}/credentials.ts (100%) rename ui/types/{lighthouse => lighthouse-v1}/index.ts (100%) rename ui/types/{lighthouse => lighthouse-v1}/lighthouse-providers.ts (100%) rename ui/types/{lighthouse => lighthouse-v1}/model-params.ts (100%) create mode 100644 ui/types/lighthouse-v2/config.ts create mode 100644 ui/types/lighthouse-v2/events.ts create mode 100644 ui/types/lighthouse-v2/index.ts create mode 100644 ui/types/lighthouse-v2/sessions.ts diff --git a/ui/actions/lighthouse/index.ts b/ui/actions/lighthouse-v1/index.ts similarity index 100% rename from ui/actions/lighthouse/index.ts rename to ui/actions/lighthouse-v1/index.ts diff --git a/ui/actions/lighthouse/lighthouse.ts b/ui/actions/lighthouse-v1/lighthouse.ts similarity index 99% rename from ui/actions/lighthouse/lighthouse.ts rename to ui/actions/lighthouse-v1/lighthouse.ts index 5365517bbd0..635571f69e5 100644 --- a/ui/actions/lighthouse/lighthouse.ts +++ b/ui/actions/lighthouse-v1/lighthouse.ts @@ -4,17 +4,17 @@ import { apiBaseUrl, getAuthHeaders } from "@/lib/helper"; import { validateBaseUrl, validateCredentials, -} from "@/lib/lighthouse/validation"; +} from "@/lib/lighthouse-v1/validation"; import { handleApiError, handleApiResponse } from "@/lib/server-actions-helper"; import { type LighthouseProvider, PROVIDER_DISPLAY_NAMES, -} from "@/types/lighthouse"; +} from "@/types/lighthouse-v1"; import type { BedrockCredentials, OpenAICompatibleCredentials, OpenAICredentials, -} from "@/types/lighthouse/credentials"; +} from "@/types/lighthouse-v1/credentials"; // API Response Types type ProviderCredentials = diff --git a/ui/actions/lighthouse-v2/lighthouse-v2.adapter.test.ts b/ui/actions/lighthouse-v2/lighthouse-v2.adapter.test.ts new file mode 100644 index 00000000000..03186a1d82d --- /dev/null +++ b/ui/actions/lighthouse-v2/lighthouse-v2.adapter.test.ts @@ -0,0 +1,168 @@ +import { describe, expect, it } from "vitest"; + +import { + buildLighthouseV2ConfigurationPayload, + mapLighthouseV2Configuration, + mapLighthouseV2Message, + mapLighthouseV2Model, + mapLighthouseV2Provider, + validateLighthouseV2ConfigurationInput, +} from "./lighthouse-v2.adapter"; + +describe("lighthouse-v2.adapter", () => { + describe("when mapping Cloud JSON:API resources", () => { + it("should map configuration attributes to UI fields", () => { + // Given + const resource: Parameters[0] = { + id: "config-1", + type: "lighthouse-ai-configurations", + attributes: { + provider_type: "bedrock", + base_url: null, + default_model: "anthropic.claude", + business_context: "Production AWS account", + connected: true, + connection_last_checked_at: "2026-06-24T10:00:00Z", + inserted_at: "2026-06-24T09:00:00Z", + updated_at: "2026-06-24T10:00:00Z", + }, + }; + + // When + const config = mapLighthouseV2Configuration(resource); + + // Then + expect(config).toEqual({ + id: "config-1", + providerType: "bedrock", + baseUrl: null, + defaultModel: "anthropic.claude", + businessContext: "Production AWS account", + connected: true, + connectionLastCheckedAt: "2026-06-24T10:00:00Z", + insertedAt: "2026-06-24T09:00:00Z", + updatedAt: "2026-06-24T10:00:00Z", + }); + }); + + it("should map supported provider and model payloads", () => { + // Given + const provider = { + id: "openai", + type: "lighthouse-supported-providers", + attributes: { name: "OpenAI" }, + }; + const model = { + id: "gpt-5.5", + type: "lighthouse-supported-models", + attributes: { + max_input_tokens: 100000, + max_output_tokens: 8192, + supports_function_calling: true, + supports_vision: false, + supports_reasoning: true, + }, + }; + + // When / Then + expect(mapLighthouseV2Provider(provider)).toEqual({ + id: "openai", + name: "OpenAI", + }); + expect(mapLighthouseV2Model(model)).toEqual({ + id: "gpt-5.5", + maxInputTokens: 100000, + maxOutputTokens: 8192, + supportsFunctionCalling: true, + supportsVision: false, + supportsReasoning: true, + }); + }); + + it("should map message parts from backend names", () => { + // Given + const resource: Parameters[0] = { + id: "message-1", + type: "lighthouse-messages", + attributes: { + role: "assistant", + model: "gpt-5.5", + token_usage: { input: 10 }, + inserted_at: "2026-06-24T10:01:00Z", + parts: [ + { + id: "part-1", + type: "lighthouse-parts", + attributes: { + part_type: "text", + content: { text: "Done" }, + tool_call_outcome: null, + inserted_at: "2026-06-24T10:01:00Z", + updated_at: "2026-06-24T10:01:00Z", + }, + }, + ], + }, + }; + + // When + const message = mapLighthouseV2Message(resource); + + // Then + expect(message.parts[0]).toMatchObject({ + id: "part-1", + type: "text", + content: { text: "Done" }, + }); + }); + }); + + describe("when building Cloud payloads", () => { + it("should use Cloud Bedrock credential keys", () => { + // Given + const input = { + providerType: "bedrock" as const, + defaultModel: "anthropic.claude", + businessContext: "Production AWS account", + credentials: { + aws_access_key_id: "AKIA0000000000000000", + aws_secret_access_key: "a".repeat(40), + aws_region_name: "us-east-1", + }, + }; + + // When + const payload = buildLighthouseV2ConfigurationPayload(input); + + // Then + expect(payload.data).toMatchObject({ + type: "lighthouse-ai-configurations", + attributes: { + provider_type: "bedrock", + credentials: { + aws_access_key_id: "AKIA0000000000000000", + aws_secret_access_key: "a".repeat(40), + aws_region_name: "us-east-1", + }, + }, + }); + }); + + it("should require base_url for OpenAI-compatible configurations", () => { + // Given + const input = { + providerType: "openai-compatible" as const, + credentials: { api_key: "provider-key" }, + }; + + // When + const result = validateLighthouseV2ConfigurationInput(input); + + // Then + expect(result).toEqual({ + success: false, + error: "Base URL is required for OpenAI-compatible providers.", + }); + }); + }); +}); diff --git a/ui/actions/lighthouse-v2/lighthouse-v2.adapter.ts b/ui/actions/lighthouse-v2/lighthouse-v2.adapter.ts new file mode 100644 index 00000000000..10b1c151b7b --- /dev/null +++ b/ui/actions/lighthouse-v2/lighthouse-v2.adapter.ts @@ -0,0 +1,348 @@ +import { + LIGHTHOUSE_V2_PROVIDER_TYPE, + type LighthouseV2Configuration, + type LighthouseV2ConfigurationInput, + type LighthouseV2ConfigurationUpdateInput, + type LighthouseV2Credentials, + type LighthouseV2Message, + type LighthouseV2Part, + type LighthouseV2ProviderType, + type LighthouseV2Session, + type LighthouseV2SupportedModel, + type LighthouseV2SupportedProvider, + type LighthouseV2Task, +} from "@/types/lighthouse-v2"; + +export interface JsonApiResource { + id: string; + type: string; + attributes: TAttributes; + meta?: Record; +} + +export interface JsonApiDocument { + data?: TData; + meta?: Record; + links?: Record; + error?: string; + errors?: unknown[]; + status?: number; +} + +interface ConfigurationAttributes { + provider_type: LighthouseV2ProviderType; + base_url: string | null; + default_model: string | null; + business_context?: string | null; + connected: boolean | null; + connection_last_checked_at: string | null; + inserted_at: string; + updated_at: string; +} + +interface SupportedProviderAttributes { + name: string; +} + +interface SupportedModelAttributes { + max_input_tokens: number | null; + max_output_tokens: number | null; + supports_function_calling: boolean | null; + supports_vision: boolean | null; + supports_reasoning: boolean | null; +} + +interface SessionAttributes { + title: string | null; + is_archived: boolean; + inserted_at: string; + updated_at: string; + active_celery_task_id?: string | null; +} + +interface MessageAttributes { + role: "user" | "assistant"; + model: string | null; + token_usage: unknown; + inserted_at: string; + parts?: UnknownPartResource[]; +} + +interface PartAttributes { + id?: string; + part_type: "text" | "reasoning" | "tool_call"; + content: unknown; + tool_call_outcome?: string | null; + inserted_at?: string | null; + updated_at?: string | null; +} + +type UnknownPartResource = + | JsonApiResource + | (PartAttributes & { id?: string }); + +interface TaskAttributes { + inserted_at?: string; + completed_at?: string | null; + name?: string | null; + state: string; + metadata?: unknown; + result?: unknown; +} + +interface ValidationSuccess { + success: true; +} + +interface ValidationFailure { + success: false; + error: string; +} + +type ValidationResult = ValidationSuccess | ValidationFailure; + +export function getJsonApiArray( + document: JsonApiDocument, +): TResource[] { + return document.data ?? []; +} + +export function mapLighthouseV2Configuration( + resource: JsonApiResource, +): LighthouseV2Configuration { + return { + id: resource.id, + providerType: resource.attributes.provider_type, + baseUrl: resource.attributes.base_url, + defaultModel: resource.attributes.default_model, + businessContext: resource.attributes.business_context ?? "", + connected: resource.attributes.connected, + connectionLastCheckedAt: resource.attributes.connection_last_checked_at, + insertedAt: resource.attributes.inserted_at, + updatedAt: resource.attributes.updated_at, + }; +} + +export function mapLighthouseV2Provider( + resource: JsonApiResource, +): LighthouseV2SupportedProvider { + return { + id: resource.id as LighthouseV2ProviderType, + name: resource.attributes.name, + }; +} + +export function mapLighthouseV2Model( + resource: JsonApiResource, +): LighthouseV2SupportedModel { + return { + id: resource.id, + maxInputTokens: resource.attributes.max_input_tokens, + maxOutputTokens: resource.attributes.max_output_tokens, + supportsFunctionCalling: resource.attributes.supports_function_calling, + supportsVision: resource.attributes.supports_vision, + supportsReasoning: resource.attributes.supports_reasoning, + }; +} + +export function mapLighthouseV2Session( + resource: JsonApiResource, +): LighthouseV2Session { + return { + id: resource.id, + title: resource.attributes.title, + isArchived: resource.attributes.is_archived, + insertedAt: resource.attributes.inserted_at, + updatedAt: resource.attributes.updated_at, + activeTaskId: resource.attributes.active_celery_task_id, + }; +} + +export function mapLighthouseV2Message( + resource: JsonApiResource, +): LighthouseV2Message { + return { + id: resource.id, + role: resource.attributes.role, + model: resource.attributes.model, + tokenUsage: resource.attributes.token_usage, + insertedAt: resource.attributes.inserted_at, + parts: (resource.attributes.parts ?? []).map(mapLighthouseV2Part), + }; +} + +export function mapLighthouseV2Task( + resource: JsonApiResource, +): LighthouseV2Task { + return { + id: resource.id, + name: resource.attributes.name ?? null, + state: resource.attributes.state, + insertedAt: resource.attributes.inserted_at, + completedAt: resource.attributes.completed_at, + metadata: resource.attributes.metadata, + result: resource.attributes.result, + }; +} + +export function buildLighthouseV2ConfigurationPayload( + input: LighthouseV2ConfigurationInput, +) { + return { + data: { + type: "lighthouse-ai-configurations", + attributes: filterUndefinedAttributes({ + provider_type: input.providerType, + credentials: input.credentials, + base_url: input.baseUrl ?? null, + default_model: input.defaultModel ?? null, + business_context: input.businessContext, + }), + }, + }; +} + +export function buildLighthouseV2ConfigurationUpdatePayload( + configId: string, + input: LighthouseV2ConfigurationUpdateInput, +) { + return { + data: { + type: "lighthouse-ai-configurations", + id: configId, + attributes: filterUndefinedAttributes({ + credentials: input.credentials, + base_url: input.baseUrl, + default_model: input.defaultModel, + business_context: input.businessContext, + }), + }, + }; +} + +export function buildLighthouseV2SessionCreatePayload(title?: string | null) { + return { + data: { + type: "lighthouse-sessions", + attributes: { title: title || null }, + }, + }; +} + +export function buildLighthouseV2SessionUpdatePayload( + sessionId: string, + attributes: { title?: string | null; isArchived?: boolean }, +) { + return { + data: { + type: "lighthouse-sessions", + id: sessionId, + attributes: filterUndefinedAttributes({ + title: attributes.title, + is_archived: attributes.isArchived, + }), + }, + }; +} + +export function buildLighthouseV2MessagePayload(input: { + text: string; + provider: LighthouseV2ProviderType; + model?: string | null; +}) { + return { + data: { + type: "lighthouse-messages", + attributes: filterUndefinedAttributes({ + parts: [ + { + part_type: "text", + content: { text: input.text }, + }, + ], + provider: input.provider, + model: input.model || undefined, + }), + }, + }; +} + +export function buildLighthouseV2CancelRunPayload(taskId: string) { + return { + data: { + type: "lighthouse-run-cancellations", + attributes: { task_id: taskId }, + }, + }; +} + +export function validateLighthouseV2ConfigurationInput(input: { + providerType: LighthouseV2ProviderType; + credentials?: LighthouseV2Credentials; + baseUrl?: string | null; +}): ValidationResult { + if (!input.credentials) { + return { success: false, error: "Credentials are required." }; + } + + if ( + input.providerType === LIGHTHOUSE_V2_PROVIDER_TYPE.OPENAI_COMPATIBLE && + !input.baseUrl + ) { + return { + success: false, + error: "Base URL is required for OpenAI-compatible providers.", + }; + } + + if ( + input.providerType !== LIGHTHOUSE_V2_PROVIDER_TYPE.OPENAI_COMPATIBLE && + input.baseUrl + ) { + return { + success: false, + error: "Base URL is only supported for OpenAI-compatible providers.", + }; + } + + if ( + input.providerType === LIGHTHOUSE_V2_PROVIDER_TYPE.BEDROCK && + !hasBedrockRegion(input.credentials) + ) { + return { + success: false, + error: "AWS region is required for Bedrock providers.", + }; + } + + return { success: true }; +} + +function mapLighthouseV2Part(resource: UnknownPartResource): LighthouseV2Part { + const attributes = "attributes" in resource ? resource.attributes : resource; + const id = + "id" in resource && resource.id ? resource.id : (attributes.id ?? ""); + + return { + id, + type: attributes.part_type, + content: attributes.content, + toolCallOutcome: attributes.tool_call_outcome ?? null, + insertedAt: attributes.inserted_at ?? null, + updatedAt: attributes.updated_at ?? null, + }; +} + +function filterUndefinedAttributes>( + attributes: T, +) { + return Object.fromEntries( + Object.entries(attributes).filter(([, value]) => value !== undefined), + ) as Partial; +} + +function hasBedrockRegion(credentials: LighthouseV2Credentials): boolean { + return ( + "aws_region_name" in credentials && Boolean(credentials.aws_region_name) + ); +} diff --git a/ui/actions/lighthouse-v2/lighthouse-v2.ts b/ui/actions/lighthouse-v2/lighthouse-v2.ts new file mode 100644 index 00000000000..068ef69a9b7 --- /dev/null +++ b/ui/actions/lighthouse-v2/lighthouse-v2.ts @@ -0,0 +1,410 @@ +"use server"; + +import { auth } from "@/auth.config"; +import { apiBaseUrl, getAuthHeaders } from "@/lib/helper"; +import { handleApiError, handleApiResponse } from "@/lib/server-actions-helper"; +import type { + LighthouseV2Configuration, + LighthouseV2ConfigurationInput, + LighthouseV2ConfigurationUpdateInput, + LighthouseV2Message, + LighthouseV2ProviderType, + LighthouseV2SendMessageInput, + LighthouseV2SendMessageResult, + LighthouseV2Session, + LighthouseV2SupportedModel, + LighthouseV2SupportedProvider, + LighthouseV2Task, +} from "@/types/lighthouse-v2"; + +import { + buildLighthouseV2CancelRunPayload, + buildLighthouseV2ConfigurationPayload, + buildLighthouseV2ConfigurationUpdatePayload, + buildLighthouseV2MessagePayload, + buildLighthouseV2SessionCreatePayload, + buildLighthouseV2SessionUpdatePayload, + getJsonApiArray, + type JsonApiDocument, + mapLighthouseV2Configuration, + mapLighthouseV2Message, + mapLighthouseV2Model, + mapLighthouseV2Provider, + mapLighthouseV2Session, + mapLighthouseV2Task, + validateLighthouseV2ConfigurationInput, +} from "./lighthouse-v2.adapter"; + +type TaskResource = Parameters[0]; + +export type LighthouseV2ActionResult = + | { + data: T; + meta?: Record; + links?: Record; + status?: number; + } + | { + error: string; + errors?: unknown[]; + status?: number; + }; + +export async function getLighthouseV2Configurations(): Promise< + LighthouseV2ActionResult +> { + return getCollection("/lighthouse/config", mapLighthouseV2Configuration); +} + +export async function createLighthouseV2Configuration( + input: LighthouseV2ConfigurationInput, +): Promise> { + const validation = validateLighthouseV2ConfigurationInput(input); + if (!validation.success) { + return { error: validation.error, status: 400 }; + } + + return mutateSingle( + "/lighthouse/config", + { + method: "POST", + body: JSON.stringify(buildLighthouseV2ConfigurationPayload(input)), + }, + mapLighthouseV2Configuration, + "/lighthouse/config", + ); +} + +export async function updateLighthouseV2Configuration( + configId: string, + input: LighthouseV2ConfigurationUpdateInput, +): Promise> { + return mutateSingle( + `/lighthouse/config/${encodeURIComponent(configId)}`, + { + method: "PATCH", + body: JSON.stringify( + buildLighthouseV2ConfigurationUpdatePayload(configId, input), + ), + }, + mapLighthouseV2Configuration, + "/lighthouse/config", + ); +} + +export async function deleteLighthouseV2Configuration( + configId: string, +): Promise> { + return mutateEmpty( + `/lighthouse/config/${encodeURIComponent(configId)}`, + { method: "DELETE" }, + "/lighthouse/config", + ); +} + +export async function testLighthouseV2ConfigurationConnection( + configId: string, +): Promise> { + return mutateSingle( + `/lighthouse/config/${encodeURIComponent(configId)}/connection`, + { method: "POST" }, + mapLighthouseV2Task, + "/lighthouse/config", + false, + ); +} + +export async function getLighthouseV2SupportedProviders(): Promise< + LighthouseV2ActionResult +> { + return getCollection( + "/lighthouse/supported-providers", + mapLighthouseV2Provider, + ); +} + +export async function getLighthouseV2SupportedModels( + provider: LighthouseV2ProviderType, +): Promise> { + return getCollection( + `/lighthouse/supported-providers/${encodeURIComponent(provider)}/models`, + mapLighthouseV2Model, + ); +} + +export async function getLighthouseV2Sessions(params?: { + search?: string; +}): Promise> { + const url = buildApiUrl("/lighthouse/sessions"); + if (params?.search) { + url.searchParams.set("search", params.search); + } + return getCollectionFromUrl(url, mapLighthouseV2Session); +} + +export async function getLighthouseV2Session( + sessionId: string, +): Promise> { + return getSingle( + `/lighthouse/sessions/${encodeURIComponent(sessionId)}`, + mapLighthouseV2Session, + ); +} + +export async function createLighthouseV2Session( + title?: string | null, +): Promise> { + return mutateSingle( + "/lighthouse/sessions", + { + method: "POST", + body: JSON.stringify(buildLighthouseV2SessionCreatePayload(title)), + }, + mapLighthouseV2Session, + "/lighthouse", + ); +} + +export async function updateLighthouseV2Session( + sessionId: string, + attributes: { title?: string | null; isArchived?: boolean }, +): Promise> { + return mutateSingle( + `/lighthouse/sessions/${encodeURIComponent(sessionId)}`, + { + method: "PATCH", + body: JSON.stringify( + buildLighthouseV2SessionUpdatePayload(sessionId, attributes), + ), + }, + mapLighthouseV2Session, + "/lighthouse", + ); +} + +export async function archiveLighthouseV2Session( + sessionId: string, +): Promise> { + return updateLighthouseV2Session(sessionId, { isArchived: true }); +} + +export async function getLighthouseV2Messages( + sessionId: string, +): Promise> { + return getCollection( + `/lighthouse/sessions/${encodeURIComponent(sessionId)}/messages`, + mapLighthouseV2Message, + ); +} + +export async function sendLighthouseV2Message( + input: LighthouseV2SendMessageInput, +): Promise> { + try { + const response = await fetch( + buildApiUrl( + `/lighthouse/sessions/${encodeURIComponent(input.sessionId)}/messages`, + ), + { + method: "POST", + headers: await getAuthHeaders({ contentType: true }), + body: JSON.stringify(buildLighthouseV2MessagePayload(input)), + }, + ); + const document = (await handleApiResponse( + response, + )) as JsonApiDocument; + + if (isErrorDocument(document) || !document.data) { + return toErrorResult(document); + } + + const streamPath = + typeof document.meta?.stream_url === "string" + ? document.meta.stream_url + : undefined; + const streamUrl = streamPath + ? await getAuthenticatedLighthouseV2StreamUrl(streamPath) + : undefined; + + return { + data: { + task: mapLighthouseV2Task(document.data), + streamUrl, + }, + meta: document.meta, + }; + } catch (error) { + return handleApiError(error); + } +} + +export async function cancelLighthouseV2Run( + sessionId: string, + taskId: string, +): Promise> { + return mutateSingle( + `/lighthouse/sessions/${encodeURIComponent(sessionId)}/cancel-run`, + { + method: "POST", + body: JSON.stringify(buildLighthouseV2CancelRunPayload(taskId)), + }, + mapLighthouseV2Task, + "/lighthouse", + ); +} + +export async function getAuthenticatedLighthouseV2StreamUrl( + streamPath: string, +): Promise { + const session = await auth(); + if (!session?.accessToken) { + return undefined; + } + + const streamUrl = new URL(streamPath, getRequiredApiBaseUrl()); + streamUrl.searchParams.set("access_token", session.accessToken); + return streamUrl.toString(); +} + +async function getCollection( + path: string, + mapper: (resource: TResource) => TOutput, +): Promise> { + return getCollectionFromUrl(buildApiUrl(path), mapper); +} + +async function getCollectionFromUrl( + url: URL, + mapper: (resource: TResource) => TOutput, +): Promise> { + try { + const headers = await getAuthHeaders({ contentType: false }); + const first = (await handleApiResponse( + await fetch(url.toString(), { + method: "GET", + headers, + cache: "no-store", + }), + )) as JsonApiDocument; + + if (isErrorDocument(first)) { + return toErrorResult(first); + } + + const resources = [...getJsonApiArray(first)]; + let nextUrl: string | undefined = first.links?.next ?? undefined; + while (nextUrl) { + const page = (await handleApiResponse( + await fetch(nextUrl, { method: "GET", headers, cache: "no-store" }), + )) as JsonApiDocument; + if (isErrorDocument(page)) { + return toErrorResult(page); + } + resources.push(...getJsonApiArray(page)); + nextUrl = page.links?.next ?? undefined; + } + + return { + data: resources.map(mapper), + meta: first.meta, + links: first.links, + }; + } catch (error) { + return handleApiError(error); + } +} + +async function getSingle( + path: string, + mapper: (resource: TResource) => TOutput, +): Promise> { + try { + const response = await fetch(buildApiUrl(path), { + method: "GET", + headers: await getAuthHeaders({ contentType: false }), + cache: "no-store", + }); + const document = (await handleApiResponse( + response, + )) as JsonApiDocument; + if (isErrorDocument(document) || !document.data) { + return toErrorResult(document); + } + return { data: mapper(document.data), meta: document.meta }; + } catch (error) { + return handleApiError(error); + } +} + +async function mutateSingle( + path: string, + init: RequestInit, + mapper: (resource: TResource) => TOutput, + pathToRevalidate: string, + includeContentType = true, +): Promise> { + try { + const response = await fetch(buildApiUrl(path), { + ...init, + headers: await getAuthHeaders({ contentType: includeContentType }), + }); + const document = (await handleApiResponse( + response, + pathToRevalidate, + )) as JsonApiDocument; + if (isErrorDocument(document) || !document.data) { + return toErrorResult(document); + } + return { data: mapper(document.data), meta: document.meta }; + } catch (error) { + return handleApiError(error); + } +} + +async function mutateEmpty( + path: string, + init: RequestInit, + pathToRevalidate: string, +): Promise> { + try { + const response = await fetch(buildApiUrl(path), { + ...init, + headers: await getAuthHeaders({ contentType: false }), + }); + const document = await handleApiResponse(response, pathToRevalidate); + if (isErrorDocument(document)) { + return toErrorResult(document); + } + return { data: true, status: document.status }; + } catch (error) { + return handleApiError(error); + } +} + +function buildApiUrl(path: string): URL { + return new URL(`${getRequiredApiBaseUrl()}${path}`); +} + +function getRequiredApiBaseUrl(): string { + if (!apiBaseUrl) { + throw new Error("API base URL is not configured."); + } + return apiBaseUrl; +} + +function isErrorDocument( + document: JsonApiDocument | { error?: unknown }, +): document is JsonApiDocument & { error: string } { + return typeof document.error === "string"; +} + +function toErrorResult( + document: JsonApiDocument, +): Extract, { error: string }> { + return { + error: document.error ?? "Unexpected Lighthouse response.", + errors: document.errors, + status: document.status, + }; +} diff --git a/ui/app/(prowler)/_overview/graphs-tabs/findings-view/findings-view.ssr.tsx b/ui/app/(prowler)/_overview/graphs-tabs/findings-view/findings-view.ssr.tsx index 0396795cb95..df91def7d77 100644 --- a/ui/app/(prowler)/_overview/graphs-tabs/findings-view/findings-view.ssr.tsx +++ b/ui/app/(prowler)/_overview/graphs-tabs/findings-view/findings-view.ssr.tsx @@ -1,7 +1,7 @@ "use server"; import { getLatestFindings } from "@/actions/findings/findings"; -import { LighthouseBanner } from "@/components/lighthouse/banner"; +import { LighthouseBanner } from "@/components/lighthouse-v1/banner"; import { LinkToFindings } from "@/components/overview"; import { ColumnLatestFindings } from "@/components/overview/new-findings-table/table"; import { CardTitle } from "@/components/shadcn"; diff --git a/ui/app/(prowler)/lighthouse/config/(connect-llm)/connect/page.tsx b/ui/app/(prowler)/lighthouse/config/(connect-llm)/connect/page.tsx index 6446c726a0f..910d20fa907 100644 --- a/ui/app/(prowler)/lighthouse/config/(connect-llm)/connect/page.tsx +++ b/ui/app/(prowler)/lighthouse/config/(connect-llm)/connect/page.tsx @@ -1,11 +1,11 @@ "use client"; -import { useSearchParams } from "next/navigation"; +import { redirect, useSearchParams } from "next/navigation"; import { Suspense } from "react"; -import { ConnectLLMProvider } from "@/components/lighthouse/connect-llm-provider"; -import { SelectBedrockAuthMethod } from "@/components/lighthouse/select-bedrock-auth-method"; -import type { LighthouseProvider } from "@/types/lighthouse"; +import { ConnectLLMProvider } from "@/components/lighthouse-v1/connect-llm-provider"; +import { SelectBedrockAuthMethod } from "@/components/lighthouse-v1/select-bedrock-auth-method"; +import type { LighthouseProvider } from "@/types/lighthouse-v1"; export const BEDROCK_AUTH_MODES = { IAM: "iam", @@ -43,6 +43,10 @@ function ConnectContent() { } export default function ConnectLLMProviderPage() { + if (process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true") { + redirect("/lighthouse/config"); + } + return ( Loading...}> diff --git a/ui/app/(prowler)/lighthouse/config/(connect-llm)/layout.tsx b/ui/app/(prowler)/lighthouse/config/(connect-llm)/layout.tsx index 61579abcb0d..59ddc80e5dc 100644 --- a/ui/app/(prowler)/lighthouse/config/(connect-llm)/layout.tsx +++ b/ui/app/(prowler)/lighthouse/config/(connect-llm)/layout.tsx @@ -10,13 +10,13 @@ import React, { useEffect, useState } from "react"; import { getTenantConfig, updateTenantConfig, -} from "@/actions/lighthouse/lighthouse"; -import { DeleteLLMProviderForm } from "@/components/lighthouse/forms/delete-llm-provider-form"; -import { WorkflowConnectLLM } from "@/components/lighthouse/workflow"; +} from "@/actions/lighthouse-v1/lighthouse"; +import { DeleteLLMProviderForm } from "@/components/lighthouse-v1/forms/delete-llm-provider-form"; +import { WorkflowConnectLLM } from "@/components/lighthouse-v1/workflow"; import { Button } from "@/components/shadcn"; import { Modal } from "@/components/shadcn/modal"; import { NavigationHeader } from "@/components/ui"; -import type { LighthouseProvider } from "@/types/lighthouse"; +import type { LighthouseProvider } from "@/types/lighthouse-v1"; interface ConnectLLMLayoutProps { children: React.ReactNode; diff --git a/ui/app/(prowler)/lighthouse/config/(connect-llm)/select-model/page.tsx b/ui/app/(prowler)/lighthouse/config/(connect-llm)/select-model/page.tsx index 2ce22b17334..6669c868afa 100644 --- a/ui/app/(prowler)/lighthouse/config/(connect-llm)/select-model/page.tsx +++ b/ui/app/(prowler)/lighthouse/config/(connect-llm)/select-model/page.tsx @@ -1,10 +1,10 @@ "use client"; -import { useRouter, useSearchParams } from "next/navigation"; +import { redirect, useRouter, useSearchParams } from "next/navigation"; import { Suspense } from "react"; -import { SelectModel } from "@/components/lighthouse/select-model"; -import type { LighthouseProvider } from "@/types/lighthouse"; +import { SelectModel } from "@/components/lighthouse-v1/select-model"; +import type { LighthouseProvider } from "@/types/lighthouse-v1"; function SelectModelContent() { const searchParams = useSearchParams(); @@ -26,6 +26,10 @@ function SelectModelContent() { } export default function SelectModelPage() { + if (process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true") { + redirect("/lighthouse/config"); + } + return ( Loading...}> diff --git a/ui/app/(prowler)/lighthouse/config/page.tsx b/ui/app/(prowler)/lighthouse/config/page.tsx index 1cf09496f60..a29fd015a33 100644 --- a/ui/app/(prowler)/lighthouse/config/page.tsx +++ b/ui/app/(prowler)/lighthouse/config/page.tsx @@ -1,11 +1,69 @@ import { Spacer } from "@heroui/spacer"; -import { LighthouseSettings, LLMProvidersTable } from "@/components/lighthouse"; +import { + getLighthouseV2Configurations, + getLighthouseV2SupportedModels, + getLighthouseV2SupportedProviders, +} from "@/actions/lighthouse-v2/lighthouse-v2"; +import { + LighthouseSettings, + LLMProvidersTable, +} from "@/components/lighthouse-v1"; +import { LighthouseV2ConfigPage } from "@/components/lighthouse-v2/config"; import { ContentLayout } from "@/components/ui"; +import { isCloud } from "@/lib/shared/env"; +import type { + LighthouseV2ProviderType, + LighthouseV2SupportedModel, +} from "@/types/lighthouse-v2"; export const dynamic = "force-dynamic"; export default async function ChatbotConfigPage() { + if (isCloud()) { + const [configurationsResult, providersResult] = await Promise.all([ + getLighthouseV2Configurations(), + getLighthouseV2SupportedProviders(), + ]); + + const providers = "data" in providersResult ? providersResult.data : []; + const modelsEntries = await Promise.all( + providers.map(async (provider) => { + const result = await getLighthouseV2SupportedModels(provider.id); + return [ + provider.id, + "data" in result ? result.data : [], + ] as const satisfies readonly [ + LighthouseV2ProviderType, + LighthouseV2SupportedModel[], + ]; + }), + ); + const modelsByProvider = Object.fromEntries(modelsEntries) as Record< + LighthouseV2ProviderType, + LighthouseV2SupportedModel[] + >; + const error = + "error" in configurationsResult + ? configurationsResult.error + : "error" in providersResult + ? providersResult.error + : undefined; + + return ( + + + + ); + } + return ( diff --git a/ui/app/(prowler)/lighthouse/page.tsx b/ui/app/(prowler)/lighthouse/page.tsx index eefca03e6b7..f72bc8195d1 100644 --- a/ui/app/(prowler)/lighthouse/page.tsx +++ b/ui/app/(prowler)/lighthouse/page.tsx @@ -3,10 +3,22 @@ import { redirect } from "next/navigation"; import { getLighthouseProvidersConfig, isLighthouseConfigured, -} from "@/actions/lighthouse/lighthouse"; +} from "@/actions/lighthouse-v1/lighthouse"; +import { + getLighthouseV2Configurations, + getLighthouseV2Messages, + getLighthouseV2Sessions, + getLighthouseV2SupportedModels, +} from "@/actions/lighthouse-v2/lighthouse-v2"; import { LighthouseIcon } from "@/components/icons/Icons"; -import { Chat } from "@/components/lighthouse"; +import { Chat } from "@/components/lighthouse-v1"; +import { LighthouseV2ChatPage } from "@/components/lighthouse-v2/chat"; import { ContentLayout } from "@/components/ui"; +import { isCloud } from "@/lib/shared/env"; +import type { + LighthouseV2ProviderType, + LighthouseV2SupportedModel, +} from "@/types/lighthouse-v2"; export const dynamic = "force-dynamic"; @@ -18,6 +30,65 @@ export default async function AIChatbot({ const params = await searchParams; const initialPrompt = typeof params.prompt === "string" ? params.prompt : undefined; + const activeSessionId = + typeof params.session === "string" ? params.session : undefined; + + if (isCloud()) { + const [configurationsResult, sessionsResult] = await Promise.all([ + getLighthouseV2Configurations(), + getLighthouseV2Sessions(), + ]); + + const configurations = + "data" in configurationsResult ? configurationsResult.data : []; + const connectedConfigurations = configurations.filter( + (configuration) => configuration.connected === true, + ); + + if (connectedConfigurations.length === 0) { + return redirect("/lighthouse/config"); + } + + const modelsEntries = await Promise.all( + configurations.map(async (configuration) => { + const result = await getLighthouseV2SupportedModels( + configuration.providerType, + ); + return [ + configuration.providerType, + "data" in result ? result.data : [], + ] as const satisfies readonly [ + LighthouseV2ProviderType, + LighthouseV2SupportedModel[], + ]; + }), + ); + const modelsByProvider = Object.fromEntries(modelsEntries) as Record< + LighthouseV2ProviderType, + LighthouseV2SupportedModel[] + >; + const initialMessages = + activeSessionId && "data" in sessionsResult + ? await getLighthouseV2Messages(activeSessionId) + : { data: [] }; + + return ( + }> +
+ +
+
+ ); + } const hasConfig = await isLighthouseConfigured(); diff --git a/ui/app/api/lighthouse/analyst/route.ts b/ui/app/api/lighthouse/analyst/route.ts index d2f3ba5ece5..aed006d4d92 100644 --- a/ui/app/api/lighthouse/analyst/route.ts +++ b/ui/app/api/lighthouse/analyst/route.ts @@ -1,7 +1,7 @@ import * as Sentry from "@sentry/nextjs"; import { createUIMessageStreamResponse, UIMessage } from "ai"; -import { getTenantConfig } from "@/actions/lighthouse/lighthouse"; +import { getTenantConfig } from "@/actions/lighthouse-v1/lighthouse"; import { auth } from "@/auth.config"; import { getErrorMessage } from "@/lib/helper"; import { @@ -14,14 +14,14 @@ import { handleChatModelStreamEvent, handleToolEvent, STREAM_MESSAGE_ID, -} from "@/lib/lighthouse/analyst-stream"; -import { authContextStorage } from "@/lib/lighthouse/auth-context"; -import { getCurrentDataSection } from "@/lib/lighthouse/data"; -import { convertVercelMessageToLangChainMessage } from "@/lib/lighthouse/utils"; +} from "@/lib/lighthouse-v1/analyst-stream"; +import { authContextStorage } from "@/lib/lighthouse-v1/auth-context"; +import { getCurrentDataSection } from "@/lib/lighthouse-v1/data"; +import { convertVercelMessageToLangChainMessage } from "@/lib/lighthouse-v1/utils"; import { initLighthouseWorkflow, type RuntimeConfig, -} from "@/lib/lighthouse/workflow"; +} from "@/lib/lighthouse-v1/workflow"; import { SentryErrorSource, SentryErrorType } from "@/sentry"; export async function POST(req: Request) { diff --git a/ui/components/lighthouse/ai-elements/actions.tsx b/ui/components/ai-elements/actions.tsx similarity index 100% rename from ui/components/lighthouse/ai-elements/actions.tsx rename to ui/components/ai-elements/actions.tsx diff --git a/ui/components/lighthouse/ai-elements/dropdown-menu.tsx b/ui/components/ai-elements/dropdown-menu.tsx similarity index 100% rename from ui/components/lighthouse/ai-elements/dropdown-menu.tsx rename to ui/components/ai-elements/dropdown-menu.tsx diff --git a/ui/components/lighthouse/ai-elements/input-group.tsx b/ui/components/ai-elements/input-group.tsx similarity index 100% rename from ui/components/lighthouse/ai-elements/input-group.tsx rename to ui/components/ai-elements/input-group.tsx diff --git a/ui/components/lighthouse/ai-elements/input.tsx b/ui/components/ai-elements/input.tsx similarity index 100% rename from ui/components/lighthouse/ai-elements/input.tsx rename to ui/components/ai-elements/input.tsx diff --git a/ui/components/lighthouse/ai-elements/prompt-input.tsx b/ui/components/ai-elements/prompt-input.tsx similarity index 100% rename from ui/components/lighthouse/ai-elements/prompt-input.tsx rename to ui/components/ai-elements/prompt-input.tsx diff --git a/ui/components/lighthouse/ai-elements/select.tsx b/ui/components/ai-elements/select.tsx similarity index 100% rename from ui/components/lighthouse/ai-elements/select.tsx rename to ui/components/ai-elements/select.tsx diff --git a/ui/components/lighthouse/ai-elements/textarea.tsx b/ui/components/ai-elements/textarea.tsx similarity index 100% rename from ui/components/lighthouse/ai-elements/textarea.tsx rename to ui/components/ai-elements/textarea.tsx diff --git a/ui/components/lighthouse/ai-elements/tooltip.tsx b/ui/components/ai-elements/tooltip.tsx similarity index 100% rename from ui/components/lighthouse/ai-elements/tooltip.tsx rename to ui/components/ai-elements/tooltip.tsx diff --git a/ui/components/lighthouse/banner-client.tsx b/ui/components/lighthouse-v1/banner-client.tsx similarity index 100% rename from ui/components/lighthouse/banner-client.tsx rename to ui/components/lighthouse-v1/banner-client.tsx diff --git a/ui/components/lighthouse/banner.tsx b/ui/components/lighthouse-v1/banner.tsx similarity index 78% rename from ui/components/lighthouse/banner.tsx rename to ui/components/lighthouse-v1/banner.tsx index b36ad760b06..d334b1df4b0 100644 --- a/ui/components/lighthouse/banner.tsx +++ b/ui/components/lighthouse-v1/banner.tsx @@ -1,4 +1,4 @@ -import { isLighthouseConfigured } from "@/actions/lighthouse/lighthouse"; +import { isLighthouseConfigured } from "@/actions/lighthouse-v1/lighthouse"; import { LighthouseBannerClient } from "./banner-client"; diff --git a/ui/components/lighthouse/chain-of-thought-display.tsx b/ui/components/lighthouse-v1/chain-of-thought-display.tsx similarity index 97% rename from ui/components/lighthouse/chain-of-thought-display.tsx rename to ui/components/lighthouse-v1/chain-of-thought-display.tsx index 6bd226d80eb..df89e9e0857 100644 --- a/ui/components/lighthouse/chain-of-thought-display.tsx +++ b/ui/components/lighthouse-v1/chain-of-thought-display.tsx @@ -17,7 +17,7 @@ import { getChainOfThoughtHeaderText, getChainOfThoughtStepLabel, isMetaTool, -} from "@/components/lighthouse/chat-utils"; +} from "@/components/lighthouse-v1/chat-utils"; interface ChainOfThoughtDisplayProps { events: ChainOfThoughtEvent[]; diff --git a/ui/components/lighthouse/chat-utils.ts b/ui/components/lighthouse-v1/chat-utils.ts similarity index 98% rename from ui/components/lighthouse/chat-utils.ts rename to ui/components/lighthouse-v1/chat-utils.ts index 70ac9e0b05e..026197851f3 100644 --- a/ui/components/lighthouse/chat-utils.ts +++ b/ui/components/lighthouse-v1/chat-utils.ts @@ -10,8 +10,8 @@ import { MESSAGE_STATUS, META_TOOLS, SKILL_PREFIX, -} from "@/lib/lighthouse/constants"; -import type { ChainOfThoughtData, Message } from "@/lib/lighthouse/types"; +} from "@/lib/lighthouse-v1/constants"; +import type { ChainOfThoughtData, Message } from "@/lib/lighthouse-v1/types"; // Re-export constants for convenience export { diff --git a/ui/components/lighthouse/chat.tsx b/ui/components/lighthouse-v1/chat.tsx similarity index 98% rename from ui/components/lighthouse/chat.tsx rename to ui/components/lighthouse-v1/chat.tsx index 6a8622df1b1..b9fbf07f716 100644 --- a/ui/components/lighthouse/chat.tsx +++ b/ui/components/lighthouse-v1/chat.tsx @@ -5,7 +5,7 @@ import { DefaultChatTransport } from "ai"; import { Plus } from "lucide-react"; import { useRef, useState } from "react"; -import { getLighthouseModelIds } from "@/actions/lighthouse/lighthouse"; +import { getLighthouseModelIds } from "@/actions/lighthouse-v1/lighthouse"; import { Conversation, ConversationContent, @@ -18,14 +18,14 @@ import { PromptInputTextarea, PromptInputToolbar, PromptInputTools, -} from "@/components/lighthouse/ai-elements/prompt-input"; +} from "@/components/ai-elements/prompt-input"; import { ERROR_PREFIX, MESSAGE_ROLES, MESSAGE_STATUS, -} from "@/components/lighthouse/chat-utils"; -import { Loader } from "@/components/lighthouse/loader"; -import { MessageItem } from "@/components/lighthouse/message-item"; +} from "@/components/lighthouse-v1/chat-utils"; +import { Loader } from "@/components/lighthouse-v1/loader"; +import { MessageItem } from "@/components/lighthouse-v1/message-item"; import { Button, Card, @@ -38,7 +38,7 @@ import { import { useToast } from "@/components/ui"; import { CustomLink } from "@/components/ui/custom/custom-link"; import { useMountEffect } from "@/hooks/use-mount-effect"; -import type { LighthouseProvider } from "@/types/lighthouse"; +import type { LighthouseProvider } from "@/types/lighthouse-v1"; interface Model { id: string; diff --git a/ui/components/lighthouse/connect-llm-provider.tsx b/ui/components/lighthouse-v1/connect-llm-provider.tsx similarity index 99% rename from ui/components/lighthouse/connect-llm-provider.tsx rename to ui/components/lighthouse-v1/connect-llm-provider.tsx index f70051fa38d..347540826f5 100644 --- a/ui/components/lighthouse/connect-llm-provider.tsx +++ b/ui/components/lighthouse-v1/connect-llm-provider.tsx @@ -7,9 +7,9 @@ import { createLighthouseProvider, getLighthouseProviderByType, updateLighthouseProviderByType, -} from "@/actions/lighthouse/lighthouse"; +} from "@/actions/lighthouse-v1/lighthouse"; import { FormButtons } from "@/components/ui/form"; -import type { LighthouseProvider } from "@/types/lighthouse"; +import type { LighthouseProvider } from "@/types/lighthouse-v1"; import { getMainFields, getProviderConfig } from "./llm-provider-registry"; import { diff --git a/ui/components/lighthouse/forms/delete-llm-provider-form.tsx b/ui/components/lighthouse-v1/forms/delete-llm-provider-form.tsx similarity index 95% rename from ui/components/lighthouse/forms/delete-llm-provider-form.tsx rename to ui/components/lighthouse-v1/forms/delete-llm-provider-form.tsx index 61d60369fa2..a528789ce6d 100644 --- a/ui/components/lighthouse/forms/delete-llm-provider-form.tsx +++ b/ui/components/lighthouse-v1/forms/delete-llm-provider-form.tsx @@ -6,11 +6,11 @@ import React, { Dispatch, SetStateAction } from "react"; import { useForm } from "react-hook-form"; import * as z from "zod"; -import { deleteLighthouseProviderByType } from "@/actions/lighthouse/lighthouse"; +import { deleteLighthouseProviderByType } from "@/actions/lighthouse-v1/lighthouse"; import { DeleteIcon } from "@/components/icons"; import { useToast } from "@/components/ui"; import { Form, FormButtons } from "@/components/ui/form"; -import type { LighthouseProvider } from "@/types/lighthouse"; +import type { LighthouseProvider } from "@/types/lighthouse-v1"; const formSchema = z.object({ providerType: z.string(), diff --git a/ui/components/lighthouse/index.ts b/ui/components/lighthouse-v1/index.ts similarity index 100% rename from ui/components/lighthouse/index.ts rename to ui/components/lighthouse-v1/index.ts diff --git a/ui/components/lighthouse/lighthouse-settings.tsx b/ui/components/lighthouse-v1/lighthouse-settings.tsx similarity index 98% rename from ui/components/lighthouse/lighthouse-settings.tsx rename to ui/components/lighthouse-v1/lighthouse-settings.tsx index 4fcd1afc860..48b4bf69d33 100644 --- a/ui/components/lighthouse/lighthouse-settings.tsx +++ b/ui/components/lighthouse-v1/lighthouse-settings.tsx @@ -9,7 +9,7 @@ import * as z from "zod"; import { getTenantConfig, updateTenantConfig, -} from "@/actions/lighthouse/lighthouse"; +} from "@/actions/lighthouse-v1/lighthouse"; import { SaveIcon } from "@/components/icons"; import { Button, diff --git a/ui/components/lighthouse/llm-provider-registry.ts b/ui/components/lighthouse-v1/llm-provider-registry.ts similarity index 97% rename from ui/components/lighthouse/llm-provider-registry.ts rename to ui/components/lighthouse-v1/llm-provider-registry.ts index 48bb8b9654f..4f91a222b40 100644 --- a/ui/components/lighthouse/llm-provider-registry.ts +++ b/ui/components/lighthouse-v1/llm-provider-registry.ts @@ -1,6 +1,6 @@ "use client"; -import type { LighthouseProvider } from "@/types/lighthouse"; +import type { LighthouseProvider } from "@/types/lighthouse-v1"; export type LLMProviderFieldType = "text" | "password"; diff --git a/ui/components/lighthouse/llm-provider-utils.ts b/ui/components/lighthouse-v1/llm-provider-utils.ts similarity index 97% rename from ui/components/lighthouse/llm-provider-utils.ts rename to ui/components/lighthouse-v1/llm-provider-utils.ts index 78362666f5c..c2d1240d86d 100644 --- a/ui/components/lighthouse/llm-provider-utils.ts +++ b/ui/components/lighthouse-v1/llm-provider-utils.ts @@ -4,10 +4,10 @@ import { getLighthouseProviderByType, refreshProviderModels, testProviderConnection, -} from "@/actions/lighthouse/lighthouse"; +} from "@/actions/lighthouse-v1/lighthouse"; import { getTask } from "@/actions/task/tasks"; import { checkTaskStatus } from "@/lib/helper"; -import type { LighthouseProvider } from "@/types/lighthouse"; +import type { LighthouseProvider } from "@/types/lighthouse-v1"; import { getProviderConfig } from "./llm-provider-registry"; diff --git a/ui/components/lighthouse/llm-providers-table.tsx b/ui/components/lighthouse-v1/llm-providers-table.tsx similarity index 99% rename from ui/components/lighthouse/llm-providers-table.tsx rename to ui/components/lighthouse-v1/llm-providers-table.tsx index 4ca3f9e0844..519195f6cbf 100644 --- a/ui/components/lighthouse/llm-providers-table.tsx +++ b/ui/components/lighthouse-v1/llm-providers-table.tsx @@ -7,7 +7,7 @@ import { useEffect, useState } from "react"; import { getLighthouseProviders, getTenantConfig, -} from "@/actions/lighthouse/lighthouse"; +} from "@/actions/lighthouse-v1/lighthouse"; import { Button, Card, CardContent, CardHeader } from "@/components/shadcn"; import { getAllProviders } from "./llm-provider-registry"; diff --git a/ui/components/lighthouse/loader.tsx b/ui/components/lighthouse-v1/loader.tsx similarity index 100% rename from ui/components/lighthouse/loader.tsx rename to ui/components/lighthouse-v1/loader.tsx diff --git a/ui/components/lighthouse/message-item.tsx b/ui/components/lighthouse-v1/message-item.tsx similarity index 95% rename from ui/components/lighthouse/message-item.tsx rename to ui/components/lighthouse-v1/message-item.tsx index 033ab7a66d4..42f52e2b986 100644 --- a/ui/components/lighthouse/message-item.tsx +++ b/ui/components/lighthouse-v1/message-item.tsx @@ -6,16 +6,16 @@ import { Copy, RotateCcw } from "lucide-react"; import { defaultRehypePlugins, Streamdown } from "streamdown"; -import { Action, Actions } from "@/components/lighthouse/ai-elements/actions"; -import { ChainOfThoughtDisplay } from "@/components/lighthouse/chain-of-thought-display"; +import { Action, Actions } from "@/components/ai-elements/actions"; +import { ChainOfThoughtDisplay } from "@/components/lighthouse-v1/chain-of-thought-display"; import { extractChainOfThoughtEvents, extractMessageText, type Message, MESSAGE_ROLES, MESSAGE_STATUS, -} from "@/components/lighthouse/chat-utils"; -import { Loader } from "@/components/lighthouse/loader"; +} from "@/components/lighthouse-v1/chat-utils"; +import { Loader } from "@/components/lighthouse-v1/loader"; /** * Escapes angle-bracket placeholders like to HTML entities diff --git a/ui/components/lighthouse/select-bedrock-auth-method.tsx b/ui/components/lighthouse-v1/select-bedrock-auth-method.tsx similarity index 100% rename from ui/components/lighthouse/select-bedrock-auth-method.tsx rename to ui/components/lighthouse-v1/select-bedrock-auth-method.tsx diff --git a/ui/components/lighthouse/select-model.tsx b/ui/components/lighthouse-v1/select-model.tsx similarity index 98% rename from ui/components/lighthouse/select-model.tsx rename to ui/components/lighthouse-v1/select-model.tsx index 5f26036a444..40d8fd308e8 100644 --- a/ui/components/lighthouse/select-model.tsx +++ b/ui/components/lighthouse-v1/select-model.tsx @@ -7,9 +7,9 @@ import { getLighthouseModelIds, getTenantConfig, updateTenantConfig, -} from "@/actions/lighthouse/lighthouse"; +} from "@/actions/lighthouse-v1/lighthouse"; import { Button } from "@/components/shadcn"; -import type { LighthouseProvider } from "@/types/lighthouse"; +import type { LighthouseProvider } from "@/types/lighthouse-v1"; import { getProviderIdByType, diff --git a/ui/components/lighthouse/workflow/index.ts b/ui/components/lighthouse-v1/workflow/index.ts similarity index 100% rename from ui/components/lighthouse/workflow/index.ts rename to ui/components/lighthouse-v1/workflow/index.ts diff --git a/ui/components/lighthouse/workflow/workflow-connect-llm.tsx b/ui/components/lighthouse-v1/workflow/workflow-connect-llm.tsx similarity index 98% rename from ui/components/lighthouse/workflow/workflow-connect-llm.tsx rename to ui/components/lighthouse-v1/workflow/workflow-connect-llm.tsx index 455e244fca2..10c9ab3de2f 100644 --- a/ui/components/lighthouse/workflow/workflow-connect-llm.tsx +++ b/ui/components/lighthouse-v1/workflow/workflow-connect-llm.tsx @@ -6,7 +6,7 @@ import { usePathname, useSearchParams } from "next/navigation"; import React from "react"; import { cn } from "@/lib/utils"; -import type { LighthouseProvider } from "@/types/lighthouse"; +import type { LighthouseProvider } from "@/types/lighthouse-v1"; import { getProviderConfig } from "../llm-provider-registry"; diff --git a/ui/components/lighthouse-v2/chat/index.ts b/ui/components/lighthouse-v2/chat/index.ts new file mode 100644 index 00000000000..2c634ab620a --- /dev/null +++ b/ui/components/lighthouse-v2/chat/index.ts @@ -0,0 +1 @@ +export { LighthouseV2ChatPage } from "./lighthouse-v2-chat-page"; diff --git a/ui/components/lighthouse-v2/chat/lighthouse-v2-chat-page.tsx b/ui/components/lighthouse-v2/chat/lighthouse-v2-chat-page.tsx new file mode 100644 index 00000000000..3716e8152de --- /dev/null +++ b/ui/components/lighthouse-v2/chat/lighthouse-v2-chat-page.tsx @@ -0,0 +1,633 @@ +"use client"; + +import { Bot, Loader2, Send, Square, UserRound } from "lucide-react"; +import { useRouter } from "next/navigation"; +import { type FormEvent, useRef, useState } from "react"; + +import { + archiveLighthouseV2Session, + cancelLighthouseV2Run, + createLighthouseV2Session, + getLighthouseV2Messages, + getLighthouseV2Sessions, + sendLighthouseV2Message, +} from "@/actions/lighthouse-v2/lighthouse-v2"; +import { + Conversation, + ConversationContent, + ConversationEmptyState, + ConversationScrollButton, +} from "@/components/ai-elements/conversation"; +import { Button } from "@/components/shadcn/button/button"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/shadcn/select/select"; +import { Textarea } from "@/components/shadcn/textarea/textarea"; +import { useMountEffect } from "@/hooks/use-mount-effect"; +import { + createInitialLighthouseV2StreamState, + type LighthouseV2StreamState, + reduceLighthouseV2Event, +} from "@/lib/lighthouse-v2/event-reducer"; +import { cn } from "@/lib/utils"; +import { + LIGHTHOUSE_V2_MESSAGE_ROLE, + LIGHTHOUSE_V2_PART_TYPE, + LIGHTHOUSE_V2_PROVIDER_TYPE, + LIGHTHOUSE_V2_SSE_EVENT, + type LighthouseV2Configuration, + type LighthouseV2Message, + type LighthouseV2ProviderType, + type LighthouseV2Session, + type LighthouseV2SSEEvent, + type LighthouseV2SupportedModel, +} from "@/types/lighthouse-v2"; + +import { LighthouseV2SessionHistory } from "../history"; + +interface LighthouseV2ChatPageProps { + configurations: LighthouseV2Configuration[]; + modelsByProvider: Record< + LighthouseV2ProviderType, + LighthouseV2SupportedModel[] + >; + sessions: LighthouseV2Session[]; + initialSessionId?: string; + initialMessages: LighthouseV2Message[]; + initialPrompt?: string; + showHistory?: boolean; +} + +export function LighthouseV2ChatPage({ + configurations, + modelsByProvider, + sessions, + initialSessionId, + initialMessages, + initialPrompt, + showHistory = true, +}: LighthouseV2ChatPageProps) { + const router = useRouter(); + const eventSourceRef = useRef(null); + const initialPromptSentRef = useRef(false); + const connectedConfigurations = configurations.filter( + (configuration) => configuration.connected === true, + ); + const initialProvider = + connectedConfigurations[0]?.providerType ?? + configurations[0]?.providerType ?? + LIGHTHOUSE_V2_PROVIDER_TYPE.OPENAI; + const [selectedProvider, setSelectedProvider] = + useState(initialProvider); + const [selectedModel, setSelectedModel] = useState( + connectedConfigurations[0]?.defaultModel ?? + modelsByProvider[initialProvider]?.[0]?.id ?? + "", + ); + const [localSessions, setLocalSessions] = useState(sessions); + const [activeSessionId, setActiveSessionId] = useState( + initialSessionId ?? null, + ); + const [messages, setMessages] = useState(initialMessages); + const [input, setInput] = useState(""); + const [search, setSearch] = useState(""); + const [feedback, setFeedback] = useState(null); + const [blockedByConflict, setBlockedByConflict] = useState(false); + const [lastSubmittedText, setLastSubmittedText] = useState( + null, + ); + const [streamState, setStreamState] = useState(() => + createInitialLighthouseV2StreamState(), + ); + + const selectedConfiguration = configurations.find( + (configuration) => configuration.providerType === selectedProvider, + ); + const providerModels = modelsByProvider[selectedProvider] ?? []; + const canSend = + selectedConfiguration?.connected === true && + !streamState.activeTaskId && + !blockedByConflict; + + const handleProviderChange = (provider: LighthouseV2ProviderType) => { + const nextConfig = configurations.find( + (configuration) => configuration.providerType === provider, + ); + setSelectedProvider(provider); + setSelectedModel( + nextConfig?.defaultModel ?? modelsByProvider[provider]?.[0]?.id ?? "", + ); + }; + + const refreshMessages = async (sessionId: string) => { + const result = await getLighthouseV2Messages(sessionId); + if ("data" in result) { + setMessages(result.data); + } + }; + + const refreshSessions = async (nextSearch = search) => { + const result = await getLighthouseV2Sessions( + nextSearch ? { search: nextSearch } : undefined, + ); + if ("data" in result) { + setLocalSessions(result.data); + } + }; + + const closeStream = () => { + eventSourceRef.current?.close(); + eventSourceRef.current = null; + }; + + const handleTerminalEvent = async ( + sessionId: string, + event: LighthouseV2SSEEvent, + ) => { + if ( + event.type === LIGHTHOUSE_V2_SSE_EVENT.MESSAGE_END || + event.type === LIGHTHOUSE_V2_SSE_EVENT.RUN_CANCELLED || + event.type === LIGHTHOUSE_V2_SSE_EVENT.ERROR + ) { + closeStream(); + setBlockedByConflict(false); + await refreshMessages(sessionId); + await refreshSessions(); + } + }; + + const startStream = (streamUrl: string, sessionId: string) => { + closeStream(); + const source = new EventSource(streamUrl); + eventSourceRef.current = source; + + const applyEvent = (event: LighthouseV2SSEEvent) => { + setStreamState((current) => reduceLighthouseV2Event(current, event)); + void handleTerminalEvent(sessionId, event); + }; + + source.addEventListener("message.delta", (event) => + applyEvent( + parseStreamEvent(event, LIGHTHOUSE_V2_SSE_EVENT.MESSAGE_DELTA), + ), + ); + source.addEventListener("tool_call.start", (event) => + applyEvent( + parseStreamEvent(event, LIGHTHOUSE_V2_SSE_EVENT.TOOL_CALL_START), + ), + ); + source.addEventListener("tool_call.end", (event) => + applyEvent( + parseStreamEvent(event, LIGHTHOUSE_V2_SSE_EVENT.TOOL_CALL_END), + ), + ); + source.addEventListener("message.end", (event) => + applyEvent(parseStreamEvent(event, LIGHTHOUSE_V2_SSE_EVENT.MESSAGE_END)), + ); + source.addEventListener("run.cancelled", (event) => + applyEvent( + parseStreamEvent(event, LIGHTHOUSE_V2_SSE_EVENT.RUN_CANCELLED), + ), + ); + source.addEventListener("error", (event) => { + if (event instanceof MessageEvent) { + applyEvent(parseStreamEvent(event, LIGHTHOUSE_V2_SSE_EVENT.ERROR)); + } + }); + source.onerror = () => { + closeStream(); + setStreamState((current) => + reduceLighthouseV2Event(current, { type: "disconnect" }), + ); + void refreshMessages(sessionId); + setFeedback("Stream disconnected. Messages were refreshed."); + }; + }; + + const ensureSession = async (text: string) => { + if (activeSessionId) { + return activeSessionId; + } + + const title = buildSessionTitle(text); + const result = await createLighthouseV2Session(title); + if ("error" in result) { + setFeedback(result.error); + return null; + } + + setActiveSessionId(result.data.id); + setLocalSessions((current) => [result.data, ...current]); + router.push(`/lighthouse?session=${encodeURIComponent(result.data.id)}`); + return result.data.id; + }; + + const submitMessage = async (text: string) => { + const trimmedText = text.trim(); + if (!trimmedText || !canSend) return; + + const sessionId = await ensureSession(trimmedText); + if (!sessionId) return; + + setFeedback(null); + setBlockedByConflict(false); + setLastSubmittedText(trimmedText); + setInput(""); + setMessages((current) => [ + ...current, + buildOptimisticMessage("user", trimmedText), + ]); + + const result = await sendLighthouseV2Message({ + sessionId, + text: trimmedText, + provider: selectedProvider, + model: selectedModel || null, + }); + + if ("error" in result) { + setFeedback(result.error); + if (result.status === 409) { + setBlockedByConflict(true); + await refreshMessages(sessionId); + } + return; + } + + setStreamState(createInitialLighthouseV2StreamState(result.data.task.id)); + if (result.data.streamUrl) { + startStream(result.data.streamUrl, sessionId); + } + await refreshSessions(); + }; + + const handleSubmit = (event: FormEvent) => { + event.preventDefault(); + void submitMessage(input); + }; + + const handleStop = async () => { + if (!activeSessionId || !streamState.activeTaskId) return; + const taskId = streamState.activeTaskId; + const result = await cancelLighthouseV2Run(activeSessionId, taskId); + closeStream(); + setStreamState((current) => + reduceLighthouseV2Event(current, { + type: "run.cancelled", + taskId, + }), + ); + setBlockedByConflict(false); + await refreshMessages(activeSessionId); + if ("error" in result) { + setFeedback(result.error); + } + }; + + const handleOpenSession = async (sessionId: string) => { + closeStream(); + setActiveSessionId(sessionId); + setStreamState(createInitialLighthouseV2StreamState()); + setBlockedByConflict(false); + setFeedback(null); + router.push(`/lighthouse?session=${encodeURIComponent(sessionId)}`); + await refreshMessages(sessionId); + }; + + const handleNewSession = () => { + closeStream(); + setActiveSessionId(null); + setMessages([]); + setInput(""); + setFeedback(null); + setBlockedByConflict(false); + setStreamState(createInitialLighthouseV2StreamState()); + router.push("/lighthouse"); + }; + + const handleArchiveSession = async (sessionId: string) => { + const result = await archiveLighthouseV2Session(sessionId); + if ("error" in result) { + setFeedback(result.error); + return; + } + setLocalSessions((current) => + current.filter((session) => session.id !== sessionId), + ); + if (sessionId === activeSessionId) { + handleNewSession(); + } + }; + + const handleSearchChange = (value: string) => { + setSearch(value); + void refreshSessions(value); + }; + + useMountEffect(() => { + if (initialPrompt && !initialPromptSentRef.current) { + initialPromptSentRef.current = true; + void submitMessage(initialPrompt); + } + }); + + return ( +
+ {showHistory && ( + + )} + +
+ + + {messages.length === 0 && !streamState.assistantText ? ( + + ) : ( + <> + {messages.map((message) => ( + + ))} + {streamState.assistantText && ( + + )} + + )} + + + + +
+ {feedback && ( +
+ {feedback} + {streamState.status === "disconnected" && lastSubmittedText && ( + + )} +
+ )} + +
+ + +
+ +
+