mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-07-23 04:21:52 +00:00
feat(ui): polish lighthouse v2 chat messages
Collapse the chain-of-thought by default with a pulsing Thinking header, summarize tool calls as 'N tools called', and hide success outcomes. Add a copy button and hover timestamps under each message (agent left, user right). Stream client: subscribe before POST without the open-event gate (avoids a deadlock) and set the session URL with history.replaceState instead of router.push (avoids remounting mid-stream).
This commit is contained in:
@@ -0,0 +1,370 @@
|
||||
import { act, render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type {
|
||||
LighthouseV2Configuration,
|
||||
LighthouseV2Message,
|
||||
LighthouseV2SupportedModel,
|
||||
} from "@/app/(prowler)/lighthouse/_types";
|
||||
|
||||
import { LighthouseV2ChatPage } from "./lighthouse-v2-chat-page";
|
||||
|
||||
// Controllable EventSource mock: records each instance so tests can drive
|
||||
// named SSE events and connection failures, while still being a vi.fn so
|
||||
// `expect(EventSource).toHaveBeenCalledWith(...)` keeps working.
|
||||
interface MockEventSource {
|
||||
url: string;
|
||||
readyState: number;
|
||||
onerror: ((event: Event) => void) | null;
|
||||
listeners: Map<string, Set<EventListener>>;
|
||||
addEventListener: (type: string, cb: EventListener) => void;
|
||||
close: ReturnType<typeof vi.fn>;
|
||||
emit: (type: string, data: unknown) => void;
|
||||
fail: (readyState: number) => void;
|
||||
}
|
||||
|
||||
let eventSources: MockEventSource[] = [];
|
||||
|
||||
function stubEventSource() {
|
||||
eventSources = [];
|
||||
const EventSourceMock = vi.fn(function (this: MockEventSource, url: string) {
|
||||
this.url = url;
|
||||
this.readyState = 0;
|
||||
this.onerror = null;
|
||||
this.listeners = new Map();
|
||||
this.addEventListener = (type: string, cb: EventListener) => {
|
||||
const set = this.listeners.get(type) ?? new Set<EventListener>();
|
||||
set.add(cb);
|
||||
this.listeners.set(type, set);
|
||||
};
|
||||
this.close = vi.fn(() => {
|
||||
this.readyState = 2;
|
||||
});
|
||||
this.emit = (type: string, data: unknown) => {
|
||||
const event = new MessageEvent(type, { data: JSON.stringify(data) });
|
||||
this.listeners.get(type)?.forEach((cb) => cb(event));
|
||||
};
|
||||
this.fail = (readyState: number) => {
|
||||
this.readyState = readyState;
|
||||
this.onerror?.(new Event("error"));
|
||||
};
|
||||
eventSources.push(this);
|
||||
});
|
||||
Object.assign(EventSourceMock, { CONNECTING: 0, OPEN: 1, CLOSED: 2 });
|
||||
vi.stubGlobal("EventSource", EventSourceMock);
|
||||
}
|
||||
|
||||
const { cancelRunMock, createSessionMock, getMessagesMock, sendMessageMock } =
|
||||
vi.hoisted(() => ({
|
||||
cancelRunMock: vi.fn(),
|
||||
createSessionMock: vi.fn(),
|
||||
getMessagesMock: vi.fn(),
|
||||
sendMessageMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/app/(prowler)/lighthouse/_actions", () => ({
|
||||
cancelLighthouseV2Run: cancelRunMock,
|
||||
createLighthouseV2Session: createSessionMock,
|
||||
getLighthouseV2Messages: getMessagesMock,
|
||||
sendLighthouseV2Message: sendMessageMock,
|
||||
}));
|
||||
|
||||
const configurations: LighthouseV2Configuration[] = [
|
||||
{
|
||||
id: "config-openai",
|
||||
providerType: "openai",
|
||||
baseUrl: null,
|
||||
defaultModel: "gpt-5.1",
|
||||
businessContext: "Production account",
|
||||
connected: true,
|
||||
connectionLastCheckedAt: "2026-06-24T10:00:00Z",
|
||||
insertedAt: "2026-06-24T09:00:00Z",
|
||||
updatedAt: "2026-06-24T10:00:00Z",
|
||||
},
|
||||
{
|
||||
id: "config-bedrock",
|
||||
providerType: "bedrock",
|
||||
baseUrl: null,
|
||||
defaultModel: "anthropic.claude-4",
|
||||
businessContext: "AWS landing zone",
|
||||
connected: true,
|
||||
connectionLastCheckedAt: "2026-06-23T10:00:00Z",
|
||||
insertedAt: "2026-06-23T09:00:00Z",
|
||||
updatedAt: "2026-06-23T10:00:00Z",
|
||||
},
|
||||
];
|
||||
|
||||
const modelsByProvider = {
|
||||
openai: [model("gpt-5.1")],
|
||||
bedrock: [model("anthropic.claude-4")],
|
||||
"openai-compatible": [model("llama-3.3")],
|
||||
};
|
||||
|
||||
describe("LighthouseV2ChatPage", () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal(
|
||||
"ResizeObserver",
|
||||
class ResizeObserver {
|
||||
observe = vi.fn();
|
||||
unobserve = vi.fn();
|
||||
disconnect = vi.fn();
|
||||
},
|
||||
);
|
||||
cancelRunMock.mockReset();
|
||||
createSessionMock.mockReset();
|
||||
getMessagesMock.mockReset();
|
||||
sendMessageMock.mockReset();
|
||||
// The mock never fires "open": the client must POST the message without
|
||||
// waiting for it (the backend sends no bytes until the worker emits, which
|
||||
// only happens after the POST). This is the regression guard for the
|
||||
// open-gate deadlock.
|
||||
stubEventSource();
|
||||
|
||||
createSessionMock.mockResolvedValue({
|
||||
data: {
|
||||
id: "session-1",
|
||||
title: "Summarize findings",
|
||||
isArchived: false,
|
||||
insertedAt: "2026-06-24T10:00:00Z",
|
||||
updatedAt: "2026-06-24T10:00:00Z",
|
||||
activeTaskId: null,
|
||||
},
|
||||
});
|
||||
getMessagesMock.mockResolvedValue({ data: [] });
|
||||
sendMessageMock.mockResolvedValue({
|
||||
data: {
|
||||
task: {
|
||||
id: "task-1",
|
||||
name: "lighthouse-run",
|
||||
state: "executing",
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("does not render provider or model selectors in the chat composer", () => {
|
||||
// Given / When
|
||||
renderPage();
|
||||
|
||||
// Then
|
||||
expect(screen.queryByRole("combobox")).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("link", { name: "Lighthouse settings" }),
|
||||
).toHaveAttribute("href", "/lighthouse/settings");
|
||||
});
|
||||
|
||||
it("uses the tuned scrollbar and bottom fade without a composer separator", () => {
|
||||
// Given / When
|
||||
const { container } = renderPage({
|
||||
initialMessages: [message("message-1", "assistant", "Existing answer")],
|
||||
});
|
||||
|
||||
// Then
|
||||
const conversation = screen.getByRole("log");
|
||||
const scrollViewport = conversation.firstElementChild as HTMLElement;
|
||||
const content = scrollViewport.firstElementChild as HTMLElement;
|
||||
const scrollFade = container.querySelector(
|
||||
'[data-slot="lighthouse-v2-chat-scroll-fade"]',
|
||||
);
|
||||
|
||||
expect(conversation).toHaveClass("h-full", "min-h-0");
|
||||
expect(conversation.parentElement).toHaveClass("flex", "overflow-hidden");
|
||||
expect(scrollViewport).toHaveClass(
|
||||
"minimal-scrollbar",
|
||||
"overflow-x-hidden",
|
||||
"overflow-y-auto",
|
||||
);
|
||||
expect(content).toHaveClass("pb-20");
|
||||
expect(scrollFade).toHaveClass(
|
||||
"pointer-events-none",
|
||||
"absolute",
|
||||
"bottom-0",
|
||||
"right-2",
|
||||
"h-16",
|
||||
"bg-gradient-to-t",
|
||||
"from-bg-neutral-secondary",
|
||||
"to-transparent",
|
||||
);
|
||||
expect(
|
||||
container.querySelector(
|
||||
'[data-slot="lighthouse-v2-chat-composer-panel"]',
|
||||
),
|
||||
).not.toHaveClass("border-t");
|
||||
});
|
||||
|
||||
it("sends messages with the connected default provider and model from configuration", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
const replaceStateSpy = vi.spyOn(window.history, "replaceState");
|
||||
renderPage();
|
||||
|
||||
// When
|
||||
await user.type(
|
||||
screen.getByRole("textbox", { name: "Message" }),
|
||||
["Summarize findings", "{Enter}"].join(""),
|
||||
);
|
||||
|
||||
// Then: the message is sent even though EventSource never fires "open"
|
||||
await waitFor(() =>
|
||||
expect(sendMessageMock).toHaveBeenCalledWith({
|
||||
sessionId: "session-1",
|
||||
text: "Summarize findings",
|
||||
provider: "openai",
|
||||
model: "gpt-5.1",
|
||||
}),
|
||||
);
|
||||
expect(createSessionMock).toHaveBeenCalledWith("Summarize findings");
|
||||
// The session URL is set in place (no router navigation / remount)
|
||||
expect(replaceStateSpy).toHaveBeenCalledWith(
|
||||
null,
|
||||
"",
|
||||
"/lighthouse?session=session-1",
|
||||
);
|
||||
// The stream is opened against our same-origin SSE proxy (not the
|
||||
// cross-origin API host), so the browser EventSource can actually connect.
|
||||
expect(EventSource).toHaveBeenCalledWith(
|
||||
"/api/lighthouse/v2/sessions/session-1/event-stream",
|
||||
);
|
||||
replaceStateSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("subscribes to the stream before sending the message (no replay buffer)", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
renderPage();
|
||||
|
||||
// When
|
||||
await user.type(
|
||||
screen.getByRole("textbox", { name: "Message" }),
|
||||
["Summarize findings", "{Enter}"].join(""),
|
||||
);
|
||||
|
||||
// Then: the EventSource must be constructed before the POST, otherwise
|
||||
// early tokens emitted by the worker would be lost (backend has no replay).
|
||||
await waitFor(() => expect(sendMessageMock).toHaveBeenCalled());
|
||||
const eventSourceOrder = vi.mocked(EventSource).mock.invocationCallOrder[0];
|
||||
const sendOrder = sendMessageMock.mock.invocationCallOrder[0];
|
||||
expect(eventSourceOrder).toBeLessThan(sendOrder);
|
||||
});
|
||||
|
||||
it("renders a copy button (copies text) and an ISO timestamp under each message", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
const writeText = vi.fn().mockResolvedValue(undefined);
|
||||
Object.defineProperty(navigator, "clipboard", {
|
||||
value: { writeText },
|
||||
configurable: true,
|
||||
});
|
||||
const { container } = renderPage({
|
||||
initialMessages: [message("message-1", "assistant", "Existing answer")],
|
||||
});
|
||||
|
||||
// When
|
||||
await user.click(screen.getByRole("button", { name: "Copy message" }));
|
||||
|
||||
// Then: copies the message text, and the timestamp carries the raw ISO
|
||||
expect(writeText).toHaveBeenCalledWith("Existing answer");
|
||||
expect(container.querySelector("time")).toHaveAttribute(
|
||||
"datetime",
|
||||
"2026-06-25T10:00:00Z",
|
||||
);
|
||||
});
|
||||
|
||||
it("renders streamed deltas and reloads persisted messages on message.end", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
renderPage();
|
||||
await user.type(
|
||||
screen.getByRole("textbox", { name: "Message" }),
|
||||
["Summarize findings", "{Enter}"].join(""),
|
||||
);
|
||||
await waitFor(() => expect(eventSources).toHaveLength(1));
|
||||
const source = eventSources[0];
|
||||
|
||||
// When a delta arrives, it renders live
|
||||
act(() => source.emit("message.delta", { content: "Hello there" }));
|
||||
expect(await screen.findByText("Hello there")).toBeInTheDocument();
|
||||
|
||||
// When the run ends, the full persisted message is reloaded from the DB
|
||||
act(() => source.emit("message.end", { message_id: "message-1" }));
|
||||
await waitFor(() =>
|
||||
expect(getMessagesMock).toHaveBeenCalledWith("session-1"),
|
||||
);
|
||||
expect(source.close).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("surfaces a connection error when the stream closes without retrying", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
renderPage();
|
||||
await user.type(
|
||||
screen.getByRole("textbox", { name: "Message" }),
|
||||
["Summarize findings", "{Enter}"].join(""),
|
||||
);
|
||||
await waitFor(() => expect(eventSources).toHaveLength(1));
|
||||
|
||||
// When the EventSource fails terminally (e.g. 401/404 on the SSE GET)
|
||||
act(() => eventSources[0].fail(2 /* EventSource.CLOSED */));
|
||||
|
||||
// Then a clear error is shown instead of an endless "Reconnecting" spinner
|
||||
expect(
|
||||
await screen.findByText("Unable to connect to the response stream."),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
type RenderPageProps = Partial<Parameters<typeof LighthouseV2ChatPage>[0]>;
|
||||
|
||||
function renderPage(props?: RenderPageProps) {
|
||||
const componentProps = {
|
||||
configurations: props?.configurations ?? configurations,
|
||||
modelsByProvider: props?.modelsByProvider ?? modelsByProvider,
|
||||
initialSessionId: props?.initialSessionId,
|
||||
initialMessages: props?.initialMessages ?? [],
|
||||
initialPrompt: props?.initialPrompt,
|
||||
initialActiveTaskId: props?.initialActiveTaskId,
|
||||
initialStreamUrl: props?.initialStreamUrl,
|
||||
} satisfies Parameters<typeof LighthouseV2ChatPage>[0];
|
||||
|
||||
return render(<LighthouseV2ChatPage {...componentProps} />);
|
||||
}
|
||||
|
||||
function model(id: string): LighthouseV2SupportedModel {
|
||||
return {
|
||||
id,
|
||||
maxInputTokens: null,
|
||||
maxOutputTokens: null,
|
||||
supportsFunctionCalling: null,
|
||||
supportsVision: null,
|
||||
supportsReasoning: null,
|
||||
};
|
||||
}
|
||||
|
||||
function message(
|
||||
id: string,
|
||||
role: LighthouseV2Message["role"],
|
||||
content: string,
|
||||
): LighthouseV2Message {
|
||||
return {
|
||||
id,
|
||||
role,
|
||||
model: null,
|
||||
tokenUsage: null,
|
||||
insertedAt: "2026-06-25T10:00:00Z",
|
||||
parts: [
|
||||
{
|
||||
id: `${id}-part`,
|
||||
type: "text",
|
||||
content,
|
||||
toolCallOutcome: null,
|
||||
insertedAt: "2026-06-25T10:00:00Z",
|
||||
updatedAt: "2026-06-25T10:00:00Z",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
+290
-84
@@ -1,9 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { format } from "date-fns";
|
||||
import {
|
||||
ArrowRight,
|
||||
BookOpen,
|
||||
Bot,
|
||||
Check,
|
||||
Copy,
|
||||
FileCheck2,
|
||||
Loader2,
|
||||
Network,
|
||||
@@ -11,9 +14,9 @@ import {
|
||||
ShieldAlert,
|
||||
Square,
|
||||
UserRound,
|
||||
Wrench,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { type FormEvent, useRef, useState } from "react";
|
||||
|
||||
import {
|
||||
@@ -21,23 +24,26 @@ import {
|
||||
createLighthouseV2Session,
|
||||
getLighthouseV2Messages,
|
||||
sendLighthouseV2Message,
|
||||
} from "@/actions/lighthouse-v2/lighthouse-v2";
|
||||
} from "@/app/(prowler)/lighthouse/_actions";
|
||||
import {
|
||||
ChainOfThought,
|
||||
ChainOfThoughtContent,
|
||||
ChainOfThoughtHeader,
|
||||
ChainOfThoughtStep,
|
||||
} from "@/app/(prowler)/lighthouse/_components/ai-elements/chain-of-thought";
|
||||
import {
|
||||
Conversation,
|
||||
ConversationContent,
|
||||
ConversationScrollButton,
|
||||
} from "@/components/ai-elements/conversation";
|
||||
import { LighthouseIcon } from "@/components/icons/Icons";
|
||||
import { Button } from "@/components/shadcn/button/button";
|
||||
import { Textarea } from "@/components/shadcn/textarea/textarea";
|
||||
import { useMountEffect } from "@/hooks/use-mount-effect";
|
||||
} from "@/app/(prowler)/lighthouse/_components/ai-elements/conversation";
|
||||
import {
|
||||
createInitialLighthouseV2StreamState,
|
||||
LIGHTHOUSE_V2_STREAM_STATUS,
|
||||
type LighthouseV2StreamState,
|
||||
reduceLighthouseV2Event,
|
||||
} from "@/lib/lighthouse-v2/event-reducer";
|
||||
import { notifyLighthouseV2SessionsChanged } from "@/lib/lighthouse-v2/session-events";
|
||||
import { cn } from "@/lib/utils";
|
||||
} from "@/app/(prowler)/lighthouse/_lib/event-reducer";
|
||||
import { notifyLighthouseV2SessionsChanged } from "@/app/(prowler)/lighthouse/_lib/session-events";
|
||||
import { buildLighthouseV2StreamUrl } from "@/app/(prowler)/lighthouse/_lib/stream-url";
|
||||
import {
|
||||
LIGHTHOUSE_V2_MESSAGE_ROLE,
|
||||
LIGHTHOUSE_V2_PART_TYPE,
|
||||
@@ -48,9 +54,13 @@ import {
|
||||
type LighthouseV2ProviderType,
|
||||
type LighthouseV2SSEEvent,
|
||||
type LighthouseV2SupportedModel,
|
||||
} from "@/types/lighthouse-v2";
|
||||
|
||||
import { Card } from "../../shadcn";
|
||||
} 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";
|
||||
|
||||
interface LighthouseV2ChatPageProps {
|
||||
configurations: LighthouseV2Configuration[];
|
||||
@@ -60,6 +70,8 @@ interface LighthouseV2ChatPageProps {
|
||||
>;
|
||||
initialSessionId?: string;
|
||||
initialMessages: LighthouseV2Message[];
|
||||
initialActiveTaskId?: string | null;
|
||||
initialStreamUrl?: string;
|
||||
initialPrompt?: string;
|
||||
}
|
||||
|
||||
@@ -91,9 +103,10 @@ export function LighthouseV2ChatPage({
|
||||
modelsByProvider,
|
||||
initialSessionId,
|
||||
initialMessages,
|
||||
initialActiveTaskId,
|
||||
initialStreamUrl,
|
||||
initialPrompt,
|
||||
}: LighthouseV2ChatPageProps) {
|
||||
const router = useRouter();
|
||||
const eventSourceRef = useRef<EventSource | null>(null);
|
||||
const initialPromptSentRef = useRef(false);
|
||||
const connectedConfigurations = configurations.filter(
|
||||
@@ -113,23 +126,27 @@ export function LighthouseV2ChatPage({
|
||||
const [input, setInput] = useState("");
|
||||
const [feedback, setFeedback] = useState<string | null>(null);
|
||||
const [blockedByConflict, setBlockedByConflict] = useState(false);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [lastSubmittedText, setLastSubmittedText] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [streamState, setStreamState] = useState<LighthouseV2StreamState>(() =>
|
||||
createInitialLighthouseV2StreamState(),
|
||||
createInitialLighthouseV2StreamState(initialActiveTaskId ?? null),
|
||||
);
|
||||
|
||||
const canSend =
|
||||
selectedConfiguration?.connected === true &&
|
||||
!streamState.activeTaskId &&
|
||||
!blockedByConflict;
|
||||
!blockedByConflict &&
|
||||
!isSubmitting;
|
||||
|
||||
const refreshMessages = async (sessionId: string) => {
|
||||
const refreshMessages = async (sessionId: string): Promise<boolean> => {
|
||||
const result = await getLighthouseV2Messages(sessionId);
|
||||
if ("data" in result) {
|
||||
setMessages(result.data);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const closeStream = () => {
|
||||
@@ -148,7 +165,13 @@ export function LighthouseV2ChatPage({
|
||||
) {
|
||||
closeStream();
|
||||
setBlockedByConflict(false);
|
||||
await refreshMessages(sessionId);
|
||||
if (event.type === LIGHTHOUSE_V2_SSE_EVENT.ERROR) {
|
||||
setFeedback(event.detail || "Agent run failed.");
|
||||
}
|
||||
const refreshed = await refreshMessages(sessionId);
|
||||
if (refreshed) {
|
||||
setStreamState(createInitialLighthouseV2StreamState());
|
||||
}
|
||||
notifyLighthouseV2SessionsChanged();
|
||||
}
|
||||
};
|
||||
@@ -191,13 +214,19 @@ export function LighthouseV2ChatPage({
|
||||
applyEvent(parseStreamEvent(event, LIGHTHOUSE_V2_SSE_EVENT.ERROR));
|
||||
}
|
||||
});
|
||||
// The browser fires `onerror` both on a transient drop (it auto-reconnects)
|
||||
// and on a non-retryable failure such as a 401/404 on the SSE GET. Only the
|
||||
// latter leaves the source CLOSED, so surface a connection error there and
|
||||
// treat everything else as a reconnect.
|
||||
source.onerror = () => {
|
||||
closeStream();
|
||||
if (eventSourceRef.current !== source) return;
|
||||
if (source.readyState === EventSource.CLOSED) {
|
||||
closeStream();
|
||||
setFeedback("Unable to connect to the response stream.");
|
||||
}
|
||||
setStreamState((current) =>
|
||||
reduceLighthouseV2Event(current, { type: "disconnect" }),
|
||||
);
|
||||
void refreshMessages(sessionId);
|
||||
setFeedback("Stream disconnected. Messages were refreshed.");
|
||||
};
|
||||
};
|
||||
|
||||
@@ -215,7 +244,14 @@ export function LighthouseV2ChatPage({
|
||||
|
||||
setActiveSessionId(result.data.id);
|
||||
notifyLighthouseV2SessionsChanged();
|
||||
router.push(`/lighthouse?session=${encodeURIComponent(result.data.id)}`);
|
||||
// Update the URL in place (not router.push) so the force-dynamic server
|
||||
// component is NOT re-run mid-submit. A re-run would change `key` in
|
||||
// page.tsx and remount this component, tearing down the open EventSource.
|
||||
window.history.replaceState(
|
||||
null,
|
||||
"",
|
||||
`/lighthouse?session=${encodeURIComponent(result.data.id)}`,
|
||||
);
|
||||
return result.data.id;
|
||||
};
|
||||
|
||||
@@ -223,39 +259,54 @@ export function LighthouseV2ChatPage({
|
||||
const trimmedText = text.trim();
|
||||
if (!trimmedText || !canSend) return;
|
||||
|
||||
const sessionId = await ensureSession(trimmedText);
|
||||
if (!sessionId) return;
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const sessionId = await ensureSession(trimmedText);
|
||||
if (!sessionId) return;
|
||||
|
||||
setFeedback(null);
|
||||
setBlockedByConflict(false);
|
||||
setLastSubmittedText(trimmedText);
|
||||
setInput("");
|
||||
setMessages((current) => [
|
||||
...current,
|
||||
buildOptimisticMessage("user", trimmedText),
|
||||
]);
|
||||
const provisionalTaskId = `pending-${Date.now()}`;
|
||||
setFeedback(null);
|
||||
setBlockedByConflict(false);
|
||||
setLastSubmittedText(trimmedText);
|
||||
setInput("");
|
||||
setMessages((current) => [
|
||||
...current,
|
||||
buildOptimisticMessage("user", trimmedText),
|
||||
]);
|
||||
setStreamState(createInitialLighthouseV2StreamState(provisionalTaskId));
|
||||
|
||||
const result = await sendLighthouseV2Message({
|
||||
sessionId,
|
||||
text: trimmedText,
|
||||
provider: selectedProvider,
|
||||
model: selectedModel || null,
|
||||
});
|
||||
// Subscribe to the same-origin SSE proxy BEFORE sending the message: the
|
||||
// backend has no replay buffer, so the listener must be attached before
|
||||
// the worker starts emitting.
|
||||
startStream(buildLighthouseV2StreamUrl(sessionId), sessionId);
|
||||
|
||||
if ("error" in result) {
|
||||
setFeedback(result.error);
|
||||
if (result.status === 409) {
|
||||
setBlockedByConflict(true);
|
||||
await refreshMessages(sessionId);
|
||||
const result = await sendLighthouseV2Message({
|
||||
sessionId,
|
||||
text: trimmedText,
|
||||
provider: selectedProvider,
|
||||
model: selectedModel || null,
|
||||
});
|
||||
|
||||
if ("error" in result) {
|
||||
closeStream();
|
||||
setStreamState(createInitialLighthouseV2StreamState());
|
||||
setFeedback(result.error);
|
||||
if (result.status === 409) {
|
||||
setBlockedByConflict(true);
|
||||
await refreshMessages(sessionId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
setStreamState(createInitialLighthouseV2StreamState(result.data.task.id));
|
||||
if (result.data.streamUrl) {
|
||||
startStream(result.data.streamUrl, sessionId);
|
||||
setStreamState((current) =>
|
||||
current.activeTaskId === provisionalTaskId
|
||||
? { ...current, activeTaskId: result.data.task.id }
|
||||
: current,
|
||||
);
|
||||
notifyLighthouseV2SessionsChanged();
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
notifyLighthouseV2SessionsChanged();
|
||||
};
|
||||
|
||||
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
|
||||
@@ -283,6 +334,10 @@ export function LighthouseV2ChatPage({
|
||||
};
|
||||
|
||||
useMountEffect(() => {
|
||||
if (initialSessionId && initialActiveTaskId && initialStreamUrl) {
|
||||
startStream(initialStreamUrl, initialSessionId);
|
||||
}
|
||||
|
||||
return () => closeStream();
|
||||
});
|
||||
|
||||
@@ -293,8 +348,11 @@ export function LighthouseV2ChatPage({
|
||||
}
|
||||
});
|
||||
|
||||
const hasConversation =
|
||||
messages.length > 0 || Boolean(streamState.assistantText);
|
||||
const hasLiveAssistantActivity =
|
||||
Boolean(streamState.activeTaskId) ||
|
||||
Boolean(streamState.assistantText) ||
|
||||
streamState.toolCalls.length > 0;
|
||||
const hasConversation = messages.length > 0 || hasLiveAssistantActivity;
|
||||
|
||||
return (
|
||||
<Card
|
||||
@@ -312,7 +370,7 @@ export function LighthouseV2ChatPage({
|
||||
{messages.map((message) => (
|
||||
<MessageBubble key={message.id} message={message} />
|
||||
))}
|
||||
{streamState.assistantText && (
|
||||
{hasLiveAssistantActivity && (
|
||||
<StreamingAssistantMessage streamState={streamState} />
|
||||
)}
|
||||
</ConversationContent>
|
||||
@@ -525,30 +583,59 @@ function LighthouseV2Composer({
|
||||
|
||||
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("flex gap-3", isUser ? "justify-end" : "justify-start")}
|
||||
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(
|
||||
"max-w-[min(760px,85%)] rounded-[8px] px-4 py-3 text-sm",
|
||||
isUser
|
||||
? "bg-button-primary text-black"
|
||||
: "bg-bg-neutral-tertiary text-text-neutral-primary",
|
||||
"flex max-w-[min(760px,85%)] flex-col gap-1",
|
||||
isUser ? "items-end" : "items-start",
|
||||
)}
|
||||
>
|
||||
{message.parts.map((part) => (
|
||||
<div key={part.id || `${message.id}-${part.type}`}>
|
||||
{part.type === LIGHTHOUSE_V2_PART_TYPE.TEXT ? (
|
||||
<p className="whitespace-pre-wrap">
|
||||
{getTextContent(part.content)}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-text-neutral-secondary text-xs">{part.type}</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<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" />
|
||||
@@ -557,37 +644,156 @@ function MessageBubble({ message }: { message: LighthouseV2Message }) {
|
||||
);
|
||||
}
|
||||
|
||||
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">
|
||||
<p className="whitespace-pre-wrap">{streamState.assistantText}</p>
|
||||
{streamState.toolCalls.length > 0 && (
|
||||
<div className="mt-3 grid gap-1">
|
||||
{streamState.toolCalls.map((toolCall) => (
|
||||
<div
|
||||
key={toolCall.id}
|
||||
className="text-text-neutral-secondary flex items-center gap-2 text-xs"
|
||||
>
|
||||
{toolCall.status === "running" && (
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
)}
|
||||
<span>{toolCall.name}</span>
|
||||
{toolCall.outcome && <span>{toolCall.outcome}</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{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"],
|
||||
@@ -1,272 +0,0 @@
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type {
|
||||
LighthouseV2Configuration,
|
||||
LighthouseV2Message,
|
||||
LighthouseV2SupportedModel,
|
||||
} from "@/types/lighthouse-v2";
|
||||
|
||||
import { LighthouseV2ChatPage } from "./lighthouse-v2-chat-page";
|
||||
|
||||
const {
|
||||
cancelRunMock,
|
||||
createSessionMock,
|
||||
getMessagesMock,
|
||||
pushMock,
|
||||
sendMessageMock,
|
||||
} = vi.hoisted(() => ({
|
||||
cancelRunMock: vi.fn(),
|
||||
createSessionMock: vi.fn(),
|
||||
getMessagesMock: vi.fn(),
|
||||
pushMock: vi.fn(),
|
||||
sendMessageMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({
|
||||
push: pushMock,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/actions/lighthouse-v2/lighthouse-v2", () => ({
|
||||
cancelLighthouseV2Run: cancelRunMock,
|
||||
createLighthouseV2Session: createSessionMock,
|
||||
getLighthouseV2Messages: getMessagesMock,
|
||||
sendLighthouseV2Message: sendMessageMock,
|
||||
}));
|
||||
|
||||
const configurations: LighthouseV2Configuration[] = [
|
||||
{
|
||||
id: "config-openai",
|
||||
providerType: "openai",
|
||||
baseUrl: null,
|
||||
defaultModel: "gpt-5.1",
|
||||
businessContext: "Production account",
|
||||
connected: true,
|
||||
connectionLastCheckedAt: "2026-06-24T10:00:00Z",
|
||||
insertedAt: "2026-06-24T09:00:00Z",
|
||||
updatedAt: "2026-06-24T10:00:00Z",
|
||||
},
|
||||
{
|
||||
id: "config-bedrock",
|
||||
providerType: "bedrock",
|
||||
baseUrl: null,
|
||||
defaultModel: "anthropic.claude-4",
|
||||
businessContext: "AWS landing zone",
|
||||
connected: true,
|
||||
connectionLastCheckedAt: "2026-06-23T10:00:00Z",
|
||||
insertedAt: "2026-06-23T09:00:00Z",
|
||||
updatedAt: "2026-06-23T10:00:00Z",
|
||||
},
|
||||
];
|
||||
|
||||
const modelsByProvider = {
|
||||
openai: [model("gpt-5.1")],
|
||||
bedrock: [model("anthropic.claude-4")],
|
||||
"openai-compatible": [model("llama-3.3")],
|
||||
};
|
||||
|
||||
describe("LighthouseV2ChatPage", () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal(
|
||||
"ResizeObserver",
|
||||
class ResizeObserver {
|
||||
observe = vi.fn();
|
||||
unobserve = vi.fn();
|
||||
disconnect = vi.fn();
|
||||
},
|
||||
);
|
||||
cancelRunMock.mockReset();
|
||||
createSessionMock.mockReset();
|
||||
getMessagesMock.mockReset();
|
||||
pushMock.mockReset();
|
||||
sendMessageMock.mockReset();
|
||||
|
||||
createSessionMock.mockResolvedValue({
|
||||
data: {
|
||||
id: "session-1",
|
||||
title: "Summarize findings",
|
||||
isArchived: false,
|
||||
insertedAt: "2026-06-24T10:00:00Z",
|
||||
updatedAt: "2026-06-24T10:00:00Z",
|
||||
activeTaskId: null,
|
||||
},
|
||||
});
|
||||
getMessagesMock.mockResolvedValue({ data: [] });
|
||||
sendMessageMock.mockResolvedValue({
|
||||
data: {
|
||||
task: {
|
||||
id: "task-1",
|
||||
name: "lighthouse-run",
|
||||
state: "executing",
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("does not render provider or model selectors in the chat composer", () => {
|
||||
// Given / When
|
||||
renderPage();
|
||||
|
||||
// Then
|
||||
expect(screen.queryByRole("combobox")).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("link", { name: "Lighthouse settings" }),
|
||||
).toHaveAttribute("href", "/lighthouse/settings");
|
||||
});
|
||||
|
||||
it("uses the tuned scrollbar and bottom fade without a composer separator", () => {
|
||||
// Given / When
|
||||
const { container } = renderPage({
|
||||
initialMessages: [message("message-1", "assistant", "Existing answer")],
|
||||
});
|
||||
|
||||
// Then
|
||||
const conversation = screen.getByRole("log");
|
||||
const scrollViewport = conversation.firstElementChild as HTMLElement;
|
||||
const content = scrollViewport.firstElementChild as HTMLElement;
|
||||
const scrollFade = container.querySelector(
|
||||
'[data-slot="lighthouse-v2-chat-scroll-fade"]',
|
||||
);
|
||||
|
||||
expect(conversation).toHaveClass("h-full", "min-h-0");
|
||||
expect(conversation.parentElement).toHaveClass("flex", "overflow-hidden");
|
||||
expect(scrollViewport).toHaveClass(
|
||||
"minimal-scrollbar",
|
||||
"overflow-x-hidden",
|
||||
"overflow-y-auto",
|
||||
);
|
||||
expect(content).toHaveClass("pb-20");
|
||||
expect(scrollFade).toHaveClass(
|
||||
"pointer-events-none",
|
||||
"absolute",
|
||||
"bottom-0",
|
||||
"right-2",
|
||||
"h-16",
|
||||
"bg-gradient-to-t",
|
||||
"from-bg-neutral-secondary",
|
||||
"to-transparent",
|
||||
);
|
||||
expect(
|
||||
container.querySelector(
|
||||
'[data-slot="lighthouse-v2-chat-composer-panel"]',
|
||||
),
|
||||
).not.toHaveClass("border-t");
|
||||
});
|
||||
|
||||
it("sends messages with the connected default provider and model from configuration", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
renderPage();
|
||||
|
||||
// When
|
||||
await user.type(
|
||||
screen.getByRole("textbox", { name: "Message" }),
|
||||
["Summarize findings", "{Enter}"].join(""),
|
||||
);
|
||||
|
||||
// Then
|
||||
await waitFor(() =>
|
||||
expect(sendMessageMock).toHaveBeenCalledWith({
|
||||
sessionId: "session-1",
|
||||
text: "Summarize findings",
|
||||
provider: "openai",
|
||||
model: "gpt-5.1",
|
||||
}),
|
||||
);
|
||||
expect(createSessionMock).toHaveBeenCalledWith("Summarize findings");
|
||||
expect(pushMock).toHaveBeenCalledWith("/lighthouse?session=session-1");
|
||||
});
|
||||
|
||||
it("closes an active stream when the chat unmounts", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
const closeMock = vi.fn();
|
||||
const eventSourceMock = vi.fn(function MockEventSource(
|
||||
this: Record<string, unknown>,
|
||||
) {
|
||||
this.addEventListener = vi.fn();
|
||||
this.close = closeMock;
|
||||
});
|
||||
vi.stubGlobal("EventSource", eventSourceMock);
|
||||
sendMessageMock.mockResolvedValue({
|
||||
data: {
|
||||
task: {
|
||||
id: "task-1",
|
||||
name: "lighthouse-run",
|
||||
state: "executing",
|
||||
},
|
||||
streamUrl: "/api/stream",
|
||||
},
|
||||
});
|
||||
const { unmount } = renderPage({ initialSessionId: "session-1" });
|
||||
|
||||
// When
|
||||
await user.type(
|
||||
screen.getByRole("textbox", { name: "Message" }),
|
||||
["Summarize findings", "{Enter}"].join(""),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(eventSourceMock).toHaveBeenCalledWith("/api/stream"),
|
||||
);
|
||||
unmount();
|
||||
|
||||
// Then
|
||||
expect(closeMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
function renderPage(
|
||||
props?: Partial<Parameters<typeof LighthouseV2ChatPage>[0]>,
|
||||
) {
|
||||
return render(
|
||||
<LighthouseV2ChatPage
|
||||
configurations={props?.configurations ?? configurations}
|
||||
modelsByProvider={props?.modelsByProvider ?? modelsByProvider}
|
||||
initialSessionId={props?.initialSessionId}
|
||||
initialMessages={props?.initialMessages ?? []}
|
||||
initialPrompt={props?.initialPrompt}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
function model(id: string): LighthouseV2SupportedModel {
|
||||
return {
|
||||
id,
|
||||
maxInputTokens: null,
|
||||
maxOutputTokens: null,
|
||||
supportsFunctionCalling: null,
|
||||
supportsVision: null,
|
||||
supportsReasoning: null,
|
||||
};
|
||||
}
|
||||
|
||||
function message(
|
||||
id: string,
|
||||
role: LighthouseV2Message["role"],
|
||||
content: string,
|
||||
): LighthouseV2Message {
|
||||
return {
|
||||
id,
|
||||
role,
|
||||
model: null,
|
||||
tokenUsage: null,
|
||||
insertedAt: "2026-06-25T10:00:00Z",
|
||||
parts: [
|
||||
{
|
||||
id: `${id}-part`,
|
||||
type: "text",
|
||||
content,
|
||||
toolCallOutcome: null,
|
||||
insertedAt: "2026-06-25T10:00:00Z",
|
||||
updatedAt: "2026-06-25T10:00:00Z",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user