"use client"; import { useChat } from "@ai-sdk/react"; import { DefaultChatTransport } from "ai"; import { Copy, Play, Plus, RotateCcw, Square } from "lucide-react"; import { useEffect, useRef, useState } from "react"; import { useForm } from "react-hook-form"; import { Action, Actions } from "@/components/lighthouse/actions"; 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 { CustomLink } from "@/components/ui/custom/custom-link"; import { Form } from "@/components/ui/form"; interface SuggestedAction { title: string; label: string; action: string; } interface ChatProps { hasConfig: boolean; isActive: boolean; } interface ChatFormData { message: string; } export const Chat = ({ hasConfig, isActive }: ChatProps) => { const [errorMessage, setErrorMessage] = useState(null); const { toast } = useToast(); const { messages, sendMessage, status, error, setMessages, regenerate } = useChat({ transport: new DefaultChatTransport({ api: "/api/lighthouse/analyst", credentials: "same-origin", }), experimental_throttle: 100, onFinish: ({ message }) => { // There is no specific way to output the error message from langgraph supervisor // Hence, all error messages are sent as normal messages with the prefix [LIGHTHOUSE_ANALYST_ERROR]: // Detect error messages sent from backend using specific prefix and display the error const firstTextPart = message.parts.find((p) => p.type === "text"); if ( firstTextPart && "text" in firstTextPart && firstTextPart.text.startsWith("[LIGHTHOUSE_ANALYST_ERROR]:") ) { const errorText = firstTextPart.text .replace("[LIGHTHOUSE_ANALYST_ERROR]:", "") .trim(); setErrorMessage(errorText); // Remove error message from chat history setMessages((prev) => prev.filter((m) => { const textPart = m.parts.find((p) => p.type === "text"); return !( textPart && "text" in textPart && textPart.text.startsWith("[LIGHTHOUSE_ANALYST_ERROR]:") ); }), ); } }, onError: (error) => { console.error("Chat error:", error); if ( error?.message?.includes("") && error?.message?.includes("403 Forbidden") ) { setErrorMessage("403 Forbidden"); return; } setErrorMessage( error?.message || "An error occurred. Please retry your message.", ); }, }); const form = useForm({ defaultValues: { message: "", }, }); const messageValue = form.watch("message"); const messagesContainerRef = useRef(null); const latestUserMsgRef = useRef(null); const messageValueRef = useRef(""); // Keep ref in sync with current value messageValueRef.current = messageValue; // 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(); 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); } return currentMessages; }); } }, [errorMessage, form, setMessages]); // Reset form when message is sent useEffect(() => { if (status === "submitted") { form.reset({ message: "" }); } }, [status, form]); // Auto-scroll to bottom when new messages arrive or when streaming useEffect(() => { if (messagesContainerRef.current) { messagesContainerRef.current.scrollTop = messagesContainerRef.current.scrollHeight; } }, [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 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(); } } }; 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; const handleNewChat = () => { setMessages([]); setErrorMessage(null); form.reset({ message: "" }); }; return (
{/* Header with New Chat button */} {messages.length > 0 && (
} onPress={handleNewChat} className="gap-1" > New Chat
)} {shouldDisableChat && (

{!hasConfig ? "OpenAI API Key Required" : "OpenAI API Key Invalid"}

{!hasConfig ? "Please configure your OpenAI API key to use Lighthouse AI." : "OpenAI API key is invalid. Please update your key to use Lighthouse AI."}

Configure API Key
)} {/* Error Banner */} {(error || errorMessage) && (

Error

{errorMessage || error?.message || "An error occurred. Please retry your message."}

{/* Original error details for native errors */} {error && (error as any).status && (

Status: {(error as any).status}

)} {error && (error as any).body && (
Show details
                    {JSON.stringify((error as any).body, null, 2)}
                  
)}
)} {messages.length === 0 && !errorMessage && !error ? (

Suggestions

{suggestedActions.map((action, index) => ( { sendMessage({ text: 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" > {action.title} {action.label} ))}
) : (
{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") .map((p) => ("text" in p ? p.text : "")) .join(""); // Check if this is the streaming assistant message (last message, assistant role, while streaming) const isStreamingAssistant = isLastMessage && message.role === "assistant" && status === "streaming"; // Use a composite key to ensure uniqueness even if IDs are duplicated temporarily const uniqueKey = `${message.id}-${idx}-${message.role}`; return (
{/* Show loader before text appears or while streaming empty content */} {isStreamingAssistant && !messageText ? ( ) : (
)}
{/* Actions for assistant messages */} {message.role === "assistant" && isLastMessage && messageText && status !== "streaming" && (
} onClick={() => { navigator.clipboard.writeText(messageText); toast({ title: "Copied", description: "Message copied to clipboard", }); }} /> } onClick={() => regenerate()} />
)}
); })} {/* Show loader only if no assistant message exists yet */} {(status === "submitted" || status === "streaming") && messages.length > 0 && messages[messages.length - 1].role === "user" && (
)}
)}
{status === "streaming" || status === "submitted" ? ( ) : ( )}
); }; export default Chat;