Spaces:
Running
Running
Modularize chat completion and add loading and typing animation
Browse filesmodified: README.md
modified: src/components/ChatInterface.jsx
modified: src/components/ChatSettings.jsx
new file: src/components/LoadingBar.jsx
new file: src/components/LoadingIndicator.jsx
new file: src/components/Spinner.jsx
new file: src/components/ThinkingDots.jsx
new file: src/components/TypingMessage.jsx
new file: src/components/UISettings.jsx
new file: src/hooks/useChatCompletion.js
modified: src/hooks/useChatSettings.js
new file: src/hooks/useUISettings.js
new file: src/styles/LoadingBar.css
new file: src/styles/Spinner.css
new file: src/styles/ThinkingDots.css
new file: src/utils/simulateTyping.js
- README.md +1 -0
- src/components/ChatInterface.jsx +26 -22
- src/components/ChatSettings.jsx +1 -5
- src/components/LoadingBar.jsx +5 -0
- src/components/LoadingIndicator.jsx +11 -0
- src/components/Spinner.jsx +5 -0
- src/components/ThinkingDots.jsx +11 -0
- src/components/TypingMessage.jsx +3 -0
- src/components/UISettings.jsx +31 -0
- src/hooks/useChatCompletion.js +61 -0
- src/hooks/useChatSettings.js +2 -14
- src/hooks/useUISettings.js +10 -0
- src/styles/LoadingBar.css +16 -0
- src/styles/Spinner.css +14 -0
- src/styles/ThinkingDots.css +28 -0
- src/utils/simulateTyping.js +12 -0
README.md
CHANGED
|
@@ -19,6 +19,7 @@ This project was bootstrapped with [Create React App](https://github.com/faceboo
|
|
| 19 |
|
| 20 |
- Support user input of Hugging Face Access Token.
|
| 21 |
- Support tuning system prompt and parameters: temperature, top_p, max_tokens.
|
|
|
|
| 22 |
- Support UI to send user input and get response of the chatbot.
|
| 23 |
- Keep user input and response in chat history.
|
| 24 |
|
|
|
|
| 19 |
|
| 20 |
- Support user input of Hugging Face Access Token.
|
| 21 |
- Support tuning system prompt and parameters: temperature, top_p, max_tokens.
|
| 22 |
+
- Support typing animation, loading animation, and error handling.
|
| 23 |
- Support UI to send user input and get response of the chatbot.
|
| 24 |
- Keep user input and response in chat history.
|
| 25 |
|
src/components/ChatInterface.jsx
CHANGED
|
@@ -1,35 +1,30 @@
|
|
| 1 |
import { useState } from "react";
|
|
|
|
|
|
|
|
|
|
| 2 |
import ChatSettings from "./ChatSettings";
|
|
|
|
| 3 |
import MessageInput from "./MessageInput";
|
| 4 |
import MessagePair from "./MessagePair";
|
| 5 |
import TokenInput from "./TokenInput";
|
|
|
|
|
|
|
| 6 |
|
| 7 |
export default function ChatInterface() {
|
| 8 |
const [hfClient, setHFClient] = useState(null);
|
| 9 |
const [chatHistory, setChatHistory] = useState([]);
|
| 10 |
-
const [chatSettings, setChatSettings] =
|
|
|
|
| 11 |
|
| 12 |
-
const
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
const result = await hfClient.chatCompletion({
|
| 20 |
-
model: "openai/gpt-oss-20b",
|
| 21 |
-
messages: [
|
| 22 |
-
{ role: "system", content: chatSettings.system },
|
| 23 |
-
{ role: "user", content: message },
|
| 24 |
-
],
|
| 25 |
-
temperature: chatSettings.temperature,
|
| 26 |
-
top_p: chatSettings.top_p,
|
| 27 |
-
max_tokens: chatSettings.max_tokens,
|
| 28 |
-
});
|
| 29 |
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
setChatHistory([newPair, ...chatHistory]);
|
| 33 |
};
|
| 34 |
|
| 35 |
const handleHFClientReady = (client) => {
|
|
@@ -54,7 +49,12 @@ export default function ChatInterface() {
|
|
| 54 |
|
| 55 |
{/* Chat settings */}
|
| 56 |
<div>
|
| 57 |
-
<ChatSettings
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
</div>
|
| 59 |
|
| 60 |
{/* Input area */}
|
|
@@ -64,6 +64,10 @@ export default function ChatInterface() {
|
|
| 64 |
|
| 65 |
{/* Chat history */}
|
| 66 |
<div>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 67 |
{chatHistory.map((pair, index) => (
|
| 68 |
<MessagePair
|
| 69 |
key={index}
|
|
|
|
| 1 |
import { useState } from "react";
|
| 2 |
+
import { useChatCompletion } from "../hooks/useChatCompletion";
|
| 3 |
+
import { useChatSettings } from "../hooks/useChatSettings";
|
| 4 |
+
import { useUISettings } from "../hooks/useUISettings";
|
| 5 |
import ChatSettings from "./ChatSettings";
|
| 6 |
+
import LoadingIndicator from "./LoadingIndicator";
|
| 7 |
import MessageInput from "./MessageInput";
|
| 8 |
import MessagePair from "./MessagePair";
|
| 9 |
import TokenInput from "./TokenInput";
|
| 10 |
+
import TypingMessage from "./TypingMessage";
|
| 11 |
+
import UISettings from "./UISettings";
|
| 12 |
|
| 13 |
export default function ChatInterface() {
|
| 14 |
const [hfClient, setHFClient] = useState(null);
|
| 15 |
const [chatHistory, setChatHistory] = useState([]);
|
| 16 |
+
const [chatSettings, setChatSettings] = useChatSettings();
|
| 17 |
+
const [uiSettings, setUISettings] = useUISettings();
|
| 18 |
|
| 19 |
+
const { sendMessage, isLoading, typingMessage, error } = useChatCompletion(
|
| 20 |
+
hfClient,
|
| 21 |
+
chatSettings,
|
| 22 |
+
uiSettings,
|
| 23 |
+
(newPair) => setChatHistory([newPair, ...chatHistory])
|
| 24 |
+
);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
|
| 26 |
+
const handleMessageSend = async (message) => {
|
| 27 |
+
sendMessage(message);
|
|
|
|
| 28 |
};
|
| 29 |
|
| 30 |
const handleHFClientReady = (client) => {
|
|
|
|
| 49 |
|
| 50 |
{/* Chat settings */}
|
| 51 |
<div>
|
| 52 |
+
<ChatSettings settings={chatSettings} setSettings={setChatSettings} />
|
| 53 |
+
</div>
|
| 54 |
+
|
| 55 |
+
{/* UI behavior settings */}
|
| 56 |
+
<div>
|
| 57 |
+
<UISettings settings={uiSettings} setSettings={setUISettings} />
|
| 58 |
</div>
|
| 59 |
|
| 60 |
{/* Input area */}
|
|
|
|
| 64 |
|
| 65 |
{/* Chat history */}
|
| 66 |
<div>
|
| 67 |
+
{isLoading && <LoadingIndicator type={uiSettings.loadingStyle} />}
|
| 68 |
+
{typingMessage && <TypingMessage text={typingMessage} />}
|
| 69 |
+
{error && <div style={{ color: "red" }}>{error.message}</div>}
|
| 70 |
+
|
| 71 |
{chatHistory.map((pair, index) => (
|
| 72 |
<MessagePair
|
| 73 |
key={index}
|
src/components/ChatSettings.jsx
CHANGED
|
@@ -1,8 +1,4 @@
|
|
| 1 |
-
|
| 2 |
-
|
| 3 |
-
export default function ChatSettings({ onChange }) {
|
| 4 |
-
const [settings, setSettings] = useChatSettings(onChange);
|
| 5 |
-
|
| 6 |
return (
|
| 7 |
<div style={{ display: "flex", flexDirection: "column", gap: "16px" }}>
|
| 8 |
<input
|
|
|
|
| 1 |
+
export default function ChatSettings({ settings, setSettings }) {
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
return (
|
| 3 |
<div style={{ display: "flex", flexDirection: "column", gap: "16px" }}>
|
| 4 |
<input
|
src/components/LoadingBar.jsx
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import "../styles/LoadingBar.css";
|
| 2 |
+
|
| 3 |
+
export default function LoadingBar() {
|
| 4 |
+
return <div className="loading-bar" />;
|
| 5 |
+
}
|
src/components/LoadingIndicator.jsx
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import LoadingBar from "./LoadingBar";
|
| 2 |
+
import Spinner from "./Spinner";
|
| 3 |
+
import ThinkingDots from "./ThinkingDots";
|
| 4 |
+
|
| 5 |
+
// Render different loading styles based on type
|
| 6 |
+
export default function LoadingIndicator({ type = "dots" }) {
|
| 7 |
+
if (type === "dots") return <ThinkingDots />;
|
| 8 |
+
if (type === "spinner") return <Spinner />;
|
| 9 |
+
if (type === "bar") return <LoadingBar />;
|
| 10 |
+
return null;
|
| 11 |
+
}
|
src/components/Spinner.jsx
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import "../styles/Spinner.css";
|
| 2 |
+
|
| 3 |
+
export default function Spinner() {
|
| 4 |
+
return <div className="spinner" />;
|
| 5 |
+
}
|
src/components/ThinkingDots.jsx
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import "../styles/ThinkingDots.css";
|
| 2 |
+
|
| 3 |
+
export default function ThinkingDots() {
|
| 4 |
+
return (
|
| 5 |
+
<div className="thinking-dots">
|
| 6 |
+
<span>.</span>
|
| 7 |
+
<span>.</span>
|
| 8 |
+
<span>.</span>
|
| 9 |
+
</div>
|
| 10 |
+
);
|
| 11 |
+
}
|
src/components/TypingMessage.jsx
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export default function TypingMessage({ text }) {
|
| 2 |
+
return <div style={{ fontStyle: "italic", color: "#666" }}>{text}</div>;
|
| 3 |
+
}
|
src/components/UISettings.jsx
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// UI behavior control panel
|
| 2 |
+
export default function UISettings({ settings, setSettings }) {
|
| 3 |
+
return (
|
| 4 |
+
<div style={{ display: "flex", alignItems: "center", gap: "12px" }}>
|
| 5 |
+
<label htmlFor="loading-style">Loading Style:</label>
|
| 6 |
+
<select
|
| 7 |
+
id="loading-style"
|
| 8 |
+
value={settings.loadingStyle}
|
| 9 |
+
onChange={(e) =>
|
| 10 |
+
setSettings({ ...settings, loadingStyle: e.target.value })
|
| 11 |
+
}
|
| 12 |
+
>
|
| 13 |
+
<option value="dots">Thinking Dots</option>
|
| 14 |
+
<option value="spinner">Spinner</option>
|
| 15 |
+
<option value="bar">Loading Bar</option>
|
| 16 |
+
<option value="none">None</option>
|
| 17 |
+
</select>
|
| 18 |
+
|
| 19 |
+
<label style={{ marginLeft: "20px" }}>
|
| 20 |
+
<input
|
| 21 |
+
type="checkbox"
|
| 22 |
+
checked={settings.typingEnabled}
|
| 23 |
+
onChange={(e) =>
|
| 24 |
+
setSettings({ ...settings, typingEnabled: e.target.checked })
|
| 25 |
+
}
|
| 26 |
+
/>
|
| 27 |
+
Enable Typing Animation
|
| 28 |
+
</label>
|
| 29 |
+
</div>
|
| 30 |
+
);
|
| 31 |
+
}
|
src/hooks/useChatCompletion.js
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useState } from "react";
|
| 2 |
+
import { simulateTyping } from "../utils/simulateTyping";
|
| 3 |
+
|
| 4 |
+
// Handle chat completion logic, including settings, UI behavior, and error management
|
| 5 |
+
export function useChatCompletion(
|
| 6 |
+
client,
|
| 7 |
+
chatSettings,
|
| 8 |
+
uiSettings,
|
| 9 |
+
onMessageComplete
|
| 10 |
+
) {
|
| 11 |
+
const [isLoading, setIsLoading] = useState(false);
|
| 12 |
+
const [typingMessage, setTypingMessage] = useState("");
|
| 13 |
+
const [error, setError] = useState(null);
|
| 14 |
+
|
| 15 |
+
const sendMessage = async (message) => {
|
| 16 |
+
if (!client) {
|
| 17 |
+
setError({
|
| 18 |
+
type: "MISSING_CLIENT",
|
| 19 |
+
message: "Please enter a valid Hugging Face token first.",
|
| 20 |
+
});
|
| 21 |
+
return;
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
setIsLoading(true);
|
| 25 |
+
setError(null);
|
| 26 |
+
setTypingMessage("");
|
| 27 |
+
|
| 28 |
+
let result;
|
| 29 |
+
|
| 30 |
+
try {
|
| 31 |
+
result = await client.chatCompletion({
|
| 32 |
+
model: "openai/gpt-oss-20b",
|
| 33 |
+
messages: [
|
| 34 |
+
{ role: "system", content: chatSettings.system },
|
| 35 |
+
{ role: "user", content: message },
|
| 36 |
+
],
|
| 37 |
+
temperature: chatSettings.temperature,
|
| 38 |
+
top_p: chatSettings.top_p,
|
| 39 |
+
max_tokens: chatSettings.max_tokens,
|
| 40 |
+
});
|
| 41 |
+
} catch (err) {
|
| 42 |
+
setError({ type: "CHAT_ERROR", message: err.message });
|
| 43 |
+
return;
|
| 44 |
+
} finally {
|
| 45 |
+
setIsLoading(false);
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
const assistantReply = result.choices[0].message.content;
|
| 49 |
+
|
| 50 |
+
if (uiSettings.typingEnabled) {
|
| 51 |
+
simulateTyping(assistantReply, setTypingMessage, () => {
|
| 52 |
+
onMessageComplete({ user: message, assistant: assistantReply });
|
| 53 |
+
setTypingMessage("");
|
| 54 |
+
});
|
| 55 |
+
} else {
|
| 56 |
+
onMessageComplete({ user: message, assistant: assistantReply });
|
| 57 |
+
}
|
| 58 |
+
};
|
| 59 |
+
|
| 60 |
+
return { sendMessage, isLoading, typingMessage, error };
|
| 61 |
+
}
|
src/hooks/useChatSettings.js
CHANGED
|
@@ -1,24 +1,12 @@
|
|
| 1 |
-
import {
|
| 2 |
|
| 3 |
// Manage chat settings and trigger onChange when settings change
|
| 4 |
-
export function useChatSettings(
|
| 5 |
const [settings, setSettings] = useState({
|
| 6 |
system: "You are a helpful assistant.",
|
| 7 |
temperature: 0.7,
|
| 8 |
top_p: 1,
|
| 9 |
max_tokens: 512,
|
| 10 |
});
|
| 11 |
-
|
| 12 |
-
// Always use the latest onChange without triggering effect
|
| 13 |
-
const stableOnChange = useRef(onChange);
|
| 14 |
-
useEffect(() => {
|
| 15 |
-
stableOnChange.current = onChange;
|
| 16 |
-
}, [onChange]);
|
| 17 |
-
|
| 18 |
-
// Trigger onChange only when settings change
|
| 19 |
-
useEffect(() => {
|
| 20 |
-
stableOnChange.current(settings);
|
| 21 |
-
}, [settings]);
|
| 22 |
-
|
| 23 |
return [settings, setSettings];
|
| 24 |
}
|
|
|
|
| 1 |
+
import { useState } from "react";
|
| 2 |
|
| 3 |
// Manage chat settings and trigger onChange when settings change
|
| 4 |
+
export function useChatSettings() {
|
| 5 |
const [settings, setSettings] = useState({
|
| 6 |
system: "You are a helpful assistant.",
|
| 7 |
temperature: 0.7,
|
| 8 |
top_p: 1,
|
| 9 |
max_tokens: 512,
|
| 10 |
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
return [settings, setSettings];
|
| 12 |
}
|
src/hooks/useUISettings.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useState } from "react";
|
| 2 |
+
|
| 3 |
+
// Manage UI behavior settings like loading style and typing animation
|
| 4 |
+
export function useUISettings() {
|
| 5 |
+
const [settings, setSettings] = useState({
|
| 6 |
+
loadingStyle: "dots",
|
| 7 |
+
typingEnabled: true,
|
| 8 |
+
});
|
| 9 |
+
return [settings, setSettings];
|
| 10 |
+
}
|
src/styles/LoadingBar.css
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.loading-bar {
|
| 2 |
+
width: 100px;
|
| 3 |
+
height: 6px;
|
| 4 |
+
background: linear-gradient(90deg, #333 0%, #ccc 50%, #333 100%);
|
| 5 |
+
background-size: 200% 100%;
|
| 6 |
+
animation: slide 1.2s linear infinite;
|
| 7 |
+
}
|
| 8 |
+
|
| 9 |
+
@keyframes slide {
|
| 10 |
+
0% {
|
| 11 |
+
background-position: 200% 0;
|
| 12 |
+
}
|
| 13 |
+
100% {
|
| 14 |
+
background-position: -200% 0;
|
| 15 |
+
}
|
| 16 |
+
}
|
src/styles/Spinner.css
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.spinner {
|
| 2 |
+
width: 24px;
|
| 3 |
+
height: 24px;
|
| 4 |
+
border: 4px solid #ccc;
|
| 5 |
+
border-top-color: #333;
|
| 6 |
+
border-radius: 50%;
|
| 7 |
+
animation: spin 0.8s linear infinite;
|
| 8 |
+
}
|
| 9 |
+
|
| 10 |
+
@keyframes spin {
|
| 11 |
+
to {
|
| 12 |
+
transform: rotate(360deg);
|
| 13 |
+
}
|
| 14 |
+
}
|
src/styles/ThinkingDots.css
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.thinking-dots {
|
| 2 |
+
font-size: 24px;
|
| 3 |
+
font-weight: bold;
|
| 4 |
+
display: inline-block;
|
| 5 |
+
}
|
| 6 |
+
|
| 7 |
+
.thinking-dots span {
|
| 8 |
+
animation: blink 1.2s infinite;
|
| 9 |
+
margin: 0 2px;
|
| 10 |
+
}
|
| 11 |
+
|
| 12 |
+
.thinking-dots span:nth-child(2) {
|
| 13 |
+
animation-delay: 0.2s;
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
.thinking-dots span:nth-child(3) {
|
| 17 |
+
animation-delay: 0.4s;
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
@keyframes blink {
|
| 21 |
+
0%,
|
| 22 |
+
100% {
|
| 23 |
+
opacity: 0;
|
| 24 |
+
}
|
| 25 |
+
50% {
|
| 26 |
+
opacity: 1;
|
| 27 |
+
}
|
| 28 |
+
}
|
src/utils/simulateTyping.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Simulate typing effect by revealing text one character at a time
|
| 2 |
+
export function simulateTyping(fullText, onUpdate, onComplete, delay = 20) {
|
| 3 |
+
let index = 0;
|
| 4 |
+
const interval = setInterval(() => {
|
| 5 |
+
onUpdate((prev) => prev + fullText[index]);
|
| 6 |
+
index++;
|
| 7 |
+
if (index >= fullText.length) {
|
| 8 |
+
clearInterval(interval);
|
| 9 |
+
onComplete();
|
| 10 |
+
}
|
| 11 |
+
}, delay);
|
| 12 |
+
}
|