@vezlo/assistant-chat 1.2.0 β†’ 1.3.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/PACKAGE_README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  [![npm version](https://img.shields.io/npm/v/@vezlo/assistant-chat.svg)](https://www.npmjs.com/package/@vezlo/assistant-chat) [![license](https://img.shields.io/badge/license-AGPL--3.0-blue.svg)](https://opensource.org/licenses/AGPL-3.0)
4
4
 
5
- A React component library for integrating AI assistant chat functionality into web applications.
5
+ A React component library for integrating AI assistant chat functionality into web applications with realtime updates and human agent support.
6
6
 
7
7
  > **πŸ“¦ This is the NPM package documentation**
8
8
  > **🏠 Repository**: [assistant-chat](https://github.com/vezlo/assistant-chat) - Contains both this NPM package and a standalone admin application
@@ -19,6 +19,7 @@ npm install @vezlo/assistant-chat
19
19
  - React 18 or higher
20
20
  - Tailwind CSS (for styling)
21
21
  - Assistant Server running (see [Assistant Server](https://github.com/vezlo/assistant-server))
22
+ - **Realtime Updates** (Optional): Provide `supabaseUrl` + `supabaseAnonKey` for agent handoff / live message sync. Without these the widget still works, it just won’t receive realtime pushes.
22
23
 
23
24
  ## Quick Start
24
25
 
@@ -37,7 +38,10 @@ function App() {
37
38
  themeColor: '#10b981',
38
39
  position: 'bottom-right',
39
40
  size: { width: 400, height: 600 },
40
- defaultOpen: false
41
+ defaultOpen: false,
42
+ // Optional realtime config
43
+ supabaseUrl: 'https://your-project.supabase.co',
44
+ supabaseAnonKey: 'your-anon-key'
41
45
  };
42
46
 
43
47
  return <Widget config={widgetConfig} />;
@@ -59,6 +63,8 @@ The `WidgetConfig` interface includes:
59
63
  - `position`: Widget position ('bottom-right', 'bottom-left', 'top-right', 'top-left')
60
64
  - `size`: Widget dimensions
61
65
  - `defaultOpen`: Whether widget opens by default
66
+ - `supabaseUrl`: Supabase project URL (optional, required for realtime updates)
67
+ - `supabaseAnonKey`: Supabase anon key (optional, required for realtime updates)
62
68
 
63
69
  ### Configuration Options Table
64
70
 
@@ -75,6 +81,8 @@ The `WidgetConfig` interface includes:
75
81
  | `defaultOpen` | boolean | `false` | Whether widget opens by default |
76
82
  | `apiUrl` | string | Required | Assistant Server API URL |
77
83
  | `apiKey` | string | Required | API key for authentication |
84
+ | `supabaseUrl` | string | Optional | Supabase project URL (for realtime updates) |
85
+ | `supabaseAnonKey` | string | Optional | Supabase anon key (for realtime updates) |
78
86
 
79
87
  ## API Integration
80
88
 
@@ -83,6 +91,7 @@ This widget requires a running Assistant Server instance. The widget will:
83
91
  1. Create conversations automatically
84
92
  2. Send user messages to the server
85
93
  3. Stream AI responses in real-time
94
+ 4. **Realtime Updates**: With `supabaseUrl` and `supabaseAnonKey` configured, the widget receives realtime updates for agent handoff and live message synchronization
86
95
 
87
96
  Configure your Assistant Server URL in your application:
88
97
 
package/README.md CHANGED
@@ -74,6 +74,10 @@ npm run dev
74
74
  - **Assistant Server**: Both components require a running Assistant Server
75
75
  - Node.js 18+ and npm
76
76
  - React 18+ (for package usage)
77
+ - **Realtime Updates** (Optional): For agent handoff + live message sync, provide Supabase Realtime credentials
78
+ - `VITE_SUPABASE_URL`: Supabase project URL
79
+ - `VITE_SUPABASE_ANON_KEY`: Supabase anon/public key
80
+ - Without these, the widget works normally but won’t receive realtime updates
77
81
 
78
82
  ## Features
79
83
 
@@ -82,6 +86,7 @@ npm run dev
82
86
  - βœ… TypeScript support
83
87
  - βœ… Tailwind CSS styling
84
88
  - βœ… Real-time streaming
89
+ - βœ… **Realtime updates** (agent handoff, live message sync)
85
90
  - βœ… Customizable themes
86
91
  - βœ… Shadow DOM support
87
92
  - βœ… API integration included
@@ -92,6 +97,8 @@ npm run dev
92
97
  - βœ… Playground testing
93
98
  - βœ… Embed code generation
94
99
  - βœ… Multiple widget management
100
+ - βœ… **Human agent support** (conversation management, agent handoff)
101
+ - βœ… **Realtime updates** (live message synchronization)
95
102
  - βœ… Docker support
96
103
  - βœ… Vercel deployment
97
104
 
@@ -129,6 +136,9 @@ vercel
129
136
  # Set environment variables (required)
130
137
  vercel env add VITE_ASSISTANT_SERVER_URL
131
138
  vercel env add VITE_ASSISTANT_SERVER_API_KEY
139
+ # Optional: For realtime updates
140
+ vercel env add VITE_SUPABASE_URL
141
+ vercel env add VITE_SUPABASE_ANON_KEY
132
142
 
133
143
  # Deploy to production
134
144
  vercel --prod
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Auth API Service
3
+ * Handles login, logout, and current user retrieval
4
+ */
5
+ export interface LoginRequest {
6
+ email: string;
7
+ password: string;
8
+ }
9
+ export interface LoginResponse {
10
+ access_token: string;
11
+ }
12
+ export interface MeResponse {
13
+ user: {
14
+ uuid: string;
15
+ email: string;
16
+ name: string;
17
+ };
18
+ profile: {
19
+ uuid: string;
20
+ company_uuid: string;
21
+ company_name: string;
22
+ role: string;
23
+ };
24
+ }
25
+ export declare function loginUser(payload: LoginRequest, apiUrl?: string): Promise<LoginResponse>;
26
+ export declare function logoutUser(token: string, apiUrl?: string): Promise<void>;
27
+ export declare function getCurrentUser(token: string, apiUrl?: string): Promise<MeResponse>;
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Auth API Service
3
+ * Handles login, logout, and current user retrieval
4
+ */
5
+ const DEFAULT_API_BASE_URL = import.meta.env.VITE_ASSISTANT_SERVER_URL || 'http://localhost:3000';
6
+ const parseErrorMessage = async (response) => {
7
+ const data = await response.json().catch(() => ({}));
8
+ return data.error || data.message || 'Unexpected server error';
9
+ };
10
+ export async function loginUser(payload, apiUrl) {
11
+ const API_BASE_URL = apiUrl || DEFAULT_API_BASE_URL;
12
+ const response = await fetch(`${API_BASE_URL}/api/auth/login`, {
13
+ method: 'POST',
14
+ headers: {
15
+ 'Content-Type': 'application/json',
16
+ Accept: 'application/json',
17
+ },
18
+ body: JSON.stringify(payload),
19
+ });
20
+ if (!response.ok) {
21
+ const message = await parseErrorMessage(response);
22
+ throw new Error(message);
23
+ }
24
+ const data = (await response.json());
25
+ if (!data.access_token) {
26
+ throw new Error('Login succeeded but no access token was returned.');
27
+ }
28
+ return data;
29
+ }
30
+ export async function logoutUser(token, apiUrl) {
31
+ const API_BASE_URL = apiUrl || DEFAULT_API_BASE_URL;
32
+ const response = await fetch(`${API_BASE_URL}/api/auth/logout`, {
33
+ method: 'POST',
34
+ headers: {
35
+ Accept: 'application/json',
36
+ Authorization: `Bearer ${token}`,
37
+ },
38
+ });
39
+ if (!response.ok && response.status !== 401) {
40
+ // 401 just means the token is already invalid – safe to continue
41
+ const message = await parseErrorMessage(response);
42
+ throw new Error(message);
43
+ }
44
+ }
45
+ export async function getCurrentUser(token, apiUrl) {
46
+ const API_BASE_URL = apiUrl || DEFAULT_API_BASE_URL;
47
+ const response = await fetch(`${API_BASE_URL}/api/auth/me`, {
48
+ method: 'GET',
49
+ headers: {
50
+ Accept: 'application/json',
51
+ Authorization: `Bearer ${token}`,
52
+ },
53
+ });
54
+ if (!response.ok) {
55
+ const message = await parseErrorMessage(response);
56
+ throw new Error(message);
57
+ }
58
+ return (await response.json());
59
+ }
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Conversation API Service
3
- * Handles conversation creation and management
3
+ * Handles conversation creation, listing, and agent messaging
4
4
  */
5
5
  export interface CreateConversationRequest {
6
6
  title?: string;
@@ -14,6 +14,45 @@ export interface ConversationResponse {
14
14
  created_at: string;
15
15
  updated_at: string;
16
16
  }
17
+ export interface ConversationListItem {
18
+ uuid: string;
19
+ status: string;
20
+ message_count: number;
21
+ last_message_at: string | null;
22
+ joined_at: string | null;
23
+ closed_at: string | null;
24
+ created_at: string;
25
+ updated_at: string;
26
+ }
27
+ export interface ConversationListResponse {
28
+ conversations: ConversationListItem[];
29
+ pagination: {
30
+ page: number;
31
+ page_size: number;
32
+ total: number;
33
+ has_more: boolean;
34
+ };
35
+ }
36
+ export interface ConversationMessage {
37
+ uuid: string;
38
+ content: string;
39
+ type: 'user' | 'assistant' | 'agent' | 'system';
40
+ author_id: number | null;
41
+ created_at: string;
42
+ pending?: boolean;
43
+ }
44
+ export interface ConversationMessagesResponse {
45
+ messages: ConversationMessage[];
46
+ pagination: {
47
+ page: number;
48
+ page_size: number;
49
+ total: number;
50
+ has_more: boolean;
51
+ };
52
+ }
53
+ export interface JoinConversationResponse {
54
+ message: ConversationMessage;
55
+ }
17
56
  /**
18
57
  * Create a new conversation
19
58
  */
@@ -22,3 +61,23 @@ export declare function createConversation(request: CreateConversationRequest, a
22
61
  * Get conversation by UUID
23
62
  */
24
63
  export declare function getConversation(uuid: string, apiUrl?: string): Promise<ConversationResponse>;
64
+ /**
65
+ * Get paginated conversations for agent UI
66
+ */
67
+ export declare function getConversations(token: string, page?: number, pageSize?: number, orderBy?: string, apiUrl?: string): Promise<ConversationListResponse>;
68
+ /**
69
+ * Get messages within a conversation
70
+ */
71
+ export declare function getConversationMessages(token: string, conversationUuid: string, page?: number, pageSize?: number, apiUrl?: string): Promise<ConversationMessagesResponse>;
72
+ /**
73
+ * Join a conversation as an agent
74
+ */
75
+ export declare function joinConversation(token: string, conversationUuid: string, apiUrl?: string): Promise<JoinConversationResponse>;
76
+ /**
77
+ * Close a conversation as an agent
78
+ */
79
+ export declare function closeConversation(token: string, conversationUuid: string, apiUrl?: string): Promise<JoinConversationResponse>;
80
+ /**
81
+ * Send agent-authored message
82
+ */
83
+ export declare function sendAgentMessage(token: string, conversationUuid: string, content: string, apiUrl?: string): Promise<ConversationMessage>;
@@ -1,8 +1,14 @@
1
1
  /**
2
2
  * Conversation API Service
3
- * Handles conversation creation and management
3
+ * Handles conversation creation, listing, and agent messaging
4
4
  */
5
5
  const DEFAULT_API_BASE_URL = import.meta.env.VITE_ASSISTANT_SERVER_URL || 'http://localhost:3000';
6
+ const parseErrorMessage = async (response) => {
7
+ const data = await response.json().catch(() => ({}));
8
+ return (data.error ||
9
+ data.message ||
10
+ 'Unexpected server error');
11
+ };
6
12
  /**
7
13
  * Create a new conversation
8
14
  */
@@ -52,3 +58,95 @@ export async function getConversation(uuid, apiUrl) {
52
58
  throw error;
53
59
  }
54
60
  }
61
+ /**
62
+ * Get paginated conversations for agent UI
63
+ */
64
+ export async function getConversations(token, page = 1, pageSize = 20, orderBy = 'last_message_at', apiUrl) {
65
+ const API_BASE_URL = apiUrl || DEFAULT_API_BASE_URL;
66
+ const response = await fetch(`${API_BASE_URL}/api/conversations?page=${page}&page_size=${pageSize}&order_by=${orderBy}`, {
67
+ method: 'GET',
68
+ headers: {
69
+ Accept: 'application/json',
70
+ Authorization: `Bearer ${token}`,
71
+ },
72
+ });
73
+ if (!response.ok) {
74
+ const message = await parseErrorMessage(response);
75
+ throw new Error(message);
76
+ }
77
+ return (await response.json());
78
+ }
79
+ /**
80
+ * Get messages within a conversation
81
+ */
82
+ export async function getConversationMessages(token, conversationUuid, page = 1, pageSize = 50, apiUrl) {
83
+ const API_BASE_URL = apiUrl || DEFAULT_API_BASE_URL;
84
+ const response = await fetch(`${API_BASE_URL}/api/conversations/${conversationUuid}/messages?page=${page}&page_size=${pageSize}`, {
85
+ method: 'GET',
86
+ headers: {
87
+ Accept: 'application/json',
88
+ Authorization: `Bearer ${token}`,
89
+ },
90
+ });
91
+ if (!response.ok) {
92
+ const message = await parseErrorMessage(response);
93
+ throw new Error(message);
94
+ }
95
+ return (await response.json());
96
+ }
97
+ /**
98
+ * Join a conversation as an agent
99
+ */
100
+ export async function joinConversation(token, conversationUuid, apiUrl) {
101
+ const API_BASE_URL = apiUrl || DEFAULT_API_BASE_URL;
102
+ const response = await fetch(`${API_BASE_URL}/api/conversations/${conversationUuid}/join`, {
103
+ method: 'POST',
104
+ headers: {
105
+ Accept: 'application/json',
106
+ Authorization: `Bearer ${token}`,
107
+ },
108
+ });
109
+ if (!response.ok) {
110
+ const message = await parseErrorMessage(response);
111
+ throw new Error(message);
112
+ }
113
+ return (await response.json());
114
+ }
115
+ /**
116
+ * Close a conversation as an agent
117
+ */
118
+ export async function closeConversation(token, conversationUuid, apiUrl) {
119
+ const API_BASE_URL = apiUrl || DEFAULT_API_BASE_URL;
120
+ const response = await fetch(`${API_BASE_URL}/api/conversations/${conversationUuid}/close`, {
121
+ method: 'POST',
122
+ headers: {
123
+ Accept: 'application/json',
124
+ Authorization: `Bearer ${token}`,
125
+ },
126
+ });
127
+ if (!response.ok) {
128
+ const message = await parseErrorMessage(response);
129
+ throw new Error(message);
130
+ }
131
+ return (await response.json());
132
+ }
133
+ /**
134
+ * Send agent-authored message
135
+ */
136
+ export async function sendAgentMessage(token, conversationUuid, content, apiUrl) {
137
+ const API_BASE_URL = apiUrl || DEFAULT_API_BASE_URL;
138
+ const response = await fetch(`${API_BASE_URL}/api/conversations/${conversationUuid}/messages/agent`, {
139
+ method: 'POST',
140
+ headers: {
141
+ 'Content-Type': 'application/json',
142
+ Accept: 'application/json',
143
+ Authorization: `Bearer ${token}`,
144
+ },
145
+ body: JSON.stringify({ content }),
146
+ });
147
+ if (!response.ok) {
148
+ const message = await parseErrorMessage(response);
149
+ throw new Error(message);
150
+ }
151
+ return (await response.json());
152
+ }
@@ -2,5 +2,6 @@
2
2
  * API Entry Point
3
3
  * Central export for all API services
4
4
  */
5
+ export * from './auth.js';
5
6
  export * from './conversation.js';
6
7
  export * from './message.js';
package/lib/api/index.js CHANGED
@@ -2,5 +2,6 @@
2
2
  * API Entry Point
3
3
  * Central export for all API services
4
4
  */
5
+ export * from './auth.js';
5
6
  export * from './conversation.js';
6
7
  export * from './message.js';
@@ -1,10 +1,11 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  import { useState, useEffect, useRef } from 'react';
3
3
  import { createPortal } from 'react-dom';
4
4
  import { Send, X, MessageCircle, Bot, ThumbsUp, ThumbsDown } from 'lucide-react';
5
5
  import { generateId, formatTimestamp } from '../utils/index.js';
6
6
  import { VezloFooter } from './ui/VezloFooter.js';
7
7
  import { createConversation, createUserMessage, generateAIResponse } from '../api/index.js';
8
+ import { subscribeToConversations } from '../services/conversationRealtime.js';
8
9
  import { THEME } from '../config/theme.js';
9
10
  export function Widget({ config, isPlayground = false, onOpen, onClose, onMessage, onError, useShadowRoot = false, }) {
10
11
  // Use defaultOpen from config, fallback to isPlayground for backward compatibility
@@ -21,6 +22,9 @@ export function Widget({ config, isPlayground = false, onOpen, onClose, onMessag
21
22
  const [messageFeedback, setMessageFeedback] = useState({});
22
23
  const [streamingMessage, setStreamingMessage] = useState('');
23
24
  const [conversationUuid, setConversationUuid] = useState(null);
25
+ const [companyUuid, setCompanyUuid] = useState(null);
26
+ const [agentJoined, setAgentJoined] = useState(false);
27
+ const [conversationClosed, setConversationClosed] = useState(false);
24
28
  const [isCreatingConversation, setIsCreatingConversation] = useState(false);
25
29
  const messagesEndRef = useRef(null);
26
30
  const hostRef = useRef(null);
@@ -67,7 +71,10 @@ export function Widget({ config, isPlayground = false, onOpen, onClose, onMessag
67
71
  title: 'New Chat',
68
72
  }, config.apiUrl);
69
73
  setConversationUuid(conversation.uuid);
70
- console.log('[Widget] Conversation created:', conversation.uuid);
74
+ setCompanyUuid(conversation.company_uuid);
75
+ setAgentJoined(false);
76
+ setConversationClosed(false);
77
+ console.log('[Widget] Conversation created:', conversation.uuid, 'Company:', conversation.company_uuid);
71
78
  // Add welcome message after conversation is created
72
79
  const welcomeMsg = {
73
80
  id: generateId(),
@@ -98,12 +105,45 @@ export function Widget({ config, isPlayground = false, onOpen, onClose, onMessag
98
105
  };
99
106
  initializeConversation();
100
107
  }, [isOpen, conversationUuid, isCreatingConversation, config.welcomeMessage, onMessage, onError]);
108
+ // Subscribe to realtime updates for agent messages
109
+ useEffect(() => {
110
+ if (!companyUuid || !conversationUuid) {
111
+ return;
112
+ }
113
+ const handleMessageCreated = (payload) => {
114
+ if (payload.conversation_uuid !== conversationUuid) {
115
+ return;
116
+ }
117
+ const status = payload.conversation_update?.status;
118
+ if (status === 'in_progress') {
119
+ setAgentJoined(true);
120
+ setConversationClosed(false);
121
+ }
122
+ else if (status === 'closed') {
123
+ setAgentJoined(false);
124
+ setConversationClosed(true);
125
+ }
126
+ if (payload.message.type !== 'system' && payload.message.type !== 'agent') {
127
+ return;
128
+ }
129
+ const newMessage = {
130
+ id: payload.message.uuid,
131
+ content: payload.message.content,
132
+ role: payload.message.type === 'agent' ? 'assistant' : 'system',
133
+ timestamp: new Date(payload.message.created_at),
134
+ };
135
+ setMessages(prev => [...prev, newMessage]);
136
+ };
137
+ const cleanup = subscribeToConversations(companyUuid, handleMessageCreated, () => { }, // No need to handle conversation:created in widget
138
+ config.supabaseUrl, config.supabaseAnonKey);
139
+ return cleanup;
140
+ }, [companyUuid, conversationUuid, config.supabaseUrl, config.supabaseAnonKey]);
101
141
  useEffect(() => {
102
142
  // Scroll to bottom when messages change or when streaming
103
143
  messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
104
144
  }, [messages, streamingMessage]);
105
145
  const handleSendMessage = async () => {
106
- if (!input.trim() || isLoading || !conversationUuid)
146
+ if (!input.trim() || isLoading || !conversationUuid || conversationClosed)
107
147
  return;
108
148
  const userMessageContent = input;
109
149
  const userMessage = {
@@ -124,35 +164,42 @@ export function Widget({ config, isPlayground = false, onOpen, onClose, onMessag
124
164
  console.log('[Widget] User message created:', userMessageResponse.uuid);
125
165
  // Update the user message with the actual UUID from server
126
166
  setMessages((prev) => prev.map((msg) => msg.id === userMessage.id ? { ...msg, id: userMessageResponse.uuid } : msg));
127
- // Step 2: Generate AI response
128
- // Keep loading indicator visible until AI response is received
129
- const aiResponse = await generateAIResponse(userMessageResponse.uuid, config.apiUrl);
130
- console.log('[Widget] AI response received:', aiResponse.uuid);
131
- // Hide loading indicator now that we have the response
132
- setIsLoading(false);
133
- // Stream the AI response character by character
134
- const responseContent = aiResponse.content;
135
- setStreamingMessage('');
136
- let currentText = '';
137
- const streamInterval = setInterval(() => {
138
- if (currentText.length < responseContent.length) {
139
- currentText += responseContent[currentText.length];
140
- setStreamingMessage(currentText);
141
- }
142
- else {
143
- clearInterval(streamInterval);
144
- // Add the complete message to messages array
145
- const assistantMessage = {
146
- id: aiResponse.uuid,
147
- content: responseContent,
148
- role: 'assistant',
149
- timestamp: new Date(aiResponse.created_at),
150
- };
151
- setMessages((prev) => [...prev, assistantMessage]);
152
- onMessage?.(assistantMessage);
153
- setStreamingMessage('');
154
- }
155
- }, 15); // 15ms delay between characters for smooth streaming
167
+ // Step 2: Generate AI response (only if agent hasn't joined)
168
+ if (!agentJoined) {
169
+ // Keep loading indicator visible until AI response is received
170
+ const aiResponse = await generateAIResponse(userMessageResponse.uuid, config.apiUrl);
171
+ console.log('[Widget] AI response received:', aiResponse.uuid);
172
+ // Hide loading indicator now that we have the response
173
+ setIsLoading(false);
174
+ // Stream the AI response character by character
175
+ const responseContent = aiResponse.content;
176
+ setStreamingMessage('');
177
+ let currentText = '';
178
+ const streamInterval = setInterval(() => {
179
+ if (currentText.length < responseContent.length) {
180
+ currentText += responseContent[currentText.length];
181
+ setStreamingMessage(currentText);
182
+ }
183
+ else {
184
+ clearInterval(streamInterval);
185
+ // Add the complete message to messages array
186
+ const assistantMessage = {
187
+ id: aiResponse.uuid,
188
+ content: responseContent,
189
+ role: 'assistant',
190
+ timestamp: new Date(aiResponse.created_at),
191
+ };
192
+ setMessages((prev) => [...prev, assistantMessage]);
193
+ onMessage?.(assistantMessage);
194
+ setStreamingMessage('');
195
+ }
196
+ }, 15); // 15ms delay between characters for smooth streaming
197
+ }
198
+ else {
199
+ // Agent has joined, don't generate AI response
200
+ setIsLoading(false);
201
+ console.log('[Widget] Skipping AI response - agent has joined');
202
+ }
156
203
  }
157
204
  catch (error) {
158
205
  console.error('[Widget] Error sending message:', error);
@@ -169,7 +216,22 @@ export function Widget({ config, isPlayground = false, onOpen, onClose, onMessag
169
216
  onMessage?.(errorMessage);
170
217
  }
171
218
  };
219
+ const handleStartNewChat = () => {
220
+ if (isCreatingConversation)
221
+ return;
222
+ setMessages([]);
223
+ setStreamingMessage('');
224
+ setIsLoading(false);
225
+ setMessageFeedback({});
226
+ setAgentJoined(false);
227
+ setConversationClosed(false);
228
+ setConversationUuid(null);
229
+ setCompanyUuid(null);
230
+ setInput('');
231
+ };
172
232
  const handleKeyPress = (e) => {
233
+ if (conversationClosed)
234
+ return;
173
235
  if (e.key === 'Enter' && !e.shiftKey) {
174
236
  e.preventDefault();
175
237
  handleSendMessage();
@@ -260,21 +322,29 @@ export function Widget({ config, isPlayground = false, onOpen, onClose, onMessag
260
322
  pointerEvents: 'auto',
261
323
  width: (config.size && config.size.width) ? config.size.width : 420,
262
324
  height: (config.size && config.size.height) ? config.size.height : 600
263
- }, children: [_jsxs("div", { className: "text-white p-4 flex justify-between items-center relative overflow-hidden", style: { background: `linear-gradient(to right, ${config.themeColor || THEME.primary.hex}, ${config.themeColor || THEME.primary.hex}dd, ${config.themeColor || THEME.primary.hex}bb)`, color: '#fff' }, children: [_jsxs("div", { className: "absolute inset-0 opacity-10", children: [_jsx("div", { className: "absolute top-0 left-0 w-full h-full bg-gradient-to-br from-white/20 to-transparent" }), _jsx("div", { className: "absolute bottom-0 right-0 w-32 h-32 bg-white/10 rounded-full -translate-y-8 translate-x-8" })] }), _jsxs("div", { className: "flex items-center gap-3 relative z-10", children: [_jsx("div", { className: "w-12 h-12 bg-white/20 rounded-2xl flex items-center justify-center backdrop-blur-sm flex-shrink-0", children: _jsx(Bot, { className: "w-6 h-6 text-white" }) }), _jsxs("div", { className: "min-w-0 flex-1", children: [_jsx("h3", { className: "font-semibold text-lg leading-tight truncate", children: config.title }), _jsxs("div", { className: "flex items-center gap-1.5 mt-0.5", children: [_jsx("div", { className: "w-2 h-2 bg-green-400 rounded-full animate-pulse flex-shrink-0" }), _jsxs("p", { className: "text-xs text-white/90 truncate", children: ["Online \u2022 ", config.subtitle] })] })] })] }), _jsx("button", { onClick: handleCloseWidget, className: "hover:bg-white/20 rounded-lg p-2 transition-all duration-200 hover:scale-110 relative z-10", children: _jsx(X, { className: "w-5 h-5" }) })] }), _jsxs("div", { className: "flex-1 overflow-y-auto p-4 space-y-4 bg-gradient-to-b from-gray-50 to-white", children: [messages.map((message, index) => (_jsxs("div", { className: `flex ${message.role === 'user' ? 'justify-end' : 'justify-start'} animate-fadeIn`, style: { animationDelay: `${index * 0.1}s` }, children: [message.role === 'assistant' && (_jsx("div", { className: "w-8 h-8 bg-emerald-50 rounded-full flex items-center justify-center flex-shrink-0 mt-1 mr-2 border border-emerald-100", children: _jsx(Bot, { className: "w-4 h-4 text-emerald-600" }) })), _jsxs("div", { className: "flex flex-col max-w-[75%]", children: [_jsx("div", { className: `rounded-2xl px-4 py-3 shadow-sm transition-all duration-200 hover:shadow-md ${message.role === 'user'
264
- ? 'text-white'
265
- : 'bg-white text-gray-900 border border-gray-200'}`, style: {
266
- backgroundColor: message.role === 'user' ? (config.themeColor || THEME.primary.hex) : undefined,
267
- boxShadow: message.role === 'user'
268
- ? `0 4px 12px ${(config.themeColor || THEME.primary.hex)}4D` // 4D is ~30% opacity
269
- : '0 2px 8px rgba(0, 0, 0, 0.1)'
270
- }, children: _jsx("p", { className: "text-sm whitespace-pre-wrap break-words leading-relaxed", children: message.content }) }), _jsxs("div", { className: "flex items-center justify-between mt-1", children: [_jsx("p", { className: `text-xs ${message.role === 'user' ? 'text-emerald-100' : 'text-gray-500'}`, children: formatTimestamp(message.timestamp) }), message.role === 'assistant' && (_jsxs("div", { className: "flex items-center gap-1 ml-2", children: [_jsx("button", { onClick: () => handleFeedback(message.id, 'like'), className: `p-1 rounded transition-all duration-200 hover:scale-110 cursor-pointer ${messageFeedback[message.id] === 'like'
271
- ? 'text-green-600'
272
- : 'text-gray-400 hover:text-green-600'}`, children: _jsx(ThumbsUp, { className: `w-4 h-4 ${messageFeedback[message.id] === 'like' ? 'fill-current' : ''}` }) }), _jsx("button", { onClick: () => handleFeedback(message.id, 'dislike'), className: `p-1 rounded transition-all duration-200 hover:scale-110 cursor-pointer ${messageFeedback[message.id] === 'dislike'
273
- ? 'text-red-600'
274
- : 'text-gray-400 hover:text-red-600'}`, children: _jsx(ThumbsDown, { className: `w-4 h-4 ${messageFeedback[message.id] === 'dislike' ? 'fill-current' : ''}` }) })] }))] })] })] }, message.id))), streamingMessage && (_jsxs("div", { className: "flex justify-start animate-fadeIn", children: [_jsx("div", { className: "w-8 h-8 bg-emerald-50 rounded-full flex items-center justify-center flex-shrink-0 mt-1 mr-2 border border-emerald-100", children: _jsx(Bot, { className: "w-4 h-4 text-emerald-600" }) }), _jsx("div", { className: "flex flex-col max-w-[75%]", children: _jsx("div", { className: "bg-white text-gray-900 border border-gray-200 rounded-2xl px-4 py-3 shadow-sm", children: _jsxs("p", { className: "text-sm whitespace-pre-wrap break-words leading-relaxed", style: { color: '#111827' }, children: [streamingMessage, _jsx("span", { style: { display: 'inline-block', animation: 'vezloCaretBlink 1s steps(1, end) infinite' }, children: "|" })] }) }) })] })), isLoading && (_jsx("div", { className: "flex justify-start animate-fadeIn", children: _jsx("div", { className: "bg-white border border-gray-200 rounded-2xl px-4 py-3 flex items-center gap-3 shadow-sm", children: _jsxs("div", { className: "flex gap-1", style: { display: 'flex', gap: '4px' }, children: [_jsx("span", { style: { width: 8, height: 8, borderRadius: 9999, backgroundColor: config.themeColor || THEME.primary.hex, display: 'inline-block', animation: 'vezloDotPulse 1s infinite ease-in-out', animationDelay: '0s' } }), _jsx("span", { style: { width: 8, height: 8, borderRadius: 9999, backgroundColor: config.themeColor || THEME.primary.hex, display: 'inline-block', animation: 'vezloDotPulse 1s infinite ease-in-out', animationDelay: '0.15s' } }), _jsx("span", { style: { width: 8, height: 8, borderRadius: 9999, backgroundColor: config.themeColor || THEME.primary.hex, display: 'inline-block', animation: 'vezloDotPulse 1s infinite ease-in-out', animationDelay: '0.3s' } })] }) }) })), _jsx("div", { ref: messagesEndRef })] }), _jsx("div", { className: "border-t border-gray-200 p-4 bg-white", children: _jsxs("div", { className: "flex gap-3", children: [_jsx("input", { type: "text", value: input, onChange: (e) => setInput(e.target.value), onKeyPress: handleKeyPress, placeholder: config.placeholder, disabled: isLoading, className: "flex-1 px-4 py-3 border border-gray-300 rounded-2xl focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:border-transparent disabled:bg-gray-100 disabled:cursor-not-allowed text-sm transition-all duration-200 placeholder:text-gray-400" }), _jsx("button", { onClick: handleSendMessage, disabled: !input.trim() || isLoading, className: "text-white px-4 py-3 rounded-2xl transition-all duration-200 disabled:bg-gray-300 disabled:cursor-not-allowed flex items-center justify-center shadow-lg hover:shadow-xl disabled:shadow-none transform hover:scale-105 disabled:scale-100 min-w-[48px]", style: {
325
+ }, children: [_jsxs("div", { className: "text-white p-4 flex justify-between items-center relative overflow-hidden", style: { background: `linear-gradient(to right, ${config.themeColor || THEME.primary.hex}, ${config.themeColor || THEME.primary.hex}dd, ${config.themeColor || THEME.primary.hex}bb)`, color: '#fff' }, children: [_jsxs("div", { className: "absolute inset-0 opacity-10", children: [_jsx("div", { className: "absolute top-0 left-0 w-full h-full bg-gradient-to-br from-white/20 to-transparent" }), _jsx("div", { className: "absolute bottom-0 right-0 w-32 h-32 bg-white/10 rounded-full -translate-y-8 translate-x-8" })] }), _jsxs("div", { className: "flex items-center gap-3 relative z-10", children: [_jsx("div", { className: "w-12 h-12 bg-white/20 rounded-2xl flex items-center justify-center backdrop-blur-sm flex-shrink-0", children: _jsx(Bot, { className: "w-6 h-6 text-white" }) }), _jsxs("div", { className: "min-w-0 flex-1", children: [_jsx("h3", { className: "font-semibold text-lg leading-tight truncate", children: config.title }), _jsxs("div", { className: "flex items-center gap-1.5 mt-0.5", children: [_jsx("div", { className: "w-2 h-2 bg-green-400 rounded-full animate-pulse flex-shrink-0" }), _jsxs("p", { className: "text-xs text-white/90 truncate", children: ["Online \u2022 ", config.subtitle] })] })] })] }), _jsx("button", { onClick: handleCloseWidget, className: "hover:bg-white/20 rounded-lg p-2 transition-all duration-200 hover:scale-110 relative z-10", children: _jsx(X, { className: "w-5 h-5" }) })] }), _jsxs("div", { className: "flex-1 overflow-y-auto p-4 space-y-4 bg-gradient-to-b from-gray-50 to-white", children: [messages.map((message, index) => (_jsx("div", { className: `flex ${message.role === 'system'
326
+ ? 'justify-center'
327
+ : message.role === 'user'
328
+ ? 'justify-end'
329
+ : 'justify-start'} animate-fadeIn`, style: { animationDelay: `${index * 0.1}s` }, children: message.role === 'system' ? (_jsx("div", { className: `text-xs px-4 py-2 rounded-full border ${message.content.toLowerCase().includes('closed')
330
+ ? 'bg-red-50 text-red-700 border-red-200'
331
+ : 'bg-blue-50 text-blue-700 border-blue-200'}`, children: _jsxs("div", { className: "flex items-center justify-between gap-3", children: [_jsx("span", { children: message.content }), _jsx("span", { className: "text-xs opacity-70", children: formatTimestamp(message.timestamp) })] }) })) : (_jsxs(_Fragment, { children: [message.role === 'assistant' && (_jsx("div", { className: "w-8 h-8 bg-emerald-50 rounded-full flex items-center justify-center flex-shrink-0 mt-1 mr-2 border border-emerald-100", children: _jsx(Bot, { className: "w-4 h-4 text-emerald-600" }) })), _jsxs("div", { className: "flex flex-col max-w-[75%]", children: [_jsx("div", { className: `rounded-2xl px-4 py-3 shadow-sm transition-all duration-200 hover:shadow-md ${message.role === 'user'
332
+ ? 'text-white'
333
+ : 'bg-white text-gray-900 border border-gray-200'}`, style: {
334
+ backgroundColor: message.role === 'user' ? (config.themeColor || THEME.primary.hex) : undefined,
335
+ boxShadow: message.role === 'user'
336
+ ? `0 4px 12px ${(config.themeColor || THEME.primary.hex)}4D` // 4D is ~30% opacity
337
+ : '0 2px 8px rgba(0, 0, 0, 0.1)'
338
+ }, children: _jsx("p", { className: "text-sm whitespace-pre-wrap break-words leading-relaxed", children: message.content }) }), _jsxs("div", { className: "flex items-center justify-between mt-1", children: [_jsx("p", { className: `text-xs ${message.role === 'user' ? 'text-emerald-100' : 'text-gray-500'}`, children: formatTimestamp(message.timestamp) }), message.role === 'assistant' && (_jsxs("div", { className: "flex items-center gap-1 ml-2", children: [_jsx("button", { onClick: () => handleFeedback(message.id, 'like'), className: `p-1 rounded transition-all duration-200 hover:scale-110 cursor-pointer ${messageFeedback[message.id] === 'like'
339
+ ? 'text-green-600'
340
+ : 'text-gray-400 hover:text-green-600'}`, children: _jsx(ThumbsUp, { className: `w-4 h-4 ${messageFeedback[message.id] === 'like' ? 'fill-current' : ''}` }) }), _jsx("button", { onClick: () => handleFeedback(message.id, 'dislike'), className: `p-1 rounded transition-all duration-200 hover:scale-110 cursor-pointer ${messageFeedback[message.id] === 'dislike'
341
+ ? 'text-red-600'
342
+ : 'text-gray-400 hover:text-red-600'}`, children: _jsx(ThumbsDown, { className: `w-4 h-4 ${messageFeedback[message.id] === 'dislike' ? 'fill-current' : ''}` }) })] }))] })] })] })) }, message.id))), streamingMessage && (_jsxs("div", { className: "flex justify-start animate-fadeIn", children: [_jsx("div", { className: "w-8 h-8 bg-emerald-50 rounded-full flex items-center justify-center flex-shrink-0 mt-1 mr-2 border border-emerald-100", children: _jsx(Bot, { className: "w-4 h-4 text-emerald-600" }) }), _jsx("div", { className: "flex flex-col max-w-[75%]", children: _jsx("div", { className: "bg-white text-gray-900 border border-gray-200 rounded-2xl px-4 py-3 shadow-sm", children: _jsxs("p", { className: "text-sm whitespace-pre-wrap break-words leading-relaxed", style: { color: '#111827' }, children: [streamingMessage, _jsx("span", { style: { display: 'inline-block', animation: 'vezloCaretBlink 1s steps(1, end) infinite' }, children: "|" })] }) }) })] })), isLoading && (_jsx("div", { className: "flex justify-start animate-fadeIn", children: _jsx("div", { className: "bg-white border border-gray-200 rounded-2xl px-4 py-3 flex items-center gap-3 shadow-sm", children: _jsxs("div", { className: "flex gap-1", style: { display: 'flex', gap: '4px' }, children: [_jsx("span", { style: { width: 8, height: 8, borderRadius: 9999, backgroundColor: config.themeColor || THEME.primary.hex, display: 'inline-block', animation: 'vezloDotPulse 1s infinite ease-in-out', animationDelay: '0s' } }), _jsx("span", { style: { width: 8, height: 8, borderRadius: 9999, backgroundColor: config.themeColor || THEME.primary.hex, display: 'inline-block', animation: 'vezloDotPulse 1s infinite ease-in-out', animationDelay: '0.15s' } }), _jsx("span", { style: { width: 8, height: 8, borderRadius: 9999, backgroundColor: config.themeColor || THEME.primary.hex, display: 'inline-block', animation: 'vezloDotPulse 1s infinite ease-in-out', animationDelay: '0.3s' } })] }) }) })), _jsx("div", { ref: messagesEndRef })] }), conversationClosed && (_jsx("div", { className: "border-t border-gray-200 p-4 bg-white flex justify-center", children: _jsx("button", { onClick: handleStartNewChat, disabled: isCreatingConversation, className: "px-6 py-3 bg-gradient-to-r from-emerald-600 to-emerald-500 text-white text-sm font-medium rounded-lg hover:from-emerald-700 hover:to-emerald-600 transition-all duration-200 shadow-md hover:shadow-lg transform hover:scale-105 disabled:opacity-50 disabled:cursor-not-allowed disabled:transform-none cursor-pointer", children: isCreatingConversation ? 'Starting new chat...' : 'Start New Chat' }) })), !conversationClosed && (_jsx("div", { className: "border-t border-gray-200 p-4 bg-white", children: _jsxs("div", { className: "flex gap-3", children: [_jsx("input", { type: "text", value: input, onChange: (e) => setInput(e.target.value), onKeyPress: handleKeyPress, placeholder: conversationClosed
343
+ ? 'Conversation closed. Start a new chat to continue.'
344
+ : config.placeholder, disabled: isLoading || conversationClosed, className: "flex-1 px-4 py-3 border border-gray-300 rounded-2xl focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:border-transparent disabled:bg-gray-100 disabled:cursor-not-allowed text-sm transition-all duration-200 placeholder:text-gray-400" }), _jsx("button", { onClick: handleSendMessage, disabled: !input.trim() || isLoading || conversationClosed, className: "text-white px-4 py-3 rounded-2xl transition-all duration-200 disabled:bg-gray-300 disabled:cursor-not-allowed flex items-center justify-center shadow-lg hover:shadow-xl disabled:shadow-none transform hover:scale-105 disabled:scale-100 min-w-[48px]", style: {
275
345
  background: `linear-gradient(to right, ${config.themeColor || THEME.primary.hex}, ${config.themeColor || THEME.primary.hex}dd)`,
276
- opacity: (!input.trim() || isLoading) ? 0.6 : 1
277
- }, children: _jsx(Send, { className: "w-4 h-4" }) })] }) }), _jsx("div", { className: "border-t border-gray-200 px-4 bg-gradient-to-r from-gray-50 to-white", style: { minHeight: 52 }, children: _jsx(VezloFooter, { size: "sm" }) })] }))] }));
346
+ opacity: (!input.trim() || isLoading || conversationClosed) ? 0.6 : 1
347
+ }, children: _jsx(Send, { className: "w-4 h-4" }) })] }) })), _jsx("div", { className: "border-t border-gray-200 px-4 bg-gradient-to-r from-gray-50 to-white", style: { minHeight: 52 }, children: _jsx(VezloFooter, { size: "sm" }) })] }))] }));
278
348
  if (useShadowRoot) {
279
349
  // Ensure host exists in DOM
280
350
  return (_jsx("div", { ref: hostRef, style: { all: 'initial' }, children: shadowReady && shadowMountRef.current ? createPortal(content, shadowMountRef.current) : null }));
@@ -0,0 +1,27 @@
1
+ export interface MessageCreatedPayload {
2
+ conversation_uuid: string;
3
+ message: {
4
+ uuid: string;
5
+ content: string;
6
+ type: 'user' | 'assistant' | 'agent' | 'system';
7
+ author_id: number | null;
8
+ created_at: string;
9
+ };
10
+ conversation_update: {
11
+ message_count: number;
12
+ last_message_at: string;
13
+ joined_at?: string;
14
+ status?: string;
15
+ closed_at?: string;
16
+ };
17
+ }
18
+ export interface ConversationCreatedPayload {
19
+ conversation: {
20
+ uuid: string;
21
+ status: string;
22
+ message_count: number;
23
+ last_message_at: string | null;
24
+ created_at: string;
25
+ };
26
+ }
27
+ export declare function subscribeToConversations(companyUuid: string, onMessageCreated: (payload: MessageCreatedPayload) => void, onConversationCreated: (payload: ConversationCreatedPayload) => void, supabaseUrl?: string, supabaseAnonKey?: string): () => void;
@@ -0,0 +1,68 @@
1
+ import { createClient } from '@supabase/supabase-js';
2
+ let client = null;
3
+ let clientUrl = null;
4
+ let clientKey = null;
5
+ function getClient(supabaseUrl, supabaseAnonKey) {
6
+ if (!supabaseUrl || !supabaseAnonKey) {
7
+ console.error('[ConversationRealtime] Missing supabaseUrl or supabaseAnonKey');
8
+ return null;
9
+ }
10
+ // Recreate the client if credentials change
11
+ if (!client || clientUrl !== supabaseUrl || clientKey !== supabaseAnonKey) {
12
+ try {
13
+ client = createClient(supabaseUrl, supabaseAnonKey, {
14
+ realtime: { params: { eventsPerSecond: 2 } },
15
+ });
16
+ clientUrl = supabaseUrl;
17
+ clientKey = supabaseAnonKey;
18
+ }
19
+ catch (error) {
20
+ console.error('[ConversationRealtime] Failed to initialize client:', error instanceof Error ? error.message : 'Unknown error');
21
+ return null;
22
+ }
23
+ }
24
+ return client;
25
+ }
26
+ export function subscribeToConversations(companyUuid, onMessageCreated, onConversationCreated, supabaseUrl, supabaseAnonKey) {
27
+ try {
28
+ const realtimeClient = getClient(supabaseUrl, supabaseAnonKey);
29
+ if (!realtimeClient) {
30
+ console.error('[ConversationRealtime] Client not available');
31
+ return () => { };
32
+ }
33
+ const channelName = `company:${companyUuid}:conversations`;
34
+ const channel = realtimeClient.channel(channelName, {
35
+ config: {
36
+ broadcast: { self: true, ack: false }
37
+ }
38
+ });
39
+ channel.on('broadcast', { event: 'message:created' }, ({ payload }) => {
40
+ console.info('[Realtime] Received update:', payload);
41
+ onMessageCreated(payload);
42
+ });
43
+ channel.on('broadcast', { event: 'conversation:created' }, ({ payload }) => {
44
+ console.info('[Realtime] Received update:', payload);
45
+ onConversationCreated(payload);
46
+ });
47
+ channel.subscribe((status, err) => {
48
+ if (err) {
49
+ console.error('[Realtime] Subscription failed:', err.message || err);
50
+ }
51
+ else if (status === 'SUBSCRIBED') {
52
+ console.info(`[Realtime] Subscribed to channel: ${channelName}`);
53
+ }
54
+ });
55
+ return () => {
56
+ try {
57
+ channel.unsubscribe();
58
+ }
59
+ catch (error) {
60
+ console.error('[Realtime] Unsubscribe error:', error instanceof Error ? error.message : 'Unknown error');
61
+ }
62
+ };
63
+ }
64
+ catch (error) {
65
+ console.error('[Realtime] Failed to setup listener:', error instanceof Error ? error.message : 'Unknown error');
66
+ return () => { };
67
+ }
68
+ }
@@ -14,11 +14,13 @@ export interface WidgetConfig {
14
14
  apiKey: string;
15
15
  themeColor?: string;
16
16
  defaultOpen?: boolean;
17
+ supabaseUrl?: string;
18
+ supabaseAnonKey?: string;
17
19
  }
18
20
  export interface ChatMessage {
19
21
  id: string;
20
22
  content: string;
21
- role: 'user' | 'assistant';
23
+ role: 'user' | 'assistant' | 'system';
22
24
  timestamp: Date;
23
25
  sources?: ChatSource[];
24
26
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@vezlo/assistant-chat",
3
- "version": "1.2.0",
4
- "description": "React component library for AI-powered chat widgets with RAG knowledge base integration and real-time streaming",
3
+ "version": "1.3.0",
4
+ "description": "React component library for AI-powered chat widgets with RAG knowledge base integration, realtime updates, and human agent support",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "module": "lib/index.js",
@@ -31,7 +31,9 @@
31
31
  "prepack": "npm run build"
32
32
  },
33
33
  "dependencies": {
34
+ "@supabase/supabase-js": "^2.84.0",
34
35
  "clsx": "^2.1.1",
36
+ "date-fns": "^4.1.0",
35
37
  "lucide-react": "^0.544.0",
36
38
  "react-router-dom": "^7.9.3",
37
39
  "tailwindcss": "^4.1.14"