@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,175 @@
1
+ // src/contexts/ChatContext.js
2
+ import { createContext, useCallback, useContext, useRef, useState } from "react";
3
+ import { jsx } from "react/jsx-runtime";
4
+ var ChatContext = createContext({
5
+ triggerMessage: () => {
6
+ },
7
+ registerTriggerMessage: () => {
8
+ },
9
+ shareConversation: () => {
10
+ },
11
+ shareAnswer: () => {
12
+ },
13
+ canShare: false,
14
+ registerShareHandlers: () => {
15
+ },
16
+ chatControls: null,
17
+ registerChatControls: () => {
18
+ },
19
+ hasTransitioned: false,
20
+ setHasTransitioned: () => {
21
+ },
22
+ shareModalOpen: false,
23
+ openShareModal: () => {
24
+ },
25
+ closeShareModal: () => {
26
+ }
27
+ });
28
+ var useChatContext = () => {
29
+ const context = useContext(ChatContext);
30
+ if (!context) {
31
+ return {
32
+ triggerMessage: () => {
33
+ },
34
+ registerTriggerMessage: () => {
35
+ },
36
+ shareConversation: () => {
37
+ },
38
+ shareAnswer: () => {
39
+ },
40
+ canShare: false,
41
+ registerShareHandlers: () => {
42
+ },
43
+ chatControls: null,
44
+ registerChatControls: () => {
45
+ },
46
+ hasTransitioned: false,
47
+ setHasTransitioned: () => {
48
+ },
49
+ shareModalOpen: false,
50
+ openShareModal: () => {
51
+ },
52
+ closeShareModal: () => {
53
+ }
54
+ };
55
+ }
56
+ return context;
57
+ };
58
+ var ChatProvider = ({ children }) => {
59
+ const triggerMessageRef = useRef(() => {
60
+ });
61
+ const shareHandlersRef = useRef({
62
+ shareConversation: () => {
63
+ },
64
+ shareAnswer: () => {
65
+ }
66
+ });
67
+ const [canShare, setCanShare] = useState(false);
68
+ const [messageCount, setMessageCount] = useState(0);
69
+ const [chatControls, setChatControls] = useState(null);
70
+ const [hasTransitioned, setHasTransitioned] = useState(false);
71
+ const [shareModalOpen, setShareModalOpen] = useState(false);
72
+ const registerTriggerMessage = (fn) => {
73
+ triggerMessageRef.current = fn;
74
+ };
75
+ const registerShareHandlers = useCallback((handlers) => {
76
+ shareHandlersRef.current = {
77
+ shareConversation: handlers.shareConversation,
78
+ shareAnswer: handlers.shareAnswer
79
+ };
80
+ setCanShare((prev) => {
81
+ const next = handlers.canShare || false;
82
+ return prev === next ? prev : next;
83
+ });
84
+ setMessageCount((prev) => {
85
+ const next = handlers.messageCount || 0;
86
+ return prev === next ? prev : next;
87
+ });
88
+ }, []);
89
+ const registerChatControls = useCallback((controls) => {
90
+ setChatControls((prev) => {
91
+ if (prev === controls) {
92
+ return prev;
93
+ }
94
+ if (prev === null && controls === null) {
95
+ return prev;
96
+ }
97
+ if (prev && controls && prev.firstQuestionId === controls.firstQuestionId && prev.onMakePublic === controls.onMakePublic) {
98
+ return prev;
99
+ }
100
+ return controls;
101
+ });
102
+ }, []);
103
+ const triggerMessage = (message) => triggerMessageRef.current(message);
104
+ const shareConversation = () => shareHandlersRef.current.shareConversation();
105
+ const shareAnswer = () => shareHandlersRef.current.shareAnswer();
106
+ const openShareModal = useCallback(() => setShareModalOpen(true), []);
107
+ const closeShareModal = useCallback(() => setShareModalOpen(false), []);
108
+ const value = {
109
+ triggerMessage,
110
+ registerTriggerMessage,
111
+ shareConversation,
112
+ shareAnswer,
113
+ canShare,
114
+ messageCount,
115
+ registerShareHandlers,
116
+ chatControls,
117
+ registerChatControls,
118
+ hasTransitioned,
119
+ setHasTransitioned,
120
+ shareModalOpen,
121
+ openShareModal,
122
+ closeShareModal
123
+ };
124
+ return /* @__PURE__ */ jsx(ChatContext.Provider, { value, children });
125
+ };
126
+
127
+ // src/contexts/ToastContext.js
128
+ import {
129
+ createContext as createContext2,
130
+ useContext as useContext2,
131
+ useState as useState2,
132
+ useCallback as useCallback2,
133
+ useRef as useRef2,
134
+ useEffect
135
+ } from "react";
136
+ import { jsx as jsx2 } from "react/jsx-runtime";
137
+ var ToastContext = createContext2({
138
+ showToast: () => {
139
+ },
140
+ toast: null,
141
+ hideToast: () => {
142
+ }
143
+ });
144
+ var useToast = () => useContext2(ToastContext);
145
+ var ToastProvider = ({ children }) => {
146
+ const [toast, setToast] = useState2(null);
147
+ const timeoutRef = useRef2(null);
148
+ useEffect(() => {
149
+ return () => {
150
+ if (timeoutRef.current) {
151
+ clearTimeout(timeoutRef.current);
152
+ }
153
+ };
154
+ }, []);
155
+ const showToast = useCallback2((message, type = "success") => {
156
+ if (timeoutRef.current) {
157
+ clearTimeout(timeoutRef.current);
158
+ }
159
+ setToast({ message, type, id: Date.now() });
160
+ timeoutRef.current = setTimeout(() => {
161
+ setToast(null);
162
+ timeoutRef.current = null;
163
+ }, 3e3);
164
+ }, []);
165
+ const hideToast = useCallback2(() => {
166
+ setToast(null);
167
+ }, []);
168
+ return /* @__PURE__ */ jsx2(ToastContext.Provider, { value: { showToast, toast, hideToast }, children });
169
+ };
170
+ export {
171
+ ChatProvider,
172
+ ToastProvider,
173
+ useChatContext,
174
+ useToast
175
+ };
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Hook for managing countdown timers.
3
+ * Counts down from initial seconds to 0, calling onComplete when finished.
4
+ */
5
+ declare function useCountdown(initialSeconds: number, onComplete: any): {
6
+ seconds: any;
7
+ setSeconds: any;
8
+ };
9
+
10
+ /**
11
+ * Hook for managing chat visibility state (public/private).
12
+ */
13
+ declare function useChatVisibility(chatId: any, pathname: any): {
14
+ visibility: any;
15
+ setVisibility: any;
16
+ toggle: any;
17
+ };
18
+
19
+ /**
20
+ * Hook for loading conversation messages from the API with ownership validation.
21
+ * Handles 403 and 404 HTTP errors with redirects.
22
+ */
23
+ declare function useConversationLoad(chatId: any, options?: {}): {
24
+ loading: any;
25
+ };
26
+
27
+ /**
28
+ * Hook for managing email capture form state.
29
+ * Handles field state, validation, auto-population from tool input, and form reset.
30
+ */
31
+ declare function useEmailForm(messages: any, options?: {}): {
32
+ email: any;
33
+ name: any;
34
+ phone: any;
35
+ message: any;
36
+ error: any;
37
+ setEmail: any;
38
+ setName: any;
39
+ setPhone: any;
40
+ setMessage: any;
41
+ setError: any;
42
+ isEmailValid: any;
43
+ isValid: any;
44
+ validateEmail: any;
45
+ reset: any;
46
+ };
47
+
48
+ /**
49
+ * Hook for forking conversations — waits for DB confirmation before navigating.
50
+ */
51
+ declare function useForkConversation(): {
52
+ forkConversation: ({ questionId, sourceChatId }: {
53
+ questionId: any;
54
+ sourceChatId: any;
55
+ }) => Promise<void>;
56
+ isForking: any;
57
+ };
58
+
59
+ /**
60
+ * Hook for navigating to a new chat with a pending question.
61
+ * Pattern matches useForkConversation — waits for DB before navigating.
62
+ */
63
+ declare function useNavigateWithQuestion(): {
64
+ navigate: (question: any) => Promise<void>;
65
+ isNavigating: any;
66
+ };
67
+
68
+ /**
69
+ * When the app is embedded in an iframe, notify the parent window whenever
70
+ * the internal route changes so it can keep its URL bar in sync.
71
+ *
72
+ * Parent window listens for: { type: 'route', path: '/conversation/abc123' }
73
+ */
74
+ declare function useParentRouteSync(pathname: any): void;
75
+
76
+ export { useChatVisibility, useConversationLoad, useCountdown, useEmailForm, useForkConversation, useNavigateWithQuestion, useParentRouteSync };
@@ -0,0 +1,406 @@
1
+ // src/hooks/useCountdown.js
2
+ import { useState, useEffect, useRef } from "react";
3
+ function useCountdown(initialSeconds = 0, onComplete) {
4
+ const [seconds, setSeconds] = useState(initialSeconds);
5
+ const onCompleteRef = useRef(onComplete);
6
+ useEffect(() => {
7
+ onCompleteRef.current = onComplete;
8
+ }, [onComplete]);
9
+ const isActive = seconds > 0;
10
+ useEffect(() => {
11
+ if (!isActive) {
12
+ return;
13
+ }
14
+ const timer = setInterval(() => {
15
+ setSeconds((prev) => {
16
+ const next = prev - 1;
17
+ if (next <= 0) {
18
+ onCompleteRef.current?.();
19
+ return 0;
20
+ }
21
+ return next;
22
+ });
23
+ }, 1e3);
24
+ return () => clearInterval(timer);
25
+ }, [isActive]);
26
+ return { seconds, setSeconds };
27
+ }
28
+
29
+ // src/hooks/useChatVisibility.js
30
+ import { useState as useState3, useCallback as useCallback2, useEffect as useEffect3, useRef as useRef3 } from "react";
31
+
32
+ // src/contexts/ToastContext.js
33
+ import {
34
+ createContext,
35
+ useContext,
36
+ useState as useState2,
37
+ useCallback,
38
+ useRef as useRef2,
39
+ useEffect as useEffect2
40
+ } from "react";
41
+ import { jsx } from "react/jsx-runtime";
42
+ var ToastContext = createContext({
43
+ showToast: () => {
44
+ },
45
+ toast: null,
46
+ hideToast: () => {
47
+ }
48
+ });
49
+ var useToast = () => useContext(ToastContext);
50
+
51
+ // src/hooks/useChatVisibility.js
52
+ function useChatVisibility(chatId, pathname) {
53
+ const [visibility, setVisibility] = useState3("private");
54
+ const { showToast } = useToast();
55
+ const hasLoadedRef = useRef3(false);
56
+ const isLoadingRef = useRef3(false);
57
+ const lastChatIdRef = useRef3(null);
58
+ useEffect3(() => {
59
+ if (chatId !== lastChatIdRef.current) {
60
+ hasLoadedRef.current = false;
61
+ isLoadingRef.current = false;
62
+ lastChatIdRef.current = chatId;
63
+ }
64
+ }, [chatId]);
65
+ useEffect3(() => {
66
+ if (!chatId || typeof window === "undefined") {
67
+ return;
68
+ }
69
+ const isConversationPage = pathname?.startsWith("/conversation/") || pathname?.startsWith("/shared/");
70
+ if (!isConversationPage) {
71
+ return;
72
+ }
73
+ if (hasLoadedRef.current || isLoadingRef.current) {
74
+ return;
75
+ }
76
+ const controller = new AbortController();
77
+ isLoadingRef.current = true;
78
+ const loadVisibility = (signal) => {
79
+ fetch(`/api/chat/share?chatId=${chatId}`, { signal }).then((res) => {
80
+ if (!res.ok) {
81
+ if (res.status === 404) {
82
+ isLoadingRef.current = false;
83
+ return;
84
+ }
85
+ throw new Error(`HTTP ${res.status}`);
86
+ }
87
+ return res.json();
88
+ }).then((data) => {
89
+ if (data && data.visibility) {
90
+ setVisibility(data.visibility);
91
+ hasLoadedRef.current = true;
92
+ }
93
+ isLoadingRef.current = false;
94
+ }).catch((err) => {
95
+ isLoadingRef.current = false;
96
+ if (err.name === "AbortError") {
97
+ return;
98
+ }
99
+ if (err.message && !err.message.includes("404")) {
100
+ console.error("Error loading visibility:", err);
101
+ }
102
+ });
103
+ };
104
+ loadVisibility(controller.signal);
105
+ return () => {
106
+ controller.abort();
107
+ };
108
+ }, [chatId, pathname]);
109
+ const toggle = useCallback2(async () => {
110
+ if (!chatId) {
111
+ showToast("Error: Chat ID not found", "error");
112
+ return;
113
+ }
114
+ const newVisibility = visibility === "public" ? "private" : "public";
115
+ try {
116
+ const response = await fetch("/api/chat/share", {
117
+ method: "POST",
118
+ headers: { "Content-Type": "application/json" },
119
+ body: JSON.stringify({ chatId, visibility: newVisibility })
120
+ });
121
+ if (!response.ok) {
122
+ const error = await response.json();
123
+ throw new Error(error.error || "Failed to update visibility");
124
+ }
125
+ setVisibility(newVisibility);
126
+ if (newVisibility === "public") {
127
+ const shareUrl = `${window.location.origin}/shared/${chatId}`;
128
+ await navigator.clipboard.writeText(shareUrl);
129
+ showToast("Now shareable. Link copied to clipboard");
130
+ } else {
131
+ showToast("Now private. Only you can see this");
132
+ }
133
+ } catch (err) {
134
+ console.error("Privacy toggle error:", err);
135
+ showToast(err.message || "Failed to update visibility", "error");
136
+ }
137
+ }, [chatId, visibility, showToast]);
138
+ return { visibility, setVisibility, toggle };
139
+ }
140
+
141
+ // src/hooks/useConversationLoad.js
142
+ import { useState as useState4, useEffect as useEffect4, useRef as useRef4 } from "react";
143
+ import { useRouter } from "next/navigation";
144
+ function useConversationLoad(chatId, options = {}) {
145
+ const {
146
+ shouldLoad = true,
147
+ skipIfLoaded = false,
148
+ onSuccess,
149
+ onError
150
+ } = options;
151
+ const router = useRouter();
152
+ const [loading, setLoading] = useState4(false);
153
+ const hasLoadedRef = useRef4(false);
154
+ const onSuccessRef = useRef4(onSuccess);
155
+ const onErrorRef = useRef4(onError);
156
+ useEffect4(() => {
157
+ onSuccessRef.current = onSuccess;
158
+ onErrorRef.current = onError;
159
+ }, [onSuccess, onError]);
160
+ useEffect4(() => {
161
+ hasLoadedRef.current = false;
162
+ }, [chatId]);
163
+ useEffect4(() => {
164
+ if (!shouldLoad || !chatId) {
165
+ return;
166
+ }
167
+ if (skipIfLoaded && hasLoadedRef.current) {
168
+ setLoading(false);
169
+ return;
170
+ }
171
+ const controller = new AbortController();
172
+ const { signal } = controller;
173
+ const loadConversation = async () => {
174
+ setLoading(true);
175
+ try {
176
+ const response = await fetch(`/api/chat/load/${chatId}`, {
177
+ signal: controller.signal
178
+ });
179
+ if (response.status === 403) {
180
+ const reason = "This conversation is private and you do not have access.";
181
+ const err = new Error(reason);
182
+ if (!signal.aborted) {
183
+ onErrorRef.current?.(err);
184
+ }
185
+ router.replace("/?reason=private");
186
+ return;
187
+ }
188
+ if (response.status === 404) {
189
+ const reason = "Conversation not found.";
190
+ const err = new Error(reason);
191
+ if (!signal.aborted) {
192
+ onErrorRef.current?.(err);
193
+ }
194
+ router.replace("/?reason=not_found");
195
+ return;
196
+ }
197
+ if (!response.ok) {
198
+ const reason = `Failed to load conversation (HTTP ${response.status})`;
199
+ const err = new Error(reason);
200
+ if (!signal.aborted) {
201
+ onErrorRef.current?.(err);
202
+ }
203
+ return;
204
+ }
205
+ const data = await response.json();
206
+ if (!data || typeof data !== "object") {
207
+ const err = new Error("Invalid response format from server");
208
+ if (!signal.aborted) {
209
+ onErrorRef.current?.(err);
210
+ }
211
+ return;
212
+ }
213
+ hasLoadedRef.current = true;
214
+ if (!signal.aborted && Array.isArray(data.messages)) {
215
+ onSuccessRef.current?.(data.messages);
216
+ }
217
+ } catch (err) {
218
+ if (err.name === "AbortError") {
219
+ return;
220
+ }
221
+ const errorObj = err instanceof Error ? err : new Error(String(err));
222
+ console.error(`Error loading conversation ${chatId}:`, errorObj);
223
+ if (!signal.aborted) {
224
+ onErrorRef.current?.(errorObj);
225
+ }
226
+ } finally {
227
+ if (!signal.aborted) {
228
+ setLoading(false);
229
+ }
230
+ }
231
+ };
232
+ loadConversation();
233
+ return () => {
234
+ controller.abort();
235
+ };
236
+ }, [chatId, shouldLoad, skipIfLoaded, router]);
237
+ return { loading };
238
+ }
239
+
240
+ // src/hooks/useEmailForm.js
241
+ import { useState as useState5, useMemo, useCallback as useCallback3 } from "react";
242
+ var EMAIL_REGEX = /^(?:[a-zA-Z0-9_'^&+%`{}~|-]+(?:\.[a-zA-Z0-9_'^&+%`{}~|-]+)*)@(?:(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)*[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)$/;
243
+ function useEmailForm(messages, options = {}) {
244
+ const { toolName = "emailCapture" } = options;
245
+ const [email, setEmail] = useState5("");
246
+ const [name, setName] = useState5("");
247
+ const [phone, setPhone] = useState5("");
248
+ const [message, setMessage] = useState5("");
249
+ const [error, setError] = useState5("");
250
+ const autoMessage = useMemo(() => {
251
+ if (!Array.isArray(messages) || messages.length === 0) {
252
+ return "";
253
+ }
254
+ for (const msg of messages) {
255
+ if (!msg.parts || !Array.isArray(msg.parts)) {
256
+ continue;
257
+ }
258
+ for (const part of msg.parts) {
259
+ if (part.type && part.type.includes(toolName) && part.input?.summary) {
260
+ return part.input.summary;
261
+ }
262
+ }
263
+ }
264
+ return "";
265
+ }, [messages, toolName]);
266
+ const isEmailValid = useCallback3((value) => {
267
+ if (!value || !value.trim()) {
268
+ return false;
269
+ }
270
+ return EMAIL_REGEX.test(value.trim());
271
+ }, []);
272
+ const isValid = useCallback3(() => {
273
+ return isEmailValid(email) && name.trim().length > 0;
274
+ }, [email, name, isEmailValid]);
275
+ const reset = useCallback3(() => {
276
+ setEmail("");
277
+ setName("");
278
+ setPhone("");
279
+ setMessage("");
280
+ setError("");
281
+ }, []);
282
+ const validateEmail = useCallback3(
283
+ (value) => {
284
+ if (!isEmailValid(value)) {
285
+ setError("Please enter a valid email address");
286
+ return false;
287
+ }
288
+ setError("");
289
+ return true;
290
+ },
291
+ [isEmailValid]
292
+ );
293
+ return {
294
+ email,
295
+ name,
296
+ phone,
297
+ message: message || autoMessage,
298
+ error,
299
+ setEmail,
300
+ setName,
301
+ setPhone,
302
+ setMessage,
303
+ setError,
304
+ isEmailValid,
305
+ isValid,
306
+ validateEmail,
307
+ reset
308
+ };
309
+ }
310
+
311
+ // src/hooks/useForkConversation.js
312
+ import { useState as useState6 } from "react";
313
+ import { useRouter as useRouter2 } from "next/navigation";
314
+ function useForkConversation() {
315
+ const router = useRouter2();
316
+ const [isForking, setIsForking] = useState6(false);
317
+ const { showToast } = useToast();
318
+ const forkConversation = async ({ questionId, sourceChatId }) => {
319
+ if (isForking) {
320
+ return;
321
+ }
322
+ setIsForking(true);
323
+ showToast("Creating your copy...");
324
+ try {
325
+ const res = await fetch("/api/chat/fork", {
326
+ method: "POST",
327
+ headers: { "Content-Type": "application/json" },
328
+ body: JSON.stringify({ questionId, sourceChatId })
329
+ });
330
+ if (!res.ok) {
331
+ const error = await res.json().catch(() => ({}));
332
+ throw new Error(error.error || "Failed to create copy");
333
+ }
334
+ const { chatId } = await res.json();
335
+ router.push(`/conversation/${chatId}`);
336
+ } catch (err) {
337
+ console.error("Fork error:", err);
338
+ showToast(
339
+ err.message || "Failed to create copy. Please try again.",
340
+ "error"
341
+ );
342
+ setIsForking(false);
343
+ }
344
+ };
345
+ return { forkConversation, isForking };
346
+ }
347
+
348
+ // src/hooks/useNavigateWithQuestion.js
349
+ import { useState as useState7 } from "react";
350
+ import { useRouter as useRouter3 } from "next/navigation";
351
+ function useNavigateWithQuestion() {
352
+ const router = useRouter3();
353
+ const [isNavigating, setIsNavigating] = useState7(false);
354
+ const { showToast } = useToast();
355
+ const navigate = async (question) => {
356
+ if (isNavigating) {
357
+ return;
358
+ }
359
+ setIsNavigating(true);
360
+ showToast("Starting new conversation...");
361
+ try {
362
+ const res = await fetch("/api/chat/create", {
363
+ method: "POST",
364
+ headers: { "Content-Type": "application/json" },
365
+ body: JSON.stringify({ question })
366
+ });
367
+ if (!res.ok) {
368
+ const error = await res.json().catch(() => ({}));
369
+ throw new Error(error.error || "Failed to create conversation");
370
+ }
371
+ const { chatId } = await res.json();
372
+ router.push(`/conversation/${chatId}`);
373
+ } catch (err) {
374
+ console.error("Navigate with question error:", err);
375
+ showToast(
376
+ err.message || "Failed to start conversation. Please try again.",
377
+ "error"
378
+ );
379
+ setIsNavigating(false);
380
+ }
381
+ };
382
+ return { navigate, isNavigating };
383
+ }
384
+
385
+ // src/hooks/useParentRouteSync.js
386
+ import { useEffect as useEffect5 } from "react";
387
+ function useParentRouteSync(pathname) {
388
+ useEffect5(() => {
389
+ if (typeof window === "undefined") {
390
+ return;
391
+ }
392
+ if (window.parent === window) {
393
+ return;
394
+ }
395
+ window.parent.postMessage({ type: "route", path: pathname }, "*");
396
+ }, [pathname]);
397
+ }
398
+ export {
399
+ useChatVisibility,
400
+ useConversationLoad,
401
+ useCountdown,
402
+ useEmailForm,
403
+ useForkConversation,
404
+ useNavigateWithQuestion,
405
+ useParentRouteSync
406
+ };