@makemore/agent-frontend 2.0.0 → 2.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@makemore/agent-frontend",
3
- "version": "2.0.0",
3
+ "version": "2.2.0",
4
4
  "description": "A lightweight chat widget for AI agents built with Preact. Embed conversational AI into any website with a single script tag.",
5
5
  "type": "module",
6
6
  "main": "dist/chat-widget.js",
@@ -17,7 +17,7 @@
17
17
  "build": "esbuild src/index.js --bundle --minify --format=iife --global-name=ChatWidgetModule --outfile=dist/chat-widget.js && npm run copy",
18
18
  "build:dev": "esbuild src/index.js --bundle --format=iife --global-name=ChatWidgetModule --outfile=dist/chat-widget.js --sourcemap && npm run copy",
19
19
  "watch": "node watch.js",
20
- "copy": "cp dist/chat-widget.js ../django_agent_studio/static/agent-frontend/chat-widget.js && echo 'Copied to django_agent_studio'",
20
+ "copy": "cp -f dist/chat-widget.js ../django_agent_studio/static/agent-frontend/chat-widget.js 2>/dev/null || true && echo 'Copied to django_agent_studio'",
21
21
  "prepublishOnly": "npm run build",
22
22
  "serve": "python -m http.server 8080"
23
23
  },
@@ -252,6 +252,7 @@ export function ChatWidget({ config, onStateChange, markdownParser, apiRef }) {
252
252
  isLoading=${chat.isLoading}
253
253
  placeholder=${config.placeholder}
254
254
  primaryColor=${config.primaryColor}
255
+ enableVoice=${config.enableVoice}
255
256
  />
256
257
  </div>
257
258
  `;
@@ -1,17 +1,29 @@
1
1
  /**
2
- * InputForm component - message input and send button
2
+ * InputForm component - message input, voice input, and send button
3
3
  * Supports multiline input with Shift+Enter for newlines, Enter to send
4
4
  * Shows a stop button when loading that can cancel the current run
5
+ * Voice input uses Web Speech API for speech-to-text
5
6
  */
6
7
 
7
8
  import { html } from 'htm/preact';
8
9
  import { useState, useRef, useEffect } from 'preact/hooks';
9
- import { escapeHtml } from '../utils/helpers.js';
10
+ import { escapeHtml, formatFileSize, getFileTypeIcon } from '../utils/helpers.js';
10
11
 
11
- export function InputForm({ onSend, onCancel, isLoading, placeholder, primaryColor }) {
12
+ // Check if Web Speech API is available
13
+ const SpeechRecognition = typeof window !== 'undefined'
14
+ ? (window.SpeechRecognition || window.webkitSpeechRecognition)
15
+ : null;
16
+
17
+ export function InputForm({ onSend, onCancel, isLoading, placeholder, primaryColor, enableVoice = true, enableFiles = true }) {
12
18
  const [value, setValue] = useState('');
19
+ const [files, setFiles] = useState([]);
13
20
  const [isHovering, setIsHovering] = useState(false);
21
+ const [isRecording, setIsRecording] = useState(false);
22
+ const [voiceSupported] = useState(() => !!SpeechRecognition);
14
23
  const textareaRef = useRef(null);
24
+ const fileInputRef = useRef(null);
25
+ const recognitionRef = useRef(null);
26
+ const shouldKeepListeningRef = useRef(false); // Track if we should keep listening
15
27
 
16
28
  // Focus input when not loading
17
29
  useEffect(() => {
@@ -28,15 +40,48 @@ export function InputForm({ onSend, onCancel, isLoading, placeholder, primaryCol
28
40
  }
29
41
  }, [value]);
30
42
 
43
+ // Cleanup recognition on unmount
44
+ useEffect(() => {
45
+ return () => {
46
+ shouldKeepListeningRef.current = false;
47
+ if (recognitionRef.current) {
48
+ recognitionRef.current.abort();
49
+ }
50
+ };
51
+ }, []);
52
+
31
53
  const handleSubmit = (e) => {
32
54
  e.preventDefault();
33
- if (value.trim() && !isLoading) {
34
- onSend(value);
55
+ if ((value.trim() || files.length > 0) && !isLoading) {
56
+ onSend(value, files);
35
57
  setValue('');
58
+ setFiles([]);
36
59
  // Reset height after sending
37
60
  if (textareaRef.current) {
38
61
  textareaRef.current.style.height = 'auto';
39
62
  }
63
+ // Reset file input
64
+ if (fileInputRef.current) {
65
+ fileInputRef.current.value = '';
66
+ }
67
+ }
68
+ };
69
+
70
+ const handleFileSelect = (e) => {
71
+ const selectedFiles = Array.from(e.target.files || []);
72
+ if (selectedFiles.length > 0) {
73
+ setFiles(prev => [...prev, ...selectedFiles]);
74
+ }
75
+ };
76
+
77
+ const removeFile = (index) => {
78
+ setFiles(prev => prev.filter((_, i) => i !== index));
79
+ };
80
+
81
+ const triggerFileInput = (e) => {
82
+ e.preventDefault();
83
+ if (fileInputRef.current && !isLoading) {
84
+ fileInputRef.current.click();
40
85
  }
41
86
  };
42
87
 
@@ -56,6 +101,89 @@ export function InputForm({ onSend, onCancel, isLoading, placeholder, primaryCol
56
101
  }
57
102
  };
58
103
 
104
+ const startRecording = () => {
105
+ if (!SpeechRecognition || isLoading) return;
106
+
107
+ shouldKeepListeningRef.current = true;
108
+
109
+ const recognition = new SpeechRecognition();
110
+ recognition.continuous = true; // Keep listening until manually stopped
111
+ recognition.interimResults = true;
112
+ recognition.lang = navigator.language || 'en-US';
113
+
114
+ let finalTranscript = value; // Start with existing text
115
+ let interimTranscript = '';
116
+
117
+ recognition.onstart = () => {
118
+ setIsRecording(true);
119
+ };
120
+
121
+ recognition.onresult = (event) => {
122
+ interimTranscript = '';
123
+ for (let i = event.resultIndex; i < event.results.length; i++) {
124
+ const transcript = event.results[i][0].transcript;
125
+ if (event.results[i].isFinal) {
126
+ finalTranscript += (finalTranscript ? ' ' : '') + transcript;
127
+ } else {
128
+ interimTranscript += transcript;
129
+ }
130
+ }
131
+ // Show both final and interim results
132
+ setValue(finalTranscript + (interimTranscript ? ' ' + interimTranscript : ''));
133
+ };
134
+
135
+ recognition.onerror = (event) => {
136
+ // Don't stop on "no-speech" or "aborted" - just keep listening
137
+ if (event.error === 'no-speech' || event.error === 'aborted') {
138
+ console.log('[ChatWidget] Speech recognition:', event.error, '- continuing...');
139
+ return;
140
+ }
141
+ console.warn('[ChatWidget] Speech recognition error:', event.error);
142
+ shouldKeepListeningRef.current = false;
143
+ setIsRecording(false);
144
+ // Keep whatever text we have
145
+ setValue(finalTranscript || value);
146
+ };
147
+
148
+ recognition.onend = () => {
149
+ // If we should keep listening, restart (handles browser auto-stop after silence)
150
+ if (shouldKeepListeningRef.current) {
151
+ console.log('[ChatWidget] Recognition paused, restarting...');
152
+ try {
153
+ recognition.start();
154
+ return;
155
+ } catch (e) {
156
+ console.warn('[ChatWidget] Could not restart recognition:', e);
157
+ }
158
+ }
159
+ setIsRecording(false);
160
+ // Ensure we have the final transcript
161
+ if (finalTranscript) {
162
+ setValue(finalTranscript);
163
+ }
164
+ recognitionRef.current = null;
165
+ };
166
+
167
+ recognitionRef.current = recognition;
168
+ recognition.start();
169
+ };
170
+
171
+ const stopRecording = () => {
172
+ shouldKeepListeningRef.current = false; // Signal to not restart
173
+ if (recognitionRef.current) {
174
+ recognitionRef.current.stop();
175
+ }
176
+ };
177
+
178
+ const toggleRecording = (e) => {
179
+ e.preventDefault();
180
+ if (isRecording) {
181
+ stopRecording();
182
+ } else {
183
+ startRecording();
184
+ }
185
+ };
186
+
59
187
  // Stop icon (square)
60
188
  const stopIcon = html`
61
189
  <svg width="14" height="14" viewBox="0 0 14 14" fill="currentColor">
@@ -63,8 +191,52 @@ export function InputForm({ onSend, onCancel, isLoading, placeholder, primaryCol
63
191
  </svg>
64
192
  `;
65
193
 
194
+ // Microphone icon
195
+ const micIcon = html`
196
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
197
+ <path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"></path>
198
+ <path d="M19 10v2a7 7 0 0 1-14 0v-2"></path>
199
+ <line x1="12" y1="19" x2="12" y2="23"></line>
200
+ <line x1="8" y1="23" x2="16" y2="23"></line>
201
+ </svg>
202
+ `;
203
+
204
+ // Paperclip/attach icon
205
+ const attachIcon = html`
206
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
207
+ <path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"></path>
208
+ </svg>
209
+ `;
210
+
211
+ const showVoiceButton = enableVoice && voiceSupported;
212
+ const showAttachButton = enableFiles;
213
+
66
214
  return html`
67
215
  <form class="cw-input-form" onSubmit=${handleSubmit}>
216
+ <input
217
+ type="file"
218
+ ref=${fileInputRef}
219
+ style="display: none"
220
+ multiple
221
+ onChange=${handleFileSelect}
222
+ />
223
+ ${files.length > 0 && html`
224
+ <div class="cw-file-chips">
225
+ ${files.map((file, index) => html`
226
+ <div class="cw-file-chip" key=${index}>
227
+ <span class="cw-file-chip-icon">${getFileTypeIcon(file.type)}</span>
228
+ <span class="cw-file-chip-name" title=${file.name}>${file.name.length > 20 ? file.name.substring(0, 17) + '...' : file.name}</span>
229
+ <span class="cw-file-chip-size">(${formatFileSize(file.size)})</span>
230
+ <button
231
+ type="button"
232
+ class="cw-file-chip-remove"
233
+ onClick=${() => removeFile(index)}
234
+ title="Remove file"
235
+ >×</button>
236
+ </div>
237
+ `)}
238
+ </div>
239
+ `}
68
240
  <textarea
69
241
  ref=${textareaRef}
70
242
  class="cw-input"
@@ -75,6 +247,28 @@ export function InputForm({ onSend, onCancel, isLoading, placeholder, primaryCol
75
247
  disabled=${isLoading}
76
248
  rows="1"
77
249
  />
250
+ ${showAttachButton && html`
251
+ <button
252
+ type="button"
253
+ class="cw-attach-btn"
254
+ onClick=${triggerFileInput}
255
+ disabled=${isLoading}
256
+ title="Attach files"
257
+ >
258
+ ${attachIcon}
259
+ </button>
260
+ `}
261
+ ${showVoiceButton && html`
262
+ <button
263
+ type="button"
264
+ class=${`cw-voice-btn ${isRecording ? 'cw-voice-btn-recording' : ''}`}
265
+ onClick=${toggleRecording}
266
+ disabled=${isLoading}
267
+ title=${isRecording ? 'Stop recording' : 'Voice input'}
268
+ >
269
+ ${micIcon}
270
+ </button>
271
+ `}
78
272
  <button
79
273
  type=${isLoading ? 'button' : 'submit'}
80
274
  class=${`cw-send-btn ${isLoading ? 'cw-send-btn-loading' : ''} ${isLoading && isHovering ? 'cw-send-btn-stop' : ''}`}
@@ -4,7 +4,7 @@
4
4
 
5
5
  import { html } from 'htm/preact';
6
6
  import { useState } from 'preact/hooks';
7
- import { escapeHtml, parseMarkdown } from '../utils/helpers.js';
7
+ import { escapeHtml, parseMarkdown, formatFileSize, getFileTypeIcon } from '../utils/helpers.js';
8
8
 
9
9
  // Debug payload viewer component
10
10
  function DebugPayload({ msg, show, onToggle }) {
@@ -105,8 +105,43 @@ export function Message({ msg, debugMode, markdownParser }) {
105
105
  ? parseMarkdown(msg.content, markdownParser)
106
106
  : escapeHtml(msg.content);
107
107
 
108
+ // Check if message has file attachments
109
+ const hasFiles = msg.files && msg.files.length > 0;
110
+
111
+ // Render file attachments
112
+ const renderAttachments = () => {
113
+ if (!hasFiles) return null;
114
+
115
+ return html`
116
+ <div class="cw-message-attachments">
117
+ ${msg.files.map(file => {
118
+ const isImage = file.type && file.type.startsWith('image/');
119
+
120
+ if (isImage) {
121
+ return html`
122
+ <a class="cw-attachment-thumbnail" href=${file.url} target="_blank" title=${file.name}>
123
+ <img src=${file.url} alt=${file.name} />
124
+ </a>
125
+ `;
126
+ }
127
+
128
+ return html`
129
+ <a class="cw-attachment-file" href=${file.url} target="_blank" title=${file.name}>
130
+ <span class="cw-attachment-icon">${getFileTypeIcon(file.type)}</span>
131
+ <span class="cw-attachment-info">
132
+ <span class="cw-attachment-name">${file.name}</span>
133
+ <span class="cw-attachment-size">${formatFileSize(file.size)}</span>
134
+ </span>
135
+ </a>
136
+ `;
137
+ })}
138
+ </div>
139
+ `;
140
+ };
141
+
108
142
  return html`
109
143
  <div class=${rowClasses} style="position: relative;">
144
+ ${renderAttachments()}
110
145
  <div class=${classes} dangerouslySetInnerHTML=${{ __html: content }} />
111
146
  ${debugMode && html`<${DebugPayload} msg=${msg} show=${showPayload} onToggle=${() => setShowPayload(!showPayload)} />`}
112
147
  </div>
@@ -205,45 +205,95 @@ export function useChat(config, api, storage) {
205
205
  };
206
206
  }, [config]);
207
207
 
208
- const sendMessage = useCallback(async (content, options = {}) => {
208
+ const sendMessage = useCallback(async (content, optionsOrFiles = {}, legacyOptions = {}) => {
209
209
  if (!content.trim() || isLoading) return;
210
210
 
211
- const { model, onAssistantMessage } = typeof options === 'function'
212
- ? { onAssistantMessage: options }
213
- : options;
211
+ // Handle multiple call signatures:
212
+ // sendMessage(content, callback) - legacy
213
+ // sendMessage(content, options) - options object
214
+ // sendMessage(content, files, options) - with files array
215
+ let files = [];
216
+ let options = {};
217
+
218
+ if (typeof optionsOrFiles === 'function') {
219
+ options = { onAssistantMessage: optionsOrFiles };
220
+ } else if (Array.isArray(optionsOrFiles)) {
221
+ files = optionsOrFiles;
222
+ options = legacyOptions;
223
+ } else {
224
+ options = optionsOrFiles || {};
225
+ }
226
+
227
+ const { model, onAssistantMessage } = options;
214
228
 
215
229
  setIsLoading(true);
216
230
  setError(null);
217
231
 
232
+ // Build user message with optional file attachments
218
233
  const userMessage = {
219
234
  id: generateId(),
220
235
  role: 'user',
221
236
  content: content.trim(),
222
237
  timestamp: new Date(),
223
238
  type: 'message',
239
+ files: files.length > 0 ? files.map(f => ({
240
+ name: f.name,
241
+ size: f.size,
242
+ type: f.type,
243
+ })) : undefined,
224
244
  };
225
245
  setMessages(prev => [...prev, userMessage]);
226
246
 
227
247
  try {
228
248
  const token = await api.getOrCreateSession();
229
249
 
230
- const requestBody = {
231
- agentKey: config.agentKey,
232
- conversationId: conversationId,
233
- messages: [{ role: 'user', content: content.trim() }],
234
- metadata: { ...config.metadata, journeyType: config.defaultJourneyType },
235
- };
250
+ let fetchOptions;
251
+
252
+ if (files.length > 0) {
253
+ // Use FormData for file uploads
254
+ const formData = new FormData();
255
+ formData.append('agentKey', config.agentKey);
256
+ if (conversationId) {
257
+ formData.append('conversationId', conversationId);
258
+ }
259
+ formData.append('messages', JSON.stringify([{ role: 'user', content: content.trim() }]));
260
+ formData.append('metadata', JSON.stringify({ ...config.metadata, journeyType: config.defaultJourneyType }));
261
+
262
+ if (model) {
263
+ formData.append('model', model);
264
+ }
236
265
 
237
- // Include model if specified
238
- if (model) {
239
- requestBody.model = model;
266
+ // Append each file
267
+ files.forEach(file => {
268
+ formData.append('files', file);
269
+ });
270
+
271
+ // Don't set Content-Type header - browser will set it with boundary
272
+ fetchOptions = api.getFetchOptions({
273
+ method: 'POST',
274
+ body: formData,
275
+ }, token);
276
+ } else {
277
+ // Use JSON for text-only messages
278
+ const requestBody = {
279
+ agentKey: config.agentKey,
280
+ conversationId: conversationId,
281
+ messages: [{ role: 'user', content: content.trim() }],
282
+ metadata: { ...config.metadata, journeyType: config.defaultJourneyType },
283
+ };
284
+
285
+ if (model) {
286
+ requestBody.model = model;
287
+ }
288
+
289
+ fetchOptions = api.getFetchOptions({
290
+ method: 'POST',
291
+ headers: { 'Content-Type': 'application/json' },
292
+ body: JSON.stringify(requestBody),
293
+ }, token);
240
294
  }
241
295
 
242
- const response = await fetch(`${config.backendUrl}${config.apiPaths.runs}`, api.getFetchOptions({
243
- method: 'POST',
244
- headers: { 'Content-Type': 'application/json' },
245
- body: JSON.stringify(requestBody),
246
- }));
296
+ const response = await fetch(`${config.backendUrl}${config.apiPaths.runs}`, fetchOptions);
247
297
 
248
298
  if (!response.ok) {
249
299
  const errorData = await response.json().catch(() => ({}));
@@ -376,7 +426,7 @@ export function useChat(config, api, storage) {
376
426
  const limit = 10;
377
427
  const url = `${config.backendUrl}${config.apiPaths.conversations}${convId}/?limit=${limit}&offset=0`;
378
428
 
379
- const response = await fetch(url, api.getFetchOptions({ method: 'GET' }));
429
+ const response = await fetch(url, api.getFetchOptions({ method: 'GET' }, token));
380
430
 
381
431
  if (response.ok) {
382
432
  const conversation = await response.json();
@@ -403,10 +453,11 @@ export function useChat(config, api, storage) {
403
453
  setLoadingMoreMessages(true);
404
454
 
405
455
  try {
456
+ const token = await api.getOrCreateSession();
406
457
  const limit = 10;
407
458
  const url = `${config.backendUrl}${config.apiPaths.conversations}${conversationId}/?limit=${limit}&offset=${messagesOffset}`;
408
459
 
409
- const response = await fetch(url, api.getFetchOptions({ method: 'GET' }));
460
+ const response = await fetch(url, api.getFetchOptions({ method: 'GET' }, token));
410
461
 
411
462
  if (response.ok) {
412
463
  const conversation = await response.json();
package/src/utils/api.js CHANGED
@@ -12,10 +12,11 @@ export function createApiClient(config, getState, setState) {
12
12
  return 'none';
13
13
  };
14
14
 
15
- const getAuthHeaders = () => {
15
+ const getAuthHeaders = (overrideToken = null) => {
16
16
  const strategy = getAuthStrategy();
17
17
  const headers = {};
18
- const token = config.authToken || getState().authToken;
18
+ // Use override token if provided (fixes race condition with async state updates)
19
+ const token = overrideToken || config.authToken || getState().authToken;
19
20
 
20
21
  if (strategy === 'token' && token) {
21
22
  const headerName = config.authHeader || 'Authorization';
@@ -40,10 +41,10 @@ export function createApiClient(config, getState, setState) {
40
41
  return headers;
41
42
  };
42
43
 
43
- const getFetchOptions = (options = {}) => {
44
+ const getFetchOptions = (options = {}, overrideToken = null) => {
44
45
  const strategy = getAuthStrategy();
45
46
  const fetchOptions = { ...options };
46
- fetchOptions.headers = { ...fetchOptions.headers, ...getAuthHeaders() };
47
+ fetchOptions.headers = { ...fetchOptions.headers, ...getAuthHeaders(overrideToken) };
47
48
  if (strategy === 'session') fetchOptions.credentials = 'include';
48
49
  return fetchOptions;
49
50
  };
@@ -50,6 +50,7 @@ export const DEFAULT_CONFIG = {
50
50
  showVoiceSettings: true,
51
51
  showExpandButton: true,
52
52
  showModelSelector: false,
53
+ enableVoice: true, // Enable voice input (speech-to-text)
53
54
 
54
55
  // Model selection
55
56
  modelKey: 'chat_widget_selected_model',
@@ -81,3 +81,26 @@ export function getCSRFToken(cookieName = 'csrftoken') {
81
81
  return null;
82
82
  }
83
83
 
84
+ // Format file size for display
85
+ export function formatFileSize(bytes) {
86
+ if (bytes === 0) return '0 B';
87
+ const k = 1024;
88
+ const sizes = ['B', 'KB', 'MB', 'GB'];
89
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
90
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
91
+ }
92
+
93
+ // Get file type icon based on mime type
94
+ export function getFileTypeIcon(mimeType) {
95
+ if (!mimeType) return '📄';
96
+ if (mimeType.startsWith('image/')) return '🖼️';
97
+ if (mimeType.startsWith('video/')) return '🎬';
98
+ if (mimeType.startsWith('audio/')) return '🎵';
99
+ if (mimeType.includes('pdf')) return '📕';
100
+ if (mimeType.includes('spreadsheet') || mimeType.includes('excel')) return '📊';
101
+ if (mimeType.includes('document') || mimeType.includes('word')) return '📝';
102
+ if (mimeType.includes('presentation') || mimeType.includes('powerpoint')) return '📽️';
103
+ if (mimeType.includes('zip') || mimeType.includes('compressed')) return '🗜️';
104
+ if (mimeType.includes('text/')) return '📄';
105
+ return '📄';
106
+ }