@makemore/agent-frontend 2.3.0 → 2.7.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.
@@ -3,7 +3,7 @@
3
3
  */
4
4
 
5
5
  import { useState, useCallback, useRef, useEffect } from 'preact/hooks';
6
- import { generateId } from '../utils/helpers.js';
6
+ import { generateId, camelToSnake } from '../utils/helpers.js';
7
7
 
8
8
  export function useChat(config, api, storage) {
9
9
  const [messages, setMessages] = useState([]);
@@ -224,7 +224,7 @@ export function useChat(config, api, storage) {
224
224
  options = optionsOrFiles || {};
225
225
  }
226
226
 
227
- const { model, onAssistantMessage } = options;
227
+ const { model, onAssistantMessage, supersedeFromMessageIndex } = options;
228
228
 
229
229
  setIsLoading(true);
230
230
  setError(null);
@@ -251,13 +251,21 @@ export function useChat(config, api, storage) {
251
251
 
252
252
  if (files.length > 0) {
253
253
  // Use FormData for file uploads
254
+ // Transform keys based on apiCaseStyle config
255
+ const useSnake = config.apiCaseStyle !== 'camel';
256
+ const key = (k) => useSnake ? camelToSnake(k) : k;
257
+
254
258
  const formData = new FormData();
255
- formData.append('agentKey', config.agentKey);
259
+ formData.append(key('agentKey'), config.agentKey);
256
260
  if (conversationId) {
257
- formData.append('conversationId', conversationId);
261
+ formData.append(key('conversationId'), conversationId);
258
262
  }
259
263
  formData.append('messages', JSON.stringify([{ role: 'user', content: content.trim() }]));
260
- formData.append('metadata', JSON.stringify({ ...config.metadata, journeyType: config.defaultJourneyType }));
264
+ formData.append('metadata', JSON.stringify(
265
+ useSnake
266
+ ? { ...config.metadata, journey_type: config.defaultJourneyType }
267
+ : { ...config.metadata, journeyType: config.defaultJourneyType }
268
+ ));
261
269
 
262
270
  if (model) {
263
271
  formData.append('model', model);
@@ -275,16 +283,15 @@ export function useChat(config, api, storage) {
275
283
  }, token);
276
284
  } else {
277
285
  // Use JSON for text-only messages
278
- const requestBody = {
286
+ const requestBody = api.transformRequest({
279
287
  agentKey: config.agentKey,
280
288
  conversationId: conversationId,
281
289
  messages: [{ role: 'user', content: content.trim() }],
282
290
  metadata: { ...config.metadata, journeyType: config.defaultJourneyType },
283
- };
284
-
285
- if (model) {
286
- requestBody.model = model;
287
- }
291
+ ...(model && { model }),
292
+ // Edit/retry support: tell backend to mark old runs as superseded
293
+ ...(supersedeFromMessageIndex !== undefined && { supersedeFromMessageIndex }),
294
+ });
288
295
 
289
296
  fetchOptions = api.getFetchOptions({
290
297
  method: 'POST',
@@ -300,11 +307,11 @@ export function useChat(config, api, storage) {
300
307
  throw new Error(errorData.error || `HTTP ${response.status}`);
301
308
  }
302
309
 
303
- const run = await response.json();
310
+ const rawRun = await response.json();
311
+ const run = api.transformResponse(rawRun);
304
312
  currentRunIdRef.current = run.id;
305
- const runConversationId = run.conversationId || run.conversation_id;
306
- if (!conversationId && runConversationId) {
307
- setConversationId(runConversationId);
313
+ if (!conversationId && run.conversationId) {
314
+ setConversationId(run.conversationId);
308
315
  }
309
316
 
310
317
  await subscribeToEvents(run.id, token, onAssistantMessage);
@@ -364,6 +371,7 @@ export function useChat(config, api, storage) {
364
371
  }, [config.conversationIdKey, storage]);
365
372
 
366
373
  // Map API message format to internal message format with proper types
374
+ // Note: Input is already transformed to camelCase by api.transformResponse()
367
375
  const mapApiMessage = (m) => {
368
376
  const base = {
369
377
  id: generateId(),
@@ -380,15 +388,15 @@ export function useChat(config, api, storage) {
380
388
  type: 'tool_result',
381
389
  metadata: {
382
390
  result: m.content,
383
- toolCallId: m.tool_call_id,
391
+ toolCallId: m.toolCallId,
384
392
  },
385
393
  };
386
394
  }
387
395
 
388
396
  // Assistant messages with tool calls
389
- if (m.role === 'assistant' && m.tool_calls && m.tool_calls.length > 0) {
397
+ if (m.role === 'assistant' && m.toolCalls && m.toolCalls.length > 0) {
390
398
  // Return an array of tool call messages (will be flattened)
391
- return m.tool_calls.map(tc => ({
399
+ return m.toolCalls.map(tc => ({
392
400
  id: generateId(),
393
401
  role: 'assistant',
394
402
  content: `🔧 ${tc.function?.name || tc.name || 'tool'}`,
@@ -429,12 +437,13 @@ export function useChat(config, api, storage) {
429
437
  const response = await fetch(url, api.getFetchOptions({ method: 'GET' }, token));
430
438
 
431
439
  if (response.ok) {
432
- const conversation = await response.json();
440
+ const rawConversation = await response.json();
441
+ const conversation = api.transformResponse(rawConversation);
433
442
  if (conversation.messages) {
434
443
  // Use flatMap to handle tool_calls which return arrays, filter out nulls (empty messages)
435
444
  setMessages(conversation.messages.flatMap(mapApiMessage).filter(Boolean));
436
445
  }
437
- setHasMoreMessages(conversation.has_more || conversation.hasMore || false);
446
+ setHasMoreMessages(conversation.hasMore || false);
438
447
  setMessagesOffset(conversation.messages?.length || 0);
439
448
  } else if (response.status === 404) {
440
449
  setConversationId(null);
@@ -460,14 +469,15 @@ export function useChat(config, api, storage) {
460
469
  const response = await fetch(url, api.getFetchOptions({ method: 'GET' }, token));
461
470
 
462
471
  if (response.ok) {
463
- const conversation = await response.json();
472
+ const rawConversation = await response.json();
473
+ const conversation = api.transformResponse(rawConversation);
464
474
  if (conversation.messages?.length > 0) {
465
475
  // Use flatMap to handle tool_calls which return arrays, filter out nulls (empty messages)
466
476
  const olderMessages = conversation.messages.flatMap(mapApiMessage).filter(Boolean);
467
477
  setMessages(prev => [...olderMessages, ...prev]);
468
478
  // Use original message count for offset, not flattened count
469
479
  setMessagesOffset(prev => prev + conversation.messages.length);
470
- setHasMoreMessages(conversation.has_more || conversation.hasMore || false);
480
+ setHasMoreMessages(conversation.hasMore || false);
471
481
  } else {
472
482
  setHasMoreMessages(false);
473
483
  }
@@ -479,6 +489,69 @@ export function useChat(config, api, storage) {
479
489
  }
480
490
  }, [config, api, conversationId, messagesOffset, loadingMoreMessages, hasMoreMessages]);
481
491
 
492
+ // Edit a message and resend from that point
493
+ // This truncates the conversation to the edited message and resends
494
+ // The backend will mark old runs as superseded so conversation history stays clean
495
+ const editMessage = useCallback(async (messageIndex, newContent, options = {}) => {
496
+ if (isLoading) return;
497
+
498
+ // Find the message to edit
499
+ const messageToEdit = messages[messageIndex];
500
+ if (!messageToEdit || messageToEdit.role !== 'user') return;
501
+
502
+ // Truncate messages to just before this message
503
+ const truncatedMessages = messages.slice(0, messageIndex);
504
+ setMessages(truncatedMessages);
505
+
506
+ // Send the edited message with supersede flag
507
+ // This tells the backend to mark old runs from this point as superseded
508
+ await sendMessage(newContent, {
509
+ ...options,
510
+ supersedeFromMessageIndex: messageIndex,
511
+ });
512
+ }, [messages, isLoading, sendMessage]);
513
+
514
+ // Retry from a specific message (resend the same content)
515
+ // For user messages: retry that message
516
+ // For assistant messages: find the previous user message and retry from there
517
+ // The backend will mark old runs as superseded so conversation history stays clean
518
+ const retryMessage = useCallback(async (messageIndex, options = {}) => {
519
+ if (isLoading) return;
520
+
521
+ const messageAtIndex = messages[messageIndex];
522
+ if (!messageAtIndex) return;
523
+
524
+ let userMessageIndex = messageIndex;
525
+ let userMessage = messageAtIndex;
526
+
527
+ // If this is an assistant message, find the previous user message
528
+ if (messageAtIndex.role === 'assistant') {
529
+ for (let i = messageIndex - 1; i >= 0; i--) {
530
+ if (messages[i].role === 'user') {
531
+ userMessageIndex = i;
532
+ userMessage = messages[i];
533
+ break;
534
+ }
535
+ }
536
+ // If no user message found, can't retry
537
+ if (userMessage.role !== 'user') return;
538
+ } else if (messageAtIndex.role !== 'user') {
539
+ // Only user and assistant messages can be retried
540
+ return;
541
+ }
542
+
543
+ // Truncate messages to just before the user message
544
+ const truncatedMessages = messages.slice(0, userMessageIndex);
545
+ setMessages(truncatedMessages);
546
+
547
+ // Resend the same message with supersede flag
548
+ // This tells the backend to mark old runs from this point as superseded
549
+ await sendMessage(userMessage.content, {
550
+ ...options,
551
+ supersedeFromMessageIndex: userMessageIndex,
552
+ });
553
+ }, [messages, isLoading, sendMessage]);
554
+
482
555
  // Cleanup on unmount
483
556
  useEffect(() => {
484
557
  return () => {
@@ -501,6 +574,8 @@ export function useChat(config, api, storage) {
501
574
  loadConversation,
502
575
  loadMoreMessages,
503
576
  setConversationId,
577
+ editMessage,
578
+ retryMessage,
504
579
  };
505
580
  }
506
581
 
@@ -0,0 +1,164 @@
1
+ /**
2
+ * useTasks hook - manages task list state and API interactions
3
+ */
4
+
5
+ import { useState, useCallback, useEffect } from 'preact/hooks';
6
+
7
+ export function useTasks(config, api) {
8
+ const [taskList, setTaskList] = useState(null);
9
+ const [isLoading, setIsLoading] = useState(false);
10
+ const [error, setError] = useState(null);
11
+
12
+ // Build tasks API path
13
+ const tasksPath = config.apiPaths?.tasks || '/api/agent/tasks/';
14
+
15
+ // Load the user's task list
16
+ const loadTaskList = useCallback(async () => {
17
+ setIsLoading(true);
18
+ setError(null);
19
+
20
+ try {
21
+ const url = `${config.backendUrl}${tasksPath}`;
22
+ const response = await fetch(url, api.getFetchOptions({ method: 'GET' }));
23
+
24
+ if (response.ok) {
25
+ const data = await response.json();
26
+ setTaskList(data);
27
+ } else {
28
+ const errorData = await response.json().catch(() => ({}));
29
+ setError(errorData.error || 'Failed to load tasks');
30
+ }
31
+ } catch (err) {
32
+ console.error('[useTasks] Failed to load task list:', err);
33
+ setError('Failed to load tasks');
34
+ } finally {
35
+ setIsLoading(false);
36
+ }
37
+ }, [config.backendUrl, tasksPath, api]);
38
+
39
+ // Add a task
40
+ const addTask = useCallback(async (taskData) => {
41
+ if (!taskList) return null;
42
+
43
+ try {
44
+ const url = `${config.backendUrl}${tasksPath}${taskList.id}/add_task/`;
45
+ const response = await fetch(url, api.getFetchOptions({
46
+ method: 'POST',
47
+ headers: { 'Content-Type': 'application/json' },
48
+ body: JSON.stringify(taskData),
49
+ }));
50
+
51
+ if (response.ok) {
52
+ const newTask = await response.json();
53
+ // Refresh the task list to get updated state
54
+ await loadTaskList();
55
+ return newTask;
56
+ } else {
57
+ const errorData = await response.json().catch(() => ({}));
58
+ setError(errorData.error || 'Failed to add task');
59
+ return null;
60
+ }
61
+ } catch (err) {
62
+ console.error('[useTasks] Failed to add task:', err);
63
+ setError('Failed to add task');
64
+ return null;
65
+ }
66
+ }, [config.backendUrl, tasksPath, taskList, api, loadTaskList]);
67
+
68
+ // Update a task
69
+ const updateTask = useCallback(async (taskId, updates) => {
70
+ if (!taskList) return null;
71
+
72
+ try {
73
+ const url = `${config.backendUrl}${tasksPath}${taskList.id}/update_task/${taskId}/`;
74
+ const response = await fetch(url, api.getFetchOptions({
75
+ method: 'PUT',
76
+ headers: { 'Content-Type': 'application/json' },
77
+ body: JSON.stringify(updates),
78
+ }));
79
+
80
+ if (response.ok) {
81
+ const updatedTask = await response.json();
82
+ // Refresh the task list
83
+ await loadTaskList();
84
+ return updatedTask;
85
+ } else {
86
+ const errorData = await response.json().catch(() => ({}));
87
+ setError(errorData.error || 'Failed to update task');
88
+ return null;
89
+ }
90
+ } catch (err) {
91
+ console.error('[useTasks] Failed to update task:', err);
92
+ setError('Failed to update task');
93
+ return null;
94
+ }
95
+ }, [config.backendUrl, tasksPath, taskList, api, loadTaskList]);
96
+
97
+ // Remove a task
98
+ const removeTask = useCallback(async (taskId) => {
99
+ if (!taskList) return false;
100
+
101
+ try {
102
+ const url = `${config.backendUrl}${tasksPath}${taskList.id}/remove_task/${taskId}/`;
103
+ const response = await fetch(url, api.getFetchOptions({
104
+ method: 'DELETE',
105
+ }));
106
+
107
+ if (response.ok) {
108
+ await loadTaskList();
109
+ return true;
110
+ } else {
111
+ const errorData = await response.json().catch(() => ({}));
112
+ setError(errorData.error || 'Failed to remove task');
113
+ return false;
114
+ }
115
+ } catch (err) {
116
+ console.error('[useTasks] Failed to remove task:', err);
117
+ setError('Failed to remove task');
118
+ return false;
119
+ }
120
+ }, [config.backendUrl, tasksPath, taskList, api, loadTaskList]);
121
+
122
+ // Clear all tasks
123
+ const clearTasks = useCallback(async () => {
124
+ if (!taskList) return false;
125
+
126
+ try {
127
+ const url = `${config.backendUrl}${tasksPath}${taskList.id}/clear/`;
128
+ const response = await fetch(url, api.getFetchOptions({
129
+ method: 'POST',
130
+ }));
131
+
132
+ if (response.ok) {
133
+ await loadTaskList();
134
+ return true;
135
+ } else {
136
+ const errorData = await response.json().catch(() => ({}));
137
+ setError(errorData.error || 'Failed to clear tasks');
138
+ return false;
139
+ }
140
+ } catch (err) {
141
+ console.error('[useTasks] Failed to clear tasks:', err);
142
+ setError('Failed to clear tasks');
143
+ return false;
144
+ }
145
+ }, [config.backendUrl, tasksPath, taskList, api, loadTaskList]);
146
+
147
+ // Clear error
148
+ const clearError = useCallback(() => setError(null), []);
149
+
150
+ return {
151
+ taskList,
152
+ tasks: taskList?.tasks || [],
153
+ progress: taskList?.progress || { total: 0, completed: 0, percent_complete: 0 },
154
+ isLoading,
155
+ error,
156
+ loadTaskList,
157
+ addTask,
158
+ updateTask,
159
+ removeTask,
160
+ clearTasks,
161
+ clearError,
162
+ };
163
+ }
164
+
package/src/utils/api.js CHANGED
@@ -2,9 +2,31 @@
2
2
  * API utilities for the chat widget
3
3
  */
4
4
 
5
- import { getCSRFToken } from './helpers.js';
5
+ import { getCSRFToken, keysToSnake, keysToCamel } from './helpers.js';
6
6
 
7
7
  export function createApiClient(config, getState, setState) {
8
+ /**
9
+ * Transform request body based on apiCaseStyle config
10
+ */
11
+ const transformRequest = (body) => {
12
+ if (!body || typeof body !== 'object') return body;
13
+ // For 'snake' or 'auto', convert to snake_case
14
+ // For 'camel', keep as-is (frontend already uses camelCase)
15
+ if (config.apiCaseStyle === 'camel') return body;
16
+ return keysToSnake(body);
17
+ };
18
+
19
+ /**
20
+ * Transform response data based on apiCaseStyle config
21
+ * For 'auto', we normalize to camelCase for consistent internal use
22
+ */
23
+ const transformResponse = (data) => {
24
+ if (!data || typeof data !== 'object') return data;
25
+ // For 'camel' or 'auto', normalize to camelCase
26
+ // For 'snake', keep as-is (but this is unusual for frontend)
27
+ if (config.apiCaseStyle === 'snake') return data;
28
+ return keysToCamel(data);
29
+ };
8
30
  const getAuthStrategy = () => {
9
31
  if (config.authStrategy) return config.authStrategy;
10
32
  if (config.authToken) return 'token';
@@ -86,6 +108,8 @@ export function createApiClient(config, getState, setState) {
86
108
  getAuthHeaders,
87
109
  getFetchOptions,
88
110
  getOrCreateSession,
111
+ transformRequest,
112
+ transformResponse,
89
113
  };
90
114
  }
91
115
 
@@ -42,6 +42,12 @@ export const DEFAULT_CONFIG = {
42
42
  models: '/api/agent-runtime/models/',
43
43
  },
44
44
 
45
+ // API case style: 'camel', 'snake', or 'auto' (accepts both, sends camelCase)
46
+ // - 'camel': Backend uses camelCase (e.g., with djangorestframework-camel-case)
47
+ // - 'snake': Backend uses snake_case (standard Django REST Framework)
48
+ // - 'auto': Accept both in responses, send snake_case in requests (default)
49
+ apiCaseStyle: 'auto',
50
+
45
51
  // UI options
46
52
  showConversationSidebar: true,
47
53
  showClearButton: true,
@@ -2,6 +2,75 @@
2
2
  * Utility functions for the chat widget
3
3
  */
4
4
 
5
+ // =============================================================================
6
+ // Case Conversion Utilities
7
+ // =============================================================================
8
+
9
+ /**
10
+ * Convert a string from snake_case to camelCase
11
+ */
12
+ export function snakeToCamel(str) {
13
+ return str.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase());
14
+ }
15
+
16
+ /**
17
+ * Convert a string from camelCase to snake_case
18
+ */
19
+ export function camelToSnake(str) {
20
+ return str.replace(/[A-Z]/g, letter => `_${letter.toLowerCase()}`);
21
+ }
22
+
23
+ /**
24
+ * Recursively convert all keys in an object from snake_case to camelCase
25
+ */
26
+ export function keysToCamel(obj) {
27
+ if (Array.isArray(obj)) {
28
+ return obj.map(keysToCamel);
29
+ }
30
+ if (obj !== null && typeof obj === 'object') {
31
+ return Object.fromEntries(
32
+ Object.entries(obj).map(([key, value]) => [
33
+ snakeToCamel(key),
34
+ keysToCamel(value)
35
+ ])
36
+ );
37
+ }
38
+ return obj;
39
+ }
40
+
41
+ /**
42
+ * Recursively convert all keys in an object from camelCase to snake_case
43
+ */
44
+ export function keysToSnake(obj) {
45
+ if (Array.isArray(obj)) {
46
+ return obj.map(keysToSnake);
47
+ }
48
+ if (obj !== null && typeof obj === 'object') {
49
+ return Object.fromEntries(
50
+ Object.entries(obj).map(([key, value]) => [
51
+ camelToSnake(key),
52
+ keysToSnake(value)
53
+ ])
54
+ );
55
+ }
56
+ return obj;
57
+ }
58
+
59
+ /**
60
+ * Get a value from an object supporting both camelCase and snake_case keys
61
+ */
62
+ export function getAnyCase(obj, camelKey) {
63
+ if (!obj || typeof obj !== 'object') return undefined;
64
+ if (camelKey in obj) return obj[camelKey];
65
+ const snakeKey = camelToSnake(camelKey);
66
+ if (snakeKey in obj) return obj[snakeKey];
67
+ return undefined;
68
+ }
69
+
70
+ // =============================================================================
71
+ // ID Generation
72
+ // =============================================================================
73
+
5
74
  export function generateId() {
6
75
  return 'msg-' + Date.now() + '-' + Math.random().toString(36).substr(2, 9);
7
76
  }