@makemore/agent-frontend 1.8.0 → 2.0.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 +140 -7
- package/dist/chat-widget.css +611 -1
- package/dist/chat-widget.js +305 -1376
- package/package.json +19 -7
- package/src/components/ChatWidget.js +259 -0
- package/src/components/Header.js +111 -0
- package/src/components/InputForm.js +95 -0
- package/src/components/Message.js +115 -0
- package/src/components/MessageList.js +106 -0
- package/src/components/ModelSelector.js +68 -0
- package/src/components/Sidebar.js +58 -0
- package/src/hooks/useChat.js +455 -0
- package/src/hooks/useModels.js +69 -0
- package/src/index.js +222 -0
- package/src/utils/api.js +90 -0
- package/src/utils/config.js +83 -0
- package/src/utils/helpers.js +83 -0
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MessageList component - renders the scrollable message container
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { html } from 'htm/preact';
|
|
6
|
+
import { useRef, useEffect } from 'preact/hooks';
|
|
7
|
+
import { Message } from './Message.js';
|
|
8
|
+
import { escapeHtml } from '../utils/helpers.js';
|
|
9
|
+
|
|
10
|
+
export function MessageList({
|
|
11
|
+
messages,
|
|
12
|
+
isLoading,
|
|
13
|
+
hasMoreMessages,
|
|
14
|
+
loadingMoreMessages,
|
|
15
|
+
onLoadMore,
|
|
16
|
+
debugMode,
|
|
17
|
+
markdownParser,
|
|
18
|
+
emptyStateTitle,
|
|
19
|
+
emptyStateMessage,
|
|
20
|
+
}) {
|
|
21
|
+
const containerRef = useRef(null);
|
|
22
|
+
const shouldAutoScrollRef = useRef(true);
|
|
23
|
+
|
|
24
|
+
// Track if user scrolls away from bottom
|
|
25
|
+
const handleScroll = (e) => {
|
|
26
|
+
const el = e.target;
|
|
27
|
+
const isNearBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 100;
|
|
28
|
+
shouldAutoScrollRef.current = isNearBottom;
|
|
29
|
+
|
|
30
|
+
// Load more when scrolling to top
|
|
31
|
+
if (el.scrollTop < 50 && hasMoreMessages && !loadingMoreMessages) {
|
|
32
|
+
const prevScrollHeight = el.scrollHeight;
|
|
33
|
+
onLoadMore().then(() => {
|
|
34
|
+
// Maintain scroll position after loading
|
|
35
|
+
const newScrollHeight = el.scrollHeight;
|
|
36
|
+
el.scrollTop = newScrollHeight - prevScrollHeight + el.scrollTop;
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
// Auto-scroll to bottom when messages change or loading state changes
|
|
42
|
+
useEffect(() => {
|
|
43
|
+
const el = containerRef.current;
|
|
44
|
+
if (el && shouldAutoScrollRef.current) {
|
|
45
|
+
// Use requestAnimationFrame to ensure DOM has updated
|
|
46
|
+
requestAnimationFrame(() => {
|
|
47
|
+
el.scrollTop = el.scrollHeight;
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
}, [messages, isLoading]);
|
|
51
|
+
|
|
52
|
+
// Always scroll to bottom on initial load or when first message added
|
|
53
|
+
useEffect(() => {
|
|
54
|
+
const el = containerRef.current;
|
|
55
|
+
if (el && messages.length <= 2) {
|
|
56
|
+
shouldAutoScrollRef.current = true;
|
|
57
|
+
requestAnimationFrame(() => {
|
|
58
|
+
el.scrollTop = el.scrollHeight;
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
}, [messages.length]);
|
|
62
|
+
|
|
63
|
+
const isEmpty = messages.length === 0;
|
|
64
|
+
|
|
65
|
+
return html`
|
|
66
|
+
<div class="cw-messages" ref=${containerRef} onScroll=${handleScroll}>
|
|
67
|
+
${isEmpty && html`
|
|
68
|
+
<div class="cw-empty-state">
|
|
69
|
+
<svg class="cw-empty-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
70
|
+
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"></path>
|
|
71
|
+
</svg>
|
|
72
|
+
<h3>${escapeHtml(emptyStateTitle)}</h3>
|
|
73
|
+
<p>${escapeHtml(emptyStateMessage)}</p>
|
|
74
|
+
</div>
|
|
75
|
+
`}
|
|
76
|
+
|
|
77
|
+
${!isEmpty && hasMoreMessages && html`
|
|
78
|
+
<div class="cw-load-more" onClick=${onLoadMore}>
|
|
79
|
+
${loadingMoreMessages
|
|
80
|
+
? html`<span class="cw-spinner"></span><span>Loading...</span>`
|
|
81
|
+
: html`<span>↑ Scroll up or click to load older messages</span>`
|
|
82
|
+
}
|
|
83
|
+
</div>
|
|
84
|
+
`}
|
|
85
|
+
|
|
86
|
+
${messages.map(msg => html`
|
|
87
|
+
<${Message}
|
|
88
|
+
key=${msg.id}
|
|
89
|
+
msg=${msg}
|
|
90
|
+
debugMode=${debugMode}
|
|
91
|
+
markdownParser=${markdownParser}
|
|
92
|
+
/>
|
|
93
|
+
`)}
|
|
94
|
+
|
|
95
|
+
${isLoading && html`
|
|
96
|
+
<div class="cw-message-row">
|
|
97
|
+
<div class="cw-typing">
|
|
98
|
+
<span class="cw-spinner"></span>
|
|
99
|
+
<span>Thinking...</span>
|
|
100
|
+
</div>
|
|
101
|
+
</div>
|
|
102
|
+
`}
|
|
103
|
+
</div>
|
|
104
|
+
`;
|
|
105
|
+
}
|
|
106
|
+
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ModelSelector component - dropdown for selecting LLM model
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { html } from 'htm/preact';
|
|
6
|
+
import { useState } from 'preact/hooks';
|
|
7
|
+
import { escapeHtml } from '../utils/helpers.js';
|
|
8
|
+
|
|
9
|
+
export function ModelSelector({
|
|
10
|
+
availableModels,
|
|
11
|
+
selectedModel,
|
|
12
|
+
onSelectModel,
|
|
13
|
+
disabled
|
|
14
|
+
}) {
|
|
15
|
+
const [isOpen, setIsOpen] = useState(false);
|
|
16
|
+
|
|
17
|
+
if (!availableModels || availableModels.length === 0) {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const selectedModelInfo = availableModels.find(m => m.id === selectedModel);
|
|
22
|
+
const displayName = selectedModelInfo?.name || 'Select Model';
|
|
23
|
+
|
|
24
|
+
const handleToggle = () => {
|
|
25
|
+
if (!disabled) {
|
|
26
|
+
setIsOpen(!isOpen);
|
|
27
|
+
}
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const handleSelect = (modelId) => {
|
|
31
|
+
onSelectModel(modelId);
|
|
32
|
+
setIsOpen(false);
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
return html`
|
|
36
|
+
<div class="cw-model-selector">
|
|
37
|
+
<button
|
|
38
|
+
class="cw-model-btn"
|
|
39
|
+
onClick=${handleToggle}
|
|
40
|
+
disabled=${disabled}
|
|
41
|
+
title="Select Model"
|
|
42
|
+
>
|
|
43
|
+
<span class="cw-model-icon">🤖</span>
|
|
44
|
+
<span class="cw-model-name">${escapeHtml(displayName)}</span>
|
|
45
|
+
<span class="cw-model-chevron">${isOpen ? '▲' : '▼'}</span>
|
|
46
|
+
</button>
|
|
47
|
+
|
|
48
|
+
${isOpen && html`
|
|
49
|
+
<div class="cw-model-dropdown">
|
|
50
|
+
${availableModels.map(model => html`
|
|
51
|
+
<button
|
|
52
|
+
key=${model.id}
|
|
53
|
+
class="cw-model-option ${model.id === selectedModel ? 'cw-model-option-selected' : ''}"
|
|
54
|
+
onClick=${() => handleSelect(model.id)}
|
|
55
|
+
>
|
|
56
|
+
<span class="cw-model-option-name">${escapeHtml(model.name)}</span>
|
|
57
|
+
<span class="cw-model-option-provider">${escapeHtml(model.provider)}</span>
|
|
58
|
+
${model.description && html`
|
|
59
|
+
<span class="cw-model-option-desc">${escapeHtml(model.description)}</span>
|
|
60
|
+
`}
|
|
61
|
+
</button>
|
|
62
|
+
`)}
|
|
63
|
+
</div>
|
|
64
|
+
`}
|
|
65
|
+
</div>
|
|
66
|
+
`;
|
|
67
|
+
}
|
|
68
|
+
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sidebar component - conversation list
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { html } from 'htm/preact';
|
|
6
|
+
import { escapeHtml, formatDate } from '../utils/helpers.js';
|
|
7
|
+
|
|
8
|
+
export function Sidebar({
|
|
9
|
+
isOpen,
|
|
10
|
+
conversations,
|
|
11
|
+
conversationsLoading,
|
|
12
|
+
currentConversationId,
|
|
13
|
+
onClose,
|
|
14
|
+
onNewConversation,
|
|
15
|
+
onSwitchConversation,
|
|
16
|
+
}) {
|
|
17
|
+
return html`
|
|
18
|
+
<div class="cw-sidebar ${isOpen ? 'cw-sidebar-open' : ''}">
|
|
19
|
+
<div class="cw-sidebar-header">
|
|
20
|
+
<span>Conversations</span>
|
|
21
|
+
<button class="cw-sidebar-close" onClick=${onClose}>✕</button>
|
|
22
|
+
</div>
|
|
23
|
+
|
|
24
|
+
<button class="cw-new-conversation" onClick=${onNewConversation}>
|
|
25
|
+
<span>+ New Conversation</span>
|
|
26
|
+
</button>
|
|
27
|
+
|
|
28
|
+
<div class="cw-conversation-list">
|
|
29
|
+
${conversationsLoading && html`
|
|
30
|
+
<div class="cw-sidebar-loading">
|
|
31
|
+
<span class="cw-spinner"></span>
|
|
32
|
+
</div>
|
|
33
|
+
`}
|
|
34
|
+
|
|
35
|
+
${!conversationsLoading && conversations.length === 0 && html`
|
|
36
|
+
<div class="cw-sidebar-empty">No conversations yet</div>
|
|
37
|
+
`}
|
|
38
|
+
|
|
39
|
+
${conversations.map(conv => html`
|
|
40
|
+
<div
|
|
41
|
+
key=${conv.id}
|
|
42
|
+
class="cw-conversation-item ${conv.id === currentConversationId ? 'cw-conversation-active' : ''}"
|
|
43
|
+
onClick=${() => onSwitchConversation(conv.id)}
|
|
44
|
+
>
|
|
45
|
+
<div class="cw-conversation-title">${escapeHtml(conv.title || 'Untitled')}</div>
|
|
46
|
+
<div class="cw-conversation-date">${formatDate(conv.updatedAt || conv.createdAt)}</div>
|
|
47
|
+
</div>
|
|
48
|
+
`)}
|
|
49
|
+
</div>
|
|
50
|
+
</div>
|
|
51
|
+
|
|
52
|
+
<div
|
|
53
|
+
class="cw-sidebar-overlay ${isOpen ? 'cw-sidebar-overlay-visible' : ''}"
|
|
54
|
+
onClick=${onClose}
|
|
55
|
+
/>
|
|
56
|
+
`;
|
|
57
|
+
}
|
|
58
|
+
|
|
@@ -0,0 +1,455 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Chat hook - manages messages, sending, and SSE streaming
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { useState, useCallback, useRef, useEffect } from 'preact/hooks';
|
|
6
|
+
import { generateId } from '../utils/helpers.js';
|
|
7
|
+
|
|
8
|
+
export function useChat(config, api, storage) {
|
|
9
|
+
const [messages, setMessages] = useState([]);
|
|
10
|
+
const [isLoading, setIsLoading] = useState(false);
|
|
11
|
+
const [error, setError] = useState(null);
|
|
12
|
+
const [conversationId, setConversationId] = useState(() => storage?.get(config.conversationIdKey) || null);
|
|
13
|
+
const [hasMoreMessages, setHasMoreMessages] = useState(false);
|
|
14
|
+
const [loadingMoreMessages, setLoadingMoreMessages] = useState(false);
|
|
15
|
+
const [messagesOffset, setMessagesOffset] = useState(0);
|
|
16
|
+
|
|
17
|
+
const eventSourceRef = useRef(null);
|
|
18
|
+
const currentRunIdRef = useRef(null);
|
|
19
|
+
|
|
20
|
+
// Save conversation ID to storage when it changes
|
|
21
|
+
useEffect(() => {
|
|
22
|
+
if (conversationId) {
|
|
23
|
+
storage?.set(config.conversationIdKey, conversationId);
|
|
24
|
+
}
|
|
25
|
+
}, [conversationId, config.conversationIdKey, storage]);
|
|
26
|
+
|
|
27
|
+
const subscribeToEvents = useCallback(async (runId, token, onAssistantMessage) => {
|
|
28
|
+
if (eventSourceRef.current) eventSourceRef.current.close();
|
|
29
|
+
|
|
30
|
+
const eventPath = config.apiPaths.runEvents.replace('{runId}', runId);
|
|
31
|
+
let url = `${config.backendUrl}${eventPath}`;
|
|
32
|
+
if (token) url += `?anonymous_token=${encodeURIComponent(token)}`;
|
|
33
|
+
|
|
34
|
+
const eventSource = new EventSource(url);
|
|
35
|
+
eventSourceRef.current = eventSource;
|
|
36
|
+
let assistantContent = '';
|
|
37
|
+
|
|
38
|
+
eventSource.addEventListener('assistant.message', (event) => {
|
|
39
|
+
try {
|
|
40
|
+
const data = JSON.parse(event.data);
|
|
41
|
+
if (config.onEvent) config.onEvent('assistant.message', data.payload);
|
|
42
|
+
const content = data.payload.content;
|
|
43
|
+
if (content) {
|
|
44
|
+
assistantContent += content;
|
|
45
|
+
setMessages(prev => {
|
|
46
|
+
const last = prev[prev.length - 1];
|
|
47
|
+
if (last?.role === 'assistant' && last.id.startsWith('assistant-stream-')) {
|
|
48
|
+
return [...prev.slice(0, -1), { ...last, content: assistantContent }];
|
|
49
|
+
}
|
|
50
|
+
return [...prev, {
|
|
51
|
+
id: 'assistant-stream-' + Date.now(),
|
|
52
|
+
role: 'assistant',
|
|
53
|
+
content: assistantContent,
|
|
54
|
+
timestamp: new Date(),
|
|
55
|
+
type: 'message',
|
|
56
|
+
}];
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
} catch (err) { console.error('[ChatWidget] Parse error:', err); }
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
eventSource.addEventListener('tool.call', (event) => {
|
|
63
|
+
try {
|
|
64
|
+
const data = JSON.parse(event.data);
|
|
65
|
+
if (config.onEvent) config.onEvent('tool.call', data.payload);
|
|
66
|
+
// Add tool call to messages
|
|
67
|
+
setMessages(prev => [...prev, {
|
|
68
|
+
id: 'tool-call-' + Date.now(),
|
|
69
|
+
role: 'assistant',
|
|
70
|
+
content: `🔧 ${data.payload.name}`,
|
|
71
|
+
timestamp: new Date(),
|
|
72
|
+
type: 'tool_call',
|
|
73
|
+
metadata: {
|
|
74
|
+
toolName: data.payload.name,
|
|
75
|
+
arguments: data.payload.arguments,
|
|
76
|
+
toolCallId: data.payload.id,
|
|
77
|
+
},
|
|
78
|
+
}]);
|
|
79
|
+
} catch (err) { console.error('[ChatWidget] Parse error:', err); }
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
eventSource.addEventListener('tool.result', (event) => {
|
|
83
|
+
try {
|
|
84
|
+
const data = JSON.parse(event.data);
|
|
85
|
+
if (config.onEvent) config.onEvent('tool.result', data.payload);
|
|
86
|
+
// Add tool result to messages
|
|
87
|
+
const result = data.payload.result;
|
|
88
|
+
const isError = result?.error;
|
|
89
|
+
setMessages(prev => [...prev, {
|
|
90
|
+
id: 'tool-result-' + Date.now(),
|
|
91
|
+
role: 'system',
|
|
92
|
+
content: isError ? `❌ ${result.error}` : `✓ Done`,
|
|
93
|
+
timestamp: new Date(),
|
|
94
|
+
type: 'tool_result',
|
|
95
|
+
metadata: {
|
|
96
|
+
toolName: data.payload.name,
|
|
97
|
+
result: result,
|
|
98
|
+
toolCallId: data.payload.tool_call_id,
|
|
99
|
+
},
|
|
100
|
+
}]);
|
|
101
|
+
} catch (err) { console.error('[ChatWidget] Parse error:', err); }
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
// Handle custom events (e.g., UI control from builder agent)
|
|
105
|
+
eventSource.addEventListener('custom', (event) => {
|
|
106
|
+
try {
|
|
107
|
+
const data = JSON.parse(event.data);
|
|
108
|
+
if (config.onEvent) config.onEvent('custom', data.payload);
|
|
109
|
+
// Custom events can be used for UI control, agent switching, etc.
|
|
110
|
+
if (data.payload?.type === 'ui_control') {
|
|
111
|
+
// Emit a special callback for UI control events
|
|
112
|
+
if (config.onUIControl) {
|
|
113
|
+
config.onUIControl(data.payload);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
// Handle sub-agent context changes
|
|
117
|
+
if (data.payload?.type === 'agent_context') {
|
|
118
|
+
setMessages(prev => [...prev, {
|
|
119
|
+
id: 'agent-context-' + Date.now(),
|
|
120
|
+
role: 'system',
|
|
121
|
+
content: `🔗 ${data.payload.agent_name || 'Sub-agent'} is now handling this request`,
|
|
122
|
+
timestamp: new Date(),
|
|
123
|
+
type: 'agent_context',
|
|
124
|
+
metadata: {
|
|
125
|
+
agentKey: data.payload.agent_key,
|
|
126
|
+
agentName: data.payload.agent_name,
|
|
127
|
+
action: data.payload.action,
|
|
128
|
+
},
|
|
129
|
+
}]);
|
|
130
|
+
}
|
|
131
|
+
} catch (err) { console.error('[ChatWidget] Parse error:', err); }
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
// Handle sub-agent invocation events
|
|
135
|
+
eventSource.addEventListener('sub_agent.start', (event) => {
|
|
136
|
+
try {
|
|
137
|
+
const data = JSON.parse(event.data);
|
|
138
|
+
if (config.onEvent) config.onEvent('sub_agent.start', data.payload);
|
|
139
|
+
setMessages(prev => [...prev, {
|
|
140
|
+
id: 'sub-agent-start-' + Date.now(),
|
|
141
|
+
role: 'system',
|
|
142
|
+
content: `🔗 Delegating to ${data.payload.agent_name || data.payload.sub_agent_key || 'sub-agent'}...`,
|
|
143
|
+
timestamp: new Date(),
|
|
144
|
+
type: 'sub_agent_start',
|
|
145
|
+
metadata: {
|
|
146
|
+
subAgentKey: data.payload.sub_agent_key,
|
|
147
|
+
agentName: data.payload.agent_name,
|
|
148
|
+
invocationMode: data.payload.invocation_mode,
|
|
149
|
+
},
|
|
150
|
+
}]);
|
|
151
|
+
} catch (err) { console.error('[ChatWidget] Parse error:', err); }
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
eventSource.addEventListener('sub_agent.end', (event) => {
|
|
155
|
+
try {
|
|
156
|
+
const data = JSON.parse(event.data);
|
|
157
|
+
if (config.onEvent) config.onEvent('sub_agent.end', data.payload);
|
|
158
|
+
setMessages(prev => [...prev, {
|
|
159
|
+
id: 'sub-agent-end-' + Date.now(),
|
|
160
|
+
role: 'system',
|
|
161
|
+
content: `✓ ${data.payload.agent_name || 'Sub-agent'} completed`,
|
|
162
|
+
timestamp: new Date(),
|
|
163
|
+
type: 'sub_agent_end',
|
|
164
|
+
metadata: {
|
|
165
|
+
subAgentKey: data.payload.sub_agent_key,
|
|
166
|
+
agentName: data.payload.agent_name,
|
|
167
|
+
},
|
|
168
|
+
}]);
|
|
169
|
+
} catch (err) { console.error('[ChatWidget] Parse error:', err); }
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
const handleTerminal = (event) => {
|
|
173
|
+
try {
|
|
174
|
+
const data = JSON.parse(event.data);
|
|
175
|
+
if (config.onEvent) config.onEvent(data.type, data.payload);
|
|
176
|
+
if (data.type === 'run.failed') {
|
|
177
|
+
const errMsg = data.payload.error || 'Agent run failed';
|
|
178
|
+
setError(errMsg);
|
|
179
|
+
setMessages(prev => [...prev, {
|
|
180
|
+
id: 'error-' + Date.now(),
|
|
181
|
+
role: 'system',
|
|
182
|
+
content: `❌ Error: ${errMsg}`,
|
|
183
|
+
timestamp: new Date(),
|
|
184
|
+
type: 'error',
|
|
185
|
+
}]);
|
|
186
|
+
}
|
|
187
|
+
} catch (err) { console.error('[ChatWidget] Parse error:', err); }
|
|
188
|
+
setIsLoading(false);
|
|
189
|
+
eventSource.close();
|
|
190
|
+
eventSourceRef.current = null;
|
|
191
|
+
if (assistantContent && onAssistantMessage) {
|
|
192
|
+
onAssistantMessage(assistantContent);
|
|
193
|
+
}
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
eventSource.addEventListener('run.succeeded', handleTerminal);
|
|
197
|
+
eventSource.addEventListener('run.failed', handleTerminal);
|
|
198
|
+
eventSource.addEventListener('run.cancelled', handleTerminal);
|
|
199
|
+
eventSource.addEventListener('run.timed_out', handleTerminal);
|
|
200
|
+
|
|
201
|
+
eventSource.onerror = () => {
|
|
202
|
+
setIsLoading(false);
|
|
203
|
+
eventSource.close();
|
|
204
|
+
eventSourceRef.current = null;
|
|
205
|
+
};
|
|
206
|
+
}, [config]);
|
|
207
|
+
|
|
208
|
+
const sendMessage = useCallback(async (content, options = {}) => {
|
|
209
|
+
if (!content.trim() || isLoading) return;
|
|
210
|
+
|
|
211
|
+
const { model, onAssistantMessage } = typeof options === 'function'
|
|
212
|
+
? { onAssistantMessage: options }
|
|
213
|
+
: options;
|
|
214
|
+
|
|
215
|
+
setIsLoading(true);
|
|
216
|
+
setError(null);
|
|
217
|
+
|
|
218
|
+
const userMessage = {
|
|
219
|
+
id: generateId(),
|
|
220
|
+
role: 'user',
|
|
221
|
+
content: content.trim(),
|
|
222
|
+
timestamp: new Date(),
|
|
223
|
+
type: 'message',
|
|
224
|
+
};
|
|
225
|
+
setMessages(prev => [...prev, userMessage]);
|
|
226
|
+
|
|
227
|
+
try {
|
|
228
|
+
const token = await api.getOrCreateSession();
|
|
229
|
+
|
|
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
|
+
};
|
|
236
|
+
|
|
237
|
+
// Include model if specified
|
|
238
|
+
if (model) {
|
|
239
|
+
requestBody.model = model;
|
|
240
|
+
}
|
|
241
|
+
|
|
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
|
+
}));
|
|
247
|
+
|
|
248
|
+
if (!response.ok) {
|
|
249
|
+
const errorData = await response.json().catch(() => ({}));
|
|
250
|
+
throw new Error(errorData.error || `HTTP ${response.status}`);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const run = await response.json();
|
|
254
|
+
currentRunIdRef.current = run.id;
|
|
255
|
+
const runConversationId = run.conversationId || run.conversation_id;
|
|
256
|
+
if (!conversationId && runConversationId) {
|
|
257
|
+
setConversationId(runConversationId);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
await subscribeToEvents(run.id, token, onAssistantMessage);
|
|
261
|
+
} catch (err) {
|
|
262
|
+
setError(err.message || 'Failed to send message');
|
|
263
|
+
setIsLoading(false);
|
|
264
|
+
} finally {
|
|
265
|
+
currentRunIdRef.current = null;
|
|
266
|
+
}
|
|
267
|
+
}, [config, api, conversationId, isLoading, subscribeToEvents]);
|
|
268
|
+
|
|
269
|
+
const cancelRun = useCallback(async () => {
|
|
270
|
+
const runId = currentRunIdRef.current;
|
|
271
|
+
if (!runId || !isLoading) return;
|
|
272
|
+
|
|
273
|
+
try {
|
|
274
|
+
// Build the cancel URL - replace {runId} placeholder or append to runs path
|
|
275
|
+
const cancelPath = config.apiPaths.cancelRun
|
|
276
|
+
? config.apiPaths.cancelRun.replace('{runId}', runId)
|
|
277
|
+
: `${config.apiPaths.runs}${runId}/cancel/`;
|
|
278
|
+
|
|
279
|
+
const response = await fetch(`${config.backendUrl}${cancelPath}`, api.getFetchOptions({
|
|
280
|
+
method: 'POST',
|
|
281
|
+
headers: { 'Content-Type': 'application/json' },
|
|
282
|
+
}));
|
|
283
|
+
|
|
284
|
+
if (response.ok) {
|
|
285
|
+
// Close the event source immediately
|
|
286
|
+
if (eventSourceRef.current) {
|
|
287
|
+
eventSourceRef.current.close();
|
|
288
|
+
eventSourceRef.current = null;
|
|
289
|
+
}
|
|
290
|
+
setIsLoading(false);
|
|
291
|
+
currentRunIdRef.current = null;
|
|
292
|
+
|
|
293
|
+
// Add a cancelled message
|
|
294
|
+
setMessages(prev => [...prev, {
|
|
295
|
+
id: 'cancelled-' + Date.now(),
|
|
296
|
+
role: 'system',
|
|
297
|
+
content: '⏹ Run cancelled',
|
|
298
|
+
timestamp: new Date(),
|
|
299
|
+
type: 'cancelled',
|
|
300
|
+
}]);
|
|
301
|
+
}
|
|
302
|
+
} catch (err) {
|
|
303
|
+
console.error('[ChatWidget] Failed to cancel run:', err);
|
|
304
|
+
}
|
|
305
|
+
}, [config, api, isLoading]);
|
|
306
|
+
|
|
307
|
+
const clearMessages = useCallback(() => {
|
|
308
|
+
setMessages([]);
|
|
309
|
+
setConversationId(null);
|
|
310
|
+
setError(null);
|
|
311
|
+
setHasMoreMessages(false);
|
|
312
|
+
setMessagesOffset(0);
|
|
313
|
+
storage?.set(config.conversationIdKey, null);
|
|
314
|
+
}, [config.conversationIdKey, storage]);
|
|
315
|
+
|
|
316
|
+
// Map API message format to internal message format with proper types
|
|
317
|
+
const mapApiMessage = (m) => {
|
|
318
|
+
const base = {
|
|
319
|
+
id: generateId(),
|
|
320
|
+
role: m.role,
|
|
321
|
+
timestamp: m.timestamp ? new Date(m.timestamp) : new Date(),
|
|
322
|
+
};
|
|
323
|
+
|
|
324
|
+
// Tool result messages (role: "tool")
|
|
325
|
+
if (m.role === 'tool') {
|
|
326
|
+
return {
|
|
327
|
+
...base,
|
|
328
|
+
role: 'system',
|
|
329
|
+
content: '✓ Done',
|
|
330
|
+
type: 'tool_result',
|
|
331
|
+
metadata: {
|
|
332
|
+
result: m.content,
|
|
333
|
+
toolCallId: m.tool_call_id,
|
|
334
|
+
},
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// Assistant messages with tool calls
|
|
339
|
+
if (m.role === 'assistant' && m.tool_calls && m.tool_calls.length > 0) {
|
|
340
|
+
// Return an array of tool call messages (will be flattened)
|
|
341
|
+
return m.tool_calls.map(tc => ({
|
|
342
|
+
id: generateId(),
|
|
343
|
+
role: 'assistant',
|
|
344
|
+
content: `🔧 ${tc.function?.name || tc.name || 'tool'}`,
|
|
345
|
+
timestamp: base.timestamp,
|
|
346
|
+
type: 'tool_call',
|
|
347
|
+
metadata: {
|
|
348
|
+
toolName: tc.function?.name || tc.name,
|
|
349
|
+
arguments: tc.function?.arguments || tc.arguments,
|
|
350
|
+
toolCallId: tc.id,
|
|
351
|
+
},
|
|
352
|
+
}));
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// Skip empty assistant messages (e.g., tool-call-only responses)
|
|
356
|
+
const content = typeof m.content === 'string' ? m.content : JSON.stringify(m.content);
|
|
357
|
+
if (m.role === 'assistant' && !content?.trim()) {
|
|
358
|
+
return null; // Will be filtered out
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// Regular messages
|
|
362
|
+
return {
|
|
363
|
+
...base,
|
|
364
|
+
content,
|
|
365
|
+
type: 'message',
|
|
366
|
+
};
|
|
367
|
+
};
|
|
368
|
+
|
|
369
|
+
const loadConversation = useCallback(async (convId) => {
|
|
370
|
+
setIsLoading(true);
|
|
371
|
+
setMessages([]);
|
|
372
|
+
setConversationId(convId);
|
|
373
|
+
|
|
374
|
+
try {
|
|
375
|
+
const token = await api.getOrCreateSession();
|
|
376
|
+
const limit = 10;
|
|
377
|
+
const url = `${config.backendUrl}${config.apiPaths.conversations}${convId}/?limit=${limit}&offset=0`;
|
|
378
|
+
|
|
379
|
+
const response = await fetch(url, api.getFetchOptions({ method: 'GET' }));
|
|
380
|
+
|
|
381
|
+
if (response.ok) {
|
|
382
|
+
const conversation = await response.json();
|
|
383
|
+
if (conversation.messages) {
|
|
384
|
+
// Use flatMap to handle tool_calls which return arrays, filter out nulls (empty messages)
|
|
385
|
+
setMessages(conversation.messages.flatMap(mapApiMessage).filter(Boolean));
|
|
386
|
+
}
|
|
387
|
+
setHasMoreMessages(conversation.has_more || conversation.hasMore || false);
|
|
388
|
+
setMessagesOffset(conversation.messages?.length || 0);
|
|
389
|
+
} else if (response.status === 404) {
|
|
390
|
+
setConversationId(null);
|
|
391
|
+
storage?.set(config.conversationIdKey, null);
|
|
392
|
+
}
|
|
393
|
+
} catch (err) {
|
|
394
|
+
console.error('[ChatWidget] Failed to load conversation:', err);
|
|
395
|
+
} finally {
|
|
396
|
+
setIsLoading(false);
|
|
397
|
+
}
|
|
398
|
+
}, [config, api, storage]);
|
|
399
|
+
|
|
400
|
+
const loadMoreMessages = useCallback(async () => {
|
|
401
|
+
if (!conversationId || loadingMoreMessages || !hasMoreMessages) return;
|
|
402
|
+
|
|
403
|
+
setLoadingMoreMessages(true);
|
|
404
|
+
|
|
405
|
+
try {
|
|
406
|
+
const limit = 10;
|
|
407
|
+
const url = `${config.backendUrl}${config.apiPaths.conversations}${conversationId}/?limit=${limit}&offset=${messagesOffset}`;
|
|
408
|
+
|
|
409
|
+
const response = await fetch(url, api.getFetchOptions({ method: 'GET' }));
|
|
410
|
+
|
|
411
|
+
if (response.ok) {
|
|
412
|
+
const conversation = await response.json();
|
|
413
|
+
if (conversation.messages?.length > 0) {
|
|
414
|
+
// Use flatMap to handle tool_calls which return arrays, filter out nulls (empty messages)
|
|
415
|
+
const olderMessages = conversation.messages.flatMap(mapApiMessage).filter(Boolean);
|
|
416
|
+
setMessages(prev => [...olderMessages, ...prev]);
|
|
417
|
+
// Use original message count for offset, not flattened count
|
|
418
|
+
setMessagesOffset(prev => prev + conversation.messages.length);
|
|
419
|
+
setHasMoreMessages(conversation.has_more || conversation.hasMore || false);
|
|
420
|
+
} else {
|
|
421
|
+
setHasMoreMessages(false);
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
} catch (err) {
|
|
425
|
+
console.error('[ChatWidget] Failed to load more messages:', err);
|
|
426
|
+
} finally {
|
|
427
|
+
setLoadingMoreMessages(false);
|
|
428
|
+
}
|
|
429
|
+
}, [config, api, conversationId, messagesOffset, loadingMoreMessages, hasMoreMessages]);
|
|
430
|
+
|
|
431
|
+
// Cleanup on unmount
|
|
432
|
+
useEffect(() => {
|
|
433
|
+
return () => {
|
|
434
|
+
if (eventSourceRef.current) {
|
|
435
|
+
eventSourceRef.current.close();
|
|
436
|
+
}
|
|
437
|
+
};
|
|
438
|
+
}, []);
|
|
439
|
+
|
|
440
|
+
return {
|
|
441
|
+
messages,
|
|
442
|
+
isLoading,
|
|
443
|
+
error,
|
|
444
|
+
conversationId,
|
|
445
|
+
hasMoreMessages,
|
|
446
|
+
loadingMoreMessages,
|
|
447
|
+
sendMessage,
|
|
448
|
+
cancelRun,
|
|
449
|
+
clearMessages,
|
|
450
|
+
loadConversation,
|
|
451
|
+
loadMoreMessages,
|
|
452
|
+
setConversationId,
|
|
453
|
+
};
|
|
454
|
+
}
|
|
455
|
+
|