feat: Update Lighthouse UI to support multi LLM (#8925)

Co-authored-by: Chandrapal Badshah <12944530+Chan9390@users.noreply.github.com>
Co-authored-by: Alan Buscaglia <gentlemanprogramming@gmail.com>
Co-authored-by: alejandrobailo <alejandrobailo94@gmail.com>
This commit is contained in:
Chandrapal Badshah
2025-11-14 16:16:38 +05:30
committed by GitHub
parent 866edfb167
commit 031548ca7e
46 changed files with 8299 additions and 1003 deletions

View File

@@ -7,6 +7,7 @@ All notable changes to the **Prowler UI** are documented in this file.
### 🚀 Added
- RSS feeds support [(#9109)](https://github.com/prowler-cloud/prowler/pull/9109)
- Multi LLM support to Lighthouse AI [(#8925)](https://github.com/prowler-cloud/prowler/pull/8925)
- Customer Support menu item [(#9143)](https://github.com/prowler-cloud/prowler/pull/9143)
- IaC (Infrastructure as Code) provider support for scanning remote repositories [(#8751)](https://github.com/prowler-cloud/prowler/pull/8751)
- External resource link to IaC findings for direct navigation to source code in Git repositories [(#9151)](https://github.com/prowler-cloud/prowler/pull/9151)

View File

@@ -1,72 +1,117 @@
"use server";
import { revalidatePath } from "next/cache";
import { apiBaseUrl, getAuthHeaders } from "@/lib/helper";
import {
validateBaseUrl,
validateCredentials,
} from "@/lib/lighthouse/validation";
import { handleApiError, handleApiResponse } from "@/lib/server-actions-helper";
import {
type LighthouseProvider,
PROVIDER_DISPLAY_NAMES,
} from "@/types/lighthouse";
import type {
BedrockCredentials,
OpenAICompatibleCredentials,
OpenAICredentials,
} from "@/types/lighthouse/credentials";
export const getAIKey = async (): Promise<string> => {
const headers = await getAuthHeaders({ contentType: false });
const url = new URL(
`${apiBaseUrl}/lighthouse-configurations?fields[lighthouse-config]=api_key`,
);
// API Response Types
type ProviderCredentials =
| OpenAICredentials
| BedrockCredentials
| OpenAICompatibleCredentials;
try {
const response = await fetch(url.toString(), {
method: "GET",
headers,
});
interface ApiError {
detail: string;
}
const data = await response.json();
interface ApiLinks {
next?: string;
}
// Check if data array exists and has at least one item
if (data?.data && data.data.length > 0) {
return data.data[0].attributes.api_key || "";
}
interface ApiResponse<T> {
data?: T;
errors?: ApiError[];
links?: ApiLinks;
}
// Return empty string if no configuration found
return "";
} catch (error) {
console.error("[Server] Error in getAIKey:", error);
return "";
}
};
interface LighthouseModelAttributes {
model_id: string;
model_name: string;
}
export const checkLighthouseConnection = async (configId: string) => {
const headers = await getAuthHeaders({ contentType: false });
const url = new URL(
`${apiBaseUrl}/lighthouse-configurations/${configId}/connection`,
);
interface LighthouseModel {
id: string;
attributes: LighthouseModelAttributes;
}
try {
const response = await fetch(url.toString(), {
method: "POST",
headers,
});
interface LighthouseProviderAttributes {
provider_type: string;
credentials: ProviderCredentials;
base_url?: string;
is_active: boolean;
}
const data = await response.json();
return data;
} catch (error) {
console.error("[Server] Error in checkLighthouseConnection:", error);
return undefined;
}
};
interface LighthouseProviderResource {
id: string;
attributes: LighthouseProviderAttributes;
}
export const createLighthouseConfig = async (config: {
model: string;
apiKey: string;
businessContext: string;
interface ModelOption {
id: string;
name: string;
}
interface ProviderCredentialsAttributes {
credentials: ProviderCredentials;
base_url?: string;
}
interface ProviderCredentialsResponse {
attributes: ProviderCredentialsAttributes;
}
/**
* Create a new lighthouse provider configuration
*/
export const createLighthouseProvider = async (config: {
provider_type: LighthouseProvider;
credentials: ProviderCredentials;
base_url?: string;
}) => {
const headers = await getAuthHeaders({ contentType: true });
const url = new URL(`${apiBaseUrl}/lighthouse-configurations`);
const url = new URL(`${apiBaseUrl}/lighthouse/providers`);
try {
// Validate credentials
const credentialsValidation = validateCredentials(
config.provider_type,
config.credentials,
);
if (!credentialsValidation.success) {
return {
errors: [{ detail: credentialsValidation.error }],
};
}
// Validate base_url if provided
if (config.base_url) {
const baseUrlValidation = validateBaseUrl(config.base_url);
if (!baseUrlValidation.success) {
return {
errors: [{ detail: baseUrlValidation.error }],
};
}
}
const payload = {
data: {
type: "lighthouse-configurations",
type: "lighthouse-providers",
attributes: {
name: "OpenAI",
model: config.model,
api_key: config.apiKey,
business_context: config.businessContext,
provider_type: config.provider_type,
credentials: config.credentials,
base_url: config.base_url || null,
},
},
};
@@ -76,100 +121,486 @@ export const createLighthouseConfig = async (config: {
headers,
body: JSON.stringify(payload),
});
const data = await response.json();
// Trigger connection check in background
if (data?.data?.id) {
checkLighthouseConnection(data.data.id);
}
revalidatePath("/");
return data;
return handleApiResponse(response, "/lighthouse/config");
} catch (error) {
console.error("[Server] Error in createLighthouseConfig:", error);
return undefined;
return handleApiError(error);
}
};
export const getLighthouseConfig = async () => {
/**
* Test provider connection (returns task)
*/
export const testProviderConnection = async (providerId: string) => {
const headers = await getAuthHeaders({ contentType: false });
const url = new URL(`${apiBaseUrl}/lighthouse-configurations`);
const url = new URL(
`${apiBaseUrl}/lighthouse/providers/${providerId}/connection`,
);
try {
const response = await fetch(url.toString(), {
method: "POST",
headers,
});
return handleApiResponse(response);
} catch (error) {
return handleApiError(error);
}
};
/**
* Refresh provider models (returns task)
*/
export const refreshProviderModels = async (providerId: string) => {
const headers = await getAuthHeaders({ contentType: false });
const url = new URL(
`${apiBaseUrl}/lighthouse/providers/${providerId}/refresh-models`,
);
try {
const response = await fetch(url.toString(), {
method: "POST",
headers,
});
return handleApiResponse(response);
} catch (error) {
return handleApiError(error);
}
};
/**
* Get all lighthouse providers
*/
export const getLighthouseProviders = async () => {
const headers = await getAuthHeaders({ contentType: false });
const url = new URL(`${apiBaseUrl}/lighthouse/providers`);
try {
const response = await fetch(url.toString(), {
method: "GET",
headers,
});
const data = await response.json();
// Check if data array exists and has at least one item
if (data?.data && data.data.length > 0) {
return data.data[0].attributes;
}
return undefined;
return handleApiResponse(response);
} catch (error) {
console.error("[Server] Error in getLighthouseConfig:", error);
return undefined;
return handleApiError(error);
}
};
export const updateLighthouseConfig = async (config: {
model: string;
apiKey: string;
businessContext: string;
}) => {
const headers = await getAuthHeaders({ contentType: true });
/**
* Get only model identifiers and names for a provider type.
* Uses sparse fieldsets to only fetch model_id and model_name, avoiding over-fetching.
* Fetches all pages automatically.
*/
export const getLighthouseModelIds = async (
providerType: LighthouseProvider,
) => {
const headers = await getAuthHeaders({ contentType: false });
const url = new URL(`${apiBaseUrl}/lighthouse/models`);
url.searchParams.set("filter[provider_type]", providerType);
url.searchParams.set("fields[lighthouse-models]", "model_id,model_name");
try {
// Fetch first page
const response = await fetch(url.toString(), {
method: "GET",
headers,
});
const data = await handleApiResponse(response);
if (data.errors) {
return data;
}
const allModels: LighthouseModel[] = [...(data.data || [])];
// Fetch remaining pages
let nextUrl = data.links?.next;
while (nextUrl) {
const pageResponse = await fetch(nextUrl, {
method: "GET",
headers,
});
const pageData = await handleApiResponse(pageResponse);
if (pageData.errors) {
return pageData;
}
if (pageData.data && Array.isArray(pageData.data)) {
allModels.push(...pageData.data);
}
nextUrl = pageData.links?.next;
}
// Transform to minimal format
const models: ModelOption[] = allModels
.map((m: LighthouseModel) => ({
id: m.attributes.model_id,
name: m.attributes.model_name || m.attributes.model_id,
}))
.filter((v: ModelOption) => typeof v.id === "string");
return { data: models };
} catch (error) {
return handleApiError(error);
}
};
/**
* Get tenant lighthouse configuration
*/
export const getTenantConfig = async () => {
const headers = await getAuthHeaders({ contentType: false });
const url = new URL(`${apiBaseUrl}/lighthouse/configuration`);
// Get the config ID from the list endpoint
const url = new URL(`${apiBaseUrl}/lighthouse-configurations`);
try {
const response = await fetch(url.toString(), {
method: "GET",
headers: await getAuthHeaders({ contentType: false }),
headers,
});
const data = await response.json();
return handleApiResponse(response);
} catch (error) {
return handleApiError(error);
}
};
// Check if data array exists and has at least one item
if (!data?.data || data.data.length === 0) {
return undefined;
}
/**
* Update tenant lighthouse configuration
*/
export const updateTenantConfig = async (config: {
default_models?: Record<string, string>;
default_provider?: LighthouseProvider;
business_context?: string;
}) => {
const headers = await getAuthHeaders({ contentType: true });
const url = new URL(`${apiBaseUrl}/lighthouse/configuration`);
const configId = data.data[0].id;
const updateUrl = new URL(
`${apiBaseUrl}/lighthouse-configurations/${configId}`,
);
// Prepare the request payload following the JSONAPI format
try {
const payload = {
data: {
type: "lighthouse-configurations",
id: configId,
attributes: {
model: config.model,
api_key: config.apiKey,
business_context: config.businessContext,
},
attributes: config,
},
};
const updateResponse = await fetch(updateUrl.toString(), {
const response = await fetch(url.toString(), {
method: "PATCH",
headers,
body: JSON.stringify(payload),
});
const updateData = await updateResponse.json();
// Trigger connection check in background
if (updateData?.data?.id || configId) {
checkLighthouseConnection(configId);
}
revalidatePath("/");
return updateData;
return handleApiResponse(response, "/lighthouse/config");
} catch (error) {
console.error("[Server] Error in updateLighthouseConfig:", error);
return undefined;
return handleApiError(error);
}
};
/**
* Get credentials and configuration from the specified provider (or first active provider)
* Returns an object containing:
* - credentials: varies by provider type
* - OpenAI: { api_key: string }
* - Bedrock: { access_key_id: string, secret_access_key: string, region: string }
* - OpenAI Compatible: { api_key: string }
* - base_url: string | undefined (for OpenAI Compatible providers)
*/
export const getProviderCredentials = async (
providerType?: LighthouseProvider,
): Promise<{
credentials: ProviderCredentials | Record<string, never>;
base_url?: string;
}> => {
const headers = await getAuthHeaders({ contentType: false });
// Note: fields[lighthouse-providers]=credentials is required to get decrypted credentials
// base_url is not sensitive and is returned by default
const url = new URL(`${apiBaseUrl}/lighthouse/providers`);
if (providerType) {
url.searchParams.append("filter[provider_type]", providerType);
}
url.searchParams.append("filter[is_active]", "true");
url.searchParams.append(
"fields[lighthouse-providers]",
"credentials,base_url",
);
try {
const response = await fetch(url.toString(), {
method: "GET",
headers,
});
const data: ApiResponse<ProviderCredentialsResponse[]> =
await response.json();
if (data?.data && data.data.length > 0) {
const provider = data.data[0]?.attributes;
return {
credentials: provider.credentials || {},
base_url: provider.base_url,
};
}
return { credentials: {} };
} catch (error) {
console.error("[Server] Error in getProviderCredentials:", error);
return { credentials: {} };
}
};
/**
* Check if lighthouse is properly configured
* Returns true if tenant config exists AND there's at least one active provider
*/
export const isLighthouseConfigured = async () => {
try {
const [tenantConfig, providers] = await Promise.all([
getTenantConfig(),
getLighthouseProviders(),
]);
const hasTenantConfig = !!tenantConfig?.data;
const hasActiveProvider =
providers?.data &&
Array.isArray(providers.data) &&
providers.data.some(
(p: LighthouseProviderResource) => p.attributes?.is_active,
);
return hasTenantConfig && hasActiveProvider;
} catch (error) {
console.error("[Server] Error in isLighthouseConfigured:", error);
return false;
}
};
/**
* Get a single lighthouse provider by provider type
* Server-side only - never exposes internal IDs to client
*/
export const getLighthouseProviderByType = async (
providerType: LighthouseProvider,
) => {
const headers = await getAuthHeaders({ contentType: false });
const url = new URL(`${apiBaseUrl}/lighthouse/providers`);
url.searchParams.set("filter[provider_type]", providerType);
try {
const response = await fetch(url.toString(), {
method: "GET",
headers,
});
const data = await handleApiResponse(response);
if (data.errors) {
return data;
}
// Should only be one config per provider type per tenant
if (data.data && data.data.length > 0) {
return { data: data.data[0] };
}
return { errors: [{ detail: "Provider configuration not found" }] };
} catch (error) {
return handleApiError(error);
}
};
/**
* Update a lighthouse provider configuration by provider type
* Looks up the provider server-side, never exposes ID to client
*/
export const updateLighthouseProviderByType = async (
providerType: LighthouseProvider,
config: {
credentials?: ProviderCredentials;
base_url?: string;
is_active?: boolean;
},
) => {
try {
// Validate credentials if provided
if (config.credentials && Object.keys(config.credentials).length > 0) {
const credentialsValidation = validateCredentials(
providerType,
config.credentials as Record<string, string>,
);
if (!credentialsValidation.success) {
return {
errors: [{ detail: credentialsValidation.error }],
};
}
}
// Validate base_url if provided
if (config.base_url) {
const baseUrlValidation = validateBaseUrl(config.base_url);
if (!baseUrlValidation.success) {
return {
errors: [{ detail: baseUrlValidation.error }],
};
}
}
// First, get the provider by type
const providerResult = await getLighthouseProviderByType(providerType);
if (providerResult.errors || !providerResult.data) {
return providerResult;
}
const providerId = providerResult.data.id;
// Now update it
const headers = await getAuthHeaders({ contentType: true });
const url = new URL(`${apiBaseUrl}/lighthouse/providers/${providerId}`);
const payload = {
data: {
type: "lighthouse-providers",
id: providerId,
attributes: config,
},
};
const response = await fetch(url.toString(), {
method: "PATCH",
headers,
body: JSON.stringify(payload),
});
return handleApiResponse(response, "/lighthouse/config");
} catch (error) {
return handleApiError(error);
}
};
/**
* Delete a lighthouse provider configuration by provider type
* Looks up the provider server-side, never exposes ID to client
*/
export const deleteLighthouseProviderByType = async (
providerType: LighthouseProvider,
) => {
try {
// First, get the provider by type
const providerResult = await getLighthouseProviderByType(providerType);
if (providerResult.errors || !providerResult.data) {
return providerResult;
}
const providerId = providerResult.data.id;
// Now delete it
const headers = await getAuthHeaders({ contentType: false });
const url = new URL(`${apiBaseUrl}/lighthouse/providers/${providerId}`);
const response = await fetch(url.toString(), {
method: "DELETE",
headers,
});
return handleApiResponse(response, "/lighthouse/config");
} catch (error) {
return handleApiError(error);
}
};
/**
* Get lighthouse providers configuration with default models
* Returns only the default model for each provider to avoid loading hundreds of models
*/
export const getLighthouseProvidersConfig = async () => {
try {
const [tenantConfig, providers] = await Promise.all([
getTenantConfig(),
getLighthouseProviders(),
]);
if (tenantConfig.errors || providers.errors) {
return {
errors: tenantConfig.errors || providers.errors,
};
}
const tenantData = tenantConfig?.data?.attributes;
const defaultProvider = tenantData?.default_provider || "";
const defaultModels = tenantData?.default_models || {};
// Filter only active providers
const activeProviders =
providers?.data?.filter(
(p: LighthouseProviderResource) => p.attributes?.is_active,
) || [];
// Build provider configuration with only default models
const providersConfig = await Promise.all(
activeProviders.map(async (provider: LighthouseProviderResource) => {
const providerType = provider.attributes
.provider_type as LighthouseProvider;
const defaultModelId = defaultModels[providerType];
// Fetch only the default model for this provider if it exists
let defaultModel = null;
if (defaultModelId) {
const headers = await getAuthHeaders({ contentType: false });
const url = new URL(`${apiBaseUrl}/lighthouse/models`);
url.searchParams.append("filter[provider_type]", providerType);
url.searchParams.append("filter[model_id]", defaultModelId);
try {
const response = await fetch(url.toString(), {
method: "GET",
headers,
});
const data = await response.json();
if (data.data && data.data.length > 0) {
defaultModel = {
id: data.data[0].attributes.model_id,
name:
data.data[0].attributes.model_name ||
data.data[0].attributes.model_id,
};
}
} catch (error) {
console.error(
`[Server] Error fetching default model for ${providerType}:`,
error,
);
}
}
return {
id: providerType,
name: PROVIDER_DISPLAY_NAMES[providerType],
models: defaultModel ? [defaultModel] : [],
};
}),
);
// Filter out providers with no models
const validProviders = providersConfig.filter((p) => p.models.length > 0);
return {
providers: validProviders,
defaultProviderId: defaultProvider,
defaultModelId: defaultModels[defaultProvider],
};
} catch (error) {
console.error("[Server] Error in getLighthouseProvidersConfig:", error);
return {
errors: [{ detail: String(error) }],
};
}
};

View File

@@ -10,7 +10,7 @@ export async function getLighthouseResources({
page?: number;
query?: string;
sort?: string;
filters?: any;
filters?: Record<string, string | number | boolean>;
fields?: string[];
}) {
const headers = await getAuthHeaders({ contentType: false });
@@ -62,7 +62,7 @@ export async function getLighthouseLatestResources({
page?: number;
query?: string;
sort?: string;
filters?: any;
filters?: Record<string, string | number | boolean>;
fields?: string[];
}) {
const headers = await getAuthHeaders({ contentType: false });

View File

@@ -0,0 +1,27 @@
"use client";
import { useSearchParams } from "next/navigation";
import { Suspense } from "react";
import { ConnectLLMProvider } from "@/components/lighthouse/connect-llm-provider";
import type { LighthouseProvider } from "@/types/lighthouse";
function ConnectContent() {
const searchParams = useSearchParams();
const provider = searchParams.get("provider") as LighthouseProvider | null;
const mode = searchParams.get("mode") || "create";
if (!provider) {
return null;
}
return <ConnectLLMProvider provider={provider} mode={mode} />;
}
export default function ConnectLLMProviderPage() {
return (
<Suspense fallback={<div>Loading...</div>}>
<ConnectContent />
</Suspense>
);
}

View File

@@ -0,0 +1,128 @@
"use client";
import "@/styles/globals.css";
import { Spacer } from "@heroui/spacer";
import { Icon } from "@iconify/react";
import { useRouter, useSearchParams } from "next/navigation";
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";
import { NavigationHeader } from "@/components/ui";
import { CustomAlertModal, CustomButton } from "@/components/ui/custom";
import type { LighthouseProvider } from "@/types/lighthouse";
interface ConnectLLMLayoutProps {
children: React.ReactNode;
}
export default function ConnectLLMLayout({ children }: ConnectLLMLayoutProps) {
const router = useRouter();
const searchParams = useSearchParams();
const mode = searchParams.get("mode");
const provider = searchParams.get("provider") as LighthouseProvider | null;
const isEditMode = mode === "edit";
const [isDeleteOpen, setIsDeleteOpen] = useState(false);
const [isDefaultProvider, setIsDefaultProvider] = useState(false);
// Check if current provider is the default
useEffect(() => {
const checkDefaultProvider = async () => {
if (!provider) return;
try {
const config = await getTenantConfig();
const defaultProvider = config.data?.attributes?.default_provider || "";
setIsDefaultProvider(provider === defaultProvider);
} catch (error) {
console.error("Error checking default provider:", error);
}
};
checkDefaultProvider();
}, [provider]);
const handleSetDefault = async () => {
if (!provider) return;
await updateTenantConfig({
default_provider: provider,
});
router.push("/lighthouse/config");
};
if (!provider) {
return null;
}
return (
<>
<CustomAlertModal
isOpen={isDeleteOpen}
onOpenChange={setIsDeleteOpen}
title="Are you absolutely sure?"
description="This action cannot be undone. This will permanently delete your LLM provider configuration and remove your data from the server."
>
<DeleteLLMProviderForm
providerType={provider}
setIsOpen={setIsDeleteOpen}
/>
</CustomAlertModal>
<NavigationHeader
title={isEditMode ? "Configure LLM Provider" : "Connect LLM Provider"}
icon="icon-park-outline:close-small"
href="/lighthouse/config"
/>
<Spacer y={8} />
<div className="grid grid-cols-1 gap-8 lg:grid-cols-12">
<div className="order-1 my-auto hidden h-full lg:col-span-4 lg:col-start-2 lg:block">
<WorkflowConnectLLM />
</div>
<div className="order-2 my-auto lg:col-span-5 lg:col-start-6">
{isEditMode && provider && (
<>
<div className="flex flex-wrap gap-2">
{!isDefaultProvider && (
<CustomButton
ariaLabel="Set as Default Provider"
variant="bordered"
size="sm"
startContent={
<Icon icon="heroicons:star" className="h-4 w-4" />
}
onPress={handleSetDefault}
className="w-full sm:w-auto"
>
Set as Default
</CustomButton>
)}
<CustomButton
ariaLabel="Delete Provider"
variant="bordered"
color="danger"
size="sm"
startContent={
<Icon icon="heroicons:trash" className="h-4 w-4" />
}
onPress={() => setIsDeleteOpen(true)}
className="w-full sm:w-auto"
>
Delete Provider
</CustomButton>
</div>
<Spacer y={4} />
</>
)}
{children}
</div>
</div>
</>
);
}

View File

@@ -0,0 +1,34 @@
"use client";
import { useRouter, useSearchParams } from "next/navigation";
import { Suspense } from "react";
import { SelectModel } from "@/components/lighthouse/select-model";
import type { LighthouseProvider } from "@/types/lighthouse";
function SelectModelContent() {
const searchParams = useSearchParams();
const router = useRouter();
const provider = searchParams.get("provider") as LighthouseProvider | null;
const mode = searchParams.get("mode") || "create";
if (!provider) {
return null;
}
return (
<SelectModel
provider={provider}
mode={mode}
onSelect={() => router.push("/lighthouse/config")}
/>
);
}
export default function SelectModelPage() {
return (
<Suspense fallback={<div>Loading...</div>}>
<SelectModelContent />
</Suspense>
);
}

View File

@@ -1,31 +1,16 @@
import { getLighthouseConfig } from "@/actions/lighthouse";
import { ChatbotConfig } from "@/components/lighthouse";
import { Spacer } from "@heroui/spacer";
import { LighthouseSettings, LLMProvidersTable } from "@/components/lighthouse";
import { ContentLayout } from "@/components/ui";
export const dynamic = "force-dynamic";
export default async function ChatbotConfigPage() {
const lighthouseConfig = await getLighthouseConfig();
const initialValues = lighthouseConfig
? {
model: lighthouseConfig.model,
apiKey: lighthouseConfig.api_key || "",
businessContext: lighthouseConfig.business_context || "",
}
: {
model: "gpt-4o",
apiKey: "",
businessContext: "",
};
const configExists = !!lighthouseConfig;
return (
<ContentLayout title="Settings">
<ChatbotConfig
initialValues={initialValues}
configExists={configExists}
/>
<ContentLayout title="LLM Configuration">
<LLMProvidersTable />
<Spacer y={8} />
<LighthouseSettings />
</ContentLayout>
);
}

View File

@@ -1,24 +1,36 @@
import { redirect } from "next/navigation";
import { getLighthouseConfig } from "@/actions/lighthouse/lighthouse";
import {
getLighthouseProvidersConfig,
isLighthouseConfigured,
} from "@/actions/lighthouse/lighthouse";
import { LighthouseIcon } from "@/components/icons/Icons";
import { Chat } from "@/components/lighthouse";
import { ContentLayout } from "@/components/ui";
export default async function AIChatbot() {
const lighthouseConfig = await getLighthouseConfig();
const hasConfig = !!lighthouseConfig;
const hasConfig = await isLighthouseConfigured();
if (!hasConfig) {
return redirect("/lighthouse/config");
}
const isActive = lighthouseConfig.is_active ?? false;
// Fetch provider configuration with default models
const providersConfig = await getLighthouseProvidersConfig();
// Handle errors or missing configuration
if (providersConfig.errors || !providersConfig.providers) {
return redirect("/lighthouse/config");
}
return (
<ContentLayout title="Lighthouse AI" icon={<LighthouseIcon />}>
<Chat hasConfig={hasConfig} isActive={isActive} />
<Chat
hasConfig={hasConfig}
providers={providersConfig.providers}
defaultProviderId={providersConfig.defaultProviderId}
defaultModelId={providersConfig.defaultModelId}
/>
</ContentLayout>
);
}

View File

@@ -2,19 +2,26 @@ import { toUIMessageStream } from "@ai-sdk/langchain";
import * as Sentry from "@sentry/nextjs";
import { createUIMessageStreamResponse, UIMessage } from "ai";
import { getLighthouseConfig } from "@/actions/lighthouse/lighthouse";
import { getTenantConfig } from "@/actions/lighthouse/lighthouse";
import { getErrorMessage } from "@/lib/helper";
import { getCurrentDataSection } from "@/lib/lighthouse/data";
import { convertVercelMessageToLangChainMessage } from "@/lib/lighthouse/utils";
import { initLighthouseWorkflow } from "@/lib/lighthouse/workflow";
import {
initLighthouseWorkflow,
type RuntimeConfig,
} from "@/lib/lighthouse/workflow";
import { SentryErrorSource, SentryErrorType } from "@/sentry";
export async function POST(req: Request) {
try {
const {
messages,
model,
provider,
}: {
messages: UIMessage[];
model?: string;
provider?: string;
} = await req.json();
if (!messages) {
@@ -25,8 +32,9 @@ export async function POST(req: Request) {
const processedMessages = [...messages];
// Get AI configuration to access business context
const lighthouseConfig = await getLighthouseConfig();
const businessContext = lighthouseConfig.business_context;
const tenantConfigResult = await getTenantConfig();
const businessContext =
tenantConfigResult?.data?.attributes?.business_context;
// Get current user data
const currentData = await getCurrentDataSection();
@@ -65,7 +73,13 @@ export async function POST(req: Request) {
// Insert all context messages at the beginning
processedMessages.unshift(...contextMessages);
const app = await initLighthouseWorkflow();
// Prepare runtime config with client-provided model
const runtimeConfig: RuntimeConfig = {
model,
provider,
};
const app = await initLighthouseWorkflow(runtimeConfig);
const agentStream = app.streamEvents(
{

View File

@@ -1,81 +0,0 @@
"use client";
import { Button } from "@heroui/button";
import type { PressEvent } from "@react-types/shared";
import { cn } from "@/lib/utils";
interface ActionsProps extends React.HTMLAttributes<HTMLDivElement> {
className?: string;
children?: React.ReactNode;
ref?: React.Ref<HTMLDivElement>;
}
const Actions = ({ className, children, ref, ...props }: ActionsProps) => {
return (
<div
ref={ref}
className={cn(
"border-default-200 bg-default-50 dark:border-default-100 dark:bg-default-100/50 flex flex-wrap items-center gap-2 rounded-lg border p-2",
className,
)}
{...props}
>
{children}
</div>
);
};
interface ActionProps {
/**
* Action label text
*/
label: string;
/**
* Optional icon component (Lucide React icon recommended)
*/
icon?: React.ReactNode;
/**
* Click handler
*/
onClick?: (e: PressEvent) => void;
/**
* Visual variant
* @default "light"
*/
variant?: "solid" | "bordered" | "light" | "flat" | "faded" | "shadow";
className?: string;
isDisabled?: boolean;
ref?: React.Ref<HTMLButtonElement>;
}
const Action = ({
label,
icon,
onClick,
variant = "light",
className,
isDisabled = false,
ref,
...props
}: ActionProps) => {
return (
<Button
ref={ref}
variant={variant}
size="sm"
onPress={onClick}
isDisabled={isDisabled}
className={cn(
"min-w-unit-16 gap-1.5 text-xs font-medium transition-all hover:scale-105",
className,
)}
startContent={icon}
{...props}
>
{label}
</Button>
);
};
export { Action, Actions };

View File

@@ -0,0 +1,67 @@
"use client";
import type { ComponentProps } from "react";
import { cn } from "@/lib/utils";
import { Button } from "./button";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "./tooltip";
export type ActionsProps = ComponentProps<"div">;
export const Actions = ({ className, children, ...props }: ActionsProps) => (
<div className={cn("flex items-center gap-1", className)} {...props}>
{children}
</div>
);
export type ActionProps = ComponentProps<typeof Button> & {
tooltip?: string;
label?: string;
};
export const Action = ({
tooltip,
children,
label,
className,
variant = "ghost",
size = "sm",
...props
}: ActionProps) => {
const button = (
<Button
className={cn(
"text-muted-foreground hover:text-foreground relative size-9 p-1.5",
className,
)}
size={size}
type="button"
variant={variant}
{...props}
>
{children}
<span className="sr-only">{label || tooltip}</span>
</Button>
);
if (tooltip) {
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent>
<p>{tooltip}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
return button;
};

View File

@@ -0,0 +1,60 @@
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import * as React from "react";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
"icon-sm": "size-8",
"icon-lg": "size-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
function Button({
className,
variant,
size,
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean;
}) {
const Comp = asChild ? Slot : "button";
return (
<Comp
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
);
}
export { Button, buttonVariants };

View File

@@ -0,0 +1,257 @@
"use client";
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react";
import * as React from "react";
import { cn } from "@/lib/utils";
function DropdownMenu({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />;
}
function DropdownMenuPortal({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return (
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
);
}
function DropdownMenuTrigger({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return (
<DropdownMenuPrimitive.Trigger
data-slot="dropdown-menu-trigger"
{...props}
/>
);
}
function DropdownMenuContent({
className,
sideOffset = 4,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
className,
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
);
}
function DropdownMenuGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return (
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
);
}
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean;
variant?: "default" | "destructive";
}) {
return (
<DropdownMenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
/>
);
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
return (
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
);
}
function DropdownMenuRadioGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return (
<DropdownMenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
);
}
function DropdownMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
return (
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
);
}
function DropdownMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean;
}) {
return (
<DropdownMenuPrimitive.Label
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
className,
)}
{...props}
/>
);
}
function DropdownMenuSeparator({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
return (
<DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("bg-border -mx-1 my-1 h-px", className)}
{...props}
/>
);
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className,
)}
{...props}
/>
);
}
function DropdownMenuSub({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />;
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean;
}) {
return (
<DropdownMenuPrimitive.SubTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger>
);
}
function DropdownMenuSubContent({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
return (
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
className,
)}
{...props}
/>
);
}
export {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuPortal,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
};

View File

@@ -0,0 +1,171 @@
"use client";
import { cva, type VariantProps } from "class-variance-authority";
import * as React from "react";
import { cn } from "@/lib/utils";
import { Button } from "./button";
import { Input } from "./input";
import { Textarea } from "./textarea";
function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="input-group"
role="group"
className={cn(
"group/input-group border-input dark:bg-input/30 relative flex w-full items-center rounded-md border shadow-xs transition-[color,box-shadow] outline-none",
"h-9 min-w-0 has-[>textarea]:h-auto",
// Variants based on alignment.
"has-[>[data-align=inline-start]]:[&>input]:pl-2",
"has-[>[data-align=inline-end]]:[&>input]:pr-2",
"has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>[data-align=block-start]]:[&>input]:pb-3",
"has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-end]]:[&>input]:pt-3",
// Focus state.
"has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot=input-group-control]:focus-visible]:ring-[3px]",
// Error state.
"has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[[data-slot][aria-invalid=true]]:border-destructive dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40",
className,
)}
{...props}
/>
);
}
const inputGroupAddonVariants = cva(
"text-muted-foreground flex h-auto cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium select-none [&>svg:not([class*='size-'])]:size-4 [&>kbd]:rounded-sm group-data-[disabled=true]/input-group:opacity-50",
{
variants: {
align: {
"inline-start":
"order-first pl-3 has-[>button]:ml-[-0.45rem] has-[>kbd]:ml-[-0.35rem]",
"inline-end":
"order-last pr-3 has-[>button]:mr-[-0.45rem] has-[>kbd]:mr-[-0.35rem]",
"block-start":
"order-first w-full justify-start px-3 pt-3 [.border-b]:pb-3 group-has-[>input]/input-group:pt-2.5",
"block-end":
"order-last w-full justify-start px-3 pb-3 [.border-t]:pt-3 group-has-[>input]/input-group:pb-2.5",
},
},
defaultVariants: {
align: "inline-start",
},
},
);
function InputGroupAddon({
className,
align = "inline-start",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof inputGroupAddonVariants>) {
return (
/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-noninteractive-element-interactions */
<div
role="group"
data-slot="input-group-addon"
data-align={align}
className={cn(inputGroupAddonVariants({ align }), className)}
onClick={(e) => {
if ((e.target as HTMLElement).closest("button")) {
return;
}
e.currentTarget.parentElement?.querySelector("input")?.focus();
}}
{...props}
/>
);
}
const inputGroupButtonVariants = cva(
"text-sm shadow-none flex gap-2 items-center",
{
variants: {
size: {
xs: "h-6 gap-1 px-2 rounded-sm [&>svg:not([class*='size-'])]:size-3.5 has-[>svg]:px-2",
sm: "h-8 px-2.5 gap-1.5 rounded-md has-[>svg]:px-2.5",
"icon-xs": "size-6 rounded-sm p-0 has-[>svg]:p-0",
"icon-sm": "size-8 p-0 has-[>svg]:p-0",
},
},
defaultVariants: {
size: "xs",
},
},
);
function InputGroupButton({
className,
type = "button",
variant = "ghost",
size = "xs",
...props
}: Omit<React.ComponentProps<typeof Button>, "size"> &
VariantProps<typeof inputGroupButtonVariants>) {
return (
<Button
type={type}
data-size={size}
variant={variant}
className={cn(inputGroupButtonVariants({ size }), className)}
{...props}
/>
);
}
function InputGroupText({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
className={cn(
"text-muted-foreground flex items-center gap-2 text-sm [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
/>
);
}
function InputGroupInput({
className,
...props
}: React.ComponentProps<"input">) {
return (
<Input
data-slot="input-group-control"
className={cn(
"flex-1 rounded-none border-0 bg-transparent shadow-none focus-visible:ring-0 dark:bg-transparent",
className,
)}
{...props}
/>
);
}
function InputGroupTextarea({
className,
...props
}: React.ComponentProps<"textarea">) {
return (
<Textarea
data-slot="input-group-control"
className={cn(
"flex-1 resize-none rounded-none border-0 bg-transparent py-3 shadow-none focus-visible:ring-0 dark:bg-transparent",
className,
)}
{...props}
/>
);
}
export {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupInput,
InputGroupText,
InputGroupTextarea,
};

View File

@@ -0,0 +1,21 @@
import * as React from "react";
import { cn } from "@/lib/utils";
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className,
)}
{...props}
/>
);
}
export { Input };

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,187 @@
"use client";
import * as SelectPrimitive from "@radix-ui/react-select";
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react";
import * as React from "react";
import { cn } from "@/lib/utils";
function Select({
...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />;
}
function SelectGroup({
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />;
}
function SelectValue({
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />;
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default";
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="size-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
);
}
function SelectContent({
className,
children,
position = "popper",
align = "center",
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className,
)}
position={position}
align={align}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1",
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
);
}
function SelectLabel({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
{...props}
/>
);
}
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className,
)}
{...props}
>
<span className="absolute right-2 flex size-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
);
}
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
{...props}
/>
);
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className,
)}
{...props}
>
<ChevronUpIcon className="size-4" />
</SelectPrimitive.ScrollUpButton>
);
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className,
)}
{...props}
>
<ChevronDownIcon className="size-4" />
</SelectPrimitive.ScrollDownButton>
);
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
};

View File

@@ -0,0 +1,18 @@
import * as React from "react";
import { cn } from "@/lib/utils";
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className,
)}
{...props}
/>
);
}
export { Textarea };

View File

@@ -0,0 +1,61 @@
"use client";
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import * as React from "react";
import { cn } from "@/lib/utils";
function TooltipProvider({
delayDuration = 0,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delayDuration={delayDuration}
{...props}
/>
);
}
function Tooltip({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return (
<TooltipProvider>
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
</TooltipProvider>
);
}
function TooltipTrigger({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />;
}
function TooltipContent({
className,
sideOffset = 0,
children,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
"bg-foreground text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
className,
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="bg-foreground fill-foreground z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
);
}
export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger };

View File

@@ -1,7 +1,7 @@
import { Bot } from "lucide-react";
import Link from "next/link";
import { getLighthouseConfig } from "@/actions/lighthouse/lighthouse";
import { isLighthouseConfigured } from "@/actions/lighthouse/lighthouse";
interface BannerConfig {
message: string;
@@ -28,9 +28,9 @@ const renderBanner = ({ message, href, gradient }: BannerConfig) => (
export const LighthouseBanner = async () => {
try {
const lighthouseConfig = await getLighthouseConfig();
const isConfigured = await isLighthouseConfigured();
if (!lighthouseConfig) {
if (!isConfigured) {
return renderBanner({
message: "Enable Lighthouse to secure your cloud with AI insights",
href: "/lighthouse/config",

View File

@@ -2,17 +2,42 @@
import { useChat } from "@ai-sdk/react";
import { DefaultChatTransport } from "ai";
import { Copy, Play, Plus, RotateCcw, Square } from "lucide-react";
import { ChevronDown, Copy, Plus, RotateCcw } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { useForm } from "react-hook-form";
import { Streamdown } from "streamdown";
import { Action, Actions } from "@/components/lighthouse/actions";
import { getLighthouseModelIds } from "@/actions/lighthouse/lighthouse";
import { Action, Actions } from "@/components/lighthouse/ai-elements/actions";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger,
} from "@/components/lighthouse/ai-elements/dropdown-menu";
import {
PromptInput,
PromptInputBody,
PromptInputSubmit,
PromptInputTextarea,
PromptInputToolbar,
PromptInputTools,
} from "@/components/lighthouse/ai-elements/prompt-input";
import { Loader } from "@/components/lighthouse/loader";
import { MemoizedMarkdown } from "@/components/lighthouse/memoized-markdown";
import { useToast } from "@/components/ui";
import { CustomButton, CustomTextarea } from "@/components/ui/custom";
import { CustomButton } from "@/components/ui/custom";
import { CustomLink } from "@/components/ui/custom/custom-link";
import { Form } from "@/components/ui/form";
import { cn } from "@/lib/utils";
import type { LighthouseProvider } from "@/types/lighthouse";
interface Model {
id: string;
name: string;
}
interface Provider {
id: LighthouseProvider;
name: string;
models: Model[];
}
interface SuggestedAction {
title: string;
@@ -22,22 +47,158 @@ interface SuggestedAction {
interface ChatProps {
hasConfig: boolean;
isActive: boolean;
providers: Provider[];
defaultProviderId?: LighthouseProvider;
defaultModelId?: string;
}
interface ChatFormData {
message: string;
interface SelectedModel {
providerType: LighthouseProvider | "";
modelId: string;
modelName: string;
}
export const Chat = ({ hasConfig, isActive }: ChatProps) => {
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const SUGGESTED_ACTIONS: SuggestedAction[] = [
{
title: "Are there any exposed S3",
label: "buckets in my AWS accounts?",
action: "List exposed S3 buckets in my AWS accounts",
},
{
title: "What is the risk of having",
label: "RDS databases unencrypted?",
action: "What is the risk of having RDS databases unencrypted?",
},
{
title: "What is the CIS 1.10 compliance status",
label: "of my Kubernetes cluster?",
action: "What is the CIS 1.10 compliance status of my Kubernetes cluster?",
},
{
title: "List my highest privileged",
label: "AWS IAM users with full admin access?",
action: "List my highest privileged AWS IAM users with full admin access",
},
];
export const Chat = ({
hasConfig,
providers: initialProviders,
defaultProviderId,
defaultModelId,
}: ChatProps) => {
const { toast } = useToast();
// Consolidated UI state
const [uiState, setUiState] = useState<{
inputValue: string;
isDropdownOpen: boolean;
modelSearchTerm: string;
hoveredProvider: LighthouseProvider | "";
}>({
inputValue: "",
isDropdownOpen: false,
modelSearchTerm: "",
hoveredProvider: defaultProviderId || initialProviders[0]?.id || "",
});
// Error handling
const [errorMessage, setErrorMessage] = useState<string | null>(null);
// Provider and model management
const [providers, setProviders] = useState<Provider[]>(initialProviders);
const [providerLoadState, setProviderLoadState] = useState<{
loaded: Set<LighthouseProvider>;
loading: Set<LighthouseProvider>;
}>({
loaded: new Set(),
loading: new Set(),
});
// Initialize selectedModel with defaults from props
const [selectedModel, setSelectedModel] = useState<SelectedModel>(() => {
const defaultProvider =
initialProviders.find((p) => p.id === defaultProviderId) ||
initialProviders[0];
const defaultModel =
defaultProvider?.models.find((m) => m.id === defaultModelId) ||
defaultProvider?.models[0];
return {
providerType: defaultProvider?.id || "",
modelId: defaultModel?.id || "",
modelName: defaultModel?.name || "",
};
});
// Keep ref in sync with selectedModel for stable access in callbacks
const selectedModelRef = useRef(selectedModel);
selectedModelRef.current = selectedModel;
// Load all models for a specific provider
const loadModelsForProvider = async (providerType: LighthouseProvider) => {
setProviderLoadState((prev) => {
// Skip if already loaded or currently loading
if (prev.loaded.has(providerType) || prev.loading.has(providerType)) {
return prev;
}
// Mark as loading
return {
...prev,
loading: new Set([...Array.from(prev.loading), providerType]),
};
});
try {
const response = await getLighthouseModelIds(providerType);
if (response.errors) {
console.error(
`Error loading models for ${providerType}:`,
response.errors,
);
return;
}
if (response.data && Array.isArray(response.data)) {
// Use the model data directly from the API
const models: Model[] = response.data;
// Update the provider's models
setProviders((prev) =>
prev.map((p) => (p.id === providerType ? { ...p, models } : p)),
);
// Mark as loaded and remove from loading
setProviderLoadState((prev) => ({
loaded: new Set([...Array.from(prev.loaded), providerType]),
loading: new Set(
Array.from(prev.loading).filter((id) => id !== providerType),
),
}));
}
} catch (error) {
console.error(`Error loading models for ${providerType}:`, error);
// Remove from loading state on error
setProviderLoadState((prev) => ({
...prev,
loading: new Set(
Array.from(prev.loading).filter((id) => id !== providerType),
),
}));
}
};
const { messages, sendMessage, status, error, setMessages, regenerate } =
useChat({
transport: new DefaultChatTransport({
api: "/api/lighthouse/analyst",
credentials: "same-origin",
body: () => ({
model: selectedModelRef.current.modelId,
provider: selectedModelRef.current.providerType,
}),
}),
experimental_throttle: 100,
onFinish: ({ message }) => {
@@ -65,6 +226,7 @@ export const Chat = ({ hasConfig, isActive }: ChatProps) => {
);
}),
);
restoreLastUserMessage();
}
},
onError: (error) => {
@@ -74,59 +236,53 @@ export const Chat = ({ hasConfig, isActive }: ChatProps) => {
error?.message?.includes("<html>") &&
error?.message?.includes("<title>403 Forbidden</title>")
) {
restoreLastUserMessage();
setErrorMessage("403 Forbidden");
return;
}
restoreLastUserMessage();
setErrorMessage(
error?.message || "An error occurred. Please retry your message.",
);
},
});
const form = useForm<ChatFormData>({
defaultValues: {
message: "",
},
});
const messageValue = form.watch("message");
const messagesContainerRef = useRef<HTMLDivElement | null>(null);
const latestUserMsgRef = useRef<HTMLDivElement | null>(null);
const messageValueRef = useRef<string>("");
// Keep ref in sync with current value
messageValueRef.current = messageValue;
const restoreLastUserMessage = () => {
let restoredText = "";
// Restore last user message to input when any error occurs
useEffect(() => {
if (errorMessage) {
// Capture current messages to avoid dependency issues
setMessages((currentMessages) => {
const lastUserMessage = currentMessages
.filter((m) => m.role === "user")
.pop();
setMessages((currentMessages) => {
const nextMessages = [...currentMessages];
if (lastUserMessage) {
const textPart = lastUserMessage.parts.find((p) => p.type === "text");
if (textPart && "text" in textPart) {
form.setValue("message", textPart.text);
}
// Remove the last user message from history since it's now in the input
return currentMessages.slice(0, -1);
for (let index = nextMessages.length - 1; index >= 0; index -= 1) {
const current = nextMessages[index];
if (current.role !== "user") {
continue;
}
return currentMessages;
});
}
}, [errorMessage, form, setMessages]);
const textPart = current.parts.find(
(part): part is { type: "text"; text: string } =>
part.type === "text" && "text" in part,
);
// Reset form when message is sent
useEffect(() => {
if (status === "submitted") {
form.reset({ message: "" });
if (textPart) {
restoredText = textPart.text;
}
nextMessages.splice(index, 1);
break;
}
return nextMessages;
});
if (restoredText) {
setUiState((prev) => ({ ...prev, inputValue: restoredText }));
}
}, [status, form]);
};
// Auto-scroll to bottom when new messages arrive or when streaming
useEffect(() => {
@@ -136,71 +292,40 @@ export const Chat = ({ hasConfig, isActive }: ChatProps) => {
}
}, [messages, status]);
const onFormSubmit = form.handleSubmit((data) => {
// Block submission while streaming or submitted
if (status === "streaming" || status === "submitted") {
return;
}
if (data.message.trim()) {
// Clear error on new submission
setErrorMessage(null);
sendMessage({ text: data.message });
form.reset();
}
});
// Global keyboard shortcut handler
// Handle dropdown state changes
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
// Block enter key while streaming or submitted
if (
messageValue?.trim() &&
status !== "streaming" &&
status !== "submitted"
) {
onFormSubmit();
}
}
};
if (uiState.isDropdownOpen && uiState.hoveredProvider) {
loadModelsForProvider(uiState.hoveredProvider as LighthouseProvider);
}
}, [uiState.isDropdownOpen, uiState.hoveredProvider, loadModelsForProvider]);
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [messageValue, onFormSubmit, status]);
const suggestedActions: SuggestedAction[] = [
{
title: "Are there any exposed S3",
label: "buckets in my AWS accounts?",
action: "List exposed S3 buckets in my AWS accounts",
},
{
title: "What is the risk of having",
label: "RDS databases unencrypted?",
action: "What is the risk of having RDS databases unencrypted?",
},
{
title: "What is the CIS 1.10 compliance status",
label: "of my Kubernetes cluster?",
action:
"What is the CIS 1.10 compliance status of my Kubernetes cluster?",
},
{
title: "List my highest privileged",
label: "AWS IAM users with full admin access?",
action: "List my highest privileged AWS IAM users with full admin access",
},
];
// Determine if chat should be disabled
const shouldDisableChat = !hasConfig || !isActive;
// Filter models based on search term
const currentProvider = providers.find(
(p) => p.id === uiState.hoveredProvider,
);
const filteredModels =
currentProvider?.models.filter((model) =>
model.name.toLowerCase().includes(uiState.modelSearchTerm.toLowerCase()),
) || [];
// Handlers
const handleNewChat = () => {
setMessages([]);
setErrorMessage(null);
form.reset({ message: "" });
setUiState((prev) => ({ ...prev, inputValue: "" }));
};
const handleModelSelect = (
providerType: LighthouseProvider,
modelId: string,
modelName: string,
) => {
setSelectedModel({ providerType, modelId, modelName });
setUiState((prev) => ({
...prev,
isDropdownOpen: false,
modelSearchTerm: "", // Reset search when selecting
}));
};
return (
@@ -223,18 +348,14 @@ export const Chat = ({ hasConfig, isActive }: ChatProps) => {
</div>
)}
{shouldDisableChat && (
{!hasConfig && (
<div className="bg-background/80 absolute inset-0 z-50 flex items-center justify-center backdrop-blur-sm">
<div className="bg-card max-w-md rounded-lg p-6 text-center shadow-lg">
<h3 className="mb-2 text-lg font-semibold">
{!hasConfig
? "OpenAI API Key Required"
: "OpenAI API Key Invalid"}
LLM Provider Configuration Required
</h3>
<p className="text-muted-foreground mb-4">
{!hasConfig
? "Please configure your OpenAI API key to use Lighthouse AI."
: "OpenAI API key is invalid. Please update your key to use Lighthouse AI."}
Please configure an LLM provider to use Lighthouse AI.
</p>
<CustomLink
href="/lighthouse/config"
@@ -242,7 +363,7 @@ export const Chat = ({ hasConfig, isActive }: ChatProps) => {
target="_self"
size="sm"
>
Configure API Key
Configure Provider
</CustomLink>
</div>
</div>
@@ -300,7 +421,7 @@ export const Chat = ({ hasConfig, isActive }: ChatProps) => {
<div className="w-full max-w-2xl">
<h2 className="mb-4 text-center font-sans text-xl">Suggestions</h2>
<div className="grid gap-2 sm:grid-cols-2">
{suggestedActions.map((action, index) => (
{SUGGESTED_ACTIONS.map((action, index) => (
<CustomButton
key={`suggested-action-${index}`}
ariaLabel={`Send message: ${action.action}`}
@@ -324,12 +445,6 @@ export const Chat = ({ hasConfig, isActive }: ChatProps) => {
ref={messagesContainerRef}
>
{messages.map((message, idx) => {
const lastUserIdx = messages
.map((m, i) => (m.role === "user" ? i : -1))
.filter((i) => i !== -1)
.pop();
const isLatestUserMsg =
message.role === "user" && lastUserIdx === idx;
const isLastMessage = idx === messages.length - 1;
const messageText = message.parts
.filter((p) => p.type === "text")
@@ -348,7 +463,6 @@ export const Chat = ({ hasConfig, isActive }: ChatProps) => {
return (
<div key={uniqueKey}>
<div
ref={isLatestUserMsg ? latestUserMsgRef : undefined}
className={`flex ${
message.role === "user" ? "justify-end" : "justify-start"
}`}
@@ -365,12 +479,23 @@ export const Chat = ({ hasConfig, isActive }: ChatProps) => {
<Loader size="default" text="Thinking..." />
) : (
<div
className={`prose dark:prose-invert ${message.role === "user" ? "dark:text-black!" : ""}`}
className={
message.role === "user" ? "dark:text-black!" : ""
}
>
<MemoizedMarkdown
id={message.id}
content={messageText}
/>
<Streamdown
parseIncompleteMarkdown={true}
shikiTheme={["github-light", "github-dark"]}
controls={{
code: true,
table: true,
mermaid: true,
}}
allowedLinkPrefixes={["*"]}
allowedImagePrefixes={["*"]}
>
{messageText}
</Streamdown>
</div>
)}
</div>
@@ -384,8 +509,8 @@ export const Chat = ({ hasConfig, isActive }: ChatProps) => {
<div className="mt-2 flex justify-start">
<Actions className="max-w-[80%]">
<Action
tooltip="Copy message"
label="Copy"
icon={<Copy className="h-3 w-3" />}
onClick={() => {
navigator.clipboard.writeText(messageText);
toast({
@@ -393,12 +518,16 @@ export const Chat = ({ hasConfig, isActive }: ChatProps) => {
description: "Message copied to clipboard",
});
}}
/>
>
<Copy className="h-3 w-3" />
</Action>
<Action
tooltip="Regenerate response"
label="Retry"
icon={<RotateCcw className="h-3 w-3" />}
onClick={() => regenerate()}
/>
>
<RotateCcw className="h-3 w-3" />
</Action>
</Actions>
</div>
)}
@@ -418,52 +547,162 @@ export const Chat = ({ hasConfig, isActive }: ChatProps) => {
</div>
)}
<Form {...form}>
<form
onSubmit={onFormSubmit}
className="mx-auto flex w-full gap-2 px-4 pb-16 md:max-w-3xl md:pb-16"
<div className="mx-auto w-full px-4 pb-16 md:max-w-3xl md:pb-16">
<PromptInput
onSubmit={(message) => {
if (status === "streaming" || status === "submitted") {
return;
}
if (message.text?.trim()) {
setErrorMessage(null);
sendMessage({
text: message.text,
});
setUiState((prev) => ({ ...prev, inputValue: "" }));
}
}}
>
<div className="flex w-full items-end gap-2">
<div className="w-full flex-1">
<CustomTextarea
control={form.control}
name="message"
label=""
placeholder={
error || errorMessage
? "Edit your message and try again..."
: "Type your message..."
}
variant="bordered"
minRows={1}
maxRows={6}
fullWidth={true}
disableAutosize={false}
/>
</div>
<CustomButton
type="submit"
ariaLabel={
status === "streaming" || status === "submitted"
? "Generating response..."
: "Send message"
<PromptInputBody>
<PromptInputTextarea
placeholder={
error || errorMessage
? "Edit your message and try again..."
: "Type your message..."
}
isDisabled={
value={uiState.inputValue}
onChange={(e) =>
setUiState((prev) => ({ ...prev, inputValue: e.target.value }))
}
/>
</PromptInputBody>
<PromptInputToolbar>
<PromptInputTools>
{/* Model Selector */}
<DropdownMenu
open={uiState.isDropdownOpen}
onOpenChange={(open) =>
setUiState((prev) => ({ ...prev, isDropdownOpen: open }))
}
>
<DropdownMenuTrigger asChild>
<button
type="button"
className="hover:bg-accent text-muted-foreground hover:text-foreground flex items-center gap-1.5 rounded-md px-2.5 py-1.5 text-sm font-medium transition-colors"
>
<span>{selectedModel.modelName}</span>
<ChevronDown className="h-4 w-4" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
className="bg-background w-[400px] border p-0 shadow-lg"
>
<div className="flex h-[300px]">
{/* Left column - Providers */}
<div className="border-default-200 dark:border-default-100 bg-muted/30 w-[180px] overflow-y-auto border-r p-1">
{providers.map((provider) => (
<div
key={provider.id}
onMouseEnter={() => {
setUiState((prev) => ({
...prev,
hoveredProvider: provider.id,
modelSearchTerm: "", // Reset search when changing provider
}));
loadModelsForProvider(provider.id);
}}
className={cn(
"flex cursor-default items-center justify-between rounded-sm px-3 py-2 text-sm transition-colors",
uiState.hoveredProvider === provider.id
? "bg-gray-100 dark:bg-gray-800"
: "hover:ring-default-200 dark:hover:ring-default-700 hover:bg-gray-100 hover:ring-1 dark:hover:bg-gray-800",
)}
>
<span className="font-medium">{provider.name}</span>
<ChevronDown className="h-4 w-4 -rotate-90" />
</div>
))}
</div>
{/* Right column - Models */}
<div className="flex flex-1 flex-col">
{/* Search bar */}
<div className="border-default-200 dark:border-default-100 border-b p-2">
<input
type="text"
placeholder="Search models..."
value={uiState.modelSearchTerm}
onChange={(e) =>
setUiState((prev) => ({
...prev,
modelSearchTerm: e.target.value,
}))
}
className="placeholder:text-muted-foreground w-full rounded-md border border-gray-200 bg-transparent px-3 py-1.5 text-sm outline-hidden focus:border-gray-400 dark:border-gray-700 dark:focus:border-gray-500"
/>
</div>
{/* Models list */}
<div className="flex-1 overflow-y-auto p-1">
{uiState.hoveredProvider &&
providerLoadState.loading.has(
uiState.hoveredProvider as LighthouseProvider,
) ? (
<div className="flex items-center justify-center py-8">
<Loader size="sm" text="Loading models..." />
</div>
) : filteredModels.length === 0 ? (
<div className="text-muted-foreground flex items-center justify-center py-8 text-sm">
{uiState.modelSearchTerm
? "No models found"
: "No models available"}
</div>
) : (
filteredModels.map((model) => (
<button
key={model.id}
type="button"
onClick={() =>
uiState.hoveredProvider &&
handleModelSelect(
uiState.hoveredProvider as LighthouseProvider,
model.id,
model.name,
)
}
className={cn(
"focus:bg-accent focus:text-accent-foreground hover:ring-default-200 dark:hover:ring-default-700 relative flex w-full cursor-default items-center rounded-sm px-3 py-2 text-left text-sm outline-hidden transition-colors hover:bg-gray-100 hover:ring-1 dark:hover:bg-gray-800",
selectedModel.modelId === model.id &&
selectedModel.providerType ===
uiState.hoveredProvider
? "bg-accent text-accent-foreground"
: "",
)}
>
{model.name}
</button>
))
)}
</div>
</div>
</div>
</DropdownMenuContent>
</DropdownMenu>
</PromptInputTools>
{/* Submit Button */}
<PromptInputSubmit
status={status}
disabled={
status === "streaming" ||
status === "submitted" ||
!messageValue?.trim()
!uiState.inputValue?.trim()
}
className="bg-primary text-primary-foreground hover:bg-primary/90 dark:bg-primary/90 flex h-10 w-10 shrink-0 items-center justify-center rounded-lg p-2 disabled:opacity-50"
>
{status === "streaming" || status === "submitted" ? (
<Square className="h-5 w-5" />
) : (
<Play className="h-5 w-5" />
)}
</CustomButton>
</div>
</form>
</Form>
/>
</PromptInputToolbar>
</PromptInput>
</div>
</div>
);
};

View File

@@ -1,194 +0,0 @@
"use client";
import { Select, SelectItem } from "@heroui/select";
import { zodResolver } from "@hookform/resolvers/zod";
import { SaveIcon } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { Controller, useForm } from "react-hook-form";
import * as z from "zod";
import {
createLighthouseConfig,
updateLighthouseConfig,
} from "@/actions/lighthouse";
import { useToast } from "@/components/ui";
import {
CustomButton,
CustomInput,
CustomTextarea,
} from "@/components/ui/custom";
import { Form } from "@/components/ui/form";
const chatbotConfigSchema = z.object({
model: z.string().min(1, "Model selection is required"),
apiKey: z.string().min(1, "API Key is required"),
businessContext: z
.string()
.max(1000, "Business context cannot exceed 1000 characters")
.optional(),
});
type FormValues = z.infer<typeof chatbotConfigSchema>;
interface ChatbotConfigClientProps {
initialValues: FormValues;
configExists: boolean;
}
export const ChatbotConfig = ({
initialValues,
configExists: initialConfigExists,
}: ChatbotConfigClientProps) => {
const router = useRouter();
const { toast } = useToast();
const [isLoading, setIsLoading] = useState(false);
const [configExists, setConfigExists] = useState(initialConfigExists);
const form = useForm<FormValues>({
resolver: zodResolver(chatbotConfigSchema),
defaultValues: initialValues,
mode: "onChange",
});
const onSubmit = async (data: FormValues) => {
if (isLoading) return;
// Validate API key: required for new config, or if changing an existing masked key
if (!configExists && (!data.apiKey || data.apiKey.trim().length === 0)) {
form.setError("apiKey", {
type: "manual",
message: "API Key is required",
});
return;
}
setIsLoading(true);
try {
const configData: any = {
model: data.model,
businessContext: data.businessContext || "",
};
if (data.apiKey && !data.apiKey.includes("*")) {
configData.apiKey = data.apiKey;
}
const result = configExists
? await updateLighthouseConfig(configData)
: await createLighthouseConfig(configData);
if (result) {
setConfigExists(true);
toast({
title: "Success",
description: `Lighthouse AI configuration ${
configExists ? "updated" : "created"
} successfully`,
});
// Navigate to lighthouse chat page after successful save
router.push("/lighthouse");
} else {
throw new Error("Failed to save configuration");
}
} catch (error) {
toast({
title: "Error",
description:
"Failed to save Lighthouse AI configuration: " + String(error),
variant: "destructive",
});
} finally {
setIsLoading(false);
}
};
return (
<div className="rounded-lg border border-gray-200 bg-white p-6 dark:border-gray-800 dark:bg-gray-900">
<h2 className="mb-4 text-xl font-semibold">Chatbot Settings</h2>
<p className="mb-6 text-gray-600 dark:text-gray-300">
Configure your chatbot model and API settings.
</p>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="flex flex-col gap-6"
>
<div className="flex flex-col gap-6 md:flex-row">
<div className="md:w-1/3">
<Controller
name="model"
control={form.control}
render={({ field }) => (
<Select
label="Model"
placeholder="Select a model"
labelPlacement="inside"
value={field.value}
defaultSelectedKeys={[field.value]}
onChange={(e) => field.onChange(e.target.value)}
variant="bordered"
size="md"
isRequired
>
<SelectItem key="gpt-4o-2024-08-06">
GPT-4o (Recommended)
</SelectItem>
<SelectItem key="gpt-4o-mini-2024-07-18">
GPT-4o Mini
</SelectItem>
<SelectItem key="gpt-5-2025-08-07">GPT-5</SelectItem>
<SelectItem key="gpt-5-mini-2025-08-07">
GPT-5 Mini
</SelectItem>
</Select>
)}
/>
</div>
<div className="md:flex-1">
<CustomInput
control={form.control}
name="apiKey"
type="password"
label="API Key"
labelPlacement="inside"
placeholder="Enter your API key"
variant="bordered"
isRequired
isInvalid={!!form.formState.errors.apiKey}
/>
</div>
</div>
<CustomTextarea
control={form.control}
name="businessContext"
label="Business Context"
labelPlacement="inside"
placeholder="Enter business context and relevant information for the chatbot (max 1000 characters)"
variant="bordered"
minRows={4}
maxRows={8}
description={`${form.watch("businessContext")?.length || 0}/1000 characters`}
isInvalid={!!form.formState.errors.businessContext}
/>
<div className="flex w-full justify-end">
<CustomButton
type="submit"
ariaLabel="Save Configuration"
variant="solid"
color="action"
size="md"
isLoading={isLoading}
startContent={!isLoading && <SaveIcon size={20} />}
>
{isLoading ? "Saving..." : "Save"}
</CustomButton>
</div>
</form>
</Form>
</div>
);
};

View File

@@ -0,0 +1,271 @@
"use client";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import {
createLighthouseProvider,
getLighthouseProviderByType,
updateLighthouseProviderByType,
} from "@/actions/lighthouse/lighthouse";
import { CustomButton } from "@/components/ui/custom";
import type { LighthouseProvider } from "@/types/lighthouse";
import { getMainFields, getProviderConfig } from "./llm-provider-registry";
import {
isProviderFormValid,
shouldTestConnection,
testAndRefreshModels,
} from "./llm-provider-utils";
interface ConnectLLMProviderProps {
provider: LighthouseProvider;
mode?: string;
}
type FormData = Record<string, string>;
type Status = "idle" | "connecting" | "verifying" | "loading-models";
export const ConnectLLMProvider = ({
provider,
mode = "create",
}: ConnectLLMProviderProps) => {
const router = useRouter();
const providerConfig = getProviderConfig(provider);
const isEditMode = mode === "edit";
const [formData, setFormData] = useState<FormData>({});
const [existingProviderId, setExistingProviderId] = useState<string | null>(
null,
);
const [status, setStatus] = useState<Status>("idle");
const [isFetching, setIsFetching] = useState(false);
const [error, setError] = useState<string | null>(null);
// Fetch existing provider ID in edit mode
useEffect(() => {
if (!isEditMode || !providerConfig) return;
const fetchProvider = async () => {
setIsFetching(true);
try {
const result = await getLighthouseProviderByType(provider);
if (result.errors) {
throw new Error(
result.errors[0]?.detail || "Failed to fetch provider",
);
}
setExistingProviderId(result.data.id);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setIsFetching(false);
}
};
fetchProvider();
}, [isEditMode, provider, providerConfig]);
const buildPayload = (): Record<string, any> => {
if (!providerConfig) return {};
const credentials: Record<string, string> = {};
const otherFields: Record<string, string> = {};
providerConfig.fields.forEach((field) => {
if (formData[field.name]) {
if (field.requiresConnectionTest) {
credentials[field.name] = formData[field.name];
} else {
otherFields[field.name] = formData[field.name];
}
}
});
return {
...(Object.keys(credentials).length > 0 && { credentials }),
...otherFields,
};
};
const handleConnect = async () => {
if (!providerConfig) return;
setStatus("connecting");
setError(null);
try {
let providerId: string;
const payload = buildPayload();
// Update if we have an existing provider, otherwise create
if (existingProviderId) {
if (Object.keys(payload).length > 0) {
await updateLighthouseProviderByType(provider, payload);
}
providerId = existingProviderId;
} else {
const result = await createLighthouseProvider({
provider_type: provider,
credentials: payload.credentials || {},
...(payload.base_url && { base_url: payload.base_url }),
});
if (result.errors) {
throw new Error(
result.errors[0]?.detail || "Failed to create provider",
);
}
if (!result.data?.id) {
throw new Error("Failed to create provider");
}
providerId = result.data.id;
setExistingProviderId(providerId);
}
// Test connection if credentials provided
if (shouldTestConnection(provider, formData)) {
setStatus("verifying");
await testAndRefreshModels(providerId);
setStatus("loading-models");
}
// Navigate to model selection on success
router.push(
`/lighthouse/config/select-model?provider=${provider}${isEditMode ? "&mode=edit" : ""}`,
);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
setStatus("idle");
}
};
const handleFieldChange = (fieldName: string, value: string) => {
setFormData((prev) => ({ ...prev, [fieldName]: value }));
if (error) setError(null);
};
const getButtonText = () => {
if (status === "idle") {
if (error && existingProviderId) return "Retry Connection";
return isEditMode ? "Continue" : "Connect";
}
const statusText = {
connecting: "Connecting...",
verifying: "Verifying...",
"loading-models": "Loading models...",
};
return statusText[status] || "Connecting...";
};
if (!providerConfig) {
return (
<div className="flex h-64 items-center justify-center">
<div className="text-sm text-red-600 dark:text-red-400">
Provider configuration not found: {provider}
</div>
</div>
);
}
if (isFetching) {
return (
<div className="flex h-64 items-center justify-center">
<div className="text-sm text-gray-600 dark:text-gray-400">
Loading provider configuration...
</div>
</div>
);
}
const mainFields = getMainFields(provider);
const isFormValid = isProviderFormValid(provider, formData, isEditMode);
const isLoading = status !== "idle";
return (
<div className="flex w-full flex-col gap-6">
<div>
<h2 className="mb-2 text-xl font-semibold">
{isEditMode
? `Update ${providerConfig.name}`
: `Connect to ${providerConfig.name}`}
</h2>
<p className="text-sm text-gray-600 dark:text-gray-300">
{isEditMode
? `Update your API credentials or settings for ${providerConfig.name}.`
: `Enter your API credentials to connect to ${providerConfig.name}.`}
</p>
</div>
{error && (
<div className="rounded-lg border border-red-200 bg-red-50 p-4 dark:border-red-800 dark:bg-red-900/20">
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
</div>
)}
<div className="flex flex-col gap-4">
{mainFields.map((field) => (
<div key={field.name}>
<label
htmlFor={field.name}
className="mb-2 block text-sm font-medium"
>
{field.label}{" "}
{!isEditMode && field.required && (
<span className="text-red-500">*</span>
)}
{isEditMode && (
<span className="text-xs text-gray-500">
(leave empty to keep existing)
</span>
)}
</label>
<input
id={field.name}
type={field.type}
value={formData[field.name] || ""}
onChange={(e) => handleFieldChange(field.name, e.target.value)}
placeholder={
isEditMode
? `Enter new ${field.label.toLowerCase()} or leave empty`
: field.placeholder
}
className="w-full rounded-lg border border-gray-300 px-3 py-2 dark:border-gray-700 dark:bg-gray-800"
/>
</div>
))}
<div className="mt-4 flex justify-end gap-4">
<CustomButton
type="button"
ariaLabel="Cancel"
className="w-full bg-transparent"
variant="faded"
size="lg"
radius="lg"
onPress={() => router.push("/lighthouse/config")}
isDisabled={isLoading}
>
<span>Cancel</span>
</CustomButton>
<CustomButton
ariaLabel={isEditMode ? "Update" : "Connect"}
className="w-full"
variant="solid"
color="action"
size="lg"
radius="lg"
isLoading={isLoading}
isDisabled={!isFormValid}
onPress={handleConnect}
>
{getButtonText()}
</CustomButton>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,91 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { useRouter } from "next/navigation";
import React, { Dispatch, SetStateAction } from "react";
import { useForm } from "react-hook-form";
import * as z from "zod";
import { deleteLighthouseProviderByType } from "@/actions/lighthouse/lighthouse";
import { DeleteIcon } from "@/components/icons";
import { useToast } from "@/components/ui";
import { CustomButton } from "@/components/ui/custom";
import { Form } from "@/components/ui/form";
import type { LighthouseProvider } from "@/types/lighthouse";
const formSchema = z.object({
providerType: z.string(),
});
export const DeleteLLMProviderForm = ({
providerType,
setIsOpen,
}: {
providerType: LighthouseProvider;
setIsOpen: Dispatch<SetStateAction<boolean>>;
}) => {
const router = useRouter();
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
});
const { toast } = useToast();
const isLoading = form.formState.isSubmitting;
async function onSubmitClient(formData: FormData) {
const providerType = formData.get("providerType") as LighthouseProvider;
const data = await deleteLighthouseProviderByType(providerType);
if (data?.errors && data.errors.length > 0) {
const error = data.errors[0];
const errorMessage = `${error.detail}`;
toast({
variant: "destructive",
title: "Oops! Something went wrong",
description: errorMessage,
});
} else {
toast({
title: "Success!",
description: "The LLM provider was removed successfully.",
});
setIsOpen(false);
router.push("/lighthouse/config");
}
}
return (
<Form {...form}>
<form action={onSubmitClient}>
<input type="hidden" name="providerType" value={providerType} />
<div className="flex w-full justify-center sm:gap-6">
<CustomButton
type="button"
ariaLabel="Cancel"
className="w-full bg-transparent"
variant="faded"
size="lg"
radius="lg"
onPress={() => setIsOpen(false)}
isDisabled={isLoading}
>
<span>Cancel</span>
</CustomButton>
<CustomButton
type="submit"
ariaLabel="Delete"
className="w-full"
variant="solid"
color="danger"
size="lg"
isLoading={isLoading}
startContent={!isLoading && <DeleteIcon size={24} />}
>
{isLoading ? <>Loading</> : <span>Delete</span>}
</CustomButton>
</div>
</form>
</Form>
);
};

View File

@@ -1,3 +1,8 @@
export * from "./banner";
export * from "./chat";
export * from "./chatbot-config";
export * from "./connect-llm-provider";
export * from "./lighthouse-settings";
export * from "./llm-provider-registry";
export * from "./llm-provider-utils";
export * from "./llm-providers-table";
export * from "./select-model";

View File

@@ -0,0 +1,151 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { Icon } from "@iconify/react";
import { SaveIcon } from "lucide-react";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import * as z from "zod";
import {
getTenantConfig,
updateTenantConfig,
} from "@/actions/lighthouse/lighthouse";
import { useToast } from "@/components/ui";
import { CustomButton, CustomTextarea } from "@/components/ui/custom";
import { Form } from "@/components/ui/form";
const lighthouseSettingsSchema = z.object({
businessContext: z
.string()
.max(1000, "Business context cannot exceed 1000 characters")
.optional(),
});
type FormValues = z.infer<typeof lighthouseSettingsSchema>;
export const LighthouseSettings = () => {
const { toast } = useToast();
const [isLoading, setIsLoading] = useState(false);
const [isFetchingData, setIsFetchingData] = useState(true);
const form = useForm<FormValues>({
resolver: zodResolver(lighthouseSettingsSchema),
defaultValues: {
businessContext: "",
},
mode: "onChange",
});
// Fetch initial data
useEffect(() => {
const fetchData = async () => {
setIsFetchingData(true);
try {
// Fetch tenant config
const configResult = await getTenantConfig();
if (configResult.data && !configResult.errors) {
const config = configResult.data.attributes;
form.reset({
businessContext: config?.business_context || "",
});
}
} catch (error) {
console.error("Failed to fetch settings:", error);
} finally {
setIsFetchingData(false);
}
};
fetchData();
}, [form]);
const onSubmit = async (data: FormValues) => {
if (isLoading) return;
setIsLoading(true);
try {
const config: Record<string, string> = {
business_context: data.businessContext || "",
};
const result = await updateTenantConfig(config);
if (result.errors) {
const errorMessage =
result.errors[0]?.detail || "Failed to save settings";
toast({
title: "Error",
description: errorMessage,
variant: "destructive",
});
} else {
toast({
title: "Success",
description: "Lighthouse settings saved successfully",
});
}
} catch (error) {
toast({
title: "Error",
description: "Failed to save Lighthouse settings: " + String(error),
variant: "destructive",
});
} finally {
setIsLoading(false);
}
};
if (isFetchingData) {
return (
<div className="rounded-lg border border-gray-200 bg-white p-6 dark:border-gray-800 dark:bg-gray-900">
<h2 className="mb-4 text-xl font-semibold">Settings</h2>
<div className="flex items-center justify-center py-12">
<Icon
icon="heroicons:arrow-path"
className="h-8 w-8 animate-spin text-gray-400"
/>
</div>
</div>
);
}
return (
<div className="rounded-lg border border-gray-200 bg-white p-6 dark:border-gray-800 dark:bg-gray-900">
<h2 className="mb-4 text-xl font-semibold">Settings</h2>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="flex flex-col gap-6"
>
<CustomTextarea
control={form.control}
name="businessContext"
label="Business Context"
labelPlacement="inside"
placeholder="Enter business context and relevant information for the chatbot (max 1000 characters)"
variant="bordered"
minRows={4}
maxRows={8}
description={`${form.watch("businessContext")?.length || 0}/1000 characters`}
isInvalid={!!form.formState.errors.businessContext}
/>
<div className="flex w-full justify-end">
<CustomButton
type="submit"
ariaLabel="Save Settings"
variant="solid"
color="action"
size="md"
isLoading={isLoading}
startContent={!isLoading && <SaveIcon size={20} />}
>
{isLoading ? "Saving..." : "Save"}
</CustomButton>
</div>
</form>
</Form>
</div>
);
};

View File

@@ -0,0 +1,117 @@
"use client";
import type { LighthouseProvider } from "@/types/lighthouse";
export type LLMProviderFieldType = "text" | "password";
export interface LLMProviderField {
name: string;
type: LLMProviderFieldType;
label: string;
placeholder: string;
required: boolean;
requiresConnectionTest?: boolean;
}
export interface LLMProviderConfig {
id: LighthouseProvider;
name: string;
description: string;
icon: string;
fields: LLMProviderField[];
}
export const LLM_PROVIDER_REGISTRY: Record<
LighthouseProvider,
LLMProviderConfig
> = {
openai: {
id: "openai",
name: "OpenAI",
description: "Industry-leading GPT models for general-purpose AI",
icon: "simple-icons:openai",
fields: [
{
name: "api_key",
type: "password",
label: "API Key",
placeholder: "Enter your API key",
required: true,
requiresConnectionTest: true,
},
],
},
bedrock: {
id: "bedrock",
name: "Amazon Bedrock",
description: "AWS-managed AI with Claude, Llama, Titan & more",
icon: "simple-icons:amazonwebservices",
fields: [
{
name: "access_key_id",
type: "text",
label: "AWS Access Key ID",
placeholder: "Enter the AWS Access Key ID",
required: true,
requiresConnectionTest: true,
},
{
name: "secret_access_key",
type: "password",
label: "AWS Secret Access Key",
placeholder: "Enter the AWS Secret Access Key",
required: true,
requiresConnectionTest: true,
},
{
name: "region",
type: "text",
label: "AWS Region",
placeholder: "Enter the AWS Region",
required: true,
requiresConnectionTest: true,
},
],
},
openai_compatible: {
id: "openai_compatible",
name: "OpenAI Compatible",
description: "Connect to custom OpenAI-compatible endpoints",
icon: "simple-icons:openai",
fields: [
{
name: "api_key",
type: "password",
label: "API Key",
placeholder: "Enter your API key",
required: true,
requiresConnectionTest: true,
},
{
name: "base_url",
type: "text",
label: "Base URL",
placeholder: "https://openrouter.ai/api/v1",
required: true,
requiresConnectionTest: false,
},
],
},
};
export const getProviderConfig = (
providerType: LighthouseProvider,
): LLMProviderConfig | undefined => {
return LLM_PROVIDER_REGISTRY[providerType];
};
export const getAllProviders = (): LLMProviderConfig[] => {
return Object.values(LLM_PROVIDER_REGISTRY);
};
export const getMainFields = (
providerType: LighthouseProvider,
): LLMProviderField[] => {
const config = getProviderConfig(providerType);
return config?.fields ?? [];
};

View File

@@ -0,0 +1,139 @@
"use client";
import {
getLighthouseProviderByType,
refreshProviderModels,
testProviderConnection,
} from "@/actions/lighthouse/lighthouse";
import { getTask } from "@/actions/task/tasks";
import { checkTaskStatus } from "@/lib/helper";
import type { LighthouseProvider } from "@/types/lighthouse";
import { getProviderConfig } from "./llm-provider-registry";
export type LLMCredentialsFormData = Record<string, string>;
export const isProviderFormValid = (
providerType: LighthouseProvider,
formData: LLMCredentialsFormData,
isEditMode: boolean = false,
): boolean => {
const config = getProviderConfig(providerType);
if (!config) {
return false;
}
if (isEditMode) {
return true;
}
return config.fields
.filter((field) => field.required)
.every((field) => formData[field.name]?.trim());
};
export const shouldTestConnection = (
providerType: LighthouseProvider,
formData: LLMCredentialsFormData,
): boolean => {
const config = getProviderConfig(providerType);
if (!config) {
return false;
}
const testFields = config.fields.filter(
(field) => field.requiresConnectionTest,
);
return testFields.some((field) => formData[field.name]?.trim());
};
/**
* Triggers a background job to refresh models from the LLM provider's API
*/
export const refreshModelsInBackground = async (
providerId: string,
): Promise<void> => {
const modelsResult = await refreshProviderModels(providerId);
if (modelsResult.errors) {
throw new Error(
modelsResult.errors[0]?.detail || "Failed to start model refresh",
);
}
if (!modelsResult.data?.id) {
throw new Error("Failed to start model refresh");
}
// Wait for task to complete
const modelsStatus = await checkTaskStatus(modelsResult.data.id);
if (!modelsStatus.completed) {
throw new Error(modelsStatus.error || "Model refresh failed");
}
// Check final result
const modelsTask = await getTask(modelsResult.data.id);
if (modelsTask.data.attributes.result.error) {
throw new Error(modelsTask.data.attributes.result.error);
}
};
/**
* Tests provider connection and refreshes models
*/
export const testAndRefreshModels = async (
providerId: string,
): Promise<void> => {
// Test connection
const connectionResult = await testProviderConnection(providerId);
if (connectionResult.errors) {
throw new Error(
connectionResult.errors[0]?.detail || "Failed to start connection test",
);
}
if (!connectionResult.data?.id) {
throw new Error("Failed to start connection test");
}
const connectionStatus = await checkTaskStatus(connectionResult.data.id);
if (!connectionStatus.completed) {
throw new Error(connectionStatus.error || "Connection test failed");
}
const connectionTask = await getTask(connectionResult.data.id);
const { connected, error: connectionError } =
connectionTask.data.attributes.result;
if (!connected) {
throw new Error(connectionError || "Connection test failed");
}
// Refresh models
await refreshModelsInBackground(providerId);
};
/**
* Gets the provider ID for a given provider type
* @param providerType - The provider type (e.g., "openai", "anthropic")
* @returns Promise that resolves with the provider ID
* @throws Error if provider not found
*/
export const getProviderIdByType = async (
providerType: LighthouseProvider,
): Promise<string> => {
const result = await getLighthouseProviderByType(providerType);
if (result.errors) {
throw new Error(result.errors[0]?.detail || "Failed to fetch provider");
}
if (!result.data?.id) {
throw new Error("Provider not found");
}
return result.data.id;
};

View File

@@ -0,0 +1,237 @@
"use client";
import { Icon } from "@iconify/react";
import { useEffect, useState } from "react";
import {
getLighthouseProviders,
getTenantConfig,
} from "@/actions/lighthouse/lighthouse";
import { CustomButton } from "@/components/ui/custom";
import { getAllProviders } from "./llm-provider-registry";
interface LighthouseProviderResource {
id: string;
attributes: {
provider_type: string;
is_active: boolean;
};
}
type LLMProvider = {
id: string;
provider: string;
description: string;
defaultModel: string;
icon: string;
isConnected: boolean;
isActive: boolean;
isDefaultProvider: boolean;
};
export const LLMProvidersTable = () => {
const [providers, setProviders] = useState<LLMProvider[]>([]);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
const fetchProviders = async () => {
setIsLoading(true);
try {
// Fetch connected providers from API
const result = await getLighthouseProviders();
const connectedProviders = new Map<
string,
LighthouseProviderResource
>();
if (result.data && !result.errors) {
result.data.forEach((provider: LighthouseProviderResource) => {
connectedProviders.set(provider.attributes.provider_type, provider);
});
}
// Fetch tenant config for default models and default provider
const configResult = await getTenantConfig();
const defaultModels =
configResult.data?.attributes?.default_models || {};
const defaultProvider =
configResult.data?.attributes?.default_provider || "";
// Build provider list from registry
const allProviders: LLMProvider[] = getAllProviders().map((config) => {
const connected = connectedProviders.get(config.id);
const defaultModel = defaultModels[config.id] || "";
return {
id: config.id,
provider: config.name,
description: config.description,
icon: config.icon,
defaultModel,
isConnected: !!connected,
isActive: connected?.attributes?.is_active || false,
isDefaultProvider: config.id === defaultProvider,
};
});
setProviders(allProviders);
} catch (error) {
console.error("Failed to fetch providers:", error);
// Fallback to showing all providers from registry as not connected
const allProviders: LLMProvider[] = getAllProviders().map((config) => ({
id: config.id,
provider: config.name,
description: config.description,
icon: config.icon,
defaultModel: "",
isConnected: false,
isActive: false,
isDefaultProvider: false,
}));
setProviders(allProviders);
} finally {
setIsLoading(false);
}
};
fetchProviders();
}, []);
if (isLoading) {
return (
<div>
<h2 className="mb-4 text-xl font-semibold">LLM Providers</h2>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
{[1, 2, 3].map((i) => (
<div
key={i}
className="flex flex-col gap-4 rounded-lg border border-gray-200 bg-white p-6 dark:border-gray-800 dark:bg-gray-900"
>
<div className="flex items-center gap-3">
<div className="h-10 w-10 animate-pulse rounded-full bg-gray-200 dark:bg-gray-700" />
<div className="flex flex-1 flex-col gap-2">
<div className="h-5 w-32 animate-pulse rounded bg-gray-200 dark:bg-gray-700" />
<div className="h-3 w-48 animate-pulse rounded bg-gray-200 dark:bg-gray-700" />
</div>
</div>
<div className="flex-grow space-y-3">
<div>
<div className="mb-2 h-4 w-16 animate-pulse rounded bg-gray-200 dark:bg-gray-700" />
<div className="h-4 w-28 animate-pulse rounded bg-gray-200 dark:bg-gray-700" />
</div>
<div>
<div className="mb-2 h-4 w-24 animate-pulse rounded bg-gray-200 dark:bg-gray-700" />
<div className="h-4 w-36 animate-pulse rounded bg-gray-200 dark:bg-gray-700" />
</div>
</div>
<div className="h-10 w-full animate-pulse rounded-lg bg-gray-200 dark:bg-gray-700" />
</div>
))}
</div>
</div>
);
}
return (
<div>
<h2 className="mb-4 text-xl font-semibold">LLM Providers</h2>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
{providers.map((provider) => {
// Show Connect button if not connected, Configure if connected
const showConnect = !provider.isConnected;
const showConfigure = provider.isConnected;
return (
<div
key={provider.id}
className="flex flex-col gap-4 rounded-lg border border-gray-200 bg-white p-6 dark:border-gray-800 dark:bg-gray-900"
>
{/* Header */}
<div className="flex items-center gap-3">
<Icon icon={provider.icon} width={40} height={40} />
<div className="flex flex-1 flex-col">
<div className="flex items-center gap-2">
<h3 className="text-lg font-semibold">
{provider.provider}
</h3>
{provider.isDefaultProvider && (
<span className="rounded-full bg-blue-100 px-2 py-0.5 text-xs font-medium text-blue-800 dark:bg-blue-900/30 dark:text-blue-400">
Default
</span>
)}
</div>
<p className="text-xs text-gray-500 dark:text-gray-400">
{provider.description}
</p>
</div>
</div>
{/* Status and Model Info */}
<div className="flex-grow space-y-3">
<div>
<p className="text-sm text-gray-600 dark:text-gray-400">
Status
</p>
<p
className={`text-sm ${
provider.isConnected && provider.isActive
? "font-bold text-green-600 dark:text-green-500"
: "text-gray-500 dark:text-gray-500"
}`}
>
{provider.isConnected
? provider.isActive
? "Connected"
: "Connection Failed"
: "Not configured"}
</p>
</div>
{provider.defaultModel && (
<div>
<p className="text-sm text-gray-600 dark:text-gray-400">
Default Model
</p>
<p className="text-sm text-gray-700 dark:text-gray-300">
{provider.defaultModel}
</p>
</div>
)}
</div>
{/* Action Button */}
{showConnect && (
<CustomButton
asLink={`/lighthouse/config/connect?provider=${provider.id}`}
ariaLabel={`Connect ${provider.provider}`}
variant="solid"
color="action"
size="md"
className="w-full"
>
Connect
</CustomButton>
)}
{showConfigure && (
<CustomButton
asLink={`/lighthouse/config/connect?provider=${provider.id}&mode=edit`}
ariaLabel={`Configure ${provider.provider}`}
variant="bordered"
color="action"
size="md"
className="w-full"
>
Configure
</CustomButton>
)}
</div>
);
})}
</div>
</div>
);
};

View File

@@ -1,32 +0,0 @@
import { marked } from "marked";
import { memo, useMemo } from "react";
import ReactMarkdown from "react-markdown";
function parseMarkdownIntoBlocks(markdown: string): string[] {
const tokens = marked.lexer(markdown);
return tokens.map((token) => token.raw);
}
const MemoizedMarkdownBlock = memo(
({ content }: { content: string }) => {
return <ReactMarkdown>{content}</ReactMarkdown>;
},
(prevProps, nextProps) => {
if (prevProps.content !== nextProps.content) return false;
return true;
},
);
MemoizedMarkdownBlock.displayName = "MemoizedMarkdownBlock";
export const MemoizedMarkdown = memo(
({ content, id }: { content: string; id: string }) => {
const blocks = useMemo(() => parseMarkdownIntoBlocks(content), [content]);
return blocks.map((block, index) => (
<MemoizedMarkdownBlock content={block} key={`${id}-block_${index}`} />
));
},
);
MemoizedMarkdown.displayName = "MemoizedMarkdown";

View File

@@ -0,0 +1,273 @@
"use client";
import { Icon } from "@iconify/react";
import { useEffect, useState } from "react";
import {
getLighthouseModelIds,
getTenantConfig,
updateTenantConfig,
} from "@/actions/lighthouse/lighthouse";
import { CustomButton } from "@/components/ui/custom";
import type { LighthouseProvider } from "@/types/lighthouse";
import {
getProviderIdByType,
refreshModelsInBackground,
} from "./llm-provider-utils";
// Recommended models per provider
const RECOMMENDED_MODELS: Record<LighthouseProvider, Set<string>> = {
openai: new Set(["gpt-5"]),
bedrock: new Set([]),
openai_compatible: new Set([]),
};
interface SelectModelProps {
provider: LighthouseProvider;
mode?: string;
onSelect: () => void;
}
interface Model {
id: string;
name: string;
}
export const SelectModel = ({
provider,
mode = "create",
onSelect,
}: SelectModelProps) => {
const [selectedModel, setSelectedModel] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const [models, setModels] = useState<Model[]>([]);
const [searchQuery, setSearchQuery] = useState("");
const [error, setError] = useState<string | null>(null);
const isEditMode = mode === "edit";
const isRecommended = (modelId: string) => {
return RECOMMENDED_MODELS[provider]?.has(modelId) || false;
};
const fetchModels = async (triggerRefresh: boolean = false) => {
setIsLoading(true);
setError(null);
try {
// If triggerRefresh is true, trigger background job to refetch models from LLM provider API
if (triggerRefresh) {
const providerId = await getProviderIdByType(provider);
await refreshModelsInBackground(providerId);
}
// Fetch models from database
const result = await getLighthouseModelIds(provider);
if (result.errors) {
throw new Error(result.errors[0]?.detail || "Failed to fetch models");
}
const models = result.data || [];
setModels(models);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
setModels([]);
} finally {
setIsLoading(false);
}
};
useEffect(() => {
fetchModels();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const handleSelect = async () => {
if (!selectedModel) return;
setIsSaving(true);
setError(null);
try {
const currentConfig = await getTenantConfig();
const existingDefaults =
currentConfig?.data?.attributes?.default_models || {};
const existingDefaultProvider =
currentConfig?.data?.attributes?.default_provider || "";
const mergedDefaults = {
...existingDefaults,
[provider]: selectedModel,
};
// Prepare update payload
const updatePayload: {
default_models: Record<string, string>;
default_provider?: LighthouseProvider;
} = {
default_models: mergedDefaults,
};
// Set this provider as default if no default provider is currently set
if (!existingDefaultProvider) {
updatePayload.default_provider = provider;
}
const result = await updateTenantConfig(updatePayload);
if (result.errors) {
throw new Error(
result.errors[0]?.detail || "Failed to save default model",
);
}
onSelect();
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setIsSaving(false);
}
};
// Filter models based on search query and sort with recommended models first
const filteredModels = models
.filter(
(model) =>
model.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
model.id.toLowerCase().includes(searchQuery.toLowerCase()),
)
.sort((a, b) => {
const aRecommended = isRecommended(a.id);
const bRecommended = isRecommended(b.id);
// Recommended models first
if (aRecommended && !bRecommended) return -1;
if (!aRecommended && bRecommended) return 1;
// Then alphabetically by name
return a.name.localeCompare(b.name);
});
return (
<div className="flex w-full flex-col gap-6">
<div className="flex items-start justify-between">
<div>
<h2 className="mb-2 text-xl font-semibold">
{isEditMode ? "Update Default Model" : "Select Default Model"}
</h2>
<p className="text-sm text-gray-600 dark:text-gray-300">
{isEditMode
? "Update the default model to use with this provider."
: "Choose the default model to use with this provider."}
</p>
</div>
<button
onClick={() => fetchModels(true)}
disabled={isLoading}
className="flex items-center gap-2 rounded-lg px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-100 disabled:opacity-50 dark:text-gray-300 dark:hover:bg-gray-800"
aria-label="Refresh models"
>
<Icon
icon="heroicons:arrow-path"
className={`h-5 w-5 ${isLoading ? "animate-spin" : ""}`}
/>
<span>{isLoading ? "Refreshing..." : "Refresh"}</span>
</button>
</div>
{error && (
<div className="rounded-lg border border-red-200 bg-red-50 p-4 dark:border-red-800 dark:bg-red-900/20">
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
</div>
)}
{!isLoading && models.length > 0 && (
<div className="relative">
<Icon
icon="heroicons:magnifying-glass"
className="pointer-events-none absolute top-1/2 left-3 h-5 w-5 -translate-y-1/2 text-gray-400"
/>
<input
type="text"
placeholder="Search models..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full rounded-lg border border-gray-300 py-2.5 pr-4 pl-11 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none dark:border-gray-700 dark:bg-gray-800 dark:text-white"
/>
</div>
)}
{isLoading ? (
<div className="flex items-center justify-center py-12">
<Icon
icon="heroicons:arrow-path"
className="h-8 w-8 animate-spin text-gray-400"
/>
</div>
) : models.length === 0 ? (
<div className="rounded-lg border border-gray-200 bg-gray-50 p-8 text-center dark:border-gray-700 dark:bg-gray-800">
<p className="text-sm text-gray-600 dark:text-gray-400">
No models available. Click refresh to fetch models.
</p>
</div>
) : filteredModels.length === 0 ? (
<div className="rounded-lg border border-gray-200 bg-gray-50 p-8 text-center dark:border-gray-700 dark:bg-gray-800">
<p className="text-sm text-gray-600 dark:text-gray-400">
No models found matching &quot;{searchQuery}&quot;
</p>
</div>
) : (
<div className="max-h-[calc(100vh-380px)] overflow-y-auto rounded-lg border border-gray-200 dark:border-gray-700">
{filteredModels.map((model) => (
<label
key={model.id}
htmlFor={`model-${provider}-${model.id}`}
aria-label={model.name}
className={`block cursor-pointer border-b border-gray-200 px-6 py-4 transition-colors last:border-b-0 dark:border-gray-700 ${
selectedModel === model.id
? "bg-blue-50 dark:bg-blue-900/20"
: "hover:bg-gray-50 dark:hover:bg-gray-800"
}`}
>
<div className="flex items-center gap-4">
<input
id={`model-${provider}-${model.id}`}
name="model"
type="radio"
checked={selectedModel === model.id}
onChange={() => setSelectedModel(model.id)}
className="h-4 w-4 cursor-pointer"
/>
<div className="flex items-center gap-2">
<span className="text-sm font-medium">{model.name}</span>
{isRecommended(model.id) && (
<span className="inline-flex items-center gap-1 rounded-full bg-blue-100 px-2 py-0.5 text-xs font-medium text-blue-800 dark:bg-blue-900/30 dark:text-blue-400">
<Icon icon="heroicons:star-solid" className="h-3 w-3" />
Recommended
</span>
)}
</div>
</div>
</label>
))}
</div>
)}
<div className="flex flex-col gap-4">
<div className="flex justify-end">
<CustomButton
ariaLabel="Select Model"
variant="solid"
color="action"
size="md"
isDisabled={!selectedModel || isSaving}
isLoading={isSaving}
onPress={handleSelect}
>
{isSaving ? "Saving..." : "Select"}
</CustomButton>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1 @@
export * from "./workflow-connect-llm";

View File

@@ -0,0 +1,86 @@
"use client";
import { Progress } from "@heroui/progress";
import { Spacer } from "@heroui/spacer";
import { usePathname, useSearchParams } from "next/navigation";
import React from "react";
import { VerticalSteps } from "@/components/providers/workflow/vertical-steps";
import type { LighthouseProvider } from "@/types/lighthouse";
import { getProviderConfig } from "../llm-provider-registry";
const steps = [
{
title: "Enter Credentials",
description:
"Enter your API key and configure connection settings for the LLM provider.",
href: "/lighthouse/config/connect",
},
{
title: "Select Default Model",
description:
"Choose the default model to use for AI-powered features in Prowler.",
href: "/lighthouse/config/select-model",
},
];
const ROUTE_CONFIG: Record<
string,
{
stepIndex: number;
}
> = {
"/lighthouse/config/connect": { stepIndex: 0 },
"/lighthouse/config/select-model": { stepIndex: 1 },
};
export const WorkflowConnectLLM = () => {
const pathname = usePathname();
const searchParams = useSearchParams();
const config = ROUTE_CONFIG[pathname] || { stepIndex: 0 };
const currentStep = config.stepIndex;
const provider = searchParams.get("provider") as LighthouseProvider | null;
const mode = searchParams.get("mode");
const isEditMode = mode === "edit";
// Get provider name from registry
const providerConfig = provider ? getProviderConfig(provider) : null;
const providerName = providerConfig?.name || "LLM Provider";
return (
<section className="max-w-sm">
<h1 className="mb-2 text-xl font-medium" id="getting-started">
{isEditMode ? `Configure ${providerName}` : `Connect ${providerName}`}
</h1>
<p className="text-small text-default-500 mb-5">
{isEditMode
? "Update your LLM provider configuration and settings."
: "Follow these steps to configure your LLM provider and enable AI-powered features."}
</p>
<Progress
classNames={{
base: "px-0.5 mb-5",
label: "text-small",
value: "text-small text-default-400",
}}
label="Steps"
maxValue={steps.length - 1}
minValue={0}
showValueLabel={true}
size="md"
value={currentStep}
valueLabel={`${currentStep + 1} of ${steps.length}`}
/>
<VerticalSteps
hideProgressBars
currentStep={currentStep}
stepClassName="border border-default-200 dark:border-default-50 aria-[current]:bg-default-100 dark:aria-[current]:bg-prowler-blue-800 cursor-default"
steps={steps}
/>
<Spacer y={4} />
</section>
);
};

View File

@@ -4,6 +4,7 @@ export * from "./alert/Alert";
export * from "./alert-dialog/AlertDialog";
export * from "./breadcrumbs";
export * from "./chart/Chart";
export * from "./collapsible/collapsible";
export * from "./content-layout/content-layout";
export * from "./dialog/dialog";
export * from "./download-icon-button/download-icon-button";

View File

@@ -31,13 +31,21 @@
"strategy": "installed",
"generatedAt": "2025-10-22T12:36:37.962Z"
},
{
"section": "dependencies",
"name": "@langchain/aws",
"from": "0.1.15",
"to": "0.1.15",
"strategy": "installed",
"generatedAt": "2025-11-03T07:43:34.628Z"
},
{
"section": "dependencies",
"name": "@langchain/core",
"from": "0.3.77",
"to": "0.3.77",
"to": "0.3.78",
"strategy": "installed",
"generatedAt": "2025-10-22T12:36:37.962Z"
"generatedAt": "2025-11-03T07:43:34.628Z"
},
{
"section": "dependencies",
@@ -59,9 +67,9 @@
"section": "dependencies",
"name": "@langchain/openai",
"from": "0.5.18",
"to": "0.5.18",
"to": "0.6.16",
"strategy": "installed",
"generatedAt": "2025-10-22T12:36:37.962Z"
"generatedAt": "2025-11-03T07:43:34.628Z"
},
{
"section": "dependencies",
@@ -399,6 +407,14 @@
"strategy": "installed",
"generatedAt": "2025-10-22T12:36:37.962Z"
},
{
"section": "dependencies",
"name": "streamdown",
"from": "1.3.0",
"to": "1.3.0",
"strategy": "installed",
"generatedAt": "2025-11-03T07:43:34.628Z"
},
{
"section": "dependencies",
"name": "tailwind-merge",

View File

@@ -1,6 +1,21 @@
import { getProviders } from "@/actions/providers/providers";
import { getScans } from "@/actions/scans/scans";
import { getUserInfo } from "@/actions/users/users";
import type { ProviderProps } from "@/types/providers";
interface ProviderEntry {
alias: string;
name: string;
provider_type: string;
id: string;
last_checked_at: string;
}
interface ProviderWithScans extends ProviderEntry {
scan_id?: string;
scan_duration?: number;
resource_count?: number;
}
export async function getCurrentDataSection(): Promise<string> {
try {
@@ -22,17 +37,19 @@ export async function getCurrentDataSection(): Promise<string> {
throw new Error("Unable to fetch providers data");
}
const providerEntries = providersData.data.map((provider: any) => ({
alias: provider.attributes?.alias || "Unknown",
name: provider.attributes?.uid || "Unknown",
provider_type: provider.attributes?.provider || "Unknown",
id: provider.id || "Unknown",
last_checked_at:
provider.attributes?.connection?.last_checked_at || "Unknown",
}));
const providerEntries: ProviderEntry[] = providersData.data.map(
(provider: ProviderProps) => ({
alias: provider.attributes?.alias || "Unknown",
name: provider.attributes?.uid || "Unknown",
provider_type: provider.attributes?.provider || "Unknown",
id: provider.id || "Unknown",
last_checked_at:
provider.attributes?.connection?.last_checked_at || "Unknown",
}),
);
const providersWithScans = await Promise.all(
providerEntries.map(async (provider: any) => {
const providersWithScans: ProviderWithScans[] = await Promise.all(
providerEntries.map(async (provider: ProviderEntry) => {
try {
// Get scan data for this provider
const scansData = await getScans({

View File

@@ -0,0 +1,79 @@
import { ChatBedrockConverse } from "@langchain/aws";
import { BaseChatModel } from "@langchain/core/language_models/chat_models";
import { ChatOpenAI } from "@langchain/openai";
export type ProviderType = "openai" | "bedrock" | "openai_compatible";
export interface LLMCredentials {
api_key?: string;
access_key_id?: string;
secret_access_key?: string;
region?: string;
}
export interface LLMConfig {
provider: ProviderType;
model: string;
credentials: LLMCredentials;
baseUrl?: string;
streaming?: boolean;
tags?: string[];
modelParams?: {
maxTokens?: number;
temperature?: number;
reasoningEffort?: string;
};
}
export function createLLM(config: LLMConfig): BaseChatModel {
switch (config.provider) {
case "openai":
return new ChatOpenAI({
modelName: config.model,
apiKey: config.credentials.api_key,
streaming: config.streaming,
tags: config.tags,
maxTokens: config.modelParams?.maxTokens,
temperature: config.modelParams?.temperature,
});
case "openai_compatible":
return new ChatOpenAI({
modelName: config.model,
apiKey: config.credentials.api_key,
configuration: {
baseURL: config.baseUrl,
},
streaming: config.streaming,
tags: config.tags,
maxTokens: config.modelParams?.maxTokens,
temperature: config.modelParams?.temperature,
});
case "bedrock":
if (
!config.credentials.access_key_id ||
!config.credentials.secret_access_key ||
!config.credentials.region
) {
throw new Error(
"Bedrock provider requires access_key_id, secret_access_key, and region",
);
}
return new ChatBedrockConverse({
model: config.model,
region: config.credentials.region,
credentials: {
accessKeyId: config.credentials.access_key_id,
secretAccessKey: config.credentials.secret_access_key,
},
streaming: config.streaming,
tags: config.tags,
maxTokens: config.modelParams?.maxTokens,
temperature: config.modelParams?.temperature,
});
default:
throw new Error(`Unknown provider type: ${config.provider}`);
}
}

View File

@@ -34,7 +34,11 @@ export const convertVercelMessageToLangChainMessage = (
}
};
export const getModelParams = (config: any): ModelParams => {
export const getModelParams = (config: {
model: string;
max_tokens?: number;
temperature?: number;
}): ModelParams => {
const modelId = config.model;
const params: ModelParams = {

View File

@@ -0,0 +1,68 @@
import type { LighthouseProvider } from "@/types/lighthouse";
import {
baseUrlSchema,
bedrockCredentialsSchema,
openAICompatibleCredentialsSchema,
openAICredentialsSchema,
} from "@/types/lighthouse/credentials";
/**
* Validate credentials based on provider type
*/
export function validateCredentials(
providerType: LighthouseProvider,
credentials: Record<string, string>,
): { success: boolean; error?: string } {
try {
switch (providerType) {
case "openai":
openAICredentialsSchema.parse(credentials);
break;
case "bedrock":
bedrockCredentialsSchema.parse(credentials);
break;
case "openai_compatible":
openAICompatibleCredentialsSchema.parse(credentials);
break;
default:
return {
success: false,
error: `Unknown provider type: ${providerType}`,
};
}
return { success: true };
} catch (error: unknown) {
const errorMessage =
(error as { issues?: Array<{ message: string }>; message?: string })
?.issues?.[0]?.message ||
(error as { message?: string })?.message ||
"Validation failed";
return {
success: false,
error: errorMessage,
};
}
}
/**
* Validate base URL
*/
export function validateBaseUrl(baseUrl: string): {
success: boolean;
error?: string;
} {
try {
baseUrlSchema.parse(baseUrl);
return { success: true };
} catch (error: unknown) {
const errorMessage =
(error as { issues?: Array<{ message: string }>; message?: string })
?.issues?.[0]?.message ||
(error as { message?: string })?.message ||
"Invalid base URL";
return {
success: false,
error: errorMessage,
};
}
}

View File

@@ -1,8 +1,12 @@
import { createReactAgent } from "@langchain/langgraph/prebuilt";
import { createSupervisor } from "@langchain/langgraph-supervisor";
import { ChatOpenAI } from "@langchain/openai";
import { getAIKey, getLighthouseConfig } from "@/actions/lighthouse/lighthouse";
import {
getProviderCredentials,
getTenantConfig,
} from "@/actions/lighthouse/lighthouse";
import type { ProviderType } from "@/lib/lighthouse/llm-factory";
import { createLLM } from "@/lib/lighthouse/llm-factory";
import {
complianceAgentPrompt,
findingsAgentPrompt,
@@ -49,26 +53,51 @@ import {
} from "@/lib/lighthouse/tools/users";
import { getModelParams } from "@/lib/lighthouse/utils";
export async function initLighthouseWorkflow() {
const apiKey = await getAIKey();
const lighthouseConfig = await getLighthouseConfig();
export interface RuntimeConfig {
model?: string;
provider?: string;
}
const modelParams = getModelParams(lighthouseConfig);
export async function initLighthouseWorkflow(runtimeConfig?: RuntimeConfig) {
const tenantConfigResult = await getTenantConfig();
const tenantConfig = tenantConfigResult?.data?.attributes;
// Initialize models without API keys
const llm = new ChatOpenAI({
model: lighthouseConfig.model,
apiKey: apiKey,
// Get the default provider and model
const defaultProvider = tenantConfig?.default_provider || "openai";
const defaultModels = tenantConfig?.default_models || {};
const defaultModel = defaultModels[defaultProvider] || "gpt-4o";
// Determine provider type and model ID from runtime config or defaults
const providerType = (runtimeConfig?.provider ||
defaultProvider) as ProviderType;
const modelId = runtimeConfig?.model || defaultModel;
// Get provider credentials and configuration
const providerConfig = await getProviderCredentials(providerType);
const { credentials, base_url: baseUrl } = providerConfig;
// Get model parameters
const modelParams = getModelParams({ model: modelId });
// Initialize models using the LLM factory
const llm = createLLM({
provider: providerType,
model: modelId,
credentials,
baseUrl,
streaming: true,
tags: ["agent"],
...modelParams,
modelParams,
});
const supervisorllm = new ChatOpenAI({
model: lighthouseConfig.model,
apiKey: apiKey,
const supervisorllm = createLLM({
provider: providerType,
model: modelId,
credentials,
baseUrl,
streaming: true,
tags: ["supervisor"],
...modelParams,
modelParams,
});
const providerAgent = createReactAgent({

3666
ui/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -27,10 +27,11 @@
"@ai-sdk/react": "2.0.59",
"@heroui/react": "2.8.4",
"@hookform/resolvers": "5.2.2",
"@langchain/core": "0.3.77",
"@langchain/aws": "0.1.15",
"@langchain/core": "0.3.78",
"@langchain/langgraph": "0.4.9",
"@langchain/langgraph-supervisor": "0.0.20",
"@langchain/openai": "0.5.18",
"@langchain/openai": "0.6.16",
"@next/third-parties": "15.3.5",
"@radix-ui/react-alert-dialog": "1.1.14",
"@radix-ui/react-dialog": "1.1.14",
@@ -73,6 +74,7 @@
"rss-parser": "3.13.0",
"server-only": "0.0.1",
"sharp": "0.33.5",
"streamdown": "1.3.0",
"tailwind-merge": "3.3.1",
"tailwindcss-animate": "1.0.7",
"topojson-client": "3.1.0",

View File

@@ -1,5 +1,6 @@
@import "tailwindcss";
@config "../tailwind.config.js";
@source "../node_modules/streamdown/dist/index.js";
@custom-variant dark (&:where(.dark, .dark *));

View File

@@ -0,0 +1,143 @@
import { z } from "zod";
/**
* Valid AWS regions for Bedrock
* Reference: https://docs.aws.amazon.com/bedrock/latest/userguide/models-regions.html
*/
const AWS_BEDROCK_REGIONS = [
// US Regions
"us-east-1",
"us-east-2",
"us-west-1",
"us-west-2",
"us-gov-east-1",
"us-gov-west-1",
"ap-taipei-1",
"ap-northeast-1",
"ap-northeast-2",
"ap-northeast-3",
"ap-south-1",
"ap-south-2",
"ap-southeast-1",
"ap-southeast-2",
"ap-southeast-3",
"ap-southeast-4",
"ap-southeast-5",
"ap-southeast-6",
"ca-central-1",
"eu-central-1",
"eu-central-2",
"eu-north-1",
"eu-south-1",
"eu-south-2",
"eu-west-1",
"eu-west-2",
"eu-west-3",
"il-central-1",
"me-central-1",
"sa-east-1",
] as const;
/**
* OpenAI API Key validation
* Format: sk-... or sk-proj-... (32+ characters after prefix)
*/
export const openAIApiKeySchema = z
.string()
.min(1, "API key is required")
.regex(
/^sk-(proj-)?[A-Za-z0-9_-]{32,}$/,
"Invalid API key format. OpenAI keys should start with 'sk-' or 'sk-proj-' followed by at least 32 characters",
);
/**
* AWS Access Key ID validation (long-term credentials only)
* Format: AKIA... (20 characters total)
*/
export const awsAccessKeyIdSchema = z
.string()
.min(1, "AWS Access Key ID is required")
.regex(/^AKIA[A-Z0-9]{16}$/, "Invalid AWS Access Key ID");
/**
* AWS Secret Access Key validation
* Format: 40 characters (alphanumeric + special chars)
*/
export const awsSecretAccessKeySchema = z
.string()
.min(1, "AWS Secret Access Key is required")
.regex(/^[A-Za-z0-9/+=]{40}$/, "Invalid AWS Secret Access Key");
/**
* AWS Region validation for Bedrock
*/
export const awsRegionSchema = z
.string()
.min(1, "AWS Region is required")
.refine((region) => AWS_BEDROCK_REGIONS.includes(region as any), {
message: `Invalid AWS region. Must be one of: ${AWS_BEDROCK_REGIONS.join(", ")}`,
});
/**
* Base URL validation for OpenAI-compatible providers
* Must be a valid HTTP/HTTPS URL
*/
export const baseUrlSchema = z
.string()
.min(1, "Base URL is required")
.refine((url) => {
try {
const parsed = new URL(url);
return parsed.protocol === "http:" || parsed.protocol === "https:";
} catch {
return false;
}
}, "Invalid URL format. Must be a valid HTTP or HTTPS URL");
/**
* Generic API Key validation (for OpenAI-compatible providers with unknown formats)
*/
export const genericApiKeySchema = z
.string()
.min(8, "API key must be at least 8 characters")
.max(512, "API key cannot exceed 512 characters");
/**
* OpenAI Provider Credentials Schema
*/
export const openAICredentialsSchema = z.object({
api_key: openAIApiKeySchema,
});
/**
* Amazon Bedrock Provider Credentials Schema
*/
export const bedrockCredentialsSchema = z.object({
access_key_id: awsAccessKeyIdSchema,
secret_access_key: awsSecretAccessKeySchema,
region: awsRegionSchema,
});
/**
* OpenAI Compatible Provider Credentials Schema
*/
export const openAICompatibleCredentialsSchema = z.object({
api_key: genericApiKeySchema,
});
/**
* Full OpenAI Compatible Config (includes base_url)
*/
export const openAICompatibleConfigSchema = z.object({
credentials: openAICompatibleCredentialsSchema,
base_url: baseUrlSchema,
});
/**
* Type exports for all provider credentials
*/
export type OpenAICredentials = z.infer<typeof openAICredentialsSchema>;
export type BedrockCredentials = z.infer<typeof bedrockCredentialsSchema>;
export type OpenAICompatibleCredentials = z.infer<
typeof openAICompatibleCredentialsSchema
>;

View File

@@ -1,6 +1,8 @@
export * from "./checks";
export * from "./compliances";
export * from "./credentials";
export * from "./findings";
export * from "./lighthouse-providers";
export * from "./model-params";
export * from "./overviews";
export * from "./providers";

View File

@@ -0,0 +1,13 @@
export const LIGHTHOUSE_PROVIDERS = [
"openai",
"bedrock",
"openai_compatible",
] as const;
export type LighthouseProvider = (typeof LIGHTHOUSE_PROVIDERS)[number];
export const PROVIDER_DISPLAY_NAMES = {
openai: "OpenAI",
bedrock: "Amazon Bedrock",
openai_compatible: "OpenAI Compatible",
} as const satisfies Record<LighthouseProvider, string>;