mirror of
https://github.com/prowler-cloud/prowler.git
synced 2025-12-19 05:17:47 +00:00
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:
committed by
GitHub
parent
866edfb167
commit
031548ca7e
@@ -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)
|
||||
|
||||
@@ -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) }],
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
128
ui/app/(prowler)/lighthouse/config/(connect-llm)/layout.tsx
Normal file
128
ui/app/(prowler)/lighthouse/config/(connect-llm)/layout.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
@@ -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 };
|
||||
67
ui/components/lighthouse/ai-elements/actions.tsx
Normal file
67
ui/components/lighthouse/ai-elements/actions.tsx
Normal 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;
|
||||
};
|
||||
60
ui/components/lighthouse/ai-elements/button.tsx
Normal file
60
ui/components/lighthouse/ai-elements/button.tsx
Normal 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 };
|
||||
257
ui/components/lighthouse/ai-elements/dropdown-menu.tsx
Normal file
257
ui/components/lighthouse/ai-elements/dropdown-menu.tsx
Normal 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,
|
||||
};
|
||||
171
ui/components/lighthouse/ai-elements/input-group.tsx
Normal file
171
ui/components/lighthouse/ai-elements/input-group.tsx
Normal 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,
|
||||
};
|
||||
21
ui/components/lighthouse/ai-elements/input.tsx
Normal file
21
ui/components/lighthouse/ai-elements/input.tsx
Normal 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 };
|
||||
1168
ui/components/lighthouse/ai-elements/prompt-input.tsx
Normal file
1168
ui/components/lighthouse/ai-elements/prompt-input.tsx
Normal file
File diff suppressed because it is too large
Load Diff
187
ui/components/lighthouse/ai-elements/select.tsx
Normal file
187
ui/components/lighthouse/ai-elements/select.tsx
Normal 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,
|
||||
};
|
||||
18
ui/components/lighthouse/ai-elements/textarea.tsx
Normal file
18
ui/components/lighthouse/ai-elements/textarea.tsx
Normal 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 };
|
||||
61
ui/components/lighthouse/ai-elements/tooltip.tsx
Normal file
61
ui/components/lighthouse/ai-elements/tooltip.tsx
Normal 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 };
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
271
ui/components/lighthouse/connect-llm-provider.tsx
Normal file
271
ui/components/lighthouse/connect-llm-provider.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
91
ui/components/lighthouse/forms/delete-llm-provider-form.tsx
Normal file
91
ui/components/lighthouse/forms/delete-llm-provider-form.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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";
|
||||
|
||||
151
ui/components/lighthouse/lighthouse-settings.tsx
Normal file
151
ui/components/lighthouse/lighthouse-settings.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
117
ui/components/lighthouse/llm-provider-registry.ts
Normal file
117
ui/components/lighthouse/llm-provider-registry.ts
Normal 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 ?? [];
|
||||
};
|
||||
139
ui/components/lighthouse/llm-provider-utils.ts
Normal file
139
ui/components/lighthouse/llm-provider-utils.ts
Normal 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;
|
||||
};
|
||||
237
ui/components/lighthouse/llm-providers-table.tsx
Normal file
237
ui/components/lighthouse/llm-providers-table.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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";
|
||||
273
ui/components/lighthouse/select-model.tsx
Normal file
273
ui/components/lighthouse/select-model.tsx
Normal 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 "{searchQuery}"
|
||||
</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>
|
||||
);
|
||||
};
|
||||
1
ui/components/lighthouse/workflow/index.ts
Normal file
1
ui/components/lighthouse/workflow/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./workflow-connect-llm";
|
||||
86
ui/components/lighthouse/workflow/workflow-connect-llm.tsx
Normal file
86
ui/components/lighthouse/workflow/workflow-connect-llm.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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";
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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({
|
||||
|
||||
79
ui/lib/lighthouse/llm-factory.ts
Normal file
79
ui/lib/lighthouse/llm-factory.ts
Normal 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}`);
|
||||
}
|
||||
}
|
||||
@@ -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 = {
|
||||
|
||||
68
ui/lib/lighthouse/validation.ts
Normal file
68
ui/lib/lighthouse/validation.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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
3666
ui/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
@import "tailwindcss";
|
||||
@config "../tailwind.config.js";
|
||||
@source "../node_modules/streamdown/dist/index.js";
|
||||
|
||||
@custom-variant dark (&:where(.dark, .dark *));
|
||||
|
||||
|
||||
143
ui/types/lighthouse/credentials.ts
Normal file
143
ui/types/lighthouse/credentials.ts
Normal 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
|
||||
>;
|
||||
@@ -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";
|
||||
|
||||
13
ui/types/lighthouse/lighthouse-providers.ts
Normal file
13
ui/types/lighthouse/lighthouse-providers.ts
Normal 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>;
|
||||
Reference in New Issue
Block a user