177 lines
5.3 KiB
TypeScript
177 lines
5.3 KiB
TypeScript
"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[];
|
|
};
|
|
|
|
type SendChatResponse = {
|
|
memorySuggestions?: unknown[];
|
|
memoryAction?: {
|
|
type: string;
|
|
intent?: string;
|
|
status?: string;
|
|
error?: string;
|
|
};
|
|
};
|
|
|
|
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() as Promise<SendChatResponse>;
|
|
},
|
|
onMutate: (content) => {
|
|
setInput("");
|
|
setPendingState(createPendingStateOnSend(content));
|
|
},
|
|
onSuccess: (data) => {
|
|
setPendingState(null);
|
|
queryClient.invalidateQueries({ queryKey: ["chat-session", sessionId] });
|
|
queryClient.invalidateQueries({ queryKey: ["chat-sessions"] });
|
|
queryClient.invalidateQueries({ queryKey: ["ai-memory-suggestions"] });
|
|
queryClient.invalidateQueries({ queryKey: ["ai-knows"] });
|
|
if (
|
|
data.memoryAction?.type === "memory_created" ||
|
|
data.memoryAction?.type === "memory_suggestion_accepted"
|
|
) {
|
|
queryClient.invalidateQueries({ queryKey: ["ai-memory"] });
|
|
}
|
|
},
|
|
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();
|
|
}
|