This commit is contained in:
158
apps/web/src/hooks/useMentorChat.ts
Normal file
158
apps/web/src/hooks/useMentorChat.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
"use client";
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
buildDisplayMessages,
|
||||
createPendingStateOnError,
|
||||
createPendingStateOnSend,
|
||||
type MentorChatMessage,
|
||||
type MentorPendingState,
|
||||
shouldBlockSend,
|
||||
} from "@/lib/mentor-chat-utils";
|
||||
|
||||
const DELETE_CONFIRM_MESSAGE =
|
||||
"Delete this chat? Your saved AI memories will not be affected.";
|
||||
|
||||
type ChatSessionData = {
|
||||
session: { id: string; title: string };
|
||||
messages: MentorChatMessage[];
|
||||
};
|
||||
|
||||
export function useMentorChat(sessionId: string | null) {
|
||||
const queryClient = useQueryClient();
|
||||
const bottomRef = useRef<HTMLDivElement>(null);
|
||||
const [input, setInput] = useState("");
|
||||
const [pendingState, setPendingState] = useState<MentorPendingState | null>(null);
|
||||
const [deleteError, setDeleteError] = useState<string | null>(null);
|
||||
|
||||
const { data: chatData } = useQuery<ChatSessionData>({
|
||||
queryKey: ["chat-session", sessionId],
|
||||
queryFn: async () => {
|
||||
const res = await fetch(`/api/ai/chat/sessions/${sessionId}`);
|
||||
if (!res.ok) throw new Error("Failed to load chat");
|
||||
return res.json();
|
||||
},
|
||||
enabled: !!sessionId,
|
||||
});
|
||||
|
||||
const serverMessages = chatData?.messages ?? [];
|
||||
const displayMessages = buildDisplayMessages(serverMessages, pendingState);
|
||||
|
||||
useEffect(() => {
|
||||
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
}, [displayMessages.length, pendingState]);
|
||||
|
||||
const send = useMutation({
|
||||
mutationFn: async (content: string) => {
|
||||
const res = await fetch(`/api/ai/chat/sessions/${sessionId}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ content }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
throw new Error(body.error ?? "Send failed");
|
||||
}
|
||||
return res.json();
|
||||
},
|
||||
onMutate: (content) => {
|
||||
setInput("");
|
||||
setPendingState(createPendingStateOnSend(content));
|
||||
},
|
||||
onSuccess: () => {
|
||||
setPendingState(null);
|
||||
queryClient.invalidateQueries({ queryKey: ["chat-session", sessionId] });
|
||||
queryClient.invalidateQueries({ queryKey: ["chat-sessions"] });
|
||||
},
|
||||
onError: (_err, content) => {
|
||||
setPendingState(createPendingStateOnError(content));
|
||||
},
|
||||
});
|
||||
|
||||
const archive = useMutation({
|
||||
mutationFn: async () => {
|
||||
const res = await fetch(`/api/ai/chat/sessions/${sessionId}`, { method: "DELETE" });
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
throw new Error(body.error ?? "Delete failed");
|
||||
}
|
||||
return res.json();
|
||||
},
|
||||
onMutate: () => {
|
||||
setDeleteError(null);
|
||||
},
|
||||
onSuccess: () => {
|
||||
setPendingState(null);
|
||||
setInput("");
|
||||
queryClient.invalidateQueries({ queryKey: ["chat-sessions"] });
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
setDeleteError(err.message || "Could not delete chat. Please try again.");
|
||||
},
|
||||
});
|
||||
|
||||
const sendMessage = useCallback(
|
||||
(content: string) => {
|
||||
if (!sessionId || shouldBlockSend(send.isPending, content)) return;
|
||||
send.mutate(content.trim());
|
||||
},
|
||||
[sessionId, send]
|
||||
);
|
||||
|
||||
const retry = useCallback(() => {
|
||||
const content = pendingState?.lastFailedContent;
|
||||
if (!content || send.isPending) return;
|
||||
setPendingState(null);
|
||||
send.mutate(content);
|
||||
}, [pendingState?.lastFailedContent, send]);
|
||||
|
||||
const deleteChat = useCallback(() => {
|
||||
if (!sessionId || archive.isPending) return;
|
||||
if (!confirm(DELETE_CONFIRM_MESSAGE)) return;
|
||||
archive.mutate();
|
||||
}, [sessionId, archive]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === "Enter" && input.trim()) {
|
||||
e.preventDefault();
|
||||
sendMessage(input);
|
||||
}
|
||||
},
|
||||
[input, sendMessage]
|
||||
);
|
||||
|
||||
return {
|
||||
input,
|
||||
setInput,
|
||||
displayMessages,
|
||||
sendMessage,
|
||||
retry,
|
||||
deleteChat,
|
||||
handleKeyDown,
|
||||
bottomRef,
|
||||
isSendPending: send.isPending,
|
||||
isDeletePending: archive.isPending,
|
||||
canRetry: !!pendingState?.lastFailedContent,
|
||||
deleteError,
|
||||
archiveSuccess: archive.isSuccess,
|
||||
resetArchiveSuccess: archive.reset,
|
||||
};
|
||||
}
|
||||
|
||||
export async function createChatSession(title?: string): Promise<{ id: string }> {
|
||||
const res = await fetch("/api/ai/chat/sessions", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(title ? { title } : {}),
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed to create session");
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function fetchChatSessions(): Promise<{ sessions: { id: string; title: string }[] }> {
|
||||
const res = await fetch("/api/ai/chat/sessions");
|
||||
if (!res.ok) throw new Error("Failed to load sessions");
|
||||
return res.json();
|
||||
}
|
||||
Reference in New Issue
Block a user