mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-07-24 21:11:53 +00:00
feat(ui): connect Lighthouse v2 to Cloud backend
This commit is contained in:
@@ -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)
|
||||
);
|
||||
}
|
||||
@@ -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 />
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
+1
-1
@@ -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;
|
||||
+2
-2
@@ -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 {
|
||||
+2
-2
@@ -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(),
|
||||
+1
-1
@@ -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
-1
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import type { LighthouseProvider } from "@/types/lighthouse";
|
||||
import type { LighthouseProvider } from "@/types/lighthouse-v1";
|
||||
|
||||
export type LLMProviderFieldType = "text" | "password";
|
||||
|
||||
+2
-2
@@ -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";
|
||||
|
||||
+1
-1
@@ -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";
|
||||
+4
-4
@@ -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
|
||||
+2
-2
@@ -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,
|
||||
+1
-1
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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")}>
|
||||
|
||||
@@ -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;
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from "./config";
|
||||
export * from "./events";
|
||||
export * from "./sessions";
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user