nitrostack 1.0.71 → 1.0.72

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.
@@ -4,7 +4,34 @@ import { useEffect, useRef, useState } from 'react';
4
4
  import { useStudioStore } from '@/lib/store';
5
5
  import { api } from '@/lib/api';
6
6
  import { WidgetRenderer } from '@/components/WidgetRenderer';
7
- import type { ChatMessage, Tool, ToolCall } from '@/lib/types';
7
+ import { MarkdownRenderer } from '@/components/MarkdownRenderer';
8
+ import { VoiceOrbOverlay } from '@/components/VoiceOrbOverlay';
9
+ import type { ChatMessage, Tool, ToolCall, Prompt } from '@/lib/types';
10
+ import {
11
+ SparklesIcon,
12
+ Cog6ToothIcon,
13
+ TrashIcon,
14
+ PhotoIcon,
15
+ PaperAirplaneIcon,
16
+ WrenchScrewdriverIcon,
17
+ BookmarkIcon,
18
+ XMarkIcon,
19
+ DocumentTextIcon,
20
+ PlayIcon,
21
+ ArrowTopRightOnSquareIcon,
22
+ InformationCircleIcon,
23
+ EllipsisVerticalIcon,
24
+ MicrophoneIcon,
25
+ SpeakerWaveIcon,
26
+ StopIcon
27
+ } from '@heroicons/react/24/outline';
28
+
29
+ // Add type for webkitSpeechRecognition
30
+ declare global {
31
+ interface Window {
32
+ webkitSpeechRecognition: any;
33
+ }
34
+ }
8
35
 
9
36
  export default function ChatPage() {
10
37
  const {
@@ -13,26 +40,154 @@ export default function ChatPage() {
13
40
  clearChat,
14
41
  currentProvider,
15
42
  setCurrentProvider,
16
- currentImage,
17
- setCurrentImage,
18
- jwtToken,
43
+ currentFile,
44
+ setCurrentFile,
19
45
  tools,
20
46
  setTools,
47
+ elevenLabsApiKey,
48
+ setElevenLabsApiKey
21
49
  } = useStudioStore();
22
50
 
51
+ // ... (existing helper methods)
52
+ const getAuthTokens = () => {
53
+ const state = useStudioStore.getState();
54
+ const jwtToken = state.jwtToken || state.oauthState?.currentToken;
55
+ return {
56
+ jwtToken,
57
+ mcpApiKey: state.apiKey,
58
+ };
59
+ };
60
+
23
61
  const [inputValue, setInputValue] = useState('');
24
62
  const [loading, setLoading] = useState(false);
25
63
  const [showSettings, setShowSettings] = useState(false);
64
+ const [prompts, setPrompts] = useState<Prompt[]>([]);
65
+ const [selectedPrompt, setSelectedPrompt] = useState<Prompt | null>(null);
66
+ const [promptArgs, setPromptArgs] = useState<Record<string, string>>({});
67
+ const [fullscreenWidget, setFullscreenWidget] = useState<{ uri: string, data: any } | null>(null);
68
+
69
+ // Voice Mode State
70
+ const [isRecording, setIsRecording] = useState(false);
71
+ const [isSpeaking, setIsSpeaking] = useState(false);
72
+ const [voiceModeEnabled, setVoiceModeEnabled] = useState(false);
73
+ const [voiceOverlayOpen, setVoiceOverlayOpen] = useState(false);
74
+ const [spokenText, setSpokenText] = useState('');
75
+ const recognitionRef = useRef<any>(null);
76
+ const audioRef = useRef<HTMLAudioElement | null>(null);
77
+
26
78
  const messagesEndRef = useRef<HTMLDivElement>(null);
27
79
  const fileInputRef = useRef<HTMLInputElement>(null);
80
+ const textareaRef = useRef<HTMLTextAreaElement>(null);
81
+ const initialToolExecuted = useRef(false);
82
+
83
+ // ... (existing useEffects)
28
84
 
85
+ // Initialize Speech Recognition
29
86
  useEffect(() => {
30
- loadTools();
87
+ if (typeof window !== 'undefined' && window.webkitSpeechRecognition) {
88
+ const recognition = new window.webkitSpeechRecognition();
89
+ recognition.continuous = false;
90
+ recognition.interimResults = false;
91
+ recognition.lang = 'en-US';
92
+
93
+ recognition.onresult = (event: any) => {
94
+ const transcript = event.results[0][0].transcript;
95
+ setInputValue((prev) => prev + (prev ? ' ' : '') + transcript);
96
+ setIsRecording(false);
97
+ // Optional: Auto-send if desired, but letting user review is safer for code
98
+ };
99
+
100
+ recognition.onerror = (event: any) => {
101
+ console.error('Speech recognition error', event.error);
102
+ setIsRecording(false);
103
+ };
104
+
105
+ recognition.onend = () => {
106
+ setIsRecording(false);
107
+ };
108
+
109
+ recognitionRef.current = recognition;
110
+ }
31
111
  }, []);
32
112
 
113
+ // Text-to-Speech logic for new messages
33
114
  useEffect(() => {
34
- messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
35
- }, [chatMessages]);
115
+ if (!voiceModeEnabled || !elevenLabsApiKey || chatMessages.length === 0) return;
116
+
117
+ const lastMessage = chatMessages[chatMessages.length - 1];
118
+ if (lastMessage.role === 'assistant' && lastMessage.content) {
119
+ // Stop any current audio
120
+ if (audioRef.current) {
121
+ audioRef.current.pause();
122
+ audioRef.current = null;
123
+ }
124
+ playTextToSpeech(lastMessage.content);
125
+ }
126
+ }, [chatMessages, voiceModeEnabled, elevenLabsApiKey]);
127
+
128
+
129
+ const toggleRecording = () => {
130
+ if (isRecording) {
131
+ recognitionRef.current?.stop();
132
+ } else {
133
+ if (!recognitionRef.current) {
134
+ alert('Speech recognition not supported in this browser.');
135
+ return;
136
+ }
137
+ setVoiceModeEnabled(true); // Enable voice mode if they use mic
138
+ recognitionRef.current.start();
139
+ setIsRecording(true);
140
+ }
141
+ };
142
+
143
+ const playTextToSpeech = async (text: string) => {
144
+ if (!elevenLabsApiKey) return;
145
+
146
+ try {
147
+ setIsSpeaking(true);
148
+ const voiceId = '21m00Tcm4TlvDq8ikWAM'; // Rachel - popular default
149
+ const response = await fetch(`https://api.elevenlabs.io/v1/text-to-speech/${voiceId}/stream`, {
150
+ method: 'POST',
151
+ headers: {
152
+ 'Content-Type': 'application/json',
153
+ 'xi-api-key': elevenLabsApiKey,
154
+ },
155
+ body: JSON.stringify({
156
+ text,
157
+ model_id: 'eleven_monolingual_v1',
158
+ voice_settings: {
159
+ stability: 0.5,
160
+ similarity_boost: 0.75,
161
+ },
162
+ }),
163
+ });
164
+
165
+ if (!response.ok) throw new Error('TTS failed');
166
+
167
+ const blob = await response.blob();
168
+ const url = URL.createObjectURL(blob);
169
+ const audio = new Audio(url);
170
+
171
+ audio.onended = () => {
172
+ setIsSpeaking(false);
173
+ URL.revokeObjectURL(url);
174
+ };
175
+
176
+ audioRef.current = audio;
177
+ audio.play();
178
+ } catch (error) {
179
+ console.error('TTS Error:', error);
180
+ setIsSpeaking(false);
181
+ }
182
+ };
183
+
184
+ const stopSpeaking = () => {
185
+ if (audioRef.current) {
186
+ audioRef.current.pause();
187
+ audioRef.current = null;
188
+ }
189
+ setIsSpeaking(false);
190
+ };
36
191
 
37
192
  const loadTools = async () => {
38
193
  try {
@@ -43,7 +198,146 @@ export default function ChatPage() {
43
198
  }
44
199
  };
45
200
 
46
- const handleImageUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
201
+ const loadPrompts = async () => {
202
+ try {
203
+ const data = await api.getPrompts();
204
+ setPrompts(data.prompts || []);
205
+ } catch (error) {
206
+ console.error('Failed to load prompts:', error);
207
+ }
208
+ };
209
+
210
+ const checkAndRunInitialTool = async () => {
211
+ // Find initial tool using specific metadata key
212
+ const initialTool = tools.find(t => t._meta?.['tool/initial'] === true);
213
+ if (!initialTool) return;
214
+
215
+ // Check for API keys (Gemini or OpenAI)
216
+ const geminiKey = localStorage.getItem('gemini_api_key');
217
+ const openaiKey = localStorage.getItem('openai_api_key');
218
+ const hasKey = (geminiKey && geminiKey !== '••••••••') || (openaiKey && openaiKey !== '••••••••');
219
+
220
+ if (!hasKey) return;
221
+
222
+ // Mark as executed immediately to prevent double run
223
+ initialToolExecuted.current = true;
224
+ console.log('🚀 Auto-executing initial tool:', initialTool.name);
225
+
226
+ // Initial message
227
+ const autoMsg: ChatMessage = {
228
+ role: 'user',
229
+ content: `(Auto) Executing initial tool: ${initialTool.name}`,
230
+ };
231
+ addChatMessage(autoMsg);
232
+ setLoading(true);
233
+
234
+ try {
235
+ const { jwtToken, mcpApiKey } = getAuthTokens();
236
+ const effectiveToken = jwtToken || useStudioStore.getState().oauthState?.currentToken;
237
+
238
+ // Call the tool
239
+ const result = await api.callTool(
240
+ initialTool.name,
241
+ {},
242
+ effectiveToken,
243
+ mcpApiKey || undefined
244
+ );
245
+
246
+ // Add assistant message with tool call info
247
+ const toolCallId = `call_${Date.now()}`;
248
+ const assistantMsg: ChatMessage = {
249
+ role: 'assistant',
250
+ content: `Invoking ${initialTool.name}...`,
251
+ toolCalls: [{
252
+ id: toolCallId,
253
+ name: initialTool.name,
254
+ arguments: {},
255
+ result // Attach result here for widget rendering
256
+ }]
257
+ };
258
+ addChatMessage(assistantMsg);
259
+
260
+ // Add tool result message
261
+ const toolResultMsg: ChatMessage = {
262
+ role: 'tool',
263
+ content: JSON.stringify(result),
264
+ toolCallId: toolCallId
265
+ };
266
+ addChatMessage(toolResultMsg);
267
+
268
+ } catch (error) {
269
+ console.error('Initial tool execution failed:', error);
270
+ addChatMessage({
271
+ role: 'assistant',
272
+ content: `Failed to execute initial tool ${initialTool.name}: ${error instanceof Error ? error.message : String(error)}`
273
+ });
274
+ } finally {
275
+ setLoading(false);
276
+ }
277
+ };
278
+
279
+ const handleExecutePrompt = async () => {
280
+ if (!selectedPrompt) return;
281
+
282
+ // Close modal
283
+ const prompt = selectedPrompt;
284
+ const args = { ...promptArgs };
285
+ setSelectedPrompt(null);
286
+ setPromptArgs({});
287
+
288
+ // Build user message showing what prompt was executed
289
+ let userMessageContent = `Execute prompt: ${prompt.name}`;
290
+ if (Object.keys(args).length > 0) {
291
+ userMessageContent += `\nArguments: ${JSON.stringify(args, null, 2)}`;
292
+ }
293
+
294
+ // Add user message to chat
295
+ addChatMessage({
296
+ role: 'user',
297
+ content: userMessageContent,
298
+ });
299
+
300
+ setLoading(true);
301
+
302
+ try {
303
+ // Execute the prompt directly via API
304
+ const result = await api.executePrompt(prompt.name, args);
305
+
306
+ // Add the prompt result as an assistant message
307
+ if (result.messages && result.messages.length > 0) {
308
+ // Combine all prompt messages into one assistant message
309
+ const combinedContent = result.messages
310
+ .map((msg: any) => {
311
+ const content = typeof msg.content === 'string'
312
+ ? msg.content
313
+ : msg.content?.text || JSON.stringify(msg.content);
314
+ return `[${msg.role.toUpperCase()}]\n${content}`;
315
+ })
316
+ .join('\n\n');
317
+
318
+ addChatMessage({
319
+ role: 'assistant',
320
+ content: combinedContent,
321
+ });
322
+ } else {
323
+ addChatMessage({
324
+ role: 'assistant',
325
+ content: 'Prompt executed successfully, but returned no messages.',
326
+ });
327
+ }
328
+ } catch (error) {
329
+ console.error('Failed to execute prompt:', error);
330
+ addChatMessage({
331
+ role: 'assistant',
332
+ content: `Error executing prompt: ${error instanceof Error ? error.message : 'Unknown error'}`,
333
+ });
334
+ } finally {
335
+ setLoading(false);
336
+ setTimeout(() => textareaRef.current?.focus(), 100);
337
+ }
338
+ };
339
+
340
+ const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
47
341
  const file = e.target.files?.[0];
48
342
  if (!file) return;
49
343
 
@@ -54,7 +348,7 @@ export default function ChatPage() {
54
348
 
55
349
  const reader = new FileReader();
56
350
  reader.onload = (event) => {
57
- setCurrentImage({
351
+ setCurrentFile({
58
352
  data: event.target?.result as string,
59
353
  type: file.type,
60
354
  name: file.name,
@@ -64,7 +358,7 @@ export default function ChatPage() {
64
358
  };
65
359
 
66
360
  const handleSend = async () => {
67
- if (!inputValue.trim() && !currentImage) return;
361
+ if (!inputValue.trim() && !currentFile) return;
68
362
 
69
363
  const apiKey = localStorage.getItem(`${currentProvider}_api_key`);
70
364
  if (!apiKey) {
@@ -78,72 +372,180 @@ export default function ChatPage() {
78
372
  content: inputValue,
79
373
  };
80
374
 
81
- if (currentImage) {
82
- userMessage.image = currentImage;
375
+ if (currentFile) {
376
+ userMessage.file = currentFile;
83
377
  }
84
378
 
85
379
  addChatMessage(userMessage);
86
380
  setInputValue('');
87
- setCurrentImage(null);
381
+ setCurrentFile(null);
88
382
  setLoading(true);
89
383
 
90
384
  try {
385
+ const messagesToSend = [...chatMessages, userMessage];
386
+
387
+ // Clean messages to ensure they're serializable
388
+ const cleanedMessages = messagesToSend.map(msg => {
389
+ const cleaned: any = {
390
+ role: msg.role,
391
+ content: msg.content || '',
392
+ };
393
+
394
+ if (msg.toolCalls && msg.toolCalls.length > 0) {
395
+ cleaned.toolCalls = msg.toolCalls;
396
+ }
397
+
398
+ if (msg.toolCallId) {
399
+ cleaned.toolCallId = msg.toolCallId;
400
+ }
401
+
402
+ // Skip image property for now (not supported by OpenAI chat completions)
403
+ if (msg.file) {
404
+ cleaned.file = msg.file;
405
+ }
406
+
407
+ return cleaned;
408
+ });
409
+
410
+ // Get fresh auth tokens from store
411
+ const { jwtToken, mcpApiKey } = getAuthTokens();
412
+
413
+ console.log('Sending messages to API:', cleanedMessages);
414
+ console.log('Auth tokens:', { hasJwtToken: !!jwtToken, hasMcpApiKey: !!mcpApiKey });
415
+ console.log('Original messages:', messagesToSend);
416
+ console.log('Cleaned messages JSON:', JSON.stringify(cleanedMessages));
417
+
91
418
  const response = await api.chat({
92
419
  provider: currentProvider,
93
- messages: [...chatMessages, userMessage],
94
- apiKey,
420
+ messages: cleanedMessages,
421
+ apiKey, // LLM API key (OpenAI/Gemini)
95
422
  jwtToken: jwtToken || undefined,
423
+ mcpApiKey: mcpApiKey || undefined, // MCP server API key
96
424
  });
97
425
 
98
- if (response.message) {
99
- addChatMessage(response.message);
100
- }
101
-
102
- // Handle tool calls
426
+ // Handle tool calls FIRST (before adding the message)
103
427
  if (response.toolCalls && response.toolResults) {
104
- // Check for login token
428
+ // Attach results to tool calls for widget rendering
429
+ const toolCallsWithResults = response.toolCalls.map((tc: ToolCall, i: number) => {
430
+ const toolResult = response.toolResults[i];
431
+
432
+ // Parse the result content
433
+ let parsedResult;
434
+ if (toolResult.content) {
435
+ try {
436
+ parsedResult = JSON.parse(toolResult.content);
437
+
438
+ // Unwrap if response was wrapped by TransformInterceptor
439
+ if (parsedResult.success !== undefined && parsedResult.data !== undefined) {
440
+ parsedResult = parsedResult.data;
441
+ }
442
+ } catch {
443
+ parsedResult = { content: toolResult.content };
444
+ }
445
+ }
446
+
447
+ return {
448
+ ...tc,
449
+ result: parsedResult,
450
+ };
451
+ });
452
+
453
+ // Add assistant message with tool calls
454
+ if (response.message) {
455
+ response.message.toolCalls = toolCallsWithResults;
456
+ addChatMessage(response.message);
457
+ }
458
+
459
+ // Extract JWT token from ANY tool response (not just 'login')
105
460
  for (let i = 0; i < response.toolCalls.length; i++) {
106
461
  const toolCall = response.toolCalls[i];
107
462
  const toolResult = response.toolResults[i];
108
463
 
109
- if (toolCall.name === 'login' && toolResult.content) {
464
+ if (toolResult.content) {
110
465
  try {
111
466
  const parsed = JSON.parse(toolResult.content);
112
- if (parsed.token) {
113
- useStudioStore.getState().setJwtToken(parsed.token);
467
+ // Check for token in multiple possible locations
468
+ const token = parsed.token || parsed.access_token || parsed.jwt || parsed.data?.token;
469
+ if (token) {
470
+ console.log('🔐 Token received from tool in chat, saving to global state');
471
+ useStudioStore.getState().setJwtToken(token);
472
+ break; // Stop after first token found
114
473
  }
115
474
  } catch (e) {
116
- // Ignore
475
+ // Ignore parsing errors
117
476
  }
118
477
  }
119
478
  }
120
479
 
121
- // Add tool results
480
+ // Add tool results to messages
481
+ const toolResultMessages: ChatMessage[] = [];
122
482
  for (const result of response.toolResults) {
123
483
  addChatMessage(result);
484
+ toolResultMessages.push(result);
124
485
  }
125
486
 
126
- // Continue conversation
127
- await continueChatWithToolResults(apiKey);
487
+ // Continue conversation and WAIT for it to complete before allowing new messages
488
+ // Build the full message history to pass to continuation
489
+ const messagesForContinuation = [
490
+ ...chatMessages,
491
+ response.message!, // Assistant message with tool calls
492
+ ...toolResultMessages, // Tool result messages
493
+ ];
494
+ await continueChatWithToolResults(apiKey, messagesForContinuation);
495
+ } else {
496
+ // No tool calls, just add the message
497
+ if (response.message) {
498
+ addChatMessage(response.message);
499
+ }
128
500
  }
501
+
502
+ // Set loading to false AFTER all async operations complete
503
+ setLoading(false);
129
504
  } catch (error) {
130
505
  console.error('Chat error:', error);
131
506
  addChatMessage({
132
507
  role: 'assistant',
133
508
  content: 'Sorry, I encountered an error. Please try again.',
134
509
  });
135
- } finally {
136
510
  setLoading(false);
137
511
  }
138
512
  };
139
513
 
140
- const continueChatWithToolResults = async (apiKey: string) => {
514
+ const continueChatWithToolResults = async (apiKey: string, messages?: ChatMessage[]) => {
141
515
  try {
516
+ // Use provided messages or fall back to store (for recursive calls)
517
+ const messagesToUse = messages || chatMessages;
518
+
519
+ // Get fresh auth tokens from store (token may have been updated by login)
520
+ const { jwtToken, mcpApiKey } = getAuthTokens();
521
+
522
+ // Clean messages before sending
523
+ const cleanedMessages = messagesToUse.map(msg => {
524
+ const cleaned: any = {
525
+ role: msg.role,
526
+ content: msg.content || '',
527
+ };
528
+
529
+ if (msg.toolCalls && msg.toolCalls.length > 0) {
530
+ cleaned.toolCalls = msg.toolCalls;
531
+ }
532
+
533
+ if (msg.toolCallId) {
534
+ cleaned.toolCallId = msg.toolCallId;
535
+ }
536
+
537
+ return cleaned;
538
+ });
539
+
540
+ console.log('Continue with cleaned messages:', JSON.stringify(cleanedMessages));
541
+ console.log('Continue auth tokens:', { hasJwtToken: !!jwtToken, hasMcpApiKey: !!mcpApiKey });
542
+
142
543
  const response = await api.chat({
143
544
  provider: currentProvider,
144
- messages: chatMessages,
145
- apiKey,
545
+ messages: cleanedMessages,
546
+ apiKey, // LLM API key (OpenAI/Gemini)
146
547
  jwtToken: jwtToken || undefined,
548
+ mcpApiKey: mcpApiKey || undefined, // MCP server API key
147
549
  });
148
550
 
149
551
  if (response.message) {
@@ -152,10 +554,19 @@ export default function ChatPage() {
152
554
 
153
555
  // Recursive tool calls
154
556
  if (response.toolCalls && response.toolResults) {
557
+ const newToolResults: ChatMessage[] = [];
155
558
  for (const result of response.toolResults) {
156
559
  addChatMessage(result);
560
+ newToolResults.push(result);
157
561
  }
158
- await continueChatWithToolResults(apiKey);
562
+
563
+ // Build messages for next iteration
564
+ const nextMessages = [
565
+ ...(messages || chatMessages),
566
+ response.message!,
567
+ ...newToolResults,
568
+ ];
569
+ await continueChatWithToolResults(apiKey, nextMessages);
159
570
  }
160
571
  } catch (error) {
161
572
  console.error('Continuation error:', error);
@@ -177,148 +588,599 @@ export default function ChatPage() {
177
588
  };
178
589
 
179
590
  return (
180
- <div className="h-screen flex flex-col bg-dark-bg">
181
- {/* Header */}
182
- <div className="border-b border-dark-border p-4 flex items-center justify-between">
183
- <div>
184
- <h1 className="text-2xl font-bold">🤖 AI Chat</h1>
185
- <p className="text-text-secondary text-sm">Chat with tools integration</p>
186
- </div>
591
+ <div className="fixed inset-0 flex flex-col" style={{ left: 'var(--sidebar-width, 15rem)', backgroundColor: '#0a0a0a' }}>
592
+ {/* Minimal Professional Header */}
593
+ <div className="sticky top-0 z-10 border-b border-border/50 px-3 sm:px-6 py-4 flex flex-col sm:flex-row items-start sm:items-center justify-between bg-card/50 backdrop-blur-sm gap-3 sm:gap-0">
594
+ <h1 className="text-lg font-semibold text-foreground flex items-center gap-2">
595
+ AI Chat
596
+ {voiceModeEnabled && <span className="text-xs bg-primary/20 text-primary px-2 py-0.5 rounded-full animate-pulse">Voice Active</span>}
597
+ </h1>
598
+
599
+ <div className="flex items-center gap-2 w-full sm:w-auto">
600
+ {/* Voice Output Toggle */}
601
+ {elevenLabsApiKey && (
602
+ <button
603
+ onClick={() => {
604
+ if (isSpeaking) stopSpeaking();
605
+ setVoiceModeEnabled(!voiceModeEnabled);
606
+ }}
607
+ className={`h-8 w-8 rounded-lg flex items-center justify-center transition-all flex-shrink-0 ${voiceModeEnabled
608
+ ? 'bg-primary/20 text-primary ring-1 ring-primary/50'
609
+ : 'bg-muted/50 text-muted-foreground hover:text-foreground'
610
+ }`}
611
+ title={voiceModeEnabled ? "Disable Voice Output" : "Enable Voice Output"}
612
+ >
613
+ {isSpeaking ? <SpeakerWaveIcon className="h-4 w-4 animate-pulse" /> : <MicrophoneIcon className="h-4 w-4" />}
614
+ </button>
615
+ )}
187
616
 
188
- <div className="flex items-center gap-2">
189
617
  <select
190
618
  value={currentProvider}
191
619
  onChange={(e) => setCurrentProvider(e.target.value as 'openai' | 'gemini')}
192
- className="input w-32"
620
+ className="input text-sm px-3 py-1.5 w-full sm:w-28 flex-1 sm:flex-none"
193
621
  >
194
- <option value="openai">OpenAI</option>
195
622
  <option value="gemini">Gemini</option>
623
+ <option value="openai">OpenAI</option>
196
624
  </select>
197
- <button onClick={() => setShowSettings(!showSettings)} className="btn btn-secondary">
198
- ⚙️
625
+ <button
626
+ onClick={() => setShowSettings(!showSettings)}
627
+ className={`h-8 w-8 rounded-lg flex items-center justify-center transition-all flex-shrink-0 ${showSettings
628
+ ? 'bg-primary/10 text-primary ring-1 ring-primary/30'
629
+ : 'bg-muted/50 text-muted-foreground hover:bg-muted hover:text-foreground'
630
+ }`}
631
+ title="Settings"
632
+ >
633
+ <Cog6ToothIcon className="h-4 w-4" />
199
634
  </button>
200
- <button onClick={clearChat} className="btn btn-secondary">
201
- 🗑️
635
+ <button
636
+ onClick={clearChat}
637
+ className="h-8 w-8 rounded-lg flex items-center justify-center bg-muted/50 text-muted-foreground hover:bg-muted hover:text-foreground transition-all flex-shrink-0"
638
+ title="Clear chat"
639
+ >
640
+ <TrashIcon className="h-4 w-4" />
202
641
  </button>
203
642
  </div>
204
643
  </div>
205
644
 
206
- {/* Settings Panel */}
645
+ {/* Enhanced Settings Panel */}
207
646
  {showSettings && (
208
- <div className="border-b border-dark-border p-4 bg-dark-surface">
209
- <h3 className="font-medium mb-3">API Keys</h3>
210
- <div className="grid grid-cols-2 gap-4">
211
- <div>
212
- <label className="text-sm mb-2 block">OpenAI API Key</label>
213
- <div className="flex gap-2">
214
- <input id="openai-api-key" type="password" className="input flex-1" placeholder="sk-..." />
215
- <button onClick={() => saveApiKey('openai')} className="btn btn-primary">
216
- Save
217
- </button>
647
+ <div className="border-b border-border/50 px-3 sm:px-6 py-4 sm:py-5 bg-muted/20 backdrop-blur-md shadow-sm">
648
+ <div className="max-w-4xl mx-auto">
649
+ <div className="flex items-start justify-between mb-4">
650
+ <div>
651
+ <h3 className="text-sm font-semibold text-foreground flex items-center gap-2">
652
+ <Cog6ToothIcon className="h-4 w-4" />
653
+ API Configuration
654
+ </h3>
655
+ <p className="text-xs text-muted-foreground mt-1">Configure your AI provider API keys to enable chat functionality</p>
218
656
  </div>
219
657
  </div>
220
- <div>
221
- <label className="text-sm mb-2 block">Gemini API Key</label>
222
- <div className="flex gap-2">
223
- <input id="gemini-api-key" type="password" className="input flex-1" placeholder="AIza..." />
224
- <button onClick={() => saveApiKey('gemini')} className="btn btn-primary">
225
- Save
226
- </button>
658
+
659
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
660
+ {/* OpenAI Section */}
661
+ <div className="card p-4">
662
+ <div className="flex items-center justify-between mb-3">
663
+ <label className="text-xs font-semibold text-foreground flex items-center gap-2">
664
+ <div className="w-6 h-6 rounded bg-green-500/10 flex items-center justify-center">
665
+ <span className="text-xs font-bold text-green-600">AI</span>
666
+ </div>
667
+ OpenAI API Key
668
+ </label>
669
+ <a
670
+ href="https://platform.openai.com/api-keys"
671
+ target="_blank"
672
+ rel="noopener noreferrer"
673
+ className="text-xs text-primary hover:text-primary/80 flex items-center gap-1 transition-colors"
674
+ >
675
+ Get Key <ArrowTopRightOnSquareIcon className="w-3 h-3" />
676
+ </a>
677
+ </div>
678
+ <div className="flex gap-2 mb-3">
679
+ <input
680
+ id="openai-api-key"
681
+ type="password"
682
+ className="input flex-1 text-sm py-2"
683
+ placeholder="sk-proj-..."
684
+ />
685
+ <button onClick={() => saveApiKey('openai')} className="btn btn-primary text-xs px-4 py-2">
686
+ <BookmarkIcon className="w-3 h-3 mr-1" />
687
+ Save
688
+ </button>
689
+ </div>
690
+ <div className="flex items-start gap-2 p-2 bg-blue-500/5 rounded-lg border border-blue-500/10">
691
+ <InformationCircleIcon className="w-3 h-3 text-blue-500 mt-0.5 flex-shrink-0" />
692
+ <div className="text-xs text-muted-foreground">
693
+ <p className="mb-1">
694
+ <strong>How to get:</strong> Sign up at{' '}
695
+ <a href="https://platform.openai.com/signup" target="_blank" rel="noopener noreferrer" className="text-primary hover:underline">
696
+ OpenAI Platform
697
+ </a>
698
+ , navigate to API Keys, and create a new secret key.
699
+ </p>
700
+ <a
701
+ href="https://help.openai.com/en/articles/4936850-where-do-i-find-my-openai-api-key"
702
+ target="_blank"
703
+ rel="noopener noreferrer"
704
+ className="text-primary hover:underline inline-flex items-center gap-1"
705
+ >
706
+ View Guide <ArrowTopRightOnSquareIcon className="w-2.5 h-2.5" />
707
+ </a>
708
+ </div>
709
+ </div>
710
+ </div>
711
+
712
+ {/* Gemini Section */}
713
+ <div className="card p-4">
714
+ <div className="flex items-center justify-between mb-3">
715
+ <label className="text-xs font-semibold text-foreground flex items-center gap-2">
716
+ <div className="w-6 h-6 rounded bg-blue-500/10 flex items-center justify-center">
717
+ <span className="text-xs font-bold text-blue-600">G</span>
718
+ </div>
719
+ Gemini API Key
720
+ </label>
721
+ <a
722
+ href="https://aistudio.google.com/app/apikey"
723
+ target="_blank"
724
+ rel="noopener noreferrer"
725
+ className="text-xs text-primary hover:text-primary/80 flex items-center gap-1 transition-colors"
726
+ >
727
+ Get Key <ArrowTopRightOnSquareIcon className="w-3 h-3" />
728
+ </a>
729
+ </div>
730
+ <div className="flex gap-2 mb-3">
731
+ <input
732
+ id="gemini-api-key"
733
+ type="password"
734
+ className="input flex-1 text-sm py-2"
735
+ placeholder="AIza..."
736
+ />
737
+ <button onClick={() => saveApiKey('gemini')} className="btn btn-primary text-xs px-4 py-2">
738
+ <BookmarkIcon className="w-3 h-3 mr-1" />
739
+ Save
740
+ </button>
741
+ </div>
742
+ <div className="flex items-start gap-2 p-2 bg-blue-500/5 rounded-lg border border-blue-500/10">
743
+ <InformationCircleIcon className="w-3 h-3 text-blue-500 mt-0.5 flex-shrink-0" />
744
+ <div className="text-xs text-muted-foreground">
745
+ <p className="mb-1">
746
+ <strong>How to get:</strong> Visit{' '}
747
+ <a href="https://aistudio.google.com" target="_blank" rel="noopener noreferrer" className="text-primary hover:underline">
748
+ Google AI Studio
749
+ </a>
750
+ , sign in with your Google account, and click "Get API key".
751
+ </p>
752
+ <a
753
+ href="https://ai.google.dev/gemini-api/docs/api-key"
754
+ target="_blank"
755
+ rel="noopener noreferrer"
756
+ className="text-primary hover:underline inline-flex items-center gap-1"
757
+ >
758
+ View Guide <ArrowTopRightOnSquareIcon className="w-2.5 h-2.5" />
759
+ </a>
760
+ </div>
761
+ </div>
762
+ </div>
763
+ </div>
764
+
765
+ {/* Security Notice */}
766
+ <div className="mt-4 p-3 bg-status-info/5 rounded border border-status-info/10">
767
+ <div className="flex items-start gap-2">
768
+ <InformationCircleIcon className="h-4 w-4 text-status-info mt-0.5 flex-shrink-0" />
769
+ <div className="text-xs text-muted-foreground">
770
+ <strong className="text-foreground">Security Note:</strong> Your API keys are stored locally in your browser and never sent to our servers.
771
+ Keep them confidential and avoid sharing them publicly.
772
+ </div>
773
+ </div>
774
+ </div>
775
+
776
+ {/* ElevenLabs Voice Section */}
777
+ <div className="mt-4">
778
+ <h3 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-3">Voice Integration</h3>
779
+ <div className="card p-4">
780
+ <div className="flex items-center justify-between mb-3">
781
+ <label className="text-xs font-semibold text-foreground flex items-center gap-2">
782
+ <div className="w-6 h-6 rounded bg-purple-500/10 flex items-center justify-center">
783
+ <MicrophoneIcon className="w-3.5 h-3.5 text-purple-600" />
784
+ </div>
785
+ ElevenLabs API Key
786
+ </label>
787
+ <a
788
+ href="https://elevenlabs.io/api"
789
+ target="_blank"
790
+ rel="noopener noreferrer"
791
+ className="text-xs text-primary hover:text-primary/80 flex items-center gap-1 transition-colors"
792
+ >
793
+ Get Key <ArrowTopRightOnSquareIcon className="w-3 h-3" />
794
+ </a>
795
+ </div>
796
+ <div className="flex gap-2 mb-3">
797
+ <input
798
+ type="password"
799
+ className="input flex-1 text-sm py-2"
800
+ placeholder="sk_..."
801
+ value={elevenLabsApiKey || ''}
802
+ onChange={(e) => setElevenLabsApiKey(e.target.value || null)}
803
+ />
804
+ {elevenLabsApiKey && (
805
+ <button
806
+ onClick={() => setElevenLabsApiKey(null)}
807
+ className="btn btn-ghost text-destructive text-xs px-3"
808
+ >
809
+ Clear
810
+ </button>
811
+ )}
812
+ </div>
813
+ <div className="flex items-start gap-2 p-2 bg-purple-500/5 rounded-lg border border-purple-500/10">
814
+ <InformationCircleIcon className="w-3 h-3 text-purple-500 mt-0.5 flex-shrink-0" />
815
+ <div className="text-xs text-muted-foreground">
816
+ <p>
817
+ <strong>Enables voice mode:</strong> When configured, a microphone button appears
818
+ allowing you to speak your messages and hear AI responses read aloud.
819
+ </p>
820
+ </div>
821
+ </div>
227
822
  </div>
228
823
  </div>
229
824
  </div>
230
825
  </div>
231
826
  )}
232
827
 
233
- {/* Messages */}
234
- <div className="flex-1 overflow-y-auto p-4 space-y-4">
235
- {chatMessages.map((msg, idx) => (
236
- <ChatMessageComponent key={idx} message={msg} tools={tools} />
237
- ))}
238
- {loading && (
239
- <div className="flex items-center gap-2 text-text-secondary">
240
- <div className="animate-pulse">Thinking...</div>
241
- </div>
242
- )}
243
- <div ref={messagesEndRef} />
828
+ {/* ChatGPT-style Messages Container - ONLY this scrolls */}
829
+ <div className="flex-1 overflow-y-auto overflow-x-hidden">
830
+ <div className="max-w-5xl mx-auto px-4 py-6 space-y-6 min-h-full">
831
+ {chatMessages.length === 0 && !loading ? (
832
+ /* Welcome Screen */
833
+ <div className="flex flex-col items-center justify-center min-h-[calc(100vh-300px)] animate-fade-in">
834
+ <div className="w-16 h-16 rounded bg-gradient-to-br from-primary to-secondary flex items-center justify-center shadow-xl mb-6">
835
+ <SparklesIcon className="h-10 w-10 text-white" />
836
+ </div>
837
+
838
+ <h2 className="text-3xl font-bold text-foreground mb-3">Welcome to NitroStudio</h2>
839
+ <p className="text-muted-foreground text-center max-w-md mb-8">
840
+ Your AI-powered development environment for Model Context Protocol (MCP) servers.
841
+ Start a conversation or try a prompt below.
842
+ </p>
843
+
844
+ {/* Prompts Overview */}
845
+ {prompts.length > 0 && (
846
+ <div className="w-full max-w-2xl">
847
+ <div className="flex items-center gap-2 mb-4">
848
+ <SparklesIcon className="h-5 w-5 text-primary" />
849
+ <h3 className="text-lg font-semibold text-foreground">Available Prompts</h3>
850
+ <span className="text-sm text-muted-foreground">({prompts.length})</span>
851
+ </div>
852
+
853
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-3">
854
+ {prompts.slice(0, 6).map((prompt) => (
855
+ <button
856
+ key={prompt.name}
857
+ onClick={() => {
858
+ setSelectedPrompt(prompt);
859
+ setPromptArgs({});
860
+ }}
861
+ className="card card-hover p-4 text-left group transition-all hover:scale-[1.02]"
862
+ >
863
+ <div className="flex items-start gap-3">
864
+ <div className="h-8 w-8 rounded-lg bg-primary/10 flex items-center justify-center group-hover:bg-primary/20 transition-colors flex-shrink-0">
865
+ <DocumentTextIcon className="h-4 w-4 text-primary" />
866
+ </div>
867
+ <div className="flex-1 min-w-0">
868
+ <h4 className="font-semibold text-foreground text-sm mb-1 truncate">
869
+ {prompt.name}
870
+ </h4>
871
+ <p className="text-xs text-muted-foreground line-clamp-2">
872
+ {prompt.description || 'No description'}
873
+ </p>
874
+ {prompt.arguments && prompt.arguments.length > 0 && (
875
+ <span className="badge badge-secondary text-xs mt-2 inline-block">
876
+ {prompt.arguments.length} arg{prompt.arguments.length !== 1 ? 's' : ''}
877
+ </span>
878
+ )}
879
+ </div>
880
+ </div>
881
+ </button>
882
+ ))}
883
+ </div>
884
+
885
+ {prompts.length > 6 && (
886
+ <p className="text-xs text-muted-foreground text-center mt-4">
887
+ ...and {prompts.length - 6} more. Visit the Prompts tab to see all.
888
+ </p>
889
+ )}
890
+ </div>
891
+ )}
892
+
893
+ {/* Suggestion Cards */}
894
+ <div className="w-full max-w-2xl mt-8">
895
+ <p className="text-sm text-muted-foreground mb-3">Or try asking:</p>
896
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-3">
897
+ {[
898
+ 'What tools are available?',
899
+ 'Show me the health status',
900
+ 'List all resources',
901
+ 'Help me get started'
902
+ ].map((suggestion) => (
903
+ <button
904
+ key={suggestion}
905
+ onClick={() => {
906
+ setInputValue(suggestion);
907
+ setTimeout(() => textareaRef.current?.focus(), 100);
908
+ }}
909
+ className="card card-hover p-3 text-left text-sm text-muted-foreground hover:text-foreground transition-colors"
910
+ >
911
+ "{suggestion}"
912
+ </button>
913
+ ))}
914
+ </div>
915
+ </div>
916
+ </div>
917
+ ) : (
918
+ <>
919
+ {chatMessages.map((msg, idx) => (
920
+ <ChatMessageComponent key={idx} message={msg} tools={tools} />
921
+ ))}
922
+ {loading && (
923
+ <div className="flex gap-4 items-start animate-fade-in">
924
+ <div className="h-8 w-8 rounded-full bg-gradient-to-br from-primary to-secondary flex items-center justify-center flex-shrink-0 shadow-md">
925
+ <SparklesIcon className="h-5 w-5 text-white" />
926
+ </div>
927
+ <div className="flex-1 bg-card/50 backdrop-blur-sm rounded px-5 py-4 border border-border/50">
928
+ <div className="flex items-center gap-2">
929
+ <div className="flex gap-1">
930
+ <span className="w-2 h-2 bg-primary rounded-full animate-bounce" style={{ animationDelay: '0s' }}></span>
931
+ <span className="w-2 h-2 bg-primary rounded-full animate-bounce" style={{ animationDelay: '0.15s' }}></span>
932
+ <span className="w-2 h-2 bg-primary rounded-full animate-bounce" style={{ animationDelay: '0.3s' }}></span>
933
+ </div>
934
+ <span className="text-sm text-muted-foreground font-medium">Thinking...</span>
935
+ </div>
936
+ </div>
937
+ </div>
938
+ )}
939
+ </>
940
+ )}
941
+ <div ref={messagesEndRef} />
942
+ </div>
244
943
  </div>
245
944
 
246
- {/* Input */}
247
- <div className="border-t border-dark-border p-4">
248
- {currentImage && (
249
- <div className="mb-2 relative inline-block">
250
- <img
251
- src={currentImage.data}
252
- alt="Upload preview"
253
- className="h-20 rounded-lg border border-dark-border"
254
- />
255
- <button
256
- onClick={() => setCurrentImage(null)}
257
- className="absolute -top-2 -right-2 bg-error text-white rounded-full w-6 h-6 flex items-center justify-center text-xs"
258
- >
259
-
260
- </button>
945
+ {/* ChatGPT-style Input Area - Fixed at bottom */}
946
+ <div className="sticky bottom-0 border-t border-border/50 bg-background/95 backdrop-blur-md shadow-[0_-2px_10px_rgba(0,0,0,0.1)]">
947
+ <div className="max-w-4xl mx-auto px-4 pb-6">
948
+ <div className={`
949
+ rounded-xl border shadow-sm p-4 relative transition-all duration-300
950
+ ${isRecording
951
+ ? 'bg-primary/5 border-primary ring-1 ring-primary/50'
952
+ : 'bg-card border-border focus-within:ring-2 focus-within:ring-primary/20'
953
+ }
954
+ `}>
955
+ {isRecording && (
956
+ <div className="absolute -top-10 left-1/2 -translate-x-1/2 flex items-center gap-2 px-4 py-2 bg-primary text-primary-foreground rounded-full text-sm font-medium animate-bounce shadow-lg">
957
+ <MicrophoneIcon className="w-4 h-4" />
958
+ Listening...
959
+ </div>
960
+ )}
961
+
962
+ <div className="flex items-end gap-2">
963
+ <input
964
+ type="file"
965
+ ref={fileInputRef}
966
+ onChange={handleFileUpload}
967
+ accept="image/*,.pdf,.txt,.md,.json,.csv,.docx"
968
+ className="hidden"
969
+ />
970
+ <button
971
+ onClick={() => fileInputRef.current?.click()}
972
+ className="p-2 text-muted-foreground hover:text-foreground hover:bg-muted rounded-lg transition-colors"
973
+ title="Attach file (max 20MB)"
974
+ >
975
+ <PhotoIcon className="h-5 w-5" />
976
+ </button>
977
+
978
+ <textarea
979
+ ref={textareaRef}
980
+ value={inputValue}
981
+ onChange={(e) => setInputValue(e.target.value)}
982
+ onKeyDown={(e) => {
983
+ if (e.key === 'Enter' && !e.shiftKey) {
984
+ e.preventDefault();
985
+ handleSend();
986
+ }
987
+ }}
988
+ placeholder={isRecording ? "Listening..." : "Message Nitro..."}
989
+ className="flex-1 bg-transparent border-0 focus:ring-0 resize-none p-2 min-h-[44px] max-h-[200px]"
990
+ rows={1}
991
+ disabled={loading || isRecording}
992
+ />
993
+
994
+ {/* Microphone button - only show if ElevenLabs API key is configured */}
995
+ {elevenLabsApiKey && (
996
+ <button
997
+ onClick={() => setVoiceOverlayOpen(true)}
998
+ className="p-2 text-muted-foreground hover:text-primary hover:bg-primary/10 rounded-lg transition-colors"
999
+ title="Voice mode"
1000
+ >
1001
+ <MicrophoneIcon className="h-5 w-5" />
1002
+ </button>
1003
+ )}
1004
+
1005
+ {/* Send button */}
1006
+ <button
1007
+ onClick={handleSend}
1008
+ disabled={loading || (!inputValue.trim() && !currentFile)}
1009
+ className="p-2 text-primary hover:bg-primary/10 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
1010
+ title="Send message"
1011
+ >
1012
+ <PaperAirplaneIcon className="h-5 w-5" />
1013
+ </button>
1014
+ </div>
261
1015
  </div>
262
- )}
263
- <div className="flex gap-2">
264
- <input
265
- type="file"
266
- ref={fileInputRef}
267
- onChange={handleImageUpload}
268
- accept="image/*"
269
- className="hidden"
270
- />
271
- <button
272
- onClick={() => fileInputRef.current?.click()}
273
- className="btn btn-secondary"
274
- >
275
- 🖼️
276
- </button>
277
- <textarea
278
- value={inputValue}
279
- onChange={(e) => setInputValue(e.target.value)}
280
- onKeyDown={(e) => {
281
- if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
282
- handleSend();
283
- }
284
- }}
285
- placeholder="Type a message... (Cmd/Ctrl + Enter to send)"
286
- className="input flex-1 resize-none"
287
- rows={2}
288
- />
289
- <button onClick={handleSend} className="btn btn-primary px-6" disabled={loading}>
290
- Send
291
- </button>
1016
+
1017
+ {/* Prompt Executor Modal */}
1018
+ {selectedPrompt && (
1019
+ <div
1020
+ className="fixed inset-0 z-50 flex items-center justify-center animate-fade-in"
1021
+ style={{ backgroundColor: 'rgba(0, 0, 0, 0.85)' }}
1022
+ onClick={() => setSelectedPrompt(null)}
1023
+ >
1024
+ <div
1025
+ className="bg-card rounded p-6 w-[600px] max-h-[80vh] overflow-auto border border-border shadow-2xl animate-scale-in"
1026
+ onClick={(e) => e.stopPropagation()}
1027
+ >
1028
+ <div className="flex items-center justify-between mb-4">
1029
+ <div className="flex items-center gap-3">
1030
+ <div className="h-10 w-10 rounded-lg bg-primary/10 flex items-center justify-center">
1031
+ <DocumentTextIcon className="h-5 w-5 text-primary" />
1032
+ </div>
1033
+ <h2 className="text-xl font-bold text-foreground">{selectedPrompt.name}</h2>
1034
+ </div>
1035
+ <button
1036
+ onClick={() => setSelectedPrompt(null)}
1037
+ className="btn btn-ghost w-10 h-10 p-0"
1038
+ >
1039
+ <XMarkIcon className="h-5 w-5" />
1040
+ </button>
1041
+ </div>
1042
+
1043
+ <p className="text-sm text-muted-foreground mb-6">
1044
+ {selectedPrompt.description || 'No description'}
1045
+ </p>
1046
+
1047
+ <div>
1048
+ {selectedPrompt.arguments && selectedPrompt.arguments.length > 0 ? (
1049
+ selectedPrompt.arguments.map((arg) => (
1050
+ <div key={arg.name} className="mb-4">
1051
+ <label className="block text-sm font-medium text-foreground mb-2">
1052
+ {arg.name}
1053
+ {arg.required && <span className="text-destructive ml-1">*</span>}
1054
+ </label>
1055
+ <input
1056
+ type="text"
1057
+ className="input"
1058
+ value={promptArgs[arg.name] || ''}
1059
+ onChange={(e) =>
1060
+ setPromptArgs({ ...promptArgs, [arg.name]: e.target.value })
1061
+ }
1062
+ required={arg.required}
1063
+ placeholder={arg.description || `Enter ${arg.name}`}
1064
+ />
1065
+ {arg.description && (
1066
+ <p className="text-xs text-muted-foreground mt-1">{arg.description}</p>
1067
+ )}
1068
+ </div>
1069
+ ))
1070
+ ) : (
1071
+ <div className="bg-muted/30 rounded-lg p-4 mb-4">
1072
+ <p className="text-sm text-muted-foreground">No arguments required</p>
1073
+ </div>
1074
+ )}
1075
+
1076
+ <button
1077
+ onClick={handleExecutePrompt}
1078
+ className="btn btn-primary w-full gap-2"
1079
+ >
1080
+ <PlayIcon className="h-4 w-4" />
1081
+ Execute Prompt
1082
+ </button>
1083
+ </div>
1084
+
1085
+ </div>
1086
+ </div>
1087
+ )}
1088
+
1089
+ {/* Fullscreen Widget Modal */}
1090
+ {fullscreenWidget && (
1091
+ <div
1092
+ className="fixed inset-0 z-50 flex items-center justify-center"
1093
+ style={{ backgroundColor: 'rgba(0, 0, 0, 0.9)' }}
1094
+ >
1095
+ {/* Close Button */}
1096
+ <button
1097
+ onClick={() => setFullscreenWidget(null)}
1098
+ className="absolute top-4 right-4 z-60 p-3 rounded-lg bg-white/10 hover:bg-white/20 backdrop-blur-sm border border-white/20 transition-all"
1099
+ title="Exit fullscreen"
1100
+ >
1101
+ <XMarkIcon className="w-6 h-6 text-white" />
1102
+ </button>
1103
+
1104
+ {/* Widget Container */}
1105
+ <div className="w-full h-full p-8">
1106
+ <div className="w-full h-full rounded-xl overflow-hidden shadow-2xl">
1107
+ <WidgetRenderer uri={fullscreenWidget.uri} data={fullscreenWidget.data} className="widget-fullscreen" />
1108
+ </div>
1109
+ </div>
1110
+ </div>
1111
+ )}
292
1112
  </div>
293
1113
  </div>
1114
+
1115
+ {/* Voice Mode Overlay */}
1116
+ <VoiceOrbOverlay
1117
+ isOpen={voiceOverlayOpen}
1118
+ onClose={() => {
1119
+ setVoiceOverlayOpen(false);
1120
+ setVoiceModeEnabled(false);
1121
+ }}
1122
+ onTranscript={(text) => setInputValue(text)}
1123
+ onSendMessage={(text) => {
1124
+ setInputValue(text);
1125
+ setVoiceOverlayOpen(false);
1126
+ // Trigger send after a short delay to let state update
1127
+ setTimeout(() => handleSend(), 100);
1128
+ }}
1129
+ elevenLabsApiKey={elevenLabsApiKey || ''}
1130
+ isSpeaking={isSpeaking}
1131
+ spokenText={spokenText}
1132
+ />
294
1133
  </div>
295
1134
  );
296
1135
  }
297
1136
 
298
1137
  function ChatMessageComponent({ message, tools }: { message: ChatMessage; tools: Tool[] }) {
299
- if (message.role === 'tool') return null; // Don't render tool messages directly
300
-
1138
+ if (message.role === 'tool') return null;
301
1139
  const isUser = message.role === 'user';
302
1140
 
303
1141
  return (
304
- <div
305
- className={`flex ${isUser ? 'justify-end' : 'justify-start'} animate-fade-in`}
306
- >
307
- <div className={`max-w-[70%] ${isUser ? 'bg-primary-600' : 'bg-dark-surface'} rounded-2xl p-4 border border-dark-border`}>
308
- {message.image && (
309
- <img
310
- src={message.image.data}
311
- alt={message.image.name}
312
- className="rounded-lg mb-2 max-w-full"
313
- />
1142
+ <div className="flex gap-4 items-start animate-fade-in group">
1143
+ {!isUser && (
1144
+ <div className="h-8 w-8 rounded-full bg-gradient-to-br from-primary to-secondary flex items-center justify-center flex-shrink-0 shadow-md group-hover:shadow-lg transition-shadow">
1145
+ <SparklesIcon className="h-5 w-5 text-white" />
1146
+ </div>
1147
+ )}
1148
+ {isUser && (
1149
+ <div className="h-8 w-8 rounded-full bg-gradient-to-br from-slate-600 to-slate-700 flex items-center justify-center flex-shrink-0 shadow-md group-hover:shadow-lg transition-shadow">
1150
+ <span className="text-white text-sm font-bold">You</span>
1151
+ </div>
1152
+ )}
1153
+ <div className="flex-1 min-w-0">
1154
+ {message.file && (
1155
+ <div className="mb-3 rounded-xl overflow-hidden border border-border/50 shadow-sm max-w-sm">
1156
+ {message.file.type.startsWith('image/') ? (
1157
+ <img src={message.file.data} alt={message.file.name} className="max-w-full" />
1158
+ ) : (
1159
+ <div className="p-4 bg-muted/30 flex items-center gap-3">
1160
+ <div className="h-10 w-10 rounded-lg bg-primary/10 flex items-center justify-center">
1161
+ <DocumentTextIcon className="h-5 w-5 text-primary" />
1162
+ </div>
1163
+ <div className="flex-1 min-w-0">
1164
+ <p className="text-sm font-medium text-foreground truncate">{message.file.name}</p>
1165
+ <p className="text-xs text-muted-foreground">{message.file.type}</p>
1166
+ </div>
1167
+ </div>
1168
+ )}
1169
+ </div>
1170
+ )}
1171
+ {message.content && (
1172
+ <div className="text-sm leading-relaxed mb-4">
1173
+ {isUser ? (
1174
+ <div className="whitespace-pre-wrap text-foreground/90">{message.content}</div>
1175
+ ) : (
1176
+ <MarkdownRenderer content={message.content} />
1177
+ )}
1178
+ </div>
314
1179
  )}
315
- <p className="text-sm whitespace-pre-wrap">{message.content}</p>
316
-
317
- {/* Tool Calls */}
318
1180
  {message.toolCalls && message.toolCalls.length > 0 && (
319
- <div className="mt-3 space-y-2">
320
- {message.toolCalls.map((toolCall) => (
321
- <ToolCallComponent key={toolCall.id} toolCall={toolCall} tools={tools} />
1181
+ <div className="space-y-3">
1182
+ {message.toolCalls.map((tc: ToolCall) => (
1183
+ <ToolCallComponent key={tc.id} toolCall={tc} tools={tools} />
322
1184
  ))}
323
1185
  </div>
324
1186
  )}
@@ -328,63 +1190,60 @@ function ChatMessageComponent({ message, tools }: { message: ChatMessage; tools:
328
1190
  }
329
1191
 
330
1192
  function ToolCallComponent({ toolCall, tools }: { toolCall: ToolCall; tools: Tool[] }) {
1193
+ const [showArgs, setShowArgs] = useState(false);
331
1194
  const tool = tools.find((t) => t.name === toolCall.name);
332
-
333
- // Get widget URI from multiple possible sources
334
- const componentUri =
335
- tool?.widget?.route ||
336
- tool?.outputTemplate ||
337
- tool?._meta?.['openai/outputTemplate'] ||
1195
+
1196
+ const componentUri =
1197
+ tool?.widget?.route ||
1198
+ tool?.outputTemplate ||
1199
+ tool?._meta?.['openai/outputTemplate'] ||
338
1200
  tool?._meta?.['ui/template'];
339
-
340
- // Get result data from toolCall and unwrap if needed
1201
+
341
1202
  let widgetData = toolCall.result || toolCall.arguments;
342
-
343
- // Unwrap if response was wrapped by TransformInterceptor
344
- // Check if it has the interceptor's structure: { success, data, metadata }
345
- if (widgetData && typeof widgetData === 'object' &&
346
- widgetData.success !== undefined && widgetData.data !== undefined) {
347
- widgetData = widgetData.data; // Return the unwrapped data
348
- }
349
1203
 
350
- console.log('ToolCallComponent:', {
351
- toolName: toolCall.name,
352
- componentUri,
353
- hasData: !!widgetData,
354
- tool
355
- });
1204
+ if (widgetData && typeof widgetData === 'object' &&
1205
+ widgetData.success !== undefined && widgetData.data !== undefined) {
1206
+ widgetData = widgetData.data;
1207
+ }
356
1208
 
357
1209
  return (
358
- <div className="rounded-lg p-3 border" style={{
359
- backgroundColor: 'hsl(var(--muted))',
360
- borderColor: 'hsl(var(--border))'
361
- }}>
362
- <div className="flex items-center gap-2 mb-2">
363
- <span>🔧</span>
364
- <span className="font-medium text-sm">{toolCall.name}</span>
365
- </div>
366
-
367
- {/* Arguments */}
368
- <details className="text-xs" style={{ color: 'hsl(var(--muted-foreground))' }}>
369
- <summary className="cursor-pointer">Arguments</summary>
370
- <pre className="mt-2 p-2 rounded overflow-auto" style={{
371
- backgroundColor: 'hsl(var(--background))'
372
- }}>
373
- {JSON.stringify(toolCall.arguments, null, 2)}
374
- </pre>
375
- </details>
376
-
377
- {/* Widget if available */}
1210
+ <div className="relative group/widget">
378
1211
  {componentUri && widgetData && (
379
- <div className="mt-3 rounded-lg overflow-hidden border" style={{
380
- height: '320px',
381
- borderColor: 'hsl(var(--border))',
382
- backgroundColor: 'hsl(var(--background))'
383
- }}>
384
- <WidgetRenderer uri={componentUri} data={widgetData} />
1212
+ <div className="rounded-lg overflow-hidden max-w-5xl">
1213
+ <WidgetRenderer uri={componentUri} data={widgetData} className="widget-in-chat" />
1214
+ </div>
1215
+ )}
1216
+ <button
1217
+ onClick={() => setShowArgs(!showArgs)}
1218
+ className="absolute top-2 right-2 w-8 h-8 rounded-lg flex items-center justify-center bg-background/80 backdrop-blur-sm border border-border/50 hover:bg-background hover:border-border transition-all opacity-0 group-hover/widget:opacity-100 shadow-sm z-10"
1219
+ title="View tool details"
1220
+ >
1221
+ <EllipsisVerticalIcon className="h-4 w-4 text-muted-foreground" />
1222
+ </button>
1223
+ {showArgs && (
1224
+ <div className="absolute top-12 right-2 w-96 max-w-[calc(100%-1rem)] bg-card rounded-xl border border-border shadow-2xl p-4 animate-fade-in z-20">
1225
+ <div className="flex items-center justify-between mb-3">
1226
+ <div className="flex items-center gap-2">
1227
+ <div className="w-6 h-6 rounded-md bg-primary/10 flex items-center justify-center">
1228
+ <WrenchScrewdriverIcon className="w-3.5 h-3.5 text-primary" />
1229
+ </div>
1230
+ <span className="font-semibold text-sm text-foreground">{toolCall.name}</span>
1231
+ </div>
1232
+ <button
1233
+ onClick={() => setShowArgs(false)}
1234
+ className="w-6 h-6 rounded-md flex items-center justify-center hover:bg-muted transition-colors"
1235
+ >
1236
+ <XMarkIcon className="h-4 w-4 text-muted-foreground" />
1237
+ </button>
1238
+ </div>
1239
+ <div>
1240
+ <p className="text-xs font-medium text-muted-foreground mb-2">Arguments:</p>
1241
+ <pre className="p-3 rounded-lg overflow-auto bg-background border border-border/30 font-mono text-xs text-foreground max-h-60">
1242
+ {JSON.stringify(toolCall.arguments, null, 2)}
1243
+ </pre>
1244
+ </div>
385
1245
  </div>
386
1246
  )}
387
1247
  </div>
388
1248
  );
389
1249
  }
390
-