feat: add lighthouse caching

This commit is contained in:
Chandrapal Badshah
2025-07-08 12:45:09 +05:30
parent 3bb3edc0bb
commit 11649f227a
5 changed files with 483 additions and 99 deletions
+41 -2
View File
@@ -2,16 +2,55 @@ import { getLighthouseConfig } from "@/actions/lighthouse/lighthouse";
import { LighthouseIcon } from "@/components/icons/Icons";
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<string, string> = {};
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 (
<ContentLayout title="Lighthouse AI" icon={<LighthouseIcon />}>
<Chat hasConfig={hasConfig} isActive={isActive} />
<Chat
hasConfig={hasConfig}
isActive={isActive}
cachedContent={cachedContent}
messageType={searchParams.cachedMessage}
isProcessing={isProcessing}
questionAnswers={questionAnswers}
/>
</ContentLayout>
);
}
+2 -2
View File
@@ -50,7 +50,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",
});
@@ -62,7 +62,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",
});
+196 -40
View File
@@ -1,30 +1,40 @@
"use client";
import { useChat } from "@ai-sdk/react";
import { useEffect, useRef, useState } 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 { CustomLink } from "@/components/ui/custom/custom-link";
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<string, string>;
}
interface ChatFormData {
message: string;
}
export const Chat = ({ hasConfig, isActive }: ChatProps) => {
export const Chat = ({
hasConfig,
isActive,
cachedContent,
messageType,
isProcessing,
questionAnswers,
}: ChatProps) => {
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const {
@@ -74,6 +84,13 @@ export const Chat = ({ hasConfig, isActive }: ChatProps) => {
},
});
// State for cached response streaming simulation
const [isStreamingCached, setIsStreamingCached] = useState(false);
const [streamingMessageId, setStreamingMessageId] = useState<string | null>(
null,
);
const [currentStreamText, setCurrentStreamText] = useState("");
const form = useForm<ChatFormData>({
defaultValues: {
message: "",
@@ -108,6 +125,149 @@ export const Chat = ({ hasConfig, isActive }: ChatProps) => {
}
}, [errorMessage, form, setMessages]);
// 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 = {
@@ -146,6 +306,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;
@@ -156,30 +329,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;
@@ -267,10 +416,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"
>
@@ -320,10 +466,12 @@ export const Chat = ({ hasConfig, isActive }: ChatProps) => {
</div>
);
})}
{status === "submitted" && (
{(status === "submitted" || isStreamingCached) && (
<div className="flex justify-start">
<div className="bg-muted max-w-[80%] rounded-lg px-4 py-2">
<div className="animate-pulse">Thinking...</div>
<div className="animate-pulse">
{isStreamingCached ? "" : "Thinking..."}
</div>
</div>
</div>
)}
@@ -358,10 +506,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" ? <span></span> : <span></span>}
{status === "submitted" || isStreamingCached ? (
<span></span>
) : (
<span></span>
)}
</CustomButton>
</div>
</form>
+89 -24
View File
@@ -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<string | null> {
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<boolean> {
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<{
+155 -31
View File
@@ -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<string> => {
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<string> => {
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<string> => {
const detailed = await generateDetailedRecommendation(scanSummary);
if (!detailed) return "";
return await generateBannerFromDetailed(detailed);
};
export const generateQuestionAnswers = async (
questions: SuggestedAction[],
): Promise<Record<string, string>> => {
const answers: Record<string, string> = {};
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;
};