) {
+ const isInput = type === "input";
return (
- {data.label}
+ {isInput ? "Input" : "Output"}
(null);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ if (!isHydrated || !isAuthenticated) return;
+ let cancelled = false;
+ getChatbot(chatbotId, apiKey)
+ .then((bot) => {
+ if (!cancelled) setChatbot(bot);
+ })
+ .catch((err) => {
+ if (!cancelled) {
+ setError(err instanceof Error ? err.message : "Chatbot not found.");
+ }
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, [chatbotId, apiKey, isAuthenticated, isHydrated]);
+
+ return { chatbot, error };
+}
diff --git a/app/hooks/chatbot/useChatbotFlow.ts b/app/hooks/chatbot/useChatbotFlow.ts
index b92d8145..97fa8125 100644
--- a/app/hooks/chatbot/useChatbotFlow.ts
+++ b/app/hooks/chatbot/useChatbotFlow.ts
@@ -1,116 +1,96 @@
"use client";
-import { useCallback, useEffect, useRef, useState } from "react";
+import { useCallback, useEffect, useMemo, useState } from "react";
import { Edge, useNodesState } from "@xyflow/react";
-import { DEFAULT_CHATBOT_NAME } from "@/app/lib/constants";
-import { useChatbotStore } from "@/app/lib/store/chatbot";
+import { useAuth } from "@/app/lib/context/AuthContext";
+import { useToast } from "@/app/hooks/useToast";
import {
+ getChatbotFlow,
+ publishChatbotFlow,
+ saveChatbotFlow,
+} from "@/app/lib/chatbotClient";
+import {
+ createDefaultFlow,
+ flowSignature,
+ toFlowPayload,
+} from "@/app/lib/utils/chatbotFlow";
+import {
+ ChatbotFlowGraph,
ChatbotFlowNode,
- ChatbotLlmNode,
- ChatbotNodeData,
+ OnboardingNodeData,
} from "@/app/lib/types/chatbot";
-const INPUT_ID = "input";
-const OUTPUT_ID = "output";
-const LLM_ID = "LLMResponseWithPrompt-1";
-
-const DEFAULT_NODE_DATA: ChatbotNodeData = {
- label: DEFAULT_CHATBOT_NAME,
- systemPrompt: "",
- configId: "",
- configVersion: undefined,
-};
-
-function createInitialFlow(): { nodes: ChatbotFlowNode[]; edges: Edge[] } {
- return {
- nodes: [
- {
- id: INPUT_ID,
- type: "flowInput",
- position: { x: 0, y: 55 },
- data: { label: "Input" },
- deletable: false,
- },
- {
- id: LLM_ID,
- type: "llm",
- position: { x: 260, y: 0 },
- data: DEFAULT_NODE_DATA,
- deletable: false,
- },
- {
- id: OUTPUT_ID,
- type: "flowOutput",
- position: { x: 640, y: 230 },
- data: { label: "Output" },
- deletable: false,
- },
- ],
- edges: [
- { id: `${INPUT_ID}->${LLM_ID}`, source: INPUT_ID, target: LLM_ID },
- { id: `${LLM_ID}->${OUTPUT_ID}`, source: LLM_ID, target: OUTPUT_ID },
- ],
- };
-}
-
-function isLlmNode(node: ChatbotFlowNode): node is ChatbotLlmNode {
- return node.type === "llm";
-}
-
-function isSameData(a: ChatbotNodeData, b: ChatbotNodeData): boolean {
- return (
- a.label === b.label &&
- a.systemPrompt === b.systemPrompt &&
- a.configId === b.configId &&
- (a.configVersion ?? null) === (b.configVersion ?? null)
- );
+interface FlowVersion {
+ version: number;
+ isLive: boolean;
}
-/**
- * Owns the canvas graph (Input → LLM → Output) and tracks the LLM node's
- * edits against the last saved version, for the Save / Discard buttons.
- */
-export function useChatbotFlow() {
- const [initial] = useState(createInitialFlow);
- const [nodes, setNodes, onNodesChange] = useNodesState(
- initial.nodes,
- );
- // The graph is fixed (Input → LLM → Output), so edges never change.
- const edges = initial.edges;
+export function useChatbotFlow(chatbotId: string) {
+ const { activeKey, isAuthenticated, isHydrated } = useAuth();
+ const apiKey = activeKey?.key ?? "";
+ const toast = useToast();
- const savedFlow = useChatbotStore((s) => s.savedFlow);
- const hasHydrated = useChatbotStore((s) => s.hasHydrated);
- const setSavedFlow = useChatbotStore((s) => s.setSavedFlow);
+ const [nodes, setNodes, onNodesChange] = useNodesState([]);
+ const [edges, setEdges] = useState([]);
+ const [saved, setSaved] = useState(null);
+ const [flowVersion, setFlowVersion] = useState(null);
+ const [isLoading, setIsLoading] = useState(true);
+ const [isSaving, setIsSaving] = useState(false);
+ const [isPublishing, setIsPublishing] = useState(false);
- const llmNode = nodes.find(isLlmNode) ?? null;
- const baseline = savedFlow ?? DEFAULT_NODE_DATA;
- const isDirty = !!llmNode && !isSameData(llmNode.data, baseline);
-
- const replaceNodeData = useCallback(
- (data: ChatbotNodeData) => {
- setNodes((prev) =>
- prev.map((n) => (isLlmNode(n) ? { ...n, data: { ...data } } : n)),
- );
+ // Nodes are fixed (no add/delete), so lock them on the canvas.
+ const showFlow = useCallback(
+ (flow: ChatbotFlowGraph) => {
+ setNodes(flow.nodes.map((n) => ({ ...n, deletable: false })));
+ setEdges(flow.edges);
+ setSaved(flow);
},
[setNodes],
);
- // Mirror the saved flow into the node whenever it changes (localStorage
- // rehydration, or the latest session restored from the backend), unless the
- // user has unsaved edits against the previously applied version.
- const appliedRef = useRef(DEFAULT_NODE_DATA);
+ // Load: saved flow, or a fresh default canvas when nothing is saved yet.
useEffect(() => {
- if (!hasHydrated || !savedFlow || !llmNode) return;
- const untouched = isSameData(llmNode.data, appliedRef.current);
- appliedRef.current = savedFlow;
- if (untouched) replaceNodeData(savedFlow);
- }, [hasHydrated, savedFlow, replaceNodeData]);
+ if (!isHydrated || !isAuthenticated) return;
+ let cancelled = false;
+ setIsLoading(true);
+ getChatbotFlow(chatbotId, apiKey)
+ .then((flow) => {
+ if (cancelled) return;
+ if (flow) {
+ showFlow({ nodes: flow.nodes, edges: flow.edges });
+ setFlowVersion({ version: flow.version, isLive: flow.is_live });
+ } else {
+ showFlow(createDefaultFlow());
+ }
+ })
+ .catch((err) => {
+ if (!cancelled) {
+ toast.error(
+ err instanceof Error ? err.message : "Failed to load the flow.",
+ );
+ }
+ })
+ .finally(() => {
+ if (!cancelled) setIsLoading(false);
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, [chatbotId, apiKey, isAuthenticated, isHydrated, showFlow]);
+
+ const savedSignature = useMemo(
+ () => (saved ? flowSignature(saved) : null),
+ [saved],
+ );
+ const isDirty =
+ savedSignature !== null &&
+ flowSignature({ nodes, edges }) !== savedSignature;
const updateNodeData = useCallback(
- (id: string, patch: Partial) => {
+ (id: string, patch: Partial) => {
setNodes((prev) =>
prev.map((n) =>
- n.id === id && isLlmNode(n)
+ n.id === id && n.type === "onboarding"
? { ...n, data: { ...n.data, ...patch } }
: n,
),
@@ -119,24 +99,64 @@ export function useChatbotFlow() {
[setNodes],
);
- const discard = useCallback(
- () => replaceNodeData(baseline),
- [baseline, replaceNodeData],
- );
+ /**
+ * Saves the canvas. The first save goes live directly (and is fully
+ * validated, so it may fail with 422); later saves are drafts.
+ */
+ const save = useCallback(async (): Promise => {
+ const payload = toFlowPayload(nodes, edges);
+ setIsSaving(true);
+ try {
+ const flow = await saveChatbotFlow(chatbotId, payload, apiKey);
+ setSaved(payload);
+ setFlowVersion({ version: flow.version, isLive: flow.is_live });
+ toast.success(
+ flow.is_live
+ ? `Saved and live (version ${flow.version}).`
+ : `Draft saved (version ${flow.version}). Publish to make it live.`,
+ );
+ return true;
+ } catch (err) {
+ toast.error(
+ err instanceof Error ? err.message : "Failed to save the flow.",
+ );
+ return false;
+ } finally {
+ setIsSaving(false);
+ }
+ }, [apiKey, chatbotId, edges, nodes, toast]);
- const markSaved = useCallback(
- (data: ChatbotNodeData) => setSavedFlow({ ...data }),
- [setSavedFlow],
- );
+ const publish = useCallback(async (): Promise => {
+ setIsPublishing(true);
+ try {
+ const flow = await publishChatbotFlow(chatbotId, apiKey);
+ setFlowVersion({ version: flow.version, isLive: flow.is_live });
+ toast.success(`Published version ${flow.version}.`);
+ } catch (err) {
+ toast.error(
+ err instanceof Error ? err.message : "Failed to publish the flow.",
+ );
+ } finally {
+ setIsPublishing(false);
+ }
+ }, [apiKey, chatbotId, toast]);
+
+ const discard = useCallback(() => {
+ if (saved) showFlow(saved);
+ }, [saved, showFlow]);
return {
nodes,
edges,
onNodesChange,
updateNodeData,
- llmNode,
+ isLoading,
isDirty,
+ isSaving,
+ isPublishing,
+ flowVersion,
+ save,
+ publish,
discard,
- markSaved,
};
}
diff --git a/app/hooks/chatbot/useChatbotSession.ts b/app/hooks/chatbot/useChatbotSession.ts
index b16b3547..83fceb8e 100644
--- a/app/hooks/chatbot/useChatbotSession.ts
+++ b/app/hooks/chatbot/useChatbotSession.ts
@@ -1,129 +1,45 @@
"use client";
-import { useCallback, useEffect, useRef, useState } from "react";
+import { useCallback, useRef, useState } from "react";
import { useAuth } from "@/app/lib/context/AuthContext";
import { useToast } from "@/app/hooks/useToast";
import { useChatbotReplyStream } from "@/app/hooks/chatbot/useChatbotReplyStream";
import { useChatbotPendingReply } from "@/app/hooks/chatbot/useChatbotPendingReply";
-import {
- useChatbotStore,
- useChatbotStoreHydration,
-} from "@/app/lib/store/chatbot";
-import {
- CHATBOT_REPLY_TIMEOUT_MS,
- DEFAULT_CHATBOT_NAME,
-} from "@/app/lib/constants";
+import { CHATBOT_REPLY_TIMEOUT_MS } from "@/app/lib/constants";
import {
createChatbotSession,
- getLatestChatbotSession,
- sendChatbotSessionMessage,
+ sendChatbotMessage,
} from "@/app/lib/chatbotClient";
-import {
- readChatbotSessionCookie,
- writeChatbotSessionCookie,
-} from "@/app/lib/utils/chatbotSessionCookie";
import {
ChatbotChatMessage,
- ChatbotNodeData,
- ChatbotSessionEvent,
ChatbotCompletionStatus,
+ ChatbotSessionEvent,
ChatbotSessionStatus,
} from "@/app/lib/types/chatbot";
-const ALREADY_STARTED_RE = /required once the session has started/i;
-const INACTIVE_RE = /session is inactive/i;
+const INACTIVE_RE = /inactive/i;
-/**
- * Manages the chatbot session: create it, send messages, show bot replies.
- * Pass `listen: false` on pages that don't chat (e.g. the canvas).
- */
-export function useChatbotSession({
- listen = true,
-}: { listen?: boolean } = {}) {
- const { activeKey, currentUser, isAuthenticated } = useAuth();
+export function useChatbotSession(chatbotId: string) {
+ const { activeKey, currentUser } = useAuth();
const apiKey = activeKey?.key ?? "";
const toast = useToast();
+ const contactRef = currentUser?.id ? `kaapi-user-${currentUser.id}` : null;
- useChatbotStoreHydration();
- const hasHydrated = useChatbotStore((s) => s.hasHydrated);
- const sessionId = useChatbotStore((s) => s.sessionId);
- const setSessionId = useChatbotStore((s) => s.setSessionId);
- const chatbotRef = useChatbotStore((s) => s.chatbotRef);
- const setChatbotRef = useChatbotStore((s) => s.setChatbotRef);
- const setSavedFlow = useChatbotStore((s) => s.setSavedFlow);
-
+ const [sessionId, setSessionId] = useState(null);
const [messages, setMessages] = useState([]);
- const lastEventSeqRef = useRef(0);
- const [sessionName, setSessionName] = useState(null);
const [status, setStatus] = useState(null);
const [completionStatus, setCompletionStatus] =
useState(null);
- const [isCreating, setIsCreating] = useState(false);
+ const [isStarting, setIsStarting] = useState(false);
+ const lastEventSeqRef = useRef(0);
const pending = useChatbotPendingReply(CHATBOT_REPLY_TIMEOUT_MS, () =>
toast.error("No reply from the chatbot yet. Try sending again."),
);
const { clear: clearPending, start: startPending } = pending;
- const contactRef = currentUser?.id ? `kaapi-user-${currentUser.id}` : null;
-
- // New session = fresh chat.
- useEffect(() => {
- setMessages([]);
- lastEventSeqRef.current = 0;
- }, [sessionId]);
-
- const activateSession = useCallback(
- (id: string) => {
- writeChatbotSessionCookie(id);
- setSessionId(id);
- },
- [setSessionId],
- );
-
- // On load: pick up the last session (cookie, else backend) and fill the flow from it.
- useEffect(() => {
- if (!hasHydrated || !isAuthenticated || !contactRef) return;
- const cookieId = readChatbotSessionCookie();
- if (cookieId) setSessionId(cookieId);
-
- let cancelled = false;
- getLatestChatbotSession(contactRef, chatbotRef, apiKey).then((summary) => {
- if (cancelled || !summary) return;
- if (cookieId && cookieId !== summary.session_id) return;
- if (!cookieId) activateSession(summary.session_id);
- const name = summary.name || DEFAULT_CHATBOT_NAME;
- setChatbotRef(summary.chatbot_ref);
- setSavedFlow({
- label: name,
- configId: summary.config_id,
- configVersion: summary.config_version ?? undefined,
- systemPrompt:
- summary.system_prompt ??
- useChatbotStore.getState().savedFlow?.systemPrompt ??
- "",
- });
- setSessionName(name);
- setStatus(summary.status);
- setCompletionStatus(summary.completion_status);
- });
- return () => {
- cancelled = true;
- };
- }, [
- hasHydrated,
- isAuthenticated,
- contactRef,
- chatbotRef,
- apiKey,
- activateSession,
- setSessionId,
- setChatbotRef,
- setSavedFlow,
- ]);
-
const handleEvent = useCallback(
(event: ChatbotSessionEvent, seq: number) => {
- if (seq <= lastEventSeqRef.current) return;
+ if (seq <= lastEventSeqRef.current) return; // already shown
lastEventSeqRef.current = seq;
clearPending();
if (!event.success) {
@@ -143,134 +59,63 @@ export function useChatbotSession({
[clearPending, toast],
);
- useChatbotReplyStream(hasHydrated && listen ? sessionId : null, handleEvent);
-
- const startSession = useCallback(
- async (
- data: ChatbotNodeData,
- { keepPending = false }: { keepPending?: boolean } = {},
- ): Promise => {
- if (!contactRef) {
- toast.error("Couldn't identify your account. Please log in again.");
- return null;
- }
- const name = data.label.trim() || DEFAULT_CHATBOT_NAME;
- setIsCreating(true);
- try {
- const created = await createChatbotSession(
- {
- name,
- chatbot_ref: chatbotRef ?? undefined,
- config_id: data.configId,
- config_version: data.configVersion,
- contact_ref: contactRef,
- system_prompt: data.systemPrompt,
- },
- apiKey,
- );
- setChatbotRef(created.chatbotRef);
- if (!keepPending) clearPending(); // keepPending: keep typing dots during a retry
- activateSession(created.sessionId);
- setSessionName(name);
- setStatus("active");
- setCompletionStatus("in_progress");
- return created.sessionId;
- } catch (err) {
- toast.error(
- err instanceof Error ? err.message : "Failed to save the flow.",
- );
- return null;
- } finally {
- setIsCreating(false);
- }
- },
- [
- activateSession,
- apiKey,
- chatbotRef,
- clearPending,
- contactRef,
- setChatbotRef,
- toast,
- ],
- );
-
- /** Sends one message (shown right away); removes it again if sending fails. */
- const queueTurn = useCallback(
- async (id: string, message?: string): Promise => {
- const optimisticId = message ? crypto.randomUUID() : null;
- if (optimisticId && message) {
- setMessages((prev) => [
- ...prev,
- { id: optimisticId, role: "user", content: message },
- ]);
- }
- startPending();
- try {
- await sendChatbotSessionMessage(id, message, apiKey);
- } catch (err) {
- if (optimisticId) {
- setMessages((prev) => prev.filter((m) => m.id !== optimisticId));
- }
- throw err;
- }
- },
- [apiKey, startPending],
- );
+ useChatbotReplyStream(sessionId, handleEvent);
+
+ const startChat = useCallback(async (): Promise => {
+ if (!contactRef) {
+ toast.error("Couldn't identify your account. Please log in again.");
+ return;
+ }
+ setIsStarting(true);
+ startPending();
+ try {
+ const created = await createChatbotSession(chatbotId, contactRef, apiKey);
+ lastEventSeqRef.current = 0;
+ setMessages([]);
+ setSessionId(created.session_id);
+ setStatus("active");
+ setCompletionStatus("in_progress");
+ await sendChatbotMessage(created.session_id, undefined, apiKey);
+ } catch (err) {
+ clearPending();
+ toast.error(
+ err instanceof Error ? err.message : "Failed to start the chat.",
+ );
+ } finally {
+ setIsStarting(false);
+ }
+ }, [apiKey, chatbotId, clearPending, contactRef, startPending, toast]);
- /**
- * Sends a user message, or no message to get the bot's greeting.
- * If the session already started elsewhere, starts a fresh one instead.
- */
const send = useCallback(
- async (message?: string): Promise => {
- if (!sessionId) {
- toast.error("Save the flow first to start a session.");
- return false;
- }
- const savedFlow = useChatbotStore.getState().savedFlow;
+ async (message: string): Promise => {
+ if (!sessionId) return false;
+ const id = crypto.randomUUID();
+ setMessages((prev) => [...prev, { id, role: "user", content: message }]);
+ startPending();
try {
- await queueTurn(sessionId, message);
+ await sendChatbotMessage(sessionId, message, apiKey);
return true;
} catch (err) {
+ setMessages((prev) => prev.filter((m) => m.id !== id));
+ clearPending();
const errorMessage =
err instanceof Error ? err.message : "Failed to send the message.";
if (INACTIVE_RE.test(errorMessage)) setStatus("inactive");
- if (message || !savedFlow || !ALREADY_STARTED_RE.test(errorMessage)) {
- clearPending();
- toast.error(errorMessage);
- return false;
- }
- }
- // Retry on a fresh session (typing dots stay on, so no flicker).
- const freshId = await startSession(savedFlow, { keepPending: true });
- if (!freshId) {
- clearPending();
- return false;
- }
- try {
- await queueTurn(freshId);
- return true;
- } catch (err) {
- clearPending();
- toast.error(
- err instanceof Error ? err.message : "Failed to start the chat.",
- );
+ toast.error(errorMessage);
return false;
}
},
- [clearPending, queueTurn, sessionId, startSession, toast],
+ [apiKey, clearPending, sessionId, startPending, toast],
);
return {
sessionId,
- sessionName,
+ messages,
status,
completionStatus,
- messages,
isPending: pending.isPending,
- isCreating,
- startSession,
+ isStarting,
+ startChat,
send,
};
}
diff --git a/app/hooks/chatbot/useChatbots.ts b/app/hooks/chatbot/useChatbots.ts
new file mode 100644
index 00000000..e1eefa92
--- /dev/null
+++ b/app/hooks/chatbot/useChatbots.ts
@@ -0,0 +1,45 @@
+"use client";
+
+import { useCallback, useEffect, useState } from "react";
+import { useAuth } from "@/app/lib/context/AuthContext";
+import { createChatbot, listChatbots } from "@/app/lib/chatbotClient";
+import { Chatbot } from "@/app/lib/types/chatbot";
+
+export function useChatbots() {
+ const { activeKey, isAuthenticated, isHydrated } = useAuth();
+ const apiKey = activeKey?.key ?? "";
+
+ const [chatbots, setChatbots] = useState([]);
+ const [isLoading, setIsLoading] = useState(true);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ if (!isHydrated || !isAuthenticated) return;
+ let cancelled = false;
+ setIsLoading(true);
+ listChatbots(apiKey)
+ .then((list) => {
+ if (!cancelled) setChatbots(list);
+ })
+ .catch((err) => {
+ if (!cancelled) {
+ setError(
+ err instanceof Error ? err.message : "Failed to load chatbots.",
+ );
+ }
+ })
+ .finally(() => {
+ if (!cancelled) setIsLoading(false);
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, [apiKey, isAuthenticated, isHydrated]);
+
+ const create = useCallback(
+ (name: string) => createChatbot(name, apiKey),
+ [apiKey],
+ );
+
+ return { chatbots, isLoading, error, create };
+}
diff --git a/app/lib/apiClient.ts b/app/lib/apiClient.ts
index 219dd435..96d6ebc3 100644
--- a/app/lib/apiClient.ts
+++ b/app/lib/apiClient.ts
@@ -102,7 +102,7 @@ function stringifyValidationDetail(value: unknown): string {
.join("; ");
}
-function extractErrorMessage(
+export function extractErrorMessage(
body: Record,
fallback: string,
): string {
diff --git a/app/lib/chatbotClient.ts b/app/lib/chatbotClient.ts
index f1c2a4fe..84a7cb70 100644
--- a/app/lib/chatbotClient.ts
+++ b/app/lib/chatbotClient.ts
@@ -1,56 +1,119 @@
-import { apiFetch } from "@/app/lib/apiClient";
import {
- ChatbotSessionSummary,
- ChatbotSessionSummaryResponse,
- CreateChatbotSessionRequest,
- CreateChatbotSessionResponse,
- SendChatbotSessionMessageResponse,
+ apiFetch,
+ apiFetchResponse,
+ extractErrorMessage,
+} from "@/app/lib/apiClient";
+import {
+ Chatbot,
+ ChatbotFlow,
+ ChatbotFlowGraph,
+ ChatbotFlowResponse,
+ ChatbotListResponse,
+ ChatbotResponse,
+ ChatbotSessionCreated,
+ ChatbotSessionCreatedResponse,
+ SendChatbotMessageResponse,
} from "@/app/lib/types/chatbot";
-export async function createChatbotSession(
- body: CreateChatbotSessionRequest,
+function unwrap(
+ res: { success: boolean; data: T | null; error?: string | null },
+ fallback: string,
+): T {
+ if (!res.success || res.data === null) throw new Error(res.error || fallback);
+ return res.data;
+}
+
+export async function listChatbots(apiKey: string): Promise {
+ const res = await apiFetch(
+ "/api/chatbots?skip=0&limit=100",
+ apiKey,
+ );
+ return unwrap(res, "Failed to load chatbots.");
+}
+
+export async function createChatbot(
+ name: string,
+ apiKey: string,
+): Promise {
+ const res = await apiFetch("/api/chatbots", apiKey, {
+ method: "POST",
+ body: JSON.stringify({ name }),
+ });
+ return unwrap(res, "Failed to create the chatbot.");
+}
+
+export async function getChatbot(id: string, apiKey: string): Promise {
+ const res = await apiFetch(
+ `/api/chatbots/${encodeURIComponent(id)}`,
+ apiKey,
+ );
+ return unwrap(res, "Failed to load the chatbot.");
+}
+
+export async function getChatbotFlow(
+ chatbotId: string,
apiKey: string,
-): Promise<{ sessionId: string; chatbotRef: string }> {
- const res = await apiFetch(
- "/api/chatbot/sessions",
+): Promise {
+ const res = await apiFetchResponse(
+ `/api/chatbots/${encodeURIComponent(chatbotId)}/flow`,
apiKey,
- { method: "POST", body: JSON.stringify(body) },
);
- if (!res.success || !res.data?.session_id) {
- throw new Error(res.error || "Failed to create the chatbot session.");
+ if (res.status === 404) return null;
+ const body = (await res.json().catch(() => ({}))) as Record;
+ if (!res.ok) {
+ throw new Error(extractErrorMessage(body, "Failed to load the flow."));
}
- return {
- sessionId: res.data.session_id,
- chatbotRef: res.data.chatbot_ref,
- };
+ return unwrap(
+ body as unknown as ChatbotFlowResponse,
+ "Failed to load the flow.",
+ );
+}
+
+export async function saveChatbotFlow(
+ chatbotId: string,
+ flow: ChatbotFlowGraph,
+ apiKey: string,
+): Promise {
+ const res = await apiFetch(
+ `/api/chatbots/${encodeURIComponent(chatbotId)}/flow`,
+ apiKey,
+ { method: "PUT", body: JSON.stringify(flow) },
+ );
+ return unwrap(res, "Failed to save the flow.");
}
-export async function getLatestChatbotSession(
+export async function publishChatbotFlow(
+ chatbotId: string,
+ apiKey: string,
+): Promise {
+ const res = await apiFetch(
+ `/api/chatbots/${encodeURIComponent(chatbotId)}/flow/publish`,
+ apiKey,
+ { method: "POST" },
+ );
+ return unwrap(res, "Failed to publish the flow.");
+}
+
+export async function createChatbotSession(
+ chatbotId: string,
contactRef: string,
- chatbotRef: string | null,
apiKey: string,
-): Promise {
- try {
- const params = new URLSearchParams({ contact_ref: contactRef });
- if (chatbotRef) params.set("chatbot_ref", chatbotRef);
- const res = await apiFetch(
- `/api/chatbot/sessions?${params}`,
- apiKey,
- );
- return res.success ? (res.data ?? null) : null;
- } catch {
- // 404 = no session for this contact yet.
- return null;
- }
+): Promise {
+ const res = await apiFetch(
+ `/api/chatbots/${encodeURIComponent(chatbotId)}/sessions`,
+ apiKey,
+ { method: "POST", body: JSON.stringify({ contact_ref: contactRef }) },
+ );
+ return unwrap(res, "Failed to start a chat session.");
}
-export async function sendChatbotSessionMessage(
+export async function sendChatbotMessage(
sessionId: string,
message: string | undefined,
apiKey: string,
): Promise {
- const res = await apiFetch(
- `/api/chatbot/sessions/${encodeURIComponent(sessionId)}/message`,
+ const res = await apiFetch(
+ `/api/chatbots/sessions/${encodeURIComponent(sessionId)}/message`,
apiKey,
{ method: "POST", body: JSON.stringify(message ? { message } : {}) },
);
diff --git a/app/lib/constants.ts b/app/lib/constants.ts
index 3dddc2f9..df03d4ec 100644
--- a/app/lib/constants.ts
+++ b/app/lib/constants.ts
@@ -23,7 +23,6 @@ export const STORAGE_KEYS = {
DOCUMENT_SIZES: "document_file_sizes",
SIDEBAR_MENUS: "sidebar-expanded-menus",
CHAT_STATE: "kaapi_chat_state",
- CHATBOT_STATE: "kaapi_chatbot_state",
} as const;
export const COOKIE_KEYS = {
@@ -31,7 +30,6 @@ export const COOKIE_KEYS = {
FEATURES: "kaapi_features",
API_KEY: "kaapi_api_key",
API_KEY_META: "kaapi_api_key_meta",
- CHATBOT_SESSION: "kaapi_chatbot_session",
} as const;
export const AUTH_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
diff --git a/app/lib/context/AuthContext.tsx b/app/lib/context/AuthContext.tsx
index 1e21a291..5f2b953c 100644
--- a/app/lib/context/AuthContext.tsx
+++ b/app/lib/context/AuthContext.tsx
@@ -22,8 +22,6 @@ import {
STORAGE_KEYS,
} from "@/app/lib/constants";
import { useChatStore } from "@/app/lib/store/chat";
-import { useChatbotStore } from "@/app/lib/store/chatbot";
-import { clearChatbotSessionCookie } from "@/app/lib/utils/chatbotSessionCookie";
import { clearAllStorage, readClientCookie } from "@/app/lib/utils";
export type { User, GoogleProfile, Session } from "@/app/lib/types/auth";
@@ -147,8 +145,6 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
setCurrentUser(null);
clearAllStorage();
useChatStore.getState().reset();
- useChatbotStore.getState().reset();
- clearChatbotSessionCookie();
setApiKeys([]);
window.dispatchEvent(new Event("kaapi-auth-changed"));
window.location.replace("/chat");
diff --git a/app/lib/store/chatbot.ts b/app/lib/store/chatbot.ts
deleted file mode 100644
index ee5fb72a..00000000
--- a/app/lib/store/chatbot.ts
+++ /dev/null
@@ -1,83 +0,0 @@
-"use client";
-
-import { useEffect } from "react";
-import { create } from "zustand";
-import { createJSONStorage, persist } from "zustand/middleware";
-import { STORAGE_KEYS } from "@/app/lib/constants";
-import { ChatbotNodeData } from "@/app/lib/types/chatbot";
-
-interface ChatbotState {
- savedFlow: ChatbotNodeData | null;
- chatbotRef: string | null;
- sessionId: string | null;
- hasHydrated: boolean;
-}
-
-interface ChatbotActions {
- setSavedFlow: (flow: ChatbotNodeData) => void;
- setChatbotRef: (ref: string) => void;
- setSessionId: (sessionId: string) => void;
- setHasHydrated: (value: boolean) => void;
- reset: () => void;
-}
-
-const INITIAL_STATE: ChatbotState = {
- savedFlow: null,
- chatbotRef: null,
- sessionId: null,
- hasHydrated: false,
-};
-
-type PersistedChatbotState = Pick<
- ChatbotState,
- "savedFlow" | "chatbotRef" | "sessionId"
->;
-
-export const useChatbotStore = create()(
- persist(
- (set) => ({
- ...INITIAL_STATE,
-
- setSavedFlow: (flow) => set({ savedFlow: flow }),
-
- setChatbotRef: (ref) => set({ chatbotRef: ref }),
-
- setSessionId: (sessionId) => set({ sessionId }),
-
- setHasHydrated: (value) => set({ hasHydrated: value }),
-
- reset: () => set({ ...INITIAL_STATE, hasHydrated: true }),
- }),
- {
- name: STORAGE_KEYS.CHATBOT_STATE,
- version: 2,
- storage: createJSONStorage(() => localStorage),
- skipHydration: true,
- partialize: (state): PersistedChatbotState => ({
- savedFlow: state.savedFlow,
- chatbotRef: state.chatbotRef,
- sessionId: state.sessionId,
- }),
- onRehydrateStorage: () => (state) => {
- state?.setHasHydrated(true);
- },
- },
- ),
-);
-
-/**
- * Loads the persisted chatbot state and keeps it in sync across tabs, so a
- * chat tab follows a session created by Save on the canvas tab.
- */
-export function useChatbotStoreHydration(): void {
- useEffect(() => {
- useChatbotStore.persist.rehydrate();
- const onStorage = (e: StorageEvent) => {
- if (e.key === STORAGE_KEYS.CHATBOT_STATE) {
- useChatbotStore.persist.rehydrate();
- }
- };
- window.addEventListener("storage", onStorage);
- return () => window.removeEventListener("storage", onStorage);
- }, []);
-}
diff --git a/app/lib/types/chatbot.ts b/app/lib/types/chatbot.ts
index 1cefe99f..8e67ebe6 100644
--- a/app/lib/types/chatbot.ts
+++ b/app/lib/types/chatbot.ts
@@ -1,84 +1,77 @@
-import type { Node } from "@xyflow/react";
+import type { Edge, Node } from "@xyflow/react";
+import type { APIResponse } from "@/app/lib/types/configs";
-export type ChatbotNodeData = {
- label: string;
- systemPrompt: string;
- configId: string;
- configVersion?: number;
+export interface Chatbot {
+ id: string;
+ name: string;
+ organization_id: number;
+ project_id: number;
+ inserted_at: string;
+ updated_at: string;
+}
+
+export type OnboardingNodeData = {
+ config_id?: string;
+ config_version?: number;
+ prompt?: string;
};
-export type ChatbotLlmNode = Node;
+export type ChatbotOnboardingNode = Node;
export type ChatbotTerminalNode = Node<
- { label: string },
- "flowInput" | "flowOutput"
+ Record,
+ "input" | "output"
>;
-export type ChatbotFlowNode = ChatbotLlmNode | ChatbotTerminalNode;
-
-export type ChatbotSessionStatus = "active" | "inactive";
-
-export type ChatbotCompletionStatus = "in_progress" | "complete" | "abandoned";
+export type ChatbotFlowNode = ChatbotOnboardingNode | ChatbotTerminalNode;
-export type ChatbotCollectedFields = Record;
+export interface ChatbotFlowGraph {
+ nodes: ChatbotFlowNode[];
+ edges: Edge[];
+}
-export interface ChatbotChatMessage {
+export interface ChatbotFlow extends ChatbotFlowGraph {
id: string;
- role: "user" | "assistant";
- content: string;
+ chatbot_id: string;
+ version: number;
+ is_live: boolean;
+ inserted_at: string;
+ updated_at: string;
}
-export interface CreateChatbotSessionRequest {
- name: string;
- chatbot_ref?: string;
- config_id: string;
- config_version?: number;
- contact_ref: string;
- system_prompt: string;
+export interface ChatbotSessionCreated {
+ session_id: string;
+ chatbot_id: string;
+ flow_version: number;
+ active_node_id: string;
}
-export interface CreateChatbotSessionResponse {
- success: boolean;
- data?: { session_id: string; chatbot_ref: string } | null;
- error?: string | null;
-}
+export type ChatbotSessionStatus = "active" | "inactive";
-export interface ChatbotSessionSummary {
- session_id: string;
- name: string;
- contact_ref: string;
- chatbot_ref: string;
- status: ChatbotSessionStatus;
- completion_status: ChatbotCompletionStatus;
- collected_fields: ChatbotCollectedFields;
- config_id: string;
- config_version: number | null;
- system_prompt: string;
- inserted_at: string;
- updated_at: string;
-}
+export type ChatbotCompletionStatus = "in_progress" | "complete" | "abandoned";
-export interface ChatbotSessionSummaryResponse {
- success: boolean;
- data?: ChatbotSessionSummary | null;
- error?: string | null;
+export interface ChatbotChatMessage {
+ id: string;
+ role: "user" | "assistant";
+ content: string;
}
-export interface SendChatbotSessionMessageResponse {
- success: boolean;
- data?: { status: string } | null;
- error?: string | null;
-}
+export type ChatbotResponse = APIResponse;
+export type ChatbotListResponse = APIResponse;
+export type ChatbotFlowResponse = APIResponse;
+export type ChatbotSessionCreatedResponse = APIResponse;
+export type SendChatbotMessageResponse = APIResponse<{ status: string }>;
export interface ChatbotWebhookPayload {
success: boolean;
data?: {
session_id: string;
- chatbot_ref?: string;
+ chatbot_id?: string;
+ node_id?: string;
reply?: string;
status?: ChatbotSessionStatus;
completion_status?: ChatbotCompletionStatus;
- collected_fields?: ChatbotCollectedFields;
+ collected_fields?: Record;
} | null;
error?: string | null;
}
diff --git a/app/lib/utils/chatbotFlow.ts b/app/lib/utils/chatbotFlow.ts
new file mode 100644
index 00000000..d488c920
--- /dev/null
+++ b/app/lib/utils/chatbotFlow.ts
@@ -0,0 +1,45 @@
+import type { Edge } from "@xyflow/react";
+import type {
+ ChatbotFlowGraph,
+ ChatbotFlowNode,
+} from "@/app/lib/types/chatbot";
+
+const INPUT_ID = "input";
+const OUTPUT_ID = "output";
+
+function newAgentNodeId(): string {
+ return `LLMResponseWithPrompt-${Math.random().toString(16).slice(2, 7)}`;
+}
+
+export function createDefaultFlow(): ChatbotFlowGraph {
+ const agentId = newAgentNodeId();
+ return {
+ nodes: [
+ { id: INPUT_ID, type: "input", position: { x: 0, y: 55 }, data: {} },
+ { id: agentId, type: "onboarding", position: { x: 260, y: 0 }, data: {} },
+ { id: OUTPUT_ID, type: "output", position: { x: 640, y: 230 }, data: {} },
+ ],
+ edges: [
+ { id: `${INPUT_ID}->${agentId}`, source: INPUT_ID, target: agentId },
+ { id: `${agentId}->${OUTPUT_ID}`, source: agentId, target: OUTPUT_ID },
+ ],
+ };
+}
+
+export function toFlowPayload(
+ nodes: ChatbotFlowNode[],
+ edges: Edge[],
+): ChatbotFlowGraph {
+ return {
+ nodes: nodes.map(
+ ({ id, type, position, data }) =>
+ ({ id, type, position, data }) as ChatbotFlowNode,
+ ),
+ edges: edges.map(({ id, source, target }) => ({ id, source, target })),
+ };
+}
+
+export function flowSignature(flow: ChatbotFlowGraph): string {
+ const { nodes, edges } = toFlowPayload(flow.nodes, flow.edges);
+ return JSON.stringify({ nodes, edges });
+}
diff --git a/app/lib/utils/chatbotSessionCookie.ts b/app/lib/utils/chatbotSessionCookie.ts
deleted file mode 100644
index 33d46670..00000000
--- a/app/lib/utils/chatbotSessionCookie.ts
+++ /dev/null
@@ -1,19 +0,0 @@
-import { AUTH_COOKIE_MAX_AGE, COOKIE_KEYS } from "@/app/lib/constants";
-import { readClientCookie } from "@/app/lib/utils";
-
-export function readChatbotSessionCookie(): string | null {
- return readClientCookie(COOKIE_KEYS.CHATBOT_SESSION) || null;
-}
-
-export function clearChatbotSessionCookie(): void {
- if (typeof document === "undefined") return;
- document.cookie = `${COOKIE_KEYS.CHATBOT_SESSION}=; Path=/; Max-Age=0; SameSite=Lax`;
-}
-
-export function writeChatbotSessionCookie(sessionId: string): void {
- if (typeof document === "undefined") return;
- const secure = process.env.NODE_ENV === "production" ? "; Secure" : "";
- document.cookie = `${COOKIE_KEYS.CHATBOT_SESSION}=${encodeURIComponent(
- sessionId,
- )}; Path=/; Max-Age=${AUTH_COOKIE_MAX_AGE}; SameSite=Lax${secure}`;
-}
From d319e09ac78e989e99036cea3f62831fdaccfd44 Mon Sep 17 00:00:00 2001
From: Ayush8923 <80516839+Ayush8923@users.noreply.github.com>
Date: Thu, 29 Oct 2026 17:08:11 +0530
Subject: [PATCH 11/12] fix: remove the js comment
---
app/(main)/chatbot/[chatbotId]/page.tsx | 2 --
1 file changed, 2 deletions(-)
diff --git a/app/(main)/chatbot/[chatbotId]/page.tsx b/app/(main)/chatbot/[chatbotId]/page.tsx
index b245487c..1404df58 100644
--- a/app/(main)/chatbot/[chatbotId]/page.tsx
+++ b/app/(main)/chatbot/[chatbotId]/page.tsx
@@ -15,7 +15,6 @@ import { useToast } from "@/app/hooks/useToast";
import { useChatbot } from "@/app/hooks/chatbot/useChatbot";
import { useChatbotFlow } from "@/app/hooks/chatbot/useChatbotFlow";
-/** Flow editor of one chatbot: edit, save a draft, publish, and test. */
export default function ChatbotEditorPage() {
const { chatbotId } = useParams<{ chatbotId: string }>();
const { sidebarCollapsed } = useApp();
@@ -59,7 +58,6 @@ export default function ChatbotEditorPage() {
>
{flow.isSaving ? "Saving…" : "Save"}
- {/* Only a draft (newer than live) can be published. */}
{flowVersion && !flowVersion.isLive && (