feat(lighthouse): Add chat interface (#7878)

Co-authored-by: Chandrapal Badshah <12944530+Chan9390@users.noreply.github.com>
This commit is contained in:
Chandrapal BadshahandChandrapal Badshah authored and GitHub committed 2025-06-12 15:19:41 +02:00
1 parent e218435b2f
commit 74f7a86c2b
40 files changed
+3350

No files matched your search

+253
View File
@@ -0,0 +1,253 @@
"use client";
import { useChat } from "@ai-sdk/react";
import Link from "next/link";
import { useEffect, useRef } 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;
}
interface ChatProps {
hasApiKey: boolean;
}
interface ChatFormData {
message: string;
}
export const Chat = ({ hasApiKey }: 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);
},
},
);
const form = useForm<ChatFormData>({
defaultValues: {
message: "",
},
});
const messageValue = form.watch("message");
const messagesContainerRef = useRef<HTMLDivElement | null>(null);
const latestUserMsgRef = useRef<HTMLDivElement | null>(null);
// Sync form value with chat input
useEffect(() => {
const syntheticEvent = {
target: { value: messageValue },
} as React.ChangeEvent<HTMLInputElement>;
handleInputChange(syntheticEvent);
}, [messageValue, handleInputChange]);
// Reset form when message is sent
useEffect(() => {
if (status === "submitted") {
form.reset({ message: "" });
}
}, [status, form]);
const onFormSubmit = form.handleSubmit((data) => {
if (data.message.trim()) {
handleSubmit();
}
});
// Global keyboard shortcut handler
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
e.preventDefault();
if (messageValue?.trim()) {
onFormSubmit();
}
}
};
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [messageValue, onFormSubmit]);
useEffect(() => {
if (messagesContainerRef.current && latestUserMsgRef.current) {
const container = messagesContainerRef.current;
const userMsg = latestUserMsgRef.current;
const containerPadding = 16; // p-4 in Tailwind = 16px
container.scrollTop =
userMsg.offsetTop - container.offsetTop - containerPadding;
}
}, [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",
},
];
return (
<div className="relative flex h-[calc(100vh-theme(spacing.16))] min-w-0 flex-col bg-background">
{!hasApiKey && (
<div className="absolute inset-0 z-50 flex items-center justify-center bg-background/80 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">
OpenAI API Key Required
</h3>
<p className="text-muted-foreground mb-4">
Please configure your OpenAI API key to use the Lighthouse Cloud
Security Analyst.
</p>
<Link
href="/lighthouse/config"
className="inline-flex items-center justify-center rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90"
>
Configure API Key
</Link>
</div>
</div>
)}
{messages.length === 0 ? (
<div className="flex flex-1 items-center justify-center p-4">
<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) => (
<CustomButton
key={`suggested-action-${index}`}
ariaLabel={`Send message: ${action.action}`}
onPress={() => {
append({
role: "user",
content: action.action,
});
}}
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"
>
<span>{action.title}</span>
<span className="text-muted-foreground">{action.label}</span>
</CustomButton>
))}
</div>
</div>
</div>
) : (
<div
className="flex-1 space-y-4 overflow-y-auto p-4"
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;
return (
<div
key={message.id}
ref={isLatestUserMsg ? latestUserMsgRef : undefined}
className={`flex ${
message.role === "user" ? "justify-end" : "justify-start"
}`}
>
<div
className={`max-w-[80%] rounded-lg px-4 py-2 ${
message.role === "user"
? "bg-primary text-primary-foreground dark:!text-black"
: "bg-muted"
}`}
>
<div
className={`prose dark:prose-invert ${message.role === "user" ? "dark:!text-black" : ""}`}
>
<MemoizedMarkdown
id={message.id}
content={message.content}
/>
</div>
</div>
</div>
);
})}
{status === "submitted" && (
<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>
</div>
)}
</div>
)}
<Form {...form}>
<form
onSubmit={onFormSubmit}
className="mx-auto flex w-full gap-2 px-4 pb-4 md:max-w-3xl md:pb-6"
>
<div className="flex w-full items-end gap-2">
<div className="w-full flex-1">
<CustomTextarea
control={form.control}
name="message"
label=""
placeholder="Type your message..."
variant="bordered"
minRows={1}
maxRows={6}
fullWidth={true}
disableAutosize={false}
/>
</div>
<CustomButton
type="submit"
ariaLabel={
status === "submitted" ? "Stop generation" : "Send message"
}
isDisabled={status === "submitted" || !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>}
</CustomButton>
</div>
</form>
</Form>
</div>
);
};
export default Chat;
+179
View File
@@ -0,0 +1,179 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { Select, SelectItem, Spacer } from "@nextui-org/react";
import { SaveIcon } from "lucide-react";
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().nonempty("Model selection is required"),
apiKey: z.string().nonempty("API Key is required").optional(),
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 { 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;
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 configuration ${
configExists ? "updated" : "created"
} successfully`,
});
} else {
throw new Error("Failed to save configuration");
}
} catch (error) {
toast({
title: "Error",
description:
"Failed to save lighthouse 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 space-y-6"
>
<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" value="gpt-4o-2024-08-06">
GPT-4o (Recommended)
</SelectItem>
<SelectItem
key="gpt-4o-mini-2024-07-18"
value="gpt-4o-mini-2024-07-18"
>
GPT-4o Mini
</SelectItem>
</Select>
)}
/>
<Spacer y={2} />
<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}
/>
<Spacer y={2} />
<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}
/>
<Spacer y={4} />
<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>
);
};
+2
View File
@@ -0,0 +1,2 @@
export * from "./chat";
export * from "./chatbot-config";
@@ -0,0 +1,32 @@
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";