diff --git a/.env.example b/.env.example index 09d3f15b..8d0346eb 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 +NEXT_PUBLIC_APP_URL=https://your-public-app-url.example.com +WEBHOOK_SECRET= diff --git a/app/(main)/chatbot/page.tsx b/app/(main)/chatbot/page.tsx new file mode 100644 index 00000000..92b7f3d1 --- /dev/null +++ b/app/(main)/chatbot/page.tsx @@ -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 => { + 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 ( +
+
+ + +
+ + + +
+ } + /> + +
+
+ +
+ + +
+
+
+ + ); +} diff --git a/app/api/chatbot/sessions/[session_id]/message/route.ts b/app/api/chatbot/sessions/[session_id]/message/route.ts new file mode 100644 index 00000000..40aeb0fc --- /dev/null +++ b/app/api/chatbot/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/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); + } +} diff --git a/app/api/chatbot/sessions/route.ts b/app/api/chatbot/sessions/route.ts new file mode 100644 index 00000000..011cdcbe --- /dev/null +++ b/app/api/chatbot/sessions/route.ts @@ -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); + } +} 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..e29e3d96 --- /dev/null +++ b/app/api/webhooks/chatbot/route.ts @@ -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 | 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 }, + ); + } +} diff --git a/app/api/webhooks/chatbot/stream/route.ts b/app/api/webhooks/chatbot/stream/route.ts new file mode 100644 index 00000000..e61b6418 --- /dev/null +++ b/app/api/webhooks/chatbot/stream/route.ts @@ -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", + }, + }); +} 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/chatbot/ChatbotChatInput.tsx b/app/components/chatbot/ChatbotChatInput.tsx new file mode 100644 index 00000000..8ed3de6c --- /dev/null +++ b/app/components/chatbot/ChatbotChatInput.tsx @@ -0,0 +1,50 @@ +"use client"; + +import { FormEvent, useState } from "react"; +import { SendIcon } from "@/app/components/icons"; + +interface ChatbotChatInputProps { + disabled: boolean; + placeholder: string; + /** Resolves false when sending failed, so the draft is restored. */ + 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-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" + /> + +
+ ); +} diff --git a/app/components/chatbot/ChatbotFlowCanvas.tsx b/app/components/chatbot/ChatbotFlowCanvas.tsx new file mode 100644 index 00000000..7bbbea55 --- /dev/null +++ b/app/components/chatbot/ChatbotFlowCanvas.tsx @@ -0,0 +1,66 @@ +"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[]) => void; + onEdgesChange: (changes: EdgeChange[]) => void; + onConnect: (connection: Connection) => void; + updateNodeData: (id: string, patch: Partial) => void; +} + +export default function ChatbotFlowCanvas({ + nodes, + edges, + onNodesChange, + onEdgesChange, + onConnect, + 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..9e49716a --- /dev/null +++ b/app/components/chatbot/ChatbotFlowContext.tsx @@ -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) => void; +} + +export const ChatbotFlowContext = createContext( + 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; +} diff --git a/app/components/chatbot/ChatbotMessageBubble.tsx b/app/components/chatbot/ChatbotMessageBubble.tsx new file mode 100644 index 00000000..5f2717b7 --- /dev/null +++ b/app/components/chatbot/ChatbotMessageBubble.tsx @@ -0,0 +1,23 @@ +import { MarkdownContent } from "@/app/components/chat"; +import { ChatbotChatMessage } from "@/app/lib/types/chatbot"; + +export default function ChatbotMessageBubble({ + message, +}: { + message: Pick; +}) { + const isUser = message.role === "user"; + return ( +
+
+ {isUser ? message.content : } +
+
+ ); +} diff --git a/app/components/chatbot/ChatbotSessionInfo.tsx b/app/components/chatbot/ChatbotSessionInfo.tsx new file mode 100644 index 00000000..f2417c4c --- /dev/null +++ b/app/components/chatbot/ChatbotSessionInfo.tsx @@ -0,0 +1,45 @@ +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; + // Inactive = superseded by a newer session of the same chatbot. + 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 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/ChatbotTestPanel.tsx b/app/components/chatbot/ChatbotTestPanel.tsx new file mode 100644 index 00000000..6161dff5 --- /dev/null +++ b/app/components/chatbot/ChatbotTestPanel.tsx @@ -0,0 +1,138 @@ +/** + * ChatbotTestPanel - Chats with the saved flow's server-side session. Replies + * arrive asynchronously via the webhook stream, so the panel only renders + * state owned by useChatbotSession. + */ + +"use client"; + +import { useEffect, useRef } from "react"; +import { Button } from "@/app/components/ui"; +import { RefreshIcon } from "@/app/components/icons"; +import ChatbotMessageBubble from "@/app/components/chatbot/ChatbotMessageBubble"; +import ChatbotChatInput from "@/app/components/chatbot/ChatbotChatInput"; +import { ChatbotStatusBadge } from "@/app/components/chatbot/ChatbotSessionInfo"; +import { + ChatbotChatMessage, + ChatbotCompletionStatus, + ChatbotSessionStatus, +} from "@/app/lib/types/chatbot"; + +interface ChatbotTestPanelProps { + hasSession: boolean; + sessionName: string | null; + status: ChatbotSessionStatus | null; + completionStatus: ChatbotCompletionStatus | null; + messages: ChatbotChatMessage[]; + isPending: boolean; + isRestarting: boolean; + isDirty: boolean; + onSend: (message?: string) => Promise; + onRestart: () => void; +} + +function inputPlaceholder(isEnded: boolean, hasStarted: boolean): string { + if (isEnded) return "Session closed"; + if (!hasStarted) return "Click Start chat to begin…"; + return "Reply as the user…"; +} + +export default function ChatbotTestPanel({ + hasSession, + sessionName, + status, + completionStatus, + messages, + isPending, + isRestarting, + isDirty, + onSend, + onRestart, +}: ChatbotTestPanelProps) { + 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 ( +
+
+
+

+ {sessionName ? `Test: ${sessionName}` : "Test chat"} +

+ +
+ {hasSession && ( + + )} +
+ + {isDirty && ( +

+ You have unsaved changes. The chat uses the last saved flow. Save to + test your edits. +

+ )} + +
+ {!hasSession ? ( +

+ Pick a configuration, write a prompt and click Save to start a + chatbot session. +

+ ) : ( + messages.length === 0 && + !isPending && ( +
+

+ The bot will send the first message. +

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

+ Bot is typing… +

+ )} + {isEnded && ( +

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

+ )} +
+
+ + {hasSession && ( + + )} +
+ ); +} diff --git a/app/components/chatbot/LlmNode.tsx b/app/components/chatbot/LlmNode.tsx new file mode 100644 index 00000000..14d03202 --- /dev/null +++ b/app/components/chatbot/LlmNode.tsx @@ -0,0 +1,94 @@ +/** + * LlmNode - Editable LLM step on the chatbot canvas: config and prompt are + * edited inline; Save turns them into a chatbot session. + */ + +"use client"; + +import { Handle, NodeProps, Position } from "@xyflow/react"; +import { ChatConfigPicker } from "@/app/components/chat"; +import { CHATBOT_NAME_MAX_LENGTH } from "@/app/lib/constants"; +import { useChatbotStore } from "@/app/lib/store/chatbot"; +import { ChatbotLlmNode, ChatbotNodeData } from "@/app/lib/types/chatbot"; +import { useChatbotFlowContext } from "@/app/components/chatbot/ChatbotFlowContext"; +import { FLOW_HANDLE_STYLE } from "@/app/components/chatbot/flowHandleStyle"; +import NodeFieldLabel from "@/app/components/chatbot/NodeFieldLabel"; +import LlmNodePromptField from "@/app/components/chatbot/LlmNodePromptField"; + +const PORT_LABEL_CLASS = "text-sm font-semibold font-mono text-text-primary"; + +export default function LlmNode({ + id, + data, + selected, +}: NodeProps) { + const { updateNodeData } = useChatbotFlowContext(); + // Shown only once the backend has generated it on the first save. + const chatbotRef = useChatbotStore((s) => s.chatbotRef); + const update = (patch: Partial) => updateNodeData(id, patch); + + return ( +
+
+ update({ label: e.target.value })} + maxLength={CHATBOT_NAME_MAX_LENGTH} + placeholder="Untitled step" + className="nodrag w-full px-6 text-center text-base font-semibold text-text-primary bg-transparent focus:outline-none" + /> + {chatbotRef && ( +

+ {chatbotRef} +

+ )} +
+ +
+ + Input +
+ +
+
+ + + update({ configId, configVersion }) + } + alignLeft + /> +
+ + update({ systemPrompt })} + /> +
+ +
+ Output + +
+
+ ); +} diff --git a/app/components/chatbot/LlmNodePromptField.tsx b/app/components/chatbot/LlmNodePromptField.tsx new file mode 100644 index 00000000..2b3755da --- /dev/null +++ b/app/components/chatbot/LlmNodePromptField.tsx @@ -0,0 +1,73 @@ +/** + * LlmNodePromptField - Inline system-prompt editor with an expanded modal view. + */ + +"use client"; + +import { useState } from "react"; +import { createPortal } from "react-dom"; +import { 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 ( +
+ + + +