feat(chatbot): per-user history via X-User-Id + agentic tools calling

Backend:
- New chatbot.tools.ts: 4 tools (get_server_stats, get_top_channels,
  get_recent_activity, get_top_flagged) with real DB executors
- chatbot.service: agentic loop — stream:true, parse SSE, execute
  tool_calls, feed results back, up to 4 rounds
- controller: resolve userId from X-User-Id header (no-login device
  uuid) with auth middleware precedence; history/clear scoped per user

Frontend:
- use-chatbot-user: mint UUID in localStorage, send as X-User-Id
- chatbotApi.send/getHistory/clearHistory accept userId header
- client.ts: apiRequest supports custom headers per call
- provider: history load + send + clear keyed to device user id
This commit is contained in:
asepharyana
2026-08-03 06:24:19 +07:00
parent 7513681b4b
commit d1c1f3e4a7
7 changed files with 506 additions and 56 deletions
@@ -9,6 +9,7 @@ import {
useRef,
useState,
} from "react";
import { useChatbotUserId } from "@/hooks/use-chatbot-user";
import { chatbotApi } from "@/lib/api";
export type ChatbotExpression =
@@ -67,6 +68,7 @@ export function ChatbotProvider({ children }: { children: ReactNode }) {
const [isTyping, setIsTyping] = useState(false);
const [guildId, setGuildId] = useState("");
const historyFetched = useRef(false);
const userId = useChatbotUserId();
// Derived legacy state
const isOpen = !minimized;
@@ -79,13 +81,13 @@ export function ChatbotProvider({ children }: { children: ReactNode }) {
setMinimized((prev) => !prev);
}, []);
// Load chat history on first mount
// Load chat history on first mount (per-device user history)
useEffect(() => {
if (historyFetched.current) return;
if (historyFetched.current || !userId) return;
historyFetched.current = true;
chatbotApi
.getHistory()
.getHistory(userId)
.then((res) => {
// Backend returns rows {user_message, bot_response, created_at} —
// interleave each user message with its bot reply.
@@ -107,7 +109,7 @@ export function ChatbotProvider({ children }: { children: ReactNode }) {
.catch(() => {
// API may not be available yet — silently ignore
});
}, []);
}, [userId]);
const sendMessage = useCallback(
async (content: string) => {
@@ -124,8 +126,9 @@ export function ChatbotProvider({ children }: { children: ReactNode }) {
try {
// Send active guild as context so the backend can answer with
// real server insights (serverInsights path in chatbot.service).
const res = await chatbotApi.send(content.trim(), guildId);
// real server insights (serverInsights path in chatbot.service),
// and the per-device user id so the history stays isolated.
const res = await chatbotApi.send(content.trim(), guildId, userId);
const botMsg: ChatbotMessage = {
role: "assistant",
content: res.response,
@@ -146,17 +149,17 @@ export function ChatbotProvider({ children }: { children: ReactNode }) {
setIsTyping(false);
}
},
[guildId],
[guildId, userId],
);
const clearMessages = useCallback(async () => {
try {
await chatbotApi.clearHistory();
await chatbotApi.clearHistory(userId);
} catch {
// Best-effort clear
}
setMessages([]);
}, []);
}, [userId]);
return (
<ChatbotContext.Provider