@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/client.ts ADDED
@@ -0,0 +1,254 @@
1
+ export type ChatRole = "system" | "user" | "assistant";
2
+
3
+ export interface ChatMessage {
4
+ role: ChatRole;
5
+ content: string;
6
+ }
7
+
8
+ export interface ChatbotUsage {
9
+ limit: number;
10
+ used: number;
11
+ remaining: number;
12
+ exhausted: boolean;
13
+ }
14
+
15
+ export interface ChatbotReply {
16
+ reply: string;
17
+ model: string;
18
+ usage: ChatbotUsage;
19
+ }
20
+
21
+ export interface ChatbotUsageResponse {
22
+ usage: ChatbotUsage;
23
+ }
24
+
25
+ export interface ChatbotClientOptions {
26
+ endpoint?: string;
27
+ credentials?: RequestCredentials;
28
+ headers?: HeadersInit | (() => HeadersInit | Promise<HeadersInit>);
29
+ fetchFn?: typeof fetch;
30
+ csrfCookieName?: string;
31
+ csrfHeaderName?: string;
32
+ bootstrapCsrf?: boolean;
33
+ }
34
+
35
+ interface ErrorPayload {
36
+ error?: string;
37
+ message?: string;
38
+ usage?: ChatbotUsage;
39
+ }
40
+
41
+ export class ChatbotClientError extends Error {
42
+ status: number;
43
+ code?: string;
44
+ usage?: ChatbotUsage;
45
+
46
+ constructor(status: number, message: string, code?: string, usage?: ChatbotUsage) {
47
+ super(message);
48
+ this.name = "ChatbotClientError";
49
+ this.status = status;
50
+ this.code = code;
51
+ this.usage = usage;
52
+ }
53
+ }
54
+
55
+ const DEFAULT_ENDPOINT = "/ai/chatbot";
56
+ const DEFAULT_CSRF_COOKIE_NAME = "csrf-token";
57
+ const DEFAULT_CSRF_HEADER_NAME = "x-csrf-token";
58
+
59
+ function resolveFetch(fetchFn?: typeof fetch): typeof fetch {
60
+ const resolved = fetchFn ?? (typeof fetch !== "undefined" ? fetch : undefined);
61
+ if (!resolved) {
62
+ throw new Error("No fetch implementation is available.");
63
+ }
64
+ return resolved;
65
+ }
66
+
67
+ async function resolveHeaders(
68
+ headers?: HeadersInit | (() => HeadersInit | Promise<HeadersInit>)
69
+ ): Promise<HeadersInit | undefined> {
70
+ if (!headers) return undefined;
71
+ if (typeof headers === "function") {
72
+ return await headers();
73
+ }
74
+ return headers;
75
+ }
76
+
77
+ function readCookie(name: string): string | undefined {
78
+ if (typeof document === "undefined" || typeof document.cookie !== "string") {
79
+ return undefined;
80
+ }
81
+
82
+ const entry = document.cookie
83
+ .split(";")
84
+ .map((part) => part.trim())
85
+ .find((value) => value.startsWith(`${name}=`));
86
+
87
+ if (!entry) return undefined;
88
+
89
+ const [, rawValue = ""] = entry.split("=");
90
+ try {
91
+ return decodeURIComponent(rawValue);
92
+ } catch {
93
+ return rawValue;
94
+ }
95
+ }
96
+
97
+ function normalizeUsage(value: unknown): ChatbotUsage | undefined {
98
+ if (!value || typeof value !== "object") return undefined;
99
+ const usage = value as Record<string, unknown>;
100
+ if (
101
+ typeof usage.limit !== "number" ||
102
+ typeof usage.used !== "number" ||
103
+ typeof usage.remaining !== "number" ||
104
+ typeof usage.exhausted !== "boolean"
105
+ ) {
106
+ return undefined;
107
+ }
108
+
109
+ return {
110
+ limit: usage.limit,
111
+ used: usage.used,
112
+ remaining: usage.remaining,
113
+ exhausted: usage.exhausted,
114
+ };
115
+ }
116
+
117
+ async function parseBody(response: Response): Promise<unknown> {
118
+ const contentType = response.headers.get("content-type") ?? "";
119
+ if (contentType.includes("application/json")) {
120
+ return await response.json();
121
+ }
122
+
123
+ const text = await response.text();
124
+ if (!text) return undefined;
125
+ try {
126
+ return JSON.parse(text) as unknown;
127
+ } catch {
128
+ return text;
129
+ }
130
+ }
131
+
132
+ function normalizeError(status: number, body: unknown): ChatbotClientError {
133
+ const payload = body && typeof body === "object" ? (body as ErrorPayload) : undefined;
134
+ const fallbackMessage =
135
+ status === 401
136
+ ? "Sign in to use chatbot."
137
+ : status === 429
138
+ ? "Chatbot usage limit reached."
139
+ : "Chatbot request failed.";
140
+ const message = payload?.message ?? fallbackMessage;
141
+
142
+ return new ChatbotClientError(status, message, payload?.error, payload?.usage);
143
+ }
144
+
145
+ async function ensureCsrfToken(
146
+ fetcher: typeof fetch,
147
+ endpoint: string,
148
+ options: ChatbotClientOptions,
149
+ baseHeaders: HeadersInit
150
+ ): Promise<string | undefined> {
151
+ const cookieName = options.csrfCookieName ?? DEFAULT_CSRF_COOKIE_NAME;
152
+ const existing = readCookie(cookieName);
153
+ if (existing || options.bootstrapCsrf === false) {
154
+ return existing;
155
+ }
156
+
157
+ await fetcher(endpoint, {
158
+ method: "GET",
159
+ credentials: options.credentials ?? "include",
160
+ headers: baseHeaders,
161
+ });
162
+
163
+ return readCookie(cookieName);
164
+ }
165
+
166
+ export async function getChatbotUsage(
167
+ options: ChatbotClientOptions = {}
168
+ ): Promise<ChatbotUsageResponse> {
169
+ const fetcher = resolveFetch(options.fetchFn);
170
+ const endpoint = options.endpoint ?? DEFAULT_ENDPOINT;
171
+ const customHeaders = await resolveHeaders(options.headers);
172
+
173
+ const response = await fetcher(endpoint, {
174
+ method: "GET",
175
+ credentials: options.credentials ?? "include",
176
+ headers: {
177
+ Accept: "application/json",
178
+ ...(customHeaders ?? {}),
179
+ },
180
+ });
181
+
182
+ const body = await parseBody(response);
183
+ if (!response.ok) {
184
+ throw normalizeError(response.status, body);
185
+ }
186
+
187
+ if (!body || typeof body !== "object") {
188
+ throw new Error("Invalid chatbot usage response.");
189
+ }
190
+ const usage = normalizeUsage((body as Record<string, unknown>).usage);
191
+ if (!usage) {
192
+ throw new Error("Invalid chatbot usage response.");
193
+ }
194
+
195
+ return { usage };
196
+ }
197
+
198
+ export async function sendChatbotMessage(
199
+ payload: {
200
+ message: string;
201
+ history?: ChatMessage[];
202
+ systemPrompt?: string;
203
+ },
204
+ options: ChatbotClientOptions = {}
205
+ ): Promise<ChatbotReply> {
206
+ const fetcher = resolveFetch(options.fetchFn);
207
+ const endpoint = options.endpoint ?? DEFAULT_ENDPOINT;
208
+ const customHeaders = await resolveHeaders(options.headers);
209
+ const baseHeaders: HeadersInit = {
210
+ Accept: "application/json",
211
+ "Content-Type": "application/json",
212
+ ...(customHeaders ?? {}),
213
+ };
214
+
215
+ const csrfToken = await ensureCsrfToken(fetcher, endpoint, options, baseHeaders);
216
+ const csrfHeader = options.csrfHeaderName ?? DEFAULT_CSRF_HEADER_NAME;
217
+ const requestHeaders = csrfToken
218
+ ? {
219
+ ...baseHeaders,
220
+ [csrfHeader]: csrfToken,
221
+ }
222
+ : baseHeaders;
223
+
224
+ const response = await fetcher(endpoint, {
225
+ method: "POST",
226
+ credentials: options.credentials ?? "include",
227
+ headers: requestHeaders,
228
+ body: JSON.stringify({
229
+ message: payload.message,
230
+ history: payload.history ?? [],
231
+ systemPrompt: payload.systemPrompt,
232
+ }),
233
+ });
234
+
235
+ const body = await parseBody(response);
236
+ if (!response.ok) {
237
+ throw normalizeError(response.status, body);
238
+ }
239
+
240
+ if (!body || typeof body !== "object") {
241
+ throw new Error("Invalid chatbot response.");
242
+ }
243
+
244
+ const content = body as Record<string, unknown>;
245
+ const reply = content.reply;
246
+ const model = content.model;
247
+ const usage = normalizeUsage(content.usage);
248
+
249
+ if (typeof reply !== "string" || typeof model !== "string" || !usage) {
250
+ throw new Error("Invalid chatbot response.");
251
+ }
252
+
253
+ return { reply, model, usage };
254
+ }
package/src/index.ts CHANGED
@@ -1 +1,2 @@
1
1
  export { default as ChatBot } from "./chatbot.js";
2
+ export * from "./client.js";
@@ -1,106 +1,139 @@
1
- /* src/components/Chatbot.css */
2
1
  .chatbotcontainer {
3
2
  display: flex;
4
3
  flex-direction: column;
5
- height: 100vh;
6
4
  width: 100%;
7
- max-width: 600px;
5
+ max-width: 720px;
8
6
  margin: 0 auto;
9
- border: 1px solid #ddd;
10
- border-radius: 8px;
11
- background: #f9f9f9;
7
+ border: 1px solid #d1d7e0;
8
+ border-radius: 12px;
9
+ background: #f7f9fc;
10
+ min-height: 70vh;
11
+ overflow: hidden;
12
+ }
13
+
14
+ .header {
15
+ display: flex;
16
+ justify-content: space-between;
17
+ align-items: center;
18
+ padding: 12px 16px;
19
+ border-bottom: 1px solid #d1d7e0;
20
+ background: #eef3fb;
21
+ }
22
+
23
+ .title {
24
+ font-size: 16px;
25
+ font-weight: 700;
26
+ color: #1f2a3d;
27
+ }
28
+
29
+ .usage {
30
+ font-size: 13px;
31
+ font-weight: 600;
32
+ color: #4c5970;
33
+ }
34
+
35
+ .notice {
36
+ padding: 10px 16px;
37
+ font-size: 13px;
38
+ border-bottom: 1px solid #d1d7e0;
39
+ background: #fff8db;
40
+ color: #6f5200;
12
41
  }
13
42
 
14
43
  .messagesbox {
15
44
  flex: 1;
16
- padding: 10px;
45
+ padding: 12px;
17
46
  overflow-y: auto;
18
- background: #fff;
47
+ background: #ffffff;
48
+ display: flex;
49
+ flex-direction: column;
50
+ gap: 8px;
19
51
  }
20
52
 
21
53
  .message {
22
54
  display: flex;
23
- margin-bottom: 10px;
24
55
  }
25
56
 
26
- .message.user {
27
- justify-content: flex-end;
57
+ .user {
58
+ margin-left: auto;
28
59
  }
29
60
 
30
- .message.system {
31
- justify-content: flex-start;
61
+ .assistant,
62
+ .system {
63
+ margin-right: auto;
32
64
  }
33
65
 
34
66
  .bubble {
35
- max-width: 60%;
36
- padding: 10px 15px;
37
- border-radius: 15px;
67
+ max-width: 85%;
68
+ padding: 10px 12px;
69
+ border-radius: 12px;
70
+ line-height: 1.4;
71
+ word-break: break-word;
72
+ white-space: pre-wrap;
38
73
  font-size: 14px;
39
- line-height: 1.5;
40
- position: relative;
41
- word-wrap: break-word;
42
- }
43
-
44
- .message.user .bubble {
45
- background: #007bff;
46
- color: #fff;
47
- }
48
-
49
- .message.system .bubble {
50
- background: #e9ecef;
51
- color: #333;
52
- }
53
-
54
- .bubble::after {
55
- content: '';
56
- position: absolute;
57
- width: 0;
58
- height: 0;
59
- border-style: solid;
60
74
  }
61
75
 
62
- .message.user .bubble::after {
63
- right: -10px;
64
- top: 50%;
65
- border-width: 10px 0 10px 10px;
66
- border-color: transparent transparent transparent #007bff;
67
- transform: translateY(-50%);
76
+ .user .bubble {
77
+ background: #2463eb;
78
+ color: #ffffff;
68
79
  }
69
80
 
70
- .message.system .bubble::after {
71
- left: -10px;
72
- top: 50%;
73
- border-width: 10px 10px 10px 0;
74
- border-color: transparent #e9ecef transparent transparent;
75
- transform: translateY(-50%);
81
+ .assistant .bubble,
82
+ .system .bubble {
83
+ background: #edf2fa;
84
+ color: #182230;
76
85
  }
77
86
 
78
87
  .inputbox {
79
88
  display: flex;
80
89
  align-items: center;
90
+ gap: 8px;
81
91
  padding: 10px;
82
- border-top: 1px solid #ddd;
83
- background: #fff;
92
+ border-top: 1px solid #d1d7e0;
93
+ background: #f7f9fc;
94
+ position: relative;
84
95
  }
85
96
 
86
97
  .inputbox input {
87
98
  flex: 1;
88
- padding: 10px;
89
- border: 1px solid #ddd;
90
- border-radius: 20px;
99
+ border: 1px solid #c3cedf;
100
+ border-radius: 999px;
101
+ padding: 10px 14px;
91
102
  font-size: 14px;
92
103
  }
93
104
 
94
- .emojiicon, .sendicon {
95
- margin-left: 10px;
105
+ .inputbox input:disabled {
106
+ cursor: not-allowed;
107
+ opacity: 0.75;
108
+ background: #f1f4f9;
109
+ }
110
+
111
+ .iconButton {
112
+ width: 36px;
113
+ height: 36px;
114
+ display: inline-flex;
115
+ align-items: center;
116
+ justify-content: center;
117
+ border: 1px solid #c3cedf;
118
+ border-radius: 999px;
119
+ background: #ffffff;
96
120
  cursor: pointer;
97
- color: whitesmoke;
98
121
  }
99
122
 
100
- .emojiicon {
101
- font-size: 20px;
123
+ .iconButton:disabled {
124
+ cursor: not-allowed;
125
+ opacity: 0.6;
102
126
  }
103
127
 
128
+ .emojiicon,
104
129
  .sendicon {
105
- font-size: 20px;
106
- }
130
+ color: #2f4f95;
131
+ font-size: 16px;
132
+ }
133
+
134
+ .emojiPicker {
135
+ position: absolute;
136
+ right: 44px;
137
+ bottom: 52px;
138
+ z-index: 20;
139
+ }