refactor(ui): componentize Lighthouse v2 chat and config pages

This commit is contained in:
alejandrobailo
2026-06-25 19:51:12 +02:00
parent bf622f8596
commit a54f16fcc5
25 changed files with 1803 additions and 1660 deletions
@@ -0,0 +1,134 @@
"use client";
import { ArrowRight, Square } from "lucide-react";
import { type FormEvent } from "react";
import { Button } from "@/components/shadcn/button/button";
import { Textarea } from "@/components/shadcn/textarea/textarea";
interface ChatComposerPanelProps {
feedback: string | null;
canRetry: boolean;
onRetry: () => void;
canSend: boolean;
input: string;
isStreaming: boolean;
selectedConfigurationConnected: boolean;
onInputChange: (value: string) => void;
onStop: () => void;
onSubmit: (event: FormEvent<HTMLFormElement>) => void;
onSubmitText: (text: string) => Promise<void>;
}
// Feedback banner + input, shared by the empty and active chat layouts so the
// two branches can't drift apart.
export function ChatComposerPanel({
feedback,
canRetry,
onRetry,
...composerProps
}: ChatComposerPanelProps) {
return (
<>
<ChatFeedbackBar
feedback={feedback}
canRetry={canRetry}
onRetry={onRetry}
/>
<ChatComposer {...composerProps} />
</>
);
}
function ChatFeedbackBar({
feedback,
canRetry,
onRetry,
}: {
feedback: string | null;
canRetry: boolean;
onRetry: () => void;
}) {
if (!feedback) return null;
return (
<div className="border-border-neutral-secondary bg-bg-neutral-secondary mb-3 flex items-center justify-between gap-3 rounded-[8px] border px-3 py-2 text-sm">
<span>{feedback}</span>
{canRetry && (
<Button type="button" variant="outline" size="sm" onClick={onRetry}>
Retry
</Button>
)}
</div>
);
}
interface ChatComposerProps {
canSend: boolean;
input: string;
isStreaming: boolean;
selectedConfigurationConnected: boolean;
onInputChange: (value: string) => void;
onStop: () => void;
onSubmit: (event: FormEvent<HTMLFormElement>) => void;
onSubmitText: (text: string) => Promise<void>;
}
function ChatComposer({
canSend,
input,
isStreaming,
selectedConfigurationConnected,
onInputChange,
onStop,
onSubmit,
onSubmitText,
}: ChatComposerProps) {
return (
<form
className="border-border-neutral-secondary bg-bg-neutral-secondary flex min-h-[150px] w-full flex-col rounded-[8px] border shadow-xs"
onSubmit={onSubmit}
>
<Textarea
aria-label="Message"
value={input}
onChange={(event) => onInputChange(event.target.value)}
disabled={!canSend}
placeholder={
selectedConfigurationConnected
? "Ask a question"
: "Connect a provider first"
}
variant="ghost"
textareaSize="lg"
className="min-h-[104px] flex-1 rounded-b-none border-0 hover:bg-transparent focus:bg-transparent focus:ring-0"
onKeyDown={(event) => {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
void onSubmitText(input);
}
}}
/>
<div className="flex items-center justify-end px-3 pb-3">
{isStreaming ? (
<Button
type="button"
variant="outline"
size="icon-sm"
onClick={onStop}
>
<Square className="size-4" />
</Button>
) : (
<Button
type="submit"
size="icon-sm"
disabled={!canSend || !input.trim()}
>
<ArrowRight className="size-4" />
</Button>
)}
</div>
</form>
);
}
@@ -0,0 +1,105 @@
"use client";
import {
BookOpen,
FileCheck2,
Network,
Settings,
ShieldAlert,
} from "lucide-react";
import Link from "next/link";
import { type FormEvent } from "react";
import { LighthouseIcon } from "@/components/icons/Icons";
import { Button } from "@/components/shadcn/button/button";
import { ChatComposerPanel } from "./composer";
const LIGHTHOUSE_V2_SUGGESTIONS = [
{
label: "Critical findings",
prompt: "Summarize my most critical open findings and what to fix first.",
icon: ShieldAlert,
},
{
label: "Compliance gaps",
prompt: "What are my highest-impact compliance gaps right now?",
icon: FileCheck2,
},
{
label: "Attack paths",
prompt: "Find risky attack paths and explain the exposure.",
icon: Network,
},
{
label: "Docs",
prompt: "Point me to the relevant Prowler documentation for this task.",
icon: BookOpen,
},
] as const;
interface ChatEmptyStateProps {
feedback: string | null;
canRetry: boolean;
onRetry: () => void;
canSend: boolean;
input: string;
isStreaming: boolean;
selectedConfigurationConnected: boolean;
onInputChange: (value: string) => void;
onStop: () => void;
onSubmit: (event: FormEvent<HTMLFormElement>) => void;
onSubmitText: (text: string) => Promise<void>;
}
export function ChatEmptyState({
onInputChange,
...composerPanelProps
}: ChatEmptyStateProps) {
return (
<div className="flex min-h-0 flex-1 items-center justify-center px-4 py-10 md:px-8">
<div className="mx-auto flex w-full max-w-5xl flex-col items-center gap-5">
<LighthouseIcon className="size-12" />
<div className="space-y-2 text-center">
<h1 className="text-text-neutral-primary text-3xl font-semibold">
What do you want to know today?
</h1>
<p className="text-text-neutral-secondary text-base italic">
Understand and secure your cloud.
</p>
</div>
<div className="w-full max-w-4xl">
<ChatComposerPanel
{...composerPanelProps}
onInputChange={onInputChange}
/>
</div>
<div className="flex max-w-4xl flex-wrap items-center justify-center gap-2">
<span className="text-text-neutral-secondary basis-full text-center text-sm font-medium">
Try Lighthouse for...
</span>
{LIGHTHOUSE_V2_SUGGESTIONS.map((suggestion) => {
const Icon = suggestion.icon;
return (
<Button
key={suggestion.label}
type="button"
variant="outline"
size="sm"
onClick={() => onInputChange(suggestion.prompt)}
>
<Icon className="size-4" />
{suggestion.label}
</Button>
);
})}
<Button type="button" variant="outline" size="icon-sm" asChild>
<Link href="/lighthouse/settings" aria-label="Lighthouse settings">
<Settings className="size-4" />
</Link>
</Button>
</div>
</div>
</div>
);
}
@@ -1,5 +1,6 @@
import { act, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { type ReactNode } from "react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type {
@@ -70,6 +71,13 @@ vi.mock("@/app/(prowler)/lighthouse/_actions", () => ({
sendLighthouseV2Message: sendMessageMock,
}));
// Streamdown pulls in shiki/wasm syntax highlighting that doesn't run under
// jsdom; render its text passthrough so message bodies are still assertable.
vi.mock("streamdown", () => ({
Streamdown: ({ children }: { children: ReactNode }) => <>{children}</>,
defaultRehypePlugins: { katex: undefined, harden: undefined },
}));
const configurations: LighthouseV2Configuration[] = [
{
id: "config-openai",
@@ -1,22 +1,5 @@
"use client";
import { format } from "date-fns";
import {
ArrowRight,
BookOpen,
Bot,
Check,
Copy,
FileCheck2,
Loader2,
Network,
Settings,
ShieldAlert,
Square,
UserRound,
Wrench,
} from "lucide-react";
import Link from "next/link";
import { type FormEvent, useRef, useState } from "react";
import {
@@ -25,12 +8,6 @@ import {
getLighthouseV2Messages,
sendLighthouseV2Message,
} from "@/app/(prowler)/lighthouse/_actions";
import {
ChainOfThought,
ChainOfThoughtContent,
ChainOfThoughtHeader,
ChainOfThoughtStep,
} from "@/app/(prowler)/lighthouse/_components/ai-elements/chain-of-thought";
import {
Conversation,
ConversationContent,
@@ -38,15 +15,17 @@ import {
} from "@/app/(prowler)/lighthouse/_components/ai-elements/conversation";
import {
createInitialLighthouseV2StreamState,
LIGHTHOUSE_V2_STREAM_STATUS,
type LighthouseV2StreamState,
reduceLighthouseV2Event,
} from "@/app/(prowler)/lighthouse/_lib/event-reducer";
import {
buildOptimisticMessage,
buildSessionTitle,
} from "@/app/(prowler)/lighthouse/_lib/messages";
import { notifyLighthouseV2SessionsChanged } from "@/app/(prowler)/lighthouse/_lib/session-events";
import { parseStreamEvent } from "@/app/(prowler)/lighthouse/_lib/stream-event-parser";
import { buildLighthouseV2StreamUrl } from "@/app/(prowler)/lighthouse/_lib/stream-url";
import {
LIGHTHOUSE_V2_MESSAGE_ROLE,
LIGHTHOUSE_V2_PART_TYPE,
LIGHTHOUSE_V2_PROVIDER_TYPE,
LIGHTHOUSE_V2_SSE_EVENT,
type LighthouseV2Configuration,
@@ -55,12 +34,13 @@ import {
type LighthouseV2SSEEvent,
type LighthouseV2SupportedModel,
} from "@/app/(prowler)/lighthouse/_types";
import { LighthouseIcon } from "@/components/icons/Icons";
import { Card } from "@/components/shadcn";
import { Button } from "@/components/shadcn/button/button";
import { Textarea } from "@/components/shadcn/textarea/textarea";
import { useMountEffect } from "@/hooks/use-mount-effect";
import { cn } from "@/lib/utils";
import { ChatComposerPanel } from "./composer";
import { ChatEmptyState } from "./empty-state";
import { MessageBubble } from "./message-bubble";
import { StreamingAssistantMessage } from "./streaming-message";
interface LighthouseV2ChatPageProps {
configurations: LighthouseV2Configuration[];
@@ -75,29 +55,6 @@ interface LighthouseV2ChatPageProps {
initialPrompt?: string;
}
const LIGHTHOUSE_V2_SUGGESTIONS = [
{
label: "Critical findings",
prompt: "Summarize my most critical open findings and what to fix first.",
icon: ShieldAlert,
},
{
label: "Compliance gaps",
prompt: "What are my highest-impact compliance gaps right now?",
icon: FileCheck2,
},
{
label: "Attack paths",
prompt: "Find risky attack paths and explain the exposure.",
icon: Network,
},
{
label: "Docs",
prompt: "Point me to the relevant Prowler documentation for this task.",
icon: BookOpen,
},
] as const;
export function LighthouseV2ChatPage({
configurations,
modelsByProvider,
@@ -354,6 +311,22 @@ export function LighthouseV2ChatPage({
streamState.toolCalls.length > 0;
const hasConversation = messages.length > 0 || hasLiveAssistantActivity;
const composerPanelProps = {
feedback,
canRetry:
streamState.status === "disconnected" && lastSubmittedText !== null,
onRetry: () =>
lastSubmittedText ? void submitMessage(lastSubmittedText) : undefined,
canSend,
input,
isStreaming: Boolean(streamState.activeTaskId),
selectedConfigurationConnected: selectedConfiguration?.connected === true,
onInputChange: setInput,
onStop: handleStop,
onSubmit: handleSubmit,
onSubmitText: submitMessage,
};
return (
<Card
variant="base"
@@ -386,519 +359,13 @@ export function LighthouseV2ChatPage({
className="bg-bg-neutral-secondary px-4 pb-5 md:px-8"
>
<div className="mx-auto w-full max-w-4xl">
<LighthouseV2Feedback
feedback={feedback}
canRetry={
streamState.status === "disconnected" &&
lastSubmittedText !== null
}
onRetry={() =>
lastSubmittedText
? void submitMessage(lastSubmittedText)
: undefined
}
/>
<LighthouseV2Composer
canSend={canSend}
input={input}
isStreaming={Boolean(streamState.activeTaskId)}
selectedConfigurationConnected={
selectedConfiguration?.connected === true
}
onInputChange={setInput}
onStop={handleStop}
onSubmit={handleSubmit}
onSubmitText={submitMessage}
/>
<ChatComposerPanel {...composerPanelProps} />
</div>
</div>
</div>
) : (
<div className="flex min-h-0 flex-1 items-center justify-center px-4 py-10 md:px-8">
<div className="mx-auto flex w-full max-w-5xl flex-col items-center gap-5">
<LighthouseIcon className="size-12" />
<div className="space-y-2 text-center">
<h1 className="text-text-neutral-primary text-3xl font-semibold">
What do you want to know today?
</h1>
<p className="text-text-neutral-secondary text-base italic">
Understand and secure your cloud.
</p>
</div>
<div className="w-full max-w-4xl">
<LighthouseV2Feedback
feedback={feedback}
canRetry={
streamState.status === "disconnected" &&
lastSubmittedText !== null
}
onRetry={() =>
lastSubmittedText
? void submitMessage(lastSubmittedText)
: undefined
}
/>
<LighthouseV2Composer
canSend={canSend}
input={input}
isStreaming={Boolean(streamState.activeTaskId)}
selectedConfigurationConnected={
selectedConfiguration?.connected === true
}
onInputChange={setInput}
onStop={handleStop}
onSubmit={handleSubmit}
onSubmitText={submitMessage}
/>
</div>
<div className="flex max-w-4xl flex-wrap items-center justify-center gap-2">
<span className="text-text-neutral-secondary basis-full text-center text-sm font-medium">
Try Lighthouse for...
</span>
{LIGHTHOUSE_V2_SUGGESTIONS.map((suggestion) => {
const Icon = suggestion.icon;
return (
<Button
key={suggestion.label}
type="button"
variant="outline"
size="sm"
onClick={() => setInput(suggestion.prompt)}
>
<Icon className="size-4" />
{suggestion.label}
</Button>
);
})}
<Button type="button" variant="outline" size="icon-sm" asChild>
<Link
href="/lighthouse/settings"
aria-label="Lighthouse settings"
>
<Settings className="size-4" />
</Link>
</Button>
</div>
</div>
</div>
<ChatEmptyState {...composerPanelProps} />
)}
</Card>
);
}
interface LighthouseV2FeedbackProps {
feedback: string | null;
canRetry: boolean;
onRetry: () => void;
}
function LighthouseV2Feedback({
feedback,
canRetry,
onRetry,
}: LighthouseV2FeedbackProps) {
if (!feedback) return null;
return (
<div className="border-border-neutral-secondary bg-bg-neutral-secondary mb-3 flex items-center justify-between gap-3 rounded-[8px] border px-3 py-2 text-sm">
<span>{feedback}</span>
{canRetry && (
<Button type="button" variant="outline" size="sm" onClick={onRetry}>
Retry
</Button>
)}
</div>
);
}
interface LighthouseV2ComposerProps {
canSend: boolean;
input: string;
isStreaming: boolean;
selectedConfigurationConnected: boolean;
onInputChange: (value: string) => void;
onStop: () => void;
onSubmit: (event: FormEvent<HTMLFormElement>) => void;
onSubmitText: (text: string) => Promise<void>;
}
function LighthouseV2Composer({
canSend,
input,
isStreaming,
selectedConfigurationConnected,
onInputChange,
onStop,
onSubmit,
onSubmitText,
}: LighthouseV2ComposerProps) {
return (
<form
className="border-border-neutral-secondary bg-bg-neutral-secondary flex min-h-[150px] w-full flex-col rounded-[8px] border shadow-xs"
onSubmit={onSubmit}
>
<Textarea
aria-label="Message"
value={input}
onChange={(event) => onInputChange(event.target.value)}
disabled={!canSend}
placeholder={
selectedConfigurationConnected
? "Ask a question"
: "Connect a provider first"
}
variant="ghost"
textareaSize="lg"
className="min-h-[104px] flex-1 rounded-b-none border-0 hover:bg-transparent focus:bg-transparent focus:ring-0"
onKeyDown={(event) => {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
void onSubmitText(input);
}
}}
/>
<div className="flex items-center justify-end px-3 pb-3">
{isStreaming ? (
<Button
type="button"
variant="outline"
size="icon-sm"
onClick={onStop}
>
<Square className="size-4" />
</Button>
) : (
<Button
type="submit"
size="icon-sm"
disabled={!canSend || !input.trim()}
>
<ArrowRight className="size-4" />
</Button>
)}
</div>
</form>
);
}
function MessageBubble({ message }: { message: LighthouseV2Message }) {
const isUser = message.role === LIGHTHOUSE_V2_MESSAGE_ROLE.USER;
const textParts = message.parts.filter(
(part) => part.type === LIGHTHOUSE_V2_PART_TYPE.TEXT,
);
const toolCallCount = message.parts.filter(
(part) => part.type === LIGHTHOUSE_V2_PART_TYPE.TOOL_CALL,
).length;
const messageText = textParts
.map((part) => getTextContent(part.content))
.filter(Boolean)
.join("\n\n");
return (
<article
className={cn(
"group flex gap-3",
isUser ? "justify-end" : "justify-start",
)}
>
{!isUser && <Bot className="text-text-neutral-tertiary mt-1 size-5" />}
<div
className={cn(
"flex max-w-[min(760px,85%)] flex-col gap-1",
isUser ? "items-end" : "items-start",
)}
>
<div
className={cn(
"rounded-[8px] px-4 py-3 text-sm",
isUser
? "bg-button-primary text-black"
: "bg-bg-neutral-tertiary text-text-neutral-primary",
)}
>
{toolCallCount > 0 && (
<p className="text-text-neutral-secondary mb-2 flex items-center gap-1.5 text-xs">
<Wrench className="size-3.5" />
{toolCallCount} {toolCallCount === 1 ? "tool" : "tools"} called
</p>
)}
{textParts.map((part) => (
<p
key={part.id || `${message.id}-text`}
className="whitespace-pre-wrap"
>
{getTextContent(part.content)}
</p>
))}
</div>
<MessageMeta
isUser={isUser}
text={messageText}
insertedAt={message.insertedAt}
/>
</div>
{isUser && (
<UserRound className="text-text-neutral-tertiary mt-1 size-5" />
)}
</article>
);
}
function MessageMeta({
isUser,
text,
insertedAt,
}: {
isUser: boolean;
text: string;
insertedAt: string;
}) {
// Copy is always shown; the timestamp only reveals on hover over the message.
// Agent footer reads left-to-right ([copy] [time]); user footer mirrors it.
return (
<div
className={cn(
"flex items-center gap-1 px-1",
isUser && "flex-row-reverse",
)}
>
<CopyMessageButton text={text} />
<time
dateTime={insertedAt}
className="text-text-neutral-tertiary text-xs opacity-0 transition-opacity group-hover:opacity-100"
>
{formatMessageTimestamp(insertedAt)}
</time>
</div>
);
}
function CopyMessageButton({ text }: { text: string }) {
const [copied, setCopied] = useState(false);
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(text);
setCopied(true);
window.setTimeout(() => setCopied(false), 1500);
} catch {
// Clipboard can reject (e.g. permissions); nothing to recover.
}
};
return (
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label="Copy message"
onClick={handleCopy}
className="text-text-neutral-tertiary hover:text-text-neutral-primary size-6"
>
{copied ? <Check className="size-3.5" /> : <Copy className="size-3.5" />}
</Button>
);
}
function formatMessageTimestamp(insertedAt: string): string {
const date = new Date(insertedAt);
if (Number.isNaN(date.getTime())) {
return "";
}
return format(date, "EEEE h:mm a");
}
function StreamingAssistantMessage({
streamState,
}: {
streamState: LighthouseV2StreamState;
}) {
const hasActivity =
Boolean(streamState.activeTaskId) || streamState.toolCalls.length > 0;
return (
<article className="flex justify-start gap-3">
<Bot className="text-text-neutral-tertiary mt-1 size-5" />
<div className="bg-bg-neutral-tertiary text-text-neutral-primary max-w-[min(760px,85%)] rounded-[8px] px-4 py-3 text-sm">
{hasActivity && <StreamingActivity streamState={streamState} />}
{streamState.assistantText && (
<p className={cn("whitespace-pre-wrap", hasActivity && "mt-3")}>
{streamState.assistantText}
</p>
)}
</div>
</article>
);
}
function StreamingActivity({
streamState,
}: {
streamState: LighthouseV2StreamState;
}) {
const hasAssistantText = Boolean(streamState.assistantText);
const hasToolCalls = streamState.toolCalls.length > 0;
const isDisconnected =
streamState.status === LIGHTHOUSE_V2_STREAM_STATUS.DISCONNECTED;
const thinkingStatus =
hasAssistantText || hasToolCalls ? "complete" : "active";
return (
<ChainOfThought className="max-w-none space-y-0">
<ChainOfThoughtHeader className="text-text-neutral-secondary">
<span className={cn(!isDisconnected && "animate-pulse")}>
{getActivityHeader(streamState)}
</span>
</ChainOfThoughtHeader>
<ChainOfThoughtContent className="mt-2 space-y-2">
<ChainOfThoughtStep
label="Preparing response"
status={thinkingStatus}
/>
{isDisconnected && (
<ChainOfThoughtStep label="Reconnecting stream" status="active" />
)}
{streamState.toolCalls.map((toolCall) => (
<ChainOfThoughtStep
key={toolCall.id}
description={
toolCall.outcome && toolCall.outcome.toLowerCase() !== "success"
? toolCall.outcome
: undefined
}
icon={toolCall.status === "running" ? Loader2 : undefined}
label={getToolCallLabel(toolCall)}
status={toolCall.status === "running" ? "active" : "complete"}
/>
))}
</ChainOfThoughtContent>
</ChainOfThought>
);
}
function getActivityHeader(streamState: LighthouseV2StreamState): string {
if (streamState.status === LIGHTHOUSE_V2_STREAM_STATUS.DISCONNECTED) {
return "Reconnecting";
}
if (streamState.toolCalls.some((toolCall) => toolCall.status === "running")) {
return "Using tools";
}
return "Thinking";
}
function getToolCallLabel(
toolCall: LighthouseV2StreamState["toolCalls"][number],
): string {
return `${toolCall.status === "running" ? "Calling" : "Called"} ${
toolCall.name
}`;
}
function parseStreamEvent(
event: Event,
type: LighthouseV2SSEEvent["type"],
): LighthouseV2SSEEvent {
const data = event instanceof MessageEvent ? parseJsonObject(event.data) : {};
if (type === LIGHTHOUSE_V2_SSE_EVENT.MESSAGE_DELTA) {
return {
type,
content: readString(data, "content"),
};
}
if (type === LIGHTHOUSE_V2_SSE_EVENT.TOOL_CALL_START) {
return {
type,
toolCallId: readString(data, "tool_call_id"),
toolName: readString(data, "tool_name"),
};
}
if (type === LIGHTHOUSE_V2_SSE_EVENT.TOOL_CALL_END) {
return {
type,
toolCallId: readString(data, "tool_call_id"),
outcome: readString(data, "outcome"),
};
}
if (type === LIGHTHOUSE_V2_SSE_EVENT.MESSAGE_END) {
return {
type,
messageId: readString(data, "message_id"),
};
}
if (type === LIGHTHOUSE_V2_SSE_EVENT.RUN_CANCELLED) {
return {
type,
taskId: readString(data, "task_id"),
};
}
return {
type: LIGHTHOUSE_V2_SSE_EVENT.ERROR,
code: readString(data, "code"),
detail: readString(data, "detail"),
};
}
function buildOptimisticMessage(
role: "user" | "assistant",
text: string,
): LighthouseV2Message {
const now = new Date().toISOString();
const id = `optimistic-${role}-${now}`;
return {
id,
role,
model: null,
tokenUsage: null,
insertedAt: now,
parts: [
{
id: `${id}-part`,
type: LIGHTHOUSE_V2_PART_TYPE.TEXT,
content: { text },
toolCallOutcome: null,
insertedAt: now,
updatedAt: now,
},
],
};
}
function buildSessionTitle(text: string): string {
const normalized = text.replace(/\s+/g, " ").trim();
return normalized.length > 80 ? `${normalized.slice(0, 77)}...` : normalized;
}
function getTextContent(content: unknown): string {
if (typeof content === "string") {
return content;
}
if (
typeof content === "object" &&
content !== null &&
"text" in content &&
typeof content.text === "string"
) {
return content.text;
}
return "";
}
function parseJsonObject(value: unknown): Record<string, unknown> {
if (typeof value !== "string") {
return {};
}
try {
const parsed: unknown = JSON.parse(value);
return typeof parsed === "object" && parsed !== null
? (parsed as Record<string, unknown>)
: {};
} catch {
return {};
}
}
function readString(data: Record<string, unknown>, key: string): string {
const value = data[key];
return typeof value === "string" ? value : "";
}
@@ -0,0 +1,134 @@
"use client";
import { Bot, Check, Copy, UserRound, Wrench } from "lucide-react";
import { useState } from "react";
import { formatMessageTimestamp } from "@/app/(prowler)/lighthouse/_lib/format";
import { getTextContent } from "@/app/(prowler)/lighthouse/_lib/messages";
import {
LIGHTHOUSE_V2_MESSAGE_ROLE,
LIGHTHOUSE_V2_PART_TYPE,
type LighthouseV2Message,
} from "@/app/(prowler)/lighthouse/_types";
import { Button } from "@/components/shadcn/button/button";
import { cn } from "@/lib/utils";
import { MessageMarkdown } from "./message-markdown";
export function MessageBubble({ message }: { message: LighthouseV2Message }) {
const isUser = message.role === LIGHTHOUSE_V2_MESSAGE_ROLE.USER;
const textParts = message.parts.filter(
(part) => part.type === LIGHTHOUSE_V2_PART_TYPE.TEXT,
);
const toolCallCount = message.parts.filter(
(part) => part.type === LIGHTHOUSE_V2_PART_TYPE.TOOL_CALL,
).length;
const messageText = textParts
.map((part) => getTextContent(part.content))
.filter(Boolean)
.join("\n\n");
return (
<article
className={cn(
"group flex gap-3",
isUser ? "justify-end" : "justify-start",
)}
>
{!isUser && <Bot className="text-text-neutral-tertiary mt-1 size-5" />}
<div
className={cn(
"flex max-w-[min(760px,85%)] flex-col gap-1",
isUser ? "items-end" : "items-start",
)}
>
<div
className={cn(
"rounded-[8px] px-4 py-3 text-sm",
isUser
? "bg-button-primary text-black"
: "bg-bg-neutral-tertiary text-text-neutral-primary",
)}
>
{toolCallCount > 0 && (
<p className="text-text-neutral-secondary mb-2 flex items-center gap-1.5 text-xs">
<Wrench className="size-3.5" />
{toolCallCount} {toolCallCount === 1 ? "tool" : "tools"} called
</p>
)}
{/* User text stays plain to preserve HTML-like tags; assistant text
renders as markdown. */}
{isUser ? (
<p className="whitespace-pre-wrap">{messageText}</p>
) : (
<MessageMarkdown text={messageText} />
)}
</div>
<MessageMeta
isUser={isUser}
text={messageText}
insertedAt={message.insertedAt}
/>
</div>
{isUser && (
<UserRound className="text-text-neutral-tertiary mt-1 size-5" />
)}
</article>
);
}
function MessageMeta({
isUser,
text,
insertedAt,
}: {
isUser: boolean;
text: string;
insertedAt: string;
}) {
// Copy is always shown; the timestamp only reveals on hover over the message.
// Agent footer reads left-to-right ([copy] [time]); user footer mirrors it.
return (
<div
className={cn(
"flex items-center gap-1 px-1",
isUser && "flex-row-reverse",
)}
>
<CopyMessageButton text={text} />
<time
dateTime={insertedAt}
className="text-text-neutral-tertiary text-xs opacity-0 transition-opacity group-hover:opacity-100"
>
{formatMessageTimestamp(insertedAt)}
</time>
</div>
);
}
function CopyMessageButton({ text }: { text: string }) {
const [copied, setCopied] = useState(false);
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(text);
setCopied(true);
window.setTimeout(() => setCopied(false), 1500);
} catch {
// Clipboard can reject (e.g. permissions); nothing to recover.
}
};
return (
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label="Copy message"
onClick={handleCopy}
className="text-text-neutral-tertiary hover:text-text-neutral-primary size-6"
>
{copied ? <Check className="size-3.5" /> : <Copy className="size-3.5" />}
</Button>
);
}
@@ -0,0 +1,32 @@
import { defaultRehypePlugins, Streamdown } from "streamdown";
import { escapeAngleBracketPlaceholders } from "@/lib/markdown";
// Renders assistant message text as markdown (code blocks, tables, lists),
// matching the Lighthouse v1 chat. `isStreaming` animates partial output.
export function MessageMarkdown({
text,
isStreaming = false,
}: {
text: string;
isStreaming?: boolean;
}) {
return (
<div className="lighthouse-markdown">
<Streamdown
parseIncompleteMarkdown
shikiTheme={["github-light", "github-dark"]}
controls={{ code: true, table: true, mermaid: true }}
// Omit defaultRehypePlugins.raw so HTML-like tokens (e.g. <bucket_name>)
// are escaped rather than parsed as elements.
rehypePlugins={[
defaultRehypePlugins.katex,
defaultRehypePlugins.harden,
]}
isAnimating={isStreaming}
>
{escapeAngleBracketPlaceholders(text)}
</Streamdown>
</div>
);
}
@@ -0,0 +1,103 @@
"use client";
import { Bot, Loader2 } from "lucide-react";
import {
ChainOfThought,
ChainOfThoughtContent,
ChainOfThoughtHeader,
ChainOfThoughtStep,
} from "@/app/(prowler)/lighthouse/_components/ai-elements/chain-of-thought";
import {
LIGHTHOUSE_V2_STREAM_STATUS,
type LighthouseV2StreamState,
} from "@/app/(prowler)/lighthouse/_lib/event-reducer";
import { cn } from "@/lib/utils";
import { MessageMarkdown } from "./message-markdown";
export function StreamingAssistantMessage({
streamState,
}: {
streamState: LighthouseV2StreamState;
}) {
const hasActivity =
Boolean(streamState.activeTaskId) || streamState.toolCalls.length > 0;
return (
<article className="flex justify-start gap-3">
<Bot className="text-text-neutral-tertiary mt-1 size-5" />
<div className="bg-bg-neutral-tertiary text-text-neutral-primary max-w-[min(760px,85%)] rounded-[8px] px-4 py-3 text-sm">
{hasActivity && <StreamingActivity streamState={streamState} />}
{streamState.assistantText && (
<div className={cn(hasActivity && "mt-3")}>
<MessageMarkdown text={streamState.assistantText} isStreaming />
</div>
)}
</div>
</article>
);
}
function StreamingActivity({
streamState,
}: {
streamState: LighthouseV2StreamState;
}) {
const hasAssistantText = Boolean(streamState.assistantText);
const hasToolCalls = streamState.toolCalls.length > 0;
const isDisconnected =
streamState.status === LIGHTHOUSE_V2_STREAM_STATUS.DISCONNECTED;
const thinkingStatus =
hasAssistantText || hasToolCalls ? "complete" : "active";
return (
<ChainOfThought className="max-w-none space-y-0">
<ChainOfThoughtHeader className="text-text-neutral-secondary">
<span className={cn(!isDisconnected && "animate-pulse")}>
{getActivityHeader(streamState)}
</span>
</ChainOfThoughtHeader>
<ChainOfThoughtContent className="mt-2 space-y-2">
<ChainOfThoughtStep
label="Preparing response"
status={thinkingStatus}
/>
{isDisconnected && (
<ChainOfThoughtStep label="Reconnecting stream" status="active" />
)}
{streamState.toolCalls.map((toolCall) => (
<ChainOfThoughtStep
key={toolCall.id}
description={
toolCall.outcome && toolCall.outcome.toLowerCase() !== "success"
? toolCall.outcome
: undefined
}
icon={toolCall.status === "running" ? Loader2 : undefined}
label={getToolCallLabel(toolCall)}
status={toolCall.status === "running" ? "active" : "complete"}
/>
))}
</ChainOfThoughtContent>
</ChainOfThought>
);
}
function getActivityHeader(streamState: LighthouseV2StreamState): string {
if (streamState.status === LIGHTHOUSE_V2_STREAM_STATUS.DISCONNECTED) {
return "Reconnecting";
}
if (streamState.toolCalls.some((toolCall) => toolCall.status === "running")) {
return "Using tools";
}
return "Thinking";
}
function getToolCallLabel(
toolCall: LighthouseV2StreamState["toolCalls"][number],
): string {
return `${toolCall.status === "running" ? "Calling" : "Called"} ${
toolCall.name
}`;
}
@@ -0,0 +1,394 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import {
Bot,
KeyRound,
Loader2,
PlugZap,
RefreshCw,
Save,
ShieldCheck,
Sparkles,
Trash2,
} from "lucide-react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { Controller, useForm } from "react-hook-form";
import {
createLighthouseV2Configuration,
deleteLighthouseV2Configuration,
testLighthouseV2ConfigurationConnection,
updateLighthouseV2Configuration,
} from "@/app/(prowler)/lighthouse/_actions";
import {
buildCredentialPayload,
buildLighthouseV2ConfigFormSchema,
BUSINESS_CONTEXT_LIMIT,
EMPTY_FORM_VALUES,
FEEDBACK_VARIANT,
type FeedbackState,
getConnectionStatus,
getFormDefaults,
type LighthouseV2ConfigFormValues,
trimToNullable,
} from "@/app/(prowler)/lighthouse/_lib/config";
import {
type LighthouseV2Configuration,
type LighthouseV2ConfigurationInput,
type LighthouseV2ConfigurationUpdateInput,
type LighthouseV2SupportedModel,
type LighthouseV2SupportedProvider,
} from "@/app/(prowler)/lighthouse/_types";
import { Button } from "@/components/shadcn/button/button";
import { Field, FieldError, FieldLabel } from "@/components/shadcn/field/field";
import { Modal } from "@/components/shadcn/modal";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/shadcn/select/select";
import { Textarea } from "@/components/shadcn/textarea/textarea";
import { cn } from "@/lib/utils";
import { ConfigurationSection } from "./configuration-section";
import { ConnectionStatusPanel } from "./connection-status-panel";
import { CredentialFields } from "./credential-fields";
import { ModelDetails } from "./model-details";
import { ProviderIcon } from "./provider-icon";
import { StatusBadge } from "./status-badge";
export function LighthouseV2ConfigurationForm({
configuration,
models,
onConfigurationDeleted,
onConfigurationSaved,
onFeedback,
provider,
}: {
configuration?: LighthouseV2Configuration;
models: LighthouseV2SupportedModel[];
onConfigurationDeleted: (configurationId: string) => void;
onConfigurationSaved: (configuration: LighthouseV2Configuration) => void;
onFeedback: (feedback: FeedbackState | null) => void;
provider: LighthouseV2SupportedProvider;
}) {
const router = useRouter();
const [saving, setSaving] = useState(false);
const [testing, setTesting] = useState(false);
const [deleting, setDeleting] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false);
const providerType = provider.id;
const hasConfiguration = Boolean(configuration);
const form = useForm<LighthouseV2ConfigFormValues>({
resolver: zodResolver(
buildLighthouseV2ConfigFormSchema(providerType, hasConfiguration),
),
defaultValues: getFormDefaults(configuration),
mode: "onSubmit",
});
const businessContext = form.watch("businessContext");
const selectedModel = form.watch("defaultModel");
const selectedModelDetails = models.find(
(model) => model.id === selectedModel,
);
const status = getConnectionStatus(configuration);
const handleSave = async (values: LighthouseV2ConfigFormValues) => {
setSaving(true);
onFeedback(null);
const credentials = buildCredentialPayload(
providerType,
values,
hasConfiguration,
);
const basePayload = {
baseUrl: trimToNullable(values.baseUrl),
defaultModel: trimToNullable(values.defaultModel),
businessContext: values.businessContext,
};
const result = configuration
? await updateLighthouseV2Configuration(configuration.id, {
...basePayload,
...(credentials ? { credentials } : {}),
} satisfies LighthouseV2ConfigurationUpdateInput)
: await createLighthouseV2Configuration({
providerType,
credentials:
credentials as LighthouseV2ConfigurationInput["credentials"],
...basePayload,
});
setSaving(false);
if ("error" in result) {
onFeedback({
title: "Configuration not saved",
description: result.error,
variant: FEEDBACK_VARIANT.ERROR,
});
return;
}
form.reset(getFormDefaults(result.data));
onConfigurationSaved(result.data);
};
const handleTestConnection = async () => {
if (!configuration) return;
setTesting(true);
onFeedback(null);
const result = await testLighthouseV2ConfigurationConnection(
configuration.id,
);
setTesting(false);
if ("error" in result) {
onFeedback({
title: "Connection check failed to start",
description: result.error,
variant: FEEDBACK_VARIANT.ERROR,
});
return;
}
onFeedback({
title: "Connection check started.",
description:
"The backend is validating this provider. Refresh status when the task finishes.",
variant: FEEDBACK_VARIANT.INFO,
showRefreshStatus: true,
});
};
const handleDelete = async () => {
if (!configuration) return;
setDeleting(true);
const result = await deleteLighthouseV2Configuration(configuration.id);
setDeleting(false);
if ("error" in result) {
onFeedback({
title: "Configuration not removed",
description: result.error,
variant: FEEDBACK_VARIANT.ERROR,
});
return;
}
setDeleteOpen(false);
form.reset(EMPTY_FORM_VALUES);
onConfigurationDeleted(configuration.id);
};
return (
<section className="min-w-0">
<div className="border-border-neutral-secondary flex flex-col gap-4 border-b px-4 py-4 md:flex-row md:items-start md:justify-between md:px-5">
<div className="flex min-w-0 gap-3">
<div className="border-border-neutral-secondary bg-bg-neutral-tertiary flex size-12 shrink-0 items-center justify-center rounded-[10px] border">
<ProviderIcon
provider={providerType}
className="text-text-neutral-secondary size-6"
/>
</div>
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<h3 className="text-text-neutral-primary text-xl font-semibold">
{provider.name}
</h3>
<StatusBadge status={status} />
</div>
<p className="text-text-neutral-secondary mt-1 max-w-2xl text-sm">
{configuration
? "Stored provider configuration. Rotate credentials only when needed."
: "Create provider configuration before Lighthouse can use this model family."}
</p>
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
<Button
type="button"
variant="outline"
onClick={handleTestConnection}
disabled={!configuration || testing}
>
{testing ? <Loader2 className="animate-spin" /> : <PlugZap />}
Test connection
</Button>
<Button
type="button"
variant="outline"
onClick={() => router.refresh()}
>
<RefreshCw />
Refresh status
</Button>
</div>
</div>
<form
className="grid gap-0"
onSubmit={form.handleSubmit(handleSave)}
noValidate
>
<ConfigurationSection
icon={<ShieldCheck className="size-4" />}
title="Connection"
description="Current backend check result for this provider."
>
<ConnectionStatusPanel
configuration={configuration}
status={status}
/>
</ConfigurationSection>
<ConfigurationSection
icon={<KeyRound className="size-4" />}
title="Credentials"
description={
configuration
? "Leave blank to keep existing credentials."
: "Credentials are required for new configurations."
}
>
<CredentialFields
errors={form.formState.errors}
hasConfiguration={hasConfiguration}
provider={providerType}
register={form.register}
/>
</ConfigurationSection>
<ConfigurationSection
icon={<Sparkles className="size-4" />}
title="Default model"
description="Model used when chat does not override provider/model for a turn."
>
<Controller
control={form.control}
name="defaultModel"
render={({ field }) => (
<Field>
<FieldLabel htmlFor="lighthouse-v2-model">
Default model
</FieldLabel>
<Select
value={field.value}
onValueChange={field.onChange}
allowDeselect
>
<SelectTrigger id="lighthouse-v2-model">
<SelectValue placeholder="Select model" />
</SelectTrigger>
<SelectContent width="wide">
{models.map((model) => (
<SelectItem key={model.id} value={model.id}>
{model.id}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
)}
/>
<ModelDetails model={selectedModelDetails} />
</ConfigurationSection>
<ConfigurationSection
icon={<Bot className="size-4" />}
title="Business context"
description="Short operational context Lighthouse should consider while answering."
>
<Field>
<div className="flex items-center justify-between gap-3">
<FieldLabel htmlFor="lighthouse-v2-business-context">
Business context
</FieldLabel>
<span
className={cn(
"text-xs",
businessContext.length > BUSINESS_CONTEXT_LIMIT
? "text-text-error-primary"
: "text-text-neutral-tertiary",
)}
>
{businessContext.length}/{BUSINESS_CONTEXT_LIMIT}
</span>
</div>
<Textarea
id="lighthouse-v2-business-context"
textareaSize="lg"
aria-invalid={Boolean(form.formState.errors.businessContext)}
placeholder="Example: production AWS accounts, PCI workloads, EU data residency, critical internet-facing services..."
{...form.register("businessContext")}
/>
{form.formState.errors.businessContext?.message && (
<FieldError>
{form.formState.errors.businessContext.message}
</FieldError>
)}
</Field>
</ConfigurationSection>
<div className="flex flex-col gap-3 px-4 py-4 sm:flex-row sm:items-center sm:justify-between md:px-5">
<div className="text-text-neutral-secondary text-sm">
{configuration
? "Saving updates may change chat behavior immediately."
: "Save provider before testing the connection."}
</div>
<div className="flex flex-wrap gap-2">
<Button type="submit" disabled={saving}>
{saving ? <Loader2 className="animate-spin" /> : <Save />}
Save
</Button>
<Button
type="button"
variant="destructive"
onClick={() => setDeleteOpen(true)}
disabled={!configuration || deleting}
>
{deleting ? <Loader2 className="animate-spin" /> : <Trash2 />}
Delete
</Button>
</div>
</div>
</form>
<Modal
open={deleteOpen}
onOpenChange={setDeleteOpen}
title="Delete Lighthouse configuration?"
description={`This removes ${provider.name} from Lighthouse. Existing chat history stays available, but this provider cannot be used until configured again.`}
size="md"
>
<div className="flex justify-end gap-2">
<Button
type="button"
variant="outline"
onClick={() => setDeleteOpen(false)}
>
Cancel
</Button>
<Button
type="button"
variant="destructive"
onClick={handleDelete}
disabled={deleting}
>
{deleting ? <Loader2 className="animate-spin" /> : <Trash2 />}
Delete configuration
</Button>
</div>
</Modal>
</section>
);
}
@@ -0,0 +1,32 @@
import { type ReactNode } from "react";
export function ConfigurationSection({
children,
description,
icon,
title,
}: {
children: ReactNode;
description: string;
icon: ReactNode;
title: string;
}) {
return (
<section className="border-border-neutral-secondary grid gap-4 border-b px-4 py-5 md:grid-cols-[220px_minmax(0,1fr)] md:px-5">
<div className="flex gap-3">
<div className="border-border-neutral-secondary bg-bg-neutral-tertiary flex size-8 shrink-0 items-center justify-center rounded-[8px] border">
{icon}
</div>
<div>
<h4 className="text-text-neutral-primary text-sm font-semibold">
{title}
</h4>
<p className="text-text-neutral-secondary mt-1 text-sm">
{description}
</p>
</div>
</div>
<div className="min-w-0">{children}</div>
</section>
);
}
@@ -0,0 +1,44 @@
import { AlertCircle, CheckCircle2, CircleDashed } from "lucide-react";
import {
CONNECTION_STATUS,
type ConnectionStatus,
getAlertVariant,
getConnectionStatusLabel,
} from "@/app/(prowler)/lighthouse/_lib/config";
import { formatLastChecked } from "@/app/(prowler)/lighthouse/_lib/format";
import { type LighthouseV2Configuration } from "@/app/(prowler)/lighthouse/_types";
import { Alert, AlertDescription, AlertTitle } from "@/components/shadcn/alert";
export function ConnectionStatusPanel({
configuration,
status,
}: {
configuration?: LighthouseV2Configuration;
status: ConnectionStatus;
}) {
const statusText = getConnectionStatusLabel(status);
const description =
status === CONNECTION_STATUS.CONNECTED
? "Lighthouse can send messages with this provider."
: status === CONNECTION_STATUS.FAILED
? "Connection failed. Review credentials and run another test."
: "Connection has not been tested yet.";
return (
<Alert variant={getAlertVariant(status)}>
{status === CONNECTION_STATUS.CONNECTED ? (
<CheckCircle2 className="size-4" />
) : status === CONNECTION_STATUS.FAILED ? (
<AlertCircle className="size-4" />
) : (
<CircleDashed className="size-4" />
)}
<AlertTitle>{statusText}</AlertTitle>
<AlertDescription>
<p>{description}</p>
<p>{formatLastChecked(configuration?.connectionLastCheckedAt)}</p>
</AlertDescription>
</Alert>
);
}
@@ -0,0 +1,116 @@
import { type useForm } from "react-hook-form";
import { type LighthouseV2ConfigFormValues } from "@/app/(prowler)/lighthouse/_lib/config";
import {
LIGHTHOUSE_V2_PROVIDER_TYPE,
type LighthouseV2ProviderType,
} from "@/app/(prowler)/lighthouse/_types";
import { Field, FieldError, FieldLabel } from "@/components/shadcn/field/field";
import { Input } from "@/components/shadcn/input/input";
export function CredentialFields({
errors,
hasConfiguration,
provider,
register,
}: {
errors: ReturnType<
typeof useForm<LighthouseV2ConfigFormValues>
>["formState"]["errors"];
hasConfiguration: boolean;
provider: LighthouseV2ProviderType;
register: ReturnType<
typeof useForm<LighthouseV2ConfigFormValues>
>["register"];
}) {
return (
<div className="grid gap-4">
{hasConfiguration && (
<p className="text-text-neutral-secondary text-sm">
Leave blank to keep existing credentials.
</p>
)}
{(provider === LIGHTHOUSE_V2_PROVIDER_TYPE.OPENAI ||
provider === LIGHTHOUSE_V2_PROVIDER_TYPE.OPENAI_COMPATIBLE) && (
<Field>
<FieldLabel htmlFor="lighthouse-v2-api-key">API key</FieldLabel>
<Input
id="lighthouse-v2-api-key"
type="password"
autoComplete="off"
aria-invalid={Boolean(errors.apiKey)}
{...register("apiKey")}
/>
{errors.apiKey?.message && (
<FieldError>{errors.apiKey.message}</FieldError>
)}
</Field>
)}
{provider === LIGHTHOUSE_V2_PROVIDER_TYPE.OPENAI_COMPATIBLE && (
<Field>
<FieldLabel htmlFor="lighthouse-v2-base-url">Base URL</FieldLabel>
<Input
id="lighthouse-v2-base-url"
aria-invalid={Boolean(errors.baseUrl)}
placeholder="https://llm.example.com/v1"
{...register("baseUrl")}
/>
{errors.baseUrl?.message && (
<FieldError>{errors.baseUrl.message}</FieldError>
)}
</Field>
)}
{provider === LIGHTHOUSE_V2_PROVIDER_TYPE.BEDROCK && (
<div className="grid gap-4 md:grid-cols-2">
<Field>
<FieldLabel htmlFor="lighthouse-v2-access-key">
AWS access key ID
</FieldLabel>
<Input
id="lighthouse-v2-access-key"
type="password"
autoComplete="off"
aria-invalid={Boolean(errors.awsAccessKeyId)}
{...register("awsAccessKeyId")}
/>
{errors.awsAccessKeyId?.message && (
<FieldError>{errors.awsAccessKeyId.message}</FieldError>
)}
</Field>
<Field>
<FieldLabel htmlFor="lighthouse-v2-secret-key">
AWS secret access key
</FieldLabel>
<Input
id="lighthouse-v2-secret-key"
type="password"
autoComplete="off"
aria-invalid={Boolean(errors.awsSecretAccessKey)}
{...register("awsSecretAccessKey")}
/>
{errors.awsSecretAccessKey?.message && (
<FieldError>{errors.awsSecretAccessKey.message}</FieldError>
)}
</Field>
<Field className="md:col-span-2">
<FieldLabel htmlFor="lighthouse-v2-region">AWS region</FieldLabel>
<Input
id="lighthouse-v2-region"
placeholder="us-east-1"
aria-invalid={Boolean(errors.awsRegionName)}
{...register("awsRegionName")}
/>
{errors.awsRegionName?.message && (
<FieldError>{errors.awsRegionName.message}</FieldError>
)}
</Field>
</div>
)}
</div>
);
}
@@ -0,0 +1,32 @@
import { AlertCircle, DatabaseZap } from "lucide-react";
import { Alert, AlertDescription, AlertTitle } from "@/components/shadcn/alert";
import { Card, CardContent } from "@/components/shadcn/card/card";
export function LighthouseV2EmptyState({ error }: { error?: string }) {
return (
<Card variant="base" padding="lg" className="mx-auto max-w-3xl">
<CardContent className="flex flex-col items-center gap-4 py-8 text-center">
<div className="border-border-neutral-secondary bg-bg-neutral-tertiary flex size-14 items-center justify-center rounded-[14px] border">
<DatabaseZap className="text-text-neutral-secondary size-7" />
</div>
<div>
<h2 className="text-text-neutral-primary text-xl font-semibold">
No Lighthouse providers available
</h2>
<p className="text-text-neutral-secondary mt-2 text-sm">
Cloud did not return supported providers for Lighthouse
configuration.
</p>
</div>
{error && (
<Alert variant="error" className="text-left">
<AlertCircle className="size-4" />
<AlertTitle>Configuration unavailable</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
</CardContent>
</Card>
);
}
@@ -0,0 +1,47 @@
import { AlertCircle, CheckCircle2, RefreshCw } from "lucide-react";
import {
FEEDBACK_VARIANT,
type FeedbackState,
} from "@/app/(prowler)/lighthouse/_lib/config";
import { Alert, AlertDescription, AlertTitle } from "@/components/shadcn/alert";
import { Button } from "@/components/shadcn/button/button";
export function ConfigFeedbackAlert({
feedback,
onClose,
onRefreshStatus,
}: {
feedback: FeedbackState;
onClose: () => void;
onRefreshStatus: () => void;
}) {
const Icon =
feedback.variant === FEEDBACK_VARIANT.ERROR
? AlertCircle
: feedback.variant === FEEDBACK_VARIANT.SUCCESS
? CheckCircle2
: RefreshCw;
return (
<Alert variant={feedback.variant} onClose={onClose}>
<Icon className="size-4" />
<AlertTitle>{feedback.title}</AlertTitle>
<AlertDescription>
{feedback.description && <p>{feedback.description}</p>}
{feedback.showRefreshStatus && (
<Button
type="button"
variant="link"
size="link-sm"
className="h-auto p-0"
onClick={onRefreshStatus}
>
<RefreshCw className="size-3.5" />
Refresh status
</Button>
)}
</AlertDescription>
</Alert>
);
}
@@ -294,7 +294,10 @@ describe("LighthouseV2ConfigPage", () => {
name: /Business context/i,
});
await user.clear(businessContext);
await user.type(businessContext, "a".repeat(1001));
// Paste the whole string in one event instead of 1001 keystrokes, which
// re-renders the watched form on every character and times out under load.
await user.click(businessContext);
await user.paste("a".repeat(1001));
await user.click(screen.getByRole("button", { name: /^Save$/i }));
// Then
@@ -1,60 +1,24 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import {
AlertCircle,
Bot,
CheckCircle2,
CircleDashed,
Cloud,
DatabaseZap,
KeyRound,
Loader2,
PlugZap,
RefreshCw,
Save,
Server,
ShieldCheck,
Sparkles,
Trash2,
} from "lucide-react";
import { useRouter } from "next/navigation";
import { type ReactNode, useState } from "react";
import { Controller, useForm } from "react-hook-form";
import { z } from "zod";
import { useState } from "react";
import {
createLighthouseV2Configuration,
deleteLighthouseV2Configuration,
testLighthouseV2ConfigurationConnection,
updateLighthouseV2Configuration,
} from "@/app/(prowler)/lighthouse/_actions";
FEEDBACK_VARIANT,
type FeedbackState,
} from "@/app/(prowler)/lighthouse/_lib/config";
import {
LIGHTHOUSE_V2_PROVIDER_TYPE,
type LighthouseV2Configuration,
type LighthouseV2ConfigurationInput,
type LighthouseV2ConfigurationUpdateInput,
type LighthouseV2Credentials,
type LighthouseV2ProviderType,
type LighthouseV2SupportedModel,
type LighthouseV2SupportedProvider,
} from "@/app/(prowler)/lighthouse/_types";
import { Alert, AlertDescription, AlertTitle } from "@/components/shadcn/alert";
import { Badge } from "@/components/shadcn/badge/badge";
import { Button } from "@/components/shadcn/button/button";
import { Card, CardContent } from "@/components/shadcn/card/card";
import { Field, FieldError, FieldLabel } from "@/components/shadcn/field/field";
import { Input } from "@/components/shadcn/input/input";
import { Modal } from "@/components/shadcn/modal";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/shadcn/select/select";
import { Textarea } from "@/components/shadcn/textarea/textarea";
import { cn } from "@/lib/utils";
import { Card } from "@/components/shadcn/card/card";
import { LighthouseV2ConfigurationForm } from "./configuration-form";
import { LighthouseV2EmptyState } from "./empty-state";
import { ConfigFeedbackAlert } from "./feedback-alert";
import { LighthouseV2ProviderRail } from "./provider-rail";
interface LighthouseV2ConfigPageProps {
configurations: LighthouseV2Configuration[];
@@ -66,58 +30,6 @@ interface LighthouseV2ConfigPageProps {
error?: string;
}
const BUSINESS_CONTEXT_LIMIT = 1000;
const CONNECTION_STATUS = {
CONNECTED: "connected",
FAILED: "failed",
NOT_TESTED: "not-tested",
} as const;
type ConnectionStatus =
(typeof CONNECTION_STATUS)[keyof typeof CONNECTION_STATUS];
const FEEDBACK_VARIANT = {
ERROR: "error",
SUCCESS: "success",
INFO: "info",
} as const;
type FeedbackVariant = (typeof FEEDBACK_VARIANT)[keyof typeof FEEDBACK_VARIANT];
interface FeedbackState {
title: string;
description?: string;
variant: FeedbackVariant;
showRefreshStatus?: boolean;
}
const lighthouseV2ConfigFormSchemaBase = z.object({
apiKey: z.string(),
awsAccessKeyId: z.string(),
awsSecretAccessKey: z.string(),
awsRegionName: z.string(),
baseUrl: z.string(),
defaultModel: z.string(),
businessContext: z.string().max(BUSINESS_CONTEXT_LIMIT, {
error: "Business context cannot exceed 1000 characters.",
}),
});
type LighthouseV2ConfigFormValues = z.infer<
typeof lighthouseV2ConfigFormSchemaBase
>;
const EMPTY_FORM_VALUES: LighthouseV2ConfigFormValues = {
apiKey: "",
awsAccessKeyId: "",
awsSecretAccessKey: "",
awsRegionName: "",
baseUrl: "",
defaultModel: "",
businessContext: "",
};
export function LighthouseV2ConfigPage({
configurations,
providers,
@@ -191,7 +103,7 @@ export function LighthouseV2ConfigPage({
>
{feedback && (
<div className="border-border-neutral-secondary border-b px-4 py-4 md:px-5">
<LighthouseV2Feedback
<ConfigFeedbackAlert
feedback={feedback}
onRefreshStatus={() => router.refresh()}
onClose={() => setFeedback(null)}
@@ -233,863 +145,3 @@ export function LighthouseV2ConfigPage({
</Card>
);
}
function LighthouseV2Feedback({
feedback,
onClose,
onRefreshStatus,
}: {
feedback: FeedbackState;
onClose: () => void;
onRefreshStatus: () => void;
}) {
const Icon =
feedback.variant === FEEDBACK_VARIANT.ERROR
? AlertCircle
: feedback.variant === FEEDBACK_VARIANT.SUCCESS
? CheckCircle2
: RefreshCw;
return (
<Alert variant={feedback.variant} onClose={onClose}>
<Icon className="size-4" />
<AlertTitle>{feedback.title}</AlertTitle>
<AlertDescription>
{feedback.description && <p>{feedback.description}</p>}
{feedback.showRefreshStatus && (
<Button
type="button"
variant="link"
size="link-sm"
className="h-auto p-0"
onClick={onRefreshStatus}
>
<RefreshCw className="size-3.5" />
Refresh status
</Button>
)}
</AlertDescription>
</Alert>
);
}
function LighthouseV2ProviderRail({
configurations,
providers,
selectedProvider,
onSelectProvider,
}: {
configurations: LighthouseV2Configuration[];
providers: LighthouseV2SupportedProvider[];
selectedProvider: LighthouseV2ProviderType;
onSelectProvider: (provider: LighthouseV2ProviderType) => void;
}) {
return (
<aside className="flex min-w-0 flex-col gap-3">
<div className="flex items-center justify-between gap-3 px-1">
<div>
<h3 className="text-text-neutral-primary text-sm font-semibold">
Providers
</h3>
<p className="text-text-neutral-secondary text-xs">
Choose provider to configure
</p>
</div>
</div>
<div className="flex flex-col gap-2">
{providers.map((provider) => {
const config = configurations.find(
(item) => item.providerType === provider.id,
);
const active = provider.id === selectedProvider;
const status = getConnectionStatus(config);
const Icon = getProviderIcon(provider.id);
return (
<button
key={provider.id}
type="button"
aria-label={provider.name}
aria-pressed={active}
onClick={() => onSelectProvider(provider.id)}
className={cn(
"border-border-neutral-secondary bg-bg-neutral-secondary hover:bg-bg-neutral-tertiary group flex min-w-0 items-start gap-3 rounded-[12px] border p-3 text-left transition-colors",
active &&
"border-border-input-primary-press bg-bg-neutral-tertiary ring-border-input-primary-press ring-1",
)}
>
<div className="border-border-neutral-secondary bg-bg-neutral-tertiary flex size-10 shrink-0 items-center justify-center rounded-[9px] border">
<Icon className="text-text-neutral-secondary size-5" />
</div>
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center justify-between gap-2">
<span className="text-text-neutral-primary truncate text-sm font-medium">
{provider.name}
</span>
<StatusBadge status={status} />
</div>
<p className="text-text-neutral-secondary mt-1 truncate text-xs">
{config?.defaultModel || "No default model"}
</p>
<p className="text-text-neutral-tertiary mt-1 text-xs">
{formatLastChecked(config?.connectionLastCheckedAt)}
</p>
</div>
</button>
);
})}
</div>
</aside>
);
}
function LighthouseV2ConfigurationForm({
configuration,
models,
onConfigurationDeleted,
onConfigurationSaved,
onFeedback,
provider,
}: {
configuration?: LighthouseV2Configuration;
models: LighthouseV2SupportedModel[];
onConfigurationDeleted: (configurationId: string) => void;
onConfigurationSaved: (configuration: LighthouseV2Configuration) => void;
onFeedback: (feedback: FeedbackState | null) => void;
provider: LighthouseV2SupportedProvider;
}) {
const router = useRouter();
const [saving, setSaving] = useState(false);
const [testing, setTesting] = useState(false);
const [deleting, setDeleting] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false);
const providerType = provider.id;
const hasConfiguration = Boolean(configuration);
const form = useForm<LighthouseV2ConfigFormValues>({
resolver: zodResolver(
buildLighthouseV2ConfigFormSchema(providerType, hasConfiguration),
),
defaultValues: getFormDefaults(configuration),
mode: "onSubmit",
});
const businessContext = form.watch("businessContext");
const selectedModel = form.watch("defaultModel");
const selectedModelDetails = models.find(
(model) => model.id === selectedModel,
);
const status = getConnectionStatus(configuration);
const handleSave = async (values: LighthouseV2ConfigFormValues) => {
setSaving(true);
onFeedback(null);
const credentials = buildCredentialPayload(
providerType,
values,
hasConfiguration,
);
const basePayload = {
baseUrl: trimToNullable(values.baseUrl),
defaultModel: trimToNullable(values.defaultModel),
businessContext: values.businessContext,
};
const result = configuration
? await updateLighthouseV2Configuration(configuration.id, {
...basePayload,
...(credentials ? { credentials } : {}),
} satisfies LighthouseV2ConfigurationUpdateInput)
: await createLighthouseV2Configuration({
providerType,
credentials:
credentials as LighthouseV2ConfigurationInput["credentials"],
...basePayload,
});
setSaving(false);
if ("error" in result) {
onFeedback({
title: "Configuration not saved",
description: result.error,
variant: FEEDBACK_VARIANT.ERROR,
});
return;
}
form.reset(getFormDefaults(result.data));
onConfigurationSaved(result.data);
};
const handleTestConnection = async () => {
if (!configuration) return;
setTesting(true);
onFeedback(null);
const result = await testLighthouseV2ConfigurationConnection(
configuration.id,
);
setTesting(false);
if ("error" in result) {
onFeedback({
title: "Connection check failed to start",
description: result.error,
variant: FEEDBACK_VARIANT.ERROR,
});
return;
}
onFeedback({
title: "Connection check started.",
description:
"The backend is validating this provider. Refresh status when the task finishes.",
variant: FEEDBACK_VARIANT.INFO,
showRefreshStatus: true,
});
};
const handleDelete = async () => {
if (!configuration) return;
setDeleting(true);
const result = await deleteLighthouseV2Configuration(configuration.id);
setDeleting(false);
if ("error" in result) {
onFeedback({
title: "Configuration not removed",
description: result.error,
variant: FEEDBACK_VARIANT.ERROR,
});
return;
}
setDeleteOpen(false);
form.reset(EMPTY_FORM_VALUES);
onConfigurationDeleted(configuration.id);
};
return (
<section className="min-w-0">
<div className="border-border-neutral-secondary flex flex-col gap-4 border-b px-4 py-4 md:flex-row md:items-start md:justify-between md:px-5">
<div className="flex min-w-0 gap-3">
<div className="border-border-neutral-secondary bg-bg-neutral-tertiary flex size-12 shrink-0 items-center justify-center rounded-[10px] border">
{(() => {
const Icon = getProviderIcon(providerType);
return <Icon className="text-text-neutral-secondary size-6" />;
})()}
</div>
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<h3 className="text-text-neutral-primary text-xl font-semibold">
{provider.name}
</h3>
<StatusBadge status={status} />
</div>
<p className="text-text-neutral-secondary mt-1 max-w-2xl text-sm">
{configuration
? "Stored provider configuration. Rotate credentials only when needed."
: "Create provider configuration before Lighthouse can use this model family."}
</p>
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
<Button
type="button"
variant="outline"
onClick={handleTestConnection}
disabled={!configuration || testing}
>
{testing ? <Loader2 className="animate-spin" /> : <PlugZap />}
Test connection
</Button>
<Button
type="button"
variant="outline"
onClick={() => router.refresh()}
>
<RefreshCw />
Refresh status
</Button>
</div>
</div>
<form
className="grid gap-0"
onSubmit={form.handleSubmit(handleSave)}
noValidate
>
<ConfigurationSection
icon={<ShieldCheck className="size-4" />}
title="Connection"
description="Current backend check result for this provider."
>
<ConnectionStatusPanel
configuration={configuration}
status={status}
/>
</ConfigurationSection>
<ConfigurationSection
icon={<KeyRound className="size-4" />}
title="Credentials"
description={
configuration
? "Leave blank to keep existing credentials."
: "Credentials are required for new configurations."
}
>
<CredentialFields
errors={form.formState.errors}
hasConfiguration={hasConfiguration}
provider={providerType}
register={form.register}
/>
</ConfigurationSection>
<ConfigurationSection
icon={<Sparkles className="size-4" />}
title="Default model"
description="Model used when chat does not override provider/model for a turn."
>
<Controller
control={form.control}
name="defaultModel"
render={({ field }) => (
<Field>
<FieldLabel htmlFor="lighthouse-v2-model">
Default model
</FieldLabel>
<Select
value={field.value}
onValueChange={field.onChange}
allowDeselect
>
<SelectTrigger id="lighthouse-v2-model">
<SelectValue placeholder="Select model" />
</SelectTrigger>
<SelectContent width="wide">
{models.map((model) => (
<SelectItem key={model.id} value={model.id}>
{model.id}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
)}
/>
<ModelDetails model={selectedModelDetails} />
</ConfigurationSection>
<ConfigurationSection
icon={<Bot className="size-4" />}
title="Business context"
description="Short operational context Lighthouse should consider while answering."
>
<Field>
<div className="flex items-center justify-between gap-3">
<FieldLabel htmlFor="lighthouse-v2-business-context">
Business context
</FieldLabel>
<span
className={cn(
"text-xs",
businessContext.length > BUSINESS_CONTEXT_LIMIT
? "text-text-error-primary"
: "text-text-neutral-tertiary",
)}
>
{businessContext.length}/{BUSINESS_CONTEXT_LIMIT}
</span>
</div>
<Textarea
id="lighthouse-v2-business-context"
textareaSize="lg"
aria-invalid={Boolean(form.formState.errors.businessContext)}
placeholder="Example: production AWS accounts, PCI workloads, EU data residency, critical internet-facing services..."
{...form.register("businessContext")}
/>
{form.formState.errors.businessContext?.message && (
<FieldError>
{form.formState.errors.businessContext.message}
</FieldError>
)}
</Field>
</ConfigurationSection>
<div className="flex flex-col gap-3 px-4 py-4 sm:flex-row sm:items-center sm:justify-between md:px-5">
<div className="text-text-neutral-secondary text-sm">
{configuration
? "Saving updates may change chat behavior immediately."
: "Save provider before testing the connection."}
</div>
<div className="flex flex-wrap gap-2">
<Button type="submit" disabled={saving}>
{saving ? <Loader2 className="animate-spin" /> : <Save />}
Save
</Button>
<Button
type="button"
variant="destructive"
onClick={() => setDeleteOpen(true)}
disabled={!configuration || deleting}
>
{deleting ? <Loader2 className="animate-spin" /> : <Trash2 />}
Delete
</Button>
</div>
</div>
</form>
<Modal
open={deleteOpen}
onOpenChange={setDeleteOpen}
title="Delete Lighthouse configuration?"
description={`This removes ${provider.name} from Lighthouse. Existing chat history stays available, but this provider cannot be used until configured again.`}
size="md"
>
<div className="flex justify-end gap-2">
<Button
type="button"
variant="outline"
onClick={() => setDeleteOpen(false)}
>
Cancel
</Button>
<Button
type="button"
variant="destructive"
onClick={handleDelete}
disabled={deleting}
>
{deleting ? <Loader2 className="animate-spin" /> : <Trash2 />}
Delete configuration
</Button>
</div>
</Modal>
</section>
);
}
function ConfigurationSection({
children,
description,
icon,
title,
}: {
children: ReactNode;
description: string;
icon: ReactNode;
title: string;
}) {
return (
<section className="border-border-neutral-secondary grid gap-4 border-b px-4 py-5 md:grid-cols-[220px_minmax(0,1fr)] md:px-5">
<div className="flex gap-3">
<div className="border-border-neutral-secondary bg-bg-neutral-tertiary flex size-8 shrink-0 items-center justify-center rounded-[8px] border">
{icon}
</div>
<div>
<h4 className="text-text-neutral-primary text-sm font-semibold">
{title}
</h4>
<p className="text-text-neutral-secondary mt-1 text-sm">
{description}
</p>
</div>
</div>
<div className="min-w-0">{children}</div>
</section>
);
}
function ConnectionStatusPanel({
configuration,
status,
}: {
configuration?: LighthouseV2Configuration;
status: ConnectionStatus;
}) {
const statusText = getConnectionStatusLabel(status);
const description =
status === CONNECTION_STATUS.CONNECTED
? "Lighthouse can send messages with this provider."
: status === CONNECTION_STATUS.FAILED
? "Connection failed. Review credentials and run another test."
: "Connection has not been tested yet.";
return (
<Alert variant={getAlertVariant(status)}>
{status === CONNECTION_STATUS.CONNECTED ? (
<CheckCircle2 className="size-4" />
) : status === CONNECTION_STATUS.FAILED ? (
<AlertCircle className="size-4" />
) : (
<CircleDashed className="size-4" />
)}
<AlertTitle>{statusText}</AlertTitle>
<AlertDescription>
<p>{description}</p>
<p>{formatLastChecked(configuration?.connectionLastCheckedAt)}</p>
</AlertDescription>
</Alert>
);
}
function CredentialFields({
errors,
hasConfiguration,
provider,
register,
}: {
errors: ReturnType<
typeof useForm<LighthouseV2ConfigFormValues>
>["formState"]["errors"];
hasConfiguration: boolean;
provider: LighthouseV2ProviderType;
register: ReturnType<
typeof useForm<LighthouseV2ConfigFormValues>
>["register"];
}) {
return (
<div className="grid gap-4">
{hasConfiguration && (
<p className="text-text-neutral-secondary text-sm">
Leave blank to keep existing credentials.
</p>
)}
{(provider === LIGHTHOUSE_V2_PROVIDER_TYPE.OPENAI ||
provider === LIGHTHOUSE_V2_PROVIDER_TYPE.OPENAI_COMPATIBLE) && (
<Field>
<FieldLabel htmlFor="lighthouse-v2-api-key">API key</FieldLabel>
<Input
id="lighthouse-v2-api-key"
type="password"
autoComplete="off"
aria-invalid={Boolean(errors.apiKey)}
{...register("apiKey")}
/>
{errors.apiKey?.message && (
<FieldError>{errors.apiKey.message}</FieldError>
)}
</Field>
)}
{provider === LIGHTHOUSE_V2_PROVIDER_TYPE.OPENAI_COMPATIBLE && (
<Field>
<FieldLabel htmlFor="lighthouse-v2-base-url">Base URL</FieldLabel>
<Input
id="lighthouse-v2-base-url"
aria-invalid={Boolean(errors.baseUrl)}
placeholder="https://llm.example.com/v1"
{...register("baseUrl")}
/>
{errors.baseUrl?.message && (
<FieldError>{errors.baseUrl.message}</FieldError>
)}
</Field>
)}
{provider === LIGHTHOUSE_V2_PROVIDER_TYPE.BEDROCK && (
<div className="grid gap-4 md:grid-cols-2">
<Field>
<FieldLabel htmlFor="lighthouse-v2-access-key">
AWS access key ID
</FieldLabel>
<Input
id="lighthouse-v2-access-key"
type="password"
autoComplete="off"
aria-invalid={Boolean(errors.awsAccessKeyId)}
{...register("awsAccessKeyId")}
/>
{errors.awsAccessKeyId?.message && (
<FieldError>{errors.awsAccessKeyId.message}</FieldError>
)}
</Field>
<Field>
<FieldLabel htmlFor="lighthouse-v2-secret-key">
AWS secret access key
</FieldLabel>
<Input
id="lighthouse-v2-secret-key"
type="password"
autoComplete="off"
aria-invalid={Boolean(errors.awsSecretAccessKey)}
{...register("awsSecretAccessKey")}
/>
{errors.awsSecretAccessKey?.message && (
<FieldError>{errors.awsSecretAccessKey.message}</FieldError>
)}
</Field>
<Field className="md:col-span-2">
<FieldLabel htmlFor="lighthouse-v2-region">AWS region</FieldLabel>
<Input
id="lighthouse-v2-region"
placeholder="us-east-1"
aria-invalid={Boolean(errors.awsRegionName)}
{...register("awsRegionName")}
/>
{errors.awsRegionName?.message && (
<FieldError>{errors.awsRegionName.message}</FieldError>
)}
</Field>
</div>
)}
</div>
);
}
function ModelDetails({ model }: { model?: LighthouseV2SupportedModel }) {
if (!model) {
return (
<div className="border-border-neutral-secondary bg-bg-neutral-tertiary mt-3 rounded-[10px] border px-3 py-3">
<p className="text-text-neutral-secondary text-sm">
Select a model to see capabilities.
</p>
</div>
);
}
return (
<div className="border-border-neutral-secondary bg-bg-neutral-tertiary mt-3 grid gap-3 rounded-[10px] border px-3 py-3 sm:grid-cols-3">
<CapabilityItem label="Tools" enabled={model.supportsFunctionCalling} />
<CapabilityItem label="Vision" enabled={model.supportsVision} />
<CapabilityItem label="Reasoning" enabled={model.supportsReasoning} />
<div className="text-text-neutral-secondary text-xs sm:col-span-3">
Input tokens: {formatTokenLimit(model.maxInputTokens)} · Output tokens:{" "}
{formatTokenLimit(model.maxOutputTokens)}
</div>
</div>
);
}
function CapabilityItem({
enabled,
label,
}: {
enabled: boolean | null;
label: string;
}) {
return (
<div className="flex items-center gap-2 text-sm">
{enabled ? (
<CheckCircle2 className="text-text-success-primary size-4" />
) : (
<CircleDashed className="text-text-neutral-tertiary size-4" />
)}
<span className="text-text-neutral-primary">{label}</span>
</div>
);
}
function LighthouseV2EmptyState({ error }: { error?: string }) {
return (
<Card variant="base" padding="lg" className="mx-auto max-w-3xl">
<CardContent className="flex flex-col items-center gap-4 py-8 text-center">
<div className="border-border-neutral-secondary bg-bg-neutral-tertiary flex size-14 items-center justify-center rounded-[14px] border">
<DatabaseZap className="text-text-neutral-secondary size-7" />
</div>
<div>
<h2 className="text-text-neutral-primary text-xl font-semibold">
No Lighthouse providers available
</h2>
<p className="text-text-neutral-secondary mt-2 text-sm">
Cloud did not return supported providers for Lighthouse
configuration.
</p>
</div>
{error && (
<Alert variant="error" className="text-left">
<AlertCircle className="size-4" />
<AlertTitle>Configuration unavailable</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
</CardContent>
</Card>
);
}
function StatusBadge({ status }: { status: ConnectionStatus }) {
if (status === CONNECTION_STATUS.CONNECTED) {
return (
<Badge variant="success">
<CheckCircle2 />
Connected
</Badge>
);
}
if (status === CONNECTION_STATUS.FAILED) {
return (
<Badge variant="error">
<AlertCircle />
Failed
</Badge>
);
}
return (
<Badge variant="outline">
<CircleDashed />
Not tested
</Badge>
);
}
function getConnectionStatus(
configuration?: LighthouseV2Configuration,
): ConnectionStatus {
if (configuration?.connected === true) return CONNECTION_STATUS.CONNECTED;
if (configuration?.connected === false) return CONNECTION_STATUS.FAILED;
return CONNECTION_STATUS.NOT_TESTED;
}
function getConnectionStatusLabel(status: ConnectionStatus) {
if (status === CONNECTION_STATUS.CONNECTED) return "Connected";
if (status === CONNECTION_STATUS.FAILED) return "Failed";
return "Not tested";
}
function getAlertVariant(status: ConnectionStatus) {
if (status === CONNECTION_STATUS.CONNECTED) return "success";
if (status === CONNECTION_STATUS.FAILED) return "error";
return "info";
}
function getProviderIcon(provider: LighthouseV2ProviderType) {
if (provider === LIGHTHOUSE_V2_PROVIDER_TYPE.BEDROCK) return Cloud;
if (provider === LIGHTHOUSE_V2_PROVIDER_TYPE.OPENAI_COMPATIBLE) return Server;
return Bot;
}
function getFormDefaults(
configuration?: LighthouseV2Configuration,
): LighthouseV2ConfigFormValues {
return {
...EMPTY_FORM_VALUES,
baseUrl: configuration?.baseUrl ?? "",
defaultModel: configuration?.defaultModel ?? "",
businessContext: configuration?.businessContext ?? "",
};
}
function buildLighthouseV2ConfigFormSchema(
provider: LighthouseV2ProviderType,
hasConfiguration: boolean,
) {
return lighthouseV2ConfigFormSchemaBase.superRefine((data, ctx) => {
const apiKey = data.apiKey.trim();
if (
provider === LIGHTHOUSE_V2_PROVIDER_TYPE.OPENAI_COMPATIBLE &&
!data.baseUrl.trim()
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Base URL is required for OpenAI-compatible providers.",
path: ["baseUrl"],
});
}
if (
(provider === LIGHTHOUSE_V2_PROVIDER_TYPE.OPENAI ||
provider === LIGHTHOUSE_V2_PROVIDER_TYPE.OPENAI_COMPATIBLE) &&
!hasConfiguration &&
!apiKey
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "API key is required for new configurations.",
path: ["apiKey"],
});
}
if (provider !== LIGHTHOUSE_V2_PROVIDER_TYPE.BEDROCK) return;
const hasAnyBedrockCredential =
Boolean(data.awsAccessKeyId.trim()) ||
Boolean(data.awsSecretAccessKey.trim()) ||
Boolean(data.awsRegionName.trim());
const shouldRequireBedrockCredentials =
!hasConfiguration || hasAnyBedrockCredential;
if (!shouldRequireBedrockCredentials) return;
if (!data.awsAccessKeyId.trim()) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "AWS access key ID is required.",
path: ["awsAccessKeyId"],
});
}
if (!data.awsSecretAccessKey.trim()) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "AWS secret access key is required.",
path: ["awsSecretAccessKey"],
});
}
if (!data.awsRegionName.trim()) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "AWS region is required.",
path: ["awsRegionName"],
});
}
});
}
function buildCredentialPayload(
provider: LighthouseV2ProviderType,
values: LighthouseV2ConfigFormValues,
hasConfiguration: boolean,
): LighthouseV2Credentials | undefined {
if (provider === LIGHTHOUSE_V2_PROVIDER_TYPE.BEDROCK) {
const hasBedrockCredentials =
Boolean(values.awsAccessKeyId.trim()) ||
Boolean(values.awsSecretAccessKey.trim()) ||
Boolean(values.awsRegionName.trim());
if (hasConfiguration && !hasBedrockCredentials) return undefined;
return {
aws_access_key_id: values.awsAccessKeyId.trim(),
aws_secret_access_key: values.awsSecretAccessKey.trim(),
aws_region_name: values.awsRegionName.trim(),
};
}
if (hasConfiguration && !values.apiKey.trim()) return undefined;
return { api_key: values.apiKey.trim() };
}
function trimToNullable(value: string) {
const trimmed = value.trim();
return trimmed ? trimmed : null;
}
function formatTokenLimit(value: number | null) {
return value === null ? "Unknown" : value.toLocaleString();
}
function formatLastChecked(value?: string | null) {
if (!value) return "Never checked";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "Last check unavailable";
return `Last checked ${date.toLocaleDateString(undefined, {
month: "short",
day: "numeric",
year: "numeric",
})}`;
}
@@ -0,0 +1,51 @@
import { CheckCircle2, CircleDashed } from "lucide-react";
import { formatTokenLimit } from "@/app/(prowler)/lighthouse/_lib/format";
import { type LighthouseV2SupportedModel } from "@/app/(prowler)/lighthouse/_types";
export function ModelDetails({
model,
}: {
model?: LighthouseV2SupportedModel;
}) {
if (!model) {
return (
<div className="border-border-neutral-secondary bg-bg-neutral-tertiary mt-3 rounded-[10px] border px-3 py-3">
<p className="text-text-neutral-secondary text-sm">
Select a model to see capabilities.
</p>
</div>
);
}
return (
<div className="border-border-neutral-secondary bg-bg-neutral-tertiary mt-3 grid gap-3 rounded-[10px] border px-3 py-3 sm:grid-cols-3">
<CapabilityItem label="Tools" enabled={model.supportsFunctionCalling} />
<CapabilityItem label="Vision" enabled={model.supportsVision} />
<CapabilityItem label="Reasoning" enabled={model.supportsReasoning} />
<div className="text-text-neutral-secondary text-xs sm:col-span-3">
Input tokens: {formatTokenLimit(model.maxInputTokens)} · Output tokens:{" "}
{formatTokenLimit(model.maxOutputTokens)}
</div>
</div>
);
}
function CapabilityItem({
enabled,
label,
}: {
enabled: boolean | null;
label: string;
}) {
return (
<div className="flex items-center gap-2 text-sm">
{enabled ? (
<CheckCircle2 className="text-text-success-primary size-4" />
) : (
<CircleDashed className="text-text-neutral-tertiary size-4" />
)}
<span className="text-text-neutral-primary">{label}</span>
</div>
);
}
@@ -0,0 +1,23 @@
import { Bot, Cloud, Server } from "lucide-react";
import {
LIGHTHOUSE_V2_PROVIDER_TYPE,
type LighthouseV2ProviderType,
} from "@/app/(prowler)/lighthouse/_types";
function getProviderIcon(provider: LighthouseV2ProviderType) {
if (provider === LIGHTHOUSE_V2_PROVIDER_TYPE.BEDROCK) return Cloud;
if (provider === LIGHTHOUSE_V2_PROVIDER_TYPE.OPENAI_COMPATIBLE) return Server;
return Bot;
}
export function ProviderIcon({
provider,
className,
}: {
provider: LighthouseV2ProviderType;
className?: string;
}) {
const Icon = getProviderIcon(provider);
return <Icon className={className} />;
}
@@ -0,0 +1,83 @@
import { getConnectionStatus } from "@/app/(prowler)/lighthouse/_lib/config";
import { formatLastChecked } from "@/app/(prowler)/lighthouse/_lib/format";
import {
type LighthouseV2Configuration,
type LighthouseV2ProviderType,
type LighthouseV2SupportedProvider,
} from "@/app/(prowler)/lighthouse/_types";
import { cn } from "@/lib/utils";
import { ProviderIcon } from "./provider-icon";
import { StatusBadge } from "./status-badge";
export function LighthouseV2ProviderRail({
configurations,
providers,
selectedProvider,
onSelectProvider,
}: {
configurations: LighthouseV2Configuration[];
providers: LighthouseV2SupportedProvider[];
selectedProvider: LighthouseV2ProviderType;
onSelectProvider: (provider: LighthouseV2ProviderType) => void;
}) {
return (
<aside className="flex min-w-0 flex-col gap-3">
<div className="flex items-center justify-between gap-3 px-1">
<div>
<h3 className="text-text-neutral-primary text-sm font-semibold">
Providers
</h3>
<p className="text-text-neutral-secondary text-xs">
Choose provider to configure
</p>
</div>
</div>
<div className="flex flex-col gap-2">
{providers.map((provider) => {
const config = configurations.find(
(item) => item.providerType === provider.id,
);
const active = provider.id === selectedProvider;
const status = getConnectionStatus(config);
return (
<button
key={provider.id}
type="button"
aria-label={provider.name}
aria-pressed={active}
onClick={() => onSelectProvider(provider.id)}
className={cn(
"border-border-neutral-secondary bg-bg-neutral-secondary hover:bg-bg-neutral-tertiary group flex min-w-0 items-start gap-3 rounded-[12px] border p-3 text-left transition-colors",
active &&
"border-border-input-primary-press bg-bg-neutral-tertiary ring-border-input-primary-press ring-1",
)}
>
<div className="border-border-neutral-secondary bg-bg-neutral-tertiary flex size-10 shrink-0 items-center justify-center rounded-[9px] border">
<ProviderIcon
provider={provider.id}
className="text-text-neutral-secondary size-5"
/>
</div>
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center justify-between gap-2">
<span className="text-text-neutral-primary truncate text-sm font-medium">
{provider.name}
</span>
<StatusBadge status={status} />
</div>
<p className="text-text-neutral-secondary mt-1 truncate text-xs">
{config?.defaultModel || "No default model"}
</p>
<p className="text-text-neutral-tertiary mt-1 text-xs">
{formatLastChecked(config?.connectionLastCheckedAt)}
</p>
</div>
</button>
);
})}
</div>
</aside>
);
}
@@ -0,0 +1,34 @@
import { AlertCircle, CheckCircle2, CircleDashed } from "lucide-react";
import {
CONNECTION_STATUS,
type ConnectionStatus,
} from "@/app/(prowler)/lighthouse/_lib/config";
import { Badge } from "@/components/shadcn/badge/badge";
export function StatusBadge({ status }: { status: ConnectionStatus }) {
if (status === CONNECTION_STATUS.CONNECTED) {
return (
<Badge variant="success">
<CheckCircle2 />
Connected
</Badge>
);
}
if (status === CONNECTION_STATUS.FAILED) {
return (
<Badge variant="error">
<AlertCircle />
Failed
</Badge>
);
}
return (
<Badge variant="outline">
<CircleDashed />
Not tested
</Badge>
);
}
@@ -1,7 +1,3 @@
import { readFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
@@ -11,12 +7,6 @@ import type { LighthouseV2Session } from "@/app/(prowler)/lighthouse/_types";
import { LighthouseV2SessionHistory } from "./lighthouse-v2-session-history";
describe("LighthouseV2SessionHistory", () => {
const currentDir = path.dirname(fileURLToPath(import.meta.url));
const source = readFileSync(
path.join(currentDir, "lighthouse-v2-session-history.tsx"),
"utf8",
);
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-06-25T12:00:00Z"));
@@ -97,36 +87,6 @@ describe("LighthouseV2SessionHistory", () => {
expect(screen.queryByText("thirty days")).not.toBeInTheDocument();
});
it("uses a single Older heading with balanced vertical spacing", () => {
// Given / When
renderHistory({
sessions: [
session({
id: "session-today",
title: "Today session",
updatedAt: "2026-06-25T09:00:00Z",
}),
session({
id: "session-thirty-days",
title: "Thirty days session",
updatedAt: "2026-05-26T09:00:00Z",
}),
],
});
// Then
const heading = screen.getByRole("heading", { name: "Older" });
expect(heading).toHaveClass("py-1");
expect(screen.getAllByRole("heading")).toHaveLength(1);
expect(
screen.queryByRole("heading", { name: "Today" }),
).not.toBeInTheDocument();
expect(
screen.queryByRole("heading", { name: "Last 30 days" }),
).not.toBeInTheDocument();
});
it("filters visible sessions by the current search value", () => {
// Given / When
renderHistory({
@@ -259,7 +219,6 @@ describe("LighthouseV2SessionHistory", () => {
// Then
const tooltip = await screen.findByRole("tooltip");
expect(tooltip).toHaveTextContent(fullTitle);
expect(source).toContain('<TooltipContent side="right">');
});
});
@@ -3,6 +3,7 @@
import { Archive, Plus } from "lucide-react";
import { useState } from "react";
import { formatSessionAge } from "@/app/(prowler)/lighthouse/_lib/format";
import type { LighthouseV2Session } from "@/app/(prowler)/lighthouse/_types";
import { Button } from "@/components/shadcn/button/button";
import { Modal } from "@/components/shadcn/modal";
@@ -14,8 +15,6 @@ import {
} from "@/components/shadcn/tooltip";
import { cn } from "@/lib/utils";
const SESSION_HISTORY_GROUP_LABEL = "Older";
interface LighthouseV2SessionHistoryProps {
sessions: LighthouseV2Session[];
activeSessionId?: string | null;
@@ -40,7 +39,6 @@ export function LighthouseV2SessionHistory({
const [sessionPendingArchive, setSessionPendingArchive] =
useState<LighthouseV2Session | null>(null);
const visibleSessions = filterSessionsBySearch(sessions, search);
const groups = groupSessionsByDate(visibleSessions);
const handleArchiveModalOpenChange = (open: boolean) => {
if (!open) {
@@ -82,63 +80,53 @@ export function LighthouseV2SessionHistory({
</div>
<div className="minimal-scrollbar min-h-0 min-w-0 flex-1 overflow-x-hidden overflow-y-auto">
{groups.length === 0 ? (
{visibleSessions.length === 0 ? (
<div className="text-text-neutral-secondary px-2 py-8 text-center text-sm">
No chats
</div>
) : (
<div className="flex flex-col gap-4">
{groups.map((group) => (
<section key={group.label} className="grid min-w-0">
<h3 className="text-text-neutral-tertiary px-2 py-1 text-xs font-semibold tracking-wide uppercase">
{group.label}
</h3>
{group.sessions.map((session) => {
const sessionTitle = session.title || "Untitled chat";
<div className="grid min-w-0">
{visibleSessions.map((session) => {
const sessionTitle = session.title || "Untitled chat";
return (
<div
key={session.id}
className={cn(
"hover:bg-bg-neutral-tertiary group relative flex min-w-0 items-center overflow-hidden rounded-[8px] transition-colors",
activeSessionId === session.id &&
"bg-bg-neutral-tertiary",
)}
>
<Tooltip delayDuration={100}>
<TooltipTrigger asChild>
<button
type="button"
className="flex min-w-0 flex-1 items-center gap-2 overflow-hidden rounded-[8px] px-2 py-2 text-left text-sm"
onClick={() => onOpenSession(session.id)}
>
<span className="min-w-0 flex-1 truncate">
{sessionTitle}
</span>
<span className="text-text-neutral-tertiary min-w-[3.25rem] shrink-0 text-right text-xs whitespace-nowrap transition-opacity group-focus-within:opacity-0 group-hover:opacity-0">
{formatAgeLabel(session.updatedAt)}
</span>
</button>
</TooltipTrigger>
<TooltipContent side="right">
{sessionTitle}
</TooltipContent>
</Tooltip>
<Button
return (
<div
key={session.id}
className={cn(
"hover:bg-bg-neutral-tertiary group relative flex min-w-0 items-center overflow-hidden rounded-[8px] transition-colors",
activeSessionId === session.id && "bg-bg-neutral-tertiary",
)}
>
<Tooltip delayDuration={100}>
<TooltipTrigger asChild>
<button
type="button"
aria-label={`Archive ${sessionTitle}`}
variant="bare"
size="icon-xs"
className="hover:text-text-neutral-secondary active:text-text-neutral-secondary absolute top-1/2 right-1 -translate-y-1/2 opacity-0 transition-opacity group-focus-within:opacity-100 group-hover:opacity-100 focus-visible:opacity-100"
onClick={() => setSessionPendingArchive(session)}
className="flex min-w-0 flex-1 items-center gap-2 overflow-hidden rounded-[8px] px-2 py-2 text-left text-sm"
onClick={() => onOpenSession(session.id)}
>
<Archive />
</Button>
</div>
);
})}
</section>
))}
<span className="min-w-0 flex-1 truncate">
{sessionTitle}
</span>
<span className="text-text-neutral-tertiary min-w-[3.25rem] shrink-0 text-right text-xs whitespace-nowrap transition-opacity group-focus-within:opacity-0 group-hover:opacity-0">
{formatSessionAge(session.updatedAt)}
</span>
</button>
</TooltipTrigger>
<TooltipContent side="right">{sessionTitle}</TooltipContent>
</Tooltip>
<Button
type="button"
aria-label={`Archive ${sessionTitle}`}
variant="bare"
size="icon-xs"
className="hover:text-text-neutral-secondary active:text-text-neutral-secondary absolute top-1/2 right-1 -translate-y-1/2 opacity-0 transition-opacity group-focus-within:opacity-100 group-hover:opacity-100 focus-visible:opacity-100"
onClick={() => setSessionPendingArchive(session)}
>
<Archive />
</Button>
</div>
);
})}
</div>
)}
</div>
@@ -174,22 +162,6 @@ export function LighthouseV2SessionHistory({
);
}
interface SessionGroup {
label: string;
sessions: LighthouseV2Session[];
}
function groupSessionsByDate(sessions: LighthouseV2Session[]): SessionGroup[] {
if (sessions.length === 0) return [];
return [
{
label: SESSION_HISTORY_GROUP_LABEL,
sessions,
},
];
}
function filterSessionsBySearch(
sessions: LighthouseV2Session[],
search: string,
@@ -203,32 +175,3 @@ function filterSessionsBySearch(
.includes(normalizedSearch),
);
}
function formatAgeLabel(dateString: string) {
const ageInDays = getAgeInDays(dateString);
if (ageInDays === 0) return "Today";
return ageInDays === 1 ? "1 day" : `${ageInDays} days`;
}
function getAgeInDays(dateString: string) {
const date = new Date(dateString);
const now = new Date();
const startOfDate = new Date(
date.getFullYear(),
date.getMonth(),
date.getDate(),
);
const startOfToday = new Date(
now.getFullYear(),
now.getMonth(),
now.getDate(),
);
const millisecondsPerDay = 24 * 60 * 60 * 1000;
return Math.max(
0,
Math.floor(
(startOfToday.getTime() - startOfDate.getTime()) / millisecondsPerDay,
),
);
}
+188
View File
@@ -0,0 +1,188 @@
import { z } from "zod";
import {
LIGHTHOUSE_V2_PROVIDER_TYPE,
type LighthouseV2Configuration,
type LighthouseV2Credentials,
type LighthouseV2ProviderType,
} from "@/app/(prowler)/lighthouse/_types";
export const BUSINESS_CONTEXT_LIMIT = 1000;
export const CONNECTION_STATUS = {
CONNECTED: "connected",
FAILED: "failed",
NOT_TESTED: "not-tested",
} as const;
export type ConnectionStatus =
(typeof CONNECTION_STATUS)[keyof typeof CONNECTION_STATUS];
export const FEEDBACK_VARIANT = {
ERROR: "error",
SUCCESS: "success",
INFO: "info",
} as const;
export type FeedbackVariant =
(typeof FEEDBACK_VARIANT)[keyof typeof FEEDBACK_VARIANT];
export interface FeedbackState {
title: string;
description?: string;
variant: FeedbackVariant;
showRefreshStatus?: boolean;
}
const lighthouseV2ConfigFormSchemaBase = z.object({
apiKey: z.string(),
awsAccessKeyId: z.string(),
awsSecretAccessKey: z.string(),
awsRegionName: z.string(),
baseUrl: z.string(),
defaultModel: z.string(),
businessContext: z.string().max(BUSINESS_CONTEXT_LIMIT, {
error: "Business context cannot exceed 1000 characters.",
}),
});
export type LighthouseV2ConfigFormValues = z.infer<
typeof lighthouseV2ConfigFormSchemaBase
>;
export const EMPTY_FORM_VALUES: LighthouseV2ConfigFormValues = {
apiKey: "",
awsAccessKeyId: "",
awsSecretAccessKey: "",
awsRegionName: "",
baseUrl: "",
defaultModel: "",
businessContext: "",
};
export function getFormDefaults(
configuration?: LighthouseV2Configuration,
): LighthouseV2ConfigFormValues {
return {
...EMPTY_FORM_VALUES,
baseUrl: configuration?.baseUrl ?? "",
defaultModel: configuration?.defaultModel ?? "",
businessContext: configuration?.businessContext ?? "",
};
}
export function buildLighthouseV2ConfigFormSchema(
provider: LighthouseV2ProviderType,
hasConfiguration: boolean,
) {
return lighthouseV2ConfigFormSchemaBase.superRefine((data, ctx) => {
const apiKey = data.apiKey.trim();
if (
provider === LIGHTHOUSE_V2_PROVIDER_TYPE.OPENAI_COMPATIBLE &&
!data.baseUrl.trim()
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Base URL is required for OpenAI-compatible providers.",
path: ["baseUrl"],
});
}
if (
(provider === LIGHTHOUSE_V2_PROVIDER_TYPE.OPENAI ||
provider === LIGHTHOUSE_V2_PROVIDER_TYPE.OPENAI_COMPATIBLE) &&
!hasConfiguration &&
!apiKey
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "API key is required for new configurations.",
path: ["apiKey"],
});
}
if (provider !== LIGHTHOUSE_V2_PROVIDER_TYPE.BEDROCK) return;
const hasAnyBedrockCredential =
Boolean(data.awsAccessKeyId.trim()) ||
Boolean(data.awsSecretAccessKey.trim()) ||
Boolean(data.awsRegionName.trim());
const shouldRequireBedrockCredentials =
!hasConfiguration || hasAnyBedrockCredential;
if (!shouldRequireBedrockCredentials) return;
if (!data.awsAccessKeyId.trim()) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "AWS access key ID is required.",
path: ["awsAccessKeyId"],
});
}
if (!data.awsSecretAccessKey.trim()) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "AWS secret access key is required.",
path: ["awsSecretAccessKey"],
});
}
if (!data.awsRegionName.trim()) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "AWS region is required.",
path: ["awsRegionName"],
});
}
});
}
export function buildCredentialPayload(
provider: LighthouseV2ProviderType,
values: LighthouseV2ConfigFormValues,
hasConfiguration: boolean,
): LighthouseV2Credentials | undefined {
if (provider === LIGHTHOUSE_V2_PROVIDER_TYPE.BEDROCK) {
const hasBedrockCredentials =
Boolean(values.awsAccessKeyId.trim()) ||
Boolean(values.awsSecretAccessKey.trim()) ||
Boolean(values.awsRegionName.trim());
if (hasConfiguration && !hasBedrockCredentials) return undefined;
return {
aws_access_key_id: values.awsAccessKeyId.trim(),
aws_secret_access_key: values.awsSecretAccessKey.trim(),
aws_region_name: values.awsRegionName.trim(),
};
}
if (hasConfiguration && !values.apiKey.trim()) return undefined;
return { api_key: values.apiKey.trim() };
}
export function trimToNullable(value: string) {
const trimmed = value.trim();
return trimmed ? trimmed : null;
}
export function getConnectionStatus(
configuration?: LighthouseV2Configuration,
): ConnectionStatus {
if (configuration?.connected === true) return CONNECTION_STATUS.CONNECTED;
if (configuration?.connected === false) return CONNECTION_STATUS.FAILED;
return CONNECTION_STATUS.NOT_TESTED;
}
export function getConnectionStatusLabel(status: ConnectionStatus): string {
if (status === CONNECTION_STATUS.CONNECTED) return "Connected";
if (status === CONNECTION_STATUS.FAILED) return "Failed";
return "Not tested";
}
export function getAlertVariant(status: ConnectionStatus) {
if (status === CONNECTION_STATUS.CONNECTED) return "success";
if (status === CONNECTION_STATUS.FAILED) return "error";
return "info";
}
@@ -0,0 +1,34 @@
import { differenceInCalendarDays, format, isValid, parseISO } from "date-fns";
import { formatLocalDate } from "@/lib/date-utils";
// Chat bubble timestamp, e.g. "Monday 9:30 AM".
export function formatMessageTimestamp(insertedAt: string): string {
const date = new Date(insertedAt);
if (Number.isNaN(date.getTime())) {
return "";
}
return format(date, "EEEE h:mm a");
}
// Provider connection "last checked" label, reusing the app-wide local date
// format (e.g. "Jun 15, 2026") instead of a bespoke toLocaleDateString call.
export function formatLastChecked(value?: string | null): string {
if (!value) return "Never checked";
const formatted = formatLocalDate(value);
return formatted ? `Last checked ${formatted}` : "Last check unavailable";
}
// Relative age for the session history list, e.g. "Today", "1 day", "5 days".
export function formatSessionAge(dateString: string): string {
const date = parseISO(dateString);
if (!isValid(date)) return "";
const ageInDays = Math.max(0, differenceInCalendarDays(new Date(), date));
if (ageInDays === 0) return "Today";
return ageInDays === 1 ? "1 day" : `${ageInDays} days`;
}
export function formatTokenLimit(value: number | null): string {
return value === null ? "Unknown" : value.toLocaleString();
}
@@ -0,0 +1,55 @@
import {
LIGHTHOUSE_V2_PART_TYPE,
type LighthouseV2Message,
type LighthouseV2MessageRole,
} from "@/app/(prowler)/lighthouse/_types";
// Message parts can arrive as a raw string or as a `{ text }` object; this
// normalizes both to a plain string and ignores anything else.
export function getTextContent(content: unknown): string {
if (typeof content === "string") {
return content;
}
if (
typeof content === "object" &&
content !== null &&
"text" in content &&
typeof content.text === "string"
) {
return content.text;
}
return "";
}
// Builds a client-only message shown immediately after submit, before the
// backend echoes the persisted message back through the stream/refresh.
export function buildOptimisticMessage(
role: LighthouseV2MessageRole,
text: string,
): LighthouseV2Message {
const now = new Date().toISOString();
const id = `optimistic-${role}-${now}`;
return {
id,
role,
model: null,
tokenUsage: null,
insertedAt: now,
parts: [
{
id: `${id}-part`,
type: LIGHTHOUSE_V2_PART_TYPE.TEXT,
content: { text },
toolCallOutcome: null,
insertedAt: now,
updatedAt: now,
},
],
};
}
// Derives a session title from the first user message (collapsed + truncated).
export function buildSessionTitle(text: string): string {
const normalized = text.replace(/\s+/g, " ").trim();
return normalized.length > 80 ? `${normalized.slice(0, 77)}...` : normalized;
}
@@ -0,0 +1,70 @@
import {
LIGHTHOUSE_V2_SSE_EVENT,
type LighthouseV2SSEEvent,
} from "@/app/(prowler)/lighthouse/_types";
// Parses a raw browser SSE event into a typed Lighthouse v2 stream event.
// The backend sends one named event per SSE type; the data payload is JSON.
export function parseStreamEvent(
event: Event,
type: LighthouseV2SSEEvent["type"],
): LighthouseV2SSEEvent {
const data = event instanceof MessageEvent ? parseJsonObject(event.data) : {};
if (type === LIGHTHOUSE_V2_SSE_EVENT.MESSAGE_DELTA) {
return {
type,
content: readString(data, "content"),
};
}
if (type === LIGHTHOUSE_V2_SSE_EVENT.TOOL_CALL_START) {
return {
type,
toolCallId: readString(data, "tool_call_id"),
toolName: readString(data, "tool_name"),
};
}
if (type === LIGHTHOUSE_V2_SSE_EVENT.TOOL_CALL_END) {
return {
type,
toolCallId: readString(data, "tool_call_id"),
outcome: readString(data, "outcome"),
};
}
if (type === LIGHTHOUSE_V2_SSE_EVENT.MESSAGE_END) {
return {
type,
messageId: readString(data, "message_id"),
};
}
if (type === LIGHTHOUSE_V2_SSE_EVENT.RUN_CANCELLED) {
return {
type,
taskId: readString(data, "task_id"),
};
}
return {
type: LIGHTHOUSE_V2_SSE_EVENT.ERROR,
code: readString(data, "code"),
detail: readString(data, "detail"),
};
}
function parseJsonObject(value: unknown): Record<string, unknown> {
if (typeof value !== "string") {
return {};
}
try {
const parsed: unknown = JSON.parse(value);
return typeof parsed === "object" && parsed !== null
? (parsed as Record<string, unknown>)
: {};
} catch {
return {};
}
}
function readString(data: Record<string, unknown>, key: string): string {
const value = data[key];
return typeof value === "string" ? value : "";
}