nico-martin's picture
nico-martin HF Staff
small adjustments
97ce2e0
import { Button, Card, Slider } from "@theme";
import { useChatSettings } from "@utils/context/chatSettings/useChatSettings.ts";
import { formatBytes } from "@utils/format.ts";
import { CONVERSATION_STARTERS, MODELS } from "@utils/models.ts";
import { TOOLS } from "@utils/tools.ts";
import { RotateCcw, Settings } from "lucide-react";
import {
useEffect,
useMemo,
useRef,
useState,
useSyncExternalStore,
} from "react";
import TextGeneration from "../textGeneration/TextGeneration.ts";
import type {
ChatMessageAssistant,
ChatMessageUser,
} from "../textGeneration/types.ts";
import cn from "../utils/classnames.ts";
import ChatForm from "./ChatForm.tsx";
import Message from "./Message.tsx";
enum State {
IDLE,
INITIALIZING,
READY,
GENERATING,
}
export default function Chat({ className = "" }: { className?: string }) {
const { openSettingsModal, settings, downloadedModels } = useChatSettings();
const initializedModelKey = useRef<string>(null);
const [downloadProgress, setDownloadProgress] = useState<number>(0);
const [state, setState] = useState<State>(State.IDLE);
const settingsKey = useRef<string>(null);
const [starterCategory, setStarterCategory] = useState<number>(0);
const messagesEndRef = useRef<HTMLDivElement>(null);
const generator = useMemo(() => new TextGeneration(), []);
const getSnapshot = () => generator.chatMessages;
const messages = useSyncExternalStore(
generator.onChatMessageUpdate,
getSnapshot
);
useEffect(() => {
const newSettingsKey = JSON.stringify(settings);
if (!settings || settingsKey.current === newSettingsKey) return;
settingsKey.current = newSettingsKey;
initializeConversation();
}, [settings]);
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages]);
if (!settings) return;
const modelDownloaded = downloadedModels.includes(settings.modelKey);
const initializeConversation = () =>
generator.initializeConversation(
TOOLS.filter((tool) => settings.tools.includes(tool.name)),
settings.temperature,
settings.enableThinking,
settings.systemPrompt
);
const initializeModel = async () => {
setState(State.INITIALIZING);
setDownloadProgress(0);
await generator.initializeModel(settings.modelKey, (percentage) =>
setDownloadProgress(percentage)
);
initializedModelKey.current = settings.modelKey;
setState(State.READY);
};
const generate = async (prompt: string) => {
if (initializedModelKey.current !== settings.modelKey) {
await initializeModel();
}
setState(State.GENERATING);
await generator.runAgent(prompt);
setState(State.READY);
};
const model = MODELS[settings.modelKey];
const ready: boolean = state === State.READY || modelDownloaded;
const conversationMessages = messages.filter(({ role }) => role !== "system");
return (
<>
<div
className={cn(
className,
"mx-auto min-h-screen w-full max-w-4xl px-4 pt-24 pb-36"
)}
>
{state === State.IDLE && !modelDownloaded ? (
<Card className="mt-26 flex max-w-[500px] flex-col gap-4 self-center justify-self-center">
<p>
You are about to load <b>{model.title}</b>.<br />
Once downloaded, the model ({formatBytes(model.size)}) will be
cached and reused when you revisit the page.
</p>
<p>
Everything runs directly in your browser using 🤗 Transformers.js
and ONNX Runtime Web, meaning your conversations aren't sent to a
server. You can even disconnect from the internet after the model
has loaded!
</p>
<Button onClick={initializeModel}>
Download Model ({formatBytes(model.size)})
</Button>
</Card>
) : state === State.INITIALIZING ? (
<div className="mt-46 flex h-full items-center justify-center">
<div className="flex h-full w-full max-w-[400px] flex-col items-center justify-center gap-2">
<p className="flex w-full items-center justify-between">
<span>
{modelDownloaded
? "initializing the model..."
: "downloading the model..."}
</span>
<span>{downloadProgress.toFixed(2)}%</span>
</p>
<Slider width={Math.round(downloadProgress)} />
</div>
</div>
) : conversationMessages.length === 0 ? (
<div className="mt-26 flex h-full items-center justify-center">
<div className="flex flex-col gap-8">
<h2 className="text-2xl font-bold">How can I help you?</h2>
<div className="flex gap-4">
{CONVERSATION_STARTERS.map((starter, index) => (
<Button
iconLeft={starter.icon}
key={index}
onClick={() => setStarterCategory(index)}
variant={index === starterCategory ? "solid" : "outline"}
>
{starter.category}
</Button>
))}
</div>
<div className="gap- flex flex-col border-t border-gray-300 dark:border-gray-700">
{CONVERSATION_STARTERS[starterCategory].prompts.map(
(prompt, index) => (
<div
key={prompt}
className="border-b border-gray-300 py-1 dark:border-gray-700"
>
<Button
key={index}
onClick={() => generate(prompt)}
variant="ghost"
color="mono"
className="justify-start"
>
{prompt}
</Button>
</div>
)
)}
</div>
</div>
</div>
) : (
<div className="flex flex-col gap-4">
{conversationMessages.map((message, index) => (
<div
className={cn("w-3/4", {
"self-end": message.role === "user",
})}
key={index}
>
{message.role === "user" ? (
<Card className="mt-2 mb-12">
<p>{(message as ChatMessageUser).content}</p>
</Card>
) : message.role === "assistant" ? (
<Message message={message as ChatMessageAssistant} />
) : null}
</div>
))}
<div ref={messagesEndRef} />
</div>
)}
</div>
<div className="fixed right-0 bottom-6 left-0 mx-auto w-full max-w-4xl space-y-2">
<Card className="mt-auto">
<ChatForm
disabled={!ready}
onSubmit={(prompt) => generate(prompt)}
isGenerating={state === State.GENERATING}
onAbort={() => generator.abort()}
/>
</Card>
<div className="flex justify-between dark:border-gray-700">
<Button
iconLeft={<Settings />}
size="xs"
variant="ghost"
color="mono"
onClick={openSettingsModal}
>
{settings?.modelKey ? MODELS[settings.modelKey].title : ""}
<span className="mx-2 text-gray-400 dark:text-gray-500"></span>
{settings?.tools.length}/{TOOLS.length} tool
{settings?.tools.length !== 1 ? "s" : ""} active
</Button>
<Button
iconLeft={<RotateCcw />}
size="xs"
variant="ghost"
color="mono"
onClick={initializeConversation}
>
new conversation
</Button>
</div>
</div>
</>
);
}