diff --git a/ui/app/(prowler)/lighthouse/page.tsx b/ui/app/(prowler)/lighthouse/page.tsx index f283237f2f..bc2a5d8273 100644 --- a/ui/app/(prowler)/lighthouse/page.tsx +++ b/ui/app/(prowler)/lighthouse/page.tsx @@ -1,16 +1,55 @@ import { getLighthouseConfig } from "@/actions/lighthouse/lighthouse"; import { Chat } from "@/components/lighthouse"; import { ContentLayout } from "@/components/ui"; +import { CacheService } from "@/lib/lighthouse/cache"; +import { suggestedActions } from "@/lib/lighthouse/suggested-actions"; -export default async function AIChatbot() { +interface LighthousePageProps { + searchParams: { cachedMessage?: string }; +} + +export default async function AIChatbot({ searchParams }: LighthousePageProps) { const config = await getLighthouseConfig(); const hasConfig = !!config; const isActive = config?.attributes?.is_active ?? false; + // Fetch cached content if a cached message type is specified + let cachedContent = null; + if (searchParams.cachedMessage) { + const cached = await CacheService.getCachedMessage( + searchParams.cachedMessage, + ); + cachedContent = cached.success ? cached.data : null; + } + + // Pre-fetch all question answers and processing status + const isProcessing = await CacheService.isRecommendationProcessing(); + const questionAnswers: Record = {}; + + if (!isProcessing) { + for (const action of suggestedActions) { + if (action.questionRef) { + const cached = await CacheService.getCachedMessage( + `question_${action.questionRef}`, + ); + if (cached.success && cached.data) { + questionAnswers[action.questionRef] = cached.data; + } + } + } + } + return ( - + ); } diff --git a/ui/components/lighthouse/banner.tsx b/ui/components/lighthouse/banner.tsx index ada1811cf8..2afc706fc6 100644 --- a/ui/components/lighthouse/banner.tsx +++ b/ui/components/lighthouse/banner.tsx @@ -62,7 +62,7 @@ export const LighthouseBanner = async () => { ) { return renderBanner({ message: cachedRecommendations.data, - href: "/lighthouse", + href: "/lighthouse?cachedMessage=recommendation", gradient: "bg-gradient-to-r from-blue-500 to-purple-600 hover:from-blue-600 hover:to-purple-700 focus:ring-blue-500/50 dark:from-blue-600 dark:to-purple-700 dark:hover:from-blue-700 dark:hover:to-purple-800 dark:focus:ring-blue-400/50", }); @@ -74,7 +74,7 @@ export const LighthouseBanner = async () => { if (isProcessing) { return renderBanner({ message: "Lighthouse Is Reviewing Your Findings for Insights", - href: "/lighthouse", + href: "", gradient: "bg-gradient-to-r from-orange-500 to-yellow-500 hover:from-orange-600 hover:to-yellow-600 focus:ring-orange-500/50 dark:from-orange-600 dark:to-yellow-600 dark:hover:from-orange-700 dark:hover:to-yellow-700 dark:focus:ring-orange-400/50", animate: true, diff --git a/ui/components/lighthouse/chat.tsx b/ui/components/lighthouse/chat.tsx index cfc350f4da..6f0340ae24 100644 --- a/ui/components/lighthouse/chat.tsx +++ b/ui/components/lighthouse/chat.tsx @@ -2,43 +2,64 @@ import { useChat } from "@ai-sdk/react"; import Link from "next/link"; -import { useEffect, useRef } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { useForm } from "react-hook-form"; import { MemoizedMarkdown } from "@/components/lighthouse/memoized-markdown"; import { CustomButton, CustomTextarea } from "@/components/ui/custom"; import { Form } from "@/components/ui/form"; - -interface SuggestedAction { - title: string; - label: string; - action: string; -} +import { + SuggestedAction, + suggestedActions, +} from "@/lib/lighthouse/suggested-actions"; interface ChatProps { hasConfig: boolean; isActive: boolean; + cachedContent?: string | null; + messageType?: string; + isProcessing: boolean; + questionAnswers: Record; } interface ChatFormData { message: string; } -export const Chat = ({ hasConfig, isActive }: ChatProps) => { - const { messages, handleSubmit, handleInputChange, append, status } = useChat( - { - api: "/api/lighthouse/analyst", - credentials: "same-origin", - experimental_throttle: 100, - sendExtraMessageFields: true, - onFinish: () => { - // Handle chat completion - }, - onError: (error) => { - console.error("Chat error:", error); - }, +export const Chat = ({ + hasConfig, + isActive, + cachedContent, + messageType, + isProcessing, + questionAnswers, +}: ChatProps) => { + const { + messages, + handleSubmit, + handleInputChange, + append, + status, + setMessages, + } = useChat({ + api: "/api/lighthouse/analyst", + credentials: "same-origin", + experimental_throttle: 100, + sendExtraMessageFields: true, + onFinish: () => { + // Handle chat completion }, + onError: (error) => { + console.error("Chat error:", error); + }, + }); + + // State for cached response streaming simulation + const [isStreamingCached, setIsStreamingCached] = useState(false); + const [streamingMessageId, setStreamingMessageId] = useState( + null, ); + const [currentStreamText, setCurrentStreamText] = useState(""); const form = useForm({ defaultValues: { @@ -50,6 +71,149 @@ export const Chat = ({ hasConfig, isActive }: ChatProps) => { const messagesContainerRef = useRef(null); const latestUserMsgRef = useRef(null); + // Function to simulate streaming text + const simulateStreaming = useCallback( + async (text: string, messageId: string) => { + setIsStreamingCached(true); + setStreamingMessageId(messageId); + setCurrentStreamText(""); + + // Stream word by word with realistic delays + const words = text.split(" "); + let currentText = ""; + + for (let i = 0; i < words.length; i++) { + currentText += (i > 0 ? " " : "") + words[i]; + setCurrentStreamText(currentText); + + // Shorter delay between words for faster streaming + const delay = Math.random() * 80 + 40; // 40-120ms delay per word + await new Promise((resolve) => setTimeout(resolve, delay)); + } + + setIsStreamingCached(false); + setStreamingMessageId(null); + setCurrentStreamText(""); + }, + [], + ); + + // Function to handle cached response for suggested actions + const handleCachedResponse = useCallback( + async (action: SuggestedAction) => { + if (!action.questionRef) { + // No question ref, use normal flow + append({ + role: "user", + content: action.action, + }); + return; + } + + try { + if (isProcessing) { + // Processing in progress, fallback to real-time LLM + append({ + role: "user", + content: action.action, + }); + return; + } + + // Check if we have cached answer + const cachedAnswer = questionAnswers[action.questionRef]; + + if (cachedAnswer) { + // Cache hit - use cached content with streaming simulation + const userMessageId = `user-cached-${Date.now()}`; + const assistantMessageId = `assistant-cached-${Date.now()}`; + + const userMessage = { + id: userMessageId, + role: "user" as const, + content: action.action, + }; + + const assistantMessage = { + id: assistantMessageId, + role: "assistant" as const, + content: "", + }; + + const updatedMessages = [...messages, userMessage, assistantMessage]; + setMessages(updatedMessages); + + // Start streaming simulation + setTimeout(() => { + simulateStreaming(cachedAnswer, assistantMessageId); + }, 300); + } else { + // Cache miss/expired/error - fallback to real-time LLM + append({ + role: "user", + content: action.action, + }); + } + } catch (error) { + console.error("Error handling cached response:", error); + // Fall back to normal API flow + append({ + role: "user", + content: action.action, + }); + } + }, + [ + messages, + setMessages, + append, + simulateStreaming, + isProcessing, + questionAnswers, + ], + ); + + // Load cached message on mount if cachedContent is provided + useEffect(() => { + const loadCachedMessage = () => { + if (cachedContent && messages.length === 0) { + // Create different user questions based on message type + let userQuestion = "Tell me more about this"; + + if (messageType === "recommendation") { + userQuestion = + "Tell me more about the security issues Lighthouse found"; + } + // Future: handle other message types + // else if (messageType === "question_1") { + // userQuestion = "Previously cached question here"; + // } + + // Create message IDs + const userMessageId = `user-cached-${messageType}-${Date.now()}`; + const assistantMessageId = `assistant-cached-${messageType}-${Date.now()}`; + + // Add user message + const userMessage = { + id: userMessageId, + role: "user" as const, + content: userQuestion, + }; + + // Add assistant message with the cached content + const assistantMessage = { + id: assistantMessageId, + role: "assistant" as const, + content: cachedContent, + }; + + setMessages([userMessage, assistantMessage]); + } + }; + + loadCachedMessage(); + }, [cachedContent, messageType, messages.length, setMessages]); + // Sync form value with chat input useEffect(() => { const syntheticEvent = { @@ -86,6 +250,19 @@ export const Chat = ({ hasConfig, isActive }: ChatProps) => { return () => document.removeEventListener("keydown", handleKeyDown); }, [messageValue, onFormSubmit]); + // Update assistant message content during streaming simulation + useEffect(() => { + if (isStreamingCached && streamingMessageId && currentStreamText) { + setMessages((prevMessages) => + prevMessages.map((msg) => + msg.id === streamingMessageId + ? { ...msg, content: currentStreamText } + : msg, + ), + ); + } + }, [currentStreamText, isStreamingCached, streamingMessageId, setMessages]); + useEffect(() => { if (messagesContainerRef.current && latestUserMsgRef.current) { const container = messagesContainerRef.current; @@ -96,30 +273,6 @@ export const Chat = ({ hasConfig, isActive }: ChatProps) => { } }, [messages]); - 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; @@ -158,10 +311,7 @@ export const Chat = ({ hasConfig, isActive }: ChatProps) => { key={`suggested-action-${index}`} ariaLabel={`Send message: ${action.action}`} onPress={() => { - append({ - role: "user", - content: action.action, - }); + handleCachedResponse(action); // Use cached response handler }} className="hover:bg-muted flex h-auto w-full flex-col items-start justify-start rounded-xl border bg-gray-50 px-4 py-3.5 text-left font-sans text-sm dark:bg-gray-900" > @@ -211,10 +361,12 @@ export const Chat = ({ hasConfig, isActive }: ChatProps) => { ); })} - {status === "submitted" && ( + {(status === "submitted" || isStreamingCached) && (
-
Thinking...
+
+ {isStreamingCached ? "" : "Thinking..."} +
)} @@ -245,10 +397,18 @@ export const Chat = ({ hasConfig, isActive }: ChatProps) => { ariaLabel={ status === "submitted" ? "Stop generation" : "Send message" } - isDisabled={status === "submitted" || !messageValue?.trim()} + isDisabled={ + status === "submitted" || + isStreamingCached || + !messageValue?.trim() + } className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-lg bg-primary p-2 text-primary-foreground hover:bg-primary/90 disabled:opacity-50 dark:bg-primary/90" > - {status === "submitted" ? : } + {status === "submitted" || isStreamingCached ? ( + + ) : ( + + )} diff --git a/ui/lib/lighthouse/cache.ts b/ui/lib/lighthouse/cache.ts index 5d66af11c6..071ddbf702 100644 --- a/ui/lib/lighthouse/cache.ts +++ b/ui/lib/lighthouse/cache.ts @@ -2,11 +2,16 @@ import Valkey from "iovalkey"; import { auth } from "@/auth.config"; -import { generateRecommendation } from "./recommendations"; import { + generateBannerFromDetailed, + generateDetailedRecommendation, + generateQuestionAnswers, +} from "./recommendations"; +import { suggestedActions } from "./suggested-actions"; +import { + compareProcessedScanIds, generateSecurityScanSummary, getCompletedScansLast24h, - compareProcessedScanIds, } from "./summary"; let valkeyClient: Valkey | null = null; @@ -136,18 +141,12 @@ export class CacheService { await this.setProcessedScanIds(scanIds); // Generate and cache recommendations asynchronously - this.generateAndCacheRecommendations(scanSummary) - .then((result) => { - if (result.success && result.data) { - console.log("Background recommendation generated successfully"); - } - }) - .catch((error) => { - console.error( - "Background recommendation generation failed:", - error, - ); - }); + this.generateAndCacheRecommendations(scanSummary).catch((error) => { + console.error( + "Background recommendation generation failed:", + error, + ); + }); return { success: true, @@ -207,10 +206,6 @@ export class CacheService { } } - static async getCachedMessage(): Promise { - return await this.get("scan-summary"); - } - static async getRecommendations(): Promise<{ success: boolean; data?: string; @@ -245,6 +240,7 @@ export class CacheService { const lockKey = "recommendations-processing"; const dataKey = `_lighthouse:${tenantId}:recommendations`; + const detailedDataKey = `_lighthouse:${tenantId}:cached-messages:recommendation`; try { const client = await getValkeyClient(); @@ -280,17 +276,40 @@ export class CacheService { }; } - // Generate recommendation using LLM - const recommendation = await generateRecommendation(scanSummary); + // Generate detailed recommendation first + const detailedRecommendation = + await generateDetailedRecommendation(scanSummary); - // Only cache non-empty recommendations - if (recommendation.trim()) { - await client.set(dataKey, recommendation); + if (!detailedRecommendation.trim()) { + return { success: true, data: "" }; + } + + // Generate banner from detailed content + const bannerRecommendation = await generateBannerFromDetailed( + detailedRecommendation, + ); + + // Both must succeed - no point in detailed without banner + if (!bannerRecommendation.trim()) { + return { success: true, data: "" }; + } + + // Generate question answers + const questionAnswers = await generateQuestionAnswers(suggestedActions); + + // Cache both versions + await client.set(dataKey, bannerRecommendation); + await client.set(detailedDataKey, detailedRecommendation); + + // Cache question answers with 24h TTL + for (const [questionRef, answer] of Object.entries(questionAnswers)) { + const questionKey = `_lighthouse:${tenantId}:cached-messages:question_${questionRef}`; + await client.set(questionKey, answer, "EX", 86400); // 24 hours } return { success: true, - data: recommendation, + data: bannerRecommendation, }; } finally { await this.releaseProcessingLock(tenantId, lockKey); @@ -315,6 +334,52 @@ export class CacheService { return false; } } + + // New method to get cached message by type + static async getCachedMessage(messageType: string): Promise<{ + success: boolean; + data?: string; + }> { + const tenantId = await this.getTenantId(); + if (!tenantId) return { success: false }; + + try { + const client = await getValkeyClient(); + const dataKey = `_lighthouse:${tenantId}:cached-messages:${messageType}`; + + const cachedData = await client.get(dataKey); + if (cachedData) { + return { + success: true, + data: cachedData.toString(), + }; + } + + return { success: true, data: undefined }; + } catch (error) { + console.error(`Error getting cached message ${messageType}:`, error); + return { success: false }; + } + } + + // New method to set cached message by type + static async setCachedMessage( + messageType: string, + content: string, + ): Promise { + const tenantId = await this.getTenantId(); + if (!tenantId) return false; + + try { + const client = await getValkeyClient(); + const dataKey = `_lighthouse:${tenantId}:cached-messages:${messageType}`; + await client.set(dataKey, content); + return true; + } catch (error) { + console.error(`Error caching message type ${messageType}:`, error); + return false; + } + } } export async function initializeTenantCache(): Promise<{ diff --git a/ui/lib/lighthouse/recommendations.ts b/ui/lib/lighthouse/recommendations.ts index bf2837699b..d8186d6c3e 100644 --- a/ui/lib/lighthouse/recommendations.ts +++ b/ui/lib/lighthouse/recommendations.ts @@ -2,7 +2,10 @@ import { ChatOpenAI } from "@langchain/openai"; import { getAIKey, getLighthouseConfig } from "@/actions/lighthouse/lighthouse"; -export const generateRecommendation = async ( +import { type SuggestedAction } from "./suggested-actions"; +import { initLighthouseWorkflow } from "./workflow"; + +export const generateDetailedRecommendation = async ( scanSummary: string, ): Promise => { try { @@ -11,52 +14,56 @@ export const generateRecommendation = async ( return ""; } - // Get lighthouse configuration const lighthouseConfig = await getLighthouseConfig(); if (!lighthouseConfig?.attributes) { return ""; } const config = lighthouseConfig.attributes; - const finalBusinessContext = config.business_context || ""; + const businessContext = config.business_context || ""; const llm = new ChatOpenAI({ model: config.model || "gpt-4o", temperature: config.temperature || 0, - maxTokens: 150, + maxTokens: 1500, apiKey: apiKey, }); - // Build the prompt with business context awareness - let systemPrompt = `You are a cloud security analyst creating concise business recommendations for a banner notification. + let systemPrompt = `You are a cloud security analyst providing focused, actionable recommendations. -IMPORTANT: Your response must be a single, short sentence (max 80 characters) that would make a user want to click on a banner to learn more. +IMPORTANT: Focus on ONE of these high-impact opportunities: +1. The most CRITICAL finding that needs immediate attention +2. A pattern where fixing one check ID resolves many findings (e.g., "Fix aws_s3_bucket_public_access_block to resolve 15 findings") +3. The issue with highest business impact -GUIDELINES: -- Frame recommendations in business terms, not technical jargon -- Focus on actionable insights -- Make it clickable and engaging -- Don't use phrases like "Lighthouse says" or "Lighthouse recommends" -- Be specific about the type of improvement when possible -- Use only information from the security scan summary to generate the recommendation -- Add words like "Lighthouse" to the recommendation -- Don't end with a question mark or full stop -- Don't use words like "urges" or "requires" -- Don't wrap the message in double quotes or single quotes -- Use words like "detected" or "found" to describe the issue +Your response should be a comprehensive analysis of this ONE focus area including: -EXAMPLES OF GOOD RESPONSES: -- Lighthouse detected critical issues in authentication services -- Lighthouse found a new exposed S3 bucket in recent scan -- Lighthouse identified fixing one check could resolve 30 open findings +**Issue Description:** +- What exactly is the problem +- Why it's critical or high-impact +- How many findings it affects -Based on the below security scan summary, generate ONE short business recommendation:`; +**Affected Resources:** +- Specific resources, services, or configurations involved +- Number of affected resources - if (finalBusinessContext) { - systemPrompt += `\n\nBUSINESS CONTEXT: ${finalBusinessContext}`; +**Business Impact:** +- Security risks and potential consequences +- Compliance violations (mention specific frameworks if applicable) +- Operational impact + +**Remediation Steps:** +- Clear, step-by-step instructions +- Specific commands or configuration changes where applicable +- Expected outcome after fix + +Be specific with numbers (e.g., "affects 12 S3 buckets", "resolves 15 findings"). Focus on actionable guidance that will have the biggest security improvement.`; + + if (businessContext) { + systemPrompt += `\n\nBUSINESS CONTEXT: ${businessContext}`; } - systemPrompt += `\n\nBased on this security scan summary, generate 1 engaging banner message:\n\n${scanSummary}`; + systemPrompt += `\n\nSecurity Scan Summary:\n${scanSummary}`; const response = await llm.invoke([ { @@ -65,11 +72,128 @@ Based on the below security scan summary, generate ONE short business recommenda }, ]); - const recommendation = response.content.toString().trim(); - - return recommendation.length > 0 ? recommendation : ""; + return response.content.toString().trim(); } catch (error) { - console.error("Error generating recommendation:", error); + console.error("Error generating detailed recommendation:", error); return ""; } }; + +export const generateBannerFromDetailed = async ( + detailedRecommendation: string, +): Promise => { + try { + const apiKey = await getAIKey(); + if (!apiKey) { + return ""; + } + + const lighthouseConfig = await getLighthouseConfig(); + if (!lighthouseConfig?.attributes) { + return ""; + } + + const config = lighthouseConfig.attributes; + + const llm = new ChatOpenAI({ + model: config.model || "gpt-4o", + temperature: config.temperature || 0, + maxTokens: 100, + apiKey: apiKey, + }); + + const systemPrompt = `Create a short, engaging banner message from this detailed security analysis. + +REQUIREMENTS: +- Maximum 80 characters +- Include "Lighthouse" in the message +- Focus on the key insight or opportunity +- Make it clickable and business-focused +- Use action words like "detected", "found", "identified" +- Don't end with punctuation + +EXAMPLES: +- Lighthouse found fixing 1 S3 check resolves 15 findings +- Lighthouse detected critical RDS encryption gaps +- Lighthouse identified 3 exposed databases needing attention + +Based on this detailed analysis, create one engaging banner message: + +${detailedRecommendation}`; + + const response = await llm.invoke([ + { + role: "system", + content: systemPrompt, + }, + ]); + + return response.content.toString().trim(); + } catch (error) { + console.error( + "Error generating banner from detailed recommendation:", + error, + ); + return ""; + } +}; + +// Legacy function for backward compatibility +export const generateRecommendation = async ( + scanSummary: string, +): Promise => { + const detailed = await generateDetailedRecommendation(scanSummary); + if (!detailed) return ""; + + return await generateBannerFromDetailed(detailed); +}; + +export const generateQuestionAnswers = async ( + questions: SuggestedAction[], +): Promise> => { + const answers: Record = {}; + + try { + const apiKey = await getAIKey(); + if (!apiKey) { + return answers; + } + + // Initialize the workflow system + const workflow = await initLighthouseWorkflow(); + + for (const question of questions) { + if (!question.questionRef) continue; + + try { + // Use the existing workflow to answer the question + const result = await workflow.invoke({ + messages: [ + { + role: "user", + content: question.action, + }, + ], + }); + + // Extract the final message content + const finalMessage = result.messages[result.messages.length - 1]; + if (finalMessage?.content) { + answers[question.questionRef] = finalMessage.content + .toString() + .trim(); + } + } catch (error) { + console.error( + `Error generating answer for question ${question.questionRef}:`, + error, + ); + continue; + } + } + } catch (error) { + console.error("Error generating question answers:", error); + } + + return answers; +};