feat(ui): connect Lighthouse v2 to Cloud backend

This commit is contained in:
alejandrobailo
2026-06-24 18:22:06 +02:00
parent 917e5d07ff
commit 9817d964e7
75 changed files with 3035 additions and 105 deletions
@@ -4,17 +4,17 @@ import { apiBaseUrl, getAuthHeaders } from "@/lib/helper";
import {
validateBaseUrl,
validateCredentials,
} from "@/lib/lighthouse/validation";
} from "@/lib/lighthouse-v1/validation";
import { handleApiError, handleApiResponse } from "@/lib/server-actions-helper";
import {
type LighthouseProvider,
PROVIDER_DISPLAY_NAMES,
} from "@/types/lighthouse";
} from "@/types/lighthouse-v1";
import type {
BedrockCredentials,
OpenAICompatibleCredentials,
OpenAICredentials,
} from "@/types/lighthouse/credentials";
} from "@/types/lighthouse-v1/credentials";
// API Response Types
type ProviderCredentials =
@@ -0,0 +1,168 @@
import { describe, expect, it } from "vitest";
import {
buildLighthouseV2ConfigurationPayload,
mapLighthouseV2Configuration,
mapLighthouseV2Message,
mapLighthouseV2Model,
mapLighthouseV2Provider,
validateLighthouseV2ConfigurationInput,
} from "./lighthouse-v2.adapter";
describe("lighthouse-v2.adapter", () => {
describe("when mapping Cloud JSON:API resources", () => {
it("should map configuration attributes to UI fields", () => {
// Given
const resource: Parameters<typeof mapLighthouseV2Configuration>[0] = {
id: "config-1",
type: "lighthouse-ai-configurations",
attributes: {
provider_type: "bedrock",
base_url: null,
default_model: "anthropic.claude",
business_context: "Production AWS account",
connected: true,
connection_last_checked_at: "2026-06-24T10:00:00Z",
inserted_at: "2026-06-24T09:00:00Z",
updated_at: "2026-06-24T10:00:00Z",
},
};
// When
const config = mapLighthouseV2Configuration(resource);
// Then
expect(config).toEqual({
id: "config-1",
providerType: "bedrock",
baseUrl: null,
defaultModel: "anthropic.claude",
businessContext: "Production AWS account",
connected: true,
connectionLastCheckedAt: "2026-06-24T10:00:00Z",
insertedAt: "2026-06-24T09:00:00Z",
updatedAt: "2026-06-24T10:00:00Z",
});
});
it("should map supported provider and model payloads", () => {
// Given
const provider = {
id: "openai",
type: "lighthouse-supported-providers",
attributes: { name: "OpenAI" },
};
const model = {
id: "gpt-5.5",
type: "lighthouse-supported-models",
attributes: {
max_input_tokens: 100000,
max_output_tokens: 8192,
supports_function_calling: true,
supports_vision: false,
supports_reasoning: true,
},
};
// When / Then
expect(mapLighthouseV2Provider(provider)).toEqual({
id: "openai",
name: "OpenAI",
});
expect(mapLighthouseV2Model(model)).toEqual({
id: "gpt-5.5",
maxInputTokens: 100000,
maxOutputTokens: 8192,
supportsFunctionCalling: true,
supportsVision: false,
supportsReasoning: true,
});
});
it("should map message parts from backend names", () => {
// Given
const resource: Parameters<typeof mapLighthouseV2Message>[0] = {
id: "message-1",
type: "lighthouse-messages",
attributes: {
role: "assistant",
model: "gpt-5.5",
token_usage: { input: 10 },
inserted_at: "2026-06-24T10:01:00Z",
parts: [
{
id: "part-1",
type: "lighthouse-parts",
attributes: {
part_type: "text",
content: { text: "Done" },
tool_call_outcome: null,
inserted_at: "2026-06-24T10:01:00Z",
updated_at: "2026-06-24T10:01:00Z",
},
},
],
},
};
// When
const message = mapLighthouseV2Message(resource);
// Then
expect(message.parts[0]).toMatchObject({
id: "part-1",
type: "text",
content: { text: "Done" },
});
});
});
describe("when building Cloud payloads", () => {
it("should use Cloud Bedrock credential keys", () => {
// Given
const input = {
providerType: "bedrock" as const,
defaultModel: "anthropic.claude",
businessContext: "Production AWS account",
credentials: {
aws_access_key_id: "AKIA0000000000000000",
aws_secret_access_key: "a".repeat(40),
aws_region_name: "us-east-1",
},
};
// When
const payload = buildLighthouseV2ConfigurationPayload(input);
// Then
expect(payload.data).toMatchObject({
type: "lighthouse-ai-configurations",
attributes: {
provider_type: "bedrock",
credentials: {
aws_access_key_id: "AKIA0000000000000000",
aws_secret_access_key: "a".repeat(40),
aws_region_name: "us-east-1",
},
},
});
});
it("should require base_url for OpenAI-compatible configurations", () => {
// Given
const input = {
providerType: "openai-compatible" as const,
credentials: { api_key: "provider-key" },
};
// When
const result = validateLighthouseV2ConfigurationInput(input);
// Then
expect(result).toEqual({
success: false,
error: "Base URL is required for OpenAI-compatible providers.",
});
});
});
});
@@ -0,0 +1,348 @@
import {
LIGHTHOUSE_V2_PROVIDER_TYPE,
type LighthouseV2Configuration,
type LighthouseV2ConfigurationInput,
type LighthouseV2ConfigurationUpdateInput,
type LighthouseV2Credentials,
type LighthouseV2Message,
type LighthouseV2Part,
type LighthouseV2ProviderType,
type LighthouseV2Session,
type LighthouseV2SupportedModel,
type LighthouseV2SupportedProvider,
type LighthouseV2Task,
} from "@/types/lighthouse-v2";
export interface JsonApiResource<TAttributes> {
id: string;
type: string;
attributes: TAttributes;
meta?: Record<string, unknown>;
}
export interface JsonApiDocument<TData> {
data?: TData;
meta?: Record<string, unknown>;
links?: Record<string, string | null>;
error?: string;
errors?: unknown[];
status?: number;
}
interface ConfigurationAttributes {
provider_type: LighthouseV2ProviderType;
base_url: string | null;
default_model: string | null;
business_context?: string | null;
connected: boolean | null;
connection_last_checked_at: string | null;
inserted_at: string;
updated_at: string;
}
interface SupportedProviderAttributes {
name: string;
}
interface SupportedModelAttributes {
max_input_tokens: number | null;
max_output_tokens: number | null;
supports_function_calling: boolean | null;
supports_vision: boolean | null;
supports_reasoning: boolean | null;
}
interface SessionAttributes {
title: string | null;
is_archived: boolean;
inserted_at: string;
updated_at: string;
active_celery_task_id?: string | null;
}
interface MessageAttributes {
role: "user" | "assistant";
model: string | null;
token_usage: unknown;
inserted_at: string;
parts?: UnknownPartResource[];
}
interface PartAttributes {
id?: string;
part_type: "text" | "reasoning" | "tool_call";
content: unknown;
tool_call_outcome?: string | null;
inserted_at?: string | null;
updated_at?: string | null;
}
type UnknownPartResource =
| JsonApiResource<PartAttributes>
| (PartAttributes & { id?: string });
interface TaskAttributes {
inserted_at?: string;
completed_at?: string | null;
name?: string | null;
state: string;
metadata?: unknown;
result?: unknown;
}
interface ValidationSuccess {
success: true;
}
interface ValidationFailure {
success: false;
error: string;
}
type ValidationResult = ValidationSuccess | ValidationFailure;
export function getJsonApiArray<TResource>(
document: JsonApiDocument<TResource[]>,
): TResource[] {
return document.data ?? [];
}
export function mapLighthouseV2Configuration(
resource: JsonApiResource<ConfigurationAttributes>,
): LighthouseV2Configuration {
return {
id: resource.id,
providerType: resource.attributes.provider_type,
baseUrl: resource.attributes.base_url,
defaultModel: resource.attributes.default_model,
businessContext: resource.attributes.business_context ?? "",
connected: resource.attributes.connected,
connectionLastCheckedAt: resource.attributes.connection_last_checked_at,
insertedAt: resource.attributes.inserted_at,
updatedAt: resource.attributes.updated_at,
};
}
export function mapLighthouseV2Provider(
resource: JsonApiResource<SupportedProviderAttributes>,
): LighthouseV2SupportedProvider {
return {
id: resource.id as LighthouseV2ProviderType,
name: resource.attributes.name,
};
}
export function mapLighthouseV2Model(
resource: JsonApiResource<SupportedModelAttributes>,
): LighthouseV2SupportedModel {
return {
id: resource.id,
maxInputTokens: resource.attributes.max_input_tokens,
maxOutputTokens: resource.attributes.max_output_tokens,
supportsFunctionCalling: resource.attributes.supports_function_calling,
supportsVision: resource.attributes.supports_vision,
supportsReasoning: resource.attributes.supports_reasoning,
};
}
export function mapLighthouseV2Session(
resource: JsonApiResource<SessionAttributes>,
): LighthouseV2Session {
return {
id: resource.id,
title: resource.attributes.title,
isArchived: resource.attributes.is_archived,
insertedAt: resource.attributes.inserted_at,
updatedAt: resource.attributes.updated_at,
activeTaskId: resource.attributes.active_celery_task_id,
};
}
export function mapLighthouseV2Message(
resource: JsonApiResource<MessageAttributes>,
): LighthouseV2Message {
return {
id: resource.id,
role: resource.attributes.role,
model: resource.attributes.model,
tokenUsage: resource.attributes.token_usage,
insertedAt: resource.attributes.inserted_at,
parts: (resource.attributes.parts ?? []).map(mapLighthouseV2Part),
};
}
export function mapLighthouseV2Task(
resource: JsonApiResource<TaskAttributes>,
): LighthouseV2Task {
return {
id: resource.id,
name: resource.attributes.name ?? null,
state: resource.attributes.state,
insertedAt: resource.attributes.inserted_at,
completedAt: resource.attributes.completed_at,
metadata: resource.attributes.metadata,
result: resource.attributes.result,
};
}
export function buildLighthouseV2ConfigurationPayload(
input: LighthouseV2ConfigurationInput,
) {
return {
data: {
type: "lighthouse-ai-configurations",
attributes: filterUndefinedAttributes({
provider_type: input.providerType,
credentials: input.credentials,
base_url: input.baseUrl ?? null,
default_model: input.defaultModel ?? null,
business_context: input.businessContext,
}),
},
};
}
export function buildLighthouseV2ConfigurationUpdatePayload(
configId: string,
input: LighthouseV2ConfigurationUpdateInput,
) {
return {
data: {
type: "lighthouse-ai-configurations",
id: configId,
attributes: filterUndefinedAttributes({
credentials: input.credentials,
base_url: input.baseUrl,
default_model: input.defaultModel,
business_context: input.businessContext,
}),
},
};
}
export function buildLighthouseV2SessionCreatePayload(title?: string | null) {
return {
data: {
type: "lighthouse-sessions",
attributes: { title: title || null },
},
};
}
export function buildLighthouseV2SessionUpdatePayload(
sessionId: string,
attributes: { title?: string | null; isArchived?: boolean },
) {
return {
data: {
type: "lighthouse-sessions",
id: sessionId,
attributes: filterUndefinedAttributes({
title: attributes.title,
is_archived: attributes.isArchived,
}),
},
};
}
export function buildLighthouseV2MessagePayload(input: {
text: string;
provider: LighthouseV2ProviderType;
model?: string | null;
}) {
return {
data: {
type: "lighthouse-messages",
attributes: filterUndefinedAttributes({
parts: [
{
part_type: "text",
content: { text: input.text },
},
],
provider: input.provider,
model: input.model || undefined,
}),
},
};
}
export function buildLighthouseV2CancelRunPayload(taskId: string) {
return {
data: {
type: "lighthouse-run-cancellations",
attributes: { task_id: taskId },
},
};
}
export function validateLighthouseV2ConfigurationInput(input: {
providerType: LighthouseV2ProviderType;
credentials?: LighthouseV2Credentials;
baseUrl?: string | null;
}): ValidationResult {
if (!input.credentials) {
return { success: false, error: "Credentials are required." };
}
if (
input.providerType === LIGHTHOUSE_V2_PROVIDER_TYPE.OPENAI_COMPATIBLE &&
!input.baseUrl
) {
return {
success: false,
error: "Base URL is required for OpenAI-compatible providers.",
};
}
if (
input.providerType !== LIGHTHOUSE_V2_PROVIDER_TYPE.OPENAI_COMPATIBLE &&
input.baseUrl
) {
return {
success: false,
error: "Base URL is only supported for OpenAI-compatible providers.",
};
}
if (
input.providerType === LIGHTHOUSE_V2_PROVIDER_TYPE.BEDROCK &&
!hasBedrockRegion(input.credentials)
) {
return {
success: false,
error: "AWS region is required for Bedrock providers.",
};
}
return { success: true };
}
function mapLighthouseV2Part(resource: UnknownPartResource): LighthouseV2Part {
const attributes = "attributes" in resource ? resource.attributes : resource;
const id =
"id" in resource && resource.id ? resource.id : (attributes.id ?? "");
return {
id,
type: attributes.part_type,
content: attributes.content,
toolCallOutcome: attributes.tool_call_outcome ?? null,
insertedAt: attributes.inserted_at ?? null,
updatedAt: attributes.updated_at ?? null,
};
}
function filterUndefinedAttributes<T extends Record<string, unknown>>(
attributes: T,
) {
return Object.fromEntries(
Object.entries(attributes).filter(([, value]) => value !== undefined),
) as Partial<T>;
}
function hasBedrockRegion(credentials: LighthouseV2Credentials): boolean {
return (
"aws_region_name" in credentials && Boolean(credentials.aws_region_name)
);
}
+410
View File
@@ -0,0 +1,410 @@
"use server";
import { auth } from "@/auth.config";
import { apiBaseUrl, getAuthHeaders } from "@/lib/helper";
import { handleApiError, handleApiResponse } from "@/lib/server-actions-helper";
import type {
LighthouseV2Configuration,
LighthouseV2ConfigurationInput,
LighthouseV2ConfigurationUpdateInput,
LighthouseV2Message,
LighthouseV2ProviderType,
LighthouseV2SendMessageInput,
LighthouseV2SendMessageResult,
LighthouseV2Session,
LighthouseV2SupportedModel,
LighthouseV2SupportedProvider,
LighthouseV2Task,
} from "@/types/lighthouse-v2";
import {
buildLighthouseV2CancelRunPayload,
buildLighthouseV2ConfigurationPayload,
buildLighthouseV2ConfigurationUpdatePayload,
buildLighthouseV2MessagePayload,
buildLighthouseV2SessionCreatePayload,
buildLighthouseV2SessionUpdatePayload,
getJsonApiArray,
type JsonApiDocument,
mapLighthouseV2Configuration,
mapLighthouseV2Message,
mapLighthouseV2Model,
mapLighthouseV2Provider,
mapLighthouseV2Session,
mapLighthouseV2Task,
validateLighthouseV2ConfigurationInput,
} from "./lighthouse-v2.adapter";
type TaskResource = Parameters<typeof mapLighthouseV2Task>[0];
export type LighthouseV2ActionResult<T> =
| {
data: T;
meta?: Record<string, unknown>;
links?: Record<string, string | null>;
status?: number;
}
| {
error: string;
errors?: unknown[];
status?: number;
};
export async function getLighthouseV2Configurations(): Promise<
LighthouseV2ActionResult<LighthouseV2Configuration[]>
> {
return getCollection("/lighthouse/config", mapLighthouseV2Configuration);
}
export async function createLighthouseV2Configuration(
input: LighthouseV2ConfigurationInput,
): Promise<LighthouseV2ActionResult<LighthouseV2Configuration>> {
const validation = validateLighthouseV2ConfigurationInput(input);
if (!validation.success) {
return { error: validation.error, status: 400 };
}
return mutateSingle(
"/lighthouse/config",
{
method: "POST",
body: JSON.stringify(buildLighthouseV2ConfigurationPayload(input)),
},
mapLighthouseV2Configuration,
"/lighthouse/config",
);
}
export async function updateLighthouseV2Configuration(
configId: string,
input: LighthouseV2ConfigurationUpdateInput,
): Promise<LighthouseV2ActionResult<LighthouseV2Configuration>> {
return mutateSingle(
`/lighthouse/config/${encodeURIComponent(configId)}`,
{
method: "PATCH",
body: JSON.stringify(
buildLighthouseV2ConfigurationUpdatePayload(configId, input),
),
},
mapLighthouseV2Configuration,
"/lighthouse/config",
);
}
export async function deleteLighthouseV2Configuration(
configId: string,
): Promise<LighthouseV2ActionResult<true>> {
return mutateEmpty(
`/lighthouse/config/${encodeURIComponent(configId)}`,
{ method: "DELETE" },
"/lighthouse/config",
);
}
export async function testLighthouseV2ConfigurationConnection(
configId: string,
): Promise<LighthouseV2ActionResult<LighthouseV2Task>> {
return mutateSingle(
`/lighthouse/config/${encodeURIComponent(configId)}/connection`,
{ method: "POST" },
mapLighthouseV2Task,
"/lighthouse/config",
false,
);
}
export async function getLighthouseV2SupportedProviders(): Promise<
LighthouseV2ActionResult<LighthouseV2SupportedProvider[]>
> {
return getCollection(
"/lighthouse/supported-providers",
mapLighthouseV2Provider,
);
}
export async function getLighthouseV2SupportedModels(
provider: LighthouseV2ProviderType,
): Promise<LighthouseV2ActionResult<LighthouseV2SupportedModel[]>> {
return getCollection(
`/lighthouse/supported-providers/${encodeURIComponent(provider)}/models`,
mapLighthouseV2Model,
);
}
export async function getLighthouseV2Sessions(params?: {
search?: string;
}): Promise<LighthouseV2ActionResult<LighthouseV2Session[]>> {
const url = buildApiUrl("/lighthouse/sessions");
if (params?.search) {
url.searchParams.set("search", params.search);
}
return getCollectionFromUrl(url, mapLighthouseV2Session);
}
export async function getLighthouseV2Session(
sessionId: string,
): Promise<LighthouseV2ActionResult<LighthouseV2Session>> {
return getSingle(
`/lighthouse/sessions/${encodeURIComponent(sessionId)}`,
mapLighthouseV2Session,
);
}
export async function createLighthouseV2Session(
title?: string | null,
): Promise<LighthouseV2ActionResult<LighthouseV2Session>> {
return mutateSingle(
"/lighthouse/sessions",
{
method: "POST",
body: JSON.stringify(buildLighthouseV2SessionCreatePayload(title)),
},
mapLighthouseV2Session,
"/lighthouse",
);
}
export async function updateLighthouseV2Session(
sessionId: string,
attributes: { title?: string | null; isArchived?: boolean },
): Promise<LighthouseV2ActionResult<LighthouseV2Session>> {
return mutateSingle(
`/lighthouse/sessions/${encodeURIComponent(sessionId)}`,
{
method: "PATCH",
body: JSON.stringify(
buildLighthouseV2SessionUpdatePayload(sessionId, attributes),
),
},
mapLighthouseV2Session,
"/lighthouse",
);
}
export async function archiveLighthouseV2Session(
sessionId: string,
): Promise<LighthouseV2ActionResult<LighthouseV2Session>> {
return updateLighthouseV2Session(sessionId, { isArchived: true });
}
export async function getLighthouseV2Messages(
sessionId: string,
): Promise<LighthouseV2ActionResult<LighthouseV2Message[]>> {
return getCollection(
`/lighthouse/sessions/${encodeURIComponent(sessionId)}/messages`,
mapLighthouseV2Message,
);
}
export async function sendLighthouseV2Message(
input: LighthouseV2SendMessageInput,
): Promise<LighthouseV2ActionResult<LighthouseV2SendMessageResult>> {
try {
const response = await fetch(
buildApiUrl(
`/lighthouse/sessions/${encodeURIComponent(input.sessionId)}/messages`,
),
{
method: "POST",
headers: await getAuthHeaders({ contentType: true }),
body: JSON.stringify(buildLighthouseV2MessagePayload(input)),
},
);
const document = (await handleApiResponse(
response,
)) as JsonApiDocument<TaskResource>;
if (isErrorDocument(document) || !document.data) {
return toErrorResult(document);
}
const streamPath =
typeof document.meta?.stream_url === "string"
? document.meta.stream_url
: undefined;
const streamUrl = streamPath
? await getAuthenticatedLighthouseV2StreamUrl(streamPath)
: undefined;
return {
data: {
task: mapLighthouseV2Task(document.data),
streamUrl,
},
meta: document.meta,
};
} catch (error) {
return handleApiError(error);
}
}
export async function cancelLighthouseV2Run(
sessionId: string,
taskId: string,
): Promise<LighthouseV2ActionResult<LighthouseV2Task>> {
return mutateSingle(
`/lighthouse/sessions/${encodeURIComponent(sessionId)}/cancel-run`,
{
method: "POST",
body: JSON.stringify(buildLighthouseV2CancelRunPayload(taskId)),
},
mapLighthouseV2Task,
"/lighthouse",
);
}
export async function getAuthenticatedLighthouseV2StreamUrl(
streamPath: string,
): Promise<string | undefined> {
const session = await auth();
if (!session?.accessToken) {
return undefined;
}
const streamUrl = new URL(streamPath, getRequiredApiBaseUrl());
streamUrl.searchParams.set("access_token", session.accessToken);
return streamUrl.toString();
}
async function getCollection<TResource, TOutput>(
path: string,
mapper: (resource: TResource) => TOutput,
): Promise<LighthouseV2ActionResult<TOutput[]>> {
return getCollectionFromUrl(buildApiUrl(path), mapper);
}
async function getCollectionFromUrl<TResource, TOutput>(
url: URL,
mapper: (resource: TResource) => TOutput,
): Promise<LighthouseV2ActionResult<TOutput[]>> {
try {
const headers = await getAuthHeaders({ contentType: false });
const first = (await handleApiResponse(
await fetch(url.toString(), {
method: "GET",
headers,
cache: "no-store",
}),
)) as JsonApiDocument<TResource[]>;
if (isErrorDocument(first)) {
return toErrorResult(first);
}
const resources = [...getJsonApiArray(first)];
let nextUrl: string | undefined = first.links?.next ?? undefined;
while (nextUrl) {
const page = (await handleApiResponse(
await fetch(nextUrl, { method: "GET", headers, cache: "no-store" }),
)) as JsonApiDocument<TResource[]>;
if (isErrorDocument(page)) {
return toErrorResult(page);
}
resources.push(...getJsonApiArray(page));
nextUrl = page.links?.next ?? undefined;
}
return {
data: resources.map(mapper),
meta: first.meta,
links: first.links,
};
} catch (error) {
return handleApiError(error);
}
}
async function getSingle<TResource, TOutput>(
path: string,
mapper: (resource: TResource) => TOutput,
): Promise<LighthouseV2ActionResult<TOutput>> {
try {
const response = await fetch(buildApiUrl(path), {
method: "GET",
headers: await getAuthHeaders({ contentType: false }),
cache: "no-store",
});
const document = (await handleApiResponse(
response,
)) as JsonApiDocument<TResource>;
if (isErrorDocument(document) || !document.data) {
return toErrorResult(document);
}
return { data: mapper(document.data), meta: document.meta };
} catch (error) {
return handleApiError(error);
}
}
async function mutateSingle<TResource, TOutput>(
path: string,
init: RequestInit,
mapper: (resource: TResource) => TOutput,
pathToRevalidate: string,
includeContentType = true,
): Promise<LighthouseV2ActionResult<TOutput>> {
try {
const response = await fetch(buildApiUrl(path), {
...init,
headers: await getAuthHeaders({ contentType: includeContentType }),
});
const document = (await handleApiResponse(
response,
pathToRevalidate,
)) as JsonApiDocument<TResource>;
if (isErrorDocument(document) || !document.data) {
return toErrorResult(document);
}
return { data: mapper(document.data), meta: document.meta };
} catch (error) {
return handleApiError(error);
}
}
async function mutateEmpty(
path: string,
init: RequestInit,
pathToRevalidate: string,
): Promise<LighthouseV2ActionResult<true>> {
try {
const response = await fetch(buildApiUrl(path), {
...init,
headers: await getAuthHeaders({ contentType: false }),
});
const document = await handleApiResponse(response, pathToRevalidate);
if (isErrorDocument(document)) {
return toErrorResult(document);
}
return { data: true, status: document.status };
} catch (error) {
return handleApiError(error);
}
}
function buildApiUrl(path: string): URL {
return new URL(`${getRequiredApiBaseUrl()}${path}`);
}
function getRequiredApiBaseUrl(): string {
if (!apiBaseUrl) {
throw new Error("API base URL is not configured.");
}
return apiBaseUrl;
}
function isErrorDocument<TData>(
document: JsonApiDocument<TData> | { error?: unknown },
): document is JsonApiDocument<TData> & { error: string } {
return typeof document.error === "string";
}
function toErrorResult<TData>(
document: JsonApiDocument<TData>,
): Extract<LighthouseV2ActionResult<never>, { error: string }> {
return {
error: document.error ?? "Unexpected Lighthouse response.",
errors: document.errors,
status: document.status,
};
}
@@ -1,7 +1,7 @@
"use server";
import { getLatestFindings } from "@/actions/findings/findings";
import { LighthouseBanner } from "@/components/lighthouse/banner";
import { LighthouseBanner } from "@/components/lighthouse-v1/banner";
import { LinkToFindings } from "@/components/overview";
import { ColumnLatestFindings } from "@/components/overview/new-findings-table/table";
import { CardTitle } from "@/components/shadcn";
@@ -1,11 +1,11 @@
"use client";
import { useSearchParams } from "next/navigation";
import { redirect, useSearchParams } from "next/navigation";
import { Suspense } from "react";
import { ConnectLLMProvider } from "@/components/lighthouse/connect-llm-provider";
import { SelectBedrockAuthMethod } from "@/components/lighthouse/select-bedrock-auth-method";
import type { LighthouseProvider } from "@/types/lighthouse";
import { ConnectLLMProvider } from "@/components/lighthouse-v1/connect-llm-provider";
import { SelectBedrockAuthMethod } from "@/components/lighthouse-v1/select-bedrock-auth-method";
import type { LighthouseProvider } from "@/types/lighthouse-v1";
export const BEDROCK_AUTH_MODES = {
IAM: "iam",
@@ -43,6 +43,10 @@ function ConnectContent() {
}
export default function ConnectLLMProviderPage() {
if (process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true") {
redirect("/lighthouse/config");
}
return (
<Suspense fallback={<div>Loading...</div>}>
<ConnectContent />
@@ -10,13 +10,13 @@ import React, { useEffect, useState } from "react";
import {
getTenantConfig,
updateTenantConfig,
} from "@/actions/lighthouse/lighthouse";
import { DeleteLLMProviderForm } from "@/components/lighthouse/forms/delete-llm-provider-form";
import { WorkflowConnectLLM } from "@/components/lighthouse/workflow";
} from "@/actions/lighthouse-v1/lighthouse";
import { DeleteLLMProviderForm } from "@/components/lighthouse-v1/forms/delete-llm-provider-form";
import { WorkflowConnectLLM } from "@/components/lighthouse-v1/workflow";
import { Button } from "@/components/shadcn";
import { Modal } from "@/components/shadcn/modal";
import { NavigationHeader } from "@/components/ui";
import type { LighthouseProvider } from "@/types/lighthouse";
import type { LighthouseProvider } from "@/types/lighthouse-v1";
interface ConnectLLMLayoutProps {
children: React.ReactNode;
@@ -1,10 +1,10 @@
"use client";
import { useRouter, useSearchParams } from "next/navigation";
import { redirect, useRouter, useSearchParams } from "next/navigation";
import { Suspense } from "react";
import { SelectModel } from "@/components/lighthouse/select-model";
import type { LighthouseProvider } from "@/types/lighthouse";
import { SelectModel } from "@/components/lighthouse-v1/select-model";
import type { LighthouseProvider } from "@/types/lighthouse-v1";
function SelectModelContent() {
const searchParams = useSearchParams();
@@ -26,6 +26,10 @@ function SelectModelContent() {
}
export default function SelectModelPage() {
if (process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true") {
redirect("/lighthouse/config");
}
return (
<Suspense fallback={<div>Loading...</div>}>
<SelectModelContent />
+59 -1
View File
@@ -1,11 +1,69 @@
import { Spacer } from "@heroui/spacer";
import { LighthouseSettings, LLMProvidersTable } from "@/components/lighthouse";
import {
getLighthouseV2Configurations,
getLighthouseV2SupportedModels,
getLighthouseV2SupportedProviders,
} from "@/actions/lighthouse-v2/lighthouse-v2";
import {
LighthouseSettings,
LLMProvidersTable,
} from "@/components/lighthouse-v1";
import { LighthouseV2ConfigPage } from "@/components/lighthouse-v2/config";
import { ContentLayout } from "@/components/ui";
import { isCloud } from "@/lib/shared/env";
import type {
LighthouseV2ProviderType,
LighthouseV2SupportedModel,
} from "@/types/lighthouse-v2";
export const dynamic = "force-dynamic";
export default async function ChatbotConfigPage() {
if (isCloud()) {
const [configurationsResult, providersResult] = await Promise.all([
getLighthouseV2Configurations(),
getLighthouseV2SupportedProviders(),
]);
const providers = "data" in providersResult ? providersResult.data : [];
const modelsEntries = await Promise.all(
providers.map(async (provider) => {
const result = await getLighthouseV2SupportedModels(provider.id);
return [
provider.id,
"data" in result ? result.data : [],
] as const satisfies readonly [
LighthouseV2ProviderType,
LighthouseV2SupportedModel[],
];
}),
);
const modelsByProvider = Object.fromEntries(modelsEntries) as Record<
LighthouseV2ProviderType,
LighthouseV2SupportedModel[]
>;
const error =
"error" in configurationsResult
? configurationsResult.error
: "error" in providersResult
? providersResult.error
: undefined;
return (
<ContentLayout title="Lighthouse Configuration">
<LighthouseV2ConfigPage
configurations={
"data" in configurationsResult ? configurationsResult.data : []
}
providers={providers}
modelsByProvider={modelsByProvider}
error={error}
/>
</ContentLayout>
);
}
return (
<ContentLayout title="LLM Configuration">
<LLMProvidersTable />
+73 -2
View File
@@ -3,10 +3,22 @@ import { redirect } from "next/navigation";
import {
getLighthouseProvidersConfig,
isLighthouseConfigured,
} from "@/actions/lighthouse/lighthouse";
} from "@/actions/lighthouse-v1/lighthouse";
import {
getLighthouseV2Configurations,
getLighthouseV2Messages,
getLighthouseV2Sessions,
getLighthouseV2SupportedModels,
} from "@/actions/lighthouse-v2/lighthouse-v2";
import { LighthouseIcon } from "@/components/icons/Icons";
import { Chat } from "@/components/lighthouse";
import { Chat } from "@/components/lighthouse-v1";
import { LighthouseV2ChatPage } from "@/components/lighthouse-v2/chat";
import { ContentLayout } from "@/components/ui";
import { isCloud } from "@/lib/shared/env";
import type {
LighthouseV2ProviderType,
LighthouseV2SupportedModel,
} from "@/types/lighthouse-v2";
export const dynamic = "force-dynamic";
@@ -18,6 +30,65 @@ export default async function AIChatbot({
const params = await searchParams;
const initialPrompt =
typeof params.prompt === "string" ? params.prompt : undefined;
const activeSessionId =
typeof params.session === "string" ? params.session : undefined;
if (isCloud()) {
const [configurationsResult, sessionsResult] = await Promise.all([
getLighthouseV2Configurations(),
getLighthouseV2Sessions(),
]);
const configurations =
"data" in configurationsResult ? configurationsResult.data : [];
const connectedConfigurations = configurations.filter(
(configuration) => configuration.connected === true,
);
if (connectedConfigurations.length === 0) {
return redirect("/lighthouse/config");
}
const modelsEntries = await Promise.all(
configurations.map(async (configuration) => {
const result = await getLighthouseV2SupportedModels(
configuration.providerType,
);
return [
configuration.providerType,
"data" in result ? result.data : [],
] as const satisfies readonly [
LighthouseV2ProviderType,
LighthouseV2SupportedModel[],
];
}),
);
const modelsByProvider = Object.fromEntries(modelsEntries) as Record<
LighthouseV2ProviderType,
LighthouseV2SupportedModel[]
>;
const initialMessages =
activeSessionId && "data" in sessionsResult
? await getLighthouseV2Messages(activeSessionId)
: { data: [] };
return (
<ContentLayout title="Lighthouse AI" icon={<LighthouseIcon />}>
<div className="-mx-6 -my-4 h-[calc(100dvh-4.5rem)] sm:-mx-8">
<LighthouseV2ChatPage
configurations={configurations}
modelsByProvider={modelsByProvider}
sessions={"data" in sessionsResult ? sessionsResult.data : []}
initialSessionId={activeSessionId}
initialMessages={
"data" in initialMessages ? initialMessages.data : []
}
initialPrompt={initialPrompt}
/>
</div>
</ContentLayout>
);
}
const hasConfig = await isLighthouseConfigured();
+6 -6
View File
@@ -1,7 +1,7 @@
import * as Sentry from "@sentry/nextjs";
import { createUIMessageStreamResponse, UIMessage } from "ai";
import { getTenantConfig } from "@/actions/lighthouse/lighthouse";
import { getTenantConfig } from "@/actions/lighthouse-v1/lighthouse";
import { auth } from "@/auth.config";
import { getErrorMessage } from "@/lib/helper";
import {
@@ -14,14 +14,14 @@ import {
handleChatModelStreamEvent,
handleToolEvent,
STREAM_MESSAGE_ID,
} from "@/lib/lighthouse/analyst-stream";
import { authContextStorage } from "@/lib/lighthouse/auth-context";
import { getCurrentDataSection } from "@/lib/lighthouse/data";
import { convertVercelMessageToLangChainMessage } from "@/lib/lighthouse/utils";
} from "@/lib/lighthouse-v1/analyst-stream";
import { authContextStorage } from "@/lib/lighthouse-v1/auth-context";
import { getCurrentDataSection } from "@/lib/lighthouse-v1/data";
import { convertVercelMessageToLangChainMessage } from "@/lib/lighthouse-v1/utils";
import {
initLighthouseWorkflow,
type RuntimeConfig,
} from "@/lib/lighthouse/workflow";
} from "@/lib/lighthouse-v1/workflow";
import { SentryErrorSource, SentryErrorType } from "@/sentry";
export async function POST(req: Request) {
@@ -1,4 +1,4 @@
import { isLighthouseConfigured } from "@/actions/lighthouse/lighthouse";
import { isLighthouseConfigured } from "@/actions/lighthouse-v1/lighthouse";
import { LighthouseBannerClient } from "./banner-client";
@@ -17,7 +17,7 @@ import {
getChainOfThoughtHeaderText,
getChainOfThoughtStepLabel,
isMetaTool,
} from "@/components/lighthouse/chat-utils";
} from "@/components/lighthouse-v1/chat-utils";
interface ChainOfThoughtDisplayProps {
events: ChainOfThoughtEvent[];
@@ -10,8 +10,8 @@ import {
MESSAGE_STATUS,
META_TOOLS,
SKILL_PREFIX,
} from "@/lib/lighthouse/constants";
import type { ChainOfThoughtData, Message } from "@/lib/lighthouse/types";
} from "@/lib/lighthouse-v1/constants";
import type { ChainOfThoughtData, Message } from "@/lib/lighthouse-v1/types";
// Re-export constants for convenience
export {
@@ -5,7 +5,7 @@ import { DefaultChatTransport } from "ai";
import { Plus } from "lucide-react";
import { useRef, useState } from "react";
import { getLighthouseModelIds } from "@/actions/lighthouse/lighthouse";
import { getLighthouseModelIds } from "@/actions/lighthouse-v1/lighthouse";
import {
Conversation,
ConversationContent,
@@ -18,14 +18,14 @@ import {
PromptInputTextarea,
PromptInputToolbar,
PromptInputTools,
} from "@/components/lighthouse/ai-elements/prompt-input";
} from "@/components/ai-elements/prompt-input";
import {
ERROR_PREFIX,
MESSAGE_ROLES,
MESSAGE_STATUS,
} from "@/components/lighthouse/chat-utils";
import { Loader } from "@/components/lighthouse/loader";
import { MessageItem } from "@/components/lighthouse/message-item";
} from "@/components/lighthouse-v1/chat-utils";
import { Loader } from "@/components/lighthouse-v1/loader";
import { MessageItem } from "@/components/lighthouse-v1/message-item";
import {
Button,
Card,
@@ -38,7 +38,7 @@ import {
import { useToast } from "@/components/ui";
import { CustomLink } from "@/components/ui/custom/custom-link";
import { useMountEffect } from "@/hooks/use-mount-effect";
import type { LighthouseProvider } from "@/types/lighthouse";
import type { LighthouseProvider } from "@/types/lighthouse-v1";
interface Model {
id: string;
@@ -7,9 +7,9 @@ import {
createLighthouseProvider,
getLighthouseProviderByType,
updateLighthouseProviderByType,
} from "@/actions/lighthouse/lighthouse";
} from "@/actions/lighthouse-v1/lighthouse";
import { FormButtons } from "@/components/ui/form";
import type { LighthouseProvider } from "@/types/lighthouse";
import type { LighthouseProvider } from "@/types/lighthouse-v1";
import { getMainFields, getProviderConfig } from "./llm-provider-registry";
import {
@@ -6,11 +6,11 @@ import React, { Dispatch, SetStateAction } from "react";
import { useForm } from "react-hook-form";
import * as z from "zod";
import { deleteLighthouseProviderByType } from "@/actions/lighthouse/lighthouse";
import { deleteLighthouseProviderByType } from "@/actions/lighthouse-v1/lighthouse";
import { DeleteIcon } from "@/components/icons";
import { useToast } from "@/components/ui";
import { Form, FormButtons } from "@/components/ui/form";
import type { LighthouseProvider } from "@/types/lighthouse";
import type { LighthouseProvider } from "@/types/lighthouse-v1";
const formSchema = z.object({
providerType: z.string(),
@@ -9,7 +9,7 @@ import * as z from "zod";
import {
getTenantConfig,
updateTenantConfig,
} from "@/actions/lighthouse/lighthouse";
} from "@/actions/lighthouse-v1/lighthouse";
import { SaveIcon } from "@/components/icons";
import {
Button,
@@ -1,6 +1,6 @@
"use client";
import type { LighthouseProvider } from "@/types/lighthouse";
import type { LighthouseProvider } from "@/types/lighthouse-v1";
export type LLMProviderFieldType = "text" | "password";
@@ -4,10 +4,10 @@ import {
getLighthouseProviderByType,
refreshProviderModels,
testProviderConnection,
} from "@/actions/lighthouse/lighthouse";
} from "@/actions/lighthouse-v1/lighthouse";
import { getTask } from "@/actions/task/tasks";
import { checkTaskStatus } from "@/lib/helper";
import type { LighthouseProvider } from "@/types/lighthouse";
import type { LighthouseProvider } from "@/types/lighthouse-v1";
import { getProviderConfig } from "./llm-provider-registry";
@@ -7,7 +7,7 @@ import { useEffect, useState } from "react";
import {
getLighthouseProviders,
getTenantConfig,
} from "@/actions/lighthouse/lighthouse";
} from "@/actions/lighthouse-v1/lighthouse";
import { Button, Card, CardContent, CardHeader } from "@/components/shadcn";
import { getAllProviders } from "./llm-provider-registry";
@@ -6,16 +6,16 @@
import { Copy, RotateCcw } from "lucide-react";
import { defaultRehypePlugins, Streamdown } from "streamdown";
import { Action, Actions } from "@/components/lighthouse/ai-elements/actions";
import { ChainOfThoughtDisplay } from "@/components/lighthouse/chain-of-thought-display";
import { Action, Actions } from "@/components/ai-elements/actions";
import { ChainOfThoughtDisplay } from "@/components/lighthouse-v1/chain-of-thought-display";
import {
extractChainOfThoughtEvents,
extractMessageText,
type Message,
MESSAGE_ROLES,
MESSAGE_STATUS,
} from "@/components/lighthouse/chat-utils";
import { Loader } from "@/components/lighthouse/loader";
} from "@/components/lighthouse-v1/chat-utils";
import { Loader } from "@/components/lighthouse-v1/loader";
/**
* Escapes angle-bracket placeholders like <bucket_name> to HTML entities
@@ -7,9 +7,9 @@ import {
getLighthouseModelIds,
getTenantConfig,
updateTenantConfig,
} from "@/actions/lighthouse/lighthouse";
} from "@/actions/lighthouse-v1/lighthouse";
import { Button } from "@/components/shadcn";
import type { LighthouseProvider } from "@/types/lighthouse";
import type { LighthouseProvider } from "@/types/lighthouse-v1";
import {
getProviderIdByType,
@@ -6,7 +6,7 @@ import { usePathname, useSearchParams } from "next/navigation";
import React from "react";
import { cn } from "@/lib/utils";
import type { LighthouseProvider } from "@/types/lighthouse";
import type { LighthouseProvider } from "@/types/lighthouse-v1";
import { getProviderConfig } from "../llm-provider-registry";
@@ -0,0 +1 @@
export { LighthouseV2ChatPage } from "./lighthouse-v2-chat-page";
@@ -0,0 +1,633 @@
"use client";
import { Bot, Loader2, Send, Square, UserRound } from "lucide-react";
import { useRouter } from "next/navigation";
import { type FormEvent, useRef, useState } from "react";
import {
archiveLighthouseV2Session,
cancelLighthouseV2Run,
createLighthouseV2Session,
getLighthouseV2Messages,
getLighthouseV2Sessions,
sendLighthouseV2Message,
} from "@/actions/lighthouse-v2/lighthouse-v2";
import {
Conversation,
ConversationContent,
ConversationEmptyState,
ConversationScrollButton,
} from "@/components/ai-elements/conversation";
import { Button } from "@/components/shadcn/button/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/shadcn/select/select";
import { Textarea } from "@/components/shadcn/textarea/textarea";
import { useMountEffect } from "@/hooks/use-mount-effect";
import {
createInitialLighthouseV2StreamState,
type LighthouseV2StreamState,
reduceLighthouseV2Event,
} from "@/lib/lighthouse-v2/event-reducer";
import { cn } from "@/lib/utils";
import {
LIGHTHOUSE_V2_MESSAGE_ROLE,
LIGHTHOUSE_V2_PART_TYPE,
LIGHTHOUSE_V2_PROVIDER_TYPE,
LIGHTHOUSE_V2_SSE_EVENT,
type LighthouseV2Configuration,
type LighthouseV2Message,
type LighthouseV2ProviderType,
type LighthouseV2Session,
type LighthouseV2SSEEvent,
type LighthouseV2SupportedModel,
} from "@/types/lighthouse-v2";
import { LighthouseV2SessionHistory } from "../history";
interface LighthouseV2ChatPageProps {
configurations: LighthouseV2Configuration[];
modelsByProvider: Record<
LighthouseV2ProviderType,
LighthouseV2SupportedModel[]
>;
sessions: LighthouseV2Session[];
initialSessionId?: string;
initialMessages: LighthouseV2Message[];
initialPrompt?: string;
showHistory?: boolean;
}
export function LighthouseV2ChatPage({
configurations,
modelsByProvider,
sessions,
initialSessionId,
initialMessages,
initialPrompt,
showHistory = true,
}: LighthouseV2ChatPageProps) {
const router = useRouter();
const eventSourceRef = useRef<EventSource | null>(null);
const initialPromptSentRef = useRef(false);
const connectedConfigurations = configurations.filter(
(configuration) => configuration.connected === true,
);
const initialProvider =
connectedConfigurations[0]?.providerType ??
configurations[0]?.providerType ??
LIGHTHOUSE_V2_PROVIDER_TYPE.OPENAI;
const [selectedProvider, setSelectedProvider] =
useState<LighthouseV2ProviderType>(initialProvider);
const [selectedModel, setSelectedModel] = useState(
connectedConfigurations[0]?.defaultModel ??
modelsByProvider[initialProvider]?.[0]?.id ??
"",
);
const [localSessions, setLocalSessions] = useState(sessions);
const [activeSessionId, setActiveSessionId] = useState<string | null>(
initialSessionId ?? null,
);
const [messages, setMessages] = useState(initialMessages);
const [input, setInput] = useState("");
const [search, setSearch] = useState("");
const [feedback, setFeedback] = useState<string | null>(null);
const [blockedByConflict, setBlockedByConflict] = useState(false);
const [lastSubmittedText, setLastSubmittedText] = useState<string | null>(
null,
);
const [streamState, setStreamState] = useState<LighthouseV2StreamState>(() =>
createInitialLighthouseV2StreamState(),
);
const selectedConfiguration = configurations.find(
(configuration) => configuration.providerType === selectedProvider,
);
const providerModels = modelsByProvider[selectedProvider] ?? [];
const canSend =
selectedConfiguration?.connected === true &&
!streamState.activeTaskId &&
!blockedByConflict;
const handleProviderChange = (provider: LighthouseV2ProviderType) => {
const nextConfig = configurations.find(
(configuration) => configuration.providerType === provider,
);
setSelectedProvider(provider);
setSelectedModel(
nextConfig?.defaultModel ?? modelsByProvider[provider]?.[0]?.id ?? "",
);
};
const refreshMessages = async (sessionId: string) => {
const result = await getLighthouseV2Messages(sessionId);
if ("data" in result) {
setMessages(result.data);
}
};
const refreshSessions = async (nextSearch = search) => {
const result = await getLighthouseV2Sessions(
nextSearch ? { search: nextSearch } : undefined,
);
if ("data" in result) {
setLocalSessions(result.data);
}
};
const closeStream = () => {
eventSourceRef.current?.close();
eventSourceRef.current = null;
};
const handleTerminalEvent = async (
sessionId: string,
event: LighthouseV2SSEEvent,
) => {
if (
event.type === LIGHTHOUSE_V2_SSE_EVENT.MESSAGE_END ||
event.type === LIGHTHOUSE_V2_SSE_EVENT.RUN_CANCELLED ||
event.type === LIGHTHOUSE_V2_SSE_EVENT.ERROR
) {
closeStream();
setBlockedByConflict(false);
await refreshMessages(sessionId);
await refreshSessions();
}
};
const startStream = (streamUrl: string, sessionId: string) => {
closeStream();
const source = new EventSource(streamUrl);
eventSourceRef.current = source;
const applyEvent = (event: LighthouseV2SSEEvent) => {
setStreamState((current) => reduceLighthouseV2Event(current, event));
void handleTerminalEvent(sessionId, event);
};
source.addEventListener("message.delta", (event) =>
applyEvent(
parseStreamEvent(event, LIGHTHOUSE_V2_SSE_EVENT.MESSAGE_DELTA),
),
);
source.addEventListener("tool_call.start", (event) =>
applyEvent(
parseStreamEvent(event, LIGHTHOUSE_V2_SSE_EVENT.TOOL_CALL_START),
),
);
source.addEventListener("tool_call.end", (event) =>
applyEvent(
parseStreamEvent(event, LIGHTHOUSE_V2_SSE_EVENT.TOOL_CALL_END),
),
);
source.addEventListener("message.end", (event) =>
applyEvent(parseStreamEvent(event, LIGHTHOUSE_V2_SSE_EVENT.MESSAGE_END)),
);
source.addEventListener("run.cancelled", (event) =>
applyEvent(
parseStreamEvent(event, LIGHTHOUSE_V2_SSE_EVENT.RUN_CANCELLED),
),
);
source.addEventListener("error", (event) => {
if (event instanceof MessageEvent) {
applyEvent(parseStreamEvent(event, LIGHTHOUSE_V2_SSE_EVENT.ERROR));
}
});
source.onerror = () => {
closeStream();
setStreamState((current) =>
reduceLighthouseV2Event(current, { type: "disconnect" }),
);
void refreshMessages(sessionId);
setFeedback("Stream disconnected. Messages were refreshed.");
};
};
const ensureSession = async (text: string) => {
if (activeSessionId) {
return activeSessionId;
}
const title = buildSessionTitle(text);
const result = await createLighthouseV2Session(title);
if ("error" in result) {
setFeedback(result.error);
return null;
}
setActiveSessionId(result.data.id);
setLocalSessions((current) => [result.data, ...current]);
router.push(`/lighthouse?session=${encodeURIComponent(result.data.id)}`);
return result.data.id;
};
const submitMessage = async (text: string) => {
const trimmedText = text.trim();
if (!trimmedText || !canSend) return;
const sessionId = await ensureSession(trimmedText);
if (!sessionId) return;
setFeedback(null);
setBlockedByConflict(false);
setLastSubmittedText(trimmedText);
setInput("");
setMessages((current) => [
...current,
buildOptimisticMessage("user", trimmedText),
]);
const result = await sendLighthouseV2Message({
sessionId,
text: trimmedText,
provider: selectedProvider,
model: selectedModel || null,
});
if ("error" in result) {
setFeedback(result.error);
if (result.status === 409) {
setBlockedByConflict(true);
await refreshMessages(sessionId);
}
return;
}
setStreamState(createInitialLighthouseV2StreamState(result.data.task.id));
if (result.data.streamUrl) {
startStream(result.data.streamUrl, sessionId);
}
await refreshSessions();
};
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
void submitMessage(input);
};
const handleStop = async () => {
if (!activeSessionId || !streamState.activeTaskId) return;
const taskId = streamState.activeTaskId;
const result = await cancelLighthouseV2Run(activeSessionId, taskId);
closeStream();
setStreamState((current) =>
reduceLighthouseV2Event(current, {
type: "run.cancelled",
taskId,
}),
);
setBlockedByConflict(false);
await refreshMessages(activeSessionId);
if ("error" in result) {
setFeedback(result.error);
}
};
const handleOpenSession = async (sessionId: string) => {
closeStream();
setActiveSessionId(sessionId);
setStreamState(createInitialLighthouseV2StreamState());
setBlockedByConflict(false);
setFeedback(null);
router.push(`/lighthouse?session=${encodeURIComponent(sessionId)}`);
await refreshMessages(sessionId);
};
const handleNewSession = () => {
closeStream();
setActiveSessionId(null);
setMessages([]);
setInput("");
setFeedback(null);
setBlockedByConflict(false);
setStreamState(createInitialLighthouseV2StreamState());
router.push("/lighthouse");
};
const handleArchiveSession = async (sessionId: string) => {
const result = await archiveLighthouseV2Session(sessionId);
if ("error" in result) {
setFeedback(result.error);
return;
}
setLocalSessions((current) =>
current.filter((session) => session.id !== sessionId),
);
if (sessionId === activeSessionId) {
handleNewSession();
}
};
const handleSearchChange = (value: string) => {
setSearch(value);
void refreshSessions(value);
};
useMountEffect(() => {
if (initialPrompt && !initialPromptSentRef.current) {
initialPromptSentRef.current = true;
void submitMessage(initialPrompt);
}
});
return (
<div className="grid h-full min-h-0 gap-4 lg:grid-cols-[300px_1fr]">
{showHistory && (
<LighthouseV2SessionHistory
sessions={localSessions}
activeSessionId={activeSessionId}
search={search}
onSearchChange={handleSearchChange}
onNewSession={handleNewSession}
onOpenSession={handleOpenSession}
onArchiveSession={handleArchiveSession}
/>
)}
<section className="border-border-neutral-secondary bg-bg-neutral-secondary flex min-h-0 flex-col rounded-[8px] border">
<Conversation className="min-h-0">
<ConversationContent>
{messages.length === 0 && !streamState.assistantText ? (
<ConversationEmptyState title="Lighthouse" description="" />
) : (
<>
{messages.map((message) => (
<MessageBubble key={message.id} message={message} />
))}
{streamState.assistantText && (
<StreamingAssistantMessage streamState={streamState} />
)}
</>
)}
</ConversationContent>
<ConversationScrollButton />
</Conversation>
<div className="border-border-neutral-secondary border-t p-3">
{feedback && (
<div className="border-border-neutral-secondary mb-2 flex items-center justify-between gap-3 rounded-[8px] border px-3 py-2 text-sm">
<span>{feedback}</span>
{streamState.status === "disconnected" && lastSubmittedText && (
<Button
type="button"
variant="outline"
size="sm"
onClick={() => submitMessage(lastSubmittedText)}
>
Retry
</Button>
)}
</div>
)}
<div className="mb-2 flex flex-wrap gap-2">
<Select
value={selectedProvider}
onValueChange={(value) =>
handleProviderChange(value as LighthouseV2ProviderType)
}
>
<SelectTrigger className="h-9 w-[180px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{configurations.map((configuration) => (
<SelectItem
key={configuration.providerType}
value={configuration.providerType}
disabled={configuration.connected !== true}
>
{configuration.providerType}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={selectedModel} onValueChange={setSelectedModel}>
<SelectTrigger className="h-9 min-w-[220px]">
<SelectValue placeholder="Model" />
</SelectTrigger>
<SelectContent width="wide">
{providerModels.map((model) => (
<SelectItem key={model.id} value={model.id}>
{model.id}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<form className="flex items-end gap-2" onSubmit={handleSubmit}>
<Textarea
aria-label="Message"
value={input}
onChange={(event) => setInput(event.target.value)}
disabled={!canSend}
placeholder={
selectedConfiguration?.connected === true
? "Ask Lighthouse"
: "Connect a provider first"
}
className="max-h-40 min-h-12 flex-1"
onKeyDown={(event) => {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
void submitMessage(input);
}
}}
/>
{streamState.activeTaskId ? (
<Button type="button" variant="outline" onClick={handleStop}>
<Square />
Stop
</Button>
) : (
<Button type="submit" disabled={!canSend || !input.trim()}>
<Send />
Send
</Button>
)}
</form>
</div>
</section>
</div>
);
}
function MessageBubble({ message }: { message: LighthouseV2Message }) {
const isUser = message.role === LIGHTHOUSE_V2_MESSAGE_ROLE.USER;
return (
<article
className={cn("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",
)}
>
{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>
{isUser && (
<UserRound className="text-text-neutral-tertiary mt-1 size-5" />
)}
</article>
);
}
function StreamingAssistantMessage({
streamState,
}: {
streamState: LighthouseV2StreamState;
}) {
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>
)}
</div>
</article>
);
}
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 @@
export { LighthouseV2ConfigPage } from "./lighthouse-v2-config-page";
@@ -0,0 +1,454 @@
"use client";
import {
AlertCircle,
CheckCircle2,
Loader2,
PlugZap,
Save,
Trash2,
} from "lucide-react";
import { type ReactNode, useState } from "react";
import {
createLighthouseV2Configuration,
deleteLighthouseV2Configuration,
testLighthouseV2ConfigurationConnection,
updateLighthouseV2Configuration,
} from "@/actions/lighthouse-v2/lighthouse-v2";
import { Badge } from "@/components/shadcn/badge/badge";
import { Button } from "@/components/shadcn/button/button";
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "@/components/shadcn/card/card";
import { Input } from "@/components/shadcn/input/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/shadcn/select/select";
import { Textarea } from "@/components/shadcn/textarea/textarea";
import { cn } from "@/lib/utils";
import {
LIGHTHOUSE_V2_PROVIDER_TYPE,
type LighthouseV2Configuration,
type LighthouseV2Credentials,
type LighthouseV2ProviderType,
type LighthouseV2SupportedModel,
type LighthouseV2SupportedProvider,
} from "@/types/lighthouse-v2";
interface LighthouseV2ConfigPageProps {
configurations: LighthouseV2Configuration[];
providers: LighthouseV2SupportedProvider[];
modelsByProvider: Record<
LighthouseV2ProviderType,
LighthouseV2SupportedModel[]
>;
error?: string;
}
const PROVIDER_ACCENT_CLASS = {
openai: "border-border-neutral-secondary",
bedrock: "border-border-warning",
"openai-compatible": "border-border-info",
} as const satisfies Record<LighthouseV2ProviderType, string>;
const EMPTY_CREDENTIALS = {
apiKey: "",
awsAccessKeyId: "",
awsSecretAccessKey: "",
awsRegionName: "",
} as const;
export function LighthouseV2ConfigPage({
configurations,
providers,
modelsByProvider,
error,
}: LighthouseV2ConfigPageProps) {
const [localConfigurations, setLocalConfigurations] =
useState(configurations);
const [selectedProvider, setSelectedProvider] =
useState<LighthouseV2ProviderType>(providers[0]?.id ?? "openai");
const [credentials, setCredentials] = useState(EMPTY_CREDENTIALS);
const [baseUrl, setBaseUrl] = useState("");
const [defaultModel, setDefaultModel] = useState("");
const [businessContext, setBusinessContext] = useState(
configurations[0]?.businessContext ?? "",
);
const [saving, setSaving] = useState(false);
const [testing, setTesting] = useState(false);
const [deleting, setDeleting] = useState(false);
const [feedback, setFeedback] = useState<string | null>(error ?? null);
const selectedConfig = localConfigurations.find(
(config) => config.providerType === selectedProvider,
);
const selectedModels = modelsByProvider[selectedProvider] ?? [];
const selectedProviderName =
providers.find((provider) => provider.id === selectedProvider)?.name ??
selectedProvider;
const handleProviderSelect = (provider: LighthouseV2ProviderType) => {
const nextConfig = localConfigurations.find(
(config) => config.providerType === provider,
);
setSelectedProvider(provider);
setCredentials(EMPTY_CREDENTIALS);
setBaseUrl(nextConfig?.baseUrl ?? "");
setDefaultModel(nextConfig?.defaultModel ?? "");
setBusinessContext(nextConfig?.businessContext ?? businessContext);
setFeedback(null);
};
const handleSave = async () => {
setSaving(true);
setFeedback(null);
const credentialPayload = buildCredentialPayload(
selectedProvider,
credentials,
);
const result = selectedConfig
? await updateLighthouseV2Configuration(selectedConfig.id, {
credentials: credentialPayload,
baseUrl: baseUrl || null,
defaultModel: defaultModel || null,
businessContext,
})
: await createLighthouseV2Configuration({
providerType: selectedProvider,
credentials: credentialPayload,
baseUrl: baseUrl || null,
defaultModel: defaultModel || null,
businessContext,
});
setSaving(false);
if ("error" in result) {
setFeedback(result.error);
return;
}
setLocalConfigurations((current) => [
...current.filter((config) => config.id !== result.data.id),
result.data,
]);
setFeedback("Configuration saved.");
};
const handleTestConnection = async () => {
if (!selectedConfig) return;
setTesting(true);
setFeedback(null);
const result = await testLighthouseV2ConfigurationConnection(
selectedConfig.id,
);
setTesting(false);
setFeedback(
"error" in result
? result.error
: "Connection check started. Refresh this page after it completes.",
);
};
const handleDelete = async () => {
if (!selectedConfig) return;
setDeleting(true);
setFeedback(null);
const result = await deleteLighthouseV2Configuration(selectedConfig.id);
setDeleting(false);
if ("error" in result) {
setFeedback(result.error);
return;
}
setLocalConfigurations((current) =>
current.filter((config) => config.id !== selectedConfig.id),
);
setFeedback("Configuration removed.");
};
return (
<div className="grid h-full min-h-0 gap-4 xl:grid-cols-[360px_1fr]">
<section className="flex min-h-0 flex-col gap-3">
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-1">
{providers.map((provider) => {
const config = localConfigurations.find(
(item) => item.providerType === provider.id,
);
const active = provider.id === selectedProvider;
return (
<button
key={provider.id}
type="button"
onClick={() => handleProviderSelect(provider.id)}
className="text-left"
>
<Card
variant="inner"
className={cn(
"min-h-[116px] gap-3 rounded-[8px] transition-colors",
PROVIDER_ACCENT_CLASS[provider.id],
active && "ring-border-input-primary-press ring-1",
)}
>
<CardHeader className="mb-0 flex-row items-start justify-between gap-3">
<div>
<CardTitle className="text-base">
{provider.name}
</CardTitle>
<p className="text-text-neutral-secondary mt-2 text-xs">
{config?.defaultModel ?? "No default model"}
</p>
</div>
<ConnectionBadge connected={config?.connected ?? null} />
</CardHeader>
</Card>
</button>
);
})}
</div>
</section>
<Card variant="inner" className="min-h-0 rounded-[8px]">
<CardHeader className="mb-0">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<CardTitle>{selectedProviderName}</CardTitle>
<p className="text-text-neutral-secondary mt-2 text-sm">
{selectedConfig ? "Stored configuration" : "New configuration"}
</p>
</div>
<ConnectionBadge connected={selectedConfig?.connected ?? null} />
</div>
</CardHeader>
<CardContent className="grid gap-5">
<CredentialFields
provider={selectedProvider}
credentials={credentials}
baseUrl={baseUrl}
onCredentialsChange={setCredentials}
onBaseUrlChange={setBaseUrl}
/>
<div className="grid gap-2">
<label
className="text-sm font-medium"
htmlFor="lighthouse-v2-model"
>
Default model
</label>
<Select value={defaultModel} onValueChange={setDefaultModel}>
<SelectTrigger id="lighthouse-v2-model">
<SelectValue placeholder="Select model" />
</SelectTrigger>
<SelectContent>
{selectedModels.map((model) => (
<SelectItem key={model.id} value={model.id}>
{model.id}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<label
className="text-sm font-medium"
htmlFor="lighthouse-v2-business-context"
>
Business context
</label>
<Textarea
id="lighthouse-v2-business-context"
value={businessContext}
onChange={(event) => setBusinessContext(event.target.value)}
textareaSize="lg"
/>
</div>
{feedback && (
<div className="border-border-neutral-secondary bg-bg-neutral-secondary rounded-[8px] border px-3 py-2 text-sm">
{feedback}
</div>
)}
<div className="flex flex-wrap items-center gap-2">
<Button type="button" onClick={handleSave} disabled={saving}>
{saving ? <Loader2 className="animate-spin" /> : <Save />}
Save
</Button>
<Button
type="button"
variant="outline"
onClick={handleTestConnection}
disabled={!selectedConfig || testing}
>
{testing ? <Loader2 className="animate-spin" /> : <PlugZap />}
Test
</Button>
<Button
type="button"
variant="ghost"
onClick={handleDelete}
disabled={!selectedConfig || deleting}
>
{deleting ? <Loader2 className="animate-spin" /> : <Trash2 />}
Delete
</Button>
</div>
</CardContent>
</Card>
</div>
);
}
function CredentialFields({
provider,
credentials,
baseUrl,
onCredentialsChange,
onBaseUrlChange,
}: {
provider: LighthouseV2ProviderType;
credentials: typeof EMPTY_CREDENTIALS;
baseUrl: string;
onCredentialsChange: (credentials: typeof EMPTY_CREDENTIALS) => void;
onBaseUrlChange: (value: string) => void;
}) {
const updateCredential = (
key: keyof typeof EMPTY_CREDENTIALS,
value: string,
) => onCredentialsChange({ ...credentials, [key]: value });
return (
<div className="grid gap-4 md:grid-cols-2">
{(provider === LIGHTHOUSE_V2_PROVIDER_TYPE.OPENAI ||
provider === LIGHTHOUSE_V2_PROVIDER_TYPE.OPENAI_COMPATIBLE ||
credentials.apiKey) && (
<Field label="API key" htmlFor="lighthouse-v2-api-key">
<Input
id="lighthouse-v2-api-key"
type="password"
value={credentials.apiKey}
onChange={(event) => updateCredential("apiKey", event.target.value)}
/>
</Field>
)}
{provider === LIGHTHOUSE_V2_PROVIDER_TYPE.OPENAI_COMPATIBLE && (
<Field label="Base URL" htmlFor="lighthouse-v2-base-url">
<Input
id="lighthouse-v2-base-url"
value={baseUrl}
onChange={(event) => onBaseUrlChange(event.target.value)}
/>
</Field>
)}
{provider === LIGHTHOUSE_V2_PROVIDER_TYPE.BEDROCK && (
<>
<Field label="AWS access key ID" htmlFor="lighthouse-v2-access-key">
<Input
id="lighthouse-v2-access-key"
type="password"
value={credentials.awsAccessKeyId}
onChange={(event) =>
updateCredential("awsAccessKeyId", event.target.value)
}
/>
</Field>
<Field
label="AWS secret access key"
htmlFor="lighthouse-v2-secret-key"
>
<Input
id="lighthouse-v2-secret-key"
type="password"
value={credentials.awsSecretAccessKey}
onChange={(event) =>
updateCredential("awsSecretAccessKey", event.target.value)
}
/>
</Field>
<Field label="AWS region" htmlFor="lighthouse-v2-region">
<Input
id="lighthouse-v2-region"
value={credentials.awsRegionName}
onChange={(event) =>
updateCredential("awsRegionName", event.target.value)
}
/>
</Field>
</>
)}
</div>
);
}
function Field({
label,
htmlFor,
children,
}: {
label: string;
htmlFor: string;
children: ReactNode;
}) {
return (
<div className="grid gap-2">
<label className="text-sm font-medium" htmlFor={htmlFor}>
{label}
</label>
{children}
</div>
);
}
function ConnectionBadge({ connected }: { connected: boolean | null }) {
if (connected === true) {
return (
<Badge variant="success" className="gap-1">
<CheckCircle2 className="size-3.5" />
Connected
</Badge>
);
}
if (connected === false) {
return (
<Badge variant="destructive" className="gap-1">
<AlertCircle className="size-3.5" />
Failed
</Badge>
);
}
return <Badge variant="outline">Not tested</Badge>;
}
function buildCredentialPayload(
provider: LighthouseV2ProviderType,
credentials: typeof EMPTY_CREDENTIALS,
): LighthouseV2Credentials {
if (provider === LIGHTHOUSE_V2_PROVIDER_TYPE.BEDROCK) {
if (credentials.apiKey) {
return {
api_key: credentials.apiKey,
aws_region_name: credentials.awsRegionName,
};
}
return {
aws_access_key_id: credentials.awsAccessKeyId,
aws_secret_access_key: credentials.awsSecretAccessKey,
aws_region_name: credentials.awsRegionName,
};
}
return { api_key: credentials.apiKey };
}
@@ -0,0 +1 @@
export { LighthouseV2SessionHistory } from "./lighthouse-v2-session-history";
@@ -0,0 +1,128 @@
"use client";
import { Archive, MessageSquare, Plus } from "lucide-react";
import { Button } from "@/components/shadcn/button/button";
import { SearchInput } from "@/components/shadcn/search-input/search-input";
import { cn } from "@/lib/utils";
import type { LighthouseV2Session } from "@/types/lighthouse-v2";
interface LighthouseV2SessionHistoryProps {
sessions: LighthouseV2Session[];
activeSessionId?: string | null;
search: string;
onSearchChange: (value: string) => void;
onNewSession: () => void;
onOpenSession: (sessionId: string) => void;
onArchiveSession: (sessionId: string) => void;
compact?: boolean;
}
export function LighthouseV2SessionHistory({
sessions,
activeSessionId,
search,
onSearchChange,
onNewSession,
onOpenSession,
onArchiveSession,
compact = false,
}: LighthouseV2SessionHistoryProps) {
const groups = groupSessionsByDate(sessions);
return (
<aside className={cn("flex min-h-0 flex-col gap-3", compact && "gap-2")}>
<div className="flex items-center gap-2">
<SearchInput
aria-label="Search Lighthouse sessions"
value={search}
placeholder="Search chats"
size={compact ? "sm" : "default"}
onChange={(event) => onSearchChange(event.target.value)}
onClear={() => onSearchChange("")}
/>
<Button
type="button"
aria-label="New chat"
size={compact ? "icon-sm" : "icon"}
onClick={onNewSession}
>
<Plus />
</Button>
</div>
<div className="minimal-scrollbar min-h-0 flex-1 overflow-y-auto">
{groups.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 gap-1">
<h3 className="text-text-neutral-tertiary px-2 text-xs font-medium">
{group.label}
</h3>
{group.sessions.map((session) => (
<div
key={session.id}
className={cn(
"group flex items-center gap-1 rounded-[8px]",
activeSessionId === session.id &&
"bg-bg-neutral-tertiary",
)}
>
<button
type="button"
className="hover:bg-bg-neutral-tertiary flex min-w-0 flex-1 items-center gap-2 rounded-[8px] px-2 py-2 text-left text-sm"
onClick={() => onOpenSession(session.id)}
>
<MessageSquare className="text-text-neutral-tertiary size-4 shrink-0" />
<span className="truncate">
{session.title || "Untitled chat"}
</span>
</button>
<Button
type="button"
aria-label={`Archive ${session.title || "chat"}`}
variant="bare"
size="icon-xs"
className="mr-1 opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100"
onClick={() => onArchiveSession(session.id)}
>
<Archive />
</Button>
</div>
))}
</section>
))}
</div>
)}
</div>
</aside>
);
}
interface SessionGroup {
label: string;
sessions: LighthouseV2Session[];
}
function groupSessionsByDate(sessions: LighthouseV2Session[]): SessionGroup[] {
const formatter = new Intl.DateTimeFormat(undefined, {
month: "short",
day: "numeric",
year: "numeric",
});
const groups = new Map<string, LighthouseV2Session[]>();
sessions.forEach((session) => {
const label = formatter.format(new Date(session.updatedAt));
groups.set(label, [...(groups.get(label) ?? []), session]);
});
return Array.from(groups.entries()).map(([label, groupSessions]) => ({
label,
sessions: groupSessions,
}));
}
@@ -0,0 +1 @@
export { LighthouseV2SidebarChat } from "./lighthouse-v2-sidebar-chat";
@@ -0,0 +1,96 @@
"use client";
import { MessageSquare, Plus } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import {
archiveLighthouseV2Session,
getLighthouseV2Sessions,
} from "@/actions/lighthouse-v2/lighthouse-v2";
import { Button } from "@/components/shadcn/button/button";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/shadcn/tooltip";
import { useMountEffect } from "@/hooks/use-mount-effect";
import type { LighthouseV2Session } from "@/types/lighthouse-v2";
import { LighthouseV2SessionHistory } from "../history";
export function LighthouseV2SidebarChat({ isOpen }: { isOpen: boolean }) {
const router = useRouter();
const [sessions, setSessions] = useState<LighthouseV2Session[]>([]);
const [search, setSearch] = useState("");
const refreshSessions = async (nextSearch = search) => {
const result = await getLighthouseV2Sessions(
nextSearch ? { search: nextSearch } : undefined,
);
if ("data" in result) {
setSessions(result.data);
}
};
const handleSearchChange = (value: string) => {
setSearch(value);
void refreshSessions(value);
};
const handleNewSession = () => {
router.push("/lighthouse");
};
const handleOpenSession = (sessionId: string) => {
router.push(`/lighthouse?session=${encodeURIComponent(sessionId)}`);
};
const handleArchiveSession = async (sessionId: string) => {
const result = await archiveLighthouseV2Session(sessionId);
if ("data" in result) {
setSessions((current) =>
current.filter((session) => session.id !== sessionId),
);
}
};
useMountEffect(() => {
void refreshSessions();
});
if (!isOpen) {
return (
<div className="flex flex-col items-center gap-2 px-2 pt-4">
<Tooltip delayDuration={100}>
<TooltipTrigger asChild>
<Button
type="button"
aria-label="New chat"
size="icon"
onClick={handleNewSession}
>
<Plus />
</Button>
</TooltipTrigger>
<TooltipContent side="right">New chat</TooltipContent>
</Tooltip>
<MessageSquare className="text-text-neutral-tertiary size-5" />
</div>
);
}
return (
<div className="flex h-full min-h-0 flex-col px-2 pt-4">
<LighthouseV2SessionHistory
compact
sessions={sessions}
search={search}
onSearchChange={handleSearchChange}
onNewSession={handleNewSession}
onOpenSession={handleOpenSession}
onArchiveSession={handleArchiveSession}
/>
</div>
);
}
+124 -34
View File
@@ -1,10 +1,12 @@
"use client";
import { Divider } from "@heroui/divider";
import { Bot, LayoutDashboard } from "lucide-react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { InfoIcon, ProwlerShort } from "@/components/icons";
import { LighthouseV2SidebarChat } from "@/components/lighthouse-v2/navigation";
import { Button } from "@/components/shadcn/button/button";
import {
Tooltip,
@@ -16,6 +18,11 @@ import { CollapsibleMenu } from "@/components/ui/sidebar/collapsible-menu";
import { MenuItem } from "@/components/ui/sidebar/menu-item";
import { useAuth } from "@/hooks";
import { useRuntimeConfig } from "@/hooks/use-runtime-config";
import {
SIDEBAR_NAVIGATION_MODE,
type SidebarNavigationMode,
useSidebar,
} from "@/hooks/use-sidebar";
import { getMenuList } from "@/lib/menu-list";
import { LAUNCH_SCAN_HREF } from "@/lib/scans-navigation";
import { cn } from "@/lib/utils";
@@ -63,6 +70,9 @@ export const Menu = ({ isOpen }: { isOpen: boolean }) => {
);
const isScansPage = pathname.startsWith("/scans");
const { apiDocsUrl } = useRuntimeConfig();
const isCloudEnv = process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true";
const navigationMode = useSidebar((state) => state.navigationMode);
const setNavigationMode = useSidebar((state) => state.setNavigationMode);
const menuList = getMenuList({
pathname,
@@ -109,42 +119,54 @@ export const Menu = ({ isOpen }: { isOpen: boolean }) => {
</Tooltip>
</div>
{isCloudEnv && (
<SidebarNavigationModeToggle
isOpen={isOpen}
value={navigationMode}
onChange={setNavigationMode}
/>
)}
{/* Menu Items */}
<div className="flex-1 overflow-hidden">
<ScrollArea className="h-full [&>div>div[style]]:block!">
<nav className="mt-2 w-full lg:mt-6">
<ul className="mx-2 flex flex-col items-start gap-1 pb-4">
{filteredMenuList.map((group, groupIndex) => (
<li key={groupIndex} className="w-full">
{group.menus.map((menu, menuIndex) => (
<div key={menuIndex} className="w-full">
{menu.submenus && menu.submenus.length > 0 ? (
<CollapsibleMenu
icon={menu.icon}
label={menu.label}
submenus={menu.submenus}
defaultOpen={menu.defaultOpen}
isOpen={isOpen}
/>
) : (
<MenuItem
href={menu.href}
label={menu.label}
icon={menu.icon}
active={menu.active}
target={menu.target}
tooltip={menu.tooltip}
isOpen={isOpen}
highlight={menu.highlight}
/>
)}
</div>
))}
</li>
))}
</ul>
</nav>
</ScrollArea>
{isCloudEnv && navigationMode === SIDEBAR_NAVIGATION_MODE.CHAT ? (
<LighthouseV2SidebarChat isOpen={isOpen} />
) : (
<ScrollArea className="h-full [&>div>div[style]]:block!">
<nav className="mt-2 w-full lg:mt-6">
<ul className="mx-2 flex flex-col items-start gap-1 pb-4">
{filteredMenuList.map((group, groupIndex) => (
<li key={groupIndex} className="w-full">
{group.menus.map((menu, menuIndex) => (
<div key={menuIndex} className="w-full">
{menu.submenus && menu.submenus.length > 0 ? (
<CollapsibleMenu
icon={menu.icon}
label={menu.label}
submenus={menu.submenus}
defaultOpen={menu.defaultOpen}
isOpen={isOpen}
/>
) : (
<MenuItem
href={menu.href}
label={menu.label}
icon={menu.icon}
active={menu.active}
target={menu.target}
tooltip={menu.tooltip}
isOpen={isOpen}
highlight={menu.highlight}
/>
)}
</div>
))}
</li>
))}
</ul>
</nav>
</ScrollArea>
)}
</div>
{/* Footer */}
@@ -191,6 +213,74 @@ export const Menu = ({ isOpen }: { isOpen: boolean }) => {
);
};
function SidebarNavigationModeToggle({
isOpen,
value,
onChange,
}: {
isOpen: boolean;
value: SidebarNavigationMode;
onChange: (value: SidebarNavigationMode) => void;
}) {
const modes = [
{
value: SIDEBAR_NAVIGATION_MODE.BROWSE,
label: "Browse",
icon: LayoutDashboard,
},
{
value: SIDEBAR_NAVIGATION_MODE.CHAT,
label: "Chat",
icon: Bot,
},
] as const;
return (
<div className={cn("mt-3 shrink-0 px-2", !isOpen && "flex justify-center")}>
<div
className={cn(
"border-border-neutral-secondary bg-bg-neutral-secondary flex rounded-[8px] border p-1",
isOpen ? "w-full" : "flex-col",
)}
>
{modes.map((mode) => {
const Icon = mode.icon;
const active = value === mode.value;
const button = (
<button
key={mode.value}
type="button"
aria-label={mode.label}
className={cn(
"flex h-8 items-center justify-center rounded-[6px] px-2 text-sm transition-colors",
isOpen ? "min-w-0 flex-1 gap-2" : "w-8",
active
? "bg-bg-neutral-tertiary text-text-neutral-primary"
: "text-text-neutral-secondary hover:text-text-neutral-primary",
)}
onClick={() => onChange(mode.value)}
>
<Icon className="size-4 shrink-0" />
{isOpen && <span className="truncate">{mode.label}</span>}
</button>
);
if (isOpen) {
return button;
}
return (
<Tooltip key={mode.value} delayDuration={100}>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent side="right">{mode.label}</TooltipContent>
</Tooltip>
);
})}
</div>
</div>
);
}
function LaunchScanButtonContent({ isOpen }: { isOpen: boolean }) {
return (
<span className={cn("flex items-center", isOpen && "gap-2.5")}>
+14
View File
@@ -1,14 +1,24 @@
import { create } from "zustand";
import { createJSONStorage, persist } from "zustand/middleware";
export const SIDEBAR_NAVIGATION_MODE = {
BROWSE: "browse",
CHAT: "chat",
} as const;
export type SidebarNavigationMode =
(typeof SIDEBAR_NAVIGATION_MODE)[keyof typeof SIDEBAR_NAVIGATION_MODE];
type SidebarSettings = { disabled: boolean; isHoverOpen: boolean };
type SidebarStore = {
isOpen: boolean;
isHover: boolean;
navigationMode: SidebarNavigationMode;
settings: SidebarSettings;
toggleOpen: () => void;
setIsOpen: (isOpen: boolean) => void;
setIsHover: (isHover: boolean) => void;
setNavigationMode: (navigationMode: SidebarNavigationMode) => void;
getOpenState: () => boolean;
};
@@ -17,6 +27,7 @@ export const useSidebar = create(
(set, get) => ({
isOpen: true,
isHover: false,
navigationMode: SIDEBAR_NAVIGATION_MODE.BROWSE,
settings: { disabled: false, isHoverOpen: false },
toggleOpen: () => {
set({ isOpen: !get().isOpen });
@@ -27,6 +38,9 @@ export const useSidebar = create(
setIsHover: (isHover: boolean) => {
set({ isHover });
},
setNavigationMode: (navigationMode: SidebarNavigationMode) => {
set({ navigationMode });
},
getOpenState: () => {
const state = get();
return state.isOpen || (state.settings.isHoverOpen && state.isHover);
@@ -11,8 +11,11 @@ import {
META_TOOLS,
SKILL_PREFIX,
STREAM_MESSAGE_ID,
} from "@/lib/lighthouse/constants";
import type { ChainOfThoughtData, StreamEvent } from "@/lib/lighthouse/types";
} from "@/lib/lighthouse-v1/constants";
import type {
ChainOfThoughtData,
StreamEvent,
} from "@/lib/lighthouse-v1/types";
// Re-export for convenience
export { CHAIN_OF_THOUGHT_ACTIONS, ERROR_PREFIX, STREAM_MESSAGE_ID };
@@ -8,7 +8,7 @@ import {
captureMessage,
} from "@sentry/nextjs";
import { getAuthContext } from "@/lib/lighthouse/auth-context";
import { getAuthContext } from "@/lib/lighthouse-v1/auth-context";
import { SentryErrorSource, SentryErrorType } from "@/sentry";
/** Maximum number of retry attempts for MCP connection */
@@ -3,7 +3,7 @@
*
* {{TOOL_LISTING}} placeholder will be replaced with dynamically generated tool list
*/
import type { SkillMetadata } from "@/lib/lighthouse/skills/types";
import type { SkillMetadata } from "@/lib/lighthouse-v1/skills/types";
export const LIGHTHOUSE_SYSTEM_PROMPT_TEMPLATE = `
## Introduction
@@ -7,7 +7,7 @@ import { z } from "zod";
import {
getRegisteredSkillIds,
getSkillById,
} from "@/lib/lighthouse/skills/index";
} from "@/lib/lighthouse-v1/skills/index";
interface SkillLoadedResult {
found: true;
@@ -5,8 +5,8 @@ import { tool } from "@langchain/core/tools";
import { addBreadcrumb, captureException } from "@sentry/nextjs";
import { z } from "zod";
import { getMCPTools, isMCPAvailable } from "@/lib/lighthouse/mcp-client";
import { isAllowedTool } from "@/lib/lighthouse/workflow";
import { getMCPTools, isMCPAvailable } from "@/lib/lighthouse-v1/mcp-client";
import { isAllowedTool } from "@/lib/lighthouse-v1/workflow";
/** Input type for describe_tool */
interface DescribeToolInput {
@@ -6,7 +6,7 @@
import type {
ChainOfThoughtAction,
StreamEventType,
} from "@/lib/lighthouse/constants";
} from "@/lib/lighthouse-v1/constants";
export interface ChainOfThoughtData {
action: ChainOfThoughtAction;
@@ -6,7 +6,7 @@ import {
} from "@langchain/core/messages";
import type { UIMessage } from "ai";
import type { ModelParams } from "@/types/lighthouse";
import type { ModelParams } from "@/types/lighthouse-v1";
// https://stackoverflow.com/questions/79081298/how-to-stream-langchain-langgraphs-final-generation
/**
@@ -1,10 +1,10 @@
import type { LighthouseProvider } from "@/types/lighthouse";
import type { LighthouseProvider } from "@/types/lighthouse-v1";
import {
baseUrlSchema,
bedrockCredentialsSchema,
openAICompatibleCredentialsSchema,
openAICredentialsSchema,
} from "@/types/lighthouse/credentials";
} from "@/types/lighthouse-v1/credentials";
/**
* Validate credentials based on provider type
@@ -3,24 +3,24 @@ import { createAgent } from "langchain";
import {
getProviderCredentials,
getTenantConfig,
} from "@/actions/lighthouse/lighthouse";
import { TOOLS_UNAVAILABLE_MESSAGE } from "@/lib/lighthouse/constants";
import type { ProviderType } from "@/lib/lighthouse/llm-factory";
import { createLLM } from "@/lib/lighthouse/llm-factory";
} from "@/actions/lighthouse-v1/lighthouse";
import { TOOLS_UNAVAILABLE_MESSAGE } from "@/lib/lighthouse-v1/constants";
import type { ProviderType } from "@/lib/lighthouse-v1/llm-factory";
import { createLLM } from "@/lib/lighthouse-v1/llm-factory";
import {
getMCPTools,
initializeMCPClient,
isMCPAvailable,
} from "@/lib/lighthouse/mcp-client";
import { getAllSkillMetadata } from "@/lib/lighthouse/skills/index";
} from "@/lib/lighthouse-v1/mcp-client";
import { getAllSkillMetadata } from "@/lib/lighthouse-v1/skills/index";
import {
generateSkillCatalog,
generateUserDataSection,
LIGHTHOUSE_SYSTEM_PROMPT_TEMPLATE,
} from "@/lib/lighthouse/system-prompt";
import { loadSkill } from "@/lib/lighthouse/tools/load-skill";
import { describeTool, executeTool } from "@/lib/lighthouse/tools/meta-tool";
import { getModelParams } from "@/lib/lighthouse/utils";
} from "@/lib/lighthouse-v1/system-prompt";
import { loadSkill } from "@/lib/lighthouse-v1/tools/load-skill";
import { describeTool, executeTool } from "@/lib/lighthouse-v1/tools/meta-tool";
import { getModelParams } from "@/lib/lighthouse-v1/utils";
export interface RuntimeConfig {
model?: string;
+116
View File
@@ -0,0 +1,116 @@
import { describe, expect, it } from "vitest";
import {
createInitialLighthouseV2StreamState,
reduceLighthouseV2Event,
} from "./event-reducer";
describe("event-reducer", () => {
it("should append message deltas", () => {
// Given
const state = createInitialLighthouseV2StreamState("task-1");
// When
const next = reduceLighthouseV2Event(state, {
type: "message.delta",
content: "Hello",
});
// Then
expect(next.assistantText).toBe("Hello");
expect(next.status).toBe("streaming");
});
it("should pair tool start and end events", () => {
// Given
const state = reduceLighthouseV2Event(
createInitialLighthouseV2StreamState("task-1"),
{
type: "tool_call.start",
toolCallId: "tool-1",
toolName: "search",
},
);
// When
const next = reduceLighthouseV2Event(state, {
type: "tool_call.end",
toolCallId: "tool-1",
outcome: "success",
});
// Then
expect(next.toolCalls).toEqual([
{
id: "tool-1",
name: "search",
status: "completed",
outcome: "success",
},
]);
});
it("should mark message end as completed", () => {
// Given
const state = createInitialLighthouseV2StreamState("task-1");
// When
const next = reduceLighthouseV2Event(state, {
type: "message.end",
messageId: "message-1",
});
// Then
expect(next.status).toBe("completed");
expect(next.messageId).toBe("message-1");
expect(next.activeTaskId).toBeNull();
});
it("should mark run cancellation as terminal without error", () => {
// Given
const state = createInitialLighthouseV2StreamState("task-1");
// When
const next = reduceLighthouseV2Event(state, {
type: "run.cancelled",
taskId: "task-1",
});
// Then
expect(next.status).toBe("cancelled");
expect(next.error).toBeUndefined();
expect(next.activeTaskId).toBeNull();
});
it("should store terminal errors", () => {
// Given
const state = createInitialLighthouseV2StreamState("task-1");
// When
const next = reduceLighthouseV2Event(state, {
type: "error",
code: "llm_error",
detail: "Provider failed",
});
// Then
expect(next.status).toBe("error");
expect(next.error).toEqual({
code: "llm_error",
detail: "Provider failed",
});
expect(next.activeTaskId).toBeNull();
});
it("should mark disconnect as recoverable", () => {
// Given
const state = createInitialLighthouseV2StreamState("task-1");
// When
const next = reduceLighthouseV2Event(state, { type: "disconnect" });
// Then
expect(next.status).toBe("disconnected");
expect(next.activeTaskId).toBe("task-1");
});
});
+116
View File
@@ -0,0 +1,116 @@
import {
LIGHTHOUSE_V2_SSE_EVENT,
type LighthouseV2SSEEvent,
} from "@/types/lighthouse-v2";
export const LIGHTHOUSE_V2_STREAM_STATUS = {
IDLE: "idle",
STREAMING: "streaming",
COMPLETED: "completed",
CANCELLED: "cancelled",
ERROR: "error",
DISCONNECTED: "disconnected",
} as const;
export type LighthouseV2StreamStatus =
(typeof LIGHTHOUSE_V2_STREAM_STATUS)[keyof typeof LIGHTHOUSE_V2_STREAM_STATUS];
export interface LighthouseV2ToolCallState {
id: string;
name: string;
status: "running" | "completed";
outcome?: string;
}
export interface LighthouseV2StreamState {
status: LighthouseV2StreamStatus;
activeTaskId: string | null;
assistantText: string;
toolCalls: LighthouseV2ToolCallState[];
messageId?: string;
error?: {
code: string;
detail: string;
};
}
export function createInitialLighthouseV2StreamState(
taskId: string | null = null,
): LighthouseV2StreamState {
return {
status: taskId
? LIGHTHOUSE_V2_STREAM_STATUS.STREAMING
: LIGHTHOUSE_V2_STREAM_STATUS.IDLE,
activeTaskId: taskId,
assistantText: "",
toolCalls: [],
};
}
export function reduceLighthouseV2Event(
state: LighthouseV2StreamState,
event: LighthouseV2SSEEvent,
): LighthouseV2StreamState {
switch (event.type) {
case LIGHTHOUSE_V2_SSE_EVENT.MESSAGE_DELTA:
return {
...state,
status: LIGHTHOUSE_V2_STREAM_STATUS.STREAMING,
assistantText: `${state.assistantText}${event.content}`,
};
case LIGHTHOUSE_V2_SSE_EVENT.TOOL_CALL_START:
return {
...state,
status: LIGHTHOUSE_V2_STREAM_STATUS.STREAMING,
toolCalls: [
...state.toolCalls,
{
id: event.toolCallId,
name: event.toolName,
status: "running",
},
],
};
case LIGHTHOUSE_V2_SSE_EVENT.TOOL_CALL_END:
return {
...state,
toolCalls: state.toolCalls.map((toolCall) =>
toolCall.id === event.toolCallId
? {
...toolCall,
status: "completed",
outcome: event.outcome,
}
: toolCall,
),
};
case LIGHTHOUSE_V2_SSE_EVENT.MESSAGE_END:
return {
...state,
status: LIGHTHOUSE_V2_STREAM_STATUS.COMPLETED,
activeTaskId: null,
messageId: event.messageId,
};
case LIGHTHOUSE_V2_SSE_EVENT.RUN_CANCELLED:
return {
...state,
status: LIGHTHOUSE_V2_STREAM_STATUS.CANCELLED,
activeTaskId: null,
};
case LIGHTHOUSE_V2_SSE_EVENT.ERROR:
return {
...state,
status: LIGHTHOUSE_V2_STREAM_STATUS.ERROR,
activeTaskId: null,
error: {
code: event.code,
detail: event.detail,
},
};
case LIGHTHOUSE_V2_SSE_EVENT.DISCONNECT:
return {
...state,
status: LIGHTHOUSE_V2_STREAM_STATUS.DISCONNECTED,
};
}
}
+74
View File
@@ -0,0 +1,74 @@
export const LIGHTHOUSE_V2_PROVIDER_TYPE = {
OPENAI: "openai",
BEDROCK: "bedrock",
OPENAI_COMPATIBLE: "openai-compatible",
} as const;
export type LighthouseV2ProviderType =
(typeof LIGHTHOUSE_V2_PROVIDER_TYPE)[keyof typeof LIGHTHOUSE_V2_PROVIDER_TYPE];
export interface LighthouseV2OpenAICredentials {
api_key: string;
}
export interface LighthouseV2OpenAICompatibleCredentials {
api_key: string;
}
export interface LighthouseV2BedrockAccessKeyCredentials {
aws_access_key_id: string;
aws_secret_access_key: string;
aws_region_name: string;
}
export interface LighthouseV2BedrockApiKeyCredentials {
api_key: string;
aws_region_name: string;
}
export type LighthouseV2Credentials =
| LighthouseV2OpenAICredentials
| LighthouseV2OpenAICompatibleCredentials
| LighthouseV2BedrockAccessKeyCredentials
| LighthouseV2BedrockApiKeyCredentials;
export interface LighthouseV2Configuration {
id: string;
providerType: LighthouseV2ProviderType;
baseUrl: string | null;
defaultModel: string | null;
businessContext: string;
connected: boolean | null;
connectionLastCheckedAt: string | null;
insertedAt: string;
updatedAt: string;
}
export interface LighthouseV2ConfigurationInput {
providerType: LighthouseV2ProviderType;
credentials: LighthouseV2Credentials;
baseUrl?: string | null;
defaultModel?: string | null;
businessContext?: string;
}
export interface LighthouseV2ConfigurationUpdateInput {
credentials?: LighthouseV2Credentials;
baseUrl?: string | null;
defaultModel?: string | null;
businessContext?: string;
}
export interface LighthouseV2SupportedProvider {
id: LighthouseV2ProviderType;
name: string;
}
export interface LighthouseV2SupportedModel {
id: string;
maxInputTokens: number | null;
maxOutputTokens: number | null;
supportsFunctionCalling: boolean | null;
supportsVision: boolean | null;
supportsReasoning: boolean | null;
}
+58
View File
@@ -0,0 +1,58 @@
export const LIGHTHOUSE_V2_SSE_EVENT = {
MESSAGE_DELTA: "message.delta",
TOOL_CALL_START: "tool_call.start",
TOOL_CALL_END: "tool_call.end",
MESSAGE_END: "message.end",
RUN_CANCELLED: "run.cancelled",
ERROR: "error",
DISCONNECT: "disconnect",
} as const;
export type LighthouseV2SSEEventName =
(typeof LIGHTHOUSE_V2_SSE_EVENT)[keyof typeof LIGHTHOUSE_V2_SSE_EVENT];
export interface LighthouseV2MessageDeltaEvent {
type: typeof LIGHTHOUSE_V2_SSE_EVENT.MESSAGE_DELTA;
content: string;
}
export interface LighthouseV2ToolCallStartEvent {
type: typeof LIGHTHOUSE_V2_SSE_EVENT.TOOL_CALL_START;
toolCallId: string;
toolName: string;
}
export interface LighthouseV2ToolCallEndEvent {
type: typeof LIGHTHOUSE_V2_SSE_EVENT.TOOL_CALL_END;
toolCallId: string;
outcome: string;
}
export interface LighthouseV2MessageEndEvent {
type: typeof LIGHTHOUSE_V2_SSE_EVENT.MESSAGE_END;
messageId: string;
}
export interface LighthouseV2RunCancelledEvent {
type: typeof LIGHTHOUSE_V2_SSE_EVENT.RUN_CANCELLED;
taskId: string;
}
export interface LighthouseV2ErrorEvent {
type: typeof LIGHTHOUSE_V2_SSE_EVENT.ERROR;
code: string;
detail: string;
}
export interface LighthouseV2DisconnectEvent {
type: typeof LIGHTHOUSE_V2_SSE_EVENT.DISCONNECT;
}
export type LighthouseV2SSEEvent =
| LighthouseV2MessageDeltaEvent
| LighthouseV2ToolCallStartEvent
| LighthouseV2ToolCallEndEvent
| LighthouseV2MessageEndEvent
| LighthouseV2RunCancelledEvent
| LighthouseV2ErrorEvent
| LighthouseV2DisconnectEvent;
+3
View File
@@ -0,0 +1,3 @@
export * from "./config";
export * from "./events";
export * from "./sessions";
+78
View File
@@ -0,0 +1,78 @@
import type { LighthouseV2ProviderType } from "./config";
export const LIGHTHOUSE_V2_MESSAGE_ROLE = {
USER: "user",
ASSISTANT: "assistant",
} as const;
export type LighthouseV2MessageRole =
(typeof LIGHTHOUSE_V2_MESSAGE_ROLE)[keyof typeof LIGHTHOUSE_V2_MESSAGE_ROLE];
export const LIGHTHOUSE_V2_PART_TYPE = {
TEXT: "text",
REASONING: "reasoning",
TOOL_CALL: "tool_call",
} as const;
export type LighthouseV2PartType =
(typeof LIGHTHOUSE_V2_PART_TYPE)[keyof typeof LIGHTHOUSE_V2_PART_TYPE];
export interface LighthouseV2Session {
id: string;
title: string | null;
isArchived: boolean;
insertedAt: string;
updatedAt: string;
activeTaskId?: string | null;
}
export interface LighthouseV2Part {
id: string;
type: LighthouseV2PartType;
content: unknown;
toolCallOutcome: string | null;
insertedAt: string | null;
updatedAt: string | null;
}
export interface LighthouseV2Message {
id: string;
role: LighthouseV2MessageRole;
model: string | null;
tokenUsage: unknown;
insertedAt: string;
parts: LighthouseV2Part[];
}
export interface LighthouseV2SendMessageInput {
sessionId: string;
text: string;
provider: LighthouseV2ProviderType;
model?: string | null;
}
export const LIGHTHOUSE_V2_TASK_STATE = {
AVAILABLE: "available",
EXECUTING: "executing",
COMPLETED: "completed",
FAILED: "failed",
CANCELLED: "cancelled",
} as const;
export type LighthouseV2TaskState =
(typeof LIGHTHOUSE_V2_TASK_STATE)[keyof typeof LIGHTHOUSE_V2_TASK_STATE];
export interface LighthouseV2Task {
id: string;
name: string | null;
state: LighthouseV2TaskState | string;
insertedAt?: string;
completedAt?: string | null;
metadata?: unknown;
result?: unknown;
}
export interface LighthouseV2SendMessageResult {
task: LighthouseV2Task;
streamUrl?: string;
}