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
74 changes: 74 additions & 0 deletions app/(main)/chatbot/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
/**
* Chatbot - Build a chatbot flow on a canvas and test each step live.
*/

"use client";

import Sidebar from "@/app/components/Sidebar";
import PageHeader from "@/app/components/PageHeader";
import { Button } from "@/app/components/ui";
import { PlusIcon } from "@/app/components/icons";
import { ChatbotFlowCanvas, ChatbotTestPanel } from "@/app/components/chatbot";
import { useApp } from "@/app/lib/context/AppContext";
import { useChatbotFlow } from "@/app/hooks/useChatbotFlow";

export default function ChatbotPage() {
const { sidebarCollapsed } = useApp();
const flow = useChatbotFlow();
const node = flow.selectedNode;

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 and test each step before saving"
actions={
<Button variant="outline" size="sm" onClick={flow.addNode}>
<PlusIcon className="w-3.5 h-3.5" />
Add step
</Button>
}
/>

<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}
onSelectNode={flow.setSelectedId}
updateNodeData={flow.updateNodeData}
/>
</div>

<aside className="w-[380px] shrink-0 border-l border-border flex flex-col min-h-0 bg-bg-primary">
{node ? (
<>
<div className="px-4 py-3 border-b border-border">
<p className="text-sm font-semibold text-text-primary">
Test: {node.data.label || "Untitled step"}
</p>
<p className="text-xs text-text-secondary">
Click a step on the canvas to test it.
</p>
</div>
<ChatbotTestPanel key={node.id} data={node.data} />
</>
) : (
<div className="flex-1 flex items-center justify-center p-6 text-center text-sm text-text-secondary">
Add a step to start testing.
</div>
)}
</aside>
</div>
</div>
</div>
</div>
);
}
23 changes: 23 additions & 0 deletions app/api/chatbot/message/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { NextResponse } from "next/server";
import { apiClient } from "@/app/lib/apiClient";

export async function POST(request: Request) {
try {
const body = await request.json();
const { status, data } = await apiClient(
request,
"/api/v1/chatbot/message",
{ method: "POST", body: JSON.stringify(body) },
);
return NextResponse.json(data, { status });
} catch (error) {
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : String(error),
data: null,
},
{ status: 500 },
);
}
}
8 changes: 3 additions & 5 deletions app/components/Sidebar.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,5 @@
/**
* Sidebar - Navigation sidebar with collapse/expand functionality
* Provides hierarchical navigation with expandable submenus
*/

"use client";

import React, { useState, useEffect, useRef, useCallback } from "react";
import { useRouter } from "next/navigation";
import Image from "next/image";
Expand All @@ -20,6 +16,7 @@ import {
ShieldCheckIcon,
SlidersIcon,
ChatIcon,
FlowIcon,
ChartBarIcon,
WarningTriangleIcon,
} from "@/app/components/icons";
Expand Down Expand Up @@ -122,6 +119,7 @@ export default function Sidebar({

const iconMap: Record<string, React.ReactNode> = {
chat: <ChatIcon />,
flow: <FlowIcon />,
clipboard: <ClipboardIcon />,
document: <DocumentFileIcon />,
book: <BookOpenIcon />,
Expand Down
69 changes: 69 additions & 0 deletions app/components/chatbot/ChatbotFlowCanvas.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
"use client";

import { useMemo } from "react";
import {
Background,
BackgroundVariant,
Connection,
Controls,
Edge,
EdgeChange,
NodeChange,
ReactFlow,
} from "@xyflow/react";
import "@xyflow/react/dist/style.css";
import LlmNode from "@/app/components/chatbot/LlmNode";
import TerminalNode from "@/app/components/chatbot/TerminalNode";
import { ChatbotFlowContext } from "@/app/components/chatbot/ChatbotFlowContext";
import { ChatbotFlowNode, ChatbotNodeData } from "@/app/lib/types/chatbot";

const NODE_TYPES = {
llm: LlmNode,
flowInput: TerminalNode,
flowOutput: TerminalNode,
};

const DEFAULT_EDGE_OPTIONS = { style: { stroke: "#a3a3a3", strokeWidth: 1 } };

interface ChatbotFlowCanvasProps {
nodes: ChatbotFlowNode[];
edges: Edge[];
onNodesChange: (changes: NodeChange<ChatbotFlowNode>[]) => void;
onEdgesChange: (changes: EdgeChange<Edge>[]) => void;
onConnect: (connection: Connection) => void;
onSelectNode: (id: string) => void;
updateNodeData: (id: string, patch: Partial<ChatbotNodeData>) => void;
}

export default function ChatbotFlowCanvas({
nodes,
edges,
onNodesChange,
onEdgesChange,
onConnect,
onSelectNode,
updateNodeData,
}: ChatbotFlowCanvasProps) {
const contextValue = useMemo(() => ({ updateNodeData }), [updateNodeData]);

return (
<ChatbotFlowContext.Provider value={contextValue}>
<ReactFlow
nodes={nodes}
edges={edges}
nodeTypes={NODE_TYPES}
defaultEdgeOptions={DEFAULT_EDGE_OPTIONS}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onConnect={onConnect}
onNodeClick={(_, node) => onSelectNode(node.id)}
fitView
fitViewOptions={{ maxZoom: 1, padding: 0.2 }}
proOptions={{ hideAttribution: true }}
>
<Background variant={BackgroundVariant.Dots} gap={16} size={1.5} />
<Controls />
</ReactFlow>
</ChatbotFlowContext.Provider>
);
}
23 changes: 23 additions & 0 deletions app/components/chatbot/ChatbotFlowContext.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
"use client";

import { createContext, useContext } from "react";
import { ChatbotNodeData } from "@/app/lib/types/chatbot";

interface ChatbotFlowContextValue {
updateNodeData: (id: string, patch: Partial<ChatbotNodeData>) => void;
}

export const ChatbotFlowContext = createContext<ChatbotFlowContextValue | null>(
null,
);

/** Lets custom nodes edit their own data inline on the canvas. */
export function useChatbotFlowContext(): ChatbotFlowContextValue {
const ctx = useContext(ChatbotFlowContext);
if (!ctx) {
throw new Error(
"useChatbotFlowContext must be used inside ChatbotFlowContext.Provider",
);
}
return ctx;
}
119 changes: 119 additions & 0 deletions app/components/chatbot/ChatbotTestPanel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
/**
* ChatbotTestPanel - Runs a throwaway test conversation against the selected
* node's current (unsaved) prompt and config.
*/

"use client";

import { FormEvent, useEffect, useRef, useState } from "react";
import { Button } from "@/app/components/ui";
import { MarkdownContent } from "@/app/components/chat";
import { RefreshIcon, SendIcon } from "@/app/components/icons";
import { useChatbotTest } from "@/app/hooks/useChatbotTest";
import { ChatbotMessage, ChatbotNodeData } from "@/app/lib/types/chatbot";

interface ChatbotTestPanelProps {
data: ChatbotNodeData;
}

function MessageBubble({ message }: { message: ChatbotMessage }) {
const isUser = message.role === "user";
return (
<div className={`flex ${isUser ? "justify-end" : "justify-start"}`}>
<div
className={`max-w-[85%] rounded-2xl px-3 py-2 text-sm ${
isUser
? "bg-accent-primary text-white whitespace-pre-wrap"
: "bg-bg-secondary text-text-primary"
}`}
>
{isUser ? message.content : <MarkdownContent text={message.content} />}
</div>
</div>
);
}

export default function ChatbotTestPanel({ data }: ChatbotTestPanelProps) {
const { history, pendingMessage, isPending, canSend, send, reset } =
useChatbotTest(data);
const [input, setInput] = useState("");
const bottomRef = useRef<HTMLDivElement>(null);

const visible = history.filter((m) => m.role !== "system");
const hasStarted = history.length > 0;

useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: "smooth", block: "end" });
}, [visible.length, pendingMessage, isPending]);

const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
const text = input.trim();
if (!text || isPending) return;
setInput("");
const ok = await send(text);
if (!ok) setInput(text);
};

return (
<div className="flex flex-col flex-1 min-h-0">
<div className="flex-1 overflow-y-auto p-4 space-y-3">
{!hasStarted && !isPending && (
<div className="h-full flex flex-col items-center justify-center text-center gap-3">
<p className="text-sm text-text-secondary max-w-[260px]">
{canSend
? "Start a test - the bot will send the first message."
: "Pick a configuration and write a system prompt to start testing."}
</p>
<Button size="sm" onClick={() => send()} disabled={!canSend}>
Start test
</Button>
</div>
)}
{visible.map((m, i) => (
<MessageBubble key={i} message={m} />
))}
{pendingMessage && (
<MessageBubble message={{ role: "user", content: pendingMessage }} />
)}
{isPending && (
<p className="text-xs text-text-secondary animate-pulse">
Bot is typing…
</p>
)}
<div ref={bottomRef} />
</div>

{hasStarted && (
<form
onSubmit={handleSubmit}
className="border-t border-border p-3 flex items-center gap-2"
>
<button
type="button"
onClick={reset}
title="Restart test"
className="p-2 rounded-full text-text-secondary hover:bg-neutral-100 hover:text-text-primary cursor-pointer"
>
<RefreshIcon className="w-4 h-4" />
</button>
<input
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="Reply as the user…"
disabled={isPending}
className="flex-1 px-3 py-2 rounded-full border border-border text-sm bg-bg-primary text-text-primary focus:outline-none focus:border-accent-primary disabled:opacity-60"
/>
<button
type="submit"
disabled={isPending || !input.trim()}
title="Send"
className="p-2 rounded-full bg-accent-primary text-white hover:bg-accent-hover disabled:bg-neutral-200 disabled:text-text-secondary cursor-pointer disabled:cursor-not-allowed"
>
<SendIcon className="w-4 h-4" />
</button>
</form>
)}
</div>
);
}
Loading
Loading