mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-07-24 04:51:51 +00:00
refactor(ui): componentize Lighthouse v2 chat and config pages
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
-41
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 : "";
|
||||
}
|
||||
Reference in New Issue
Block a user