@rahul_vendure/ai-chat-dashboard 0.1.2 → 0.2.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.
- package/LICENSE +14 -0
- package/README.md +2 -1
- package/package.json +7 -5
- package/src/dashboard/AdminChatWidget.tsx +116 -272
- package/src/dashboard/components/AiChatToolbarButton.tsx +308 -0
- package/src/dashboard/components/ChatEmptyState.tsx +61 -0
- package/src/dashboard/components/ChatInput.tsx +76 -0
- package/src/dashboard/components/ChatMessageBubble.tsx +110 -0
- package/src/dashboard/components/EmbedAllButton.tsx +69 -0
- package/src/dashboard/components/OrderCard.tsx +66 -0
- package/src/dashboard/components/ProductCard.tsx +40 -0
- package/src/dashboard/components/SessionSidebar.tsx +119 -0
- package/src/dashboard/hooks/use-admin-chat.ts +138 -0
- package/src/dashboard/hooks/use-session-list.ts +69 -0
- package/src/dashboard/index.tsx +18 -24
- package/src/dashboard/types.ts +46 -0
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
import React, { useState, useRef, useEffect, useCallback } from 'react';
|
|
2
|
+
import { SparklesIcon, XIcon, Maximize2Icon, ArrowUp, Bot } from 'lucide-react';
|
|
3
|
+
import type { ChatMessage } from '../types';
|
|
4
|
+
import { ChatMessageBubble } from './ChatMessageBubble';
|
|
5
|
+
|
|
6
|
+
function generateSessionId(): string {
|
|
7
|
+
return `toolbar-${Date.now()}-${Math.random().toString(36).substring(2, 8)}`;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function MiniLoadingDots() {
|
|
11
|
+
return (
|
|
12
|
+
<div style={{
|
|
13
|
+
display: 'flex', gap: 10, padding: 12, margin: '8px 0',
|
|
14
|
+
borderRadius: 10, border: '1px solid var(--border, #e2e8f0)',
|
|
15
|
+
background: 'var(--background, #fff)',
|
|
16
|
+
}}>
|
|
17
|
+
<div style={{
|
|
18
|
+
width: 24, height: 24, borderRadius: '50%', flexShrink: 0,
|
|
19
|
+
background: 'var(--foreground, #0f172a)', color: 'var(--background, #fff)',
|
|
20
|
+
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
21
|
+
}}>
|
|
22
|
+
<Bot style={{ width: 12, height: 12 }} />
|
|
23
|
+
</div>
|
|
24
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: 3, paddingTop: 4 }}>
|
|
25
|
+
<span className="ac-dot" />
|
|
26
|
+
<span className="ac-dot" style={{ animationDelay: '0.15s' }} />
|
|
27
|
+
<span className="ac-dot" style={{ animationDelay: '0.3s' }} />
|
|
28
|
+
</div>
|
|
29
|
+
</div>
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function AiChatToolbarButton() {
|
|
34
|
+
const [open, setOpen] = useState(false);
|
|
35
|
+
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
|
36
|
+
const [input, setInput] = useState('');
|
|
37
|
+
const [isLoading, setIsLoading] = useState(false);
|
|
38
|
+
const [sessionId, setSessionId] = useState<string>(generateSessionId);
|
|
39
|
+
const scrollRef = useRef<HTMLDivElement>(null);
|
|
40
|
+
const panelRef = useRef<HTMLDivElement>(null);
|
|
41
|
+
const fabRef = useRef<HTMLButtonElement>(null);
|
|
42
|
+
|
|
43
|
+
useEffect(() => {
|
|
44
|
+
scrollRef.current?.scrollIntoView({ behavior: 'smooth' });
|
|
45
|
+
}, [messages]);
|
|
46
|
+
|
|
47
|
+
// Close on click outside
|
|
48
|
+
useEffect(() => {
|
|
49
|
+
if (!open) return;
|
|
50
|
+
function handleClick(e: MouseEvent) {
|
|
51
|
+
if (
|
|
52
|
+
panelRef.current && !panelRef.current.contains(e.target as Node) &&
|
|
53
|
+
fabRef.current && !fabRef.current.contains(e.target as Node)
|
|
54
|
+
) {
|
|
55
|
+
setOpen(false);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
document.addEventListener('mousedown', handleClick);
|
|
59
|
+
return () => document.removeEventListener('mousedown', handleClick);
|
|
60
|
+
}, [open]);
|
|
61
|
+
|
|
62
|
+
const sendMessage = useCallback(
|
|
63
|
+
async (text: string) => {
|
|
64
|
+
if (!text.trim() || isLoading) return;
|
|
65
|
+
const trimmed = text.trim();
|
|
66
|
+
setMessages(prev => [
|
|
67
|
+
...prev,
|
|
68
|
+
{ id: `u-${Date.now()}`, role: 'user', content: trimmed },
|
|
69
|
+
]);
|
|
70
|
+
setInput('');
|
|
71
|
+
setIsLoading(true);
|
|
72
|
+
|
|
73
|
+
try {
|
|
74
|
+
const sessionToken = localStorage.getItem('vendure-session-token');
|
|
75
|
+
const channelToken = localStorage.getItem('vendure-selected-channel-token');
|
|
76
|
+
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
|
77
|
+
if (sessionToken) headers['Authorization'] = `Bearer ${sessionToken}`;
|
|
78
|
+
if (channelToken) headers['vendure-token'] = channelToken;
|
|
79
|
+
|
|
80
|
+
const res = await fetch('/admin-ai-chat/chat', {
|
|
81
|
+
method: 'POST',
|
|
82
|
+
headers,
|
|
83
|
+
credentials: 'include',
|
|
84
|
+
body: JSON.stringify({
|
|
85
|
+
message: trimmed,
|
|
86
|
+
sessionId,
|
|
87
|
+
history: messages.map(m => ({ role: m.role, content: m.content })),
|
|
88
|
+
currentPage: window.location.pathname,
|
|
89
|
+
}),
|
|
90
|
+
});
|
|
91
|
+
const data = await res.json();
|
|
92
|
+
if (data.sessionId && data.sessionId !== sessionId) {
|
|
93
|
+
setSessionId(data.sessionId);
|
|
94
|
+
}
|
|
95
|
+
setMessages(prev => [
|
|
96
|
+
...prev,
|
|
97
|
+
{
|
|
98
|
+
id: `a-${Date.now()}`,
|
|
99
|
+
role: 'assistant',
|
|
100
|
+
content: data.message ?? 'No response',
|
|
101
|
+
products: data.products,
|
|
102
|
+
collections: data.collections,
|
|
103
|
+
orders: data.orders,
|
|
104
|
+
},
|
|
105
|
+
]);
|
|
106
|
+
if (data.navigateTo) {
|
|
107
|
+
setTimeout(() => {
|
|
108
|
+
const path = data.navigateTo.startsWith('/') ? `/dashboard${data.navigateTo}` : `/dashboard/${data.navigateTo}`;
|
|
109
|
+
window.location.href = path;
|
|
110
|
+
}, 1200);
|
|
111
|
+
}
|
|
112
|
+
} catch (err) {
|
|
113
|
+
setMessages(prev => [
|
|
114
|
+
...prev,
|
|
115
|
+
{
|
|
116
|
+
id: `e-${Date.now()}`,
|
|
117
|
+
role: 'assistant',
|
|
118
|
+
content: `Error: ${err instanceof Error ? err.message : 'Request failed'}`,
|
|
119
|
+
},
|
|
120
|
+
]);
|
|
121
|
+
} finally {
|
|
122
|
+
setIsLoading(false);
|
|
123
|
+
}
|
|
124
|
+
},
|
|
125
|
+
[isLoading, messages, sessionId],
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
const canSend = input.trim() && !isLoading;
|
|
129
|
+
|
|
130
|
+
return (
|
|
131
|
+
<>
|
|
132
|
+
{/* Hidden toolbar slot */}
|
|
133
|
+
<span style={{ display: 'none' }} />
|
|
134
|
+
|
|
135
|
+
{/* FAB button */}
|
|
136
|
+
<button
|
|
137
|
+
ref={fabRef}
|
|
138
|
+
onClick={() => setOpen(prev => !prev)}
|
|
139
|
+
style={{
|
|
140
|
+
position: 'fixed', zIndex: 9998, bottom: 20, right: 20,
|
|
141
|
+
width: 48, height: 48, borderRadius: '50%', border: 'none',
|
|
142
|
+
background: 'linear-gradient(135deg, #7c3aed, #4f46e5)',
|
|
143
|
+
color: '#fff', cursor: 'pointer',
|
|
144
|
+
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
145
|
+
boxShadow: '0 4px 12px rgba(79,70,229,0.4)',
|
|
146
|
+
transition: 'transform 0.2s, box-shadow 0.2s',
|
|
147
|
+
}}
|
|
148
|
+
onMouseEnter={e => { e.currentTarget.style.transform = 'scale(1.05)'; e.currentTarget.style.boxShadow = '0 6px 20px rgba(79,70,229,0.5)'; }}
|
|
149
|
+
onMouseLeave={e => { e.currentTarget.style.transform = 'scale(1)'; e.currentTarget.style.boxShadow = '0 4px 12px rgba(79,70,229,0.4)'; }}
|
|
150
|
+
title="AI Assistant"
|
|
151
|
+
>
|
|
152
|
+
{open
|
|
153
|
+
? <XIcon style={{ width: 20, height: 20 }} />
|
|
154
|
+
: <SparklesIcon style={{ width: 20, height: 20 }} />
|
|
155
|
+
}
|
|
156
|
+
</button>
|
|
157
|
+
|
|
158
|
+
{/* Floating chat panel */}
|
|
159
|
+
{open && (
|
|
160
|
+
<div
|
|
161
|
+
ref={panelRef}
|
|
162
|
+
style={{
|
|
163
|
+
position: 'fixed', zIndex: 9999,
|
|
164
|
+
bottom: 80, right: 20,
|
|
165
|
+
width: 400, height: 'min(560px, calc(100vh - 110px))',
|
|
166
|
+
display: 'flex', flexDirection: 'column',
|
|
167
|
+
borderRadius: 12, overflow: 'hidden',
|
|
168
|
+
border: '1px solid var(--border, #e2e8f0)',
|
|
169
|
+
background: 'var(--background, #fff)',
|
|
170
|
+
boxShadow: '0 16px 48px rgba(0,0,0,0.16)',
|
|
171
|
+
}}
|
|
172
|
+
>
|
|
173
|
+
{/* Header */}
|
|
174
|
+
<div style={{
|
|
175
|
+
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
|
176
|
+
borderBottom: '1px solid var(--border, #e2e8f0)',
|
|
177
|
+
padding: '10px 16px', flexShrink: 0,
|
|
178
|
+
background: 'var(--background, #fff)',
|
|
179
|
+
}}>
|
|
180
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
|
181
|
+
<SparklesIcon style={{ width: 16, height: 16, color: '#7c3aed' }} />
|
|
182
|
+
<span style={{ fontSize: 14, fontWeight: 600 }}>AI Assistant</span>
|
|
183
|
+
</div>
|
|
184
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
|
185
|
+
<a
|
|
186
|
+
href="/dashboard/extensions/ai-chat"
|
|
187
|
+
style={{
|
|
188
|
+
width: 24, height: 24, borderRadius: 6, border: 'none',
|
|
189
|
+
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
190
|
+
color: 'var(--muted-foreground, #64748b)', textDecoration: 'none',
|
|
191
|
+
}}
|
|
192
|
+
title="Open full view"
|
|
193
|
+
>
|
|
194
|
+
<Maximize2Icon style={{ width: 14, height: 14 }} />
|
|
195
|
+
</a>
|
|
196
|
+
<button
|
|
197
|
+
onClick={() => setOpen(false)}
|
|
198
|
+
style={{
|
|
199
|
+
width: 24, height: 24, borderRadius: 6, border: 'none',
|
|
200
|
+
background: 'transparent', cursor: 'pointer',
|
|
201
|
+
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
202
|
+
color: 'var(--muted-foreground, #64748b)',
|
|
203
|
+
}}
|
|
204
|
+
>
|
|
205
|
+
<XIcon style={{ width: 14, height: 14 }} />
|
|
206
|
+
</button>
|
|
207
|
+
</div>
|
|
208
|
+
</div>
|
|
209
|
+
|
|
210
|
+
{/* Messages */}
|
|
211
|
+
<div style={{ flex: 1, overflowY: 'auto', padding: '8px 12px', minHeight: 0 }}>
|
|
212
|
+
{messages.length === 0 ? (
|
|
213
|
+
<div style={{ display: 'flex', height: '100%', alignItems: 'center', justifyContent: 'center' }}>
|
|
214
|
+
<div style={{ textAlign: 'center', padding: '0 16px' }}>
|
|
215
|
+
<div style={{
|
|
216
|
+
width: 40, height: 40, borderRadius: '50%',
|
|
217
|
+
background: 'rgba(124,58,237,0.1)',
|
|
218
|
+
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
219
|
+
margin: '0 auto 8px',
|
|
220
|
+
}}>
|
|
221
|
+
<SparklesIcon style={{ width: 20, height: 20, color: '#7c3aed' }} />
|
|
222
|
+
</div>
|
|
223
|
+
<p style={{ fontSize: 14, fontWeight: 500, margin: '0 0 4px' }}>Ask anything about your store</p>
|
|
224
|
+
<p style={{ fontSize: 12, color: 'var(--muted-foreground, #64748b)', margin: '0 0 12px' }}>
|
|
225
|
+
Products, orders, customers...
|
|
226
|
+
</p>
|
|
227
|
+
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, justifyContent: 'center' }}>
|
|
228
|
+
{['Recent orders', 'Find a product', 'Store overview'].map(q => (
|
|
229
|
+
<button
|
|
230
|
+
key={q}
|
|
231
|
+
onClick={() => sendMessage(q)}
|
|
232
|
+
style={{
|
|
233
|
+
padding: '4px 12px', borderRadius: 99, fontSize: 12,
|
|
234
|
+
border: '1px solid var(--border, #e2e8f0)',
|
|
235
|
+
background: 'transparent', cursor: 'pointer',
|
|
236
|
+
color: 'var(--muted-foreground, #64748b)',
|
|
237
|
+
}}
|
|
238
|
+
>
|
|
239
|
+
{q}
|
|
240
|
+
</button>
|
|
241
|
+
))}
|
|
242
|
+
</div>
|
|
243
|
+
</div>
|
|
244
|
+
</div>
|
|
245
|
+
) : (
|
|
246
|
+
<>
|
|
247
|
+
{messages.map(msg => (
|
|
248
|
+
<ChatMessageBubble key={msg.id} message={msg} />
|
|
249
|
+
))}
|
|
250
|
+
{isLoading && <MiniLoadingDots />}
|
|
251
|
+
<div ref={scrollRef} style={{ height: 4 }} />
|
|
252
|
+
</>
|
|
253
|
+
)}
|
|
254
|
+
</div>
|
|
255
|
+
|
|
256
|
+
{/* Input */}
|
|
257
|
+
<div style={{
|
|
258
|
+
borderTop: '1px solid var(--border, #e2e8f0)',
|
|
259
|
+
padding: '10px 12px', flexShrink: 0,
|
|
260
|
+
background: 'var(--background, #fff)',
|
|
261
|
+
}}>
|
|
262
|
+
<form
|
|
263
|
+
style={{ display: 'flex', alignItems: 'flex-end', gap: 8 }}
|
|
264
|
+
onSubmit={e => { e.preventDefault(); sendMessage(input); }}
|
|
265
|
+
>
|
|
266
|
+
<textarea
|
|
267
|
+
value={input}
|
|
268
|
+
onChange={e => setInput(e.target.value)}
|
|
269
|
+
onKeyDown={e => {
|
|
270
|
+
if (e.key === 'Enter' && !e.shiftKey) {
|
|
271
|
+
e.preventDefault();
|
|
272
|
+
if (canSend) sendMessage(input);
|
|
273
|
+
}
|
|
274
|
+
}}
|
|
275
|
+
placeholder="Ask something..."
|
|
276
|
+
disabled={isLoading}
|
|
277
|
+
rows={1}
|
|
278
|
+
style={{
|
|
279
|
+
flex: 1, resize: 'none', borderRadius: 8,
|
|
280
|
+
border: '1px solid var(--border, #e2e8f0)',
|
|
281
|
+
background: 'var(--background, #fff)',
|
|
282
|
+
padding: '8px 12px', fontSize: 13, outline: 'none',
|
|
283
|
+
color: 'var(--foreground, #0f172a)',
|
|
284
|
+
maxHeight: 80, minHeight: 36, fontFamily: 'inherit',
|
|
285
|
+
opacity: isLoading ? 0.5 : 1,
|
|
286
|
+
}}
|
|
287
|
+
/>
|
|
288
|
+
<button
|
|
289
|
+
type="submit"
|
|
290
|
+
disabled={!canSend}
|
|
291
|
+
style={{
|
|
292
|
+
width: 36, height: 36, borderRadius: 8, border: 'none',
|
|
293
|
+
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
294
|
+
flexShrink: 0, cursor: canSend ? 'pointer' : 'default',
|
|
295
|
+
background: canSend ? 'var(--primary, #6366f1)' : 'var(--muted, #f1f5f9)',
|
|
296
|
+
color: canSend ? '#fff' : 'var(--muted-foreground, #94a3b8)',
|
|
297
|
+
transition: 'background 0.15s',
|
|
298
|
+
}}
|
|
299
|
+
>
|
|
300
|
+
<ArrowUp style={{ width: 16, height: 16 }} />
|
|
301
|
+
</button>
|
|
302
|
+
</form>
|
|
303
|
+
</div>
|
|
304
|
+
</div>
|
|
305
|
+
)}
|
|
306
|
+
</>
|
|
307
|
+
);
|
|
308
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import {
|
|
3
|
+
Package, ShoppingCart, Users,
|
|
4
|
+
Layers, Truck, BarChart3, ArrowRight,
|
|
5
|
+
} from 'lucide-react';
|
|
6
|
+
|
|
7
|
+
const SUGGESTIONS = [
|
|
8
|
+
{ text: 'Show recent orders', icon: ShoppingCart },
|
|
9
|
+
{ text: 'Search for a product', icon: Package },
|
|
10
|
+
{ text: 'List all collections', icon: Layers },
|
|
11
|
+
{ text: 'Find a customer', icon: Users },
|
|
12
|
+
{ text: 'Check shipping methods', icon: Truck },
|
|
13
|
+
{ text: 'Show store overview', icon: BarChart3 },
|
|
14
|
+
];
|
|
15
|
+
|
|
16
|
+
interface ChatEmptyStateProps {
|
|
17
|
+
onSend: (text: string) => void;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function ChatEmptyState({ onSend }: ChatEmptyStateProps) {
|
|
21
|
+
return (
|
|
22
|
+
<div style={{ display: 'flex', height: '100%', alignItems: 'center', justifyContent: 'center' }}>
|
|
23
|
+
<div style={{ maxWidth: 440, textAlign: 'center' }}>
|
|
24
|
+
<h1 style={{ fontSize: 24, fontWeight: 600, color: 'var(--foreground, #0f172a)', marginBottom: 12 }}>
|
|
25
|
+
AI Assistant
|
|
26
|
+
</h1>
|
|
27
|
+
<p style={{ fontSize: 14, color: 'var(--muted-foreground, #64748b)', lineHeight: 1.6, marginBottom: 20 }}>
|
|
28
|
+
Ask me anything about your store. I can help with products, orders, customers,
|
|
29
|
+
and more. Here are some ideas to get you started:
|
|
30
|
+
</p>
|
|
31
|
+
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8 }}>
|
|
32
|
+
{SUGGESTIONS.map(s => {
|
|
33
|
+
const Icon = s.icon;
|
|
34
|
+
return (
|
|
35
|
+
<button
|
|
36
|
+
key={s.text}
|
|
37
|
+
onClick={() => onSend(s.text)}
|
|
38
|
+
style={{
|
|
39
|
+
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
|
40
|
+
padding: 12, borderRadius: 8, textAlign: 'left', fontSize: 13,
|
|
41
|
+
border: '1px solid var(--border, #e2e8f0)',
|
|
42
|
+
background: 'var(--background, #fff)',
|
|
43
|
+
color: 'var(--foreground, #0f172a)',
|
|
44
|
+
cursor: 'pointer', transition: 'background 0.15s',
|
|
45
|
+
}}
|
|
46
|
+
onMouseEnter={e => (e.currentTarget.style.background = 'var(--muted, #f1f5f9)')}
|
|
47
|
+
onMouseLeave={e => (e.currentTarget.style.background = 'var(--background, #fff)')}
|
|
48
|
+
>
|
|
49
|
+
<span style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
|
50
|
+
<Icon style={{ width: 16, height: 16, color: 'var(--muted-foreground, #64748b)' }} />
|
|
51
|
+
<span>{s.text}</span>
|
|
52
|
+
</span>
|
|
53
|
+
<ArrowRight style={{ width: 16, height: 16, color: 'var(--muted-foreground, #64748b)', opacity: 0.3 }} />
|
|
54
|
+
</button>
|
|
55
|
+
);
|
|
56
|
+
})}
|
|
57
|
+
</div>
|
|
58
|
+
</div>
|
|
59
|
+
</div>
|
|
60
|
+
);
|
|
61
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import React, { useState } from 'react';
|
|
2
|
+
import { ArrowUp } from 'lucide-react';
|
|
3
|
+
|
|
4
|
+
interface ChatInputProps {
|
|
5
|
+
value: string;
|
|
6
|
+
onChange: (value: string) => void;
|
|
7
|
+
onSubmit: () => void;
|
|
8
|
+
disabled: boolean;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function ChatInput({ value, onChange, onSubmit, disabled }: ChatInputProps) {
|
|
12
|
+
const canSend = value.trim() && !disabled;
|
|
13
|
+
const [focused, setFocused] = useState(false);
|
|
14
|
+
|
|
15
|
+
return (
|
|
16
|
+
<form
|
|
17
|
+
style={{ padding: '16px 20px' }}
|
|
18
|
+
onSubmit={e => {
|
|
19
|
+
e.preventDefault();
|
|
20
|
+
if (canSend) onSubmit();
|
|
21
|
+
}}
|
|
22
|
+
>
|
|
23
|
+
<div
|
|
24
|
+
style={{
|
|
25
|
+
display: 'flex', alignItems: 'flex-end', gap: 12,
|
|
26
|
+
borderRadius: 12,
|
|
27
|
+
border: `1px solid ${focused ? 'var(--primary, #6366f1)' : 'var(--border, #e2e8f0)'}`,
|
|
28
|
+
background: 'var(--background, #fff)',
|
|
29
|
+
padding: '10px 16px',
|
|
30
|
+
boxShadow: focused ? '0 0 0 3px rgba(99,102,241,0.12)' : '0 1px 2px rgba(0,0,0,0.04)',
|
|
31
|
+
transition: 'border-color 0.15s, box-shadow 0.15s',
|
|
32
|
+
opacity: disabled ? 0.5 : 1,
|
|
33
|
+
}}
|
|
34
|
+
>
|
|
35
|
+
<textarea
|
|
36
|
+
value={value}
|
|
37
|
+
onChange={e => onChange(e.target.value)}
|
|
38
|
+
onFocus={() => setFocused(true)}
|
|
39
|
+
onBlur={() => setFocused(false)}
|
|
40
|
+
onKeyDown={e => {
|
|
41
|
+
if (e.key === 'Enter' && !e.shiftKey) {
|
|
42
|
+
e.preventDefault();
|
|
43
|
+
if (canSend) onSubmit();
|
|
44
|
+
}
|
|
45
|
+
}}
|
|
46
|
+
placeholder="Ask the AI assistant..."
|
|
47
|
+
disabled={disabled}
|
|
48
|
+
rows={1}
|
|
49
|
+
style={{
|
|
50
|
+
flex: 1, resize: 'none', border: 'none', outline: 'none',
|
|
51
|
+
background: 'transparent', padding: '6px 0',
|
|
52
|
+
fontSize: 14, lineHeight: 1.5,
|
|
53
|
+
color: 'var(--foreground, #0f172a)',
|
|
54
|
+
maxHeight: 120, minHeight: 32,
|
|
55
|
+
fontFamily: 'inherit',
|
|
56
|
+
}}
|
|
57
|
+
/>
|
|
58
|
+
<button
|
|
59
|
+
type="submit"
|
|
60
|
+
disabled={!canSend}
|
|
61
|
+
style={{
|
|
62
|
+
width: 28, height: 28, borderRadius: 8,
|
|
63
|
+
border: 'none', cursor: canSend ? 'pointer' : 'default',
|
|
64
|
+
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
65
|
+
flexShrink: 0, marginBottom: 2,
|
|
66
|
+
background: canSend ? 'var(--primary, #6366f1)' : 'var(--muted, #f1f5f9)',
|
|
67
|
+
color: canSend ? '#fff' : 'var(--muted-foreground, #94a3b8)',
|
|
68
|
+
transition: 'background 0.15s, transform 0.1s',
|
|
69
|
+
}}
|
|
70
|
+
>
|
|
71
|
+
<ArrowUp style={{ width: 14, height: 14 }} />
|
|
72
|
+
</button>
|
|
73
|
+
</div>
|
|
74
|
+
</form>
|
|
75
|
+
);
|
|
76
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import Markdown from 'react-markdown';
|
|
3
|
+
import remarkGfm from 'remark-gfm';
|
|
4
|
+
import { Bot, User } from 'lucide-react';
|
|
5
|
+
import type { ChatMessage } from '../types';
|
|
6
|
+
import { ProductCard } from './ProductCard';
|
|
7
|
+
import { OrderCard } from './OrderCard';
|
|
8
|
+
|
|
9
|
+
interface ChatMessageBubbleProps {
|
|
10
|
+
message: ChatMessage;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function ChatMessageBubble({ message }: ChatMessageBubbleProps) {
|
|
14
|
+
const isUser = message.role === 'user';
|
|
15
|
+
|
|
16
|
+
if (isUser) {
|
|
17
|
+
return (
|
|
18
|
+
<div style={{ display: 'flex', gap: 12, padding: '16px 0' }}>
|
|
19
|
+
<div style={{
|
|
20
|
+
width: 28, height: 28, borderRadius: '50%', flexShrink: 0,
|
|
21
|
+
background: 'var(--muted, #f1f5f9)',
|
|
22
|
+
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
23
|
+
border: '1px solid var(--border, #e2e8f0)',
|
|
24
|
+
}}>
|
|
25
|
+
<User style={{ width: 16, height: 16, color: 'var(--muted-foreground, #64748b)' }} />
|
|
26
|
+
</div>
|
|
27
|
+
<div style={{ flex: 1, paddingTop: 4, fontSize: 13, color: 'var(--foreground, #0f172a)', whiteSpace: 'pre-wrap' }}>
|
|
28
|
+
{message.content}
|
|
29
|
+
</div>
|
|
30
|
+
</div>
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return (
|
|
35
|
+
<div style={{
|
|
36
|
+
borderRadius: 12, border: '1px solid var(--border, #e2e8f0)',
|
|
37
|
+
background: 'var(--background, #fff)', margin: '16px 0',
|
|
38
|
+
boxShadow: '0 1px 2px rgba(0,0,0,0.04)',
|
|
39
|
+
}}>
|
|
40
|
+
<div style={{ display: 'flex', gap: 12, padding: 16 }}>
|
|
41
|
+
<div style={{
|
|
42
|
+
width: 28, height: 28, borderRadius: '50%', flexShrink: 0,
|
|
43
|
+
background: 'var(--foreground, #0f172a)', color: 'var(--background, #fff)',
|
|
44
|
+
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
45
|
+
}}>
|
|
46
|
+
<Bot style={{ width: 16, height: 16 }} />
|
|
47
|
+
</div>
|
|
48
|
+
<div style={{ minWidth: 0, flex: 1, overflow: 'hidden' }}>
|
|
49
|
+
<div className="ac-md" style={{ fontSize: 13, lineHeight: 1.7, color: 'var(--foreground, #0f172a)' }}>
|
|
50
|
+
<Markdown
|
|
51
|
+
remarkPlugins={[remarkGfm]}
|
|
52
|
+
components={{
|
|
53
|
+
// Hide inline images — products are shown as cards below
|
|
54
|
+
img: () => null,
|
|
55
|
+
}}
|
|
56
|
+
>
|
|
57
|
+
{message.content}
|
|
58
|
+
</Markdown>
|
|
59
|
+
</div>
|
|
60
|
+
</div>
|
|
61
|
+
</div>
|
|
62
|
+
|
|
63
|
+
{/* Attached cards */}
|
|
64
|
+
{(message.products?.length || message.orders?.length) ? (
|
|
65
|
+
<div style={{
|
|
66
|
+
borderTop: '1px solid var(--border, #e2e8f0)',
|
|
67
|
+
background: 'var(--muted, #f8fafc)', padding: 12,
|
|
68
|
+
borderRadius: '0 0 12px 12px',
|
|
69
|
+
}}>
|
|
70
|
+
{message.products && message.products.length > 0 && (
|
|
71
|
+
<div style={{ display: 'flex', gap: 8, overflowX: 'auto' }}>
|
|
72
|
+
{message.products.map(p => (
|
|
73
|
+
<ProductCard key={p.id} product={p} />
|
|
74
|
+
))}
|
|
75
|
+
</div>
|
|
76
|
+
)}
|
|
77
|
+
{message.orders && message.orders.length > 0 && (
|
|
78
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
|
79
|
+
{message.orders.map(o => (
|
|
80
|
+
<OrderCard key={o.id} order={o} />
|
|
81
|
+
))}
|
|
82
|
+
</div>
|
|
83
|
+
)}
|
|
84
|
+
</div>
|
|
85
|
+
) : null}
|
|
86
|
+
|
|
87
|
+
<style>{`
|
|
88
|
+
.ac-md p { margin: 6px 0; }
|
|
89
|
+
.ac-md p:first-child { margin-top: 0; }
|
|
90
|
+
.ac-md p:last-child { margin-bottom: 0; }
|
|
91
|
+
.ac-md strong { font-weight: 600; }
|
|
92
|
+
.ac-md ul, .ac-md ol { margin: 4px 0 8px; padding-left: 20px; }
|
|
93
|
+
.ac-md li { margin: 2px 0; }
|
|
94
|
+
.ac-md code { background: var(--muted, #f1f5f9); padding: 1px 5px; border-radius: 4px; font-size: 12px; }
|
|
95
|
+
.ac-md pre { background: var(--muted, #f1f5f9); padding: 12px; border-radius: 8px; overflow-x: auto; margin: 8px 0; }
|
|
96
|
+
.ac-md pre code { background: none; padding: 0; }
|
|
97
|
+
.ac-md table { border-collapse: collapse; width: 100%; margin: 10px 0; font-size: 13px; border: 1px solid var(--border, #e2e8f0); border-radius: 8px; overflow: hidden; }
|
|
98
|
+
.ac-md thead { background: var(--muted, #f1f5f9); }
|
|
99
|
+
.ac-md th { text-align: left; padding: 8px 12px; font-weight: 600; font-size: 12px; border-bottom: 2px solid var(--border, #e2e8f0); }
|
|
100
|
+
.ac-md td { padding: 8px 12px; border-bottom: 1px solid var(--border, #f1f5f9); }
|
|
101
|
+
.ac-md tbody tr:last-child td { border-bottom: none; }
|
|
102
|
+
.ac-md a { color: var(--primary, #6366f1); text-decoration: underline; }
|
|
103
|
+
.ac-md h1 { font-size: 18px; font-weight: 600; margin: 12px 0 8px; }
|
|
104
|
+
.ac-md h2 { font-size: 16px; font-weight: 600; margin: 10px 0 6px; }
|
|
105
|
+
.ac-md h3 { font-size: 14px; font-weight: 600; margin: 8px 0 4px; }
|
|
106
|
+
.ac-md img { display: none; }
|
|
107
|
+
`}</style>
|
|
108
|
+
</div>
|
|
109
|
+
);
|
|
110
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import React, { useState } from 'react';
|
|
2
|
+
import { api } from '@vendure/dashboard';
|
|
3
|
+
import { Database, Loader2, Check, AlertCircle } from 'lucide-react';
|
|
4
|
+
|
|
5
|
+
type EmbedStatus = 'idle' | 'loading' | 'success' | 'error';
|
|
6
|
+
|
|
7
|
+
const TRIGGER_EMBED_ALL = `
|
|
8
|
+
mutation TriggerEmbedAll {
|
|
9
|
+
triggerEmbedAll {
|
|
10
|
+
jobId
|
|
11
|
+
message
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
`;
|
|
15
|
+
|
|
16
|
+
export function EmbedAllButton() {
|
|
17
|
+
const [status, setStatus] = useState<EmbedStatus>('idle');
|
|
18
|
+
|
|
19
|
+
const handleEmbedAll = async () => {
|
|
20
|
+
if (status === 'loading') return;
|
|
21
|
+
setStatus('loading');
|
|
22
|
+
|
|
23
|
+
try {
|
|
24
|
+
const result = await api.mutate(TRIGGER_EMBED_ALL);
|
|
25
|
+
if (!result.triggerEmbedAll) throw new Error('No result');
|
|
26
|
+
setStatus('success');
|
|
27
|
+
setTimeout(() => setStatus('idle'), 3000);
|
|
28
|
+
} catch {
|
|
29
|
+
setStatus('error');
|
|
30
|
+
setTimeout(() => setStatus('idle'), 3000);
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const Icon = status === 'loading' ? Loader2
|
|
35
|
+
: status === 'success' ? Check
|
|
36
|
+
: status === 'error' ? AlertCircle
|
|
37
|
+
: Database;
|
|
38
|
+
|
|
39
|
+
const label = status === 'loading' ? 'Embedding...'
|
|
40
|
+
: status === 'success' ? 'Done'
|
|
41
|
+
: status === 'error' ? 'Failed'
|
|
42
|
+
: 'Embed All';
|
|
43
|
+
|
|
44
|
+
return (
|
|
45
|
+
<button
|
|
46
|
+
onClick={handleEmbedAll}
|
|
47
|
+
disabled={status === 'loading'}
|
|
48
|
+
style={{
|
|
49
|
+
display: 'inline-flex', alignItems: 'center', gap: 6,
|
|
50
|
+
padding: '4px 8px', borderRadius: 6, border: 'none',
|
|
51
|
+
background: 'transparent', cursor: status === 'loading' ? 'wait' : 'pointer',
|
|
52
|
+
fontSize: 12, fontWeight: 500, fontFamily: 'inherit',
|
|
53
|
+
color: status === 'success' ? '#10b981'
|
|
54
|
+
: status === 'error' ? '#ef4444'
|
|
55
|
+
: 'var(--muted-foreground, #64748b)',
|
|
56
|
+
transition: 'color 0.15s',
|
|
57
|
+
}}
|
|
58
|
+
onMouseEnter={e => { if (status === 'idle') e.currentTarget.style.color = 'var(--foreground, #0f172a)'; }}
|
|
59
|
+
onMouseLeave={e => { if (status === 'idle') e.currentTarget.style.color = 'var(--muted-foreground, #64748b)'; }}
|
|
60
|
+
>
|
|
61
|
+
<Icon style={{
|
|
62
|
+
width: 12, height: 12,
|
|
63
|
+
...(status === 'loading' ? { animation: 'ac-spin 1s linear infinite' } : {}),
|
|
64
|
+
}} />
|
|
65
|
+
{label}
|
|
66
|
+
<style>{`@keyframes ac-spin { to { transform: rotate(360deg); } }`}</style>
|
|
67
|
+
</button>
|
|
68
|
+
);
|
|
69
|
+
}
|