2026-07-28 12:17:58 +07:00
|
|
|
"use client";
|
|
|
|
|
|
|
|
|
|
import {
|
|
|
|
|
createContext,
|
|
|
|
|
type ReactNode,
|
|
|
|
|
useCallback,
|
|
|
|
|
useContext,
|
|
|
|
|
useEffect,
|
|
|
|
|
useRef,
|
|
|
|
|
useState,
|
|
|
|
|
} from "react";
|
|
|
|
|
import { chatbotApi } from "@/lib/api";
|
|
|
|
|
import type { ChatHistoryMessage } from "@/lib/types";
|
|
|
|
|
|
2026-07-28 15:00:29 +07:00
|
|
|
export type ChatbotExpression = "idle" | "listening" | "surprise" | "happy" | "sad" | "talking";
|
2026-07-28 12:17:58 +07:00
|
|
|
|
2026-07-28 15:00:29 +07:00
|
|
|
interface ChatbotMessage {
|
2026-07-28 12:17:58 +07:00
|
|
|
role: "user" | "assistant";
|
|
|
|
|
content: string;
|
|
|
|
|
timestamp: string;
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-28 15:00:29 +07:00
|
|
|
interface ChatbotContextValue {
|
|
|
|
|
/** Expression the chatbot avatar should display */
|
|
|
|
|
expression: ChatbotExpression;
|
|
|
|
|
setExpression: (expr: ChatbotExpression) => void;
|
2026-07-28 12:17:58 +07:00
|
|
|
|
|
|
|
|
/** Whether the enlarged bubble is minimized to a small icon */
|
|
|
|
|
minimized: boolean;
|
|
|
|
|
setMinimized: (v: boolean) => void;
|
|
|
|
|
|
|
|
|
|
/** Whether the chat panel inside the bubble is open */
|
|
|
|
|
chatOpen: boolean;
|
|
|
|
|
setChatOpen: (v: boolean) => void;
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @deprecated Use `minimized` / `setMinimized` instead.
|
|
|
|
|
* Legacy toggle alias kept for compatibility.
|
|
|
|
|
*/
|
|
|
|
|
isOpen: boolean;
|
|
|
|
|
setOpen: (open: boolean) => void;
|
|
|
|
|
toggle: () => void;
|
|
|
|
|
|
|
|
|
|
/** Chat messages with real API backend */
|
2026-07-28 15:00:29 +07:00
|
|
|
messages: ChatbotMessage[];
|
2026-07-28 12:17:58 +07:00
|
|
|
sendMessage: (content: string) => Promise<void>;
|
|
|
|
|
clearMessages: () => Promise<void>;
|
|
|
|
|
isTyping: boolean;
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-28 15:00:29 +07:00
|
|
|
const ChatbotContext = createContext<ChatbotContextValue | null>(null);
|
2026-07-28 12:17:58 +07:00
|
|
|
|
2026-07-28 15:00:29 +07:00
|
|
|
export function ChatbotProvider({ children }: { children: ReactNode }) {
|
|
|
|
|
const [expression, setExpression] = useState<ChatbotExpression>("idle");
|
2026-07-28 12:17:58 +07:00
|
|
|
const [minimized, setMinimized] = useState(true);
|
|
|
|
|
const [chatOpen, setChatOpen] = useState(false);
|
2026-07-28 15:00:29 +07:00
|
|
|
const [messages, setMessages] = useState<ChatbotMessage[]>([]);
|
2026-07-28 12:17:58 +07:00
|
|
|
const [isTyping, setIsTyping] = useState(false);
|
|
|
|
|
const historyFetched = useRef(false);
|
|
|
|
|
|
|
|
|
|
// Derived legacy state
|
|
|
|
|
const isOpen = !minimized;
|
|
|
|
|
|
|
|
|
|
const setOpen = useCallback((open: boolean) => {
|
|
|
|
|
setMinimized(!open);
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
const toggle = useCallback(() => {
|
|
|
|
|
setMinimized((prev) => !prev);
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
// Load chat history on first mount
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (historyFetched.current) return;
|
|
|
|
|
historyFetched.current = true;
|
|
|
|
|
|
|
|
|
|
chatbotApi.getHistory().then((history) => {
|
|
|
|
|
const mapped = (history ?? []).map((msg: ChatHistoryMessage) => ({
|
|
|
|
|
role: msg.role as "user" | "assistant",
|
|
|
|
|
content: msg.content,
|
|
|
|
|
timestamp: msg.timestamp,
|
|
|
|
|
}));
|
|
|
|
|
setMessages(mapped);
|
|
|
|
|
}).catch(() => {
|
|
|
|
|
// API may not be available yet — silently ignore
|
|
|
|
|
});
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
const sendMessage = useCallback(async (content: string) => {
|
|
|
|
|
if (!content.trim()) return;
|
|
|
|
|
|
2026-07-28 15:00:29 +07:00
|
|
|
const userMsg: ChatbotMessage = {
|
2026-07-28 12:17:58 +07:00
|
|
|
role: "user",
|
|
|
|
|
content: content.trim(),
|
|
|
|
|
timestamp: new Date().toISOString(),
|
|
|
|
|
};
|
|
|
|
|
setMessages((prev) => [...prev, userMsg]);
|
|
|
|
|
setExpression("listening");
|
|
|
|
|
setIsTyping(true);
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const res = await chatbotApi.send(content.trim());
|
2026-07-28 15:00:29 +07:00
|
|
|
const botMsg: ChatbotMessage = {
|
2026-07-28 12:17:58 +07:00
|
|
|
role: "assistant",
|
|
|
|
|
content: res.response,
|
|
|
|
|
timestamp: res.timestamp ?? new Date().toISOString(),
|
|
|
|
|
};
|
|
|
|
|
setMessages((prev) => [...prev, botMsg]);
|
|
|
|
|
setExpression("happy");
|
|
|
|
|
} catch {
|
2026-07-28 15:00:29 +07:00
|
|
|
const errorMsg: ChatbotMessage = {
|
2026-07-28 12:17:58 +07:00
|
|
|
role: "assistant",
|
|
|
|
|
content: "Sorry, I couldn't process that request. Please try again.",
|
|
|
|
|
timestamp: new Date().toISOString(),
|
|
|
|
|
};
|
|
|
|
|
setMessages((prev) => [...prev, errorMsg]);
|
|
|
|
|
setExpression("sad");
|
|
|
|
|
} finally {
|
|
|
|
|
setIsTyping(false);
|
|
|
|
|
}
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
const clearMessages = useCallback(async () => {
|
|
|
|
|
try {
|
|
|
|
|
await chatbotApi.clearHistory();
|
|
|
|
|
} catch {
|
|
|
|
|
// Best-effort clear
|
|
|
|
|
}
|
|
|
|
|
setMessages([]);
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
return (
|
2026-07-28 15:00:29 +07:00
|
|
|
<ChatbotContext.Provider
|
2026-07-28 12:17:58 +07:00
|
|
|
value={{
|
|
|
|
|
expression,
|
|
|
|
|
setExpression,
|
|
|
|
|
minimized,
|
|
|
|
|
setMinimized,
|
|
|
|
|
chatOpen,
|
|
|
|
|
setChatOpen,
|
|
|
|
|
isOpen,
|
|
|
|
|
setOpen,
|
|
|
|
|
toggle,
|
|
|
|
|
messages,
|
|
|
|
|
sendMessage,
|
|
|
|
|
clearMessages,
|
|
|
|
|
isTyping,
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
{children}
|
2026-07-28 15:00:29 +07:00
|
|
|
</ChatbotContext.Provider>
|
2026-07-28 12:17:58 +07:00
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-28 15:00:29 +07:00
|
|
|
export function useChatbot(): ChatbotContextValue {
|
|
|
|
|
const ctx = useContext(ChatbotContext);
|
2026-07-28 12:17:58 +07:00
|
|
|
if (!ctx) {
|
2026-07-28 15:00:29 +07:00
|
|
|
throw new Error("useChatbot must be used within a ChatbotProvider");
|
2026-07-28 12:17:58 +07:00
|
|
|
}
|
|
|
|
|
return ctx;
|
|
|
|
|
}
|