@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.
- package/package.json +14 -13
- package/src/layouts/PaymentsLayout/components/PaymentDetailsDialog.tsx +3 -22
- package/src/layouts/SupportLayout/components/MessageList.tsx +1 -1
- package/src/layouts/SupportLayout/components/TicketList.tsx +1 -1
- package/src/snippets/Chat/components/MessageList.tsx +1 -1
- package/src/snippets/Chat/components/SessionList.tsx +1 -1
- package/src/snippets/McpChat/components/AIChatWidget.tsx +268 -0
- package/src/snippets/McpChat/components/ChatMessages.tsx +151 -0
- package/src/snippets/McpChat/components/ChatPanel.tsx +126 -0
- package/src/snippets/McpChat/components/ChatSidebar.tsx +119 -0
- package/src/snippets/McpChat/components/ChatWidget.tsx +134 -0
- package/src/snippets/McpChat/components/MessageBubble.tsx +125 -0
- package/src/snippets/McpChat/components/MessageInput.tsx +139 -0
- package/src/snippets/McpChat/components/index.ts +22 -0
- package/src/snippets/McpChat/config.ts +35 -0
- package/src/snippets/McpChat/context/AIChatContext.tsx +245 -0
- package/src/snippets/McpChat/context/ChatContext.tsx +350 -0
- package/src/snippets/McpChat/context/index.ts +7 -0
- package/src/snippets/McpChat/hooks/index.ts +5 -0
- package/src/snippets/McpChat/hooks/useAIChat.ts +487 -0
- package/src/snippets/McpChat/hooks/useChatLayout.ts +329 -0
- package/src/snippets/McpChat/index.ts +76 -0
- package/src/snippets/McpChat/types.ts +141 -0
- package/src/snippets/index.ts +32 -0
- package/src/utils/index.ts +0 -1
- package/src/utils/og-image.ts +0 -169
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@djangocfg/layouts",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.7",
|
|
4
4
|
"description": "Simple, straightforward layout components for Next.js - import and use with props",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"layouts",
|
|
@@ -92,28 +92,29 @@
|
|
|
92
92
|
"check": "tsc --noEmit"
|
|
93
93
|
},
|
|
94
94
|
"peerDependencies": {
|
|
95
|
-
"@djangocfg/api": "^1.4.
|
|
96
|
-
"@djangocfg/centrifugo": "^1.4.
|
|
97
|
-
"@djangocfg/ui": "^1.4.
|
|
95
|
+
"@djangocfg/api": "^1.4.37",
|
|
96
|
+
"@djangocfg/centrifugo": "^1.4.37",
|
|
97
|
+
"@djangocfg/ui": "^1.4.37",
|
|
98
98
|
"@hookform/resolvers": "^5.2.0",
|
|
99
99
|
"consola": "^3.4.2",
|
|
100
|
-
"lucide-react": "^0.
|
|
101
|
-
"next": "
|
|
100
|
+
"lucide-react": "^0.545.0",
|
|
101
|
+
"next": ">=15.0.0",
|
|
102
102
|
"p-retry": "^7.0.0",
|
|
103
|
-
"react": "^19.
|
|
104
|
-
"react-dom": "^19.
|
|
103
|
+
"react": "^19.2.0",
|
|
104
|
+
"react-dom": "^19.2.0",
|
|
105
105
|
"react-hook-form": "7.65.0",
|
|
106
|
-
"sonner": "2.0.
|
|
106
|
+
"sonner": "2.0.7",
|
|
107
107
|
"swr": "^2.3.0",
|
|
108
|
-
"tailwindcss": "^4.
|
|
108
|
+
"tailwindcss": "^4.1.14",
|
|
109
109
|
"tailwindcss-animate": "^1.0.7",
|
|
110
|
-
"zod": "^4.
|
|
110
|
+
"zod": "^4.1.13"
|
|
111
111
|
},
|
|
112
112
|
"dependencies": {
|
|
113
|
-
"react-ga4": "^2.1.0"
|
|
113
|
+
"react-ga4": "^2.1.0",
|
|
114
|
+
"uuid": "^11.1.0"
|
|
114
115
|
},
|
|
115
116
|
"devDependencies": {
|
|
116
|
-
"@djangocfg/typescript-config": "^1.4.
|
|
117
|
+
"@djangocfg/typescript-config": "^1.4.37",
|
|
117
118
|
"@types/node": "^24.7.2",
|
|
118
119
|
"@types/react": "19.2.2",
|
|
119
120
|
"@types/react-dom": "19.2.1",
|
|
@@ -15,8 +15,9 @@ import {
|
|
|
15
15
|
DialogTitle,
|
|
16
16
|
Button,
|
|
17
17
|
TokenIcon,
|
|
18
|
+
CopyButton,
|
|
18
19
|
} from '@djangocfg/ui';
|
|
19
|
-
import {
|
|
20
|
+
import { ExternalLink, CheckCircle2, Clock, XCircle, AlertCircle, RefreshCw } from 'lucide-react';
|
|
20
21
|
import { Hooks, api } from '@djangocfg/api';
|
|
21
22
|
import type { API } from '@djangocfg/api';
|
|
22
23
|
import { PAYMENT_EVENTS } from '../events';
|
|
@@ -24,7 +25,6 @@ import { PAYMENT_EVENTS } from '../events';
|
|
|
24
25
|
export const PaymentDetailsDialog: React.FC = () => {
|
|
25
26
|
const [open, setOpen] = useState(false);
|
|
26
27
|
const [paymentId, setPaymentId] = useState<string | null>(null);
|
|
27
|
-
const [copied, setCopied] = useState(false);
|
|
28
28
|
const [timeLeft, setTimeLeft] = useState<string>('');
|
|
29
29
|
|
|
30
30
|
// Load payment data by ID using hook
|
|
@@ -61,14 +61,6 @@ export const PaymentDetailsDialog: React.FC = () => {
|
|
|
61
61
|
setPaymentId(null);
|
|
62
62
|
};
|
|
63
63
|
|
|
64
|
-
const handleCopyAddress = async () => {
|
|
65
|
-
if (payment?.pay_address) {
|
|
66
|
-
await navigator.clipboard.writeText(payment.pay_address);
|
|
67
|
-
setCopied(true);
|
|
68
|
-
setTimeout(() => setCopied(false), 2000);
|
|
69
|
-
}
|
|
70
|
-
};
|
|
71
|
-
|
|
72
64
|
// Calculate time left until expiration
|
|
73
65
|
useEffect(() => {
|
|
74
66
|
if (!payment?.expires_at) return;
|
|
@@ -237,18 +229,7 @@ export const PaymentDetailsDialog: React.FC = () => {
|
|
|
237
229
|
<div className="flex-1 p-3 bg-muted rounded-sm font-mono text-sm break-all">
|
|
238
230
|
{payment.pay_address}
|
|
239
231
|
</div>
|
|
240
|
-
<
|
|
241
|
-
variant="outline"
|
|
242
|
-
size="icon"
|
|
243
|
-
onClick={handleCopyAddress}
|
|
244
|
-
className="shrink-0"
|
|
245
|
-
>
|
|
246
|
-
{copied ? (
|
|
247
|
-
<CheckCircle2 className="h-4 w-4 text-green-500" />
|
|
248
|
-
) : (
|
|
249
|
-
<Copy className="h-4 w-4" />
|
|
250
|
-
)}
|
|
251
|
-
</Button>
|
|
232
|
+
<CopyButton value={payment.pay_address} variant="outline" />
|
|
252
233
|
</div>
|
|
253
234
|
</div>
|
|
254
235
|
)}
|
|
@@ -230,7 +230,7 @@ export const MessageList: React.FC = () => {
|
|
|
230
230
|
}
|
|
231
231
|
|
|
232
232
|
return (
|
|
233
|
-
<ScrollArea className="h-full bg-muted/50"
|
|
233
|
+
<ScrollArea className="h-full bg-muted/50" viewportRef={scrollAreaRef}>
|
|
234
234
|
<div className="p-6 space-y-4" ref={scrollRef}>
|
|
235
235
|
{/* Load more trigger at the top */}
|
|
236
236
|
<div ref={loadMoreRef} className="h-2" />
|
|
@@ -97,7 +97,7 @@ export const TicketList: React.FC = () => {
|
|
|
97
97
|
}
|
|
98
98
|
|
|
99
99
|
return (
|
|
100
|
-
<ScrollArea className="h-full"
|
|
100
|
+
<ScrollArea className="h-full" viewportRef={scrollRef}>
|
|
101
101
|
<div className="p-4 space-y-2">
|
|
102
102
|
{tickets.map((ticket, index) => (
|
|
103
103
|
<div
|
|
@@ -43,7 +43,7 @@ export const MessageList: React.FC<MessageListProps> = ({
|
|
|
43
43
|
};
|
|
44
44
|
|
|
45
45
|
return (
|
|
46
|
-
<ScrollArea className={`h-full bg-muted/50 ${className}`}
|
|
46
|
+
<ScrollArea className={`h-full bg-muted/50 ${className}`} viewportRef={scrollRef}>
|
|
47
47
|
<div className="space-y-4 p-4">
|
|
48
48
|
{messages.length === 0 && !isLoading ? (
|
|
49
49
|
<div className="flex flex-col items-center justify-center h-full text-center py-12">
|
|
@@ -82,7 +82,7 @@ export const SessionList: React.FC<SessionListProps> = ({
|
|
|
82
82
|
</SheetDescription>
|
|
83
83
|
</SheetHeader>
|
|
84
84
|
|
|
85
|
-
<ScrollArea className="h-[calc(100vh-120px)] mt-6"
|
|
85
|
+
<ScrollArea className="h-[calc(100vh-120px)] mt-6" viewportRef={scrollRef}>
|
|
86
86
|
{isLoading ? (
|
|
87
87
|
<div className="flex items-center justify-center py-12">
|
|
88
88
|
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React from 'react';
|
|
4
|
+
import { Button, Portal } from '@djangocfg/ui';
|
|
5
|
+
import { Zap } from 'lucide-react';
|
|
6
|
+
import { ChatPanel } from './ChatPanel';
|
|
7
|
+
import { ChatSidebar } from './ChatSidebar';
|
|
8
|
+
import { useAIChatContext, useAIChatContextOptional, AIChatProvider } from '../context/AIChatContext';
|
|
9
|
+
import { useChatLayout } from '../hooks/useChatLayout';
|
|
10
|
+
import { mcpEndpoints, type ChatWidgetConfig } from '../types';
|
|
11
|
+
|
|
12
|
+
// CSS for mysterious rotating border animation with multi-color ethereal effects
|
|
13
|
+
const fabAnimationStyles = `
|
|
14
|
+
@keyframes rotate-gradient {
|
|
15
|
+
0% { transform: rotate(0deg); }
|
|
16
|
+
100% { transform: rotate(360deg); }
|
|
17
|
+
}
|
|
18
|
+
@keyframes color-shift-glow {
|
|
19
|
+
0%, 100% {
|
|
20
|
+
box-shadow:
|
|
21
|
+
0 0 15px rgba(251, 191, 36, 0.4),
|
|
22
|
+
0 0 30px rgba(168, 85, 247, 0.2),
|
|
23
|
+
0 0 45px rgba(20, 184, 166, 0.1);
|
|
24
|
+
}
|
|
25
|
+
25% {
|
|
26
|
+
box-shadow:
|
|
27
|
+
0 0 18px rgba(168, 85, 247, 0.4),
|
|
28
|
+
0 0 35px rgba(20, 184, 166, 0.25),
|
|
29
|
+
0 0 50px rgba(251, 191, 36, 0.1);
|
|
30
|
+
}
|
|
31
|
+
50% {
|
|
32
|
+
box-shadow:
|
|
33
|
+
0 0 20px rgba(20, 184, 166, 0.4),
|
|
34
|
+
0 0 40px rgba(251, 191, 36, 0.2),
|
|
35
|
+
0 0 55px rgba(168, 85, 247, 0.15);
|
|
36
|
+
}
|
|
37
|
+
75% {
|
|
38
|
+
box-shadow:
|
|
39
|
+
0 0 17px rgba(236, 72, 153, 0.35),
|
|
40
|
+
0 0 32px rgba(168, 85, 247, 0.25),
|
|
41
|
+
0 0 48px rgba(20, 184, 166, 0.1);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
@keyframes icon-pulse {
|
|
45
|
+
0%, 100% {
|
|
46
|
+
opacity: 1;
|
|
47
|
+
transform: scale(1);
|
|
48
|
+
filter: drop-shadow(0 0 2px rgba(251, 191, 36, 0.5));
|
|
49
|
+
}
|
|
50
|
+
50% {
|
|
51
|
+
opacity: 0.85;
|
|
52
|
+
transform: scale(0.95);
|
|
53
|
+
filter: drop-shadow(0 0 6px rgba(251, 191, 36, 0.8));
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
`;
|
|
57
|
+
|
|
58
|
+
export interface AIChatWidgetProps extends ChatWidgetConfig {
|
|
59
|
+
/** Custom class name for the container */
|
|
60
|
+
className?: string;
|
|
61
|
+
/** Enable streaming responses (default: true) */
|
|
62
|
+
enableStreaming?: boolean;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Internal AI chat widget that uses context
|
|
67
|
+
*/
|
|
68
|
+
const AIChatWidgetInternal = React.memo<{ className?: string }>(({ className }) => {
|
|
69
|
+
const {
|
|
70
|
+
messages,
|
|
71
|
+
isLoading,
|
|
72
|
+
isMinimized,
|
|
73
|
+
config,
|
|
74
|
+
displayMode,
|
|
75
|
+
isMobile,
|
|
76
|
+
sendMessage,
|
|
77
|
+
openChat,
|
|
78
|
+
closeChat,
|
|
79
|
+
toggleMinimize,
|
|
80
|
+
setDisplayMode,
|
|
81
|
+
stopStreaming,
|
|
82
|
+
} = useAIChatContext();
|
|
83
|
+
|
|
84
|
+
// Use layout hook for consistent positioning
|
|
85
|
+
const { getFabStyles, getFloatingStyles } = useChatLayout();
|
|
86
|
+
|
|
87
|
+
const position = config.position || 'bottom-right';
|
|
88
|
+
const fabStyles = getFabStyles(position);
|
|
89
|
+
const floatingStyles = getFloatingStyles(position);
|
|
90
|
+
|
|
91
|
+
// Mode: closed - just show FAB with multi-color rotating border animation
|
|
92
|
+
if (displayMode === 'closed') {
|
|
93
|
+
return (
|
|
94
|
+
<Portal>
|
|
95
|
+
<style>{fabAnimationStyles}</style>
|
|
96
|
+
<div style={fabStyles} className={className || ''}>
|
|
97
|
+
{/* Outer glow container with color-shifting animation */}
|
|
98
|
+
<div
|
|
99
|
+
className="relative rounded-full"
|
|
100
|
+
style={{
|
|
101
|
+
width: '64px',
|
|
102
|
+
height: '64px',
|
|
103
|
+
animation: 'color-shift-glow 6s ease-in-out infinite',
|
|
104
|
+
}}
|
|
105
|
+
>
|
|
106
|
+
{/* Border container - clips the rotating gradient */}
|
|
107
|
+
<div
|
|
108
|
+
className="absolute rounded-full group"
|
|
109
|
+
style={{
|
|
110
|
+
inset: '0',
|
|
111
|
+
overflow: 'hidden',
|
|
112
|
+
}}
|
|
113
|
+
>
|
|
114
|
+
{/* Rotating conic gradient - multi-color organic glow */}
|
|
115
|
+
<div
|
|
116
|
+
className="absolute"
|
|
117
|
+
style={{
|
|
118
|
+
width: '200%',
|
|
119
|
+
height: '200%',
|
|
120
|
+
top: '-50%',
|
|
121
|
+
left: '-50%',
|
|
122
|
+
background: `conic-gradient(
|
|
123
|
+
from 0deg,
|
|
124
|
+
#fbbf24 0%,
|
|
125
|
+
transparent 8%,
|
|
126
|
+
#a855f7 18%,
|
|
127
|
+
transparent 28%,
|
|
128
|
+
#14b8a6 38%,
|
|
129
|
+
transparent 52%,
|
|
130
|
+
#ec4899 62%,
|
|
131
|
+
transparent 75%,
|
|
132
|
+
#fbbf24 88%,
|
|
133
|
+
transparent 95%,
|
|
134
|
+
#a855f7 100%
|
|
135
|
+
)`,
|
|
136
|
+
animation: 'rotate-gradient 5s linear infinite',
|
|
137
|
+
opacity: 0.9,
|
|
138
|
+
}}
|
|
139
|
+
/>
|
|
140
|
+
{/* Inner mask - creates the thin organic border */}
|
|
141
|
+
<div
|
|
142
|
+
className="absolute rounded-full bg-background"
|
|
143
|
+
style={{ inset: '1.5px' }}
|
|
144
|
+
/>
|
|
145
|
+
{/* Main FAB button */}
|
|
146
|
+
<Button
|
|
147
|
+
onClick={openChat}
|
|
148
|
+
variant="ghost"
|
|
149
|
+
className="absolute rounded-full hover:scale-105 transition-all duration-300 bg-background hover:bg-background/95 border-0"
|
|
150
|
+
style={{
|
|
151
|
+
inset: '1.5px',
|
|
152
|
+
width: 'auto',
|
|
153
|
+
height: 'auto',
|
|
154
|
+
}}
|
|
155
|
+
>
|
|
156
|
+
<Zap
|
|
157
|
+
className="h-6 w-6"
|
|
158
|
+
style={{
|
|
159
|
+
animation: 'icon-pulse 2s ease-in-out infinite',
|
|
160
|
+
color: '#fbbf24',
|
|
161
|
+
fill: '#fbbf24',
|
|
162
|
+
}}
|
|
163
|
+
/>
|
|
164
|
+
</Button>
|
|
165
|
+
</div>
|
|
166
|
+
</div>
|
|
167
|
+
</div>
|
|
168
|
+
</Portal>
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Mode: sidebar - full-height panel on the right (desktop only)
|
|
173
|
+
if (displayMode === 'sidebar') {
|
|
174
|
+
return (
|
|
175
|
+
<Portal>
|
|
176
|
+
<ChatSidebar
|
|
177
|
+
messages={messages}
|
|
178
|
+
isLoading={isLoading}
|
|
179
|
+
onSendMessage={sendMessage}
|
|
180
|
+
onClose={closeChat}
|
|
181
|
+
onModeChange={setDisplayMode}
|
|
182
|
+
onStopStreaming={stopStreaming}
|
|
183
|
+
title={config.title}
|
|
184
|
+
placeholder={config.placeholder}
|
|
185
|
+
greeting={config.greeting}
|
|
186
|
+
/>
|
|
187
|
+
</Portal>
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Mode: floating - floating panel
|
|
192
|
+
return (
|
|
193
|
+
<Portal>
|
|
194
|
+
<div style={floatingStyles} className={className || ''}>
|
|
195
|
+
<ChatPanel
|
|
196
|
+
messages={messages}
|
|
197
|
+
isLoading={isLoading}
|
|
198
|
+
onSendMessage={sendMessage}
|
|
199
|
+
onClose={closeChat}
|
|
200
|
+
onMinimize={toggleMinimize}
|
|
201
|
+
onModeChange={setDisplayMode}
|
|
202
|
+
onStopStreaming={stopStreaming}
|
|
203
|
+
isMinimized={isMinimized}
|
|
204
|
+
isMobile={isMobile}
|
|
205
|
+
title={config.title}
|
|
206
|
+
placeholder={config.placeholder}
|
|
207
|
+
greeting={config.greeting}
|
|
208
|
+
/>
|
|
209
|
+
</div>
|
|
210
|
+
</Portal>
|
|
211
|
+
);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
AIChatWidgetInternal.displayName = 'AIChatWidgetInternal';
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* AI Chat Widget component
|
|
218
|
+
*
|
|
219
|
+
* AI-powered documentation assistant with streaming support.
|
|
220
|
+
* Uses Mastra agent backend for intelligent responses.
|
|
221
|
+
*
|
|
222
|
+
* Can be used in two ways:
|
|
223
|
+
* 1. Standalone (wraps itself in AIChatProvider)
|
|
224
|
+
* 2. Inside an AIChatProvider (uses context directly)
|
|
225
|
+
*
|
|
226
|
+
* @example
|
|
227
|
+
* ```tsx
|
|
228
|
+
* // Standalone usage
|
|
229
|
+
* <AIChatWidget apiEndpoint="/api/ai/chat" />
|
|
230
|
+
*
|
|
231
|
+
* // With provider for custom control
|
|
232
|
+
* <AIChatProvider apiEndpoint="/api/ai/chat">
|
|
233
|
+
* <MyApp />
|
|
234
|
+
* <AIChatWidget />
|
|
235
|
+
* </AIChatProvider>
|
|
236
|
+
* ```
|
|
237
|
+
*/
|
|
238
|
+
export const AIChatWidget: React.FC<AIChatWidgetProps> = ({
|
|
239
|
+
apiEndpoint = mcpEndpoints.chat,
|
|
240
|
+
title = 'DjangoCFG AI Assistant',
|
|
241
|
+
placeholder = 'Ask about DjangoCFG...',
|
|
242
|
+
greeting = "Hi! I'm your DjangoCFG AI assistant powered by GPT. Ask me anything about configuration, features, or how to use the library.",
|
|
243
|
+
position = 'bottom-right',
|
|
244
|
+
variant = 'default',
|
|
245
|
+
className,
|
|
246
|
+
enableStreaming = true,
|
|
247
|
+
}) => {
|
|
248
|
+
// Check if we're inside an AIChatProvider
|
|
249
|
+
const existingContext = useAIChatContextOptional();
|
|
250
|
+
|
|
251
|
+
// If already in context, use internal widget directly
|
|
252
|
+
if (existingContext) {
|
|
253
|
+
return <AIChatWidgetInternal className={className} />;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Otherwise, wrap in provider
|
|
257
|
+
return (
|
|
258
|
+
<AIChatProvider
|
|
259
|
+
apiEndpoint={apiEndpoint}
|
|
260
|
+
config={{ title, placeholder, greeting, position, variant }}
|
|
261
|
+
enableStreaming={enableStreaming}
|
|
262
|
+
>
|
|
263
|
+
<AIChatWidgetInternal className={className} />
|
|
264
|
+
</AIChatProvider>
|
|
265
|
+
);
|
|
266
|
+
};
|
|
267
|
+
|
|
268
|
+
AIChatWidget.displayName = 'AIChatWidget';
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React, { useRef, useEffect, useCallback, useImperativeHandle, forwardRef } from 'react';
|
|
4
|
+
import { ScrollArea, Button, type ScrollAreaHandle } from '@djangocfg/ui';
|
|
5
|
+
import { Bot, MessageSquare, StopCircle } from 'lucide-react';
|
|
6
|
+
import { MessageBubble } from './MessageBubble';
|
|
7
|
+
import type { AIChatMessage } from '../types';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* ChatMessages imperative handle
|
|
11
|
+
*/
|
|
12
|
+
export interface ChatMessagesHandle {
|
|
13
|
+
scrollToBottom: (instant?: boolean) => void;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface ChatMessagesProps {
|
|
17
|
+
/** Messages to display */
|
|
18
|
+
messages: AIChatMessage[];
|
|
19
|
+
/** Whether loading/streaming is in progress */
|
|
20
|
+
isLoading: boolean;
|
|
21
|
+
/** Greeting to show when no messages */
|
|
22
|
+
greeting?: string;
|
|
23
|
+
/** Callback to stop streaming */
|
|
24
|
+
onStopStreaming?: () => void;
|
|
25
|
+
/** Use compact layout (smaller bubbles) */
|
|
26
|
+
isCompact?: boolean;
|
|
27
|
+
/** Use larger icon for greeting (sidebar mode) */
|
|
28
|
+
largeGreetingIcon?: boolean;
|
|
29
|
+
/** Custom greeting icon */
|
|
30
|
+
greetingIcon?: 'bot' | 'message';
|
|
31
|
+
/** Custom greeting title */
|
|
32
|
+
greetingTitle?: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* ChatMessages - Shared component for displaying chat messages
|
|
37
|
+
*
|
|
38
|
+
* Handles:
|
|
39
|
+
* - Messages list rendering
|
|
40
|
+
* - Greeting display when empty
|
|
41
|
+
* - Loading indicator with stop button
|
|
42
|
+
* - Auto-scroll on new messages
|
|
43
|
+
* - Initial scroll on history load
|
|
44
|
+
*/
|
|
45
|
+
export const ChatMessages = forwardRef<ChatMessagesHandle, ChatMessagesProps>(
|
|
46
|
+
(
|
|
47
|
+
{
|
|
48
|
+
messages,
|
|
49
|
+
isLoading,
|
|
50
|
+
greeting,
|
|
51
|
+
onStopStreaming,
|
|
52
|
+
isCompact = false,
|
|
53
|
+
largeGreetingIcon = false,
|
|
54
|
+
greetingIcon = 'bot',
|
|
55
|
+
greetingTitle,
|
|
56
|
+
},
|
|
57
|
+
ref
|
|
58
|
+
) => {
|
|
59
|
+
const scrollAreaRef = useRef<ScrollAreaHandle>(null);
|
|
60
|
+
const hasScrolledOnLoad = useRef(false);
|
|
61
|
+
|
|
62
|
+
// Scroll to bottom helper
|
|
63
|
+
const scrollToBottom = useCallback((instant = false) => {
|
|
64
|
+
if (scrollAreaRef.current) {
|
|
65
|
+
scrollAreaRef.current.scrollToBottom(instant ? 'instant' : 'smooth');
|
|
66
|
+
}
|
|
67
|
+
}, []);
|
|
68
|
+
|
|
69
|
+
// Expose scrollToBottom via ref
|
|
70
|
+
useImperativeHandle(ref, () => ({
|
|
71
|
+
scrollToBottom,
|
|
72
|
+
}), [scrollToBottom]);
|
|
73
|
+
|
|
74
|
+
// Initial scroll when history loads (instant, no animation)
|
|
75
|
+
useEffect(() => {
|
|
76
|
+
if (!isLoading && messages.length > 0 && !hasScrolledOnLoad.current) {
|
|
77
|
+
requestAnimationFrame(() => {
|
|
78
|
+
scrollToBottom(true);
|
|
79
|
+
hasScrolledOnLoad.current = true;
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
}, [isLoading, messages.length, scrollToBottom]);
|
|
83
|
+
|
|
84
|
+
// Scroll to bottom on new messages (smooth animation)
|
|
85
|
+
useEffect(() => {
|
|
86
|
+
if (hasScrolledOnLoad.current && messages.length > 0) {
|
|
87
|
+
scrollToBottom(false);
|
|
88
|
+
}
|
|
89
|
+
}, [messages, scrollToBottom]);
|
|
90
|
+
|
|
91
|
+
const GreetingIcon = greetingIcon === 'message' ? MessageSquare : Bot;
|
|
92
|
+
const iconSize = largeGreetingIcon ? { container: '64px', icon: 'h-8 w-8' } : { container: '48px', icon: 'h-6 w-6' };
|
|
93
|
+
const padding = largeGreetingIcon ? 'py-12' : 'py-8';
|
|
94
|
+
|
|
95
|
+
return (
|
|
96
|
+
<ScrollArea ref={scrollAreaRef} className="h-full">
|
|
97
|
+
<div className={`${isCompact ? 'p-3' : 'p-4'} space-y-4`}>
|
|
98
|
+
{/* Greeting */}
|
|
99
|
+
{messages.length === 0 && greeting && (
|
|
100
|
+
<div className={`text-center ${padding}`}>
|
|
101
|
+
<div
|
|
102
|
+
className="mx-auto mb-4 rounded-full bg-primary/10 flex items-center justify-center"
|
|
103
|
+
style={{ width: iconSize.container, height: iconSize.container }}
|
|
104
|
+
>
|
|
105
|
+
<GreetingIcon className={`${iconSize.icon} text-primary`} />
|
|
106
|
+
</div>
|
|
107
|
+
{greetingTitle && (
|
|
108
|
+
<h4 className="font-medium mb-2">{greetingTitle}</h4>
|
|
109
|
+
)}
|
|
110
|
+
<p className={`text-sm text-muted-foreground ${largeGreetingIcon ? 'max-w-[300px]' : 'max-w-[280px]'} mx-auto`}>
|
|
111
|
+
{greeting}
|
|
112
|
+
</p>
|
|
113
|
+
</div>
|
|
114
|
+
)}
|
|
115
|
+
|
|
116
|
+
{/* Messages */}
|
|
117
|
+
{messages.map((message) => (
|
|
118
|
+
<MessageBubble key={message.id} message={message} isCompact={isCompact} />
|
|
119
|
+
))}
|
|
120
|
+
|
|
121
|
+
{/* Loading indicator with stop button */}
|
|
122
|
+
{isLoading && messages.length > 0 && (
|
|
123
|
+
<div className="flex items-center justify-between text-muted-foreground text-sm">
|
|
124
|
+
<div className="flex items-center gap-2">
|
|
125
|
+
<div className="flex gap-1">
|
|
126
|
+
<span className="animate-bounce" style={{ animationDelay: '0ms' }}>.</span>
|
|
127
|
+
<span className="animate-bounce" style={{ animationDelay: '150ms' }}>.</span>
|
|
128
|
+
<span className="animate-bounce" style={{ animationDelay: '300ms' }}>.</span>
|
|
129
|
+
</div>
|
|
130
|
+
<span>Generating response...</span>
|
|
131
|
+
</div>
|
|
132
|
+
{onStopStreaming && (
|
|
133
|
+
<Button
|
|
134
|
+
variant="ghost"
|
|
135
|
+
size="sm"
|
|
136
|
+
onClick={onStopStreaming}
|
|
137
|
+
className="h-6 px-2 text-xs"
|
|
138
|
+
>
|
|
139
|
+
<StopCircle className="h-3 w-3 mr-1" />
|
|
140
|
+
Stop
|
|
141
|
+
</Button>
|
|
142
|
+
)}
|
|
143
|
+
</div>
|
|
144
|
+
)}
|
|
145
|
+
</div>
|
|
146
|
+
</ScrollArea>
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
ChatMessages.displayName = 'ChatMessages';
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React from 'react';
|
|
4
|
+
import { Card, CardHeader, CardContent, CardFooter, Button } from '@djangocfg/ui';
|
|
5
|
+
import { X, Minimize2, PanelRight, Bot } from 'lucide-react';
|
|
6
|
+
import type { ChatDisplayMode, AIChatMessage } from '../types';
|
|
7
|
+
import { ChatMessages } from './ChatMessages';
|
|
8
|
+
import { AIMessageInput } from './MessageInput';
|
|
9
|
+
|
|
10
|
+
export interface ChatPanelProps {
|
|
11
|
+
messages: AIChatMessage[];
|
|
12
|
+
isLoading: boolean;
|
|
13
|
+
onSendMessage: (content: string) => void;
|
|
14
|
+
onClose?: () => void;
|
|
15
|
+
onMinimize?: () => void;
|
|
16
|
+
onModeChange?: (mode: ChatDisplayMode) => void;
|
|
17
|
+
onStopStreaming?: () => void;
|
|
18
|
+
isMinimized?: boolean;
|
|
19
|
+
isMobile?: boolean;
|
|
20
|
+
title?: string;
|
|
21
|
+
placeholder?: string;
|
|
22
|
+
greeting?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export const ChatPanel = React.memo<ChatPanelProps>(
|
|
26
|
+
({
|
|
27
|
+
messages,
|
|
28
|
+
isLoading,
|
|
29
|
+
onSendMessage,
|
|
30
|
+
onClose,
|
|
31
|
+
onMinimize,
|
|
32
|
+
onModeChange,
|
|
33
|
+
onStopStreaming,
|
|
34
|
+
isMinimized = false,
|
|
35
|
+
isMobile = false,
|
|
36
|
+
title = 'DjangoCFG Assistant',
|
|
37
|
+
placeholder,
|
|
38
|
+
greeting,
|
|
39
|
+
}) => {
|
|
40
|
+
if (isMinimized) {
|
|
41
|
+
return (
|
|
42
|
+
<Button
|
|
43
|
+
onClick={onMinimize}
|
|
44
|
+
className="rounded-full shadow-lg"
|
|
45
|
+
style={{ width: '56px', height: '56px' }}
|
|
46
|
+
>
|
|
47
|
+
<Bot className="h-6 w-6" />
|
|
48
|
+
</Button>
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return (
|
|
53
|
+
<Card
|
|
54
|
+
className="flex flex-col shadow-2xl border-border/50"
|
|
55
|
+
style={{
|
|
56
|
+
width: '380px',
|
|
57
|
+
height: '520px',
|
|
58
|
+
maxHeight: 'calc(100vh - 100px)',
|
|
59
|
+
}}
|
|
60
|
+
>
|
|
61
|
+
{/* Header */}
|
|
62
|
+
<CardHeader className="flex flex-row items-center justify-between p-3 border-b">
|
|
63
|
+
<div className="flex items-center gap-2">
|
|
64
|
+
<div
|
|
65
|
+
className="rounded-full bg-primary/10 flex items-center justify-center"
|
|
66
|
+
style={{ width: '32px', height: '32px' }}
|
|
67
|
+
>
|
|
68
|
+
<Bot className="h-4 w-4 text-primary" />
|
|
69
|
+
</div>
|
|
70
|
+
<div>
|
|
71
|
+
<h3 className="font-semibold text-sm">{title}</h3>
|
|
72
|
+
<p className="text-xs text-muted-foreground">AI-powered documentation assistant</p>
|
|
73
|
+
</div>
|
|
74
|
+
</div>
|
|
75
|
+
<div className="flex gap-1">
|
|
76
|
+
{/* Sidebar mode button - only on desktop */}
|
|
77
|
+
{onModeChange && !isMobile && (
|
|
78
|
+
<Button
|
|
79
|
+
variant="ghost"
|
|
80
|
+
size="icon"
|
|
81
|
+
className="h-8 w-8"
|
|
82
|
+
onClick={() => onModeChange('sidebar')}
|
|
83
|
+
title="Switch to sidebar mode"
|
|
84
|
+
>
|
|
85
|
+
<PanelRight className="h-4 w-4" />
|
|
86
|
+
</Button>
|
|
87
|
+
)}
|
|
88
|
+
{onMinimize && (
|
|
89
|
+
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={onMinimize} title="Minimize">
|
|
90
|
+
<Minimize2 className="h-4 w-4" />
|
|
91
|
+
</Button>
|
|
92
|
+
)}
|
|
93
|
+
{onClose && (
|
|
94
|
+
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={onClose} title="Close">
|
|
95
|
+
<X className="h-4 w-4" />
|
|
96
|
+
</Button>
|
|
97
|
+
)}
|
|
98
|
+
</div>
|
|
99
|
+
</CardHeader>
|
|
100
|
+
|
|
101
|
+
{/* Messages */}
|
|
102
|
+
<CardContent className="flex-1 p-0 overflow-hidden">
|
|
103
|
+
<ChatMessages
|
|
104
|
+
messages={messages}
|
|
105
|
+
isLoading={isLoading}
|
|
106
|
+
greeting={greeting}
|
|
107
|
+
onStopStreaming={onStopStreaming}
|
|
108
|
+
isCompact
|
|
109
|
+
greetingIcon="bot"
|
|
110
|
+
/>
|
|
111
|
+
</CardContent>
|
|
112
|
+
|
|
113
|
+
{/* Input */}
|
|
114
|
+
<CardFooter className="p-3 border-t">
|
|
115
|
+
<AIMessageInput
|
|
116
|
+
onSend={onSendMessage}
|
|
117
|
+
isLoading={isLoading}
|
|
118
|
+
placeholder={placeholder}
|
|
119
|
+
/>
|
|
120
|
+
</CardFooter>
|
|
121
|
+
</Card>
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
ChatPanel.displayName = 'ChatPanel';
|