mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-07-04 19:21:51 +00:00
feat(ui): render Lighthouse tool calls as collapsible chain of thought
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { Bot, Check, Copy, UserRound, Wrench } from "lucide-react";
|
||||
import { Bot, Check, Copy, UserRound } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
import { formatMessageTimestamp } from "@/app/(prowler)/lighthouse/_lib/format";
|
||||
@@ -9,21 +9,19 @@ import {
|
||||
LIGHTHOUSE_V2_MESSAGE_ROLE,
|
||||
LIGHTHOUSE_V2_PART_TYPE,
|
||||
type LighthouseV2Message,
|
||||
type LighthouseV2Part,
|
||||
} from "@/app/(prowler)/lighthouse/_types";
|
||||
import { Button } from "@/components/shadcn/button/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { MessageMarkdown } from "./message-markdown";
|
||||
import { ToolCalls } from "./tool-call-part";
|
||||
|
||||
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
|
||||
// Text-only join feeds the copy button; tool calls are rendered separately.
|
||||
const messageText = message.parts
|
||||
.filter((part) => part.type === LIGHTHOUSE_V2_PART_TYPE.TEXT)
|
||||
.map((part) => getTextContent(part.content))
|
||||
.filter(Boolean)
|
||||
.join("\n\n");
|
||||
@@ -50,18 +48,12 @@ export function MessageBubble({ message }: { message: LighthouseV2Message }) {
|
||||
: "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. */}
|
||||
{/* User text stays plain to preserve HTML-like tags; assistant
|
||||
renders parts in order so tool calls sit between text blocks. */}
|
||||
{isUser ? (
|
||||
<p className="whitespace-pre-wrap">{messageText}</p>
|
||||
) : (
|
||||
<MessageMarkdown text={messageText} />
|
||||
<AssistantParts parts={message.parts} />
|
||||
)}
|
||||
</div>
|
||||
<MessageMeta
|
||||
@@ -77,6 +69,28 @@ export function MessageBubble({ message }: { message: LighthouseV2Message }) {
|
||||
);
|
||||
}
|
||||
|
||||
function AssistantParts({ parts }: { parts: LighthouseV2Part[] }) {
|
||||
// Tool calls collapse into one disclosure (the "work"); text is the answer.
|
||||
const toolParts = parts.filter(
|
||||
(part) => part.type === LIGHTHOUSE_V2_PART_TYPE.TOOL_CALL,
|
||||
);
|
||||
const textParts = parts.filter(
|
||||
(part) => part.type === LIGHTHOUSE_V2_PART_TYPE.TEXT,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<ToolCalls parts={toolParts} />
|
||||
{textParts.map((part, index) => {
|
||||
const text = getTextContent(part.content);
|
||||
return text ? (
|
||||
<MessageMarkdown key={part.id || `text-${index}`} text={text} />
|
||||
) : null;
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MessageMeta({
|
||||
isUser,
|
||||
text,
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
LIGHTHOUSE_V2_STREAM_STATUS,
|
||||
type LighthouseV2StreamState,
|
||||
} from "@/app/(prowler)/lighthouse/_lib/event-reducer";
|
||||
import { formatToolName } from "@/app/(prowler)/lighthouse/_lib/tool-calls";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { MessageMarkdown } from "./message-markdown";
|
||||
@@ -97,7 +98,7 @@ function getActivityHeader(streamState: LighthouseV2StreamState): string {
|
||||
function getToolCallLabel(
|
||||
toolCall: LighthouseV2StreamState["toolCalls"][number],
|
||||
): string {
|
||||
return `${toolCall.status === "running" ? "Calling" : "Called"} ${
|
||||
toolCall.name
|
||||
}`;
|
||||
return `${toolCall.status === "running" ? "Calling" : "Called"} ${formatToolName(
|
||||
toolCall.name,
|
||||
)}`;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
"use client";
|
||||
|
||||
import { Check, ChevronDown, TriangleAlert, Wrench } from "lucide-react";
|
||||
|
||||
import {
|
||||
ChainOfThought,
|
||||
ChainOfThoughtContent,
|
||||
ChainOfThoughtHeader,
|
||||
} from "@/app/(prowler)/lighthouse/_components/ai-elements/chain-of-thought";
|
||||
import {
|
||||
formatToolName,
|
||||
getToolCallContent,
|
||||
isToolCallError,
|
||||
} from "@/app/(prowler)/lighthouse/_lib/tool-calls";
|
||||
import type { LighthouseV2Part } from "@/app/(prowler)/lighthouse/_types";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/shadcn/collapsible";
|
||||
import {
|
||||
QUERY_EDITOR_LANGUAGE,
|
||||
QueryCodeEditor,
|
||||
} from "@/components/shared/query-code-editor";
|
||||
|
||||
// Groups a finished message's tool calls under one collapsed disclosure so the
|
||||
// "work" is hidden behind a single chevron by default, mirroring the streaming
|
||||
// chain-of-thought. The answer text renders separately, always visible.
|
||||
export function ToolCalls({ parts }: { parts: LighthouseV2Part[] }) {
|
||||
if (parts.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const label = `Used ${parts.length} ${parts.length === 1 ? "tool" : "tools"}`;
|
||||
|
||||
return (
|
||||
<ChainOfThought className="space-y-0">
|
||||
<ChainOfThoughtHeader className="text-text-neutral-secondary">
|
||||
{label}
|
||||
</ChainOfThoughtHeader>
|
||||
<ChainOfThoughtContent className="mt-2 space-y-1.5">
|
||||
{parts.map((part, index) => (
|
||||
<ToolCallPart key={part.id || `tool-${index}`} part={part} />
|
||||
))}
|
||||
</ChainOfThoughtContent>
|
||||
</ChainOfThought>
|
||||
);
|
||||
}
|
||||
|
||||
// One tool call as a light, collapsed row: status + humanized name, expanding to
|
||||
// the arguments the agent sent and the result it got back.
|
||||
function ToolCallPart({ part }: { part: LighthouseV2Part }) {
|
||||
const toolCall = getToolCallContent(part.content);
|
||||
if (!toolCall) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// `content.outcome` is authoritative; fall back to the part-level column.
|
||||
const outcome = toolCall.outcome ?? part.toolCallOutcome;
|
||||
const isError = isToolCallError(outcome);
|
||||
|
||||
return (
|
||||
<Collapsible className="border-border-neutral-secondary rounded-[6px] border">
|
||||
<CollapsibleTrigger className="group flex w-full items-center gap-2 px-2.5 py-1.5 text-xs">
|
||||
{isError ? (
|
||||
<TriangleAlert className="text-text-error-primary size-3.5 shrink-0" />
|
||||
) : (
|
||||
<Check className="text-text-success-primary size-3.5 shrink-0" />
|
||||
)}
|
||||
<Wrench className="text-text-neutral-tertiary size-3.5 shrink-0" />
|
||||
<span className="text-text-neutral-primary flex-1 truncate text-left">
|
||||
{formatToolName(toolCall.toolName)}
|
||||
</span>
|
||||
{isError && outcome && (
|
||||
<span className="text-text-error-primary truncate">{outcome}</span>
|
||||
)}
|
||||
<ChevronDown className="text-text-neutral-tertiary size-3.5 shrink-0 transition-transform group-data-[state=open]:rotate-180" />
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="space-y-2 px-2.5 pb-2.5">
|
||||
<ToolCallSection label="Arguments" value={toolCall.arguments} />
|
||||
<ToolCallSection label="Result" value={toolCall.result} />
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
|
||||
function ToolCallSection({ label, value }: { label: string; value: unknown }) {
|
||||
if (value === null || value === undefined || value === "") {
|
||||
return null;
|
||||
}
|
||||
// String results (often markdown) render as plain text so real newlines show
|
||||
// instead of escaped "\n"; structured values render as highlighted JSON.
|
||||
const isText = typeof value === "string";
|
||||
const text = isText ? value : JSON.stringify(value, null, 2);
|
||||
if (!text) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<QueryCodeEditor
|
||||
ariaLabel={label}
|
||||
visibleLabel={label}
|
||||
language={
|
||||
isText ? QUERY_EDITOR_LANGUAGE.PLAIN_TEXT : QUERY_EDITOR_LANGUAGE.JSON
|
||||
}
|
||||
value={text}
|
||||
copyValue={text}
|
||||
editable={false}
|
||||
minHeight={0}
|
||||
showCopyButton
|
||||
showLineNumbers={false}
|
||||
onChange={() => {}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
formatToolName,
|
||||
getToolCallContent,
|
||||
isToolCallError,
|
||||
} from "./tool-calls";
|
||||
|
||||
describe("getToolCallContent", () => {
|
||||
it("should normalize the snake_case backend blob to camelCase", () => {
|
||||
// Given
|
||||
const content = {
|
||||
tool_call_id: "call_1",
|
||||
tool_name: "prowler_app_search_security_findings",
|
||||
arguments: { severity: "high" },
|
||||
result: { count: 3 },
|
||||
outcome: "success",
|
||||
};
|
||||
|
||||
// When
|
||||
const parsed = getToolCallContent(content);
|
||||
|
||||
// Then
|
||||
expect(parsed).toEqual({
|
||||
toolCallId: "call_1",
|
||||
toolName: "prowler_app_search_security_findings",
|
||||
arguments: { severity: "high" },
|
||||
result: { count: 3 },
|
||||
outcome: "success",
|
||||
});
|
||||
});
|
||||
|
||||
it("should default missing optional fields without throwing", () => {
|
||||
// Given a blob with only the required tool_name
|
||||
const parsed = getToolCallContent({ tool_name: "search_tools" });
|
||||
|
||||
// Then
|
||||
expect(parsed).toEqual({
|
||||
toolCallId: "",
|
||||
toolName: "search_tools",
|
||||
arguments: null,
|
||||
result: null,
|
||||
outcome: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("should return null for non-tool-call content", () => {
|
||||
expect(getToolCallContent(null)).toBeNull();
|
||||
expect(getToolCallContent("text")).toBeNull();
|
||||
expect(getToolCallContent({ text: "hi" })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatToolName", () => {
|
||||
it("should strip the prowler prefix and title-case", () => {
|
||||
expect(formatToolName("prowler_app_search_security_findings")).toBe(
|
||||
"Search security findings",
|
||||
);
|
||||
expect(formatToolName("prowler_hub_list_checks")).toBe("List checks");
|
||||
});
|
||||
|
||||
it("should humanize prefix-less tools", () => {
|
||||
expect(formatToolName("search_tools")).toBe("Search tools");
|
||||
});
|
||||
});
|
||||
|
||||
describe("isToolCallError", () => {
|
||||
it("should treat success and absent outcomes as non-errors", () => {
|
||||
expect(isToolCallError("success")).toBe(false);
|
||||
expect(isToolCallError(null)).toBe(false);
|
||||
});
|
||||
|
||||
it("should treat any other outcome as an error", () => {
|
||||
expect(isToolCallError("timeout")).toBe(true);
|
||||
expect(isToolCallError("mcp_tool_error")).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { LighthouseV2ToolCallContent } from "@/app/(prowler)/lighthouse/_types";
|
||||
|
||||
// Prefixes shared by the MCP-sourced tools; stripped for display so a name like
|
||||
// `prowler_app_search_security_findings` reads as "Search security findings".
|
||||
const TOOL_NAME_PREFIXES = [
|
||||
"prowler_app_",
|
||||
"prowler_hub_",
|
||||
"prowler_docs_",
|
||||
] as const;
|
||||
|
||||
// Reads the snake_case TOOL_CALL blob the backend persists and normalizes it to
|
||||
// the camelCase UI shape. Returns null when `content` isn't a tool-call object.
|
||||
export function getToolCallContent(
|
||||
content: unknown,
|
||||
): LighthouseV2ToolCallContent | null {
|
||||
if (typeof content !== "object" || content === null) {
|
||||
return null;
|
||||
}
|
||||
const record = content as Record<string, unknown>;
|
||||
if (typeof record.tool_name !== "string") {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
toolCallId:
|
||||
typeof record.tool_call_id === "string" ? record.tool_call_id : "",
|
||||
toolName: record.tool_name,
|
||||
arguments: record.arguments ?? null,
|
||||
result: record.result ?? null,
|
||||
outcome: typeof record.outcome === "string" ? record.outcome : null,
|
||||
};
|
||||
}
|
||||
|
||||
// Turns a raw tool name into a human label by dropping the known prefix and
|
||||
// title-casing, e.g. `prowler_hub_list_checks` -> "List checks". A humanizer
|
||||
// (not a hardcoded map) keeps this in sync as the MCP tool whitelist grows.
|
||||
export function formatToolName(toolName: string): string {
|
||||
const prefix = TOOL_NAME_PREFIXES.find((value) => toolName.startsWith(value));
|
||||
const stripped = prefix ? toolName.slice(prefix.length) : toolName;
|
||||
const words = stripped.replace(/_/g, " ").trim();
|
||||
if (!words) {
|
||||
return toolName;
|
||||
}
|
||||
return words.charAt(0).toUpperCase() + words.slice(1);
|
||||
}
|
||||
|
||||
// A tool call succeeded when its outcome is absent or the literal "success";
|
||||
// any other outcome is an error surfaced to the user.
|
||||
export function isToolCallError(outcome: string | null): boolean {
|
||||
return outcome !== null && outcome.toLowerCase() !== "success";
|
||||
}
|
||||
@@ -35,6 +35,17 @@ export interface LighthouseV2Part {
|
||||
updatedAt: string | null;
|
||||
}
|
||||
|
||||
// Normalized shape of a TOOL_CALL part's `content`. The backend persists this
|
||||
// blob in snake_case (tool_call_id, tool_name, ...); `getToolCallContent`
|
||||
// maps it to this camelCase form so the UI never touches the raw keys.
|
||||
export interface LighthouseV2ToolCallContent {
|
||||
toolCallId: string;
|
||||
toolName: string;
|
||||
arguments: unknown;
|
||||
result: unknown;
|
||||
outcome: string | null;
|
||||
}
|
||||
|
||||
export interface LighthouseV2Message {
|
||||
id: string;
|
||||
role: LighthouseV2MessageRole;
|
||||
|
||||
Reference in New Issue
Block a user