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:
@@ -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
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
const STORAGE_KEY = "gmw-chatbot-user-id";
|
||||
|
||||
/**
|
||||
* Per-device anonymous identity. The app has no login, so we mint a random
|
||||
* UUID on first visit, persist it to localStorage, and send it as the
|
||||
* X-User-Id header. Each visitor gets their own chat history — the backend
|
||||
* keys `chatbot_messages` by this id.
|
||||
*/
|
||||
export function useChatbotUserId(): string {
|
||||
const [userId, setUserId] = useState<string>("");
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
let id = window.localStorage.getItem(STORAGE_KEY);
|
||||
if (!id || id.length < 16) {
|
||||
id =
|
||||
typeof crypto !== "undefined" && crypto.randomUUID
|
||||
? crypto.randomUUID()
|
||||
: `u_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 12)}`;
|
||||
window.localStorage.setItem(STORAGE_KEY, id);
|
||||
}
|
||||
setUserId(id);
|
||||
} catch {
|
||||
// localStorage unavailable (private mode) — use in-memory fallback
|
||||
setUserId(
|
||||
typeof crypto !== "undefined" && crypto.randomUUID
|
||||
? crypto.randomUUID()
|
||||
: `u_${Date.now().toString(36)}`,
|
||||
);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return userId;
|
||||
}
|
||||
|
||||
export { STORAGE_KEY };
|
||||
@@ -1,17 +1,27 @@
|
||||
import type { ChatbotHistoryRow, ChatbotResponse } from "@/lib/types";
|
||||
import { api } from "./client";
|
||||
|
||||
export const chatbotApi = {
|
||||
send: (message: string, guildId?: string) =>
|
||||
api.post<ChatbotResponse>("/api/chat", {
|
||||
message,
|
||||
context: guildId ? { guildId } : undefined,
|
||||
}),
|
||||
function userHeader(userId?: string): Record<string, string> {
|
||||
return userId && userId !== "anonymous" ? { "X-User-Id": userId } : {};
|
||||
}
|
||||
|
||||
getHistory: () =>
|
||||
api.get<{ history: ChatbotHistoryRow[]; total: number }>(
|
||||
"/api/chat/history",
|
||||
export const chatbotApi = {
|
||||
send: (message: string, guildId?: string, userId?: string) =>
|
||||
api.post<ChatbotResponse>(
|
||||
"/api/chat",
|
||||
{
|
||||
message,
|
||||
context: guildId ? { guildId } : undefined,
|
||||
},
|
||||
userHeader(userId),
|
||||
),
|
||||
|
||||
clearHistory: () => api.delete<{ ok: boolean }>("/api/chat/history"),
|
||||
getHistory: (userId?: string) =>
|
||||
api.get<{ history: ChatbotHistoryRow[]; total: number }>(
|
||||
"/api/chat/history",
|
||||
userHeader(userId),
|
||||
),
|
||||
|
||||
clearHistory: (userId?: string) =>
|
||||
api.delete<{ ok: boolean }>("/api/chat/history", userHeader(userId)),
|
||||
};
|
||||
|
||||
@@ -31,17 +31,18 @@ export async function apiRequest<T>(
|
||||
method: string,
|
||||
path: string,
|
||||
body?: unknown,
|
||||
headers?: Record<string, string>,
|
||||
): Promise<T> {
|
||||
const url = `${getBaseUrl()}${path}`;
|
||||
|
||||
const headers: Record<string, string> = {};
|
||||
const finalHeaders: Record<string, string> = { ...(headers ?? {}) };
|
||||
if (body !== undefined) {
|
||||
headers["Content-Type"] = "application/json";
|
||||
finalHeaders["Content-Type"] ??= "application/json";
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
headers,
|
||||
headers: finalHeaders,
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
|
||||
@@ -59,7 +60,10 @@ export async function apiRequest<T>(
|
||||
}
|
||||
|
||||
export const api = {
|
||||
get: <T>(path: string) => apiRequest<T>("GET", path),
|
||||
post: <T>(path: string, body?: unknown) => apiRequest<T>("POST", path, body),
|
||||
delete: <T>(path: string) => apiRequest<T>("DELETE", path),
|
||||
get: <T>(path: string, headers?: Record<string, string>) =>
|
||||
apiRequest<T>("GET", path, undefined, headers),
|
||||
post: <T>(path: string, body?: unknown, headers?: Record<string, string>) =>
|
||||
apiRequest<T>("POST", path, body, headers),
|
||||
delete: <T>(path: string, headers?: Record<string, string>) =>
|
||||
apiRequest<T>("DELETE", path, undefined, headers),
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user