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=
78 changes: 78 additions & 0 deletions app/(main)/chatbot/chat/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
"use client";

import Sidebar from "@/app/components/Sidebar";
import PageHeader from "@/app/components/PageHeader";
import { Button } from "@/app/components/ui";
import { RefreshIcon } from "@/app/components/icons";
import {
ChatbotBreadcrumb,
ChatbotChatWindow,
ChatbotStatusBadge,
} from "@/app/components/chatbot";
import { useApp } from "@/app/lib/context/AppContext";
import { useToast } from "@/app/hooks/useToast";
import { useChatbotSession } from "@/app/hooks/useChatbotSession";
import { useChatbotStore } from "@/app/lib/store/chatbot";

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

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

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
actions={
session.sessionId ? (
<div className="flex items-center gap-2">
<ChatbotStatusBadge
status={session.status}
completionStatus={session.completionStatus}
/>
<Button
variant="ghost"
size="icon"
onClick={handleRestart}
disabled={session.isCreating || session.isPending}
title="Start a new session with the saved flow"
aria-label="Restart session"
>
<RefreshIcon className="w-4 h-4" />
</Button>
</div>
) : null
}
>
<ChatbotBreadcrumb
name={session.sessionName}
sessionId={session.sessionId}
/>
</PageHeader>

<ChatbotChatWindow
hasSession={!!session.sessionId}
status={session.status}
messages={session.messages}
isPending={session.isPending}
onSend={session.send}
/>
</div>
</div>
</div>
);
}
114 changes: 114 additions & 0 deletions app/(main)/chatbot/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
"use client";

import { useCallback } from "react";
import Sidebar from "@/app/components/Sidebar";
import PageHeader from "@/app/components/PageHeader";
import { Button } from "@/app/components/ui";
import { PlayIcon } from "@/app/components/icons";
import { ChatbotFlowCanvas } 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 { 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();
// The canvas only creates sessions; chatting happens on /chatbot/chat.
const session = useChatbotSession({ listen: false });

const createSessionFor = useCallback(
async (data: ChatbotNodeData): Promise<boolean> => {
const error = validateFlow(data);
if (error) {
toast.error(error);
return false;
}
return (await session.startSession(data)) !== null;
},
[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.");
}
};

// The chat runs on the saved session, so unsaved edits wouldn't be tested.
// It opens in a new tab so the canvas stays as it is.
const handlePlay = () => {
if (flow.isDirty) {
toast.error("Save your changes before testing the chatbot.");
return;
}
if (!session.sessionId) {
toast.error("Save the flow first to start a chat session.");
return;
}
window.open("/chatbot/chat", "_blank", "noopener,noreferrer");
};

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="relative flex-1 min-h-0 bg-bg-primary">
<ChatbotFlowCanvas
nodes={flow.nodes}
edges={flow.edges}
onNodesChange={flow.onNodesChange}
onEdgesChange={flow.onEdgesChange}
onConnect={flow.onConnect}
updateNodeData={flow.updateNodeData}
/>
<Button
size="icon"
onClick={handlePlay}
title="Test the chatbot"
aria-label="Test the chatbot"
className="absolute top-4 left-4 z-10 w-11 h-11 shadow-md"
>
<PlayIcon className="w-5 h-5 ml-0.5" />
</Button>
</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 },
);
}
}
Loading
Loading