@makemore/agent-frontend 2.4.0 → 2.7.1
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/LICENSE +82 -21
- package/README.md +10 -1
- package/dist/chat-widget.css +442 -1
- package/dist/chat-widget.js +357 -160
- package/package.json +1 -2
- 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 +92 -4
- package/src/hooks/useTasks.js +164 -0
- package/src/utils/api.js +24 -9
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@makemore/agent-frontend",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.7.1",
|
|
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",
|
|
@@ -56,4 +56,3 @@
|
|
|
56
56
|
"esbuild": "^0.20.0"
|
|
57
57
|
}
|
|
58
58
|
}
|
|
59
|
-
|
|
@@ -9,8 +9,10 @@ import { MessageList } from './MessageList.js';
|
|
|
9
9
|
import { InputForm } from './InputForm.js';
|
|
10
10
|
import { Sidebar } from './Sidebar.js';
|
|
11
11
|
import { ModelSelector } from './ModelSelector.js';
|
|
12
|
+
import { TaskList } from './TaskList.js';
|
|
12
13
|
import { useChat } from '../hooks/useChat.js';
|
|
13
14
|
import { useModels } from '../hooks/useModels.js';
|
|
15
|
+
import { useTasks } from '../hooks/useTasks.js';
|
|
14
16
|
import { createApiClient } from '../utils/api.js';
|
|
15
17
|
import { createStorage } from '../utils/helpers.js';
|
|
16
18
|
|
|
@@ -21,6 +23,7 @@ export function ChatWidget({ config, onStateChange, markdownParser, apiRef }) {
|
|
|
21
23
|
const [debugMode, setDebugMode] = useState(false);
|
|
22
24
|
const [sidebarOpen, setSidebarOpen] = useState(false);
|
|
23
25
|
const [conversations, setConversations] = useState([]);
|
|
26
|
+
const [activeTab, setActiveTab] = useState('chat'); // 'chat' or 'tasks'
|
|
24
27
|
const [conversationsLoading, setConversationsLoading] = useState(false);
|
|
25
28
|
|
|
26
29
|
// TTS state
|
|
@@ -57,6 +60,9 @@ export function ChatWidget({ config, onStateChange, markdownParser, apiRef }) {
|
|
|
57
60
|
// Models hook
|
|
58
61
|
const models = useModels(config, api, storage);
|
|
59
62
|
|
|
63
|
+
// Tasks hook
|
|
64
|
+
const tasks = useTasks(config, api);
|
|
65
|
+
|
|
60
66
|
// Track current agent from messages (for multi-agent systems)
|
|
61
67
|
useEffect(() => {
|
|
62
68
|
// Look for the most recent sub_agent_start or sub_agent_end message
|
|
@@ -152,6 +158,14 @@ export function ChatWidget({ config, onStateChange, markdownParser, apiRef }) {
|
|
|
152
158
|
});
|
|
153
159
|
}, [chat, enableTTS, models.selectedModel]);
|
|
154
160
|
|
|
161
|
+
// Handle tab switching
|
|
162
|
+
const handleTabChange = useCallback((tab) => {
|
|
163
|
+
setActiveTab(tab);
|
|
164
|
+
if (tab === 'tasks') {
|
|
165
|
+
tasks.loadTaskList();
|
|
166
|
+
}
|
|
167
|
+
}, [tasks]);
|
|
168
|
+
|
|
155
169
|
// Expose imperative API to parent
|
|
156
170
|
useEffect(() => {
|
|
157
171
|
if (apiRef) {
|
|
@@ -221,39 +235,71 @@ export function ChatWidget({ config, onStateChange, markdownParser, apiRef }) {
|
|
|
221
235
|
onToggleSidebar=${handleToggleSidebar}
|
|
222
236
|
/>
|
|
223
237
|
|
|
238
|
+
${config.showTasksTab !== false && html`
|
|
239
|
+
<div class="cw-tabs">
|
|
240
|
+
<button
|
|
241
|
+
class=${`cw-tab ${activeTab === 'chat' ? 'cw-tab-active' : ''}`}
|
|
242
|
+
onClick=${() => handleTabChange('chat')}
|
|
243
|
+
>
|
|
244
|
+
Chat
|
|
245
|
+
</button>
|
|
246
|
+
<button
|
|
247
|
+
class=${`cw-tab ${activeTab === 'tasks' ? 'cw-tab-active' : ''}`}
|
|
248
|
+
onClick=${() => handleTabChange('tasks')}
|
|
249
|
+
>
|
|
250
|
+
Tasks ${tasks.progress.total > 0 ? html`<span class="cw-tab-badge">${tasks.progress.completed}/${tasks.progress.total}</span>` : ''}
|
|
251
|
+
</button>
|
|
252
|
+
</div>
|
|
253
|
+
`}
|
|
254
|
+
|
|
224
255
|
${debugMode && html`<div class="cw-status-bar"><span>🐛 Debug</span></div>`}
|
|
225
256
|
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
257
|
+
${activeTab === 'chat' ? html`
|
|
258
|
+
<${MessageList}
|
|
259
|
+
messages=${chat.messages}
|
|
260
|
+
isLoading=${chat.isLoading}
|
|
261
|
+
hasMoreMessages=${chat.hasMoreMessages}
|
|
262
|
+
loadingMoreMessages=${chat.loadingMoreMessages}
|
|
263
|
+
onLoadMore=${chat.loadMoreMessages}
|
|
264
|
+
onEditMessage=${chat.editMessage}
|
|
265
|
+
onRetryMessage=${chat.retryMessage}
|
|
266
|
+
debugMode=${debugMode}
|
|
267
|
+
markdownParser=${markdownParser}
|
|
268
|
+
emptyStateTitle=${config.emptyStateTitle}
|
|
269
|
+
emptyStateMessage=${config.emptyStateMessage}
|
|
270
|
+
/>
|
|
237
271
|
|
|
238
|
-
|
|
272
|
+
${chat.error && html`<div class="cw-error-bar">${chat.error}</div>`}
|
|
239
273
|
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
274
|
+
${config.showModelSelector && models.availableModels.length > 0 && html`
|
|
275
|
+
<${ModelSelector}
|
|
276
|
+
availableModels=${models.availableModels}
|
|
277
|
+
selectedModel=${models.selectedModel}
|
|
278
|
+
onSelectModel=${models.selectModel}
|
|
279
|
+
disabled=${chat.isLoading}
|
|
280
|
+
/>
|
|
281
|
+
`}
|
|
282
|
+
|
|
283
|
+
<${InputForm}
|
|
284
|
+
onSend=${handleSend}
|
|
285
|
+
onCancel=${chat.cancelRun}
|
|
286
|
+
isLoading=${chat.isLoading}
|
|
287
|
+
placeholder=${config.placeholder}
|
|
288
|
+
primaryColor=${config.primaryColor}
|
|
289
|
+
enableVoice=${config.enableVoice}
|
|
290
|
+
/>
|
|
291
|
+
` : html`
|
|
292
|
+
<${TaskList}
|
|
293
|
+
tasks=${tasks.tasks}
|
|
294
|
+
progress=${tasks.progress}
|
|
295
|
+
isLoading=${tasks.isLoading}
|
|
296
|
+
error=${tasks.error}
|
|
297
|
+
onUpdate=${tasks.updateTask}
|
|
298
|
+
onRemove=${tasks.removeTask}
|
|
299
|
+
onClear=${tasks.clearTasks}
|
|
300
|
+
onRefresh=${tasks.loadTaskList}
|
|
246
301
|
/>
|
|
247
302
|
`}
|
|
248
|
-
|
|
249
|
-
<${InputForm}
|
|
250
|
-
onSend=${handleSend}
|
|
251
|
-
onCancel=${chat.cancelRun}
|
|
252
|
-
isLoading=${chat.isLoading}
|
|
253
|
-
placeholder=${config.placeholder}
|
|
254
|
-
primaryColor=${config.primaryColor}
|
|
255
|
-
enableVoice=${config.enableVoice}
|
|
256
|
-
/>
|
|
257
303
|
</div>
|
|
258
304
|
`;
|
|
259
305
|
}
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import { html } from 'htm/preact';
|
|
6
|
-
import { useState } from 'preact/hooks';
|
|
6
|
+
import { useState, useRef, useEffect } from 'preact/hooks';
|
|
7
7
|
import { escapeHtml, parseMarkdown, formatFileSize, getFileTypeIcon } from '../utils/helpers.js';
|
|
8
8
|
|
|
9
9
|
// Debug payload viewer component
|
|
@@ -26,9 +26,103 @@ function DebugPayload({ msg, show, onToggle }) {
|
|
|
26
26
|
`;
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
-
|
|
29
|
+
// Edit/Retry action buttons for user messages
|
|
30
|
+
function MessageActions({ onEdit, onRetry, isLoading, position, showEdit = true }) {
|
|
31
|
+
if (isLoading) return null;
|
|
32
|
+
|
|
33
|
+
return html`
|
|
34
|
+
<div class="cw-message-actions cw-message-actions-${position || 'left'}">
|
|
35
|
+
${showEdit && html`
|
|
36
|
+
<button
|
|
37
|
+
class="cw-message-action-btn"
|
|
38
|
+
onClick=${onEdit}
|
|
39
|
+
title="Edit message"
|
|
40
|
+
>
|
|
41
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
42
|
+
<path d="M17 3a2.828 2.828 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5L17 3z"></path>
|
|
43
|
+
</svg>
|
|
44
|
+
</button>
|
|
45
|
+
`}
|
|
46
|
+
<button
|
|
47
|
+
class="cw-message-action-btn"
|
|
48
|
+
onClick=${onRetry}
|
|
49
|
+
title="Retry from here"
|
|
50
|
+
>
|
|
51
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
52
|
+
<path d="M21 2v6h-6"></path>
|
|
53
|
+
<path d="M3 12a9 9 0 0 1 15-6.7L21 8"></path>
|
|
54
|
+
<path d="M3 22v-6h6"></path>
|
|
55
|
+
<path d="M21 12a9 9 0 0 1-15 6.7L3 16"></path>
|
|
56
|
+
</svg>
|
|
57
|
+
</button>
|
|
58
|
+
</div>
|
|
59
|
+
`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Inline edit form for editing a message
|
|
63
|
+
function InlineEditForm({ initialContent, onSave, onCancel }) {
|
|
64
|
+
const [content, setContent] = useState(initialContent);
|
|
65
|
+
const textareaRef = useRef(null);
|
|
66
|
+
|
|
67
|
+
useEffect(() => {
|
|
68
|
+
if (textareaRef.current) {
|
|
69
|
+
textareaRef.current.focus();
|
|
70
|
+
textareaRef.current.setSelectionRange(content.length, content.length);
|
|
71
|
+
// Auto-resize
|
|
72
|
+
textareaRef.current.style.height = 'auto';
|
|
73
|
+
textareaRef.current.style.height = textareaRef.current.scrollHeight + 'px';
|
|
74
|
+
}
|
|
75
|
+
}, []);
|
|
76
|
+
|
|
77
|
+
const handleInput = (e) => {
|
|
78
|
+
setContent(e.target.value);
|
|
79
|
+
// Auto-resize textarea
|
|
80
|
+
e.target.style.height = 'auto';
|
|
81
|
+
e.target.style.height = e.target.scrollHeight + 'px';
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const handleKeyDown = (e) => {
|
|
85
|
+
if (e.key === 'Enter' && !e.shiftKey) {
|
|
86
|
+
e.preventDefault();
|
|
87
|
+
if (content.trim()) {
|
|
88
|
+
onSave(content.trim());
|
|
89
|
+
}
|
|
90
|
+
} else if (e.key === 'Escape') {
|
|
91
|
+
onCancel();
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
return html`
|
|
96
|
+
<div class="cw-inline-edit">
|
|
97
|
+
<textarea
|
|
98
|
+
ref=${textareaRef}
|
|
99
|
+
class="cw-inline-edit-input"
|
|
100
|
+
value=${content}
|
|
101
|
+
onInput=${handleInput}
|
|
102
|
+
onKeyDown=${handleKeyDown}
|
|
103
|
+
rows="1"
|
|
104
|
+
/>
|
|
105
|
+
<div class="cw-inline-edit-actions">
|
|
106
|
+
<button
|
|
107
|
+
class="cw-inline-edit-btn cw-inline-edit-cancel"
|
|
108
|
+
onClick=${onCancel}
|
|
109
|
+
title="Cancel (Esc)"
|
|
110
|
+
>Cancel</button>
|
|
111
|
+
<button
|
|
112
|
+
class="cw-inline-edit-btn cw-inline-edit-save"
|
|
113
|
+
onClick=${() => content.trim() && onSave(content.trim())}
|
|
114
|
+
disabled=${!content.trim()}
|
|
115
|
+
title="Save & Resend (Enter)"
|
|
116
|
+
>Save & Send</button>
|
|
117
|
+
</div>
|
|
118
|
+
</div>
|
|
119
|
+
`;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function Message({ msg, debugMode, markdownParser, onEdit, onRetry, isLoading, messageIndex }) {
|
|
30
123
|
const [expanded, setExpanded] = useState(false);
|
|
31
124
|
const [showPayload, setShowPayload] = useState(false);
|
|
125
|
+
const [isEditing, setIsEditing] = useState(false);
|
|
32
126
|
|
|
33
127
|
const isUser = msg.role === 'user';
|
|
34
128
|
const isSystem = msg.role === 'system';
|
|
@@ -139,10 +233,68 @@ export function Message({ msg, debugMode, markdownParser }) {
|
|
|
139
233
|
`;
|
|
140
234
|
};
|
|
141
235
|
|
|
236
|
+
// Handle edit save - always triggers a new run with the (possibly edited) content
|
|
237
|
+
const handleEditSave = (newContent) => {
|
|
238
|
+
setIsEditing(false);
|
|
239
|
+
if (onEdit) {
|
|
240
|
+
onEdit(messageIndex, newContent);
|
|
241
|
+
}
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
// Handle retry (resend same message)
|
|
245
|
+
const handleRetry = () => {
|
|
246
|
+
if (onRetry) {
|
|
247
|
+
onRetry(messageIndex);
|
|
248
|
+
}
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
// Show inline edit form for user messages in edit mode
|
|
252
|
+
if (isUser && isEditing) {
|
|
253
|
+
return html`
|
|
254
|
+
<div class=${rowClasses} style="position: relative;">
|
|
255
|
+
${renderAttachments()}
|
|
256
|
+
<${InlineEditForm}
|
|
257
|
+
initialContent=${msg.content}
|
|
258
|
+
onSave=${handleEditSave}
|
|
259
|
+
onCancel=${() => setIsEditing(false)}
|
|
260
|
+
/>
|
|
261
|
+
</div>
|
|
262
|
+
`;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// Show edit/retry actions for user messages (on left side since user messages are on right)
|
|
266
|
+
const showUserActions = isUser && onEdit && onRetry;
|
|
267
|
+
// Show retry-only for assistant messages (below the message)
|
|
268
|
+
const isAssistant = msg.role === 'assistant';
|
|
269
|
+
const showAssistantRetry = isAssistant && onRetry && !isLoading;
|
|
270
|
+
const hasActions = showUserActions || showAssistantRetry;
|
|
271
|
+
|
|
142
272
|
return html`
|
|
143
|
-
<div class
|
|
273
|
+
<div class="${rowClasses} ${hasActions ? 'cw-message-row-with-actions' : ''}">
|
|
144
274
|
${renderAttachments()}
|
|
145
|
-
|
|
275
|
+
${showUserActions && html`
|
|
276
|
+
<div class="cw-user-actions-wrapper">
|
|
277
|
+
<${MessageActions}
|
|
278
|
+
onEdit=${() => setIsEditing(true)}
|
|
279
|
+
onRetry=${handleRetry}
|
|
280
|
+
isLoading=${isLoading}
|
|
281
|
+
position="left"
|
|
282
|
+
showEdit=${true}
|
|
283
|
+
/>
|
|
284
|
+
<div class=${classes} dangerouslySetInnerHTML=${{ __html: content }} />
|
|
285
|
+
</div>
|
|
286
|
+
`}
|
|
287
|
+
${!showUserActions && html`
|
|
288
|
+
<div class=${classes} dangerouslySetInnerHTML=${{ __html: content }} />
|
|
289
|
+
`}
|
|
290
|
+
${showAssistantRetry && html`
|
|
291
|
+
<${MessageActions}
|
|
292
|
+
onRetry=${handleRetry}
|
|
293
|
+
isLoading=${isLoading}
|
|
294
|
+
position="right"
|
|
295
|
+
showEdit=${false}
|
|
296
|
+
/>
|
|
297
|
+
`}
|
|
146
298
|
${debugMode && html`<${DebugPayload} msg=${msg} show=${showPayload} onToggle=${() => setShowPayload(!showPayload)} />`}
|
|
147
299
|
</div>
|
|
148
300
|
`;
|
|
@@ -13,6 +13,8 @@ export function MessageList({
|
|
|
13
13
|
hasMoreMessages,
|
|
14
14
|
loadingMoreMessages,
|
|
15
15
|
onLoadMore,
|
|
16
|
+
onEditMessage,
|
|
17
|
+
onRetryMessage,
|
|
16
18
|
debugMode,
|
|
17
19
|
markdownParser,
|
|
18
20
|
emptyStateTitle,
|
|
@@ -83,12 +85,16 @@ export function MessageList({
|
|
|
83
85
|
</div>
|
|
84
86
|
`}
|
|
85
87
|
|
|
86
|
-
${messages.map(msg => html`
|
|
87
|
-
<${Message}
|
|
88
|
-
key=${msg.id}
|
|
89
|
-
msg=${msg}
|
|
88
|
+
${messages.map((msg, index) => html`
|
|
89
|
+
<${Message}
|
|
90
|
+
key=${msg.id}
|
|
91
|
+
msg=${msg}
|
|
92
|
+
messageIndex=${index}
|
|
90
93
|
debugMode=${debugMode}
|
|
91
94
|
markdownParser=${markdownParser}
|
|
95
|
+
onEdit=${onEditMessage}
|
|
96
|
+
onRetry=${onRetryMessage}
|
|
97
|
+
isLoading=${isLoading}
|
|
92
98
|
/>
|
|
93
99
|
`)}
|
|
94
100
|
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TaskList component - displays and manages the user's task list
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { html } from 'htm/preact';
|
|
6
|
+
import { useState, useCallback } from 'preact/hooks';
|
|
7
|
+
|
|
8
|
+
// State icons for tasks
|
|
9
|
+
const STATE_ICONS = {
|
|
10
|
+
not_started: '○',
|
|
11
|
+
in_progress: '◐',
|
|
12
|
+
complete: '●',
|
|
13
|
+
cancelled: '⊘',
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const STATE_LABELS = {
|
|
17
|
+
not_started: 'Not Started',
|
|
18
|
+
in_progress: 'In Progress',
|
|
19
|
+
complete: 'Complete',
|
|
20
|
+
cancelled: 'Cancelled',
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
function TaskItem({ task, onUpdate, onRemove, depth = 0 }) {
|
|
24
|
+
const [isEditing, setIsEditing] = useState(false);
|
|
25
|
+
const [editName, setEditName] = useState(task.name);
|
|
26
|
+
|
|
27
|
+
const handleStateToggle = useCallback(() => {
|
|
28
|
+
// Cycle through states: not_started -> in_progress -> complete
|
|
29
|
+
const nextState = {
|
|
30
|
+
not_started: 'in_progress',
|
|
31
|
+
in_progress: 'complete',
|
|
32
|
+
complete: 'not_started',
|
|
33
|
+
cancelled: 'not_started',
|
|
34
|
+
};
|
|
35
|
+
onUpdate(task.id, { state: nextState[task.state] || 'not_started' });
|
|
36
|
+
}, [task, onUpdate]);
|
|
37
|
+
|
|
38
|
+
const handleSaveEdit = useCallback(() => {
|
|
39
|
+
if (editName.trim() && editName !== task.name) {
|
|
40
|
+
onUpdate(task.id, { name: editName.trim() });
|
|
41
|
+
}
|
|
42
|
+
setIsEditing(false);
|
|
43
|
+
}, [task, editName, onUpdate]);
|
|
44
|
+
|
|
45
|
+
const handleKeyDown = useCallback((e) => {
|
|
46
|
+
if (e.key === 'Enter') handleSaveEdit();
|
|
47
|
+
if (e.key === 'Escape') {
|
|
48
|
+
setEditName(task.name);
|
|
49
|
+
setIsEditing(false);
|
|
50
|
+
}
|
|
51
|
+
}, [handleSaveEdit, task.name]);
|
|
52
|
+
|
|
53
|
+
const stateClass = `cw-task-state-${task.state.replace('_', '-')}`;
|
|
54
|
+
|
|
55
|
+
return html`
|
|
56
|
+
<div class="cw-task-item ${stateClass}" style=${{ paddingLeft: `${depth * 16 + 8}px` }}>
|
|
57
|
+
<button
|
|
58
|
+
class="cw-task-state-btn"
|
|
59
|
+
onClick=${handleStateToggle}
|
|
60
|
+
title=${STATE_LABELS[task.state]}
|
|
61
|
+
>
|
|
62
|
+
${STATE_ICONS[task.state]}
|
|
63
|
+
</button>
|
|
64
|
+
|
|
65
|
+
${isEditing ? html`
|
|
66
|
+
<input
|
|
67
|
+
type="text"
|
|
68
|
+
class="cw-task-edit-input"
|
|
69
|
+
value=${editName}
|
|
70
|
+
onInput=${(e) => setEditName(e.target.value)}
|
|
71
|
+
onBlur=${handleSaveEdit}
|
|
72
|
+
onKeyDown=${handleKeyDown}
|
|
73
|
+
autoFocus
|
|
74
|
+
/>
|
|
75
|
+
` : html`
|
|
76
|
+
<span
|
|
77
|
+
class="cw-task-name"
|
|
78
|
+
onClick=${() => setIsEditing(true)}
|
|
79
|
+
title="Click to edit"
|
|
80
|
+
>
|
|
81
|
+
${task.name}
|
|
82
|
+
</span>
|
|
83
|
+
`}
|
|
84
|
+
|
|
85
|
+
<button
|
|
86
|
+
class="cw-task-remove-btn"
|
|
87
|
+
onClick=${() => onRemove(task.id)}
|
|
88
|
+
title="Remove task"
|
|
89
|
+
>
|
|
90
|
+
×
|
|
91
|
+
</button>
|
|
92
|
+
</div>
|
|
93
|
+
`;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function TaskList({
|
|
97
|
+
tasks,
|
|
98
|
+
progress,
|
|
99
|
+
isLoading,
|
|
100
|
+
error,
|
|
101
|
+
onUpdate,
|
|
102
|
+
onRemove,
|
|
103
|
+
onClear,
|
|
104
|
+
onRefresh
|
|
105
|
+
}) {
|
|
106
|
+
// Build tree structure from flat tasks
|
|
107
|
+
const buildTree = useCallback((tasks) => {
|
|
108
|
+
const taskMap = new Map();
|
|
109
|
+
const roots = [];
|
|
110
|
+
|
|
111
|
+
// First pass: create map
|
|
112
|
+
tasks.forEach(task => {
|
|
113
|
+
taskMap.set(task.id, { ...task, children: [] });
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
// Second pass: build tree
|
|
117
|
+
tasks.forEach(task => {
|
|
118
|
+
const node = taskMap.get(task.id);
|
|
119
|
+
if (task.parent_id && taskMap.has(task.parent_id)) {
|
|
120
|
+
taskMap.get(task.parent_id).children.push(node);
|
|
121
|
+
} else {
|
|
122
|
+
roots.push(node);
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
return roots;
|
|
127
|
+
}, []);
|
|
128
|
+
|
|
129
|
+
const renderTask = useCallback((task, depth = 0) => {
|
|
130
|
+
return html`
|
|
131
|
+
<${TaskItem}
|
|
132
|
+
key=${task.id}
|
|
133
|
+
task=${task}
|
|
134
|
+
depth=${depth}
|
|
135
|
+
onUpdate=${onUpdate}
|
|
136
|
+
onRemove=${onRemove}
|
|
137
|
+
/>
|
|
138
|
+
${task.children?.map(child => renderTask(child, depth + 1))}
|
|
139
|
+
`;
|
|
140
|
+
}, [onUpdate, onRemove]);
|
|
141
|
+
|
|
142
|
+
const taskTree = buildTree(tasks);
|
|
143
|
+
|
|
144
|
+
if (isLoading && tasks.length === 0) {
|
|
145
|
+
return html`<div class="cw-tasks-loading">Loading tasks...</div>`;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return html`
|
|
149
|
+
<div class="cw-tasks-container">
|
|
150
|
+
<div class="cw-tasks-header">
|
|
151
|
+
<div class="cw-tasks-progress">
|
|
152
|
+
<span class="cw-tasks-progress-text">
|
|
153
|
+
${progress.completed}/${progress.total} complete
|
|
154
|
+
</span>
|
|
155
|
+
<div class="cw-tasks-progress-bar">
|
|
156
|
+
<div
|
|
157
|
+
class="cw-tasks-progress-fill"
|
|
158
|
+
style=${{ width: `${progress.percent_complete}%` }}
|
|
159
|
+
/>
|
|
160
|
+
</div>
|
|
161
|
+
</div>
|
|
162
|
+
<div class="cw-tasks-actions">
|
|
163
|
+
<button class="cw-tasks-action-btn" onClick=${onRefresh} title="Refresh">↻</button>
|
|
164
|
+
${tasks.length > 0 && html`
|
|
165
|
+
<button class="cw-tasks-action-btn" onClick=${onClear} title="Clear all">🗑</button>
|
|
166
|
+
`}
|
|
167
|
+
</div>
|
|
168
|
+
</div>
|
|
169
|
+
|
|
170
|
+
${error && html`<div class="cw-tasks-error">${error}</div>`}
|
|
171
|
+
|
|
172
|
+
<div class="cw-tasks-list">
|
|
173
|
+
${taskTree.length === 0 ? html`
|
|
174
|
+
<div class="cw-tasks-empty">
|
|
175
|
+
<p>No tasks yet</p>
|
|
176
|
+
<p class="cw-tasks-empty-hint">Tasks will appear here when the agent creates them</p>
|
|
177
|
+
</div>
|
|
178
|
+
` : taskTree.map(task => renderTask(task))}
|
|
179
|
+
</div>
|
|
180
|
+
</div>
|
|
181
|
+
`;
|
|
182
|
+
}
|
|
183
|
+
|
package/src/hooks/useChat.js
CHANGED
|
@@ -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);
|
|
@@ -289,6 +289,8 @@ export function useChat(config, api, storage) {
|
|
|
289
289
|
messages: [{ role: 'user', content: content.trim() }],
|
|
290
290
|
metadata: { ...config.metadata, journeyType: config.defaultJourneyType },
|
|
291
291
|
...(model && { model }),
|
|
292
|
+
// Edit/retry support: tell backend to mark old runs as superseded
|
|
293
|
+
...(supersedeFromMessageIndex !== undefined && { supersedeFromMessageIndex }),
|
|
292
294
|
});
|
|
293
295
|
|
|
294
296
|
fetchOptions = api.getFetchOptions({
|
|
@@ -298,11 +300,32 @@ export function useChat(config, api, storage) {
|
|
|
298
300
|
}, token);
|
|
299
301
|
}
|
|
300
302
|
|
|
301
|
-
|
|
303
|
+
let response = await fetch(`${config.backendUrl}${config.apiPaths.runs}`, fetchOptions);
|
|
304
|
+
let activeToken = token;
|
|
305
|
+
|
|
306
|
+
// Handle 401 by refreshing token and retrying once
|
|
307
|
+
if (response.status === 401) {
|
|
308
|
+
api.clearSession();
|
|
309
|
+
const newToken = await api.getOrCreateSession(true);
|
|
310
|
+
if (newToken) {
|
|
311
|
+
activeToken = newToken;
|
|
312
|
+
// Rebuild fetch options with new token
|
|
313
|
+
if (files.length > 0) {
|
|
314
|
+
fetchOptions = api.getFetchOptions({ method: 'POST', body: fetchOptions.body }, newToken);
|
|
315
|
+
} else {
|
|
316
|
+
fetchOptions = api.getFetchOptions({
|
|
317
|
+
method: 'POST',
|
|
318
|
+
headers: { 'Content-Type': 'application/json' },
|
|
319
|
+
body: fetchOptions.body,
|
|
320
|
+
}, newToken);
|
|
321
|
+
}
|
|
322
|
+
response = await fetch(`${config.backendUrl}${config.apiPaths.runs}`, fetchOptions);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
302
325
|
|
|
303
326
|
if (!response.ok) {
|
|
304
327
|
const errorData = await response.json().catch(() => ({}));
|
|
305
|
-
throw new Error(errorData.error || `HTTP ${response.status}`);
|
|
328
|
+
throw new Error(errorData.error || errorData.detail || `HTTP ${response.status}`);
|
|
306
329
|
}
|
|
307
330
|
|
|
308
331
|
const rawRun = await response.json();
|
|
@@ -312,7 +335,7 @@ export function useChat(config, api, storage) {
|
|
|
312
335
|
setConversationId(run.conversationId);
|
|
313
336
|
}
|
|
314
337
|
|
|
315
|
-
await subscribeToEvents(run.id,
|
|
338
|
+
await subscribeToEvents(run.id, activeToken, onAssistantMessage);
|
|
316
339
|
} catch (err) {
|
|
317
340
|
setError(err.message || 'Failed to send message');
|
|
318
341
|
setIsLoading(false);
|
|
@@ -487,6 +510,69 @@ export function useChat(config, api, storage) {
|
|
|
487
510
|
}
|
|
488
511
|
}, [config, api, conversationId, messagesOffset, loadingMoreMessages, hasMoreMessages]);
|
|
489
512
|
|
|
513
|
+
// Edit a message and resend from that point
|
|
514
|
+
// This truncates the conversation to the edited message and resends
|
|
515
|
+
// The backend will mark old runs as superseded so conversation history stays clean
|
|
516
|
+
const editMessage = useCallback(async (messageIndex, newContent, options = {}) => {
|
|
517
|
+
if (isLoading) return;
|
|
518
|
+
|
|
519
|
+
// Find the message to edit
|
|
520
|
+
const messageToEdit = messages[messageIndex];
|
|
521
|
+
if (!messageToEdit || messageToEdit.role !== 'user') return;
|
|
522
|
+
|
|
523
|
+
// Truncate messages to just before this message
|
|
524
|
+
const truncatedMessages = messages.slice(0, messageIndex);
|
|
525
|
+
setMessages(truncatedMessages);
|
|
526
|
+
|
|
527
|
+
// Send the edited message with supersede flag
|
|
528
|
+
// This tells the backend to mark old runs from this point as superseded
|
|
529
|
+
await sendMessage(newContent, {
|
|
530
|
+
...options,
|
|
531
|
+
supersedeFromMessageIndex: messageIndex,
|
|
532
|
+
});
|
|
533
|
+
}, [messages, isLoading, sendMessage]);
|
|
534
|
+
|
|
535
|
+
// Retry from a specific message (resend the same content)
|
|
536
|
+
// For user messages: retry that message
|
|
537
|
+
// For assistant messages: find the previous user message and retry from there
|
|
538
|
+
// The backend will mark old runs as superseded so conversation history stays clean
|
|
539
|
+
const retryMessage = useCallback(async (messageIndex, options = {}) => {
|
|
540
|
+
if (isLoading) return;
|
|
541
|
+
|
|
542
|
+
const messageAtIndex = messages[messageIndex];
|
|
543
|
+
if (!messageAtIndex) return;
|
|
544
|
+
|
|
545
|
+
let userMessageIndex = messageIndex;
|
|
546
|
+
let userMessage = messageAtIndex;
|
|
547
|
+
|
|
548
|
+
// If this is an assistant message, find the previous user message
|
|
549
|
+
if (messageAtIndex.role === 'assistant') {
|
|
550
|
+
for (let i = messageIndex - 1; i >= 0; i--) {
|
|
551
|
+
if (messages[i].role === 'user') {
|
|
552
|
+
userMessageIndex = i;
|
|
553
|
+
userMessage = messages[i];
|
|
554
|
+
break;
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
// If no user message found, can't retry
|
|
558
|
+
if (userMessage.role !== 'user') return;
|
|
559
|
+
} else if (messageAtIndex.role !== 'user') {
|
|
560
|
+
// Only user and assistant messages can be retried
|
|
561
|
+
return;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
// Truncate messages to just before the user message
|
|
565
|
+
const truncatedMessages = messages.slice(0, userMessageIndex);
|
|
566
|
+
setMessages(truncatedMessages);
|
|
567
|
+
|
|
568
|
+
// Resend the same message with supersede flag
|
|
569
|
+
// This tells the backend to mark old runs from this point as superseded
|
|
570
|
+
await sendMessage(userMessage.content, {
|
|
571
|
+
...options,
|
|
572
|
+
supersedeFromMessageIndex: userMessageIndex,
|
|
573
|
+
});
|
|
574
|
+
}, [messages, isLoading, sendMessage]);
|
|
575
|
+
|
|
490
576
|
// Cleanup on unmount
|
|
491
577
|
useEffect(() => {
|
|
492
578
|
return () => {
|
|
@@ -509,6 +595,8 @@ export function useChat(config, api, storage) {
|
|
|
509
595
|
loadConversation,
|
|
510
596
|
loadMoreMessages,
|
|
511
597
|
setConversationId,
|
|
598
|
+
editMessage,
|
|
599
|
+
retryMessage,
|
|
512
600
|
};
|
|
513
601
|
}
|
|
514
602
|
|