@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
package/package.json
CHANGED
|
@@ -1,18 +1,24 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@makemore/agent-frontend",
|
|
3
|
-
"version": "
|
|
4
|
-
"description": "A
|
|
3
|
+
"version": "2.0.0",
|
|
4
|
+
"description": "A lightweight chat widget for AI agents built with Preact. Embed conversational AI into any website with a single script tag.",
|
|
5
|
+
"type": "module",
|
|
5
6
|
"main": "dist/chat-widget.js",
|
|
7
|
+
"module": "src/index.js",
|
|
6
8
|
"files": [
|
|
7
9
|
"dist/chat-widget.js",
|
|
8
10
|
"dist/chat-widget.css",
|
|
9
11
|
"dist/chat-widget-markdown.js",
|
|
12
|
+
"src/",
|
|
10
13
|
"README.md",
|
|
11
14
|
"LICENSE"
|
|
12
15
|
],
|
|
13
16
|
"scripts": {
|
|
14
|
-
"
|
|
15
|
-
"
|
|
17
|
+
"build": "esbuild src/index.js --bundle --minify --format=iife --global-name=ChatWidgetModule --outfile=dist/chat-widget.js && npm run copy",
|
|
18
|
+
"build:dev": "esbuild src/index.js --bundle --format=iife --global-name=ChatWidgetModule --outfile=dist/chat-widget.js --sourcemap && npm run copy",
|
|
19
|
+
"watch": "node watch.js",
|
|
20
|
+
"copy": "cp dist/chat-widget.js ../django_agent_studio/static/agent-frontend/chat-widget.js && echo 'Copied to django_agent_studio'",
|
|
21
|
+
"prepublishOnly": "npm run build",
|
|
16
22
|
"serve": "python -m http.server 8080"
|
|
17
23
|
},
|
|
18
24
|
"repository": {
|
|
@@ -27,8 +33,7 @@
|
|
|
27
33
|
"agent",
|
|
28
34
|
"sse",
|
|
29
35
|
"streaming",
|
|
30
|
-
"
|
|
31
|
-
"zero-dependencies",
|
|
36
|
+
"preact",
|
|
32
37
|
"embeddable",
|
|
33
38
|
"chat-widget",
|
|
34
39
|
"conversational-ai",
|
|
@@ -41,7 +46,14 @@
|
|
|
41
46
|
},
|
|
42
47
|
"homepage": "https://github.com/makemore/agent-frontend#readme",
|
|
43
48
|
"engines": {
|
|
44
|
-
"node": ">=
|
|
49
|
+
"node": ">=14.0.0"
|
|
50
|
+
},
|
|
51
|
+
"dependencies": {
|
|
52
|
+
"htm": "^3.1.1",
|
|
53
|
+
"preact": "^10.19.3"
|
|
54
|
+
},
|
|
55
|
+
"devDependencies": {
|
|
56
|
+
"esbuild": "^0.20.0"
|
|
45
57
|
}
|
|
46
58
|
}
|
|
47
59
|
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ChatWidget component - main widget container
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { html } from 'htm/preact';
|
|
6
|
+
import { useState, useEffect, useCallback, useMemo } from 'preact/hooks';
|
|
7
|
+
import { Header } from './Header.js';
|
|
8
|
+
import { MessageList } from './MessageList.js';
|
|
9
|
+
import { InputForm } from './InputForm.js';
|
|
10
|
+
import { Sidebar } from './Sidebar.js';
|
|
11
|
+
import { ModelSelector } from './ModelSelector.js';
|
|
12
|
+
import { useChat } from '../hooks/useChat.js';
|
|
13
|
+
import { useModels } from '../hooks/useModels.js';
|
|
14
|
+
import { createApiClient } from '../utils/api.js';
|
|
15
|
+
import { createStorage } from '../utils/helpers.js';
|
|
16
|
+
|
|
17
|
+
export function ChatWidget({ config, onStateChange, markdownParser, apiRef }) {
|
|
18
|
+
// UI state
|
|
19
|
+
const [isOpen, setIsOpen] = useState(config.embedded || config.forceOpen === true);
|
|
20
|
+
const [isExpanded, setIsExpanded] = useState(false);
|
|
21
|
+
const [debugMode, setDebugMode] = useState(false);
|
|
22
|
+
const [sidebarOpen, setSidebarOpen] = useState(false);
|
|
23
|
+
const [conversations, setConversations] = useState([]);
|
|
24
|
+
const [conversationsLoading, setConversationsLoading] = useState(false);
|
|
25
|
+
|
|
26
|
+
// TTS state
|
|
27
|
+
const [enableTTS, setEnableTTS] = useState(config.enableTTS);
|
|
28
|
+
const [isSpeaking, setIsSpeaking] = useState(false);
|
|
29
|
+
|
|
30
|
+
// Current agent state (for multi-agent systems)
|
|
31
|
+
const [currentAgent, setCurrentAgent] = useState(null);
|
|
32
|
+
|
|
33
|
+
// Handle forceOpen changes from parent
|
|
34
|
+
useEffect(() => {
|
|
35
|
+
if (config.forceOpen !== undefined) {
|
|
36
|
+
setIsOpen(config.forceOpen);
|
|
37
|
+
}
|
|
38
|
+
}, [config.forceOpen]);
|
|
39
|
+
|
|
40
|
+
// Create storage helper
|
|
41
|
+
const storage = useMemo(() => createStorage(config.containerId), [config.containerId]);
|
|
42
|
+
|
|
43
|
+
// Create API client
|
|
44
|
+
const [authToken, setAuthToken] = useState(config.authToken || null);
|
|
45
|
+
const api = useMemo(() => {
|
|
46
|
+
const getState = () => ({ authToken, storage });
|
|
47
|
+
const setState = (fn) => {
|
|
48
|
+
const newState = fn({ authToken, storage });
|
|
49
|
+
if (newState.authToken !== authToken) setAuthToken(newState.authToken);
|
|
50
|
+
};
|
|
51
|
+
return createApiClient(config, getState, setState);
|
|
52
|
+
}, [config, authToken, storage]);
|
|
53
|
+
|
|
54
|
+
// Chat hook
|
|
55
|
+
const chat = useChat(config, api, storage);
|
|
56
|
+
|
|
57
|
+
// Models hook
|
|
58
|
+
const models = useModels(config, api, storage);
|
|
59
|
+
|
|
60
|
+
// Track current agent from messages (for multi-agent systems)
|
|
61
|
+
useEffect(() => {
|
|
62
|
+
// Look for the most recent sub_agent_start or sub_agent_end message
|
|
63
|
+
for (let i = chat.messages.length - 1; i >= 0; i--) {
|
|
64
|
+
const msg = chat.messages[i];
|
|
65
|
+
if (msg.type === 'sub_agent_start') {
|
|
66
|
+
setCurrentAgent({
|
|
67
|
+
key: msg.metadata?.subAgentKey,
|
|
68
|
+
name: msg.metadata?.agentName,
|
|
69
|
+
});
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
if (msg.type === 'sub_agent_end') {
|
|
73
|
+
// Sub-agent finished, clear the current agent indicator
|
|
74
|
+
setCurrentAgent(null);
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}, [chat.messages]);
|
|
79
|
+
|
|
80
|
+
// Load initial conversation if stored
|
|
81
|
+
useEffect(() => {
|
|
82
|
+
const storedConvId = storage.get(config.conversationIdKey);
|
|
83
|
+
if (storedConvId) {
|
|
84
|
+
chat.loadConversation(storedConvId);
|
|
85
|
+
}
|
|
86
|
+
}, []);
|
|
87
|
+
|
|
88
|
+
// Notify parent of state changes
|
|
89
|
+
useEffect(() => {
|
|
90
|
+
if (onStateChange) {
|
|
91
|
+
onStateChange({
|
|
92
|
+
isOpen,
|
|
93
|
+
isExpanded,
|
|
94
|
+
debugMode,
|
|
95
|
+
messages: chat.messages,
|
|
96
|
+
conversationId: chat.conversationId,
|
|
97
|
+
isLoading: chat.isLoading,
|
|
98
|
+
error: chat.error,
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
}, [isOpen, isExpanded, debugMode, chat.messages, chat.conversationId, chat.isLoading, chat.error]);
|
|
102
|
+
|
|
103
|
+
// Load conversations for sidebar
|
|
104
|
+
const loadConversations = useCallback(async () => {
|
|
105
|
+
if (!config.showConversationSidebar) return;
|
|
106
|
+
|
|
107
|
+
setConversationsLoading(true);
|
|
108
|
+
try {
|
|
109
|
+
const url = `${config.backendUrl}${config.apiPaths.conversations}?agent_key=${encodeURIComponent(config.agentKey)}`;
|
|
110
|
+
const response = await fetch(url, api.getFetchOptions({ method: 'GET' }));
|
|
111
|
+
|
|
112
|
+
if (response.ok) {
|
|
113
|
+
const data = await response.json();
|
|
114
|
+
setConversations(data.results || data);
|
|
115
|
+
}
|
|
116
|
+
} catch (err) {
|
|
117
|
+
console.error('[ChatWidget] Failed to load conversations:', err);
|
|
118
|
+
setConversations([]);
|
|
119
|
+
} finally {
|
|
120
|
+
setConversationsLoading(false);
|
|
121
|
+
}
|
|
122
|
+
}, [config, api]);
|
|
123
|
+
|
|
124
|
+
// Handlers
|
|
125
|
+
const handleToggleSidebar = useCallback(() => {
|
|
126
|
+
const newOpen = !sidebarOpen;
|
|
127
|
+
setSidebarOpen(newOpen);
|
|
128
|
+
if (newOpen) loadConversations();
|
|
129
|
+
}, [sidebarOpen, loadConversations]);
|
|
130
|
+
|
|
131
|
+
const handleSwitchConversation = useCallback((convId) => {
|
|
132
|
+
if (convId !== chat.conversationId) {
|
|
133
|
+
chat.loadConversation(convId);
|
|
134
|
+
}
|
|
135
|
+
setSidebarOpen(false);
|
|
136
|
+
}, [chat]);
|
|
137
|
+
|
|
138
|
+
const handleNewConversation = useCallback(() => {
|
|
139
|
+
chat.clearMessages();
|
|
140
|
+
setSidebarOpen(false);
|
|
141
|
+
}, [chat]);
|
|
142
|
+
|
|
143
|
+
const handleSend = useCallback((content) => {
|
|
144
|
+
chat.sendMessage(content, {
|
|
145
|
+
model: models.selectedModel,
|
|
146
|
+
onAssistantMessage: (assistantContent) => {
|
|
147
|
+
// TTS callback when assistant finishes
|
|
148
|
+
if (enableTTS && assistantContent) {
|
|
149
|
+
// TTS would be handled here
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
}, [chat, enableTTS, models.selectedModel]);
|
|
154
|
+
|
|
155
|
+
// Expose imperative API to parent
|
|
156
|
+
useEffect(() => {
|
|
157
|
+
if (apiRef) {
|
|
158
|
+
apiRef.current = {
|
|
159
|
+
open: () => setIsOpen(true),
|
|
160
|
+
close: () => setIsOpen(false),
|
|
161
|
+
send: (msg) => handleSend(msg),
|
|
162
|
+
clearMessages: () => chat.clearMessages(),
|
|
163
|
+
toggleTTS: () => setEnableTTS(v => !v),
|
|
164
|
+
stopSpeech: () => setIsSpeaking(false),
|
|
165
|
+
setAuth: (cfg) => {
|
|
166
|
+
if (cfg.token !== undefined) setAuthToken(cfg.token);
|
|
167
|
+
},
|
|
168
|
+
clearAuth: () => setAuthToken(null),
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
}, [chat, apiRef, handleSend]);
|
|
172
|
+
|
|
173
|
+
// Floating mode: show FAB when closed
|
|
174
|
+
if (!config.embedded && !isOpen) {
|
|
175
|
+
return html`
|
|
176
|
+
<button
|
|
177
|
+
class="cw-fab"
|
|
178
|
+
style=${{ backgroundColor: config.primaryColor }}
|
|
179
|
+
onClick=${() => setIsOpen(true)}
|
|
180
|
+
>
|
|
181
|
+
<svg class="cw-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
182
|
+
<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>
|
|
183
|
+
</svg>
|
|
184
|
+
</button>
|
|
185
|
+
`;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const widgetClasses = [
|
|
189
|
+
'cw-widget',
|
|
190
|
+
isExpanded && 'cw-widget-expanded',
|
|
191
|
+
config.embedded && 'cw-widget-embedded',
|
|
192
|
+
].filter(Boolean).join(' ');
|
|
193
|
+
|
|
194
|
+
return html`
|
|
195
|
+
<div class=${widgetClasses} style=${{ '--cw-primary': config.primaryColor }}>
|
|
196
|
+
${config.showConversationSidebar && html`
|
|
197
|
+
<${Sidebar}
|
|
198
|
+
isOpen=${sidebarOpen}
|
|
199
|
+
conversations=${conversations}
|
|
200
|
+
conversationsLoading=${conversationsLoading}
|
|
201
|
+
currentConversationId=${chat.conversationId}
|
|
202
|
+
onClose=${() => setSidebarOpen(false)}
|
|
203
|
+
onNewConversation=${handleNewConversation}
|
|
204
|
+
onSwitchConversation=${handleSwitchConversation}
|
|
205
|
+
/>
|
|
206
|
+
`}
|
|
207
|
+
|
|
208
|
+
<${Header}
|
|
209
|
+
config=${config}
|
|
210
|
+
debugMode=${debugMode}
|
|
211
|
+
isExpanded=${isExpanded}
|
|
212
|
+
isSpeaking=${isSpeaking}
|
|
213
|
+
messagesCount=${chat.messages.length}
|
|
214
|
+
isLoading=${chat.isLoading}
|
|
215
|
+
currentAgent=${currentAgent}
|
|
216
|
+
onClose=${() => setIsOpen(false)}
|
|
217
|
+
onToggleExpand=${() => setIsExpanded(!isExpanded)}
|
|
218
|
+
onToggleDebug=${() => setDebugMode(!debugMode)}
|
|
219
|
+
onToggleTTS=${() => setEnableTTS(!enableTTS)}
|
|
220
|
+
onClear=${chat.clearMessages}
|
|
221
|
+
onToggleSidebar=${handleToggleSidebar}
|
|
222
|
+
/>
|
|
223
|
+
|
|
224
|
+
${debugMode && html`<div class="cw-status-bar"><span>🐛 Debug</span></div>`}
|
|
225
|
+
|
|
226
|
+
<${MessageList}
|
|
227
|
+
messages=${chat.messages}
|
|
228
|
+
isLoading=${chat.isLoading}
|
|
229
|
+
hasMoreMessages=${chat.hasMoreMessages}
|
|
230
|
+
loadingMoreMessages=${chat.loadingMoreMessages}
|
|
231
|
+
onLoadMore=${chat.loadMoreMessages}
|
|
232
|
+
debugMode=${debugMode}
|
|
233
|
+
markdownParser=${markdownParser}
|
|
234
|
+
emptyStateTitle=${config.emptyStateTitle}
|
|
235
|
+
emptyStateMessage=${config.emptyStateMessage}
|
|
236
|
+
/>
|
|
237
|
+
|
|
238
|
+
${chat.error && html`<div class="cw-error-bar">${chat.error}</div>`}
|
|
239
|
+
|
|
240
|
+
${config.showModelSelector && models.availableModels.length > 0 && html`
|
|
241
|
+
<${ModelSelector}
|
|
242
|
+
availableModels=${models.availableModels}
|
|
243
|
+
selectedModel=${models.selectedModel}
|
|
244
|
+
onSelectModel=${models.selectModel}
|
|
245
|
+
disabled=${chat.isLoading}
|
|
246
|
+
/>
|
|
247
|
+
`}
|
|
248
|
+
|
|
249
|
+
<${InputForm}
|
|
250
|
+
onSend=${handleSend}
|
|
251
|
+
onCancel=${chat.cancelRun}
|
|
252
|
+
isLoading=${chat.isLoading}
|
|
253
|
+
placeholder=${config.placeholder}
|
|
254
|
+
primaryColor=${config.primaryColor}
|
|
255
|
+
/>
|
|
256
|
+
</div>
|
|
257
|
+
`;
|
|
258
|
+
}
|
|
259
|
+
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Header component - title bar with action buttons
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { html } from 'htm/preact';
|
|
6
|
+
import { escapeHtml } from '../utils/helpers.js';
|
|
7
|
+
|
|
8
|
+
export function Header({
|
|
9
|
+
config,
|
|
10
|
+
debugMode,
|
|
11
|
+
isExpanded,
|
|
12
|
+
isSpeaking,
|
|
13
|
+
messagesCount,
|
|
14
|
+
isLoading,
|
|
15
|
+
currentAgent,
|
|
16
|
+
onClose,
|
|
17
|
+
onToggleExpand,
|
|
18
|
+
onToggleDebug,
|
|
19
|
+
onToggleTTS,
|
|
20
|
+
onClear,
|
|
21
|
+
onToggleSidebar,
|
|
22
|
+
}) {
|
|
23
|
+
const {
|
|
24
|
+
title,
|
|
25
|
+
primaryColor,
|
|
26
|
+
embedded,
|
|
27
|
+
showConversationSidebar,
|
|
28
|
+
showClearButton,
|
|
29
|
+
showDebugButton,
|
|
30
|
+
enableDebugMode,
|
|
31
|
+
showTTSButton,
|
|
32
|
+
showExpandButton,
|
|
33
|
+
enableTTS,
|
|
34
|
+
elevenLabsApiKey,
|
|
35
|
+
ttsProxyUrl,
|
|
36
|
+
} = config;
|
|
37
|
+
|
|
38
|
+
const hasTTS = elevenLabsApiKey || ttsProxyUrl;
|
|
39
|
+
|
|
40
|
+
return html`
|
|
41
|
+
<div class="cw-header" style=${{ backgroundColor: primaryColor }}>
|
|
42
|
+
${showConversationSidebar && html`
|
|
43
|
+
<button
|
|
44
|
+
class="cw-header-btn cw-hamburger"
|
|
45
|
+
onClick=${onToggleSidebar}
|
|
46
|
+
title="Conversations"
|
|
47
|
+
>
|
|
48
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16">
|
|
49
|
+
<line x1="3" y1="6" x2="21" y2="6"></line>
|
|
50
|
+
<line x1="3" y1="12" x2="21" y2="12"></line>
|
|
51
|
+
<line x1="3" y1="18" x2="21" y2="18"></line>
|
|
52
|
+
</svg>
|
|
53
|
+
</button>
|
|
54
|
+
`}
|
|
55
|
+
|
|
56
|
+
<div class="cw-title-container">
|
|
57
|
+
<span class="cw-title">${escapeHtml(title)}</span>
|
|
58
|
+
${currentAgent && html`
|
|
59
|
+
<span class="cw-current-agent" title="Currently active agent">
|
|
60
|
+
<span class="cw-agent-indicator">🤖</span>
|
|
61
|
+
<span class="cw-agent-name">${escapeHtml(currentAgent.name || currentAgent.key)}</span>
|
|
62
|
+
</span>
|
|
63
|
+
`}
|
|
64
|
+
</div>
|
|
65
|
+
|
|
66
|
+
<div class="cw-header-actions">
|
|
67
|
+
${showClearButton && html`
|
|
68
|
+
<button
|
|
69
|
+
class="cw-header-btn"
|
|
70
|
+
onClick=${onClear}
|
|
71
|
+
title="Clear"
|
|
72
|
+
disabled=${isLoading || messagesCount === 0}
|
|
73
|
+
>🗑️</button>
|
|
74
|
+
`}
|
|
75
|
+
|
|
76
|
+
${showDebugButton && enableDebugMode && html`
|
|
77
|
+
<button
|
|
78
|
+
class="cw-header-btn ${debugMode ? 'cw-btn-active' : ''}"
|
|
79
|
+
onClick=${onToggleDebug}
|
|
80
|
+
title="Debug"
|
|
81
|
+
>🐛</button>
|
|
82
|
+
`}
|
|
83
|
+
|
|
84
|
+
${showTTSButton && hasTTS && html`
|
|
85
|
+
<button
|
|
86
|
+
class="cw-header-btn ${enableTTS ? 'cw-btn-active' : ''}"
|
|
87
|
+
onClick=${onToggleTTS}
|
|
88
|
+
title="TTS"
|
|
89
|
+
>${enableTTS ? '🔊' : '🔇'}</button>
|
|
90
|
+
`}
|
|
91
|
+
|
|
92
|
+
${showExpandButton && !embedded && html`
|
|
93
|
+
<button
|
|
94
|
+
class="cw-header-btn"
|
|
95
|
+
onClick=${onToggleExpand}
|
|
96
|
+
title=${isExpanded ? 'Minimize' : 'Expand'}
|
|
97
|
+
>${isExpanded ? '⊖' : '⊕'}</button>
|
|
98
|
+
`}
|
|
99
|
+
|
|
100
|
+
${!embedded && html`
|
|
101
|
+
<button
|
|
102
|
+
class="cw-header-btn"
|
|
103
|
+
onClick=${onClose}
|
|
104
|
+
title="Close"
|
|
105
|
+
>✕</button>
|
|
106
|
+
`}
|
|
107
|
+
</div>
|
|
108
|
+
</div>
|
|
109
|
+
`;
|
|
110
|
+
}
|
|
111
|
+
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* InputForm component - message input and send button
|
|
3
|
+
* Supports multiline input with Shift+Enter for newlines, Enter to send
|
|
4
|
+
* Shows a stop button when loading that can cancel the current run
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { html } from 'htm/preact';
|
|
8
|
+
import { useState, useRef, useEffect } from 'preact/hooks';
|
|
9
|
+
import { escapeHtml } from '../utils/helpers.js';
|
|
10
|
+
|
|
11
|
+
export function InputForm({ onSend, onCancel, isLoading, placeholder, primaryColor }) {
|
|
12
|
+
const [value, setValue] = useState('');
|
|
13
|
+
const [isHovering, setIsHovering] = useState(false);
|
|
14
|
+
const textareaRef = useRef(null);
|
|
15
|
+
|
|
16
|
+
// Focus input when not loading
|
|
17
|
+
useEffect(() => {
|
|
18
|
+
if (!isLoading && textareaRef.current) {
|
|
19
|
+
textareaRef.current.focus();
|
|
20
|
+
}
|
|
21
|
+
}, [isLoading]);
|
|
22
|
+
|
|
23
|
+
// Auto-resize textarea based on content
|
|
24
|
+
useEffect(() => {
|
|
25
|
+
if (textareaRef.current) {
|
|
26
|
+
textareaRef.current.style.height = 'auto';
|
|
27
|
+
textareaRef.current.style.height = Math.min(textareaRef.current.scrollHeight, 150) + 'px';
|
|
28
|
+
}
|
|
29
|
+
}, [value]);
|
|
30
|
+
|
|
31
|
+
const handleSubmit = (e) => {
|
|
32
|
+
e.preventDefault();
|
|
33
|
+
if (value.trim() && !isLoading) {
|
|
34
|
+
onSend(value);
|
|
35
|
+
setValue('');
|
|
36
|
+
// Reset height after sending
|
|
37
|
+
if (textareaRef.current) {
|
|
38
|
+
textareaRef.current.style.height = 'auto';
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const handleKeyDown = (e) => {
|
|
44
|
+
// Enter without Shift sends the message
|
|
45
|
+
if (e.key === 'Enter' && !e.shiftKey) {
|
|
46
|
+
e.preventDefault();
|
|
47
|
+
handleSubmit(e);
|
|
48
|
+
}
|
|
49
|
+
// Shift+Enter allows newline (default behavior)
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const handleButtonClick = (e) => {
|
|
53
|
+
if (isLoading && onCancel) {
|
|
54
|
+
e.preventDefault();
|
|
55
|
+
onCancel();
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
// Stop icon (square)
|
|
60
|
+
const stopIcon = html`
|
|
61
|
+
<svg width="14" height="14" viewBox="0 0 14 14" fill="currentColor">
|
|
62
|
+
<rect x="2" y="2" width="10" height="10" rx="1" />
|
|
63
|
+
</svg>
|
|
64
|
+
`;
|
|
65
|
+
|
|
66
|
+
return html`
|
|
67
|
+
<form class="cw-input-form" onSubmit=${handleSubmit}>
|
|
68
|
+
<textarea
|
|
69
|
+
ref=${textareaRef}
|
|
70
|
+
class="cw-input"
|
|
71
|
+
placeholder=${escapeHtml(placeholder)}
|
|
72
|
+
value=${value}
|
|
73
|
+
onInput=${e => setValue(e.target.value)}
|
|
74
|
+
onKeyDown=${handleKeyDown}
|
|
75
|
+
disabled=${isLoading}
|
|
76
|
+
rows="1"
|
|
77
|
+
/>
|
|
78
|
+
<button
|
|
79
|
+
type=${isLoading ? 'button' : 'submit'}
|
|
80
|
+
class=${`cw-send-btn ${isLoading ? 'cw-send-btn-loading' : ''} ${isLoading && isHovering ? 'cw-send-btn-stop' : ''}`}
|
|
81
|
+
style=${{ backgroundColor: isLoading && isHovering ? '#dc2626' : primaryColor }}
|
|
82
|
+
onClick=${handleButtonClick}
|
|
83
|
+
onMouseEnter=${() => setIsHovering(true)}
|
|
84
|
+
onMouseLeave=${() => setIsHovering(false)}
|
|
85
|
+
title=${isLoading ? 'Stop' : 'Send'}
|
|
86
|
+
>
|
|
87
|
+
${isLoading
|
|
88
|
+
? (isHovering ? stopIcon : html`<span class="cw-spinner"></span>`)
|
|
89
|
+
: '➤'
|
|
90
|
+
}
|
|
91
|
+
</button>
|
|
92
|
+
</form>
|
|
93
|
+
`;
|
|
94
|
+
}
|
|
95
|
+
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Message component - renders a single chat message
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { html } from 'htm/preact';
|
|
6
|
+
import { useState } from 'preact/hooks';
|
|
7
|
+
import { escapeHtml, parseMarkdown } from '../utils/helpers.js';
|
|
8
|
+
|
|
9
|
+
// Debug payload viewer component
|
|
10
|
+
function DebugPayload({ msg, show, onToggle }) {
|
|
11
|
+
if (!show) {
|
|
12
|
+
return html`
|
|
13
|
+
<button
|
|
14
|
+
class="cw-debug-payload-btn"
|
|
15
|
+
onClick=${onToggle}
|
|
16
|
+
title="Show message payload"
|
|
17
|
+
>{ }</button>
|
|
18
|
+
`;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
return html`
|
|
22
|
+
<div class="cw-debug-payload">
|
|
23
|
+
<button class="cw-debug-payload-close" onClick=${onToggle}>×</button>
|
|
24
|
+
<pre class="cw-debug-payload-content">${JSON.stringify(msg, null, 2)}</pre>
|
|
25
|
+
</div>
|
|
26
|
+
`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function Message({ msg, debugMode, markdownParser }) {
|
|
30
|
+
const [expanded, setExpanded] = useState(false);
|
|
31
|
+
const [showPayload, setShowPayload] = useState(false);
|
|
32
|
+
|
|
33
|
+
const isUser = msg.role === 'user';
|
|
34
|
+
const isSystem = msg.role === 'system';
|
|
35
|
+
const isToolCall = msg.type === 'tool_call';
|
|
36
|
+
const isToolResult = msg.type === 'tool_result';
|
|
37
|
+
const isError = msg.type === 'error';
|
|
38
|
+
const isSubAgentStart = msg.type === 'sub_agent_start';
|
|
39
|
+
const isSubAgentEnd = msg.type === 'sub_agent_end';
|
|
40
|
+
const isAgentContext = msg.type === 'agent_context';
|
|
41
|
+
|
|
42
|
+
// Hide system messages unless debug mode is on
|
|
43
|
+
if (isSystem && !debugMode) {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Sub-agent delegation messages - show as a distinct visual element
|
|
48
|
+
if (isSubAgentStart || isSubAgentEnd || isAgentContext) {
|
|
49
|
+
return html`
|
|
50
|
+
<div class="cw-agent-context ${isSubAgentStart ? 'cw-agent-delegating' : ''} ${isSubAgentEnd ? 'cw-agent-returned' : ''}" style="position: relative;">
|
|
51
|
+
<span class="cw-agent-context-icon">${isSubAgentStart ? '🔗' : isSubAgentEnd ? '✓' : '🤖'}</span>
|
|
52
|
+
<span class="cw-agent-context-text">${msg.content}</span>
|
|
53
|
+
${msg.metadata?.agentName && html`
|
|
54
|
+
<span class="cw-agent-context-name">${msg.metadata.agentName}</span>
|
|
55
|
+
`}
|
|
56
|
+
${debugMode && html`<${DebugPayload} msg=${msg} show=${showPayload} onToggle=${() => setShowPayload(!showPayload)} />`}
|
|
57
|
+
</div>
|
|
58
|
+
`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Tool call/result: show compact inline version
|
|
62
|
+
if (isToolCall || isToolResult) {
|
|
63
|
+
const hasDetails = msg.metadata?.arguments || msg.metadata?.result;
|
|
64
|
+
|
|
65
|
+
// Format details - handle case where data is already a JSON string
|
|
66
|
+
const formatDetails = (data) => {
|
|
67
|
+
if (typeof data === 'string') {
|
|
68
|
+
try {
|
|
69
|
+
// Try to parse and re-stringify for proper formatting
|
|
70
|
+
return JSON.stringify(JSON.parse(data), null, 2);
|
|
71
|
+
} catch {
|
|
72
|
+
// Not valid JSON, return as-is
|
|
73
|
+
return data;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return JSON.stringify(data, null, 2);
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
return html`
|
|
80
|
+
<div class="cw-tool-message ${isToolResult ? 'cw-tool-result' : 'cw-tool-call'}" style="position: relative;">
|
|
81
|
+
<span class="cw-tool-label" onClick=${() => hasDetails && setExpanded(!expanded)}>
|
|
82
|
+
${msg.content}
|
|
83
|
+
${hasDetails && html`<span class="cw-tool-expand">${expanded ? '▼' : '▶'}</span>`}
|
|
84
|
+
</span>
|
|
85
|
+
${expanded && hasDetails && html`
|
|
86
|
+
<pre class="cw-tool-details">${escapeHtml(formatDetails(
|
|
87
|
+
isToolCall ? msg.metadata.arguments : msg.metadata.result
|
|
88
|
+
))}</pre>
|
|
89
|
+
`}
|
|
90
|
+
${debugMode && html`<${DebugPayload} msg=${msg} show=${showPayload} onToggle=${() => setShowPayload(!showPayload)} />`}
|
|
91
|
+
</div>
|
|
92
|
+
`;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const classes = [
|
|
96
|
+
'cw-message',
|
|
97
|
+
isUser && 'cw-message-user',
|
|
98
|
+
isError && 'cw-message-error',
|
|
99
|
+
].filter(Boolean).join(' ');
|
|
100
|
+
|
|
101
|
+
const rowClasses = `cw-message-row ${isUser ? 'cw-message-row-user' : ''}`;
|
|
102
|
+
|
|
103
|
+
// Parse content
|
|
104
|
+
let content = msg.role === 'assistant'
|
|
105
|
+
? parseMarkdown(msg.content, markdownParser)
|
|
106
|
+
: escapeHtml(msg.content);
|
|
107
|
+
|
|
108
|
+
return html`
|
|
109
|
+
<div class=${rowClasses} style="position: relative;">
|
|
110
|
+
<div class=${classes} dangerouslySetInnerHTML=${{ __html: content }} />
|
|
111
|
+
${debugMode && html`<${DebugPayload} msg=${msg} show=${showPayload} onToggle=${() => setShowPayload(!showPayload)} />`}
|
|
112
|
+
</div>
|
|
113
|
+
`;
|
|
114
|
+
}
|
|
115
|
+
|