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
WEBHOOK_URL=https://your-public-app-url.example.com
WEBHOOK_SECRET=
72 changes: 72 additions & 0 deletions app/(main)/chatbot/[chatbotId]/chat/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
"use client";

import { useParams } from "next/navigation";
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 type { BreadcrumbItem } from "@/app/components/chatbot/ChatbotBreadcrumb";
import { useApp } from "@/app/lib/context/AppContext";
import { useChatbot } from "@/app/hooks/chatbot/useChatbot";
import { useChatbotSession } from "@/app/hooks/chatbot/useChatbotSession";

export default function ChatbotChatPage() {
const { chatbotId } = useParams<{ chatbotId: string }>();
const { sidebarCollapsed } = useApp();
const { chatbot } = useChatbot(chatbotId);
const session = useChatbotSession(chatbotId);

const breadcrumb: BreadcrumbItem[] = [
{ label: chatbot?.name ?? "…", href: `/chatbot/${chatbotId}` },
];
if (session.sessionId)
breadcrumb.push({ label: session.sessionId, muted: true });

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={session.startChat}
disabled={session.isStarting || session.isPending}
title="Start a new session"
aria-label="Restart session"
>
<RefreshIcon className="w-4 h-4" />
</Button>
</div>
) : null
}
>
<ChatbotBreadcrumb items={breadcrumb} />
</PageHeader>

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

import { useParams } from "next/navigation";
import Sidebar from "@/app/components/Sidebar";
import PageHeader from "@/app/components/PageHeader";
import { Button, Loader } from "@/app/components/ui";
import { PlayIcon } from "@/app/components/icons";
import {
ChatbotBreadcrumb,
ChatbotFlowCanvas,
ChatbotVersionPill,
} from "@/app/components/chatbot";
import { useApp } from "@/app/lib/context/AppContext";
import { useToast } from "@/app/hooks/useToast";
import { useChatbot } from "@/app/hooks/chatbot/useChatbot";
import { useChatbotFlow } from "@/app/hooks/chatbot/useChatbotFlow";

export default function ChatbotEditorPage() {

Check warning on line 18 in app/(main)/chatbot/[chatbotId]/page.tsx

View workflow job for this annotation

GitHub Actions / lint-and-build

Function 'ChatbotEditorPage' has a complexity of 13. Maximum allowed is 10
const { chatbotId } = useParams<{ chatbotId: string }>();
const { sidebarCollapsed } = useApp();
const toast = useToast();
const { chatbot } = useChatbot(chatbotId);
const flow = useChatbotFlow(chatbotId);

const isBusy = flow.isSaving || flow.isPublishing;
const { flowVersion } = flow;

const handlePlay = () => {
if (!flowVersion) {
toast.error("Save the flow first to test the chatbot.");
return;
}
window.open(`/chatbot/${chatbotId}/chat`, "_blank", "noopener,noreferrer");
};

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={
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={flow.discard}
disabled={!flow.isDirty || isBusy}
>
Discard changes
</Button>
<Button
variant="outline"
size="sm"
onClick={flow.save}
disabled={!flow.isDirty || isBusy}
>
{flow.isSaving ? "Saving…" : "Save"}
</Button>
{flowVersion && !flowVersion.isLive && (
<Button
size="sm"
onClick={flow.publish}
disabled={flow.isDirty || isBusy}
title={flow.isDirty ? "Save your changes first" : undefined}
>
{flow.isPublishing ? "Publishing…" : "Publish"}
</Button>
)}
</div>
}
>
<div className="flex items-center gap-3 min-w-0">
<ChatbotBreadcrumb items={[{ label: chatbot?.name ?? "…" }]} />
<ChatbotVersionPill version={flowVersion} />
</div>
</PageHeader>

<div className="relative flex-1 min-h-0 bg-bg-primary">
{flow.isLoading ? (
<div className="h-full flex items-center justify-center">
<Loader message="Loading flow..." />
</div>
) : (
<>
<ChatbotFlowCanvas
nodes={flow.nodes}
edges={flow.edges}
onNodesChange={flow.onNodesChange}
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>
);
}
110 changes: 110 additions & 0 deletions app/(main)/chatbot/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
"use client";

import { useState } from "react";
import { useRouter } from "next/navigation";
import Sidebar from "@/app/components/Sidebar";
import PageHeader from "@/app/components/PageHeader";
import { Button, Loader } from "@/app/components/ui";
import { PlusIcon, SearchIcon } from "@/app/components/icons";
import { ChatbotList, CreateChatbotModal } from "@/app/components/chatbot";
import { useApp } from "@/app/lib/context/AppContext";
import { useToast } from "@/app/hooks/useToast";
import { useChatbots } from "@/app/hooks/chatbot/useChatbots";

export default function ChatbotsPage() {
const { sidebarCollapsed } = useApp();
const router = useRouter();
const toast = useToast();
const { chatbots, isLoading, error, create } = useChatbots();
const [search, setSearch] = useState("");
const [showCreate, setShowCreate] = useState(false);

const query = search.trim().toLowerCase();
const filtered = chatbots.filter((c) => c.name.toLowerCase().includes(query));

const handleCreate = async (name: string) => {
try {
const chatbot = await create(name);
router.push(`/chatbot/${chatbot.id}`);
} catch (err) {
toast.error(
err instanceof Error ? err.message : "Failed to create the chatbot.",
);
}
};

const renderContent = () => {
if (isLoading) {
return (
<div className="flex-1 flex items-center justify-center">
<Loader message="Loading chatbots..." />
</div>
);
}
if (error) {
return <p className="text-sm text-status-error-text">{error}</p>;
}
if (chatbots.length === 0) {
return (
<div className="py-16 text-center">
<p className="text-sm text-text-secondary">No chatbots yet.</p>
<Button
size="sm"
className="mt-3"
onClick={() => setShowCreate(true)}
>
Create your first chatbot
</Button>
</div>
);
}
if (filtered.length === 0) {
return (
<p className="py-16 text-center text-sm text-text-secondary">
No chatbots match &quot;{search}&quot;.
</p>
);
}
return <ChatbotList chatbots={filtered} />;
};

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="Chatbots"
subtitle="Build chatbot flows and test them live"
actions={
<Button size="sm" onClick={() => setShowCreate(true)}>
<PlusIcon className="w-3.5 h-3.5" />
Add New
</Button>
}
/>

<div className="flex-1 overflow-y-auto p-6 flex flex-col gap-4">
<div className="relative max-w-xs">
<SearchIcon className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-text-secondary" />
<input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search..."
className="w-full pl-9 pr-3 py-2 rounded-lg border border-border text-sm bg-bg-primary text-text-primary focus:outline-none focus:border-accent-primary"
/>
</div>
{renderContent()}
</div>
</div>
</div>

<CreateChatbotModal
open={showCreate}
onClose={() => setShowCreate(false)}
onCreate={handleCreate}
/>
</div>
);
}
49 changes: 49 additions & 0 deletions app/api/_sse.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import "server-only";

export type SseSend = (event: string, data: unknown, id: number) => void;

const PING_EVERY_MS = 20_000;

const SSE_HEADERS = {
"Content-Type": "text/event-stream; charset=utf-8",
"Cache-Control": "no-cache, no-transform",
Connection: "keep-alive",
"X-Accel-Buffering": "no",
};

/**
* Opens a long-lived SSE response. `subscribe` starts sending messages and
* returns a cleanup, which runs when the browser disconnects.
*/
export function createSseResponse(
request: Request,
subscribe: (send: SseSend) => () => void,
): Response {
const encoder = new TextEncoder();

const stream = new ReadableStream({
start(controller) {
const write = (text: string) => {
try {
controller.enqueue(encoder.encode(text));
} catch {
// Stream already closed.
}
};

const unsubscribe = subscribe((event, data, id) =>
write(`id: ${id}\nevent: ${event}\ndata: ${JSON.stringify(data)}\n\n`),
);
const ping = setInterval(() => write(": ping\n\n"), PING_EVERY_MS);

request.signal.addEventListener("abort", () => {
clearInterval(ping);
unsubscribe();
});
},
});

return new Response(stream, {
headers: SSE_HEADERS,
});
}
17 changes: 17 additions & 0 deletions app/api/chatbots/[chatbot_id]/flow/publish/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { proxyErrorResponse, proxyJsonResponse } from "@/app/api/_routeProxy";

export async function POST(
request: Request,
{ params }: { params: Promise<{ chatbot_id: string }> },
) {
try {
const { chatbot_id } = await params;
return await proxyJsonResponse(
request,
`/api/v1/chatbots/${encodeURIComponent(chatbot_id)}/flow/publish`,
{ method: "POST" },
);
} catch (error) {
return proxyErrorResponse("chatbot flow publish error:", error);
}
}
28 changes: 28 additions & 0 deletions app/api/chatbots/[chatbot_id]/flow/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { proxyErrorResponse, proxyJsonResponse } from "@/app/api/_routeProxy";

type Params = { params: Promise<{ chatbot_id: string }> };

const flowPath = (chatbotId: string) =>
`/api/v1/chatbots/${encodeURIComponent(chatbotId)}/flow`;

export async function GET(request: Request, { params }: Params) {
try {
const { chatbot_id } = await params;
return await proxyJsonResponse(request, flowPath(chatbot_id));
} catch (error) {
return proxyErrorResponse("chatbot flow get error:", error);
}
}

export async function PUT(request: Request, { params }: Params) {
try {
const { chatbot_id } = await params;
const body = await request.json();
return await proxyJsonResponse(request, flowPath(chatbot_id), {
method: "PUT",
body: JSON.stringify(body),
});
} catch (error) {
return proxyErrorResponse("chatbot flow save error:", error);
}
}
Loading
Loading