142 lines
5.3 KiB
TypeScript
142 lines
5.3 KiB
TypeScript
"use client";
|
|
|
|
import { useBackground } from "../../game-engine/background-provider.tsx";
|
|
import { useNarrative } from "../../game-engine/narrative-provider.tsx";
|
|
import ChatInput from "./chat-input.tsx";
|
|
import ChatMessageDisplay from "./chat-message.tsx";
|
|
import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
|
import { AnimatePresence, motion } from "framer-motion";
|
|
import ChevronDownIcon from "@bureaudouble/icons/radix/ChevronDownIcon";
|
|
import { useObserveTurnVisibility } from "../../hooks/use-observe-turn-visibility.ts";
|
|
import TurnProvider from "../../game-engine/turn-provider.tsx";
|
|
import { getImageDataFromMessage } from "./chat-image.tsx";
|
|
import { personaChatContainerVariants } from "../../game-engine/persona-styles.ts";
|
|
import { gameStateToVariantState } from "../../game-engine/persona-styles.ts";
|
|
import ScrollProgressTracker from "../utils/scroll-progress-tracker.tsx";
|
|
import { useClientLocale } from "../../game-engine/locale-provider.tsx";
|
|
|
|
export default function ChatInterface() {
|
|
const { messages, status, gameState } = useNarrative();
|
|
const { t } = useClientLocale();
|
|
const scrollableContainerRef = useRef<HTMLDivElement>(null);
|
|
|
|
const { visibleTurn } = useObserveTurnVisibility(scrollableContainerRef);
|
|
const { setBackground } = useBackground();
|
|
|
|
useEffect(() => {
|
|
const imageGeneration = getImageDataFromMessage(visibleTurn);
|
|
if (imageGeneration?.imageUrl) {
|
|
setBackground(
|
|
imageGeneration.imageUrl,
|
|
Math.max(0.6 * (gameState.arousalPeakPotential ?? 0.1), 1),
|
|
);
|
|
} else if (!visibleTurn) {
|
|
setBackground(null, 0);
|
|
}
|
|
}, [setBackground, visibleTurn, gameState.arousalPeakPotential]);
|
|
|
|
const typingRef = useRef<HTMLDivElement>(null);
|
|
const prevMessagesLength = useRef<number>(0);
|
|
const scrolledToBottomRef = useRef<boolean | "force">(undefined);
|
|
const [isAtBottom, setIsAtBottom] = useState(true);
|
|
|
|
useLayoutEffect(() => {
|
|
const onScrollEnd = () => {
|
|
if (scrolledToBottomRef.current !== "force") {
|
|
scrolledToBottomRef.current = (document.documentElement.scrollTop +
|
|
document.documentElement.clientHeight) >=
|
|
(document.documentElement.scrollHeight - 70);
|
|
}
|
|
setIsAtBottom(!!scrolledToBottomRef.current);
|
|
};
|
|
const onScroll = () => {
|
|
if (scrolledToBottomRef.current !== "force") {
|
|
scrolledToBottomRef.current = false;
|
|
}
|
|
};
|
|
globalThis.addEventListener("scrollend", onScrollEnd);
|
|
globalThis.addEventListener("scroll", onScroll);
|
|
return () => {
|
|
globalThis.removeEventListener("scrollend", onScrollEnd);
|
|
globalThis.removeEventListener("scroll", onScroll);
|
|
};
|
|
}, [scrolledToBottomRef]);
|
|
|
|
useLayoutEffect(() => {
|
|
const enableScroll = scrolledToBottomRef.current ||
|
|
prevMessagesLength.current !== messages.length;
|
|
if (enableScroll || status === "ready" || status === "submitted") {
|
|
scrolledToBottomRef.current = "force";
|
|
typingRef.current?.scrollIntoView({ behavior: "instant" });
|
|
setTimeout(() => scrolledToBottomRef.current = true, 0);
|
|
}
|
|
prevMessagesLength.current = messages.length;
|
|
}, [
|
|
JSON.stringify(messages),
|
|
status,
|
|
]);
|
|
|
|
return (
|
|
<>
|
|
<ScrollProgressTracker
|
|
ref={scrollableContainerRef}
|
|
applyOnBody
|
|
className={personaChatContainerVariants(
|
|
gameStateToVariantState(gameState),
|
|
)}
|
|
>
|
|
{messages.filter((v) => v.id !== "initial-00").map(
|
|
(message, index, arr) => (
|
|
<TurnProvider
|
|
key={message.id}
|
|
messages={arr.slice(0, index + 1)}
|
|
nextAssistantMessage={arr.at(index + 1)?.role === "assistant"
|
|
? arr.at(index + 1)
|
|
: arr.at(index + 2)?.role === "assistant"
|
|
? arr.at(index + 2)
|
|
: null}
|
|
>
|
|
<ChatMessageDisplay />
|
|
</TurnProvider>
|
|
),
|
|
)}
|
|
{status === "streaming" &&
|
|
(
|
|
<div className="pl-1 text-sm text-foreground animate-pulse">
|
|
{t("aiIsTyping", { personaId: gameState?.personaId ?? "..." })}
|
|
<span className="inline-block w-1 h-3 bg-white ml-1 animate-ping">
|
|
</span>
|
|
</div>
|
|
)}
|
|
<div
|
|
ref={typingRef}
|
|
data-atbottom={isAtBottom}
|
|
className="size-1 mb-4"
|
|
/>
|
|
<AnimatePresence>
|
|
{!isAtBottom && (
|
|
<motion.div
|
|
initial={{ opacity: 0, y: 20 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
exit={{ opacity: 0, y: 20 }}
|
|
transition={{ type: "spring", stiffness: 500, damping: 30 }}
|
|
className="fixed bottom-15 left-1/2 -translate-x-1/2 z-20"
|
|
>
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
typingRef.current?.scrollIntoView({ behavior: "instant" });
|
|
}}
|
|
className="p-1.5 rounded-full bg-background/70 border border-border text-foreground hover:bg-white hover:text-black transition-colors"
|
|
aria-label={t("scrollToBottom")}
|
|
>
|
|
<ChevronDownIcon className="size-4" />
|
|
</button>
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
</ScrollProgressTracker>
|
|
<ChatInput />
|
|
</>
|
|
);
|
|
}
|