epsimo-agent 0.1.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/LICENSE +21 -0
- package/SKILL.md +85 -0
- package/assets/example_asset.txt +24 -0
- package/epsimo/__init__.py +3 -0
- package/epsimo/__main__.py +4 -0
- package/epsimo/auth.py +143 -0
- package/epsimo/cli.py +586 -0
- package/epsimo/client.py +53 -0
- package/epsimo/resources/assistants.py +47 -0
- package/epsimo/resources/credits.py +16 -0
- package/epsimo/resources/db.py +31 -0
- package/epsimo/resources/files.py +39 -0
- package/epsimo/resources/projects.py +30 -0
- package/epsimo/resources/threads.py +83 -0
- package/epsimo/templates/components/AuthModal/AuthModal.module.css +39 -0
- package/epsimo/templates/components/AuthModal/AuthModal.tsx +138 -0
- package/epsimo/templates/components/BuyCredits/BuyCreditsModal.module.css +96 -0
- package/epsimo/templates/components/BuyCredits/BuyCreditsModal.tsx +132 -0
- package/epsimo/templates/components/BuyCredits/CreditsDisplay.tsx +101 -0
- package/epsimo/templates/components/ThreadChat/ThreadChat.module.css +551 -0
- package/epsimo/templates/components/ThreadChat/ThreadChat.tsx +862 -0
- package/epsimo/templates/components/ThreadChat/components/ToolRenderers.module.css +509 -0
- package/epsimo/templates/components/ThreadChat/components/ToolRenderers.tsx +322 -0
- package/epsimo/templates/next-mvp/app/globals.css.tmpl +20 -0
- package/epsimo/templates/next-mvp/app/layout.tsx.tmpl +22 -0
- package/epsimo/templates/next-mvp/app/page.module.css.tmpl +84 -0
- package/epsimo/templates/next-mvp/app/page.tsx.tmpl +43 -0
- package/epsimo/templates/next-mvp/epsimo.yaml.tmpl +12 -0
- package/epsimo/templates/next-mvp/package.json.tmpl +26 -0
- package/epsimo/tools/library.yaml +51 -0
- package/package.json +27 -0
- package/references/api_reference.md +34 -0
- package/references/virtual_db_guide.md +57 -0
- package/requirements.txt +2 -0
- package/scripts/assistant.py +165 -0
- package/scripts/auth.py +195 -0
- package/scripts/credits.py +107 -0
- package/scripts/debug_run.py +41 -0
- package/scripts/example.py +19 -0
- package/scripts/files.py +73 -0
- package/scripts/find_thread.py +55 -0
- package/scripts/project.py +60 -0
- package/scripts/run.py +75 -0
- package/scripts/test_all_skills.py +387 -0
- package/scripts/test_sdk.py +83 -0
- package/scripts/test_streaming.py +167 -0
- package/scripts/test_vdb.py +65 -0
- package/scripts/thread.py +77 -0
- package/scripts/verify_skill.py +87 -0
|
@@ -0,0 +1,862 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React, { useEffect, useState, useRef } from 'react';
|
|
4
|
+
import { useRouter } from 'next/navigation';
|
|
5
|
+
import { api } from '@/lib/api-client';
|
|
6
|
+
import { Button } from '@/components/ui/Button/Button';
|
|
7
|
+
import styles from './ThreadChat.module.css';
|
|
8
|
+
import ReactMarkdown from 'react-markdown';
|
|
9
|
+
import remarkGfm from 'remark-gfm';
|
|
10
|
+
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
|
|
11
|
+
import { atomDark } from 'react-syntax-highlighter/dist/esm/styles/prism';
|
|
12
|
+
import { ToolRequest, ToolResponse, ToolExecuting, ToolUsageContainer } from './components/ToolRenderers';
|
|
13
|
+
|
|
14
|
+
interface Message {
|
|
15
|
+
id: string;
|
|
16
|
+
role: 'user' | 'assistant' | 'system' | 'tool';
|
|
17
|
+
type?: string;
|
|
18
|
+
content: string | any;
|
|
19
|
+
timestamp?: string;
|
|
20
|
+
tool_calls?: any[];
|
|
21
|
+
tool_call_id?: string;
|
|
22
|
+
name?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface ThreadChatProps {
|
|
26
|
+
threadId: string;
|
|
27
|
+
onBack?: () => void;
|
|
28
|
+
className?: string;
|
|
29
|
+
showHeader?: boolean;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export default function ThreadChat({ threadId, onBack, className, showHeader = true }: ThreadChatProps) {
|
|
33
|
+
const router = useRouter();
|
|
34
|
+
const [messages, setMessages] = useState<Message[]>([]);
|
|
35
|
+
const [inputValue, setInputValue] = useState('');
|
|
36
|
+
const [isLoading, setIsLoading] = useState(true);
|
|
37
|
+
const [isSending, setIsSending] = useState(false);
|
|
38
|
+
const [error, setError] = useState<string | null>(null);
|
|
39
|
+
const [currentTool, setCurrentTool] = useState<string | null>(null);
|
|
40
|
+
const [isUploadingFiles, setIsUploadingFiles] = useState(false);
|
|
41
|
+
const [uploadError, setUploadError] = useState<string | null>(null);
|
|
42
|
+
const messagesEndRef = useRef<HTMLDivElement>(null);
|
|
43
|
+
const isProcessingRef = useRef(false);
|
|
44
|
+
const hasFetchedRef = useRef(false);
|
|
45
|
+
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
46
|
+
|
|
47
|
+
// We need to track the current threadId to reset state when it changes
|
|
48
|
+
const currentThreadIdRef = useRef(threadId);
|
|
49
|
+
|
|
50
|
+
const scrollToBottom = () => {
|
|
51
|
+
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const [thread, setThread] = useState<any>(null);
|
|
55
|
+
|
|
56
|
+
// Reset state when threadId changes
|
|
57
|
+
if (threadId !== currentThreadIdRef.current) {
|
|
58
|
+
currentThreadIdRef.current = threadId;
|
|
59
|
+
hasFetchedRef.current = false;
|
|
60
|
+
setMessages([]);
|
|
61
|
+
setThread(null);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
useEffect(() => {
|
|
65
|
+
const fetchThreadData = async () => {
|
|
66
|
+
if (!threadId || hasFetchedRef.current) return;
|
|
67
|
+
hasFetchedRef.current = true;
|
|
68
|
+
setIsLoading(true);
|
|
69
|
+
setError(null);
|
|
70
|
+
try {
|
|
71
|
+
// 1. Get thread details first to verify existence and get context
|
|
72
|
+
console.log('Fetching thread details for:', threadId);
|
|
73
|
+
const threadDetails = await (api.get as any)(`/threads/${threadId}`);
|
|
74
|
+
console.log('Thread details received:', JSON.stringify(threadDetails).substring(0, 500));
|
|
75
|
+
setThread(threadDetails);
|
|
76
|
+
|
|
77
|
+
// 2. Try state first (often more reliable and contains latest messages)
|
|
78
|
+
let data = null;
|
|
79
|
+
console.log('Attempting to fetch thread state...');
|
|
80
|
+
try {
|
|
81
|
+
data = await (api.get as any)(`/threads/${threadId}/state`);
|
|
82
|
+
console.log('State fetch success, keys:', Object.keys(data || {}));
|
|
83
|
+
} catch (stateErr: any) {
|
|
84
|
+
console.warn('Thread state fetch failed, trying history backup...', stateErr);
|
|
85
|
+
|
|
86
|
+
// 3. Fallback to history if state fails
|
|
87
|
+
try {
|
|
88
|
+
data = await (api.get as any)(`/threads/${threadId}/history`);
|
|
89
|
+
console.log('History backup success');
|
|
90
|
+
} catch (historyErr: any) {
|
|
91
|
+
console.error('Both state and history fetch failed:', historyErr);
|
|
92
|
+
|
|
93
|
+
// Check for specific backend configuration errors
|
|
94
|
+
const isToolError =
|
|
95
|
+
stateErr?.message?.toLowerCase()?.includes('tool configuration') ||
|
|
96
|
+
stateErr?.message?.toLowerCase()?.includes('tool type') ||
|
|
97
|
+
historyErr?.message?.toLowerCase()?.includes('tool configuration') ||
|
|
98
|
+
historyErr?.message?.toLowerCase()?.includes('tool type');
|
|
99
|
+
|
|
100
|
+
if (isToolError) {
|
|
101
|
+
setError("This thread's assistant has an invalid tool configuration. Please edit the assistant in your Project view and click 'Save Changes' to refresh its configuration.");
|
|
102
|
+
} else {
|
|
103
|
+
setError("Could not load previous messages. You can still start a new conversation below.");
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
setMessages([]);
|
|
107
|
+
setIsLoading(false);
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
console.log('Thread data received, processing messages...');
|
|
113
|
+
let rawMessages: any[] = [];
|
|
114
|
+
|
|
115
|
+
if (Array.isArray(data)) {
|
|
116
|
+
// 1. History format (List of snapshots)
|
|
117
|
+
if (data.length > 0) {
|
|
118
|
+
// Find the first snapshot that has messages in its values or at the root
|
|
119
|
+
for (const item of data) {
|
|
120
|
+
const candidate = item.values?.messages || item.messages || (Array.isArray(item.values) ? item.values : null);
|
|
121
|
+
if (Array.isArray(candidate) && candidate.length > 0) {
|
|
122
|
+
rawMessages = candidate;
|
|
123
|
+
break;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
// If still empty and data itself is an array of messages
|
|
127
|
+
if (rawMessages.length === 0 && data[0].content && (data[0].role || data[0].type)) {
|
|
128
|
+
rawMessages = data;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
} else if (data && typeof data === 'object') {
|
|
132
|
+
// 2. State format (Single object)
|
|
133
|
+
const values = data.values || data.checkpoint?.values || data;
|
|
134
|
+
console.log('Using values for extraction:', JSON.stringify(values).substring(0, 500));
|
|
135
|
+
|
|
136
|
+
if (Array.isArray(values)) {
|
|
137
|
+
rawMessages = values;
|
|
138
|
+
} else if (values && typeof values === 'object') {
|
|
139
|
+
// Look for common message array keys
|
|
140
|
+
const messageKeys = ['messages', 'history', 'chat_history', 'msgs'];
|
|
141
|
+
for (const key of messageKeys) {
|
|
142
|
+
if (Array.isArray(values[key]) && values[key].length > 0) {
|
|
143
|
+
rawMessages = values[key];
|
|
144
|
+
console.log(`Found messages in values.${key}`);
|
|
145
|
+
break;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Fallback: check if any key in values is an array (likely messages)
|
|
150
|
+
if (rawMessages.length === 0) {
|
|
151
|
+
const arrayKey = Object.keys(values).find(k => Array.isArray(values[k]) && values[k].length > 0);
|
|
152
|
+
if (arrayKey) {
|
|
153
|
+
console.log('State fallback: using array key:', arrayKey);
|
|
154
|
+
rawMessages = values[arrayKey];
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
console.log('Raw messages extracted, count:', rawMessages.length);
|
|
161
|
+
if (Array.isArray(rawMessages)) {
|
|
162
|
+
setMessages(rawMessages.map((m: any) => {
|
|
163
|
+
let role = m.role || m.type;
|
|
164
|
+
if (role === 'human' || role === 'user') role = 'user';
|
|
165
|
+
if (role === 'ai' || role === 'assistant') role = 'assistant';
|
|
166
|
+
if (role === 'function' || role === 'tool') role = 'tool';
|
|
167
|
+
|
|
168
|
+
let content = m.content;
|
|
169
|
+
if (Array.isArray(content)) {
|
|
170
|
+
content = content
|
|
171
|
+
.map((block: any) => typeof block === 'string' ? block : (block.text || ''))
|
|
172
|
+
.join('\n');
|
|
173
|
+
} else if (content && typeof content === 'object' && role !== 'tool') {
|
|
174
|
+
// Only stringify if it's not a tool response, to preserve structure for ToolResponse component
|
|
175
|
+
content = content.text || JSON.stringify(content);
|
|
176
|
+
}
|
|
177
|
+
// If it's a tool response and an object, we keep it as is
|
|
178
|
+
|
|
179
|
+
return {
|
|
180
|
+
id: m.id || String(Math.random()),
|
|
181
|
+
role,
|
|
182
|
+
content,
|
|
183
|
+
tool_calls: m.tool_calls,
|
|
184
|
+
tool_call_id: m.tool_call_id,
|
|
185
|
+
name: m.name
|
|
186
|
+
} as Message;
|
|
187
|
+
}));
|
|
188
|
+
} else {
|
|
189
|
+
setMessages([]);
|
|
190
|
+
}
|
|
191
|
+
} catch (err: any) {
|
|
192
|
+
console.error('Critical error in fetchThreadData:', err);
|
|
193
|
+
setError(err.message || 'Failed to initialize conversation');
|
|
194
|
+
} finally {
|
|
195
|
+
setIsLoading(false);
|
|
196
|
+
}
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
fetchThreadData();
|
|
200
|
+
|
|
201
|
+
// Add debugging info to console
|
|
202
|
+
console.log('🔧 Tool Debugging Features Available:');
|
|
203
|
+
console.log('=====================================');
|
|
204
|
+
console.log('• Press Ctrl+Shift+D to toggle debug panel');
|
|
205
|
+
console.log('• Run getToolDebugInfo() in console for debug summary');
|
|
206
|
+
console.log('• Check window._toolDebug for real-time tool stats');
|
|
207
|
+
console.log('• Check window._lastChunks for recent stream data');
|
|
208
|
+
console.log('');
|
|
209
|
+
|
|
210
|
+
// Add keyboard shortcut for debug panel (Ctrl+Shift+D)
|
|
211
|
+
const handleKeyDown = (e: KeyboardEvent) => {
|
|
212
|
+
if (e.ctrlKey && e.shiftKey && e.key === 'D') {
|
|
213
|
+
e.preventDefault();
|
|
214
|
+
const panel = document.getElementById('tool-debug-panel');
|
|
215
|
+
if (panel) {
|
|
216
|
+
panel.style.display = panel.style.display === 'none' ? 'block' : 'none';
|
|
217
|
+
|
|
218
|
+
// Log current debug state to console
|
|
219
|
+
if (typeof window !== 'undefined') {
|
|
220
|
+
console.log('🔧 Tool Debug State:', (window as any)._toolDebug);
|
|
221
|
+
console.log('📊 Last 5 Chunks:', (window as any)._lastChunks?.slice(-5));
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
document.addEventListener('keydown', handleKeyDown);
|
|
228
|
+
|
|
229
|
+
return () => {
|
|
230
|
+
document.removeEventListener('keydown', handleKeyDown);
|
|
231
|
+
};
|
|
232
|
+
}, [threadId]);
|
|
233
|
+
|
|
234
|
+
const copyToClipboard = (text: string) => {
|
|
235
|
+
navigator.clipboard.writeText(text);
|
|
236
|
+
// Optional: Add a toast notification here
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
useEffect(() => {
|
|
240
|
+
scrollToBottom();
|
|
241
|
+
|
|
242
|
+
// Update debug panel if it exists
|
|
243
|
+
if (typeof window !== 'undefined' && (window as any)._toolDebug) {
|
|
244
|
+
const debugInfo = (window as any)._toolDebug;
|
|
245
|
+
const panel = document.getElementById('tool-debug-panel');
|
|
246
|
+
if (panel) {
|
|
247
|
+
const toolCallsEl = document.getElementById('tool-calls-count');
|
|
248
|
+
const toolResponsesEl = document.getElementById('tool-responses-count');
|
|
249
|
+
const aiMentionsEl = document.getElementById('ai-mentions-count');
|
|
250
|
+
const chunkCountEl = document.getElementById('chunk-count');
|
|
251
|
+
|
|
252
|
+
if (toolCallsEl) toolCallsEl.textContent = debugInfo.toolCallsFound.toString();
|
|
253
|
+
if (toolResponsesEl) toolResponsesEl.textContent = debugInfo.toolResponsesFound.toString();
|
|
254
|
+
if (aiMentionsEl) aiMentionsEl.textContent = debugInfo.aiMentionsTools.toString();
|
|
255
|
+
if (chunkCountEl) chunkCountEl.textContent = ((window as any)._lastChunks?.length || 0).toString();
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}, [messages]);
|
|
259
|
+
|
|
260
|
+
const handleSendMessage = async (e?: React.FormEvent) => {
|
|
261
|
+
e?.preventDefault();
|
|
262
|
+
if (!inputValue.trim() || isSending) return;
|
|
263
|
+
|
|
264
|
+
const userMessage: Message = {
|
|
265
|
+
id: Date.now().toString(),
|
|
266
|
+
role: 'user',
|
|
267
|
+
content: inputValue.trim()
|
|
268
|
+
};
|
|
269
|
+
|
|
270
|
+
setMessages(prev => [...prev, userMessage]);
|
|
271
|
+
setInputValue('');
|
|
272
|
+
setIsSending(true);
|
|
273
|
+
setError(null);
|
|
274
|
+
|
|
275
|
+
// Stream monitoring variables
|
|
276
|
+
let chunkCount = 0;
|
|
277
|
+
let lastChunkTime = Date.now();
|
|
278
|
+
let streamStarted = false;
|
|
279
|
+
|
|
280
|
+
try {
|
|
281
|
+
console.log('Sending message to thread:', threadId, 'Current thread context:', thread);
|
|
282
|
+
|
|
283
|
+
let assistantMessageId = '';
|
|
284
|
+
setCurrentTool(null);
|
|
285
|
+
|
|
286
|
+
const mergeMessages = (current: Message[], incoming: any[]): Message[] => {
|
|
287
|
+
const messageMap = new Map<string, Message>();
|
|
288
|
+
current.forEach(m => messageMap.set(m.id, m));
|
|
289
|
+
|
|
290
|
+
const normalizeMessage = (m: any) => {
|
|
291
|
+
let role = m.role || m.type || 'assistant';
|
|
292
|
+
// Handle server format: type: 'human'/'ai' -> role: 'user'/'assistant'
|
|
293
|
+
if (role === 'ai' || role === 'assistant') role = 'assistant';
|
|
294
|
+
if (role === 'human' || role === 'user') role = 'user';
|
|
295
|
+
if (role === 'function' || role === 'tool') role = 'tool';
|
|
296
|
+
|
|
297
|
+
let content = m.content;
|
|
298
|
+
if (Array.isArray(content)) {
|
|
299
|
+
content = content
|
|
300
|
+
.map((block: any) => typeof block === 'string' ? block : (block.text || ''))
|
|
301
|
+
.join('\n');
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// Handle tool calls from server format
|
|
305
|
+
const tool_calls = m.tool_calls || m.additional_kwargs?.tool_calls || [];
|
|
306
|
+
|
|
307
|
+
return {
|
|
308
|
+
...m,
|
|
309
|
+
role,
|
|
310
|
+
content: content || '',
|
|
311
|
+
tool_calls,
|
|
312
|
+
tool_call_id: m.tool_call_id, // Explicitly preserve tool_call_id
|
|
313
|
+
name: m.name, // Preserve tool name
|
|
314
|
+
// Use server ID or generate fallback
|
|
315
|
+
id: m.id || m.message_id || `msg-${Date.now()}-${Math.random()}`
|
|
316
|
+
};
|
|
317
|
+
};
|
|
318
|
+
|
|
319
|
+
incoming.forEach(incRaw => {
|
|
320
|
+
const inc = normalizeMessage(incRaw);
|
|
321
|
+
let id = inc.id || inc.message_id;
|
|
322
|
+
|
|
323
|
+
// Fallback ID for streaming assistant messages without explicit IDs
|
|
324
|
+
if (!id) {
|
|
325
|
+
if (inc.role === 'assistant') {
|
|
326
|
+
if (!assistantMessageId) {
|
|
327
|
+
assistantMessageId = (Date.now() + 1).toString();
|
|
328
|
+
}
|
|
329
|
+
id = assistantMessageId;
|
|
330
|
+
} else if (inc.role === 'tool') {
|
|
331
|
+
id = inc.tool_call_id || `tool-${Date.now()}`;
|
|
332
|
+
} else {
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// DE-DUPLICATION: If this is a user message, check if we already have it with a temp ID
|
|
338
|
+
if (inc.role === 'user') {
|
|
339
|
+
const existingTemp = Array.from(messageMap.values()).find(m =>
|
|
340
|
+
m.role === 'user' &&
|
|
341
|
+
m.content === inc.content &&
|
|
342
|
+
/^\d{13,}$/.test(m.id)
|
|
343
|
+
);
|
|
344
|
+
if (existingTemp) {
|
|
345
|
+
messageMap.delete(existingTemp.id);
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
const existing = messageMap.get(id);
|
|
350
|
+
if (existing) {
|
|
351
|
+
messageMap.set(id, {
|
|
352
|
+
...existing,
|
|
353
|
+
...inc,
|
|
354
|
+
// Preserve existing content if incoming is empty/undefined but we have content
|
|
355
|
+
content: (inc.content !== undefined && inc.content !== '') ? inc.content : existing.content,
|
|
356
|
+
// Merge tool calls
|
|
357
|
+
tool_calls: inc.tool_calls.length > 0 ? inc.tool_calls : existing.tool_calls,
|
|
358
|
+
role: inc.role // Always trust latest normalized role
|
|
359
|
+
});
|
|
360
|
+
} else if (['user', 'assistant', 'system', 'tool'].includes(inc.role)) {
|
|
361
|
+
messageMap.set(id, {
|
|
362
|
+
...inc,
|
|
363
|
+
id,
|
|
364
|
+
content: inc.content || ''
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
return Array.from(messageMap.values());
|
|
370
|
+
};
|
|
371
|
+
|
|
372
|
+
// Track sending state with a ref to prevent race conditions
|
|
373
|
+
if (isProcessingRef.current) return;
|
|
374
|
+
isProcessingRef.current = true;
|
|
375
|
+
|
|
376
|
+
// 1. Prepare input
|
|
377
|
+
const messagePayload = {
|
|
378
|
+
content: userMessage.content,
|
|
379
|
+
type: 'human',
|
|
380
|
+
role: 'user'
|
|
381
|
+
};
|
|
382
|
+
const streamInput = [messagePayload];
|
|
383
|
+
|
|
384
|
+
const stream = (api as any).stream('/runs/stream', {
|
|
385
|
+
body: {
|
|
386
|
+
thread_id: threadId,
|
|
387
|
+
input: streamInput,
|
|
388
|
+
stream_mode: ['messages', 'events', 'values']
|
|
389
|
+
}
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
for await (const chunk of stream) {
|
|
393
|
+
chunkCount++;
|
|
394
|
+
lastChunkTime = Date.now();
|
|
395
|
+
|
|
396
|
+
// Debug logging omitted for brevity but logic remains same
|
|
397
|
+
|
|
398
|
+
// Track last chunks for debug
|
|
399
|
+
if (typeof window !== 'undefined') {
|
|
400
|
+
(window as any)._lastChunks = (window as any)._lastChunks || [];
|
|
401
|
+
(window as any)._lastChunks.push(chunk);
|
|
402
|
+
if ((window as any)._lastChunks.length > 50) (window as any)._lastChunks.shift();
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// Handle error chunks
|
|
406
|
+
if (chunk && (chunk.status_code >= 400 || chunk.type === 'processing_error')) {
|
|
407
|
+
const errorMsg = chunk.message || chunk.details || 'Backend processing error';
|
|
408
|
+
throw new Error(`Tool execution error: ${errorMsg}`);
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// Handle the actual server format
|
|
412
|
+
if (chunk.event === 'start') {
|
|
413
|
+
streamStarted = true;
|
|
414
|
+
continue;
|
|
415
|
+
} else if (chunk.event === 'metadata') {
|
|
416
|
+
continue;
|
|
417
|
+
} else if (chunk.event === 'end') {
|
|
418
|
+
break;
|
|
419
|
+
} else if (chunk.event === 'data' && Array.isArray(chunk)) {
|
|
420
|
+
// Server sends data as arrays of message objects
|
|
421
|
+
setMessages(prev => mergeMessages(prev, chunk));
|
|
422
|
+
continue;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// Legacy handling for other possible formats
|
|
426
|
+
if (Array.isArray(chunk)) {
|
|
427
|
+
// Case A: Array of message snapshots
|
|
428
|
+
setMessages(prev => mergeMessages(prev, chunk));
|
|
429
|
+
} else if (chunk.event === 'on_chat_model_stream' || chunk.event === 'messages/partial') {
|
|
430
|
+
// Case B: Event wrappers (deltas and signals)
|
|
431
|
+
const delta = chunk.data?.chunk?.content || chunk.data?.content;
|
|
432
|
+
|
|
433
|
+
// Extract tool call chunks from multiple possible locations
|
|
434
|
+
const toolCallChunks =
|
|
435
|
+
chunk.data?.chunk?.additional_kwargs?.tool_calls ||
|
|
436
|
+
chunk.data?.chunk?.tool_call_chunks ||
|
|
437
|
+
chunk.data?.chunk?.tool_calls ||
|
|
438
|
+
chunk.data?.tool_calls ||
|
|
439
|
+
chunk.tool_calls ||
|
|
440
|
+
[];
|
|
441
|
+
|
|
442
|
+
if (delta && typeof delta === 'string') {
|
|
443
|
+
if (!assistantMessageId) {
|
|
444
|
+
assistantMessageId = (Date.now() + 1).toString();
|
|
445
|
+
setMessages(prev => [...prev, {
|
|
446
|
+
id: assistantMessageId,
|
|
447
|
+
role: 'assistant',
|
|
448
|
+
content: delta,
|
|
449
|
+
tool_calls: []
|
|
450
|
+
}]);
|
|
451
|
+
} else {
|
|
452
|
+
setMessages(prev => prev.map(msg =>
|
|
453
|
+
msg.id === assistantMessageId ? { ...msg, content: msg.content + delta } : msg
|
|
454
|
+
));
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// Handle tool call chunks - accumulate instead of replace
|
|
459
|
+
if (toolCallChunks.length > 0) {
|
|
460
|
+
if (!assistantMessageId) {
|
|
461
|
+
assistantMessageId = (Date.now() + 1).toString();
|
|
462
|
+
setMessages(prev => [...prev, {
|
|
463
|
+
id: assistantMessageId,
|
|
464
|
+
role: 'assistant',
|
|
465
|
+
content: '',
|
|
466
|
+
tool_calls: toolCallChunks
|
|
467
|
+
}]);
|
|
468
|
+
} else {
|
|
469
|
+
setMessages(prev => prev.map(msg => {
|
|
470
|
+
if (msg.id === assistantMessageId) {
|
|
471
|
+
// Merge tool calls by ID, accumulating argument chunks
|
|
472
|
+
const existingCalls = msg.tool_calls || [];
|
|
473
|
+
const mergedCalls = [...existingCalls];
|
|
474
|
+
|
|
475
|
+
toolCallChunks.forEach((newCall: any) => {
|
|
476
|
+
const callId = newCall.id || newCall.index;
|
|
477
|
+
const existingIdx = mergedCalls.findIndex(
|
|
478
|
+
(c: any) => (c.id || c.index) === callId
|
|
479
|
+
);
|
|
480
|
+
|
|
481
|
+
if (existingIdx >= 0) {
|
|
482
|
+
// Accumulate arguments for existing tool call
|
|
483
|
+
const existing = mergedCalls[existingIdx];
|
|
484
|
+
const existingArgs = existing.function?.arguments || existing.args || '';
|
|
485
|
+
const newArgs = newCall.function?.arguments || newCall.args || '';
|
|
486
|
+
|
|
487
|
+
mergedCalls[existingIdx] = {
|
|
488
|
+
...existing,
|
|
489
|
+
...newCall,
|
|
490
|
+
function: {
|
|
491
|
+
...existing.function,
|
|
492
|
+
...newCall.function,
|
|
493
|
+
arguments: existingArgs + newArgs
|
|
494
|
+
},
|
|
495
|
+
args: typeof existingArgs === 'string' && typeof newArgs === 'string'
|
|
496
|
+
? existingArgs + newArgs
|
|
497
|
+
: newCall.args || existing.args
|
|
498
|
+
};
|
|
499
|
+
} else {
|
|
500
|
+
mergedCalls.push(newCall);
|
|
501
|
+
}
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
return { ...msg, tool_calls: mergedCalls };
|
|
505
|
+
}
|
|
506
|
+
return msg;
|
|
507
|
+
}));
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
} else if (chunk.event === 'on_tool_start') {
|
|
511
|
+
const toolName = chunk.name || chunk.data?.name || chunk.data?.input?.tool || 'Retrieving data...';
|
|
512
|
+
setCurrentTool(toolName);
|
|
513
|
+
|
|
514
|
+
// Also extract tool call info if available
|
|
515
|
+
const toolInput = chunk.data?.input || chunk.data?.args || chunk.input;
|
|
516
|
+
if (toolInput && assistantMessageId) {
|
|
517
|
+
const toolCallFromEvent = {
|
|
518
|
+
id: chunk.run_id || chunk.data?.run_id || `tool-${Date.now()}`,
|
|
519
|
+
name: toolName,
|
|
520
|
+
args: toolInput
|
|
521
|
+
};
|
|
522
|
+
setMessages(prev => prev.map(msg => {
|
|
523
|
+
if (msg.id === assistantMessageId) {
|
|
524
|
+
const existingCalls = msg.tool_calls || [];
|
|
525
|
+
// Only add if not already present
|
|
526
|
+
if (!existingCalls.some((c: any) => c.name === toolName)) {
|
|
527
|
+
return { ...msg, tool_calls: [...existingCalls, toolCallFromEvent] };
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
return msg;
|
|
531
|
+
}));
|
|
532
|
+
}
|
|
533
|
+
} else if (chunk.event === 'on_tool_end') {
|
|
534
|
+
setCurrentTool(null);
|
|
535
|
+
|
|
536
|
+
// Capture tool response if available
|
|
537
|
+
const toolOutput = chunk.data?.output || chunk.output;
|
|
538
|
+
const toolRunId = chunk.run_id || chunk.data?.run_id;
|
|
539
|
+
if (toolOutput && toolRunId) {
|
|
540
|
+
setMessages(prev => {
|
|
541
|
+
// Check if we already have this tool response
|
|
542
|
+
const hasResponse = prev.some(m => m.role === 'tool' && m.tool_call_id === toolRunId);
|
|
543
|
+
if (hasResponse) return prev;
|
|
544
|
+
|
|
545
|
+
return [...prev, {
|
|
546
|
+
id: `tool-response-${toolRunId}`,
|
|
547
|
+
role: 'tool',
|
|
548
|
+
content: typeof toolOutput === 'string' ? toolOutput : JSON.stringify(toolOutput),
|
|
549
|
+
tool_call_id: toolRunId
|
|
550
|
+
}];
|
|
551
|
+
});
|
|
552
|
+
}
|
|
553
|
+
} else if (chunk.values?.messages && Array.isArray(chunk.values.messages)) {
|
|
554
|
+
// Case C: Standard updates (values)
|
|
555
|
+
setMessages(prev => mergeMessages(prev, chunk.values.messages));
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
} catch (err: any) {
|
|
559
|
+
console.error('Failed to send message:', err);
|
|
560
|
+
|
|
561
|
+
// Check if this was a premature stream termination
|
|
562
|
+
const timeSinceStart = Date.now() - lastChunkTime;
|
|
563
|
+
const isPrematureEnd = streamStarted && chunkCount < 3 && timeSinceStart < 5000;
|
|
564
|
+
|
|
565
|
+
// Provide more specific error messages based on the error type
|
|
566
|
+
let errorMessage = 'An unexpected error occurred while communicating with the AI.';
|
|
567
|
+
|
|
568
|
+
if (isPrematureEnd) {
|
|
569
|
+
errorMessage = 'Tool execution failed: The AI service encountered an error while trying to use tools. This may be due to a server-side configuration issue.';
|
|
570
|
+
} else if (err.name === 'AbortError' || err.message?.includes('aborted')) {
|
|
571
|
+
errorMessage = 'The request was cancelled or timed out.';
|
|
572
|
+
} else if (err.message?.includes('fetch')) {
|
|
573
|
+
errorMessage = 'Network error: Unable to connect to the AI service.';
|
|
574
|
+
} else if (err.message?.includes('stream')) {
|
|
575
|
+
errorMessage = 'Stream error: The AI service encountered an issue during processing.';
|
|
576
|
+
} else if (err.message?.includes('tool') || err.message?.includes('Tool')) {
|
|
577
|
+
errorMessage = 'Tool execution error: There was an issue running the requested tool.';
|
|
578
|
+
} else if (err.message) {
|
|
579
|
+
errorMessage = err.message;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
setError(errorMessage);
|
|
583
|
+
|
|
584
|
+
// Clean up: remove empty assistant messages
|
|
585
|
+
setMessages(prev => prev.filter(msg =>
|
|
586
|
+
(msg.content && msg.content !== '') ||
|
|
587
|
+
(msg.tool_calls && msg.tool_calls.length > 0) ||
|
|
588
|
+
msg.role !== 'assistant'
|
|
589
|
+
));
|
|
590
|
+
} finally {
|
|
591
|
+
setIsSending(false);
|
|
592
|
+
setCurrentTool(null);
|
|
593
|
+
isProcessingRef.current = false;
|
|
594
|
+
}
|
|
595
|
+
};
|
|
596
|
+
|
|
597
|
+
const handleFileUpload = async (files: FileList | null) => {
|
|
598
|
+
if (!files || files.length === 0 || !thread?.assistant_id) return;
|
|
599
|
+
|
|
600
|
+
setIsUploadingFiles(true);
|
|
601
|
+
setUploadError(null);
|
|
602
|
+
|
|
603
|
+
try {
|
|
604
|
+
const formData = new FormData();
|
|
605
|
+
Array.from(files).forEach(file => {
|
|
606
|
+
formData.append('files', file);
|
|
607
|
+
});
|
|
608
|
+
|
|
609
|
+
const response = await fetch(
|
|
610
|
+
`${process.env.NEXT_PUBLIC_API_URL || 'https://api.epsimoagents.com'}/assistants/${thread.assistant_id}/files`,
|
|
611
|
+
{
|
|
612
|
+
method: 'POST',
|
|
613
|
+
headers: {
|
|
614
|
+
'Authorization': `Bearer ${localStorage.getItem('jwt_token')}`
|
|
615
|
+
},
|
|
616
|
+
body: formData
|
|
617
|
+
}
|
|
618
|
+
);
|
|
619
|
+
|
|
620
|
+
if (!response.ok) {
|
|
621
|
+
throw new Error(`Upload failed: ${response.status} ${response.statusText}`);
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
const result = await response.json();
|
|
625
|
+
console.log('Files uploaded successfully:', result);
|
|
626
|
+
|
|
627
|
+
// Add a system message to indicate files were uploaded
|
|
628
|
+
const fileNames = Array.from(files).map(f => f.name).join(', ');
|
|
629
|
+
const systemMessage: Message = {
|
|
630
|
+
id: `upload-${Date.now()}`,
|
|
631
|
+
role: 'system',
|
|
632
|
+
content: `📎 Uploaded ${files.length} file(s): ${fileNames}. The assistant now has access to this content.`
|
|
633
|
+
};
|
|
634
|
+
setMessages(prev => [...prev, systemMessage]);
|
|
635
|
+
|
|
636
|
+
} catch (err: any) {
|
|
637
|
+
console.error('File upload error:', err);
|
|
638
|
+
setUploadError(err.message || 'Failed to upload files');
|
|
639
|
+
} finally {
|
|
640
|
+
setIsUploadingFiles(false);
|
|
641
|
+
if (fileInputRef.current) {
|
|
642
|
+
fileInputRef.current.value = '';
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
};
|
|
646
|
+
|
|
647
|
+
const handleFileButtonClick = () => {
|
|
648
|
+
if (!thread?.assistant_id) {
|
|
649
|
+
setUploadError('No assistant associated with this thread');
|
|
650
|
+
return;
|
|
651
|
+
}
|
|
652
|
+
fileInputRef.current?.click();
|
|
653
|
+
};
|
|
654
|
+
|
|
655
|
+
// Group tool calls with their responses
|
|
656
|
+
const toolResponseMap = new Map<string, Message>();
|
|
657
|
+
const processedMessages: Message[] = [];
|
|
658
|
+
|
|
659
|
+
messages.forEach(msg => {
|
|
660
|
+
if (msg.role === 'tool' && (msg.tool_call_id || (msg as any).tool_call_id)) {
|
|
661
|
+
const id = msg.tool_call_id || (msg as any).tool_call_id;
|
|
662
|
+
toolResponseMap.set(id, msg);
|
|
663
|
+
} else {
|
|
664
|
+
processedMessages.push(msg);
|
|
665
|
+
}
|
|
666
|
+
});
|
|
667
|
+
|
|
668
|
+
return (
|
|
669
|
+
<div className={`${styles.chatContainer} ${className || ''}`}>
|
|
670
|
+
{showHeader && (
|
|
671
|
+
<div className={styles.chatHeader}>
|
|
672
|
+
{onBack && (
|
|
673
|
+
<button onClick={onBack} className={styles.backBtn}>
|
|
674
|
+
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M19 12H5"></path><path d="M12 19l-7-7 7-7"></path></svg>
|
|
675
|
+
Back
|
|
676
|
+
</button>
|
|
677
|
+
)}
|
|
678
|
+
<h2 className={styles.threadTitle}>{thread?.name || 'Untitled Thread'}</h2>
|
|
679
|
+
</div>
|
|
680
|
+
)}
|
|
681
|
+
|
|
682
|
+
<div className={styles.messageList}>
|
|
683
|
+
{processedMessages.map((message) => {
|
|
684
|
+
const isUser = message.role === 'user';
|
|
685
|
+
const isSystem = message.role === 'system';
|
|
686
|
+
|
|
687
|
+
if (isSystem) {
|
|
688
|
+
return (
|
|
689
|
+
<div key={message.id} className={`${styles.messageWrapper} ${styles.systemWrapper}`} style={{ alignSelf: 'center', opacity: 0.7 }}>
|
|
690
|
+
<span style={{ fontSize: '0.8rem', fontStyle: 'italic' }}>{String(message.content)}</span>
|
|
691
|
+
</div>
|
|
692
|
+
);
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
return (
|
|
696
|
+
<div key={message.id} className={`${styles.messageWrapper} ${isUser ? styles.userWrapper : styles.assistantWrapper}`}>
|
|
697
|
+
<div className={`${styles.avatar} ${isUser ? styles.userAvatar : styles.assistantAvatar}`}>
|
|
698
|
+
{isUser ? 'U' : 'AI'}
|
|
699
|
+
</div>
|
|
700
|
+
<div>
|
|
701
|
+
<div className={styles.messageBubble}>
|
|
702
|
+
<button
|
|
703
|
+
className={styles.copyBtn}
|
|
704
|
+
onClick={() => copyToClipboard(typeof message.content === 'string' ? message.content : JSON.stringify(message.content))}
|
|
705
|
+
title="Copy message"
|
|
706
|
+
>
|
|
707
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path></svg>
|
|
708
|
+
</button>
|
|
709
|
+
<div className={styles.markdownContent}>
|
|
710
|
+
<ReactMarkdown
|
|
711
|
+
remarkPlugins={[remarkGfm]}
|
|
712
|
+
components={{
|
|
713
|
+
code({ node, inline, className, children, ...props }: any) {
|
|
714
|
+
const match = /language-(\w+)/.exec(className || '');
|
|
715
|
+
return !inline && match ? (
|
|
716
|
+
<SyntaxHighlighter
|
|
717
|
+
style={atomDark}
|
|
718
|
+
language={match[1]}
|
|
719
|
+
PreTag="div"
|
|
720
|
+
{...props}
|
|
721
|
+
>
|
|
722
|
+
{String(children).replace(/\n$/, '')}
|
|
723
|
+
</SyntaxHighlighter>
|
|
724
|
+
) : (
|
|
725
|
+
<code className={className} {...props}>
|
|
726
|
+
{children}
|
|
727
|
+
</code>
|
|
728
|
+
);
|
|
729
|
+
}
|
|
730
|
+
}}
|
|
731
|
+
>
|
|
732
|
+
{typeof message.content === 'string' ? message.content : ''}
|
|
733
|
+
</ReactMarkdown>
|
|
734
|
+
</div>
|
|
735
|
+
</div>
|
|
736
|
+
|
|
737
|
+
{/* Render Tool Calls if present */}
|
|
738
|
+
{message.tool_calls && message.tool_calls.length > 0 && (
|
|
739
|
+
<div style={{ marginTop: '8px', maxWidth: '100%' }}>
|
|
740
|
+
<ToolUsageContainer
|
|
741
|
+
toolCalls={message.tool_calls}
|
|
742
|
+
toolResponses={Object.fromEntries(
|
|
743
|
+
Array.from(toolResponseMap.entries()).map(([k, v]) => [k, v.content])
|
|
744
|
+
)}
|
|
745
|
+
currentTool={currentTool}
|
|
746
|
+
/>
|
|
747
|
+
</div>
|
|
748
|
+
)}
|
|
749
|
+
</div>
|
|
750
|
+
</div>
|
|
751
|
+
);
|
|
752
|
+
})}
|
|
753
|
+
|
|
754
|
+
{/* Loading indicator for messages */}
|
|
755
|
+
{inputValue === '' && isLoading && messages.length === 0 && (
|
|
756
|
+
<div className={styles.loading}>
|
|
757
|
+
<div className={styles.spinner}></div>
|
|
758
|
+
<p>Loading conversation...</p>
|
|
759
|
+
</div>
|
|
760
|
+
)}
|
|
761
|
+
|
|
762
|
+
{/* Error Banner */}
|
|
763
|
+
{error && (
|
|
764
|
+
<div className={styles.errorState}>
|
|
765
|
+
<div className={styles.errorIcon}>⚠️</div>
|
|
766
|
+
<h3>Something went wrong</h3>
|
|
767
|
+
<p>{error}</p>
|
|
768
|
+
<Button
|
|
769
|
+
className={styles.retryBtn}
|
|
770
|
+
onClick={() => window.location.reload()}
|
|
771
|
+
>
|
|
772
|
+
Refresh Page
|
|
773
|
+
</Button>
|
|
774
|
+
</div>
|
|
775
|
+
)}
|
|
776
|
+
|
|
777
|
+
{/* Show active tool execution even if no message yet */}
|
|
778
|
+
{currentTool && messages.length > 0 &&
|
|
779
|
+
!messages[messages.length - 1].tool_calls?.some((tc: any) =>
|
|
780
|
+
(tc.function?.name === currentTool || tc.name === currentTool)
|
|
781
|
+
) && (
|
|
782
|
+
<div className={styles.toolIndicator}>
|
|
783
|
+
<div className={styles.toolSpinner}></div>
|
|
784
|
+
<span className={styles.toolText}>Using {currentTool}...</span>
|
|
785
|
+
</div>
|
|
786
|
+
)}
|
|
787
|
+
|
|
788
|
+
{/* Thinking/Typing indicator */}
|
|
789
|
+
{isSending && !currentTool && (
|
|
790
|
+
<div className={`${styles.messageWrapper} ${styles.assistantWrapper}`}>
|
|
791
|
+
<div className={`${styles.avatar} ${styles.assistantAvatar}`}>AI</div>
|
|
792
|
+
<div className={styles.messageBubble + " " + styles.typingBubble}>
|
|
793
|
+
<div className={styles.typingDot}></div>
|
|
794
|
+
<div className={styles.typingDot}></div>
|
|
795
|
+
<div className={styles.typingDot}></div>
|
|
796
|
+
</div>
|
|
797
|
+
</div>
|
|
798
|
+
)}
|
|
799
|
+
|
|
800
|
+
<div ref={messagesEndRef} />
|
|
801
|
+
</div>
|
|
802
|
+
|
|
803
|
+
<div className={styles.inputArea}>
|
|
804
|
+
<div className={styles.inputContainer}>
|
|
805
|
+
<input
|
|
806
|
+
type="file"
|
|
807
|
+
ref={fileInputRef}
|
|
808
|
+
className="hidden"
|
|
809
|
+
style={{ display: 'none' }}
|
|
810
|
+
onChange={(e) => handleFileUpload(e.target.files)}
|
|
811
|
+
multiple
|
|
812
|
+
/>
|
|
813
|
+
<Button
|
|
814
|
+
type="button"
|
|
815
|
+
onClick={handleFileButtonClick}
|
|
816
|
+
disabled={isUploadingFiles}
|
|
817
|
+
className={styles.fileBtn}
|
|
818
|
+
title="Upload files"
|
|
819
|
+
>
|
|
820
|
+
{isUploadingFiles ? (
|
|
821
|
+
<div className={styles.uploadSpinner}></div>
|
|
822
|
+
) : (
|
|
823
|
+
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"></path></svg>
|
|
824
|
+
)}
|
|
825
|
+
</Button>
|
|
826
|
+
<input
|
|
827
|
+
className={styles.chatInput}
|
|
828
|
+
placeholder={isUploadingFiles ? "Uploading files..." : "Message..."}
|
|
829
|
+
value={inputValue}
|
|
830
|
+
onChange={(e) => setInputValue(e.target.value)}
|
|
831
|
+
onKeyDown={(e) => {
|
|
832
|
+
if (e.key === 'Enter' && !e.shiftKey) {
|
|
833
|
+
e.preventDefault();
|
|
834
|
+
handleSendMessage();
|
|
835
|
+
}
|
|
836
|
+
}}
|
|
837
|
+
disabled={isSending || isUploadingFiles}
|
|
838
|
+
/>
|
|
839
|
+
<Button
|
|
840
|
+
className={styles.sendBtn}
|
|
841
|
+
onClick={() => handleSendMessage()}
|
|
842
|
+
disabled={!inputValue.trim() || isSending || isUploadingFiles}
|
|
843
|
+
>
|
|
844
|
+
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><line x1="22" y1="2" x2="11" y2="13"></line><polygon points="22 2 15 22 11 13 2 9 22 2"></polygon></svg>
|
|
845
|
+
</Button>
|
|
846
|
+
</div>
|
|
847
|
+
</div>
|
|
848
|
+
|
|
849
|
+
{uploadError && (
|
|
850
|
+
<div style={{ position: 'absolute', bottom: '80px', left: '50%', transform: 'translateX(-50%)', width: '90%', maxWidth: '800px' }}>
|
|
851
|
+
<div className={styles.uploadError}>
|
|
852
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
|
853
|
+
<span>⚠️</span>
|
|
854
|
+
<span>{uploadError}</span>
|
|
855
|
+
</div>
|
|
856
|
+
<button className={styles.errorClose} onClick={() => setUploadError(null)}>×</button>
|
|
857
|
+
</div>
|
|
858
|
+
</div>
|
|
859
|
+
)}
|
|
860
|
+
</div>
|
|
861
|
+
);
|
|
862
|
+
}
|