@djangocfg/layouts 2.0.6 → 2.0.7

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,245 @@
1
+ 'use client';
2
+
3
+ import React, { createContext, useContext, useState, useCallback, useMemo, useEffect, type ReactNode } from 'react';
4
+ import { useLocalStorage, useIsMobile } from '@djangocfg/ui/hooks';
5
+ import { useAIChat } from '../hooks/useAIChat';
6
+ import { mcpEndpoints, type AIChatMessage, type ChatWidgetConfig, type ChatDisplayMode } from '../types';
7
+
8
+ const STORAGE_KEY_MODE = 'djangocfg-ai-chat-mode';
9
+
10
+ /**
11
+ * AI Chat context state
12
+ */
13
+ export interface AIChatContextState {
14
+ /** All chat messages */
15
+ messages: AIChatMessage[];
16
+ /** Whether a request is in progress */
17
+ isLoading: boolean;
18
+ /** Last error if any */
19
+ error: Error | null;
20
+ /** Whether chat panel is open */
21
+ isOpen: boolean;
22
+ /** Whether chat is minimized */
23
+ isMinimized: boolean;
24
+ /** Configuration */
25
+ config: ChatWidgetConfig;
26
+ /** Current display mode */
27
+ displayMode: ChatDisplayMode;
28
+ /** Is on mobile device */
29
+ isMobile: boolean;
30
+ /** Thread ID for conversation */
31
+ threadId: string;
32
+ /** User ID for conversation */
33
+ userId: string;
34
+ }
35
+
36
+ /**
37
+ * AI Chat context actions
38
+ */
39
+ export interface AIChatContextActions {
40
+ /** Send a message */
41
+ sendMessage: (content: string) => Promise<void>;
42
+ /** Clear all messages */
43
+ clearMessages: () => void;
44
+ /** Open chat panel */
45
+ openChat: () => void;
46
+ /** Close chat panel */
47
+ closeChat: () => void;
48
+ /** Toggle chat panel */
49
+ toggleChat: () => void;
50
+ /** Minimize/restore chat */
51
+ toggleMinimize: () => void;
52
+ /** Set display mode */
53
+ setDisplayMode: (mode: ChatDisplayMode) => void;
54
+ /** Stop streaming response */
55
+ stopStreaming: () => void;
56
+ }
57
+
58
+ export type AIChatContextValue = AIChatContextState & AIChatContextActions;
59
+
60
+ const AIChatContext = createContext<AIChatContextValue | null>(null);
61
+
62
+ /**
63
+ * AI Chat provider props
64
+ */
65
+ export interface AIChatProviderProps {
66
+ children: ReactNode;
67
+ /** API endpoint for AI chat (default: /api/ai/chat) */
68
+ apiEndpoint?: string;
69
+ /** Widget configuration */
70
+ config?: Partial<ChatWidgetConfig>;
71
+ /** Callback on error */
72
+ onError?: (error: Error) => void;
73
+ /** Enable streaming (default: true) */
74
+ enableStreaming?: boolean;
75
+ }
76
+
77
+ /**
78
+ * AI Chat provider component
79
+ * Uses useAIChat hook with server-side persistence
80
+ */
81
+ export function AIChatProvider({
82
+ children,
83
+ apiEndpoint = mcpEndpoints.chat,
84
+ config: userConfig = {},
85
+ onError,
86
+ enableStreaming = true,
87
+ }: AIChatProviderProps) {
88
+ // Use AI chat hook
89
+ const {
90
+ messages,
91
+ isLoading,
92
+ error,
93
+ threadId,
94
+ userId,
95
+ sendMessage: sendAIMessage,
96
+ clearMessages: clearAIMessages,
97
+ stopStreaming,
98
+ } = useAIChat({
99
+ apiEndpoint,
100
+ onError,
101
+ enableStreaming,
102
+ });
103
+
104
+ const [isMinimized, setIsMinimized] = useState(false);
105
+
106
+ // Display mode with localStorage persistence
107
+ const [storedMode, setStoredMode] = useLocalStorage<ChatDisplayMode>(STORAGE_KEY_MODE, 'closed');
108
+
109
+ const isMobile = useIsMobile();
110
+
111
+ // On mobile, sidebar mode is not available - fallback to floating
112
+ const displayMode: ChatDisplayMode = useMemo(() => {
113
+ if (isMobile && storedMode === 'sidebar') {
114
+ return 'floating';
115
+ }
116
+ return storedMode;
117
+ }, [isMobile, storedMode]);
118
+
119
+ // Derived state: isOpen is true when not in 'closed' mode
120
+ const isOpen = displayMode !== 'closed';
121
+
122
+ const config: ChatWidgetConfig = useMemo(
123
+ () => ({
124
+ apiEndpoint,
125
+ title: 'DjangoCFG AI Assistant',
126
+ placeholder: 'Ask about DjangoCFG...',
127
+ greeting:
128
+ "Hi! I'm your DjangoCFG AI assistant powered by GPT. Ask me anything about configuration, features, or how to use the library.",
129
+ position: 'bottom-right',
130
+ variant: 'default',
131
+ ...userConfig,
132
+ }),
133
+ [apiEndpoint, userConfig]
134
+ );
135
+
136
+ const sendMessage = useCallback(
137
+ async (content: string) => {
138
+ await sendAIMessage(content);
139
+ },
140
+ [sendAIMessage]
141
+ );
142
+
143
+ const clearMessages = useCallback(() => {
144
+ clearAIMessages();
145
+ }, [clearAIMessages]);
146
+
147
+ const openChat = useCallback(() => {
148
+ setStoredMode('floating');
149
+ setIsMinimized(false);
150
+ }, [setStoredMode]);
151
+
152
+ const closeChat = useCallback(() => {
153
+ setStoredMode('closed');
154
+ setIsMinimized(false);
155
+ }, [setStoredMode]);
156
+
157
+ const toggleChat = useCallback(() => {
158
+ if (displayMode === 'closed') {
159
+ setStoredMode('floating');
160
+ setIsMinimized(false);
161
+ } else {
162
+ setStoredMode('closed');
163
+ }
164
+ }, [displayMode, setStoredMode]);
165
+
166
+ const toggleMinimize = useCallback(() => {
167
+ setIsMinimized((prev) => !prev);
168
+ }, []);
169
+
170
+ const setDisplayMode = useCallback(
171
+ (mode: ChatDisplayMode) => {
172
+ // On mobile, sidebar is not available
173
+ if (isMobile && mode === 'sidebar') {
174
+ setStoredMode('floating');
175
+ } else {
176
+ setStoredMode(mode);
177
+ }
178
+ setIsMinimized(false);
179
+ },
180
+ [isMobile, setStoredMode]
181
+ );
182
+
183
+ const value = useMemo<AIChatContextValue>(
184
+ () => ({
185
+ messages,
186
+ isLoading,
187
+ error,
188
+ isOpen,
189
+ isMinimized,
190
+ config,
191
+ displayMode,
192
+ isMobile,
193
+ threadId,
194
+ userId,
195
+ sendMessage,
196
+ clearMessages,
197
+ openChat,
198
+ closeChat,
199
+ toggleChat,
200
+ toggleMinimize,
201
+ setDisplayMode,
202
+ stopStreaming,
203
+ }),
204
+ [
205
+ messages,
206
+ isLoading,
207
+ error,
208
+ isOpen,
209
+ isMinimized,
210
+ config,
211
+ displayMode,
212
+ isMobile,
213
+ threadId,
214
+ userId,
215
+ sendMessage,
216
+ clearMessages,
217
+ openChat,
218
+ closeChat,
219
+ toggleChat,
220
+ toggleMinimize,
221
+ setDisplayMode,
222
+ stopStreaming,
223
+ ]
224
+ );
225
+
226
+ return <AIChatContext.Provider value={value}>{children}</AIChatContext.Provider>;
227
+ }
228
+
229
+ /**
230
+ * Hook to access AI chat context
231
+ */
232
+ export function useAIChatContext(): AIChatContextValue {
233
+ const context = useContext(AIChatContext);
234
+ if (!context) {
235
+ throw new Error('useAIChatContext must be used within an AIChatProvider');
236
+ }
237
+ return context;
238
+ }
239
+
240
+ /**
241
+ * Hook to check if AI chat context is available
242
+ */
243
+ export function useAIChatContextOptional(): AIChatContextValue | null {
244
+ return useContext(AIChatContext);
245
+ }
@@ -0,0 +1,350 @@
1
+ 'use client';
2
+
3
+ import React, { createContext, useContext, useState, useCallback, useMemo, useEffect, useRef, type ReactNode } from 'react';
4
+ import { useLocalStorage, useIsMobile } from '@djangocfg/ui/hooks';
5
+ import { v4 as uuidv4 } from 'uuid';
6
+ import type { AIChatMessage, ChatApiResponse, AIChatSource, ChatWidgetConfig, ChatDisplayMode } from '../types';
7
+
8
+ const STORAGE_KEY_MODE = 'djangocfg-chat-mode';
9
+ const STORAGE_KEY_USER_ID = 'djangocfg-chat-user-id';
10
+ const STORAGE_KEY_MESSAGES = 'djangocfg-chat-messages';
11
+ const MAX_STORED_MESSAGES = 50;
12
+
13
+ function generateMessageId(): string {
14
+ return `msg_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
15
+ }
16
+
17
+ /**
18
+ * Serialized message format for localStorage
19
+ */
20
+ interface SerializedMessage {
21
+ id: string;
22
+ role: 'user' | 'assistant' | 'system';
23
+ content: string;
24
+ timestamp: string; // ISO string
25
+ sources?: AIChatSource[];
26
+ }
27
+
28
+ /**
29
+ * Chat context state
30
+ */
31
+ export interface ChatContextState {
32
+ /** All chat messages */
33
+ messages: AIChatMessage[];
34
+ /** Whether a request is in progress */
35
+ isLoading: boolean;
36
+ /** Last error if any */
37
+ error: Error | null;
38
+ /** Whether chat panel is open */
39
+ isOpen: boolean;
40
+ /** Whether chat is minimized */
41
+ isMinimized: boolean;
42
+ /** Configuration */
43
+ config: ChatWidgetConfig;
44
+ /** Current display mode */
45
+ displayMode: ChatDisplayMode;
46
+ /** Is on mobile device */
47
+ isMobile: boolean;
48
+ /** User ID for this session */
49
+ userId: string;
50
+ }
51
+
52
+ /**
53
+ * Chat context actions
54
+ */
55
+ export interface ChatContextActions {
56
+ /** Send a message */
57
+ sendMessage: (content: string) => Promise<void>;
58
+ /** Clear all messages */
59
+ clearMessages: () => void;
60
+ /** Open chat panel */
61
+ openChat: () => void;
62
+ /** Close chat panel */
63
+ closeChat: () => void;
64
+ /** Toggle chat panel */
65
+ toggleChat: () => void;
66
+ /** Minimize/restore chat */
67
+ toggleMinimize: () => void;
68
+ /** Set display mode */
69
+ setDisplayMode: (mode: ChatDisplayMode) => void;
70
+ }
71
+
72
+ export type ChatContextValue = ChatContextState & ChatContextActions;
73
+
74
+ const ChatContext = createContext<ChatContextValue | null>(null);
75
+
76
+ /**
77
+ * Chat provider props
78
+ */
79
+ export interface ChatProviderProps {
80
+ children: ReactNode;
81
+ /** API endpoint for chat */
82
+ apiEndpoint?: string;
83
+ /** Widget configuration */
84
+ config?: Partial<ChatWidgetConfig>;
85
+ /** Callback on error */
86
+ onError?: (error: Error) => void;
87
+ }
88
+
89
+ /**
90
+ * Chat provider component
91
+ */
92
+ export function ChatProvider({
93
+ children,
94
+ apiEndpoint = '/api/chat',
95
+ config: userConfig = {},
96
+ onError,
97
+ }: ChatProviderProps) {
98
+ const [messages, setMessages] = useState<AIChatMessage[]>([]);
99
+ const [isLoading, setIsLoading] = useState(false);
100
+ const [error, setError] = useState<Error | null>(null);
101
+ const [isMinimized, setIsMinimized] = useState(false);
102
+ const isHydratedRef = useRef(false);
103
+
104
+ // Display mode with localStorage persistence
105
+ const [storedMode, setStoredMode] = useLocalStorage<ChatDisplayMode>(STORAGE_KEY_MODE, 'closed');
106
+
107
+ // User ID with localStorage persistence
108
+ const [userId, setUserId] = useLocalStorage<string>(STORAGE_KEY_USER_ID, '');
109
+
110
+ // Messages storage (serialized)
111
+ const [storedMessages, setStoredMessages] = useLocalStorage<SerializedMessage[]>(STORAGE_KEY_MESSAGES, []);
112
+
113
+ const isMobile = useIsMobile();
114
+
115
+ // Generate user ID if not exists
116
+ useEffect(() => {
117
+ if (!userId) {
118
+ setUserId(uuidv4());
119
+ }
120
+ }, [userId, setUserId]);
121
+
122
+ // Load messages from localStorage on mount (once)
123
+ useEffect(() => {
124
+ if (isHydratedRef.current) return;
125
+ isHydratedRef.current = true;
126
+
127
+ if (storedMessages.length > 0) {
128
+ const hydratedMessages: AIChatMessage[] = storedMessages.map((msg) => ({
129
+ ...msg,
130
+ timestamp: new Date(msg.timestamp),
131
+ }));
132
+ setMessages(hydratedMessages);
133
+ }
134
+ // eslint-disable-next-line react-hooks/exhaustive-deps
135
+ }, [storedMessages]); // Depend on storedMessages to wait for localStorage
136
+
137
+ // Save messages to localStorage when they change
138
+ useEffect(() => {
139
+ if (!isHydratedRef.current) return;
140
+
141
+ const toStore: SerializedMessage[] = messages.slice(-MAX_STORED_MESSAGES).map((msg) => ({
142
+ id: msg.id,
143
+ role: msg.role,
144
+ content: msg.content,
145
+ timestamp: msg.timestamp.toISOString(),
146
+ sources: msg.sources,
147
+ }));
148
+ setStoredMessages(toStore);
149
+ // eslint-disable-next-line react-hooks/exhaustive-deps
150
+ }, [messages]);
151
+
152
+ // On mobile, sidebar mode is not available - fallback to floating
153
+ const displayMode: ChatDisplayMode = useMemo(() => {
154
+ if (isMobile && storedMode === 'sidebar') {
155
+ return 'floating';
156
+ }
157
+ return storedMode;
158
+ }, [isMobile, storedMode]);
159
+
160
+ // Derived state: isOpen is true when not in 'closed' mode
161
+ const isOpen = displayMode !== 'closed';
162
+
163
+ const config: ChatWidgetConfig = useMemo(
164
+ () => ({
165
+ apiEndpoint,
166
+ title: 'DjangoCFG Assistant',
167
+ placeholder: 'Ask about DjangoCFG...',
168
+ greeting:
169
+ "Hi! I'm your DjangoCFG documentation assistant. Ask me anything about configuration, features, or how to use the library.",
170
+ position: 'bottom-right',
171
+ variant: 'default',
172
+ ...userConfig,
173
+ }),
174
+ [apiEndpoint, userConfig]
175
+ );
176
+
177
+ const sendMessage = useCallback(
178
+ async (content: string) => {
179
+ if (!content.trim() || isLoading) return;
180
+
181
+ const userMessage: AIChatMessage = {
182
+ id: generateMessageId(),
183
+ role: 'user',
184
+ content: content.trim(),
185
+ timestamp: new Date(),
186
+ };
187
+
188
+ setMessages((prev) => [...prev, userMessage]);
189
+ setIsLoading(true);
190
+ setError(null);
191
+
192
+ try {
193
+ const response = await fetch(config.apiEndpoint || apiEndpoint, {
194
+ method: 'POST',
195
+ headers: { 'Content-Type': 'application/json' },
196
+ body: JSON.stringify({
197
+ query: content,
198
+ userId: userId || undefined,
199
+ }),
200
+ });
201
+
202
+ if (!response.ok) {
203
+ throw new Error(`HTTP error: ${response.status}`);
204
+ }
205
+
206
+ const data: ChatApiResponse = await response.json();
207
+
208
+ if (!data.success) {
209
+ throw new Error(data.error || 'Failed to get response');
210
+ }
211
+
212
+ const sources: AIChatSource[] =
213
+ data.results?.map((r) => ({
214
+ title: r.chunk.title,
215
+ path: r.chunk.path,
216
+ url: r.chunk.url,
217
+ section: r.chunk.section,
218
+ score: r.score,
219
+ })) || [];
220
+
221
+ const assistantMessage: AIChatMessage = {
222
+ id: generateMessageId(),
223
+ role: 'assistant',
224
+ content: data.answer || 'I found some relevant documentation.',
225
+ timestamp: new Date(),
226
+ sources,
227
+ };
228
+
229
+ setMessages((prev) => [...prev, assistantMessage]);
230
+ } catch (err) {
231
+ const error = err instanceof Error ? err : new Error('Unknown error');
232
+ setError(error);
233
+ onError?.(error);
234
+
235
+ const errorMessage: AIChatMessage = {
236
+ id: generateMessageId(),
237
+ role: 'assistant',
238
+ content: `Sorry, I encountered an error: ${error.message}. Please try again.`,
239
+ timestamp: new Date(),
240
+ };
241
+
242
+ setMessages((prev) => [...prev, errorMessage]);
243
+ } finally {
244
+ setIsLoading(false);
245
+ }
246
+ },
247
+ [apiEndpoint, config.apiEndpoint, isLoading, onError, userId]
248
+ );
249
+
250
+ const clearMessages = useCallback(() => {
251
+ setMessages([]);
252
+ setStoredMessages([]);
253
+ setError(null);
254
+ }, [setStoredMessages]);
255
+
256
+ const openChat = useCallback(() => {
257
+ setStoredMode('floating');
258
+ setIsMinimized(false);
259
+ }, [setStoredMode]);
260
+
261
+ const closeChat = useCallback(() => {
262
+ setStoredMode('closed');
263
+ setIsMinimized(false);
264
+ }, [setStoredMode]);
265
+
266
+ const toggleChat = useCallback(() => {
267
+ if (displayMode === 'closed') {
268
+ setStoredMode('floating');
269
+ setIsMinimized(false);
270
+ } else {
271
+ setStoredMode('closed');
272
+ }
273
+ }, [displayMode, setStoredMode]);
274
+
275
+ const toggleMinimize = useCallback(() => {
276
+ setIsMinimized((prev) => !prev);
277
+ }, []);
278
+
279
+ const setDisplayMode = useCallback(
280
+ (mode: ChatDisplayMode) => {
281
+ // On mobile, sidebar is not available
282
+ if (isMobile && mode === 'sidebar') {
283
+ setStoredMode('floating');
284
+ } else {
285
+ setStoredMode(mode);
286
+ }
287
+ setIsMinimized(false);
288
+ },
289
+ [isMobile, setStoredMode]
290
+ );
291
+
292
+ const value = useMemo<ChatContextValue>(
293
+ () => ({
294
+ messages,
295
+ isLoading,
296
+ error,
297
+ isOpen,
298
+ isMinimized,
299
+ config,
300
+ displayMode,
301
+ isMobile,
302
+ userId: userId || '',
303
+ sendMessage,
304
+ clearMessages,
305
+ openChat,
306
+ closeChat,
307
+ toggleChat,
308
+ toggleMinimize,
309
+ setDisplayMode,
310
+ }),
311
+ [
312
+ messages,
313
+ isLoading,
314
+ error,
315
+ isOpen,
316
+ isMinimized,
317
+ config,
318
+ displayMode,
319
+ isMobile,
320
+ userId,
321
+ sendMessage,
322
+ clearMessages,
323
+ openChat,
324
+ closeChat,
325
+ toggleChat,
326
+ toggleMinimize,
327
+ setDisplayMode,
328
+ ]
329
+ );
330
+
331
+ return <ChatContext.Provider value={value}>{children}</ChatContext.Provider>;
332
+ }
333
+
334
+ /**
335
+ * Hook to access chat context
336
+ */
337
+ export function useChatContext(): ChatContextValue {
338
+ const context = useContext(ChatContext);
339
+ if (!context) {
340
+ throw new Error('useChatContext must be used within a ChatProvider');
341
+ }
342
+ return context;
343
+ }
344
+
345
+ /**
346
+ * Hook to check if chat context is available
347
+ */
348
+ export function useChatContextOptional(): ChatContextValue | null {
349
+ return useContext(ChatContext);
350
+ }
@@ -0,0 +1,7 @@
1
+ 'use client';
2
+
3
+ export { ChatProvider, useChatContext, useChatContextOptional } from './ChatContext';
4
+ export type { ChatContextState, ChatContextActions, ChatContextValue, ChatProviderProps } from './ChatContext';
5
+
6
+ export { AIChatProvider, useAIChatContext, useAIChatContextOptional } from './AIChatContext';
7
+ export type { AIChatContextState, AIChatContextActions, AIChatContextValue, AIChatProviderProps } from './AIChatContext';
@@ -0,0 +1,5 @@
1
+ 'use client';
2
+
3
+ export { useAIChat } from './useAIChat';
4
+ export { useChatLayout } from './useChatLayout';
5
+ export type { ChatLayoutConfig, UseChatLayoutReturn } from './useChatLayout';