ferrywuai commited on
Commit
cc1ccff
·
1 Parent(s): 41c0d09

Modularize chat completion and add loading and typing animation

Browse files

modified: 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 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] = useState();
 
11
 
12
- const handleMessageSend = async (message) => {
13
- if (!hfClient) {
14
- alert("Please enter a valid Hugging Face token first.");
15
- return;
16
- }
17
- if (!chatSettings) return;
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
- const assistantReply = result.choices[0].message.content;
31
- const newPair = { user: message, assistant: assistantReply };
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 onChange={(s) => setChatSettings(s)} />
 
 
 
 
 
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
- import { useChatSettings } from "../hooks/useChatSettings";
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 { useEffect, useRef, useState } from "react";
2
 
3
  // Manage chat settings and trigger onChange when settings change
4
- export function useChatSettings(onChange) {
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
+ }