@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.
- package/README.md +10 -1
- package/dist/chat-widget.css +442 -1
- package/dist/chat-widget.js +368 -171
- package/package.json +1 -1
- package/src/components/ChatWidget.js +73 -27
- package/src/components/Message.js +156 -4
- package/src/components/MessageList.js +10 -4
- package/src/components/TaskList.js +183 -0
- package/src/hooks/useChat.js +97 -22
- package/src/hooks/useTasks.js +164 -0
- package/src/utils/api.js +25 -1
- package/src/utils/config.js +6 -0
- package/src/utils/helpers.js +69 -0
package/src/hooks/useChat.js
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
286
|
-
|
|
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
|
|
310
|
+
const rawRun = await response.json();
|
|
311
|
+
const run = api.transformResponse(rawRun);
|
|
304
312
|
currentRunIdRef.current = run.id;
|
|
305
|
-
|
|
306
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
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.
|
|
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
|
|
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.
|
|
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
|
|
package/src/utils/config.js
CHANGED
|
@@ -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,
|
package/src/utils/helpers.js
CHANGED
|
@@ -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
|
}
|