>;
+ lastSeq: number;
+}
diff --git a/app/lib/types/webhookSecret.ts b/app/lib/types/webhookSecret.ts
index 957ef572..d06def7b 100644
--- a/app/lib/types/webhookSecret.ts
+++ b/app/lib/types/webhookSecret.ts
@@ -1,7 +1,7 @@
export type WebhookSecretFailureReason =
- | "prompt_improvement_webhook_secret_missing"
- | "prompt_improvement_webhook_secret_placeholder"
- | "prompt_improvement_webhook_secret_invalid_format";
+ | "webhook_secret_missing"
+ | "webhook_secret_placeholder"
+ | "webhook_secret_invalid_format";
export interface WebhookSecretResult {
ok: boolean;
diff --git a/app/lib/utils/chatbotSessionCookie.ts b/app/lib/utils/chatbotSessionCookie.ts
new file mode 100644
index 00000000..818d53b3
--- /dev/null
+++ b/app/lib/utils/chatbotSessionCookie.ts
@@ -0,0 +1,15 @@
+import { AUTH_COOKIE_MAX_AGE, COOKIE_KEYS } from "@/app/lib/constants";
+import { readClientCookie } from "@/app/lib/utils";
+
+/** Session id of the last saved chatbot flow, kept in a cookie. */
+export function readChatbotSessionCookie(): string | null {
+ return readClientCookie(COOKIE_KEYS.CHATBOT_SESSION) || null;
+}
+
+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}`;
+}
diff --git a/app/lib/webhookCallback.ts b/app/lib/webhookCallback.ts
new file mode 100644
index 00000000..e6e0c822
--- /dev/null
+++ b/app/lib/webhookCallback.ts
@@ -0,0 +1,37 @@
+import "server-only";
+
+import type { NextRequest } from "next/server";
+import { readWebhookSecret } from "@/app/lib/webhookSecret";
+
+/**
+ * Builds the public HTTPS URL the backend should POST async results to.
+ * The shared secret rides along as `secret_value` so the receiving route
+ * can reject forged deliveries.
+ */
+export function buildWebhookCallbackUrl(
+ request: NextRequest,
+ path: string,
+): string {
+ const secretResult = readWebhookSecret();
+ if (!secretResult.ok || !secretResult.secret) {
+ throw new Error(secretResult.reason ?? "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}${path}?secret_value=${encodeURIComponent(secretResult.secret)}`;
+}
+
+/** True when the incoming webhook carries our shared secret. */
+export function isWebhookAuthorized(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;
+}
diff --git a/app/lib/webhookSecret.ts b/app/lib/webhookSecret.ts
index d75add19..ca27ccd3 100644
--- a/app/lib/webhookSecret.ts
+++ b/app/lib/webhookSecret.ts
@@ -21,21 +21,21 @@ export type {
* surface a specific error code instead of a generic 500.
*/
export function readWebhookSecret(): WebhookSecretResult {
- const raw = process.env.PROMPT_IMPROVEMENT_WEBHOOK_SECRET;
+ const raw = process.env.WEBHOOK_SECRET;
if (!raw) {
- return { ok: false, reason: "prompt_improvement_webhook_secret_missing" };
+ return { ok: false, reason: "webhook_secret_missing" };
}
const trimmed = raw.trim();
if (PLACEHOLDER_VALUES.has(trimmed.toLowerCase())) {
return {
ok: false,
- reason: "prompt_improvement_webhook_secret_placeholder",
+ reason: "webhook_secret_placeholder",
};
}
if (!HEX_SECRET_REGEX.test(trimmed)) {
return {
ok: false,
- reason: "prompt_improvement_webhook_secret_invalid_format",
+ reason: "webhook_secret_invalid_format",
};
}
return { ok: true, secret: trimmed };
From 6cc3e3841028a9314a0ae95c3c75492dad62bc18 Mon Sep 17 00:00:00 2001
From: Ayush8923 <80516839+Ayush8923@users.noreply.github.com>
Date: Fri, 23 Oct 2026 23:35:22 +0530
Subject: [PATCH 3/7] fix: added the features flags, cleanups and few updates
---
app/(main)/chatbot/page.tsx | 5 --
app/api/webhooks/chatbot/stream/route.ts | 10 +++-
app/components/chat/AssistantAvatar.tsx | 9 ++++
app/components/chat/ChatMessage.tsx | 22 +++------
app/components/chat/ThinkingDots.tsx | 9 ++++
app/components/chat/index.ts | 2 +
app/components/chatbot/ChatbotChatInput.tsx | 8 ++--
app/components/chatbot/ChatbotTestPanel.tsx | 18 +++++---
app/components/chatbot/LlmNodePromptField.tsx | 16 +++----
app/components/ui/Button.tsx | 3 +-
app/hooks/useChatbotFlow.ts | 13 ++++--
app/hooks/useChatbotReplyStream.ts | 5 +-
app/hooks/useChatbotSession.ts | 46 +++++++++++--------
app/lib/chatbotClient.ts | 14 +++---
app/lib/constants.ts | 4 +-
app/lib/context/AuthContext.tsx | 5 ++
app/lib/navConfig.ts | 1 +
app/lib/store/chatbot.ts | 9 ++--
app/lib/store/chatbotSessionStore.ts | 14 +++---
app/lib/types/chatbot.ts | 1 +
app/lib/utils/chatbotSessionCookie.ts | 5 ++
middleware.ts | 5 +-
22 files changed, 133 insertions(+), 91 deletions(-)
create mode 100644 app/components/chat/AssistantAvatar.tsx
create mode 100644 app/components/chat/ThinkingDots.tsx
diff --git a/app/(main)/chatbot/page.tsx b/app/(main)/chatbot/page.tsx
index 92b7f3d1..d3af1299 100644
--- a/app/(main)/chatbot/page.tsx
+++ b/app/(main)/chatbot/page.tsx
@@ -1,8 +1,3 @@
-/**
- * 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";
diff --git a/app/api/webhooks/chatbot/stream/route.ts b/app/api/webhooks/chatbot/stream/route.ts
index e61b6418..e1618d94 100644
--- a/app/api/webhooks/chatbot/stream/route.ts
+++ b/app/api/webhooks/chatbot/stream/route.ts
@@ -1,5 +1,6 @@
import { NextRequest } from "next/server";
import {
+ getLatestSessionSeq,
getSessionEventsAfter,
subscribeSessionEvents,
} from "@/app/lib/store/chatbotSessionStore";
@@ -18,15 +19,20 @@ function parseSeq(value: string | null): number {
/**
* 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.
+ * `Last-Event-ID`; `after` lets a fresh page load skip replies it already
+ * has, and `after=latest` skips the whole backlog (a client with no local
+ * transcript must not get old bot replies without the user turns between).
*/
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 afterParam = request.nextUrl.searchParams.get("after");
const afterSeq = Math.max(
- parseSeq(request.nextUrl.searchParams.get("after")),
+ afterParam === "latest"
+ ? getLatestSessionSeq(sessionId)
+ : parseSeq(afterParam),
parseSeq(request.headers.get("last-event-id")),
);
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/ChatbotChatInput.tsx b/app/components/chatbot/ChatbotChatInput.tsx
index 8ed3de6c..f8507a3e 100644
--- a/app/components/chatbot/ChatbotChatInput.tsx
+++ b/app/components/chatbot/ChatbotChatInput.tsx
@@ -1,6 +1,7 @@
"use client";
import { FormEvent, useState } from "react";
+import { Button } from "@/app/components/ui";
import { SendIcon } from "@/app/components/icons";
interface ChatbotChatInputProps {
@@ -37,14 +38,15 @@ export default function ChatbotChatInput({
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/ChatbotTestPanel.tsx b/app/components/chatbot/ChatbotTestPanel.tsx
index 6161dff5..7adedb69 100644
--- a/app/components/chatbot/ChatbotTestPanel.tsx
+++ b/app/components/chatbot/ChatbotTestPanel.tsx
@@ -9,6 +9,7 @@
import { useEffect, useRef } from "react";
import { Button } from "@/app/components/ui";
import { RefreshIcon } from "@/app/components/icons";
+import { AssistantAvatar, ThinkingDots } from "@/app/components/chat";
import ChatbotMessageBubble from "@/app/components/chatbot/ChatbotMessageBubble";
import ChatbotChatInput from "@/app/components/chatbot/ChatbotChatInput";
import { ChatbotStatusBadge } from "@/app/components/chatbot/ChatbotSessionInfo";
@@ -71,15 +72,17 @@ export default function ChatbotTestPanel({
/>
{hasSession && (
-
+
)}
@@ -113,9 +116,12 @@ export default function ChatbotTestPanel({
))}
{isPending && (
-
- Bot is typing…
-
+
)}
{isEnded && (
diff --git a/app/components/chatbot/LlmNodePromptField.tsx b/app/components/chatbot/LlmNodePromptField.tsx
index 2b3755da..756a632b 100644
--- a/app/components/chatbot/LlmNodePromptField.tsx
+++ b/app/components/chatbot/LlmNodePromptField.tsx
@@ -1,12 +1,8 @@
-/**
- * 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 { Button, Modal } from "@/app/components/ui";
import { ExpandIcon } from "@/app/components/icons";
import NodeFieldLabel from "@/app/components/chatbot/NodeFieldLabel";
@@ -30,14 +26,17 @@ export default function LlmNodePromptField({
label="Prompt"
hint="System prompt for this step. Editing it resets the test conversation."
>
-
+
- {/* Portaled: the canvas viewport is CSS-transformed, which breaks `fixed`. */}
{expanded &&
createPortal(
{
variant?: ButtonVariant;
@@ -40,6 +40,7 @@ const sizeStyles: Record = {
sm: "px-3 py-1.5",
md: "px-4 py-2",
lg: "px-5 py-2.5",
+ icon: "p-1.5",
};
export default function Button({
diff --git a/app/hooks/useChatbotFlow.ts b/app/hooks/useChatbotFlow.ts
index 55adacfb..c1787598 100644
--- a/app/hooks/useChatbotFlow.ts
+++ b/app/hooks/useChatbotFlow.ts
@@ -101,12 +101,15 @@ export function useChatbotFlow() {
[setNodes],
);
- // Load the saved flow into the node once localStorage has rehydrated.
- const restoredRef = useRef(false);
+ // 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);
useEffect(() => {
- if (!hasHydrated || restoredRef.current) return;
- restoredRef.current = true;
- if (savedFlow) replaceNodeData(savedFlow);
+ if (!hasHydrated || !savedFlow || !llmNode) return;
+ const untouched = isSameData(llmNode.data, appliedRef.current);
+ appliedRef.current = savedFlow;
+ if (untouched) replaceNodeData(savedFlow);
}, [hasHydrated, savedFlow, replaceNodeData]);
const onConnect = useCallback(
diff --git a/app/hooks/useChatbotReplyStream.ts b/app/hooks/useChatbotReplyStream.ts
index 641c80d2..fc27b4a3 100644
--- a/app/hooks/useChatbotReplyStream.ts
+++ b/app/hooks/useChatbotReplyStream.ts
@@ -24,9 +24,12 @@ export function useChatbotReplyStream(
useEffect(() => {
if (!sessionId || typeof EventSource === "undefined") return;
+ // 0 = nothing applied yet for this session: take only new replies,
+ // never a backlog of bot messages without the user turns around them.
+ const afterSeq = getAfterSeqRef.current();
const params = new URLSearchParams({
session_id: sessionId,
- after: String(getAfterSeqRef.current()),
+ after: afterSeq > 0 ? String(afterSeq) : "latest",
});
const source = new EventSource(`/api/webhooks/chatbot/stream?${params}`);
source.addEventListener("reply", (e) => {
diff --git a/app/hooks/useChatbotSession.ts b/app/hooks/useChatbotSession.ts
index dacae371..af776783 100644
--- a/app/hooks/useChatbotSession.ts
+++ b/app/hooks/useChatbotSession.ts
@@ -44,6 +44,7 @@ export function useChatbotSession() {
const setLastEventSeq = useChatbotStore((s) => s.setLastEventSeq);
const chatbotRef = useChatbotStore((s) => s.chatbotRef);
const setChatbotRef = useChatbotStore((s) => s.setChatbotRef);
+ const setSavedFlow = useChatbotStore((s) => s.setSavedFlow);
const [sessionId, setSessionId] = useState(null);
const [sessionName, setSessionName] = useState(null);
@@ -56,8 +57,7 @@ export function useChatbotSession() {
);
const { clear: clearPending, start: startPending } = pending;
- const userRef = currentUser?.id ?? activeKey?.id;
- const contactRef = userRef ? `kaapi-user-${userRef}` : null;
+ const contactRef = currentUser?.id ? `kaapi-user-${currentUser.id}` : null;
const adoptSession = useCallback(
(id: string) => {
@@ -67,27 +67,37 @@ export function useChatbotSession() {
[bindSession],
);
- // Restore the session from the cookie, falling back to this chatbot's
- // latest session for the contact; either way refresh its status. The
- // lookup needs the chatbot ref, which only exists after the first save.
+ // Restore the session from the cookie, falling back to the user's latest
+ // session; when found, its name/config/prompt become the saved flow and its
+ // backend-issued chatbot ref is stored for later calls. The ref is sent
+ // only if we have it.
useEffect(() => {
if (!hasHydrated || !isAuthenticated || !contactRef) return;
const cookieId = readChatbotSessionCookie();
if (cookieId) adoptSession(cookieId);
- if (!chatbotRef) return;
let cancelled = false;
getLatestChatbotSession(contactRef, chatbotRef, apiKey).then((summary) => {
if (cancelled || !summary) return;
+ if (cookieId && cookieId !== summary.session_id) return;
if (!cookieId) {
writeChatbotSessionCookie(summary.session_id);
adoptSession(summary.session_id);
}
- if (!cookieId || cookieId === summary.session_id) {
- setSessionName(summary.name || DEFAULT_CHATBOT_NAME);
- setStatus(summary.status);
- setCompletionStatus(summary.completion_status);
- }
+ 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;
@@ -99,6 +109,8 @@ export function useChatbotSession() {
chatbotRef,
apiKey,
adoptSession,
+ setChatbotRef,
+ setSavedFlow,
]);
const handleEvent = useCallback(
@@ -142,6 +154,7 @@ export function useChatbotSession() {
const created = await createChatbotSession(
{
name,
+ // Backend issues the ref on the first save; resend it afterwards.
chatbot_ref: chatbotRef ?? undefined,
config_id: data.configId,
config_version: data.configVersion,
@@ -167,15 +180,7 @@ export function useChatbotSession() {
setIsCreating(false);
}
},
- [
- adoptSession,
- apiKey,
- chatbotRef,
- clearPending,
- contactRef,
- setChatbotRef,
- toast,
- ],
+ [adoptSession, apiKey, clearPending, contactRef, setChatbotRef, toast],
);
/** Queues a turn; omit `message` to get the greeting on a fresh session. */
@@ -206,6 +211,7 @@ export function useChatbotSession() {
[
appendMessage,
apiKey,
+ chatbotRef,
clearPending,
removeMessage,
sessionId,
diff --git a/app/lib/chatbotClient.ts b/app/lib/chatbotClient.ts
index ac837ef6..5edb2f11 100644
--- a/app/lib/chatbotClient.ts
+++ b/app/lib/chatbotClient.ts
@@ -29,17 +29,19 @@ export async function createChatbotSession(
};
}
-/** Latest session of this chatbot for the contact, or null when there is none. */
+/**
+ * Latest session for the contact, or null when there is none. Without a
+ * `chatbotRef` the backend picks the contact's latest session and its
+ * response carries the ref to reuse from then on.
+ */
export async function getLatestChatbotSession(
contactRef: string,
- chatbotRef: string,
+ chatbotRef: string | null,
apiKey: string,
): Promise {
try {
- const params = new URLSearchParams({
- contact_ref: contactRef,
- chatbot_ref: chatbotRef,
- });
+ const params = new URLSearchParams({ contact_ref: contactRef });
+ if (chatbotRef) params.set("chatbot_ref", chatbotRef);
const res = await apiFetch(
`/api/chatbot/sessions?${params}`,
apiKey,
diff --git a/app/lib/constants.ts b/app/lib/constants.ts
index 45d0d926..3dddc2f9 100644
--- a/app/lib/constants.ts
+++ b/app/lib/constants.ts
@@ -11,6 +11,7 @@ export const APP_NAME = "Kaapi Konsole";
export const FeatureFlag = {
ASSESSMENT: ASSESSMENT_FEATURE_FLAG,
+ CHATBOT: "CHATBOT",
} as const;
export type FeatureFlagKey = (typeof FeatureFlag)[keyof typeof FeatureFlag];
@@ -216,9 +217,6 @@ export const PAGER_BUTTON_IDLE =
export const PAGER_BUTTON_CURRENT =
"border-accent-primary bg-accent-primary text-white font-semibold";
-/** Chatbot flow — how long the test panel waits for a webhook reply. */
export const CHATBOT_REPLY_TIMEOUT_MS = 90_000;
-/** Backend limit on a chatbot session's `name`. */
export const CHATBOT_NAME_MAX_LENGTH = 255;
-/** Name used when the title is empty or the backend returns none (matches backend default). */
export const DEFAULT_CHATBOT_NAME = "Untitled Chatbot";
diff --git a/app/lib/context/AuthContext.tsx b/app/lib/context/AuthContext.tsx
index 5f2b953c..2d944192 100644
--- a/app/lib/context/AuthContext.tsx
+++ b/app/lib/context/AuthContext.tsx
@@ -22,6 +22,8 @@ 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";
@@ -145,6 +147,9 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
setCurrentUser(null);
clearAllStorage();
useChatStore.getState().reset();
+ // Chatbot session + transcript belong to this user; never leak them to the next login.
+ useChatbotStore.getState().reset();
+ clearChatbotSessionCookie();
setApiKeys([]);
window.dispatchEvent(new Event("kaapi-auth-changed"));
window.location.replace("/chat");
diff --git a/app/lib/navConfig.ts b/app/lib/navConfig.ts
index c608682a..801b88d4 100644
--- a/app/lib/navConfig.ts
+++ b/app/lib/navConfig.ts
@@ -52,6 +52,7 @@ export const NAV_ITEMS: NavItemConfig[] = [
name: "Chatbot",
route: "/chatbot",
icon: "flow",
+ featureFlag: FeatureFlag.CHATBOT,
gateDescription: "Log in to build and test chatbot flows.",
},
{
diff --git a/app/lib/store/chatbot.ts b/app/lib/store/chatbot.ts
index b63ab3d1..e5f39fcd 100644
--- a/app/lib/store/chatbot.ts
+++ b/app/lib/store/chatbot.ts
@@ -2,21 +2,16 @@
import { create } from "zustand";
import { createJSONStorage, persist } from "zustand/middleware";
-
import { STORAGE_KEYS } from "@/app/lib/constants";
import { ChatbotChatMessage, ChatbotNodeData } from "@/app/lib/types/chatbot";
const MAX_PERSISTED_MESSAGES = 100;
interface ChatbotState {
- /** Last saved node data; the baseline for Discard and dirty checks. */
savedFlow: ChatbotNodeData | null;
- /** Backend-generated chatbot id; null until the first save. */
chatbotRef: string | null;
- /** Session the stored transcript belongs to (the cookie holds the active id). */
messagesSessionId: string | null;
messages: ChatbotChatMessage[];
- /** Last webhook event applied, so reconnects don't replay it. */
lastEventSeq: number;
hasHydrated: boolean;
}
@@ -24,12 +19,12 @@ interface ChatbotState {
interface ChatbotActions {
setSavedFlow: (flow: ChatbotNodeData) => void;
setChatbotRef: (ref: string) => void;
- /** Points the transcript at a session, clearing it if the session changed. */
bindSession: (sessionId: string) => void;
appendMessage: (message: ChatbotChatMessage) => void;
removeMessage: (id: string) => void;
setLastEventSeq: (seq: number) => void;
setHasHydrated: (value: boolean) => void;
+ reset: () => void;
}
const INITIAL_STATE: ChatbotState = {
@@ -70,6 +65,8 @@ export const useChatbotStore = create()(
setLastEventSeq: (seq) => set({ lastEventSeq: seq }),
setHasHydrated: (value) => set({ hasHydrated: value }),
+
+ reset: () => set({ ...INITIAL_STATE, hasHydrated: true }),
}),
{
name: STORAGE_KEYS.CHATBOT_STATE,
diff --git a/app/lib/store/chatbotSessionStore.ts b/app/lib/store/chatbotSessionStore.ts
index cbfcfa22..4b8fde94 100644
--- a/app/lib/store/chatbotSessionStore.ts
+++ b/app/lib/store/chatbotSessionStore.ts
@@ -1,10 +1,3 @@
-/**
- * Server-side in-memory store for chatbot session replies. The backend POSTs
- * each completed turn to `/api/webhooks/chatbot`; this store keeps a short
- * per-session buffer and fans events out to open SSE streams, so the browser
- * never has to receive webhooks directly.
- */
-
import type {
ChatbotSessionEvent,
ChatbotSessionEventListener,
@@ -26,7 +19,6 @@ const MAX_EVENTS_PER_SESSION = 50;
const MAX_SESSIONS = 500;
const TTL_MS = 30 * 60 * 1000;
-/** Date-based so ids keep increasing across server restarts. */
function nextSeq(): number {
store.lastSeq = Math.max(Date.now(), store.lastSeq + 1);
return store.lastSeq;
@@ -70,6 +62,12 @@ export function saveSessionEvent(
return saved;
}
+/** Seq of the newest buffered event for a session (0 when none). */
+export function getLatestSessionSeq(sessionId: string): number {
+ const events = store.events.get(sessionId);
+ return events?.[events.length - 1]?.seq ?? 0;
+}
+
/** Events newer than `afterSeq`, oldest first. */
export function getSessionEventsAfter(
sessionId: string,
diff --git a/app/lib/types/chatbot.ts b/app/lib/types/chatbot.ts
index 52ecad1a..dc07dca7 100644
--- a/app/lib/types/chatbot.ts
+++ b/app/lib/types/chatbot.ts
@@ -55,6 +55,7 @@ export interface ChatbotSessionSummary {
collected_fields: ChatbotCollectedFields;
config_id: string;
config_version: number | null;
+ system_prompt: string;
inserted_at: string;
updated_at: string;
}
diff --git a/app/lib/utils/chatbotSessionCookie.ts b/app/lib/utils/chatbotSessionCookie.ts
index 818d53b3..631cd77a 100644
--- a/app/lib/utils/chatbotSessionCookie.ts
+++ b/app/lib/utils/chatbotSessionCookie.ts
@@ -6,6 +6,11 @@ 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" : "";
diff --git a/middleware.ts b/middleware.ts
index 5f390622..8dea52c9 100644
--- a/middleware.ts
+++ b/middleware.ts
@@ -19,7 +19,10 @@ const PUBLIC_ROUTES = new Set([
const FEATURE_GATED_PREFIXES: Array<{
prefix: string;
flag: FeatureFlagKey;
-}> = [{ prefix: "/assessment", flag: FeatureFlag.ASSESSMENT }];
+}> = [
+ { prefix: "/assessment", flag: FeatureFlag.ASSESSMENT },
+ { prefix: "/chatbot", flag: FeatureFlag.CHATBOT },
+];
// Temporarily disabled while backend changes are being synced; keep in sync with `disabledReason` in navConfig.
const DISABLED_PREFIXES = ["/guardrails"];
From 0f91ea86b10963eb873b65e6d790b529a75f6fc9 Mon Sep 17 00:00:00 2001
From: Ayush8923 <80516839+Ayush8923@users.noreply.github.com>
Date: Sat, 24 Oct 2026 00:01:58 +0530
Subject: [PATCH 4/7] fix: clenaups and ui updates
---
app/(main)/chatbot/chat/page.tsx | 78 ++++++++
app/(main)/chatbot/page.tsx | 75 ++++---
app/components/chatbot/ChatbotBreadcrumb.tsx | 43 ++++
app/components/chatbot/ChatbotChatInput.tsx | 14 +-
app/components/chatbot/ChatbotChatWindow.tsx | 103 ++++++++++
.../chatbot/ChatbotMessageBubble.tsx | 25 +--
app/components/chatbot/ChatbotTestPanel.tsx | 144 --------------
app/components/chatbot/index.ts | 4 +-
app/hooks/useChatbotSession.ts | 186 ++++++++++++------
app/lib/store/chatbot.ts | 78 ++++----
app/lib/store/chatbotSessionStore.ts | 1 -
11 files changed, 445 insertions(+), 306 deletions(-)
create mode 100644 app/(main)/chatbot/chat/page.tsx
create mode 100644 app/components/chatbot/ChatbotBreadcrumb.tsx
create mode 100644 app/components/chatbot/ChatbotChatWindow.tsx
delete mode 100644 app/components/chatbot/ChatbotTestPanel.tsx
diff --git a/app/(main)/chatbot/chat/page.tsx b/app/(main)/chatbot/chat/page.tsx
new file mode 100644
index 00000000..b6beaa7a
--- /dev/null
+++ b/app/(main)/chatbot/chat/page.tsx
@@ -0,0 +1,78 @@
+"use client";
+
+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 { useApp } from "@/app/lib/context/AppContext";
+import { useToast } from "@/app/hooks/useToast";
+import { useChatbotSession } from "@/app/hooks/useChatbotSession";
+import { useChatbotStore } from "@/app/lib/store/chatbot";
+
+export default function ChatbotChatPage() {
+ const { sidebarCollapsed } = useApp();
+ const toast = useToast();
+ const session = useChatbotSession();
+ const savedFlow = useChatbotStore((s) => s.savedFlow);
+
+ const handleRestart = async () => {
+ if (!savedFlow) {
+ toast.error("Save the flow first to start a session.");
+ return;
+ }
+ if (await session.startSession(savedFlow)) {
+ toast.success("New chat session started.");
+ }
+ };
+
+ return (
+
+
+
+
+
+ ) : null
+ }
+ >
+
+
+
+
+
+
+