Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,5 @@ BACKEND_URL=http://localhost:8000
GUARDRAILS_URL = http://localhost:8001
GUARDRAILS_TOKEN =
NEXT_PUBLIC_GOOGLE_CLIENT_ID=your-google-client-id.apps.googleusercontent.com
NEXT_PUBLIC_APP_URL=https://your-public-app-url.example.com
WEBHOOK_SECRET=
128 changes: 128 additions & 0 deletions app/(main)/chatbot/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
/**
* Chatbot - Design the chatbot flow on a canvas; Save stores it as a
* server-side session that the test panel chats with.
*/

"use client";

import { useCallback, useEffect } from "react";
import Sidebar from "@/app/components/Sidebar";
import PageHeader from "@/app/components/PageHeader";
import { Button } from "@/app/components/ui";
import { ChatbotFlowCanvas, ChatbotTestPanel } from "@/app/components/chatbot";
import { useApp } from "@/app/lib/context/AppContext";
import { useToast } from "@/app/hooks/useToast";
import { useChatbotFlow } from "@/app/hooks/useChatbotFlow";
import { useChatbotSession } from "@/app/hooks/useChatbotSession";
import { useChatbotStore } from "@/app/lib/store/chatbot";
import { ChatbotNodeData } from "@/app/lib/types/chatbot";

function validateFlow(data: ChatbotNodeData): string | null {
if (!data.configId) return "Select a configuration and version first.";
if (!data.systemPrompt.trim()) return "Add a prompt before saving.";
return null;
}

export default function ChatbotPage() {
const { sidebarCollapsed } = useApp();
const toast = useToast();
const flow = useChatbotFlow();
const session = useChatbotSession();
const savedFlow = useChatbotStore((s) => s.savedFlow);

// Trigger persisted-state rehydration on mount.
useEffect(() => {
useChatbotStore.persist.rehydrate();
}, []);

const createSessionFor = useCallback(
async (data: ChatbotNodeData): Promise<boolean> => {
const error = validateFlow(data);
if (error) {
toast.error(error);
return false;
}
return session.startSession(data);
},
[session, toast],
);

const handleSave = async () => {
if (!flow.llmNode) return;
const data = flow.llmNode.data;
if (await createSessionFor(data)) {
flow.markSaved(data);
toast.success("Flow saved. A new chat session has started.");
}
};

const handleRestart = async () => {
if (!savedFlow) {
toast.error("Save the flow first to start a session.");
return;
}
if (await createSessionFor(savedFlow)) {
toast.success("New chat session started.");
}
};

const canEdit = flow.isDirty && !session.isCreating;

return (
<div className="w-full h-screen flex flex-col bg-bg-secondary">
<div className="flex flex-1 overflow-hidden">
<Sidebar collapsed={sidebarCollapsed} activeRoute="/chatbot" />

<div className="flex-1 flex flex-col overflow-hidden bg-bg-primary">
<PageHeader
title="Chatbot"
subtitle="Design a chatbot flow, save it and test it live"
actions={
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={flow.discard}
disabled={!canEdit}
>
Discard changes
</Button>
<Button size="sm" onClick={handleSave} disabled={!canEdit}>
{session.isCreating ? "Saving…" : "Save"}
</Button>
</div>
}
/>

<div className="flex flex-1 min-h-0">
<div className="flex-1 min-w-0 bg-bg-primary">
<ChatbotFlowCanvas
nodes={flow.nodes}
edges={flow.edges}
onNodesChange={flow.onNodesChange}
onEdgesChange={flow.onEdgesChange}
onConnect={flow.onConnect}
updateNodeData={flow.updateNodeData}
/>
</div>

<aside className="w-[380px] shrink-0 border-l border-border flex flex-col min-h-0 bg-bg-primary">
<ChatbotTestPanel
hasSession={!!session.sessionId}
sessionName={session.sessionName}
status={session.status}
completionStatus={session.completionStatus}
messages={session.messages}
isPending={session.isPending}
isRestarting={session.isCreating}
isDirty={flow.isDirty}
onSend={session.send}
onRestart={handleRestart}
/>
</aside>
</div>
</div>
</div>
</div>
);
}
48 changes: 48 additions & 0 deletions app/api/chatbot/sessions/[session_id]/message/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { NextRequest, NextResponse } from "next/server";
import { proxyErrorResponse, proxyJsonResponse } from "@/app/api/_routeProxy";
import { buildWebhookCallbackUrl } from "@/app/lib/webhookCallback";

/**
* Queues one chatbot turn. The browser only sends `message`; the webhook URL
* (with its secret) is added here so it never reaches the client.
*/
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ session_id: string }> },
) {
const { session_id } = await params;

let webhookUrl: string;
try {
webhookUrl = buildWebhookCallbackUrl(request, "/api/webhooks/chatbot");
} catch (error) {
return NextResponse.json(
{
success: false,
error: `Webhook is not configured: ${
error instanceof Error ? error.message : String(error)
}`,
},
{ status: 500 },
);
}

try {
const body = (await request.json().catch(() => ({}))) as {
message?: string;
};
return await proxyJsonResponse(
request,
`/api/v1/chatbot/sessions/${encodeURIComponent(session_id)}/message`,
{
method: "POST",
body: JSON.stringify({
...(body.message ? { message: body.message } : {}),
webhook_url: webhookUrl,
}),
},
);
} catch (error) {
return proxyErrorResponse("chatbot send-message error:", error);
}
}
31 changes: 31 additions & 0 deletions app/api/chatbot/sessions/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { NextRequest } from "next/server";
import {
proxyErrorResponse,
proxyJsonResponse,
withQueryParams,
} from "@/app/api/_routeProxy";

/** Creates a chatbot session (stores the prompt + config pin server-side). */
export async function POST(request: NextRequest) {
try {
const body = await request.json();
return await proxyJsonResponse(request, "/api/v1/chatbot/sessions", {
method: "POST",
body: JSON.stringify(body),
});
} catch (error) {
return proxyErrorResponse("chatbot create-session error:", error);
}
}

/** Latest session for a `contact_ref`. */
export async function GET(request: NextRequest) {
try {
return await proxyJsonResponse(
request,
withQueryParams("/api/v1/chatbot/sessions", request.nextUrl.searchParams),
);
} catch (error) {
return proxyErrorResponse("chatbot get-session error:", error);
}
}
26 changes: 5 additions & 21 deletions app/api/evaluations/[id]/improve-prompt/route.ts
Original file line number Diff line number Diff line change
@@ -1,28 +1,9 @@
import { NextRequest, NextResponse } from "next/server";
import { apiClient } from "@/app/lib/apiClient";
import { markJobPending } from "@/app/lib/store/promptImprovementStore";
import { readWebhookSecret } from "@/app/lib/webhookSecret";
import { buildWebhookCallbackUrl } from "@/app/lib/webhookCallback";
import type { LLMJobImmediatePublic } from "@/app/lib/types/promptImprovement";

function resolveCallbackUrl(request: NextRequest): string {
const secretResult = readWebhookSecret();
if (!secretResult.ok || !secretResult.secret) {
throw new Error(
secretResult.reason ?? "prompt_improvement_webhook_secret_missing",
);
}
const configured =
process.env.NEXT_PUBLIC_APP_URL ?? process.env.APP_URL ?? null;
const base = configured
? configured.replace(/\/$/, "")
: `${request.headers.get("x-forwarded-proto") ?? "https"}://${
request.headers.get("x-forwarded-host") ??
request.headers.get("host") ??
request.nextUrl.host
}`;
return `${base}/api/webhooks/prompt-improvement?secret_value=${encodeURIComponent(secretResult.secret)}`;
}

export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> },
Expand All @@ -31,7 +12,10 @@ export async function POST(
const { id } = await params;

const payload = {
callback_url: resolveCallbackUrl(request),
callback_url: buildWebhookCallbackUrl(
request,
"/api/webhooks/prompt-improvement",
),
};

const { status, data } = await apiClient(
Expand Down
56 changes: 56 additions & 0 deletions app/api/webhooks/chatbot/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { NextRequest, NextResponse } from "next/server";
import { saveSessionEvent } from "@/app/lib/store/chatbotSessionStore";
import { isWebhookAuthorized } from "@/app/lib/webhookCallback";
import type {
ChatbotSessionEvent,
ChatbotWebhookPayload,
} from "@/app/lib/types/chatbot";

function toSessionEvent(
body: ChatbotWebhookPayload,
): Omit<ChatbotSessionEvent, "seq"> | null {
const data = body.data;
if (!data?.session_id) return null;
return {
sessionId: data.session_id,
success: body.success === true,
reply: data.reply ?? null,
status: data.status ?? null,
completionStatus: data.completion_status ?? null,
collectedFields: data.collected_fields ?? null,
error: body.error ?? null,
};
}

/** Receives a completed chatbot turn from Kaapi and hands it to open streams. */
export async function POST(request: NextRequest) {
try {
if (!isWebhookAuthorized(request)) {
return NextResponse.json(
{ success: false, error: "unauthorized" },
{ status: 401 },
);
}
const event = toSessionEvent(
(await request.json()) as ChatbotWebhookPayload,
);
if (!event) {
return NextResponse.json(
{ success: false, error: "invalid_payload" },
{ status: 400 },
);
}
saveSessionEvent(event);
return NextResponse.json({ success: true, data: { received: true } });
} catch (error) {
console.error("chatbot webhook error:", error);
return NextResponse.json(
{
success: false,
error:
error instanceof Error ? error.message : "webhook_processing_failed",
},
{ status: 500 },
);
}
}
82 changes: 82 additions & 0 deletions app/api/webhooks/chatbot/stream/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { NextRequest } from "next/server";
import {
getSessionEventsAfter,
subscribeSessionEvents,
} from "@/app/lib/store/chatbotSessionStore";
import type { ChatbotSessionEvent } from "@/app/lib/types/chatbot";

export const runtime = "nodejs";
export const dynamic = "force-dynamic";

const HEARTBEAT_MS = 20_000;

function parseSeq(value: string | null): number {
const n = Number(value);
return Number.isFinite(n) && n > 0 ? n : 0;
}

/**
* SSE stream of webhook replies for one chatbot session. Each event carries
* its `seq` as the SSE id, so a reconnecting EventSource resumes via
* `Last-Event-ID`; `after` lets a fresh page load skip replies it already has.
*/
export async function GET(request: NextRequest) {
const sessionId = request.nextUrl.searchParams.get("session_id");
if (!sessionId) {
return new Response("session_id_required", { status: 400 });
}
const afterSeq = Math.max(
parseSeq(request.nextUrl.searchParams.get("after")),
parseSeq(request.headers.get("last-event-id")),
);

const encoder = new TextEncoder();
const stream = new ReadableStream({
start(controller) {
const push = (event: ChatbotSessionEvent) => {
try {
controller.enqueue(
encoder.encode(
`id: ${event.seq}\nevent: reply\ndata: ${JSON.stringify(event)}\n\n`,
),
);
} catch {
// stream closed
}
};

// Subscribe before replaying so nothing slips in between.
const unsubscribe = subscribeSessionEvents(sessionId, push);
getSessionEventsAfter(sessionId, afterSeq).forEach(push);

const heartbeat = setInterval(() => {
try {
controller.enqueue(encoder.encode(`: ping\n\n`));
} catch {
finish();
}
}, HEARTBEAT_MS);

function finish() {
clearInterval(heartbeat);
unsubscribe();
try {
controller.close();
} catch {
// already closed
}
}

request.signal.addEventListener("abort", finish);
},
});

return new Response(stream, {
headers: {
"Content-Type": "text/event-stream; charset=utf-8",
"Cache-Control": "no-cache, no-transform",
Connection: "keep-alive",
"X-Accel-Buffering": "no",
},
});
}
Loading
Loading