diff --git a/.env.example b/.env.example index 09d3f15b..caaf9d2a 100644 --- a/.env.example +++ b/.env.example @@ -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= diff --git a/app/(main)/chatbot/[chatbotId]/chat/page.tsx b/app/(main)/chatbot/[chatbotId]/chat/page.tsx new file mode 100644 index 00000000..8a50a10e --- /dev/null +++ b/app/(main)/chatbot/[chatbotId]/chat/page.tsx @@ -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 ( +
+
+ + +
+ + + +
+ ) : null + } + > + + + + +
+
+ + ); +} diff --git a/app/(main)/chatbot/[chatbotId]/page.tsx b/app/(main)/chatbot/[chatbotId]/page.tsx new file mode 100644 index 00000000..1404df58 --- /dev/null +++ b/app/(main)/chatbot/[chatbotId]/page.tsx @@ -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() { + 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 ( +
+
+ + +
+ + + + {flowVersion && !flowVersion.isLive && ( + + )} +
+ } + > +
+ + +
+ + +
+ {flow.isLoading ? ( +
+ +
+ ) : ( + <> + + + + )} +
+
+
+ + ); +} diff --git a/app/(main)/chatbot/page.tsx b/app/(main)/chatbot/page.tsx new file mode 100644 index 00000000..dd71ce4d --- /dev/null +++ b/app/(main)/chatbot/page.tsx @@ -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 ( +
+ +
+ ); + } + if (error) { + return

{error}

; + } + if (chatbots.length === 0) { + return ( +
+

No chatbots yet.

+ +
+ ); + } + if (filtered.length === 0) { + return ( +

+ No chatbots match "{search}". +

+ ); + } + return ; + }; + + return ( +
+
+ + +
+ setShowCreate(true)}> + + Add New + + } + /> + +
+
+ + 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" + /> +
+ {renderContent()} +
+
+
+ + setShowCreate(false)} + onCreate={handleCreate} + /> +
+ ); +} diff --git a/app/api/_sse.ts b/app/api/_sse.ts new file mode 100644 index 00000000..ffe30353 --- /dev/null +++ b/app/api/_sse.ts @@ -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, + }); +} diff --git a/app/api/chatbots/[chatbot_id]/flow/publish/route.ts b/app/api/chatbots/[chatbot_id]/flow/publish/route.ts new file mode 100644 index 00000000..d2c1743b --- /dev/null +++ b/app/api/chatbots/[chatbot_id]/flow/publish/route.ts @@ -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); + } +} diff --git a/app/api/chatbots/[chatbot_id]/flow/route.ts b/app/api/chatbots/[chatbot_id]/flow/route.ts new file mode 100644 index 00000000..d0461afb --- /dev/null +++ b/app/api/chatbots/[chatbot_id]/flow/route.ts @@ -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); + } +} diff --git a/app/api/chatbots/[chatbot_id]/route.ts b/app/api/chatbots/[chatbot_id]/route.ts new file mode 100644 index 00000000..d7875b69 --- /dev/null +++ b/app/api/chatbots/[chatbot_id]/route.ts @@ -0,0 +1,16 @@ +import { proxyErrorResponse, proxyJsonResponse } from "@/app/api/_routeProxy"; + +export async function GET( + request: Request, + { params }: { params: Promise<{ chatbot_id: string }> }, +) { + try { + const { chatbot_id } = await params; + return await proxyJsonResponse( + request, + `/api/v1/chatbots/${encodeURIComponent(chatbot_id)}`, + ); + } catch (error) { + return proxyErrorResponse("chatbot get error:", error); + } +} diff --git a/app/api/chatbots/[chatbot_id]/sessions/route.ts b/app/api/chatbots/[chatbot_id]/sessions/route.ts new file mode 100644 index 00000000..6a171e77 --- /dev/null +++ b/app/api/chatbots/[chatbot_id]/sessions/route.ts @@ -0,0 +1,18 @@ +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; + const body = await request.json(); + return await proxyJsonResponse( + request, + `/api/v1/chatbots/${encodeURIComponent(chatbot_id)}/sessions`, + { method: "POST", body: JSON.stringify(body) }, + ); + } catch (error) { + return proxyErrorResponse("chatbot session create error:", error); + } +} diff --git a/app/api/chatbots/route.ts b/app/api/chatbots/route.ts new file mode 100644 index 00000000..12989323 --- /dev/null +++ b/app/api/chatbots/route.ts @@ -0,0 +1,29 @@ +import { NextRequest } from "next/server"; +import { + proxyErrorResponse, + proxyJsonResponse, + withQueryParams, +} from "@/app/api/_routeProxy"; + +export async function GET(request: NextRequest) { + try { + return await proxyJsonResponse( + request, + withQueryParams("/api/v1/chatbots", request.nextUrl.searchParams), + ); + } catch (error) { + return proxyErrorResponse("chatbot list error:", error); + } +} + +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + return await proxyJsonResponse(request, "/api/v1/chatbots", { + method: "POST", + body: JSON.stringify(body), + }); + } catch (error) { + return proxyErrorResponse("chatbot create error:", error); + } +} diff --git a/app/api/chatbots/sessions/[session_id]/message/route.ts b/app/api/chatbots/sessions/[session_id]/message/route.ts new file mode 100644 index 00000000..411ee4ff --- /dev/null +++ b/app/api/chatbots/sessions/[session_id]/message/route.ts @@ -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/chatbots/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); + } +} diff --git a/app/api/evaluations/[id]/improve-prompt/route.ts b/app/api/evaluations/[id]/improve-prompt/route.ts index ca9f4ceb..0f808fa6 100644 --- a/app/api/evaluations/[id]/improve-prompt/route.ts +++ b/app/api/evaluations/[id]/improve-prompt/route.ts @@ -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 }> }, @@ -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( diff --git a/app/api/webhooks/chatbot/route.ts b/app/api/webhooks/chatbot/route.ts new file mode 100644 index 00000000..686ed99b --- /dev/null +++ b/app/api/webhooks/chatbot/route.ts @@ -0,0 +1,48 @@ +import { NextRequest, NextResponse } from "next/server"; +import { chatbotReplyChannel } from "@/app/lib/store/chatbotSessionStore"; +import { isWebhookAuthorized } from "@/app/lib/webhookCallback"; +import type { + ChatbotSessionEvent, + ChatbotWebhookPayload, +} from "@/app/lib/types/chatbot"; + +function toSessionEvent(body: ChatbotWebhookPayload): ChatbotSessionEvent { + return { + success: body.success === true, + reply: body.data?.reply ?? null, + status: body.data?.status ?? null, + completionStatus: body.data?.completion_status ?? null, + error: body.error ?? null, + }; +} + +export async function POST(request: NextRequest) { + try { + if (!isWebhookAuthorized(request)) { + return NextResponse.json( + { success: false, error: "unauthorized" }, + { status: 401 }, + ); + } + const body = (await request.json()) as ChatbotWebhookPayload; + const sessionId = body.data?.session_id; + if (!sessionId) { + return NextResponse.json( + { success: false, error: "invalid_payload" }, + { status: 400 }, + ); + } + chatbotReplyChannel.publish(sessionId, toSessionEvent(body)); + 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 }, + ); + } +} diff --git a/app/api/webhooks/chatbot/stream/route.ts b/app/api/webhooks/chatbot/stream/route.ts new file mode 100644 index 00000000..9d5774b0 --- /dev/null +++ b/app/api/webhooks/chatbot/stream/route.ts @@ -0,0 +1,29 @@ +import { NextRequest } from "next/server"; +import { createSseResponse } from "@/app/api/_sse"; +import { chatbotReplyChannel } from "@/app/lib/store/chatbotSessionStore"; +import type { ChannelEvent } from "@/app/lib/types/eventChannel"; +import type { ChatbotSessionEvent } from "@/app/lib/types/chatbot"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +export async function GET(request: NextRequest) { + const sessionId = request.nextUrl.searchParams.get("session_id"); + if (!sessionId) { + return new Response("session_id_required", { status: 400 }); + } + + return createSseResponse(request, (send) => { + const sendReply = ({ seq, data }: ChannelEvent) => + send("reply", data, seq); + + const unsubscribe = chatbotReplyChannel.subscribe(sessionId, sendReply); + + // On reconnect, the browser sends the last id it got; resend what it missed. + const lastSeenId = Number(request.headers.get("last-event-id")); + if (lastSeenId) { + chatbotReplyChannel.after(sessionId, lastSeenId).forEach(sendReply); + } + return unsubscribe; + }); +} diff --git a/app/api/webhooks/prompt-improvement/route.ts b/app/api/webhooks/prompt-improvement/route.ts index 8fe0ea9b..fa252033 100644 --- a/app/api/webhooks/prompt-improvement/route.ts +++ b/app/api/webhooks/prompt-improvement/route.ts @@ -3,19 +3,12 @@ import { getJobSnapshot, saveJobSnapshot, } from "@/app/lib/store/promptImprovementStore"; -import { readWebhookSecret } from "@/app/lib/webhookSecret"; +import { isWebhookAuthorized } from "@/app/lib/webhookCallback"; import type { PromptImprovementJobPublic } from "@/app/lib/types/promptImprovement"; -function isAuthorized(request: NextRequest): boolean { - const secretResult = readWebhookSecret(); - if (!secretResult.ok || !secretResult.secret) return false; - const provided = request.nextUrl.searchParams.get("secret_value"); - return provided === secretResult.secret; -} - export async function POST(request: NextRequest) { try { - if (!isAuthorized(request)) { + if (!isWebhookAuthorized(request)) { return NextResponse.json( { success: false, error: "unauthorized" }, { status: 401 }, diff --git a/app/components/Sidebar.tsx b/app/components/Sidebar.tsx index db585568..2ef7443e 100644 --- a/app/components/Sidebar.tsx +++ b/app/components/Sidebar.tsx @@ -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"; @@ -20,6 +16,7 @@ import { ShieldCheckIcon, SlidersIcon, ChatIcon, + FlowIcon, ChartBarIcon, WarningTriangleIcon, } from "@/app/components/icons"; @@ -122,6 +119,7 @@ export default function Sidebar({ const iconMap: Record = { chat: , + flow: , clipboard: , document: , book: , diff --git a/app/components/chat/AssistantAvatar.tsx b/app/components/chat/AssistantAvatar.tsx new file mode 100644 index 00000000..d7401013 --- /dev/null +++ b/app/components/chat/AssistantAvatar.tsx @@ -0,0 +1,9 @@ +import { ChatIcon } from "@/app/components/icons"; + +export default function AssistantAvatar() { + return ( +
+ +
+ ); +} diff --git a/app/components/chat/ChatMessage.tsx b/app/components/chat/ChatMessage.tsx index d1985439..826a229f 100644 --- a/app/components/chat/ChatMessage.tsx +++ b/app/components/chat/ChatMessage.tsx @@ -10,14 +10,18 @@ import { ChatMessage as ChatMessageType, } from "@/app/lib/types/chat"; import { - ChatIcon, CheckLineIcon, CopyIcon, DocumentFileIcon, MicIcon, SpeakerIcon, } from "@/app/components/icons"; -import { AttachmentPreviewModal, MarkdownContent } from "@/app/components/chat"; +import { + AssistantAvatar, + AttachmentPreviewModal, + MarkdownContent, + ThinkingDots, +} from "@/app/components/chat"; import { useTextToSpeech } from "@/app/hooks/useTextToSpeech"; interface ChatMessageProps { @@ -48,16 +52,6 @@ function VoiceMessageBody() { ); } -function ThinkingDots() { - return ( -
- - - -
- ); -} - function AssistantBody({ text, baseClass, @@ -143,9 +137,7 @@ function AssistantMessage({ message }: { message: ChatMessageType }) { return (
-
- -
+
{isPending && !message.content && !hasAudio ? ( diff --git a/app/components/chat/ThinkingDots.tsx b/app/components/chat/ThinkingDots.tsx new file mode 100644 index 00000000..956b5570 --- /dev/null +++ b/app/components/chat/ThinkingDots.tsx @@ -0,0 +1,9 @@ +export default function ThinkingDots() { + return ( +
+ + + +
+ ); +} diff --git a/app/components/chat/index.ts b/app/components/chat/index.ts index 11336162..8f1caef0 100644 --- a/app/components/chat/index.ts +++ b/app/components/chat/index.ts @@ -5,6 +5,8 @@ export { default as ChatConfigPicker } from "./ChatConfigPicker"; export { default as ChatEmptyState } from "./ChatEmptyState"; export { default as VoiceInput } from "./VoiceInput"; export { default as MarkdownContent } from "./MarkdownContent"; +export { default as ThinkingDots } from "./ThinkingDots"; +export { default as AssistantAvatar } from "./AssistantAvatar"; export { default as AttachmentChip } from "./AttachmentChip"; export type { AttachmentChipFile } from "./AttachmentChip"; export { default as AttachmentPreviewModal } from "./AttachmentPreviewModal"; diff --git a/app/components/chatbot/ChatbotBreadcrumb.tsx b/app/components/chatbot/ChatbotBreadcrumb.tsx new file mode 100644 index 00000000..3620eff3 --- /dev/null +++ b/app/components/chatbot/ChatbotBreadcrumb.tsx @@ -0,0 +1,52 @@ +import { Fragment } from "react"; +import Link from "next/link"; +import { ChevronRightIcon } from "@/app/components/icons"; + +export interface BreadcrumbItem { + label: string; + href?: string; + muted?: boolean; +} + +export default function ChatbotBreadcrumb({ + items, +}: { + items: BreadcrumbItem[]; +}) { + const trail: BreadcrumbItem[] = [ + { label: "Chatbots", href: "/chatbot" }, + ...items, + ]; + return ( + + ); +} diff --git a/app/components/chatbot/ChatbotChatInput.tsx b/app/components/chatbot/ChatbotChatInput.tsx new file mode 100644 index 00000000..93420882 --- /dev/null +++ b/app/components/chatbot/ChatbotChatInput.tsx @@ -0,0 +1,46 @@ +"use client"; + +import { FormEvent, useState } from "react"; +import { Button } from "@/app/components/ui"; +import { SendIcon } from "@/app/components/icons"; + +interface ChatbotChatInputProps { + disabled: boolean; + placeholder: string; + onSend: (message: string) => Promise; +} + +export default function ChatbotChatInput({ + disabled, + placeholder, + onSend, +}: ChatbotChatInputProps) { + const [input, setInput] = useState(""); + + const handleSubmit = async (e: FormEvent) => { + e.preventDefault(); + const text = input.trim(); + if (!text || disabled) return; + setInput(""); + if (!(await onSend(text))) setInput(text); + }; + + return ( +
+ setInput(e.target.value)} + placeholder={placeholder} + disabled={disabled} + className="flex-1 px-4 py-2.5 rounded-full border border-border text-sm bg-bg-primary text-text-primary focus:outline-none focus:border-accent-primary disabled:opacity-60" + /> + +
+ ); +} diff --git a/app/components/chatbot/ChatbotChatWindow.tsx b/app/components/chatbot/ChatbotChatWindow.tsx new file mode 100644 index 00000000..05ef4f44 --- /dev/null +++ b/app/components/chatbot/ChatbotChatWindow.tsx @@ -0,0 +1,83 @@ +"use client"; + +import { useEffect, useRef } from "react"; +import { Button } from "@/app/components/ui"; +import { AssistantAvatar, ThinkingDots } from "@/app/components/chat"; +import ChatbotMessageBubble from "@/app/components/chatbot/ChatbotMessageBubble"; +import ChatbotChatInput from "@/app/components/chatbot/ChatbotChatInput"; +import { + ChatbotChatMessage, + ChatbotSessionStatus, +} from "@/app/lib/types/chatbot"; + +interface ChatbotChatWindowProps { + status: ChatbotSessionStatus | null; + messages: ChatbotChatMessage[]; + isPending: boolean; + onStart: () => void; + onSend: (message: string) => Promise; +} + +function inputPlaceholder(isEnded: boolean, hasStarted: boolean): string { + if (isEnded) return "Session closed"; + if (!hasStarted) return "Click Start chat to begin…"; + return "Type your message…"; +} + +export default function ChatbotChatWindow({ + status, + messages, + isPending, + onStart, + onSend, +}: ChatbotChatWindowProps) { + const bottomRef = useRef(null); + // Inactive = superseded by a newer session; a complete one still chats. + const isEnded = status === "inactive"; + const hasStarted = messages.length > 0; + + useEffect(() => { + bottomRef.current?.scrollIntoView({ behavior: "smooth", block: "end" }); + }, [messages.length, isPending]); + + return ( +
+
+ {!hasStarted && !isPending && ( +
+

+ The bot will send the first message. +

+ +
+ )} + {messages.map((m) => ( + + ))} + {isPending && ( +
+ +
+ +
+
+ )} + {isEnded && ( +

+ This session was replaced by a newer one. Restart to continue + testing. +

+ )} +
+
+ + +
+ ); +} diff --git a/app/components/chatbot/ChatbotFlowCanvas.tsx b/app/components/chatbot/ChatbotFlowCanvas.tsx new file mode 100644 index 00000000..6090ccae --- /dev/null +++ b/app/components/chatbot/ChatbotFlowCanvas.tsx @@ -0,0 +1,61 @@ +"use client"; + +import { useMemo } from "react"; +import { + Background, + BackgroundVariant, + Controls, + Edge, + NodeChange, + ReactFlow, +} from "@xyflow/react"; +import "@xyflow/react/dist/style.css"; +import OnboardingNode from "@/app/components/chatbot/OnboardingNode"; +import TerminalNode from "@/app/components/chatbot/TerminalNode"; +import { ChatbotFlowContext } from "@/app/components/chatbot/ChatbotFlowContext"; +import { ChatbotFlowNode, OnboardingNodeData } from "@/app/lib/types/chatbot"; + +const NODE_TYPES = { + input: TerminalNode, + output: TerminalNode, + onboarding: OnboardingNode, +}; + +const DEFAULT_EDGE_OPTIONS = { style: { stroke: "#a3a3a3", strokeWidth: 1 } }; + +interface ChatbotFlowCanvasProps { + nodes: ChatbotFlowNode[]; + edges: Edge[]; + onNodesChange: (changes: NodeChange[]) => void; + updateNodeData: (id: string, patch: Partial) => void; +} + +export default function ChatbotFlowCanvas({ + nodes, + edges, + onNodesChange, + updateNodeData, +}: ChatbotFlowCanvasProps) { + const contextValue = useMemo(() => ({ updateNodeData }), [updateNodeData]); + + return ( + + + + + + + ); +} diff --git a/app/components/chatbot/ChatbotFlowContext.tsx b/app/components/chatbot/ChatbotFlowContext.tsx new file mode 100644 index 00000000..468deeb8 --- /dev/null +++ b/app/components/chatbot/ChatbotFlowContext.tsx @@ -0,0 +1,23 @@ +"use client"; + +import { createContext, useContext } from "react"; +import { OnboardingNodeData } from "@/app/lib/types/chatbot"; + +interface ChatbotFlowContextValue { + updateNodeData: (id: string, patch: Partial) => void; +} + +export const ChatbotFlowContext = createContext( + null, +); + +/** 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; +} diff --git a/app/components/chatbot/ChatbotList.tsx b/app/components/chatbot/ChatbotList.tsx new file mode 100644 index 00000000..84a457fb --- /dev/null +++ b/app/components/chatbot/ChatbotList.tsx @@ -0,0 +1,59 @@ +import Link from "next/link"; +import { EditIcon } from "@/app/components/icons"; +import { formatRelativeTime } from "@/app/lib/utils"; +import { Chatbot } from "@/app/lib/types/chatbot"; + +const TH_CLASS = + "px-5 py-2.5 text-left text-[11px] font-semibold uppercase tracking-wide text-text-secondary"; + +const editUrl = (chatbot: Chatbot) => `/chatbot/${chatbot.id}`; + +export default function ChatbotList({ chatbots }: { chatbots: Chatbot[] }) { + return ( +
+ + + + + + + + + + + {chatbots.map((chatbot) => ( + + + + + + + ))} + +
NameLast updatedCreatedActions
+ + {chatbot.name} + + + {formatRelativeTime(chatbot.updated_at)} + + {formatRelativeTime(chatbot.inserted_at)} + + + + +
+
+ ); +} diff --git a/app/components/chatbot/ChatbotMessageBubble.tsx b/app/components/chatbot/ChatbotMessageBubble.tsx new file mode 100644 index 00000000..5c0ce077 --- /dev/null +++ b/app/components/chatbot/ChatbotMessageBubble.tsx @@ -0,0 +1,26 @@ +import { AssistantAvatar, MarkdownContent } from "@/app/components/chat"; +import { ChatbotChatMessage } from "@/app/lib/types/chatbot"; + +export default function ChatbotMessageBubble({ + message, +}: { + message: Pick; +}) { + if (message.role === "user") { + return ( +
+
+ {message.content} +
+
+ ); + } + return ( +
+ +
+ +
+
+ ); +} diff --git a/app/components/chatbot/ChatbotStatusBadge.tsx b/app/components/chatbot/ChatbotStatusBadge.tsx new file mode 100644 index 00000000..89a80774 --- /dev/null +++ b/app/components/chatbot/ChatbotStatusBadge.tsx @@ -0,0 +1,44 @@ +import { + ChatbotCompletionStatus, + ChatbotSessionStatus, +} from "@/app/lib/types/chatbot"; + +const NEUTRAL = "bg-bg-secondary text-text-secondary"; +const SUCCESS = "bg-status-success-bg text-status-success-text"; +const WARNING = "bg-status-warning-bg text-status-warning-text"; + +function badgeFor( + status: ChatbotSessionStatus | null, + completionStatus: ChatbotCompletionStatus | null, +): { label: string; className: string } | null { + if (!status) return null; + if (status === "inactive") return { label: "Closed", className: NEUTRAL }; + switch (completionStatus) { + case "complete": + return { label: "Complete", className: SUCCESS }; + case "abandoned": + return { label: "Abandoned", className: NEUTRAL }; + case "in_progress": + return { label: "In progress", className: WARNING }; + default: + return { label: "Active", className: SUCCESS }; + } +} + +export default function ChatbotStatusBadge({ + status, + completionStatus, +}: { + status: ChatbotSessionStatus | null; + completionStatus: ChatbotCompletionStatus | null; +}) { + const badge = badgeFor(status, completionStatus); + if (!badge) return null; + return ( + + {badge.label} + + ); +} diff --git a/app/components/chatbot/ChatbotVersionPill.tsx b/app/components/chatbot/ChatbotVersionPill.tsx new file mode 100644 index 00000000..d0ae6c48 --- /dev/null +++ b/app/components/chatbot/ChatbotVersionPill.tsx @@ -0,0 +1,23 @@ +interface ChatbotVersionPillProps { + version: { version: number; isLive: boolean } | null; +} + +export default function ChatbotVersionPill({ + version, +}: ChatbotVersionPillProps) { + let label = "Not saved"; + let colors = "bg-bg-secondary text-text-secondary"; + if (version) { + label = `${version.isLive ? "Live" : "Draft"} · v${version.version}`; + colors = version.isLive + ? "bg-status-success-bg text-status-success-text" + : "bg-status-warning-bg text-status-warning-text"; + } + return ( + + {label} + + ); +} diff --git a/app/components/chatbot/CreateChatbotModal.tsx b/app/components/chatbot/CreateChatbotModal.tsx new file mode 100644 index 00000000..b588394c --- /dev/null +++ b/app/components/chatbot/CreateChatbotModal.tsx @@ -0,0 +1,66 @@ +"use client"; + +import { useState } from "react"; +import { Button, Field, Modal } from "@/app/components/ui"; +import { + CHATBOT_NAME_MAX_LENGTH, + DEFAULT_CHATBOT_NAME, +} from "@/app/lib/constants"; + +interface CreateChatbotModalProps { + open: boolean; + onClose: () => void; + onCreate: (name: string) => Promise; +} + +export default function CreateChatbotModal({ + open, + onClose, + onCreate, +}: CreateChatbotModalProps) { + const [name, setName] = useState(""); + const [isSubmitting, setIsSubmitting] = useState(false); + + const handleClose = () => { + if (isSubmitting) return; + setName(""); + onClose(); + }; + + const handleSubmit = async () => { + setIsSubmitting(true); + try { + await onCreate(name.trim() || DEFAULT_CHATBOT_NAME); + } finally { + setIsSubmitting(false); + } + }; + + return ( + +
+ +
+ + +
+
+
+ ); +} diff --git a/app/components/chatbot/LlmNodePromptField.tsx b/app/components/chatbot/LlmNodePromptField.tsx new file mode 100644 index 00000000..756a632b --- /dev/null +++ b/app/components/chatbot/LlmNodePromptField.tsx @@ -0,0 +1,71 @@ +"use client"; + +import { useState } from "react"; +import { createPortal } from "react-dom"; +import { Button, Modal } from "@/app/components/ui"; +import { ExpandIcon } from "@/app/components/icons"; +import NodeFieldLabel from "@/app/components/chatbot/NodeFieldLabel"; + +interface LlmNodePromptFieldProps { + value: string; + onChange: (value: string) => void; +} + +const PLACEHOLDER = + "Goal: Register a student. Friendly, short WhatsApp messages, one question at a time."; + +export default function LlmNodePromptField({ + value, + onChange, +}: LlmNodePromptFieldProps) { + const [expanded, setExpanded] = useState(false); + + return ( +
+ + + +