@plasius/chatbot 1.0.0 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/chatbot.tsx CHANGED
@@ -1,306 +1,270 @@
1
- import React, { lazy, Suspense, useState, useEffect } from "react";
1
+ import React, { lazy, Suspense, useCallback, useEffect, useMemo, useState } from "react";
2
2
  import type { EmojiClickData } from "emoji-picker-react";
3
+ import { FaPaperPlane, FaSmile } from "react-icons/fa";
4
+ import styles from "./styles/chatbot.module.css";
5
+ import {
6
+ ChatbotClientError,
7
+ getChatbotUsage,
8
+ sendChatbotMessage,
9
+ type ChatMessage,
10
+ type ChatbotClientOptions,
11
+ type ChatbotUsage,
12
+ } from "./client.js";
3
13
 
4
14
  const EmojiPicker = lazy(() =>
5
- import("emoji-picker-react/dist/emoji-picker-react.esm.js").then(
6
- (module) => ({
7
- default: module.EmojiPicker, // <--- force the correct component export
8
- })
9
- )
15
+ import("emoji-picker-react/dist/emoji-picker-react.esm.js").then((module) => ({
16
+ default: module.EmojiPicker,
17
+ }))
10
18
  );
11
19
 
12
- import { FaPaperPlane, FaSmile } from "react-icons/fa";
20
+ type ChatbotState = "loading" | "ready" | "signed_out" | "limit_reached" | "error";
13
21
 
14
- import OpenAI from "openai";
22
+ export interface ChatBotProps extends ChatbotClientOptions {
23
+ initialMessages?: ChatMessage[];
24
+ systemPrompt?: string;
25
+ placeholder?: string;
26
+ title?: string;
27
+ onUsageChange?: (usage: ChatbotUsage) => void;
28
+ onAuthRequired?: () => void;
29
+ }
15
30
 
16
- import styles from "./styles/chatbot.module.css"; // Import a CSS file for styling
31
+ const DEFAULT_TITLE = "Plasius Chatbot";
32
+ const DEFAULT_PLACEHOLDER = "Ask Plasius something...";
33
+ const DEFAULT_SYSTEM_PROMPT =
34
+ "You are the Plasius assistant. Keep responses concise, practical, and factual.";
17
35
 
18
- interface ChatBotProps {
19
- openaiOrgID: string;
20
- openaiProjectKey: string;
21
- openaiAPIKey: string;
36
+ function statusMessage(state: ChatbotState, usage: ChatbotUsage | null): string {
37
+ if (state === "loading") return "Checking access...";
38
+ if (state === "signed_out") return "Sign in to use chatbot.";
39
+ if (state === "limit_reached") {
40
+ if (usage) {
41
+ return `Demo limit reached (${usage.used}/${usage.limit} messages).`;
42
+ }
43
+ return "Demo limit reached.";
44
+ }
45
+ if (state === "error") return "Chatbot is currently unavailable.";
46
+ return "";
22
47
  }
23
48
 
24
49
  export default function ChatBot(
25
50
  props: React.PropsWithChildren<ChatBotProps>
26
51
  ): React.ReactElement {
27
- const [messages, setMessages] = useState<
28
- OpenAI.Chat.Completions.ChatCompletionMessageParam[]
29
- >([]);
30
- const [input, setInput] = useState<string>("");
31
- const [showEmojiPicker, setShowEmojiPicker] = useState<boolean>(false);
32
-
33
- const openai = new OpenAI({
34
- apiKey: props.openaiAPIKey,
35
- project: props.openaiProjectKey,
36
- organization: props.openaiOrgID,
37
- dangerouslyAllowBrowser: true,
38
- });
52
+ const [messages, setMessages] = useState<ChatMessage[]>(props.initialMessages ?? []);
53
+ const [input, setInput] = useState("");
54
+ const [showEmojiPicker, setShowEmojiPicker] = useState(false);
55
+ const [isSending, setIsSending] = useState(false);
56
+ const [state, setState] = useState<ChatbotState>("loading");
57
+ const [usage, setUsage] = useState<ChatbotUsage | null>(null);
58
+ const [errorMessage, setErrorMessage] = useState<string | null>(null);
39
59
 
40
- const chat = async (
41
- msgs: OpenAI.Chat.Completions.ChatCompletionMessageParam[],
42
- callback: (arg: OpenAI.Chat.Completions.ChatCompletionMessageParam) => void
43
- ): Promise<void> => {
44
- try {
45
- const value = await openai.chat.completions.create({
46
- model: "gpt-o1",
47
- messages: msgs,
48
- });
49
- value.choices.forEach((choice: OpenAI.ChatCompletion.Choice) => {
50
- callback({ content: choice.message.content ?? "", role: "system" });
51
- });
52
- } catch (err) {
53
- console.error("chat() failed", err);
54
- }
55
- };
60
+ const clientOptions = useMemo<ChatbotClientOptions>(
61
+ () => ({
62
+ endpoint: props.endpoint,
63
+ credentials: props.credentials,
64
+ headers: props.headers,
65
+ fetchFn: props.fetchFn,
66
+ csrfCookieName: props.csrfCookieName,
67
+ csrfHeaderName: props.csrfHeaderName,
68
+ bootstrapCsrf: props.bootstrapCsrf,
69
+ }),
70
+ [
71
+ props.endpoint,
72
+ props.credentials,
73
+ props.headers,
74
+ props.fetchFn,
75
+ props.csrfCookieName,
76
+ props.csrfHeaderName,
77
+ props.bootstrapCsrf,
78
+ ]
79
+ );
56
80
 
57
- const objects = window.location.origin + "/api/objects/list";
58
- const decorations = window.location.origin + "/api/decorations/list";
59
- const locations = window.location.origin + "/api/locations/list";
60
- const surfaces = window.location.origin + "/api/surfaces/list";
81
+ const applyUsage = useCallback(
82
+ (nextUsage: ChatbotUsage) => {
83
+ setUsage(nextUsage);
84
+ props.onUsageChange?.(nextUsage);
85
+ setState(nextUsage.exhausted ? "limit_reached" : "ready");
86
+ },
87
+ [props.onUsageChange]
88
+ );
61
89
 
62
90
  useEffect(() => {
63
- void chat(
64
- [
65
- {
66
- role: "system",
67
- content: `You are a game designer, you are responsible for helping build the world and game mechanics, adjusting the game to be more fun for the player playing,
68
- using your knowledge of gameplay mechanics and world building you are going to help assign objects to the map.
69
-
70
- You can find the list of objects from the following url: ${objects}
71
- You can find the list of decorations from the following url: ${decorations}
72
- You can find the list of surfaces from the following url: ${surfaces}
91
+ let active = true;
73
92
 
74
- Each location is a hexagon with a radius of 10 meters and 10m tall (allowing for locations to be on top of each other!), and a q and r coordinate system.
75
- The q coordinate is the horizontal axis, and the r coordinate is the vertical axis. The center of the hexagon is at (0, 0),
76
- and the corners are at (5, 8.66), (10, 0), (5, -8.66), (-5, -8.66), (-10, 0), and (-5, 8.66).
77
- Adjacent hexagons are at (q + 1, r), (q - 1, r), (q, r + 1), (q, r - 1), (q + 1, r - 1), and (q - 1, r + 1) and you should try and
78
- coordinate over the hexagons to make sure the objects are placed in a way that makes sense.
79
-
80
- Try and align surfaces, decorations, and objects to a 1m size hexagon when placing items so they align to each other in the world,
81
- but avoid overlapping the objects with each other, unless they are meant to overlap (like a chair under a table, or a tree in a bush).
82
- Surfaces should not overlap with each other, and should be placed in a way that makes sense for the location,
83
- such as a road should be continuous and have purpose, to or from somewhere,
84
- use the locations map to identify good roads, forests, mountains, lakes and oceans locations.
85
-
86
- for each prompt the user gives you, will relate to a specific location in the game world, you should take in the location,
87
- some basic information about the users expectations for the location and return a json object with the following fields:
88
- {
89
- "location": {
90
- "r": "number", // 10m hexagon radius
91
- "q": "number", // 10m hexagon radius
92
- "elevation": "number",
93
- "name": "string",
94
- "description": "string",
95
- "type": "string",
96
-
97
- "surfaces": [{
98
- "location": {
99
- "q": "number", // 1m hexagon radius
100
- "r": "number", // 1m hexagon radius
101
- "elevation": "number"
102
- },
103
- "name": "string",
104
- "type": "string",
105
- "description": "string",
106
- "url": "string",
107
- "image": "string",
108
- "rotation": "number",
109
- "color": "string"
110
- }],
111
- "decorations": [
112
- {
113
- "name": "string",
114
- "type": "string",
115
- "description": "string",
116
- "url": "string",
117
- "image": "string",
118
- "rotation": "number",
119
- "scale": "number",
120
- "color": "string",
121
- "location": {
122
- "x": "number",
123
- "y": "number",
124
- "z": "number"
125
- }
126
- }
127
- ],
128
- "objects": [
129
- {
130
- "name": "string",
131
- "type": "string",
132
- "description": "string",
133
- "url": "string",
134
- "image": "string",
135
- "rotation": "number",
136
- "scale": "number",
137
- "color": "string",
138
- "location": {
139
- "x": "number",
140
- "y": "number",
141
- "z": "number"
142
- }
143
- }
144
- ]
145
- }
146
- }
93
+ const loadUsage = async () => {
94
+ setState("loading");
95
+ setErrorMessage(null);
96
+ try {
97
+ const result = await getChatbotUsage(clientOptions);
98
+ if (!active) return;
99
+ applyUsage(result.usage);
100
+ } catch (error) {
101
+ if (!active) return;
102
+ if (error instanceof ChatbotClientError && error.status === 401) {
103
+ setState("signed_out");
104
+ setErrorMessage("Sign in to start chatting.");
105
+ props.onAuthRequired?.();
106
+ return;
107
+ }
147
108
 
148
- You can find the list of populated locations from the following url: ${locations} for reference and to allow you to be more creative in your assignments.
149
- If your current location is in the list, then take the current objects and decorations into account when placing the new objects, and remove or replace the old ones.`,
150
- },
151
- ],
152
- (arg: OpenAI.Chat.Completions.ChatCompletionMessageParam) => {
153
- setMessages((prev) => [...prev, arg]);
109
+ setState("error");
110
+ setErrorMessage(
111
+ error instanceof Error ? error.message : "Failed to load chatbot."
112
+ );
154
113
  }
155
- );
156
- }, []);
114
+ };
157
115
 
158
- const handleSend = async (): Promise<void> => {
159
- if (input.trim()) {
160
- setMessages((prev) => [...prev, { content: input, role: "user" }]);
161
- setInput("");
162
- setShowEmojiPicker(false);
116
+ void loadUsage();
117
+ return () => {
118
+ active = false;
119
+ };
120
+ }, [applyUsage, clientOptions, props.onAuthRequired]);
163
121
 
164
- try {
165
- const value = await openai.chat.completions.create({
166
- model: "gpt-4o-mini",
167
- messages: [{ content: input, role: "user" }],
168
- });
169
- value.choices.forEach((choice: OpenAI.ChatCompletion.Choice) => {
170
- setMessages((prev) => [
171
- ...prev,
172
- { content: choice.message.content ?? "", role: "system" },
173
- ]);
174
- });
175
- } catch (err) {
176
- console.error("handleSend() failed", err);
177
- }
178
- }
179
- };
122
+ const sendDisabled =
123
+ isSending ||
124
+ state === "loading" ||
125
+ state === "signed_out" ||
126
+ state === "limit_reached" ||
127
+ !input.trim();
180
128
 
181
129
  const handleEmojiClick = (emojiData: EmojiClickData): void => {
182
130
  setInput((prev) => prev + (emojiData.emoji ?? ""));
183
131
  };
184
132
 
185
- const contentToString = (
186
- content: OpenAI.Chat.Completions.ChatCompletionMessageParam["content"]
187
- ): string => {
188
- if (typeof content === "string" || content == null) return content ?? "";
189
- if (Array.isArray(content)) {
190
- return content
191
- .map((part: unknown) => {
192
- if (typeof part === "string") return part;
193
- if (
194
- typeof part === "object" &&
195
- part !== null &&
196
- Object.prototype.hasOwnProperty.call(part, "text")
197
- ) {
198
- const text = (part as Record<string, unknown>).text;
199
- return typeof text === "string" ? text : "";
133
+ const handleSend = useCallback(async (): Promise<void> => {
134
+ const message = input.trim();
135
+ if (!message || sendDisabled) return;
136
+
137
+ const userMessage: ChatMessage = { role: "user", content: message };
138
+ const nextHistory = [...messages, userMessage].slice(-20);
139
+
140
+ setMessages((prev) => [...prev, userMessage]);
141
+ setInput("");
142
+ setShowEmojiPicker(false);
143
+ setIsSending(true);
144
+ setErrorMessage(null);
145
+
146
+ try {
147
+ const response = await sendChatbotMessage(
148
+ {
149
+ message,
150
+ history: nextHistory,
151
+ systemPrompt: props.systemPrompt ?? DEFAULT_SYSTEM_PROMPT,
152
+ },
153
+ clientOptions
154
+ );
155
+
156
+ setMessages((prev) => [
157
+ ...prev,
158
+ { role: "assistant", content: response.reply },
159
+ ]);
160
+ applyUsage(response.usage);
161
+ } catch (error) {
162
+ if (error instanceof ChatbotClientError) {
163
+ if (error.status === 401) {
164
+ setState("signed_out");
165
+ setErrorMessage("You must be signed in to use chatbot.");
166
+ props.onAuthRequired?.();
167
+ return;
168
+ }
169
+
170
+ if (error.status === 429) {
171
+ if (error.usage) {
172
+ applyUsage(error.usage);
173
+ } else {
174
+ setState("limit_reached");
200
175
  }
201
- return "";
202
- })
203
- .join("");
176
+ setErrorMessage("You reached the 10 message demo limit.");
177
+ return;
178
+ }
179
+
180
+ setState("error");
181
+ setErrorMessage(error.message);
182
+ return;
183
+ }
184
+
185
+ setState("error");
186
+ setErrorMessage(error instanceof Error ? error.message : "Message failed.");
187
+ } finally {
188
+ setIsSending(false);
204
189
  }
205
- return "";
206
- };
190
+ }, [
191
+ applyUsage,
192
+ clientOptions,
193
+ input,
194
+ messages,
195
+ props.onAuthRequired,
196
+ props.systemPrompt,
197
+ sendDisabled,
198
+ ]);
207
199
 
208
200
  return (
209
201
  <div className={styles.chatbotcontainer}>
202
+ <div className={styles.header}>
203
+ <div className={styles.title}>{props.title ?? DEFAULT_TITLE}</div>
204
+ <div className={styles.usage}>
205
+ {usage ? `${usage.used}/${usage.limit} used` : "No usage data"}
206
+ </div>
207
+ </div>
208
+
209
+ {(state !== "ready" || errorMessage) && (
210
+ <div className={styles.notice}>
211
+ {errorMessage ?? statusMessage(state, usage)}
212
+ </div>
213
+ )}
214
+
210
215
  <div className={styles.messagesbox}>
211
216
  {messages.map((msg, index) => (
212
- <div key={index} className={styles.message + ` ${styles[msg.role]}`}>
213
- <div className={styles.bubble}>{contentToString(msg.content)}</div>
217
+ <div key={`${msg.role}-${index}`} className={styles.message}>
218
+ <div className={styles[msg.role]}>
219
+ <div className={styles.bubble}>{msg.content}</div>
220
+ </div>
214
221
  </div>
215
222
  ))}
216
223
  </div>
224
+
217
225
  <div className={styles.inputbox}>
218
226
  <input
219
227
  type="text"
220
228
  value={input}
221
- onChange={(e) => setInput(e.target.value)}
222
- onKeyUp={async (e) => {
223
- if (e.key === "Enter" && e.shiftKey === false) {
229
+ disabled={sendDisabled}
230
+ onChange={(event) => setInput(event.target.value)}
231
+ onKeyUp={async (event) => {
232
+ if (event.key === "Enter" && !event.shiftKey) {
224
233
  await handleSend();
225
- e.stopPropagation();
234
+ event.stopPropagation();
226
235
  }
227
236
  }}
228
- placeholder="Type a message..."
229
- />
230
- <FaSmile
231
- onClick={() => setShowEmojiPicker(!showEmojiPicker)}
232
- className={styles.emojiicon}
237
+ placeholder={props.placeholder ?? DEFAULT_PLACEHOLDER}
233
238
  />
234
- {showEmojiPicker && (
235
- <Suspense fallback={<div>Loading emoji picker...</div>}>
236
- <EmojiPicker onEmojiClick={handleEmojiClick} />
237
- </Suspense>
238
- )}
239
- <FaPaperPlane onClick={handleSend} className={styles.sendicon} />
240
- </div>
241
- </div>
242
- );
243
- }
244
239
 
245
- /*
246
-
247
- // Chatbot.tsx
248
- import React, { useState } from 'react';
249
- import { FaPaperPlane, FaSmile } from 'react-icons/fa';
250
- import Picker, { IEmojiData } from 'emoji-picker-react';
251
-
252
- interface Message {
253
- text: string;
254
- user: 'me' | 'bot';
255
- }
256
-
257
- const Chatbot: React.FC = () => {
258
- const [messages, setMessages] = useState<Message[]>([]);
259
- const [input, setInput] = useState<string>('');
260
- const [showEmojiPicker, setShowEmojiPicker] = useState<boolean>(false);
261
-
262
- const handleSend = () => {
263
- if (input.trim()) {
264
- setMessages([...messages, { text: input, user: 'me' }]);
265
- setInput('');
266
- setShowEmojiPicker(false);
267
-
268
- // Here you would also call the OpenAI API and handle the response
269
- // Example:
270
- // fetchOpenAIResponse(input).then(response => {
271
- // setMessages([...messages, { text: input, user: 'me' }, { text: response, user: 'bot' }]);
272
- // });
273
- }
274
- };
240
+ <button
241
+ type="button"
242
+ className={styles.iconButton}
243
+ onClick={() => setShowEmojiPicker((current) => !current)}
244
+ disabled={state === "signed_out" || state === "limit_reached"}
245
+ aria-label="Open emoji picker"
246
+ >
247
+ <FaSmile className={styles.emojiicon} />
248
+ </button>
275
249
 
276
- const handleEmojiClick = (event: React.MouseEvent<Element, MouseEvent>, emojiObject: IEmojiData) => {
277
- setInput(input + emojiObject.emoji);
278
- };
279
-
280
- return (
281
- <div className="chatbot-container">
282
- <div className="messages-box">
283
- {messages.map((msg, index) => (
284
- <div key={index} className={`message ${msg.user}`}>
285
- {msg.text}
250
+ {showEmojiPicker && (
251
+ <div className={styles.emojiPicker}>
252
+ <Suspense fallback={<div>Loading emoji picker...</div>}>
253
+ <EmojiPicker onEmojiClick={handleEmojiClick} />
254
+ </Suspense>
286
255
  </div>
287
- ))}
288
- </div>
289
- <div className="input-box">
290
- <input
291
- type="text"
292
- value={input}
293
- onChange={(e) => setInput(e.target.value)}
294
- placeholder="Type a message..."
295
- />
296
- <FaSmile onClick={() => setShowEmojiPicker(!showEmojiPicker)} className="emoji-icon" />
297
- {showEmojiPicker && <Picker onEmojiClick={handleEmojiClick} />}
298
- <FaPaperPlane onClick={handleSend} className="send-icon" />
256
+ )}
257
+
258
+ <button
259
+ type="button"
260
+ className={styles.iconButton}
261
+ onClick={() => void handleSend()}
262
+ disabled={sendDisabled}
263
+ aria-label="Send message"
264
+ >
265
+ <FaPaperPlane className={styles.sendicon} />
266
+ </button>
299
267
  </div>
300
268
  </div>
301
269
  );
302
- };
303
-
304
- export default Chatbot;
305
-
306
- */
270
+ }