mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-08-19 09:30:21 +00:00
feat(ui): add Lighthouse request outcome feedback
This commit is contained in:
@@ -122,6 +122,35 @@ describe("lighthouse-v2.adapter", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("should map the durable run outcome attached to a message", () => {
|
||||
const resource: Parameters<typeof mapLighthouseV2Message>[0] = {
|
||||
id: "message-1",
|
||||
type: "lighthouse-messages",
|
||||
attributes: {
|
||||
role: "assistant",
|
||||
model: "gpt-5.5",
|
||||
token_usage: null,
|
||||
inserted_at: "2026-06-24T10:01:00Z",
|
||||
parts: [],
|
||||
run: {
|
||||
id: "run-1",
|
||||
status: "completed",
|
||||
terminal_code: null,
|
||||
has_assistant_message: true,
|
||||
feedback_rating: "up",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(mapLighthouseV2Message(resource).run).toEqual({
|
||||
id: "run-1",
|
||||
status: "completed",
|
||||
terminalCode: null,
|
||||
hasAssistantMessage: true,
|
||||
feedbackRating: "up",
|
||||
});
|
||||
});
|
||||
|
||||
it("should give id-less parts stable fallback keys instead of empty strings", () => {
|
||||
// Given
|
||||
const resource: Parameters<typeof mapLighthouseV2Message>[0] = {
|
||||
|
||||
@@ -4,11 +4,14 @@ import {
|
||||
type LighthouseV2ConfigurationInput,
|
||||
type LighthouseV2ConfigurationUpdateInput,
|
||||
type LighthouseV2Credentials,
|
||||
type LighthouseV2FeedbackRating,
|
||||
type LighthouseV2Message,
|
||||
type LighthouseV2MessageRole,
|
||||
type LighthouseV2Part,
|
||||
type LighthouseV2PartType,
|
||||
type LighthouseV2ProviderType,
|
||||
type LighthouseV2RunFeedbackInput,
|
||||
type LighthouseV2RunStatus,
|
||||
type LighthouseV2Session,
|
||||
type LighthouseV2SupportedModel,
|
||||
type LighthouseV2SupportedProvider,
|
||||
@@ -63,6 +66,15 @@ interface MessageAttributes {
|
||||
token_usage: unknown;
|
||||
inserted_at: string;
|
||||
parts?: UnknownPartResource[];
|
||||
run?: RunAttributes | null;
|
||||
}
|
||||
|
||||
interface RunAttributes {
|
||||
id: string;
|
||||
status: LighthouseV2RunStatus;
|
||||
terminal_code: string | null;
|
||||
has_assistant_message: boolean;
|
||||
feedback_rating: LighthouseV2FeedbackRating | null;
|
||||
}
|
||||
|
||||
interface PartAttributes {
|
||||
@@ -180,6 +192,15 @@ export function mapLighthouseV2Message(
|
||||
parts: (resource.attributes.parts ?? []).map((part, index) =>
|
||||
mapLighthouseV2Part(part, index),
|
||||
),
|
||||
run: resource.attributes.run
|
||||
? {
|
||||
id: resource.attributes.run.id,
|
||||
status: resource.attributes.run.status,
|
||||
terminalCode: resource.attributes.run.terminal_code,
|
||||
hasAssistantMessage: resource.attributes.run.has_assistant_message,
|
||||
feedbackRating: resource.attributes.run.feedback_rating,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -255,6 +276,20 @@ export function buildLighthouseV2SessionUpdatePayload(
|
||||
};
|
||||
}
|
||||
|
||||
export function buildLighthouseV2RunFeedbackPayload(
|
||||
input: LighthouseV2RunFeedbackInput,
|
||||
) {
|
||||
return {
|
||||
data: {
|
||||
type: "lighthouse-agent-run-feedbacks",
|
||||
attributes: {
|
||||
rating: input.rating,
|
||||
idempotency_key: input.idempotencyKey,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function buildLighthouseV2MessagePayload(input: {
|
||||
displayText: string;
|
||||
context?: LighthouseContextEnvelope;
|
||||
|
||||
@@ -27,6 +27,7 @@ vi.mock("@/lib/helper", () => ({
|
||||
import {
|
||||
createLighthouseV2Session,
|
||||
getLighthouseV2SupportedModels,
|
||||
submitLighthouseV2RunFeedback,
|
||||
updateLighthouseV2Configuration,
|
||||
updateLighthouseV2Session,
|
||||
} from "./lighthouse-v2";
|
||||
@@ -156,4 +157,45 @@ describe("Lighthouse v2 session write actions", () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("submits only the allowlisted run feedback fields", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
Response.json(
|
||||
{
|
||||
data: {
|
||||
id: "feedback-1",
|
||||
type: "lighthouse-agent-run-feedbacks",
|
||||
attributes: { rating: "down", revision: 1 },
|
||||
},
|
||||
},
|
||||
{ status: 201 },
|
||||
),
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await submitLighthouseV2RunFeedback({
|
||||
sessionId: "session-1",
|
||||
runId: "run-1",
|
||||
rating: "down",
|
||||
idempotencyKey: "8b18c413-8596-4b50-92ee-ab6d712279aa",
|
||||
});
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
new URL(
|
||||
"https://api.example.com/api/v1/lighthouse/sessions/session-1/runs/run-1/feedback",
|
||||
),
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
data: {
|
||||
type: "lighthouse-agent-run-feedbacks",
|
||||
attributes: {
|
||||
rating: "down",
|
||||
idempotency_key: "8b18c413-8596-4b50-92ee-ab6d712279aa",
|
||||
},
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@ import type {
|
||||
LighthouseV2ConfigurationUpdateInput,
|
||||
LighthouseV2Message,
|
||||
LighthouseV2ProviderType,
|
||||
LighthouseV2RunFeedbackInput,
|
||||
LighthouseV2SendMessageInput,
|
||||
LighthouseV2SendMessageResult,
|
||||
LighthouseV2Session,
|
||||
@@ -23,6 +24,7 @@ import {
|
||||
buildLighthouseV2ConfigurationPayload,
|
||||
buildLighthouseV2ConfigurationUpdatePayload,
|
||||
buildLighthouseV2MessagePayload,
|
||||
buildLighthouseV2RunFeedbackPayload,
|
||||
buildLighthouseV2SessionCreatePayload,
|
||||
buildLighthouseV2SessionUpdatePayload,
|
||||
getJsonApiArray,
|
||||
@@ -251,6 +253,20 @@ export async function sendLighthouseV2Message(
|
||||
}
|
||||
}
|
||||
|
||||
export async function submitLighthouseV2RunFeedback(
|
||||
input: LighthouseV2RunFeedbackInput,
|
||||
): Promise<LighthouseV2ActionResult<true>> {
|
||||
return mutateSingle(
|
||||
`${SESSIONS_ENDPOINT}/${encodeURIComponent(input.sessionId)}/runs/${encodeURIComponent(input.runId)}/feedback`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify(buildLighthouseV2RunFeedbackPayload(input)),
|
||||
},
|
||||
() => true,
|
||||
"",
|
||||
);
|
||||
}
|
||||
|
||||
async function getCollection<TResource, TOutput>(
|
||||
path: string,
|
||||
mapper: (resource: TResource) => TOutput,
|
||||
|
||||
@@ -60,6 +60,7 @@ export function LighthouseV2ChatView({
|
||||
const state = useLighthouseChatStore((current) => current);
|
||||
const {
|
||||
config,
|
||||
activeSessionId,
|
||||
messages,
|
||||
streamState,
|
||||
input,
|
||||
@@ -186,7 +187,11 @@ export function LighthouseV2ChatView({
|
||||
scrollClassName="minimal-scrollbar overflow-x-hidden overflow-y-auto"
|
||||
>
|
||||
{messages.map((message) => (
|
||||
<MessageBubble key={message.id} message={message} />
|
||||
<MessageBubble
|
||||
key={message.id}
|
||||
message={message}
|
||||
sessionId={activeSessionId ?? undefined}
|
||||
/>
|
||||
))}
|
||||
{hasLiveAssistantActivity && (
|
||||
<StreamingAssistantMessage streamState={streamState} />
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { type ReactNode } from "react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
@@ -10,6 +10,14 @@ import {
|
||||
|
||||
import { MessageBubble } from "./message-bubble";
|
||||
|
||||
const { submitFeedbackMock } = vi.hoisted(() => ({
|
||||
submitFeedbackMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/app/(prowler)/lighthouse/_actions", () => ({
|
||||
submitLighthouseV2RunFeedback: submitFeedbackMock,
|
||||
}));
|
||||
|
||||
vi.mock("streamdown", () => ({
|
||||
Streamdown: ({ children }: { children: ReactNode }) => {
|
||||
const text = String(children);
|
||||
@@ -157,6 +165,62 @@ describe("MessageBubble", () => {
|
||||
expect(isBefore(firstText, toolCall)).toBe(true);
|
||||
expect(isBefore(toolCall, secondText)).toBe(true);
|
||||
});
|
||||
|
||||
it("should submit passive feedback for a completed assistant outcome", async () => {
|
||||
submitFeedbackMock.mockResolvedValue({ data: true });
|
||||
const message = {
|
||||
...buildAssistantMessage([textPart("part-1", "Done")]),
|
||||
run: {
|
||||
id: "run-1",
|
||||
status: "completed" as const,
|
||||
terminalCode: null,
|
||||
hasAssistantMessage: true,
|
||||
feedbackRating: null,
|
||||
},
|
||||
};
|
||||
|
||||
render(<MessageBubble message={message} sessionId="session-1" />);
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", { name: "Mark outcome as not helpful" }),
|
||||
);
|
||||
|
||||
await waitFor(() => expect(submitFeedbackMock).toHaveBeenCalledOnce());
|
||||
expect(submitFeedbackMock).toHaveBeenCalledWith({
|
||||
sessionId: "session-1",
|
||||
runId: "run-1",
|
||||
rating: "down",
|
||||
idempotencyKey: expect.any(String),
|
||||
});
|
||||
const selectedButton = screen.getByRole("button", {
|
||||
name: "Mark outcome as not helpful",
|
||||
});
|
||||
expect(selectedButton).toHaveAttribute("aria-pressed", "true");
|
||||
expect(selectedButton).toHaveClass("bg-button-primary", "text-black");
|
||||
});
|
||||
|
||||
it("should expose feedback for failed outcomes without an assistant message", () => {
|
||||
const message: LighthouseV2Message = {
|
||||
id: "message-user-failed",
|
||||
role: LIGHTHOUSE_V2_MESSAGE_ROLE.USER,
|
||||
model: null,
|
||||
tokenUsage: null,
|
||||
insertedAt: "2026-06-25T10:00:00Z",
|
||||
parts: [textPart("part-user", "Run this check")],
|
||||
run: {
|
||||
id: "run-failed",
|
||||
status: "failed",
|
||||
terminalCode: "llm_error",
|
||||
hasAssistantMessage: false,
|
||||
feedbackRating: null,
|
||||
},
|
||||
};
|
||||
|
||||
render(<MessageBubble message={message} sessionId="session-1" />);
|
||||
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Mark outcome as helpful" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
function isBefore(first: HTMLElement, second: HTMLElement): boolean {
|
||||
|
||||
@@ -1,16 +1,27 @@
|
||||
"use client";
|
||||
|
||||
import { Bot, Check, Copy, UserRound } from "lucide-react";
|
||||
import {
|
||||
Bot,
|
||||
Check,
|
||||
Copy,
|
||||
ThumbsDown,
|
||||
ThumbsUp,
|
||||
UserRound,
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
import { submitLighthouseV2RunFeedback } from "@/app/(prowler)/lighthouse/_actions";
|
||||
import { formatMessageTimestamp } from "@/app/(prowler)/lighthouse/_lib/format";
|
||||
import {
|
||||
getLighthouseContext,
|
||||
getTextContent,
|
||||
} from "@/app/(prowler)/lighthouse/_lib/messages";
|
||||
import {
|
||||
LIGHTHOUSE_V2_FEEDBACK_RATING,
|
||||
LIGHTHOUSE_V2_MESSAGE_ROLE,
|
||||
LIGHTHOUSE_V2_PART_TYPE,
|
||||
LIGHTHOUSE_V2_RUN_STATUS,
|
||||
type LighthouseV2FeedbackRating,
|
||||
type LighthouseV2Message,
|
||||
type LighthouseV2Part,
|
||||
} from "@/app/(prowler)/lighthouse/_types";
|
||||
@@ -35,7 +46,12 @@ interface AssistantPartGroup {
|
||||
parts: LighthouseV2Part[];
|
||||
}
|
||||
|
||||
export function MessageBubble({ message }: { message: LighthouseV2Message }) {
|
||||
interface MessageBubbleProps {
|
||||
message: LighthouseV2Message;
|
||||
sessionId?: string;
|
||||
}
|
||||
|
||||
export function MessageBubble({ message, sessionId }: MessageBubbleProps) {
|
||||
const isUser = message.role === LIGHTHOUSE_V2_MESSAGE_ROLE.USER;
|
||||
// Text-only join feeds the copy button; tool calls are rendered separately.
|
||||
const messageText = message.parts
|
||||
@@ -85,6 +101,8 @@ export function MessageBubble({ message }: { message: LighthouseV2Message }) {
|
||||
isUser={isUser}
|
||||
text={messageText}
|
||||
insertedAt={message.insertedAt}
|
||||
message={message}
|
||||
sessionId={sessionId}
|
||||
/>
|
||||
</div>
|
||||
{isUser && (
|
||||
@@ -164,10 +182,14 @@ function MessageMeta({
|
||||
isUser,
|
||||
text,
|
||||
insertedAt,
|
||||
message,
|
||||
sessionId,
|
||||
}: {
|
||||
isUser: boolean;
|
||||
text: string;
|
||||
insertedAt: string;
|
||||
message: LighthouseV2Message;
|
||||
sessionId?: 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.
|
||||
@@ -179,6 +201,11 @@ function MessageMeta({
|
||||
)}
|
||||
>
|
||||
<CopyMessageButton text={text} />
|
||||
<RunFeedbackControls
|
||||
message={message}
|
||||
isUser={isUser}
|
||||
sessionId={sessionId}
|
||||
/>
|
||||
<time
|
||||
dateTime={insertedAt}
|
||||
className="text-text-neutral-tertiary text-xs opacity-0 transition-opacity group-hover:opacity-100"
|
||||
@@ -189,6 +216,85 @@ function MessageMeta({
|
||||
);
|
||||
}
|
||||
|
||||
function RunFeedbackControls({
|
||||
message,
|
||||
isUser,
|
||||
sessionId,
|
||||
}: {
|
||||
message: LighthouseV2Message;
|
||||
isUser: boolean;
|
||||
sessionId?: string;
|
||||
}) {
|
||||
const run = message.run;
|
||||
const [rating, setRating] = useState<LighthouseV2FeedbackRating | null>(
|
||||
run?.feedbackRating ?? null,
|
||||
);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const feedbackEligible =
|
||||
run?.status === LIGHTHOUSE_V2_RUN_STATUS.COMPLETED ||
|
||||
run?.status === LIGHTHOUSE_V2_RUN_STATUS.BLOCKED ||
|
||||
run?.status === LIGHTHOUSE_V2_RUN_STATUS.FAILED;
|
||||
const belongsOnMessage = isUser
|
||||
? run?.hasAssistantMessage === false
|
||||
: run?.hasAssistantMessage === true;
|
||||
|
||||
if (!run || !sessionId || !feedbackEligible || !belongsOnMessage) return null;
|
||||
|
||||
const submitRating = async (nextRating: LighthouseV2FeedbackRating) => {
|
||||
if (isSubmitting || rating === nextRating) return;
|
||||
const previousRating = rating;
|
||||
setRating(nextRating);
|
||||
setIsSubmitting(true);
|
||||
const result = await submitLighthouseV2RunFeedback({
|
||||
sessionId,
|
||||
runId: run.id,
|
||||
rating: nextRating,
|
||||
idempotencyKey: crypto.randomUUID(),
|
||||
});
|
||||
if ("error" in result) {
|
||||
setRating(previousRating);
|
||||
}
|
||||
setIsSubmitting(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-0.5">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label="Mark outcome as helpful"
|
||||
aria-pressed={rating === LIGHTHOUSE_V2_FEEDBACK_RATING.UP}
|
||||
disabled={isSubmitting}
|
||||
onClick={() => void submitRating(LIGHTHOUSE_V2_FEEDBACK_RATING.UP)}
|
||||
className={cn(
|
||||
"text-text-neutral-tertiary hover:text-text-neutral-primary size-6",
|
||||
rating === LIGHTHOUSE_V2_FEEDBACK_RATING.UP &&
|
||||
"bg-button-primary hover:bg-button-primary-hover active:bg-button-primary-press focus-visible:ring-button-primary/50 text-black hover:text-black",
|
||||
)}
|
||||
>
|
||||
<ThumbsUp className="size-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label="Mark outcome as not helpful"
|
||||
aria-pressed={rating === LIGHTHOUSE_V2_FEEDBACK_RATING.DOWN}
|
||||
disabled={isSubmitting}
|
||||
onClick={() => void submitRating(LIGHTHOUSE_V2_FEEDBACK_RATING.DOWN)}
|
||||
className={cn(
|
||||
"text-text-neutral-tertiary hover:text-text-neutral-primary size-6",
|
||||
rating === LIGHTHOUSE_V2_FEEDBACK_RATING.DOWN &&
|
||||
"bg-button-primary hover:bg-button-primary-hover active:bg-button-primary-press focus-visible:ring-button-primary/50 text-black hover:text-black",
|
||||
)}
|
||||
>
|
||||
<ThumbsDown className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CopyMessageButton({ text }: { text: string }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
|
||||
@@ -36,6 +36,34 @@ export interface LighthouseV2Part {
|
||||
updatedAt: string | null;
|
||||
}
|
||||
|
||||
export const LIGHTHOUSE_V2_RUN_STATUS = {
|
||||
QUEUED: "queued",
|
||||
RUNNING: "running",
|
||||
COMPLETED: "completed",
|
||||
BLOCKED: "blocked",
|
||||
FAILED: "failed",
|
||||
CANCELLED: "cancelled",
|
||||
} as const;
|
||||
|
||||
export type LighthouseV2RunStatus =
|
||||
(typeof LIGHTHOUSE_V2_RUN_STATUS)[keyof typeof LIGHTHOUSE_V2_RUN_STATUS];
|
||||
|
||||
export const LIGHTHOUSE_V2_FEEDBACK_RATING = {
|
||||
UP: "up",
|
||||
DOWN: "down",
|
||||
} as const;
|
||||
|
||||
export type LighthouseV2FeedbackRating =
|
||||
(typeof LIGHTHOUSE_V2_FEEDBACK_RATING)[keyof typeof LIGHTHOUSE_V2_FEEDBACK_RATING];
|
||||
|
||||
export interface LighthouseV2Run {
|
||||
id: string;
|
||||
status: LighthouseV2RunStatus;
|
||||
terminalCode: string | null;
|
||||
hasAssistantMessage: boolean;
|
||||
feedbackRating: LighthouseV2FeedbackRating | 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.
|
||||
@@ -54,6 +82,14 @@ export interface LighthouseV2Message {
|
||||
tokenUsage: unknown;
|
||||
insertedAt: string;
|
||||
parts: LighthouseV2Part[];
|
||||
run?: LighthouseV2Run | null;
|
||||
}
|
||||
|
||||
export interface LighthouseV2RunFeedbackInput {
|
||||
sessionId: string;
|
||||
runId: string;
|
||||
rating: LighthouseV2FeedbackRating;
|
||||
idempotencyKey: string;
|
||||
}
|
||||
|
||||
export interface LighthouseV2SendMessageInput {
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
In Prowler Cloud, Lighthouse request outcomes can be rated with persistent thumbs-up and thumbs-down controls.
|
||||
Reference in New Issue
Block a user