@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/CHANGELOG.md +19 -1
- package/dist/chatbot.d.ts +8 -5
- package/dist/chatbot.d.ts.map +1 -1
- package/dist/chatbot.js +142 -162
- package/dist/client.d.ts +41 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +168 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/styles/chatbot.module.css +94 -61
- package/dist-cjs/chatbot.d.ts +8 -5
- package/dist-cjs/chatbot.d.ts.map +1 -1
- package/dist-cjs/chatbot.js +141 -161
- package/dist-cjs/client.d.ts +41 -0
- package/dist-cjs/client.d.ts.map +1 -0
- package/dist-cjs/client.js +174 -0
- package/dist-cjs/index.d.ts +1 -0
- package/dist-cjs/index.d.ts.map +1 -1
- package/dist-cjs/index.js +15 -0
- package/dist-cjs/styles/chatbot.module.css +94 -61
- package/docs/adrs/index.md +4 -0
- package/package.json +1 -3
- package/src/chatbot.tsx +219 -255
- package/src/client.ts +254 -0
- package/src/index.ts +1 -0
- package/src/styles/chatbot.module.css +94 -61
package/src/chatbot.tsx
CHANGED
|
@@ -1,306 +1,270 @@
|
|
|
1
|
-
import React, { lazy, Suspense,
|
|
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
|
-
|
|
7
|
-
|
|
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
|
-
|
|
20
|
+
type ChatbotState = "loading" | "ready" | "signed_out" | "limit_reached" | "error";
|
|
13
21
|
|
|
14
|
-
|
|
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
|
-
|
|
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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
const [
|
|
31
|
-
const [
|
|
32
|
-
|
|
33
|
-
const
|
|
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
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
|
|
149
|
-
|
|
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
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
116
|
+
void loadUsage();
|
|
117
|
+
return () => {
|
|
118
|
+
active = false;
|
|
119
|
+
};
|
|
120
|
+
}, [applyUsage, clientOptions, props.onAuthRequired]);
|
|
163
121
|
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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
|
-
|
|
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
|
|
213
|
-
<div className={styles
|
|
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
|
-
|
|
222
|
-
|
|
223
|
-
|
|
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
|
-
|
|
234
|
+
event.stopPropagation();
|
|
226
235
|
}
|
|
227
236
|
}}
|
|
228
|
-
placeholder=
|
|
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
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
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
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
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
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
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
|
+
}
|