@27works/chat-core 0.1.0

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.
@@ -0,0 +1,18 @@
1
+ declare function ChatSessionProvider({ children, ...props }: {
2
+ [x: string]: any;
3
+ children: any;
4
+ }): any;
5
+
6
+ declare function MessageList({ renderMessage, renderThinking, renderStreamingIndicator }: {
7
+ renderMessage: any;
8
+ renderThinking: any;
9
+ renderStreamingIndicator: any;
10
+ }): any;
11
+
12
+ declare function UserInput({ renderInput }: {
13
+ renderInput: any;
14
+ }): any;
15
+
16
+ declare function useChatSession(): any;
17
+
18
+ export { ChatSessionProvider, MessageList, UserInput, useChatSession };
@@ -0,0 +1,609 @@
1
+ // src/components/ChatSessionProvider.js
2
+ import {
3
+ useState as useState5,
4
+ useRef as useRef5,
5
+ useCallback as useCallback3,
6
+ useEffect as useEffect4,
7
+ useMemo,
8
+ Suspense
9
+ } from "react";
10
+ import { useChat } from "@ai-sdk/react";
11
+ import { DefaultChatTransport, isToolUIPart } from "ai";
12
+ import { useSearchParams } from "next/navigation";
13
+
14
+ // src/components/SessionContext.js
15
+ import { createContext, useContext } from "react";
16
+ var SessionContext = createContext(null);
17
+ function useChatSession() {
18
+ const ctx = useContext(SessionContext);
19
+ if (!ctx) {
20
+ throw new Error("useChatSession must be used within a ChatSessionProvider");
21
+ }
22
+ return ctx;
23
+ }
24
+ var SessionContext_default = SessionContext;
25
+
26
+ // src/contexts/ChatContext.js
27
+ import { createContext as createContext2, useCallback, useContext as useContext2, useRef, useState } from "react";
28
+ import { jsx } from "react/jsx-runtime";
29
+ var ChatContext = createContext2({
30
+ triggerMessage: () => {
31
+ },
32
+ registerTriggerMessage: () => {
33
+ },
34
+ shareConversation: () => {
35
+ },
36
+ shareAnswer: () => {
37
+ },
38
+ canShare: false,
39
+ registerShareHandlers: () => {
40
+ },
41
+ chatControls: null,
42
+ registerChatControls: () => {
43
+ },
44
+ hasTransitioned: false,
45
+ setHasTransitioned: () => {
46
+ },
47
+ shareModalOpen: false,
48
+ openShareModal: () => {
49
+ },
50
+ closeShareModal: () => {
51
+ }
52
+ });
53
+ var useChatContext = () => {
54
+ const context = useContext2(ChatContext);
55
+ if (!context) {
56
+ return {
57
+ triggerMessage: () => {
58
+ },
59
+ registerTriggerMessage: () => {
60
+ },
61
+ shareConversation: () => {
62
+ },
63
+ shareAnswer: () => {
64
+ },
65
+ canShare: false,
66
+ registerShareHandlers: () => {
67
+ },
68
+ chatControls: null,
69
+ registerChatControls: () => {
70
+ },
71
+ hasTransitioned: false,
72
+ setHasTransitioned: () => {
73
+ },
74
+ shareModalOpen: false,
75
+ openShareModal: () => {
76
+ },
77
+ closeShareModal: () => {
78
+ }
79
+ };
80
+ }
81
+ return context;
82
+ };
83
+ var ChatProvider = ({ children }) => {
84
+ const triggerMessageRef = useRef(() => {
85
+ });
86
+ const shareHandlersRef = useRef({
87
+ shareConversation: () => {
88
+ },
89
+ shareAnswer: () => {
90
+ }
91
+ });
92
+ const [canShare, setCanShare] = useState(false);
93
+ const [messageCount, setMessageCount] = useState(0);
94
+ const [chatControls, setChatControls] = useState(null);
95
+ const [hasTransitioned, setHasTransitioned] = useState(false);
96
+ const [shareModalOpen, setShareModalOpen] = useState(false);
97
+ const registerTriggerMessage = (fn) => {
98
+ triggerMessageRef.current = fn;
99
+ };
100
+ const registerShareHandlers = useCallback((handlers) => {
101
+ shareHandlersRef.current = {
102
+ shareConversation: handlers.shareConversation,
103
+ shareAnswer: handlers.shareAnswer
104
+ };
105
+ setCanShare((prev) => {
106
+ const next = handlers.canShare || false;
107
+ return prev === next ? prev : next;
108
+ });
109
+ setMessageCount((prev) => {
110
+ const next = handlers.messageCount || 0;
111
+ return prev === next ? prev : next;
112
+ });
113
+ }, []);
114
+ const registerChatControls = useCallback((controls) => {
115
+ setChatControls((prev) => {
116
+ if (prev === controls) {
117
+ return prev;
118
+ }
119
+ if (prev === null && controls === null) {
120
+ return prev;
121
+ }
122
+ if (prev && controls && prev.firstQuestionId === controls.firstQuestionId && prev.onMakePublic === controls.onMakePublic) {
123
+ return prev;
124
+ }
125
+ return controls;
126
+ });
127
+ }, []);
128
+ const triggerMessage = (message) => triggerMessageRef.current(message);
129
+ const shareConversation = () => shareHandlersRef.current.shareConversation();
130
+ const shareAnswer = () => shareHandlersRef.current.shareAnswer();
131
+ const openShareModal = useCallback(() => setShareModalOpen(true), []);
132
+ const closeShareModal = useCallback(() => setShareModalOpen(false), []);
133
+ const value = {
134
+ triggerMessage,
135
+ registerTriggerMessage,
136
+ shareConversation,
137
+ shareAnswer,
138
+ canShare,
139
+ messageCount,
140
+ registerShareHandlers,
141
+ chatControls,
142
+ registerChatControls,
143
+ hasTransitioned,
144
+ setHasTransitioned,
145
+ shareModalOpen,
146
+ openShareModal,
147
+ closeShareModal
148
+ };
149
+ return /* @__PURE__ */ jsx(ChatContext.Provider, { value, children });
150
+ };
151
+
152
+ // src/contexts/ToastContext.js
153
+ import {
154
+ createContext as createContext3,
155
+ useContext as useContext3,
156
+ useState as useState2,
157
+ useCallback as useCallback2,
158
+ useRef as useRef2,
159
+ useEffect
160
+ } from "react";
161
+ import { jsx as jsx2 } from "react/jsx-runtime";
162
+ var ToastContext = createContext3({
163
+ showToast: () => {
164
+ },
165
+ toast: null,
166
+ hideToast: () => {
167
+ }
168
+ });
169
+ var useToast = () => useContext3(ToastContext);
170
+ var ToastProvider = ({ children }) => {
171
+ const [toast, setToast] = useState2(null);
172
+ const timeoutRef = useRef2(null);
173
+ useEffect(() => {
174
+ return () => {
175
+ if (timeoutRef.current) {
176
+ clearTimeout(timeoutRef.current);
177
+ }
178
+ };
179
+ }, []);
180
+ const showToast = useCallback2((message, type = "success") => {
181
+ if (timeoutRef.current) {
182
+ clearTimeout(timeoutRef.current);
183
+ }
184
+ setToast({ message, type, id: Date.now() });
185
+ timeoutRef.current = setTimeout(() => {
186
+ setToast(null);
187
+ timeoutRef.current = null;
188
+ }, 3e3);
189
+ }, []);
190
+ const hideToast = useCallback2(() => {
191
+ setToast(null);
192
+ }, []);
193
+ return /* @__PURE__ */ jsx2(ToastContext.Provider, { value: { showToast, toast, hideToast }, children });
194
+ };
195
+
196
+ // src/hooks/useConversationLoad.js
197
+ import { useState as useState3, useEffect as useEffect2, useRef as useRef3 } from "react";
198
+ import { useRouter } from "next/navigation";
199
+ function useConversationLoad(chatId, options = {}) {
200
+ const {
201
+ shouldLoad = true,
202
+ skipIfLoaded = false,
203
+ onSuccess,
204
+ onError
205
+ } = options;
206
+ const router = useRouter();
207
+ const [loading, setLoading] = useState3(false);
208
+ const hasLoadedRef = useRef3(false);
209
+ const onSuccessRef = useRef3(onSuccess);
210
+ const onErrorRef = useRef3(onError);
211
+ useEffect2(() => {
212
+ onSuccessRef.current = onSuccess;
213
+ onErrorRef.current = onError;
214
+ }, [onSuccess, onError]);
215
+ useEffect2(() => {
216
+ hasLoadedRef.current = false;
217
+ }, [chatId]);
218
+ useEffect2(() => {
219
+ if (!shouldLoad || !chatId) {
220
+ return;
221
+ }
222
+ if (skipIfLoaded && hasLoadedRef.current) {
223
+ setLoading(false);
224
+ return;
225
+ }
226
+ const controller = new AbortController();
227
+ const { signal } = controller;
228
+ const loadConversation = async () => {
229
+ setLoading(true);
230
+ try {
231
+ const response = await fetch(`/api/chat/load/${chatId}`, {
232
+ signal: controller.signal
233
+ });
234
+ if (response.status === 403) {
235
+ const reason = "This conversation is private and you do not have access.";
236
+ const err = new Error(reason);
237
+ if (!signal.aborted) {
238
+ onErrorRef.current?.(err);
239
+ }
240
+ router.replace("/?reason=private");
241
+ return;
242
+ }
243
+ if (response.status === 404) {
244
+ const reason = "Conversation not found.";
245
+ const err = new Error(reason);
246
+ if (!signal.aborted) {
247
+ onErrorRef.current?.(err);
248
+ }
249
+ router.replace("/?reason=not_found");
250
+ return;
251
+ }
252
+ if (!response.ok) {
253
+ const reason = `Failed to load conversation (HTTP ${response.status})`;
254
+ const err = new Error(reason);
255
+ if (!signal.aborted) {
256
+ onErrorRef.current?.(err);
257
+ }
258
+ return;
259
+ }
260
+ const data = await response.json();
261
+ if (!data || typeof data !== "object") {
262
+ const err = new Error("Invalid response format from server");
263
+ if (!signal.aborted) {
264
+ onErrorRef.current?.(err);
265
+ }
266
+ return;
267
+ }
268
+ hasLoadedRef.current = true;
269
+ if (!signal.aborted && Array.isArray(data.messages)) {
270
+ onSuccessRef.current?.(data.messages);
271
+ }
272
+ } catch (err) {
273
+ if (err.name === "AbortError") {
274
+ return;
275
+ }
276
+ const errorObj = err instanceof Error ? err : new Error(String(err));
277
+ console.error(`Error loading conversation ${chatId}:`, errorObj);
278
+ if (!signal.aborted) {
279
+ onErrorRef.current?.(errorObj);
280
+ }
281
+ } finally {
282
+ if (!signal.aborted) {
283
+ setLoading(false);
284
+ }
285
+ }
286
+ };
287
+ loadConversation();
288
+ return () => {
289
+ controller.abort();
290
+ };
291
+ }, [chatId, shouldLoad, skipIfLoaded, router]);
292
+ return { loading };
293
+ }
294
+
295
+ // src/hooks/useCountdown.js
296
+ import { useState as useState4, useEffect as useEffect3, useRef as useRef4 } from "react";
297
+ function useCountdown(initialSeconds = 0, onComplete) {
298
+ const [seconds, setSeconds] = useState4(initialSeconds);
299
+ const onCompleteRef = useRef4(onComplete);
300
+ useEffect3(() => {
301
+ onCompleteRef.current = onComplete;
302
+ }, [onComplete]);
303
+ const isActive = seconds > 0;
304
+ useEffect3(() => {
305
+ if (!isActive) {
306
+ return;
307
+ }
308
+ const timer = setInterval(() => {
309
+ setSeconds((prev) => {
310
+ const next = prev - 1;
311
+ if (next <= 0) {
312
+ onCompleteRef.current?.();
313
+ return 0;
314
+ }
315
+ return next;
316
+ });
317
+ }, 1e3);
318
+ return () => clearInterval(timer);
319
+ }, [isActive]);
320
+ return { seconds, setSeconds };
321
+ }
322
+
323
+ // src/utils/anonymousId.js
324
+ var STORAGE_KEY = "caruuto_anonymous_id";
325
+ function getOrCreateAnonymousId() {
326
+ try {
327
+ const existing = window.localStorage.getItem(STORAGE_KEY);
328
+ if (existing) {
329
+ return existing;
330
+ }
331
+ const id = crypto.randomUUID();
332
+ window.localStorage.setItem(STORAGE_KEY, id);
333
+ return id;
334
+ } catch (err) {
335
+ console.error("Failed to get or create anonymous ID:", err);
336
+ return null;
337
+ }
338
+ }
339
+
340
+ // src/components/ChatSessionProvider.js
341
+ import { jsx as jsx3 } from "react/jsx-runtime";
342
+ var DEFAULT_THINKING_OPTIONS = [
343
+ "Thinking...",
344
+ "Processing your request...",
345
+ "One moment please...",
346
+ "Analysing the information..."
347
+ ];
348
+ function ChatSessionProviderInner({
349
+ children,
350
+ chatId,
351
+ apiPath = "/api/chat",
352
+ thinkingOptions = DEFAULT_THINKING_OPTIONS,
353
+ shouldLoadConversation = true,
354
+ onFinish,
355
+ onError: onErrorProp
356
+ }) {
357
+ const { registerTriggerMessage } = useChatContext();
358
+ const { showToast } = useToast();
359
+ const searchParams = useSearchParams();
360
+ const [loadedMessages, setLoadedMessages] = useState5([]);
361
+ const [loadFailed, setLoadFailed] = useState5(false);
362
+ const [lastFailedInput, setLastFailedInput] = useState5(null);
363
+ const [acquisition, setAcquisition] = useState5(null);
364
+ const [anonymousUserId, setAnonymousUserId] = useState5(null);
365
+ const lastInputRef = useRef5("");
366
+ const onErrorRef = useRef5(onErrorProp);
367
+ const acquisitionRef = useRef5(null);
368
+ const anonymousUserIdRef = useRef5(null);
369
+ acquisitionRef.current = acquisition;
370
+ anonymousUserIdRef.current = anonymousUserId;
371
+ useEffect4(() => {
372
+ onErrorRef.current = onErrorProp;
373
+ }, [onErrorProp]);
374
+ const {
375
+ messages,
376
+ sendMessage: rawSendMessage,
377
+ status,
378
+ addToolResult,
379
+ setMessages,
380
+ clearError
381
+ } = useChat({
382
+ id: chatId,
383
+ initialMessages: loadedMessages,
384
+ transport: new DefaultChatTransport({
385
+ api: apiPath,
386
+ body: () => ({
387
+ ...acquisitionRef.current && { acquisition: acquisitionRef.current },
388
+ ...anonymousUserIdRef.current && {
389
+ clientSessionId: anonymousUserIdRef.current
390
+ }
391
+ })
392
+ }),
393
+ onFinish,
394
+ onError: (error) => {
395
+ setMessages((prev) => {
396
+ if (prev.length > 0 && prev[prev.length - 1].role === "user") {
397
+ return prev.slice(0, -1);
398
+ }
399
+ return prev;
400
+ });
401
+ setLastFailedInput(lastInputRef.current);
402
+ if (error.message?.includes("Too many requests")) {
403
+ const retryMatch = error.message.match(/(\d+)/i);
404
+ const retrySeconds = retryMatch ? parseInt(retryMatch[1]) : 60;
405
+ setRateLimitSeconds(retrySeconds);
406
+ let waitText;
407
+ if (retrySeconds < 60) {
408
+ waitText = `${retrySeconds} seconds`;
409
+ } else if (retrySeconds < 3600) {
410
+ const mins = Math.ceil(retrySeconds / 60);
411
+ waitText = `${mins} minute${mins > 1 ? "s" : ""}`;
412
+ } else {
413
+ const hours = Math.ceil(retrySeconds / 3600);
414
+ waitText = `${hours} hour${hours > 1 ? "s" : ""}`;
415
+ }
416
+ showToast(`Too many requests. Please wait ${waitText}.`, "error");
417
+ } else if (error.message?.includes("Message too long")) {
418
+ showToast("Your message is too long. Please shorten it.", "error");
419
+ } else {
420
+ showToast("Something went wrong. Please try again.", "error");
421
+ }
422
+ console.error("Chat error:", error);
423
+ onErrorRef.current?.(error);
424
+ }
425
+ });
426
+ const { seconds: rateLimitSeconds, setSeconds: setRateLimitSeconds } = useCountdown(0, clearError);
427
+ const { loading: conversationLoading } = useConversationLoad(chatId, {
428
+ shouldLoad: shouldLoadConversation && !loadFailed,
429
+ onSuccess: (msgs) => {
430
+ setLoadedMessages(msgs);
431
+ setMessages(msgs);
432
+ },
433
+ onError: () => {
434
+ setLoadFailed(true);
435
+ }
436
+ });
437
+ const streamingWithNoText = useMemo(() => {
438
+ if (status !== "streaming") {
439
+ return false;
440
+ }
441
+ const lastMsg = messages[messages.length - 1];
442
+ if (!lastMsg || lastMsg.role !== "assistant") {
443
+ return true;
444
+ }
445
+ return !lastMsg.parts?.some((p) => p.type === "text" && p.text?.length > 0);
446
+ }, [status, messages]);
447
+ const pendingToolCallConfirmation = useMemo(
448
+ () => messages.some(
449
+ (m) => m.parts?.some(
450
+ (part) => isToolUIPart(part) && part.state === "input-available"
451
+ )
452
+ ),
453
+ [messages]
454
+ );
455
+ const [thinkingMessage, setThinkingMessage] = useState5("Thinking...");
456
+ useEffect4(() => {
457
+ setThinkingMessage(
458
+ thinkingOptions[Math.floor(Math.random() * thinkingOptions.length)]
459
+ );
460
+ }, [thinkingOptions]);
461
+ useEffect4(() => {
462
+ if (!searchParams) {
463
+ return;
464
+ }
465
+ try {
466
+ const source = searchParams.get("utm_source");
467
+ const medium = searchParams.get("utm_medium");
468
+ const campaign = searchParams.get("utm_campaign");
469
+ const term = searchParams.get("utm_term");
470
+ const content = searchParams.get("utm_content");
471
+ const referrerUrl = document.referrer || null;
472
+ if (source || medium || campaign || term || content || referrerUrl) {
473
+ setAcquisition({
474
+ utm_source: source,
475
+ utm_medium: medium,
476
+ utm_campaign: campaign,
477
+ utm_term: term,
478
+ utm_content: content,
479
+ referrer_url: referrerUrl
480
+ });
481
+ }
482
+ } catch (err) {
483
+ console.error("Failed to capture acquisition data:", err);
484
+ }
485
+ }, []);
486
+ useEffect4(() => {
487
+ setAnonymousUserId(getOrCreateAnonymousId());
488
+ }, []);
489
+ const sendMessage = useCallback3(
490
+ (payload) => {
491
+ if (payload?.text) {
492
+ lastInputRef.current = payload.text;
493
+ }
494
+ return rawSendMessage(payload);
495
+ },
496
+ [rawSendMessage]
497
+ );
498
+ useEffect4(() => {
499
+ registerTriggerMessage((text) => sendMessage({ text }));
500
+ }, [registerTriggerMessage, sendMessage]);
501
+ const value = {
502
+ messages,
503
+ sendMessage,
504
+ status,
505
+ addToolResult,
506
+ setMessages,
507
+ clearError,
508
+ streamingWithNoText,
509
+ thinkingMessage,
510
+ pendingToolCallConfirmation,
511
+ rateLimitSeconds,
512
+ conversationLoading,
513
+ loadFailed,
514
+ lastFailedInput,
515
+ acquisition,
516
+ anonymousUserId
517
+ };
518
+ return /* @__PURE__ */ jsx3(SessionContext_default.Provider, { value, children });
519
+ }
520
+ function ChatSessionProvider({ children, ...props }) {
521
+ return /* @__PURE__ */ jsx3(ToastProvider, { children: /* @__PURE__ */ jsx3(ChatProvider, { children: /* @__PURE__ */ jsx3(Suspense, { children: /* @__PURE__ */ jsx3(ChatSessionProviderInner, { ...props, children }) }) }) });
522
+ }
523
+
524
+ // src/components/MessageList.js
525
+ import { Fragment, jsxs } from "react/jsx-runtime";
526
+ function MessageList({
527
+ renderMessage,
528
+ renderThinking,
529
+ renderStreamingIndicator
530
+ }) {
531
+ const {
532
+ messages,
533
+ status,
534
+ addToolResult,
535
+ streamingWithNoText,
536
+ thinkingMessage
537
+ } = useChatSession();
538
+ return /* @__PURE__ */ jsxs(Fragment, { children: [
539
+ messages.map((message, index) => {
540
+ const isUser = message.role === "user";
541
+ const isStreaming = !isUser && status === "streaming" && index === messages.length - 1;
542
+ return renderMessage({
543
+ key: message.id ?? index,
544
+ message,
545
+ parts: message.parts ?? [],
546
+ isUser,
547
+ isStreaming,
548
+ addToolResult
549
+ });
550
+ }),
551
+ status === "submitted" && renderThinking?.({ message: thinkingMessage }),
552
+ streamingWithNoText && renderStreamingIndicator?.()
553
+ ] });
554
+ }
555
+
556
+ // src/components/UserInput.js
557
+ import { useState as useState6, useEffect as useEffect5, useCallback as useCallback4 } from "react";
558
+ function UserInput({ renderInput }) {
559
+ const {
560
+ sendMessage,
561
+ status,
562
+ rateLimitSeconds,
563
+ pendingToolCallConfirmation,
564
+ lastFailedInput
565
+ } = useChatSession();
566
+ const [value, setValue] = useState6("");
567
+ useEffect5(() => {
568
+ if (lastFailedInput != null) {
569
+ setValue(lastFailedInput);
570
+ }
571
+ }, [lastFailedInput]);
572
+ const isLoading = status === "submitted" || status === "streaming";
573
+ const disabled = status !== "ready" || !value.trim() || rateLimitSeconds > 0 || pendingToolCallConfirmation;
574
+ const onSubmit = useCallback4(() => {
575
+ if (disabled) {
576
+ return;
577
+ }
578
+ const text = value.trim();
579
+ setValue("");
580
+ sendMessage({ text });
581
+ }, [disabled, value, sendMessage]);
582
+ const onChange = useCallback4((e) => {
583
+ setValue(typeof e === "string" ? e : e.target.value);
584
+ }, []);
585
+ const onKeyDown = useCallback4(
586
+ (e) => {
587
+ if (e.key === "Enter" && !e.shiftKey && !disabled) {
588
+ e.preventDefault();
589
+ onSubmit();
590
+ }
591
+ },
592
+ [disabled, onSubmit]
593
+ );
594
+ return renderInput({
595
+ value,
596
+ onChange,
597
+ onSubmit,
598
+ onKeyDown,
599
+ disabled,
600
+ isLoading,
601
+ countdownSeconds: rateLimitSeconds
602
+ });
603
+ }
604
+ export {
605
+ ChatSessionProvider,
606
+ MessageList,
607
+ UserInput,
608
+ useChatSession
609
+ };
@@ -0,0 +1,11 @@
1
+ declare function useChatContext(): any;
2
+ declare function ChatProvider({ children }: {
3
+ children: any;
4
+ }): any;
5
+
6
+ declare function useToast(): any;
7
+ declare function ToastProvider({ children }: {
8
+ children: any;
9
+ }): any;
10
+
11
+ export { ChatProvider, ToastProvider, useChatContext, useToast };