@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/README.md +9 -1
- package/dist/chat-widget.css +270 -0
- package/dist/chat-widget.js +209 -134
- package/package.json +2 -2
- package/src/components/ChatWidget.js +1 -0
- package/src/components/InputForm.js +199 -5
- package/src/components/Message.js +36 -1
- package/src/hooks/useChat.js +71 -20
- package/src/utils/api.js +5 -4
- package/src/utils/config.js +1 -0
- package/src/utils/helpers.js +23 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@makemore/agent-frontend",
|
|
3
|
-
"version": "2.
|
|
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
|
},
|
|
@@ -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
|
-
|
|
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>
|
package/src/hooks/useChat.js
CHANGED
|
@@ -205,45 +205,95 @@ export function useChat(config, api, storage) {
|
|
|
205
205
|
};
|
|
206
206
|
}, [config]);
|
|
207
207
|
|
|
208
|
-
const sendMessage = useCallback(async (content,
|
|
208
|
+
const sendMessage = useCallback(async (content, optionsOrFiles = {}, legacyOptions = {}) => {
|
|
209
209
|
if (!content.trim() || isLoading) return;
|
|
210
210
|
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
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
|
-
|
|
238
|
-
|
|
239
|
-
|
|
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}`,
|
|
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
|
-
|
|
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
|
};
|
package/src/utils/config.js
CHANGED
package/src/utils/helpers.js
CHANGED
|
@@ -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
|
+
}
|