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.
Files changed (49) hide show
  1. package/LICENSE +21 -0
  2. package/SKILL.md +85 -0
  3. package/assets/example_asset.txt +24 -0
  4. package/epsimo/__init__.py +3 -0
  5. package/epsimo/__main__.py +4 -0
  6. package/epsimo/auth.py +143 -0
  7. package/epsimo/cli.py +586 -0
  8. package/epsimo/client.py +53 -0
  9. package/epsimo/resources/assistants.py +47 -0
  10. package/epsimo/resources/credits.py +16 -0
  11. package/epsimo/resources/db.py +31 -0
  12. package/epsimo/resources/files.py +39 -0
  13. package/epsimo/resources/projects.py +30 -0
  14. package/epsimo/resources/threads.py +83 -0
  15. package/epsimo/templates/components/AuthModal/AuthModal.module.css +39 -0
  16. package/epsimo/templates/components/AuthModal/AuthModal.tsx +138 -0
  17. package/epsimo/templates/components/BuyCredits/BuyCreditsModal.module.css +96 -0
  18. package/epsimo/templates/components/BuyCredits/BuyCreditsModal.tsx +132 -0
  19. package/epsimo/templates/components/BuyCredits/CreditsDisplay.tsx +101 -0
  20. package/epsimo/templates/components/ThreadChat/ThreadChat.module.css +551 -0
  21. package/epsimo/templates/components/ThreadChat/ThreadChat.tsx +862 -0
  22. package/epsimo/templates/components/ThreadChat/components/ToolRenderers.module.css +509 -0
  23. package/epsimo/templates/components/ThreadChat/components/ToolRenderers.tsx +322 -0
  24. package/epsimo/templates/next-mvp/app/globals.css.tmpl +20 -0
  25. package/epsimo/templates/next-mvp/app/layout.tsx.tmpl +22 -0
  26. package/epsimo/templates/next-mvp/app/page.module.css.tmpl +84 -0
  27. package/epsimo/templates/next-mvp/app/page.tsx.tmpl +43 -0
  28. package/epsimo/templates/next-mvp/epsimo.yaml.tmpl +12 -0
  29. package/epsimo/templates/next-mvp/package.json.tmpl +26 -0
  30. package/epsimo/tools/library.yaml +51 -0
  31. package/package.json +27 -0
  32. package/references/api_reference.md +34 -0
  33. package/references/virtual_db_guide.md +57 -0
  34. package/requirements.txt +2 -0
  35. package/scripts/assistant.py +165 -0
  36. package/scripts/auth.py +195 -0
  37. package/scripts/credits.py +107 -0
  38. package/scripts/debug_run.py +41 -0
  39. package/scripts/example.py +19 -0
  40. package/scripts/files.py +73 -0
  41. package/scripts/find_thread.py +55 -0
  42. package/scripts/project.py +60 -0
  43. package/scripts/run.py +75 -0
  44. package/scripts/test_all_skills.py +387 -0
  45. package/scripts/test_sdk.py +83 -0
  46. package/scripts/test_streaming.py +167 -0
  47. package/scripts/test_vdb.py +65 -0
  48. package/scripts/thread.py +77 -0
  49. 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
+ }