nitrostack 1.0.65 → 1.0.66
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/package.json +2 -1
- package/src/studio/README.md +140 -0
- package/src/studio/app/api/auth/fetch-metadata/route.ts +71 -0
- package/src/studio/app/api/auth/register-client/route.ts +67 -0
- package/src/studio/app/api/chat/route.ts +250 -0
- package/src/studio/app/api/health/checks/route.ts +42 -0
- package/src/studio/app/api/health/route.ts +13 -0
- package/src/studio/app/api/init/route.ts +109 -0
- package/src/studio/app/api/ping/route.ts +13 -0
- package/src/studio/app/api/prompts/[name]/route.ts +21 -0
- package/src/studio/app/api/prompts/route.ts +13 -0
- package/src/studio/app/api/resources/[...uri]/route.ts +18 -0
- package/src/studio/app/api/resources/route.ts +13 -0
- package/src/studio/app/api/roots/route.ts +13 -0
- package/src/studio/app/api/sampling/route.ts +14 -0
- package/src/studio/app/api/tools/[name]/call/route.ts +41 -0
- package/src/studio/app/api/tools/route.ts +23 -0
- package/src/studio/app/api/widget-examples/route.ts +44 -0
- package/src/studio/app/auth/callback/page.tsx +175 -0
- package/src/studio/app/auth/page.tsx +560 -0
- package/src/studio/app/chat/page.tsx +1133 -0
- package/src/studio/app/chat/page.tsx.backup +390 -0
- package/src/studio/app/globals.css +486 -0
- package/src/studio/app/health/page.tsx +179 -0
- package/src/studio/app/layout.tsx +68 -0
- package/src/studio/app/logs/page.tsx +279 -0
- package/src/studio/app/page.tsx +351 -0
- package/src/studio/app/page.tsx.backup +346 -0
- package/src/studio/app/ping/page.tsx +209 -0
- package/src/studio/app/prompts/page.tsx +230 -0
- package/src/studio/app/resources/page.tsx +315 -0
- package/src/studio/app/settings/page.tsx +199 -0
- package/src/studio/branding.md +807 -0
- package/src/studio/components/EnlargeModal.tsx +138 -0
- package/src/studio/components/LogMessage.tsx +153 -0
- package/src/studio/components/MarkdownRenderer.tsx +410 -0
- package/src/studio/components/Sidebar.tsx +295 -0
- package/src/studio/components/ToolCard.tsx +139 -0
- package/src/studio/components/WidgetRenderer.tsx +346 -0
- package/src/studio/lib/api.ts +207 -0
- package/src/studio/lib/http-client-transport.ts +222 -0
- package/src/studio/lib/llm-service.ts +480 -0
- package/src/studio/lib/log-manager.ts +76 -0
- package/src/studio/lib/mcp-client.ts +258 -0
- package/src/studio/lib/store.ts +192 -0
- package/src/studio/lib/theme-provider.tsx +50 -0
- package/src/studio/lib/types.ts +107 -0
- package/src/studio/lib/widget-loader.ts +90 -0
- package/src/studio/middleware.ts +27 -0
- package/src/studio/next.config.js +38 -0
- package/src/studio/package.json +35 -0
- package/src/studio/postcss.config.mjs +10 -0
- package/src/studio/public/nitrocloud.png +0 -0
- package/src/studio/tailwind.config.ts +67 -0
- package/src/studio/tsconfig.json +42 -0
|
@@ -0,0 +1,1133 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useEffect, useRef, useState } from 'react';
|
|
4
|
+
import { useStudioStore } from '@/lib/store';
|
|
5
|
+
import { api } from '@/lib/api';
|
|
6
|
+
import { WidgetRenderer } from '@/components/WidgetRenderer';
|
|
7
|
+
import { MarkdownRenderer } from '@/components/MarkdownRenderer';
|
|
8
|
+
import type { ChatMessage, Tool, ToolCall, Prompt } from '@/lib/types';
|
|
9
|
+
import {
|
|
10
|
+
Bot,
|
|
11
|
+
Settings,
|
|
12
|
+
Trash2,
|
|
13
|
+
Image as ImageIcon,
|
|
14
|
+
Send,
|
|
15
|
+
Wrench,
|
|
16
|
+
Save,
|
|
17
|
+
X,
|
|
18
|
+
Sparkles,
|
|
19
|
+
FileText,
|
|
20
|
+
Play,
|
|
21
|
+
ExternalLink,
|
|
22
|
+
Info,
|
|
23
|
+
MoreVertical
|
|
24
|
+
} from 'lucide-react';
|
|
25
|
+
|
|
26
|
+
export default function ChatPage() {
|
|
27
|
+
const {
|
|
28
|
+
chatMessages,
|
|
29
|
+
addChatMessage,
|
|
30
|
+
clearChat,
|
|
31
|
+
currentProvider,
|
|
32
|
+
setCurrentProvider,
|
|
33
|
+
currentImage,
|
|
34
|
+
setCurrentImage,
|
|
35
|
+
tools,
|
|
36
|
+
setTools,
|
|
37
|
+
} = useStudioStore();
|
|
38
|
+
|
|
39
|
+
// Get jwtToken and apiKey dynamically to ensure we always have the latest value
|
|
40
|
+
const getAuthTokens = () => {
|
|
41
|
+
const state = useStudioStore.getState();
|
|
42
|
+
// Check both jwtToken and OAuth token (from OAuth tab)
|
|
43
|
+
const jwtToken = state.jwtToken || state.oauthState?.currentToken;
|
|
44
|
+
return {
|
|
45
|
+
jwtToken,
|
|
46
|
+
mcpApiKey: state.apiKey,
|
|
47
|
+
};
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const [inputValue, setInputValue] = useState('');
|
|
51
|
+
const [loading, setLoading] = useState(false);
|
|
52
|
+
const [showSettings, setShowSettings] = useState(false);
|
|
53
|
+
const [prompts, setPrompts] = useState<Prompt[]>([]);
|
|
54
|
+
const [selectedPrompt, setSelectedPrompt] = useState<Prompt | null>(null);
|
|
55
|
+
const [promptArgs, setPromptArgs] = useState<Record<string, string>>({});
|
|
56
|
+
const [fullscreenWidget, setFullscreenWidget] = useState<{ uri: string, data: any } | null>(null);
|
|
57
|
+
const messagesEndRef = useRef<HTMLDivElement>(null);
|
|
58
|
+
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
59
|
+
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
|
60
|
+
|
|
61
|
+
useEffect(() => {
|
|
62
|
+
loadTools();
|
|
63
|
+
loadPrompts();
|
|
64
|
+
|
|
65
|
+
// Check if there's a suggested message from localStorage
|
|
66
|
+
if (typeof window !== 'undefined') {
|
|
67
|
+
const chatInput = window.localStorage.getItem('chatInput');
|
|
68
|
+
if (chatInput) {
|
|
69
|
+
setInputValue(chatInput);
|
|
70
|
+
window.localStorage.removeItem('chatInput');
|
|
71
|
+
// Focus after a short delay to ensure component is mounted
|
|
72
|
+
setTimeout(() => textareaRef.current?.focus(), 100);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}, []);
|
|
76
|
+
|
|
77
|
+
useEffect(() => {
|
|
78
|
+
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
|
79
|
+
}, [chatMessages]);
|
|
80
|
+
|
|
81
|
+
// Auto-focus textarea on mount and after sending
|
|
82
|
+
useEffect(() => {
|
|
83
|
+
textareaRef.current?.focus();
|
|
84
|
+
}, [chatMessages, loading]);
|
|
85
|
+
|
|
86
|
+
// Auto-resize textarea based on content
|
|
87
|
+
useEffect(() => {
|
|
88
|
+
const textarea = textareaRef.current;
|
|
89
|
+
if (textarea) {
|
|
90
|
+
textarea.style.height = '44px'; // Reset to min height
|
|
91
|
+
const scrollHeight = textarea.scrollHeight;
|
|
92
|
+
textarea.style.height = Math.min(scrollHeight, 200) + 'px'; // Max 200px
|
|
93
|
+
}
|
|
94
|
+
}, [inputValue]);
|
|
95
|
+
|
|
96
|
+
// Listen for widget fullscreen requests
|
|
97
|
+
useEffect(() => {
|
|
98
|
+
const handleFullscreenRequest = (event: CustomEvent) => {
|
|
99
|
+
const { uri, data } = event.detail;
|
|
100
|
+
setFullscreenWidget({ uri, data });
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
window.addEventListener('widget-fullscreen-request', handleFullscreenRequest as EventListener);
|
|
104
|
+
return () => window.removeEventListener('widget-fullscreen-request', handleFullscreenRequest as EventListener);
|
|
105
|
+
}, []);
|
|
106
|
+
|
|
107
|
+
// Listen for widget tool call requests
|
|
108
|
+
useEffect(() => {
|
|
109
|
+
let isProcessingToolCall = false;
|
|
110
|
+
|
|
111
|
+
const handleToolCall = async (event: any) => {
|
|
112
|
+
// Prevent multiple simultaneous calls
|
|
113
|
+
if (isProcessingToolCall) {
|
|
114
|
+
console.log('⏭️ Skipping duplicate tool call');
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const { toolName, toolArgs } = event.detail;
|
|
119
|
+
console.log('📞 Chat received tool call from widget:', toolName, toolArgs);
|
|
120
|
+
|
|
121
|
+
isProcessingToolCall = true;
|
|
122
|
+
|
|
123
|
+
try {
|
|
124
|
+
// Get current state directly from store to avoid stale closure
|
|
125
|
+
const currentMessages = useStudioStore.getState().chatMessages;
|
|
126
|
+
const currentProv = useStudioStore.getState().currentProvider;
|
|
127
|
+
|
|
128
|
+
// Directly send the tool call message without showing in input
|
|
129
|
+
const toolCallMessage = `Use the ${toolName} tool with these arguments: ${JSON.stringify(toolArgs)}`;
|
|
130
|
+
|
|
131
|
+
// Add user message
|
|
132
|
+
const userMessage: ChatMessage = {
|
|
133
|
+
role: 'user',
|
|
134
|
+
content: toolCallMessage,
|
|
135
|
+
};
|
|
136
|
+
addChatMessage(userMessage);
|
|
137
|
+
|
|
138
|
+
// Call LLM
|
|
139
|
+
setLoading(true);
|
|
140
|
+
try {
|
|
141
|
+
const { jwtToken, mcpApiKey } = getAuthTokens();
|
|
142
|
+
const apiKey = localStorage.getItem(`${currentProv}_api_key`);
|
|
143
|
+
const response = await api.chat({
|
|
144
|
+
provider: currentProv,
|
|
145
|
+
messages: [...currentMessages, userMessage],
|
|
146
|
+
apiKey: apiKey || '',
|
|
147
|
+
jwtToken: jwtToken || undefined,
|
|
148
|
+
mcpApiKey: mcpApiKey || undefined,
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
// Handle tool calls (same as handleSend)
|
|
152
|
+
if (response.toolCalls && response.toolResults) {
|
|
153
|
+
// Attach results to tool calls for widget rendering
|
|
154
|
+
const toolCallsWithResults = response.toolCalls.map((tc: any, i: any) => {
|
|
155
|
+
const toolResult = response.toolResults[i];
|
|
156
|
+
let parsedResult;
|
|
157
|
+
if (toolResult.content) {
|
|
158
|
+
try {
|
|
159
|
+
parsedResult = JSON.parse(toolResult.content);
|
|
160
|
+
} catch (e) {
|
|
161
|
+
parsedResult = { raw: toolResult.content };
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return { ...tc, result: parsedResult };
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
if (response.message) {
|
|
168
|
+
response.message.toolCalls = toolCallsWithResults;
|
|
169
|
+
addChatMessage(response.message);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Add tool results
|
|
173
|
+
const toolResultMessages: ChatMessage[] = [];
|
|
174
|
+
for (const result of response.toolResults) {
|
|
175
|
+
addChatMessage(result);
|
|
176
|
+
toolResultMessages.push(result);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Continue conversation
|
|
180
|
+
const messagesForContinuation = [
|
|
181
|
+
...currentMessages,
|
|
182
|
+
userMessage,
|
|
183
|
+
response.message!,
|
|
184
|
+
...toolResultMessages,
|
|
185
|
+
];
|
|
186
|
+
|
|
187
|
+
// Call continueChatWithToolResults
|
|
188
|
+
await continueChatWithToolResults(apiKey || '', messagesForContinuation);
|
|
189
|
+
} else if (response.message) {
|
|
190
|
+
addChatMessage(response.message);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
setLoading(false);
|
|
194
|
+
} catch (error) {
|
|
195
|
+
console.error('Tool call failed:', error);
|
|
196
|
+
setLoading(false);
|
|
197
|
+
}
|
|
198
|
+
} finally {
|
|
199
|
+
// Reset flag after a short delay to allow next call
|
|
200
|
+
setTimeout(() => {
|
|
201
|
+
isProcessingToolCall = false;
|
|
202
|
+
}, 1000);
|
|
203
|
+
}
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
window.addEventListener('widget-tool-call', handleToolCall);
|
|
207
|
+
return () => window.removeEventListener('widget-tool-call', handleToolCall);
|
|
208
|
+
}, []); // Empty dependency array - only register once
|
|
209
|
+
|
|
210
|
+
const loadTools = async () => {
|
|
211
|
+
try {
|
|
212
|
+
const data = await api.getTools();
|
|
213
|
+
setTools(data.tools || []);
|
|
214
|
+
} catch (error) {
|
|
215
|
+
console.error('Failed to load tools:', error);
|
|
216
|
+
}
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
const loadPrompts = async () => {
|
|
220
|
+
try {
|
|
221
|
+
const data = await api.getPrompts();
|
|
222
|
+
setPrompts(data.prompts || []);
|
|
223
|
+
} catch (error) {
|
|
224
|
+
console.error('Failed to load prompts:', error);
|
|
225
|
+
}
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
const handleExecutePrompt = async () => {
|
|
229
|
+
if (!selectedPrompt) return;
|
|
230
|
+
|
|
231
|
+
// Close modal
|
|
232
|
+
const prompt = selectedPrompt;
|
|
233
|
+
const args = { ...promptArgs };
|
|
234
|
+
setSelectedPrompt(null);
|
|
235
|
+
setPromptArgs({});
|
|
236
|
+
|
|
237
|
+
// Build user message showing what prompt was executed
|
|
238
|
+
let userMessageContent = `Execute prompt: ${prompt.name}`;
|
|
239
|
+
if (Object.keys(args).length > 0) {
|
|
240
|
+
userMessageContent += `\nArguments: ${JSON.stringify(args, null, 2)}`;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Add user message to chat
|
|
244
|
+
addChatMessage({
|
|
245
|
+
role: 'user',
|
|
246
|
+
content: userMessageContent,
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
setLoading(true);
|
|
250
|
+
|
|
251
|
+
try {
|
|
252
|
+
// Execute the prompt directly via API
|
|
253
|
+
const result = await api.executePrompt(prompt.name, args);
|
|
254
|
+
|
|
255
|
+
// Add the prompt result as an assistant message
|
|
256
|
+
if (result.messages && result.messages.length > 0) {
|
|
257
|
+
// Combine all prompt messages into one assistant message
|
|
258
|
+
const combinedContent = result.messages
|
|
259
|
+
.map((msg: any) => {
|
|
260
|
+
const content = typeof msg.content === 'string'
|
|
261
|
+
? msg.content
|
|
262
|
+
: msg.content?.text || JSON.stringify(msg.content);
|
|
263
|
+
return `[${msg.role.toUpperCase()}]\n${content}`;
|
|
264
|
+
})
|
|
265
|
+
.join('\n\n');
|
|
266
|
+
|
|
267
|
+
addChatMessage({
|
|
268
|
+
role: 'assistant',
|
|
269
|
+
content: combinedContent,
|
|
270
|
+
});
|
|
271
|
+
} else {
|
|
272
|
+
addChatMessage({
|
|
273
|
+
role: 'assistant',
|
|
274
|
+
content: 'Prompt executed successfully, but returned no messages.',
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
} catch (error) {
|
|
278
|
+
console.error('Failed to execute prompt:', error);
|
|
279
|
+
addChatMessage({
|
|
280
|
+
role: 'assistant',
|
|
281
|
+
content: `Error executing prompt: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
|
282
|
+
});
|
|
283
|
+
} finally {
|
|
284
|
+
setLoading(false);
|
|
285
|
+
setTimeout(() => textareaRef.current?.focus(), 100);
|
|
286
|
+
}
|
|
287
|
+
};
|
|
288
|
+
|
|
289
|
+
const handleImageUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
290
|
+
const file = e.target.files?.[0];
|
|
291
|
+
if (!file) return;
|
|
292
|
+
|
|
293
|
+
if (file.size > 20 * 1024 * 1024) {
|
|
294
|
+
alert('Image too large (max 20MB)');
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const reader = new FileReader();
|
|
299
|
+
reader.onload = (event) => {
|
|
300
|
+
setCurrentImage({
|
|
301
|
+
data: event.target?.result as string,
|
|
302
|
+
type: file.type,
|
|
303
|
+
name: file.name,
|
|
304
|
+
});
|
|
305
|
+
};
|
|
306
|
+
reader.readAsDataURL(file);
|
|
307
|
+
};
|
|
308
|
+
|
|
309
|
+
const handleSend = async () => {
|
|
310
|
+
if (!inputValue.trim() && !currentImage) return;
|
|
311
|
+
|
|
312
|
+
const apiKey = localStorage.getItem(`${currentProvider}_api_key`);
|
|
313
|
+
if (!apiKey) {
|
|
314
|
+
setShowSettings(true);
|
|
315
|
+
alert('Please set your API key first');
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const userMessage: ChatMessage = {
|
|
320
|
+
role: 'user',
|
|
321
|
+
content: inputValue,
|
|
322
|
+
};
|
|
323
|
+
|
|
324
|
+
if (currentImage) {
|
|
325
|
+
userMessage.image = currentImage;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
addChatMessage(userMessage);
|
|
329
|
+
setInputValue('');
|
|
330
|
+
setCurrentImage(null);
|
|
331
|
+
setLoading(true);
|
|
332
|
+
|
|
333
|
+
try {
|
|
334
|
+
const messagesToSend = [...chatMessages, userMessage];
|
|
335
|
+
|
|
336
|
+
// Clean messages to ensure they're serializable
|
|
337
|
+
const cleanedMessages = messagesToSend.map(msg => {
|
|
338
|
+
const cleaned: any = {
|
|
339
|
+
role: msg.role,
|
|
340
|
+
content: msg.content || '',
|
|
341
|
+
};
|
|
342
|
+
|
|
343
|
+
if (msg.toolCalls && msg.toolCalls.length > 0) {
|
|
344
|
+
cleaned.toolCalls = msg.toolCalls;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
if (msg.toolCallId) {
|
|
348
|
+
cleaned.toolCallId = msg.toolCallId;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// Skip image property for now (not supported by OpenAI chat completions)
|
|
352
|
+
// if (msg.image) {
|
|
353
|
+
// cleaned.image = msg.image;
|
|
354
|
+
// }
|
|
355
|
+
|
|
356
|
+
return cleaned;
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
// Get fresh auth tokens from store
|
|
360
|
+
const { jwtToken, mcpApiKey } = getAuthTokens();
|
|
361
|
+
|
|
362
|
+
console.log('Sending messages to API:', cleanedMessages);
|
|
363
|
+
console.log('Auth tokens:', { hasJwtToken: !!jwtToken, hasMcpApiKey: !!mcpApiKey });
|
|
364
|
+
console.log('Original messages:', messagesToSend);
|
|
365
|
+
console.log('Cleaned messages JSON:', JSON.stringify(cleanedMessages));
|
|
366
|
+
|
|
367
|
+
const response = await api.chat({
|
|
368
|
+
provider: currentProvider,
|
|
369
|
+
messages: cleanedMessages,
|
|
370
|
+
apiKey, // LLM API key (OpenAI/Gemini)
|
|
371
|
+
jwtToken: jwtToken || undefined,
|
|
372
|
+
mcpApiKey: mcpApiKey || undefined, // MCP server API key
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
// Handle tool calls FIRST (before adding the message)
|
|
376
|
+
if (response.toolCalls && response.toolResults) {
|
|
377
|
+
// Attach results to tool calls for widget rendering
|
|
378
|
+
const toolCallsWithResults = response.toolCalls.map((tc, i) => {
|
|
379
|
+
const toolResult = response.toolResults[i];
|
|
380
|
+
|
|
381
|
+
// Parse the result content
|
|
382
|
+
let parsedResult;
|
|
383
|
+
if (toolResult.content) {
|
|
384
|
+
try {
|
|
385
|
+
parsedResult = JSON.parse(toolResult.content);
|
|
386
|
+
|
|
387
|
+
// Unwrap if response was wrapped by TransformInterceptor
|
|
388
|
+
if (parsedResult.success !== undefined && parsedResult.data !== undefined) {
|
|
389
|
+
parsedResult = parsedResult.data;
|
|
390
|
+
}
|
|
391
|
+
} catch {
|
|
392
|
+
parsedResult = { content: toolResult.content };
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
return {
|
|
397
|
+
...tc,
|
|
398
|
+
result: parsedResult,
|
|
399
|
+
};
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
// Add assistant message with tool calls
|
|
403
|
+
if (response.message) {
|
|
404
|
+
response.message.toolCalls = toolCallsWithResults;
|
|
405
|
+
addChatMessage(response.message);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// Extract JWT token from ANY tool response (not just 'login')
|
|
409
|
+
for (let i = 0; i < response.toolCalls.length; i++) {
|
|
410
|
+
const toolCall = response.toolCalls[i];
|
|
411
|
+
const toolResult = response.toolResults[i];
|
|
412
|
+
|
|
413
|
+
if (toolResult.content) {
|
|
414
|
+
try {
|
|
415
|
+
const parsed = JSON.parse(toolResult.content);
|
|
416
|
+
// Check for token in multiple possible locations
|
|
417
|
+
const token = parsed.token || parsed.access_token || parsed.jwt || parsed.data?.token;
|
|
418
|
+
if (token) {
|
|
419
|
+
console.log('🔐 Token received from tool in chat, saving to global state');
|
|
420
|
+
useStudioStore.getState().setJwtToken(token);
|
|
421
|
+
break; // Stop after first token found
|
|
422
|
+
}
|
|
423
|
+
} catch (e) {
|
|
424
|
+
// Ignore parsing errors
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// Add tool results to messages
|
|
430
|
+
const toolResultMessages: ChatMessage[] = [];
|
|
431
|
+
for (const result of response.toolResults) {
|
|
432
|
+
addChatMessage(result);
|
|
433
|
+
toolResultMessages.push(result);
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// Continue conversation and WAIT for it to complete before allowing new messages
|
|
437
|
+
// Build the full message history to pass to continuation
|
|
438
|
+
const messagesForContinuation = [
|
|
439
|
+
...chatMessages,
|
|
440
|
+
response.message!, // Assistant message with tool calls
|
|
441
|
+
...toolResultMessages, // Tool result messages
|
|
442
|
+
];
|
|
443
|
+
await continueChatWithToolResults(apiKey, messagesForContinuation);
|
|
444
|
+
} else {
|
|
445
|
+
// No tool calls, just add the message
|
|
446
|
+
if (response.message) {
|
|
447
|
+
addChatMessage(response.message);
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// Set loading to false AFTER all async operations complete
|
|
452
|
+
setLoading(false);
|
|
453
|
+
} catch (error) {
|
|
454
|
+
console.error('Chat error:', error);
|
|
455
|
+
addChatMessage({
|
|
456
|
+
role: 'assistant',
|
|
457
|
+
content: 'Sorry, I encountered an error. Please try again.',
|
|
458
|
+
});
|
|
459
|
+
setLoading(false);
|
|
460
|
+
}
|
|
461
|
+
};
|
|
462
|
+
|
|
463
|
+
const continueChatWithToolResults = async (apiKey: string, messages?: Message[]) => {
|
|
464
|
+
try {
|
|
465
|
+
// Use provided messages or fall back to store (for recursive calls)
|
|
466
|
+
const messagesToUse = messages || chatMessages;
|
|
467
|
+
|
|
468
|
+
// Get fresh auth tokens from store (token may have been updated by login)
|
|
469
|
+
const { jwtToken, mcpApiKey } = getAuthTokens();
|
|
470
|
+
|
|
471
|
+
// Clean messages before sending
|
|
472
|
+
const cleanedMessages = messagesToUse.map(msg => {
|
|
473
|
+
const cleaned: any = {
|
|
474
|
+
role: msg.role,
|
|
475
|
+
content: msg.content || '',
|
|
476
|
+
};
|
|
477
|
+
|
|
478
|
+
if (msg.toolCalls && msg.toolCalls.length > 0) {
|
|
479
|
+
cleaned.toolCalls = msg.toolCalls;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
if (msg.toolCallId) {
|
|
483
|
+
cleaned.toolCallId = msg.toolCallId;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
return cleaned;
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
console.log('Continue with cleaned messages:', JSON.stringify(cleanedMessages));
|
|
490
|
+
console.log('Continue auth tokens:', { hasJwtToken: !!jwtToken, hasMcpApiKey: !!mcpApiKey });
|
|
491
|
+
|
|
492
|
+
const response = await api.chat({
|
|
493
|
+
provider: currentProvider,
|
|
494
|
+
messages: cleanedMessages,
|
|
495
|
+
apiKey, // LLM API key (OpenAI/Gemini)
|
|
496
|
+
jwtToken: jwtToken || undefined,
|
|
497
|
+
mcpApiKey: mcpApiKey || undefined, // MCP server API key
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
if (response.message) {
|
|
501
|
+
addChatMessage(response.message);
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
// Recursive tool calls
|
|
505
|
+
if (response.toolCalls && response.toolResults) {
|
|
506
|
+
const newToolResults: Message[] = [];
|
|
507
|
+
for (const result of response.toolResults) {
|
|
508
|
+
addChatMessage(result);
|
|
509
|
+
newToolResults.push(result);
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
// Build messages for next iteration
|
|
513
|
+
const nextMessages = [
|
|
514
|
+
...(messages || chatMessages),
|
|
515
|
+
response.message!,
|
|
516
|
+
...newToolResults,
|
|
517
|
+
];
|
|
518
|
+
await continueChatWithToolResults(apiKey, nextMessages);
|
|
519
|
+
}
|
|
520
|
+
} catch (error) {
|
|
521
|
+
console.error('Continuation error:', error);
|
|
522
|
+
}
|
|
523
|
+
};
|
|
524
|
+
|
|
525
|
+
const saveApiKey = (provider: 'openai' | 'gemini') => {
|
|
526
|
+
const input = document.getElementById(`${provider}-api-key`) as HTMLInputElement;
|
|
527
|
+
const key = input?.value.trim();
|
|
528
|
+
|
|
529
|
+
if (!key || key === '••••••••') {
|
|
530
|
+
alert('Please enter a valid API key');
|
|
531
|
+
return;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
localStorage.setItem(`${provider}_api_key`, key);
|
|
535
|
+
input.value = '••••••••';
|
|
536
|
+
alert(`${provider === 'openai' ? 'OpenAI' : 'Gemini'} API key saved`);
|
|
537
|
+
};
|
|
538
|
+
|
|
539
|
+
return (
|
|
540
|
+
<div className="fixed inset-0 flex flex-col" style={{ left: 'var(--sidebar-width, 15rem)', backgroundColor: '#0a0a0a' }}>
|
|
541
|
+
{/* Sticky Header */}
|
|
542
|
+
<div className="sticky top-0 z-10 border-b border-border/50 px-3 sm:px-6 py-3 flex flex-col sm:flex-row items-start sm:items-center justify-between bg-card/80 backdrop-blur-md shadow-sm gap-3 sm:gap-0">
|
|
543
|
+
<div className="flex items-center gap-3">
|
|
544
|
+
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-primary to-amber-500 flex items-center justify-center shadow-md">
|
|
545
|
+
<Bot className="w-5 h-5 text-white" strokeWidth={2.5} />
|
|
546
|
+
</div>
|
|
547
|
+
<div>
|
|
548
|
+
<h1 className="text-lg font-bold text-foreground">AI Chat</h1>
|
|
549
|
+
</div>
|
|
550
|
+
</div>
|
|
551
|
+
|
|
552
|
+
<div className="flex items-center gap-2 w-full sm:w-auto">
|
|
553
|
+
<select
|
|
554
|
+
value={currentProvider}
|
|
555
|
+
onChange={(e) => setCurrentProvider(e.target.value as 'openai' | 'gemini')}
|
|
556
|
+
className="input text-sm px-3 py-1.5 w-full sm:w-28 flex-1 sm:flex-none"
|
|
557
|
+
>
|
|
558
|
+
<option value="gemini">Gemini</option>
|
|
559
|
+
<option value="openai">OpenAI</option>
|
|
560
|
+
</select>
|
|
561
|
+
<button
|
|
562
|
+
onClick={() => setShowSettings(!showSettings)}
|
|
563
|
+
className={`w-8 h-8 rounded-lg flex items-center justify-center transition-all flex-shrink-0 ${showSettings
|
|
564
|
+
? 'bg-primary/10 text-primary ring-1 ring-primary/30'
|
|
565
|
+
: 'bg-muted/50 text-muted-foreground hover:bg-muted hover:text-foreground'
|
|
566
|
+
}`}
|
|
567
|
+
title="Settings"
|
|
568
|
+
>
|
|
569
|
+
<Settings className="w-4 h-4" />
|
|
570
|
+
</button>
|
|
571
|
+
<button
|
|
572
|
+
onClick={clearChat}
|
|
573
|
+
className="w-8 h-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"
|
|
574
|
+
title="Clear chat"
|
|
575
|
+
>
|
|
576
|
+
<Trash2 className="w-4 h-4" />
|
|
577
|
+
</button>
|
|
578
|
+
</div>
|
|
579
|
+
</div>
|
|
580
|
+
|
|
581
|
+
{/* Enhanced Settings Panel */}
|
|
582
|
+
{showSettings && (
|
|
583
|
+
<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">
|
|
584
|
+
<div className="max-w-4xl mx-auto">
|
|
585
|
+
<div className="flex items-start justify-between mb-4">
|
|
586
|
+
<div>
|
|
587
|
+
<h3 className="text-sm font-semibold text-foreground flex items-center gap-2">
|
|
588
|
+
<Settings className="w-4 h-4" />
|
|
589
|
+
API Configuration
|
|
590
|
+
</h3>
|
|
591
|
+
<p className="text-xs text-muted-foreground mt-1">Configure your AI provider API keys to enable chat functionality</p>
|
|
592
|
+
</div>
|
|
593
|
+
</div>
|
|
594
|
+
|
|
595
|
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
596
|
+
{/* OpenAI Section */}
|
|
597
|
+
<div className="card p-4">
|
|
598
|
+
<div className="flex items-center justify-between mb-3">
|
|
599
|
+
<label className="text-xs font-semibold text-foreground flex items-center gap-2">
|
|
600
|
+
<div className="w-6 h-6 rounded bg-green-500/10 flex items-center justify-center">
|
|
601
|
+
<span className="text-xs font-bold text-green-600">AI</span>
|
|
602
|
+
</div>
|
|
603
|
+
OpenAI API Key
|
|
604
|
+
</label>
|
|
605
|
+
<a
|
|
606
|
+
href="https://platform.openai.com/api-keys"
|
|
607
|
+
target="_blank"
|
|
608
|
+
rel="noopener noreferrer"
|
|
609
|
+
className="text-xs text-primary hover:text-primary/80 flex items-center gap-1 transition-colors"
|
|
610
|
+
>
|
|
611
|
+
Get Key <ExternalLink className="w-3 h-3" />
|
|
612
|
+
</a>
|
|
613
|
+
</div>
|
|
614
|
+
<div className="flex gap-2 mb-3">
|
|
615
|
+
<input
|
|
616
|
+
id="openai-api-key"
|
|
617
|
+
type="password"
|
|
618
|
+
className="input flex-1 text-sm py-2"
|
|
619
|
+
placeholder="sk-proj-..."
|
|
620
|
+
/>
|
|
621
|
+
<button onClick={() => saveApiKey('openai')} className="btn btn-primary text-xs px-4 py-2">
|
|
622
|
+
<Save className="w-3 h-3 mr-1" />
|
|
623
|
+
Save
|
|
624
|
+
</button>
|
|
625
|
+
</div>
|
|
626
|
+
<div className="flex items-start gap-2 p-2 bg-blue-500/5 rounded-lg border border-blue-500/10">
|
|
627
|
+
<Info className="w-3 h-3 text-blue-500 mt-0.5 flex-shrink-0" />
|
|
628
|
+
<div className="text-xs text-muted-foreground">
|
|
629
|
+
<p className="mb-1">
|
|
630
|
+
<strong>How to get:</strong> Sign up at{' '}
|
|
631
|
+
<a href="https://platform.openai.com/signup" target="_blank" rel="noopener noreferrer" className="text-primary hover:underline">
|
|
632
|
+
OpenAI Platform
|
|
633
|
+
</a>
|
|
634
|
+
, navigate to API Keys, and create a new secret key.
|
|
635
|
+
</p>
|
|
636
|
+
<a
|
|
637
|
+
href="https://help.openai.com/en/articles/4936850-where-do-i-find-my-openai-api-key"
|
|
638
|
+
target="_blank"
|
|
639
|
+
rel="noopener noreferrer"
|
|
640
|
+
className="text-primary hover:underline inline-flex items-center gap-1"
|
|
641
|
+
>
|
|
642
|
+
View Guide <ExternalLink className="w-2.5 h-2.5" />
|
|
643
|
+
</a>
|
|
644
|
+
</div>
|
|
645
|
+
</div>
|
|
646
|
+
</div>
|
|
647
|
+
|
|
648
|
+
{/* Gemini Section */}
|
|
649
|
+
<div className="card p-4">
|
|
650
|
+
<div className="flex items-center justify-between mb-3">
|
|
651
|
+
<label className="text-xs font-semibold text-foreground flex items-center gap-2">
|
|
652
|
+
<div className="w-6 h-6 rounded bg-blue-500/10 flex items-center justify-center">
|
|
653
|
+
<span className="text-xs font-bold text-blue-600">G</span>
|
|
654
|
+
</div>
|
|
655
|
+
Gemini API Key
|
|
656
|
+
</label>
|
|
657
|
+
<a
|
|
658
|
+
href="https://aistudio.google.com/app/apikey"
|
|
659
|
+
target="_blank"
|
|
660
|
+
rel="noopener noreferrer"
|
|
661
|
+
className="text-xs text-primary hover:text-primary/80 flex items-center gap-1 transition-colors"
|
|
662
|
+
>
|
|
663
|
+
Get Key <ExternalLink className="w-3 h-3" />
|
|
664
|
+
</a>
|
|
665
|
+
</div>
|
|
666
|
+
<div className="flex gap-2 mb-3">
|
|
667
|
+
<input
|
|
668
|
+
id="gemini-api-key"
|
|
669
|
+
type="password"
|
|
670
|
+
className="input flex-1 text-sm py-2"
|
|
671
|
+
placeholder="AIza..."
|
|
672
|
+
/>
|
|
673
|
+
<button onClick={() => saveApiKey('gemini')} className="btn btn-primary text-xs px-4 py-2">
|
|
674
|
+
<Save className="w-3 h-3 mr-1" />
|
|
675
|
+
Save
|
|
676
|
+
</button>
|
|
677
|
+
</div>
|
|
678
|
+
<div className="flex items-start gap-2 p-2 bg-blue-500/5 rounded-lg border border-blue-500/10">
|
|
679
|
+
<Info className="w-3 h-3 text-blue-500 mt-0.5 flex-shrink-0" />
|
|
680
|
+
<div className="text-xs text-muted-foreground">
|
|
681
|
+
<p className="mb-1">
|
|
682
|
+
<strong>How to get:</strong> Visit{' '}
|
|
683
|
+
<a href="https://aistudio.google.com" target="_blank" rel="noopener noreferrer" className="text-primary hover:underline">
|
|
684
|
+
Google AI Studio
|
|
685
|
+
</a>
|
|
686
|
+
, sign in with your Google account, and click "Get API key".
|
|
687
|
+
</p>
|
|
688
|
+
<a
|
|
689
|
+
href="https://ai.google.dev/gemini-api/docs/api-key"
|
|
690
|
+
target="_blank"
|
|
691
|
+
rel="noopener noreferrer"
|
|
692
|
+
className="text-primary hover:underline inline-flex items-center gap-1"
|
|
693
|
+
>
|
|
694
|
+
View Guide <ExternalLink className="w-2.5 h-2.5" />
|
|
695
|
+
</a>
|
|
696
|
+
</div>
|
|
697
|
+
</div>
|
|
698
|
+
</div>
|
|
699
|
+
</div>
|
|
700
|
+
|
|
701
|
+
{/* Security Notice */}
|
|
702
|
+
<div className="mt-4 p-3 bg-amber-500/5 rounded-lg border border-amber-500/10">
|
|
703
|
+
<div className="flex items-start gap-2">
|
|
704
|
+
<Info className="w-4 h-4 text-amber-500 mt-0.5 flex-shrink-0" />
|
|
705
|
+
<div className="text-xs text-muted-foreground">
|
|
706
|
+
<strong className="text-foreground">Security Note:</strong> Your API keys are stored locally in your browser and never sent to our servers.
|
|
707
|
+
Keep them confidential and avoid sharing them publicly.
|
|
708
|
+
</div>
|
|
709
|
+
</div>
|
|
710
|
+
</div>
|
|
711
|
+
</div>
|
|
712
|
+
</div>
|
|
713
|
+
)}
|
|
714
|
+
|
|
715
|
+
{/* ChatGPT-style Messages Container - ONLY this scrolls */}
|
|
716
|
+
<div className="flex-1 overflow-y-auto overflow-x-hidden">
|
|
717
|
+
<div className="max-w-5xl mx-auto px-4 py-6 space-y-6 min-h-full">
|
|
718
|
+
{chatMessages.length === 0 && !loading ? (
|
|
719
|
+
/* Welcome Screen */
|
|
720
|
+
<div className="flex flex-col items-center justify-center min-h-[calc(100vh-300px)] animate-fade-in">
|
|
721
|
+
<div className="w-16 h-16 rounded-2xl bg-gradient-to-br from-primary to-amber-500 flex items-center justify-center shadow-xl mb-6">
|
|
722
|
+
<Bot className="w-10 h-10 text-white" strokeWidth={2.5} />
|
|
723
|
+
</div>
|
|
724
|
+
|
|
725
|
+
<h2 className="text-3xl font-bold text-foreground mb-3">Welcome to NitroStudio</h2>
|
|
726
|
+
<p className="text-muted-foreground text-center max-w-md mb-8">
|
|
727
|
+
Your AI-powered development environment for Model Context Protocol (MCP) servers.
|
|
728
|
+
Start a conversation or try a prompt below.
|
|
729
|
+
</p>
|
|
730
|
+
|
|
731
|
+
{/* Prompts Overview */}
|
|
732
|
+
{prompts.length > 0 && (
|
|
733
|
+
<div className="w-full max-w-2xl">
|
|
734
|
+
<div className="flex items-center gap-2 mb-4">
|
|
735
|
+
<Sparkles className="w-5 h-5 text-primary" />
|
|
736
|
+
<h3 className="text-lg font-semibold text-foreground">Available Prompts</h3>
|
|
737
|
+
<span className="text-sm text-muted-foreground">({prompts.length})</span>
|
|
738
|
+
</div>
|
|
739
|
+
|
|
740
|
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
|
741
|
+
{prompts.slice(0, 6).map((prompt) => (
|
|
742
|
+
<button
|
|
743
|
+
key={prompt.name}
|
|
744
|
+
onClick={() => {
|
|
745
|
+
setSelectedPrompt(prompt);
|
|
746
|
+
setPromptArgs({});
|
|
747
|
+
}}
|
|
748
|
+
className="card card-hover p-4 text-left group transition-all hover:scale-[1.02]"
|
|
749
|
+
>
|
|
750
|
+
<div className="flex items-start gap-3">
|
|
751
|
+
<div className="w-8 h-8 rounded-lg bg-primary/10 flex items-center justify-center group-hover:bg-primary/20 transition-colors flex-shrink-0">
|
|
752
|
+
<FileText className="w-4 h-4 text-primary" />
|
|
753
|
+
</div>
|
|
754
|
+
<div className="flex-1 min-w-0">
|
|
755
|
+
<h4 className="font-semibold text-foreground text-sm mb-1 truncate">
|
|
756
|
+
{prompt.name}
|
|
757
|
+
</h4>
|
|
758
|
+
<p className="text-xs text-muted-foreground line-clamp-2">
|
|
759
|
+
{prompt.description || 'No description'}
|
|
760
|
+
</p>
|
|
761
|
+
{prompt.arguments && prompt.arguments.length > 0 && (
|
|
762
|
+
<span className="badge badge-secondary text-xs mt-2 inline-block">
|
|
763
|
+
{prompt.arguments.length} arg{prompt.arguments.length !== 1 ? 's' : ''}
|
|
764
|
+
</span>
|
|
765
|
+
)}
|
|
766
|
+
</div>
|
|
767
|
+
</div>
|
|
768
|
+
</button>
|
|
769
|
+
))}
|
|
770
|
+
</div>
|
|
771
|
+
|
|
772
|
+
{prompts.length > 6 && (
|
|
773
|
+
<p className="text-xs text-muted-foreground text-center mt-4">
|
|
774
|
+
...and {prompts.length - 6} more. Visit the Prompts tab to see all.
|
|
775
|
+
</p>
|
|
776
|
+
)}
|
|
777
|
+
</div>
|
|
778
|
+
)}
|
|
779
|
+
|
|
780
|
+
{/* Suggestion Cards */}
|
|
781
|
+
<div className="w-full max-w-2xl mt-8">
|
|
782
|
+
<p className="text-sm text-muted-foreground mb-3">Or try asking:</p>
|
|
783
|
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
|
784
|
+
{[
|
|
785
|
+
'What tools are available?',
|
|
786
|
+
'Show me the health status',
|
|
787
|
+
'List all resources',
|
|
788
|
+
'Help me get started'
|
|
789
|
+
].map((suggestion) => (
|
|
790
|
+
<button
|
|
791
|
+
key={suggestion}
|
|
792
|
+
onClick={() => {
|
|
793
|
+
setInputValue(suggestion);
|
|
794
|
+
setTimeout(() => textareaRef.current?.focus(), 100);
|
|
795
|
+
}}
|
|
796
|
+
className="card card-hover p-3 text-left text-sm text-muted-foreground hover:text-foreground transition-colors"
|
|
797
|
+
>
|
|
798
|
+
"{suggestion}"
|
|
799
|
+
</button>
|
|
800
|
+
))}
|
|
801
|
+
</div>
|
|
802
|
+
</div>
|
|
803
|
+
</div>
|
|
804
|
+
) : (
|
|
805
|
+
<>
|
|
806
|
+
{chatMessages.map((msg, idx) => (
|
|
807
|
+
<ChatMessageComponent key={idx} message={msg} tools={tools} />
|
|
808
|
+
))}
|
|
809
|
+
{loading && (
|
|
810
|
+
<div className="flex gap-4 items-start animate-fade-in">
|
|
811
|
+
<div className="w-8 h-8 rounded-full bg-gradient-to-br from-primary to-amber-500 flex items-center justify-center flex-shrink-0 shadow-md">
|
|
812
|
+
<Bot className="w-5 h-5 text-white" strokeWidth={2.5} />
|
|
813
|
+
</div>
|
|
814
|
+
<div className="flex-1 bg-card/50 backdrop-blur-sm rounded-2xl px-5 py-4 border border-border/50">
|
|
815
|
+
<div className="flex items-center gap-2">
|
|
816
|
+
<div className="flex gap-1">
|
|
817
|
+
<span className="w-2 h-2 bg-primary rounded-full animate-bounce" style={{ animationDelay: '0s' }}></span>
|
|
818
|
+
<span className="w-2 h-2 bg-primary rounded-full animate-bounce" style={{ animationDelay: '0.15s' }}></span>
|
|
819
|
+
<span className="w-2 h-2 bg-primary rounded-full animate-bounce" style={{ animationDelay: '0.3s' }}></span>
|
|
820
|
+
</div>
|
|
821
|
+
<span className="text-sm text-muted-foreground font-medium">Thinking...</span>
|
|
822
|
+
</div>
|
|
823
|
+
</div>
|
|
824
|
+
</div>
|
|
825
|
+
)}
|
|
826
|
+
</>
|
|
827
|
+
)}
|
|
828
|
+
<div ref={messagesEndRef} />
|
|
829
|
+
</div>
|
|
830
|
+
</div>
|
|
831
|
+
|
|
832
|
+
{/* ChatGPT-style Input Area - Fixed at bottom */}
|
|
833
|
+
<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)]">
|
|
834
|
+
<div className="max-w-5xl mx-auto px-3 sm:px-4 py-3 sm:py-4">
|
|
835
|
+
{currentImage && (
|
|
836
|
+
<div className="mb-3 p-3 bg-card rounded-xl flex items-start gap-3 border border-border/50 animate-fade-in">
|
|
837
|
+
<img
|
|
838
|
+
src={currentImage.data}
|
|
839
|
+
alt={currentImage.name}
|
|
840
|
+
className="w-20 h-20 object-cover rounded-lg border border-border"
|
|
841
|
+
/>
|
|
842
|
+
<div className="flex-1 min-w-0">
|
|
843
|
+
<p className="text-sm font-medium text-foreground truncate">{currentImage.name}</p>
|
|
844
|
+
<p className="text-xs text-muted-foreground">{currentImage.type}</p>
|
|
845
|
+
</div>
|
|
846
|
+
<button
|
|
847
|
+
onClick={() => setCurrentImage(null)}
|
|
848
|
+
className="w-7 h-7 rounded-lg flex items-center justify-center bg-muted/50 hover:bg-muted text-muted-foreground hover:text-foreground transition-all flex-shrink-0"
|
|
849
|
+
>
|
|
850
|
+
<X className="w-4 h-4" />
|
|
851
|
+
</button>
|
|
852
|
+
</div>
|
|
853
|
+
)}
|
|
854
|
+
<div className="flex items-center gap-2">
|
|
855
|
+
<input
|
|
856
|
+
type="file"
|
|
857
|
+
ref={fileInputRef}
|
|
858
|
+
onChange={handleImageUpload}
|
|
859
|
+
accept="image/*"
|
|
860
|
+
className="hidden"
|
|
861
|
+
/>
|
|
862
|
+
<button
|
|
863
|
+
onClick={() => fileInputRef.current?.click()}
|
|
864
|
+
className="h-11 w-11 rounded-xl flex items-center justify-center bg-muted/50 hover:bg-muted text-muted-foreground hover:text-foreground transition-all flex-shrink-0"
|
|
865
|
+
title="Upload image"
|
|
866
|
+
>
|
|
867
|
+
<ImageIcon className="w-5 h-5" />
|
|
868
|
+
</button>
|
|
869
|
+
<div className="flex-1 relative flex items-center">
|
|
870
|
+
<textarea
|
|
871
|
+
ref={textareaRef}
|
|
872
|
+
value={inputValue}
|
|
873
|
+
onChange={(e) => setInputValue(e.target.value)}
|
|
874
|
+
onKeyDown={(e) => {
|
|
875
|
+
// Send on Enter, new line on Shift+Enter
|
|
876
|
+
if (e.key === 'Enter' && !e.shiftKey) {
|
|
877
|
+
e.preventDefault();
|
|
878
|
+
handleSend();
|
|
879
|
+
}
|
|
880
|
+
}}
|
|
881
|
+
placeholder="Message NitroStudio... (Shift + Enter for new line)"
|
|
882
|
+
className="w-full px-4 py-3 rounded-xl bg-card border border-border/50 focus:border-primary/50 focus:ring-2 focus:ring-primary/20 resize-none text-sm text-foreground placeholder:text-muted-foreground transition-all outline-none"
|
|
883
|
+
rows={1}
|
|
884
|
+
style={{
|
|
885
|
+
minHeight: '44px',
|
|
886
|
+
maxHeight: '200px',
|
|
887
|
+
overflow: 'hidden',
|
|
888
|
+
}}
|
|
889
|
+
/>
|
|
890
|
+
</div>
|
|
891
|
+
<button
|
|
892
|
+
onClick={handleSend}
|
|
893
|
+
disabled={loading || (!inputValue.trim() && !currentImage)}
|
|
894
|
+
className="h-11 w-11 rounded-xl flex items-center justify-center bg-gradient-to-br from-primary to-amber-500 text-white shadow-lg hover:shadow-xl disabled:opacity-50 disabled:cursor-not-allowed transition-all flex-shrink-0 hover:scale-105 active:scale-95"
|
|
895
|
+
title="Send message (Enter)"
|
|
896
|
+
>
|
|
897
|
+
<Send className="w-5 h-5" strokeWidth={2.5} />
|
|
898
|
+
</button>
|
|
899
|
+
</div>
|
|
900
|
+
|
|
901
|
+
</div>
|
|
902
|
+
</div>
|
|
903
|
+
|
|
904
|
+
{/* Prompt Executor Modal */}
|
|
905
|
+
{selectedPrompt && (
|
|
906
|
+
<div
|
|
907
|
+
className="fixed inset-0 z-50 flex items-center justify-center animate-fade-in"
|
|
908
|
+
style={{ backgroundColor: 'rgba(0, 0, 0, 0.85)' }}
|
|
909
|
+
onClick={() => setSelectedPrompt(null)}
|
|
910
|
+
>
|
|
911
|
+
<div
|
|
912
|
+
className="bg-card rounded-2xl p-6 w-[600px] max-h-[80vh] overflow-auto border border-border shadow-2xl animate-scale-in"
|
|
913
|
+
onClick={(e) => e.stopPropagation()}
|
|
914
|
+
>
|
|
915
|
+
<div className="flex items-center justify-between mb-4">
|
|
916
|
+
<div className="flex items-center gap-3">
|
|
917
|
+
<div className="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center">
|
|
918
|
+
<FileText className="w-5 h-5 text-primary" />
|
|
919
|
+
</div>
|
|
920
|
+
<h2 className="text-xl font-bold text-foreground">{selectedPrompt.name}</h2>
|
|
921
|
+
</div>
|
|
922
|
+
<button
|
|
923
|
+
onClick={() => setSelectedPrompt(null)}
|
|
924
|
+
className="btn btn-ghost w-10 h-10 p-0"
|
|
925
|
+
>
|
|
926
|
+
<X className="w-5 h-5" />
|
|
927
|
+
</button>
|
|
928
|
+
</div>
|
|
929
|
+
|
|
930
|
+
<p className="text-sm text-muted-foreground mb-6">
|
|
931
|
+
{selectedPrompt.description || 'No description'}
|
|
932
|
+
</p>
|
|
933
|
+
|
|
934
|
+
<div>
|
|
935
|
+
{selectedPrompt.arguments && selectedPrompt.arguments.length > 0 ? (
|
|
936
|
+
selectedPrompt.arguments.map((arg) => (
|
|
937
|
+
<div key={arg.name} className="mb-4">
|
|
938
|
+
<label className="block text-sm font-medium text-foreground mb-2">
|
|
939
|
+
{arg.name}
|
|
940
|
+
{arg.required && <span className="text-destructive ml-1">*</span>}
|
|
941
|
+
</label>
|
|
942
|
+
<input
|
|
943
|
+
type="text"
|
|
944
|
+
className="input"
|
|
945
|
+
value={promptArgs[arg.name] || ''}
|
|
946
|
+
onChange={(e) =>
|
|
947
|
+
setPromptArgs({ ...promptArgs, [arg.name]: e.target.value })
|
|
948
|
+
}
|
|
949
|
+
required={arg.required}
|
|
950
|
+
placeholder={arg.description || `Enter ${arg.name}`}
|
|
951
|
+
/>
|
|
952
|
+
{arg.description && (
|
|
953
|
+
<p className="text-xs text-muted-foreground mt-1">{arg.description}</p>
|
|
954
|
+
)}
|
|
955
|
+
</div>
|
|
956
|
+
))
|
|
957
|
+
) : (
|
|
958
|
+
<div className="bg-muted/30 rounded-lg p-4 mb-4">
|
|
959
|
+
<p className="text-sm text-muted-foreground">No arguments required</p>
|
|
960
|
+
</div>
|
|
961
|
+
)}
|
|
962
|
+
|
|
963
|
+
<button
|
|
964
|
+
onClick={handleExecutePrompt}
|
|
965
|
+
className="btn btn-primary w-full gap-2"
|
|
966
|
+
>
|
|
967
|
+
<Play className="w-4 h-4" />
|
|
968
|
+
Execute Prompt
|
|
969
|
+
</button>
|
|
970
|
+
</div>
|
|
971
|
+
|
|
972
|
+
</div>
|
|
973
|
+
</div>
|
|
974
|
+
)}
|
|
975
|
+
|
|
976
|
+
{/* Fullscreen Widget Modal */}
|
|
977
|
+
{fullscreenWidget && (
|
|
978
|
+
<div
|
|
979
|
+
className="fixed inset-0 z-50 flex items-center justify-center"
|
|
980
|
+
style={{ backgroundColor: 'rgba(0, 0, 0, 0.9)' }}
|
|
981
|
+
>
|
|
982
|
+
{/* Close Button */}
|
|
983
|
+
<button
|
|
984
|
+
onClick={() => setFullscreenWidget(null)}
|
|
985
|
+
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"
|
|
986
|
+
title="Exit fullscreen"
|
|
987
|
+
>
|
|
988
|
+
<X className="w-6 h-6 text-white" />
|
|
989
|
+
</button>
|
|
990
|
+
|
|
991
|
+
{/* Widget Container */}
|
|
992
|
+
<div className="w-full h-full p-8">
|
|
993
|
+
<div className="w-full h-full rounded-xl overflow-hidden shadow-2xl">
|
|
994
|
+
<WidgetRenderer uri={fullscreenWidget.uri} data={fullscreenWidget.data} className="widget-fullscreen" />
|
|
995
|
+
</div>
|
|
996
|
+
</div>
|
|
997
|
+
</div>
|
|
998
|
+
)}
|
|
999
|
+
</div>
|
|
1000
|
+
);
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
function ChatMessageComponent({ message, tools }: { message: ChatMessage; tools: Tool[] }) {
|
|
1004
|
+
if (message.role === 'tool') return null; // Don't render tool messages directly
|
|
1005
|
+
|
|
1006
|
+
const isUser = message.role === 'user';
|
|
1007
|
+
|
|
1008
|
+
return (
|
|
1009
|
+
<div className="flex gap-4 items-start animate-fade-in group">
|
|
1010
|
+
{/* Avatar */}
|
|
1011
|
+
{!isUser && (
|
|
1012
|
+
<div className="w-8 h-8 rounded-full bg-gradient-to-br from-primary to-amber-500 flex items-center justify-center flex-shrink-0 shadow-md group-hover:shadow-lg transition-shadow">
|
|
1013
|
+
<Bot className="w-5 h-5 text-white" strokeWidth={2.5} />
|
|
1014
|
+
</div>
|
|
1015
|
+
)}
|
|
1016
|
+
{isUser && (
|
|
1017
|
+
<div className="w-8 h-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">
|
|
1018
|
+
<span className="text-white text-sm font-bold">You</span>
|
|
1019
|
+
</div>
|
|
1020
|
+
)}
|
|
1021
|
+
|
|
1022
|
+
{/* Message Content */}
|
|
1023
|
+
<div className="flex-1 min-w-0">
|
|
1024
|
+
{/* Image if present */}
|
|
1025
|
+
{message.image && (
|
|
1026
|
+
<div className="mb-3 rounded-xl overflow-hidden border border-border/50 shadow-sm">
|
|
1027
|
+
<img
|
|
1028
|
+
src={message.image.data}
|
|
1029
|
+
alt={message.image.name}
|
|
1030
|
+
className="max-w-full"
|
|
1031
|
+
/>
|
|
1032
|
+
</div>
|
|
1033
|
+
)}
|
|
1034
|
+
|
|
1035
|
+
{/* Text content with markdown rendering */}
|
|
1036
|
+
{message.content && (
|
|
1037
|
+
<div className="text-sm leading-relaxed mb-4">
|
|
1038
|
+
{isUser ? (
|
|
1039
|
+
<div className="whitespace-pre-wrap text-foreground/90">{message.content}</div>
|
|
1040
|
+
) : (
|
|
1041
|
+
<MarkdownRenderer content={message.content} />
|
|
1042
|
+
)}
|
|
1043
|
+
</div>
|
|
1044
|
+
)}
|
|
1045
|
+
|
|
1046
|
+
{/* Tool Calls - ChatGPT-style cards */}
|
|
1047
|
+
{message.toolCalls && message.toolCalls.length > 0 && (
|
|
1048
|
+
<div className="space-y-3">
|
|
1049
|
+
{message.toolCalls.map((toolCall) => (
|
|
1050
|
+
<ToolCallComponent key={toolCall.id} toolCall={toolCall} tools={tools} />
|
|
1051
|
+
))}
|
|
1052
|
+
</div>
|
|
1053
|
+
)}
|
|
1054
|
+
</div>
|
|
1055
|
+
</div>
|
|
1056
|
+
);
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
function ToolCallComponent({ toolCall, tools }: { toolCall: ToolCall; tools: Tool[] }) {
|
|
1060
|
+
const [showArgs, setShowArgs] = useState(false);
|
|
1061
|
+
const tool = tools.find((t) => t.name === toolCall.name);
|
|
1062
|
+
|
|
1063
|
+
// Get widget URI from multiple possible sources
|
|
1064
|
+
const componentUri =
|
|
1065
|
+
tool?.widget?.route ||
|
|
1066
|
+
tool?.outputTemplate ||
|
|
1067
|
+
tool?._meta?.['openai/outputTemplate'] ||
|
|
1068
|
+
tool?._meta?.['ui/template'];
|
|
1069
|
+
|
|
1070
|
+
// Get result data from toolCall and unwrap if needed
|
|
1071
|
+
let widgetData = toolCall.result || toolCall.arguments;
|
|
1072
|
+
|
|
1073
|
+
// Unwrap if response was wrapped by TransformInterceptor
|
|
1074
|
+
// Check if it has the interceptor's structure: { success, data, metadata }
|
|
1075
|
+
if (widgetData && typeof widgetData === 'object' &&
|
|
1076
|
+
widgetData.success !== undefined && widgetData.data !== undefined) {
|
|
1077
|
+
widgetData = widgetData.data; // Return the unwrapped data
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
console.log('ToolCallComponent:', {
|
|
1081
|
+
toolName: toolCall.name,
|
|
1082
|
+
componentUri,
|
|
1083
|
+
hasData: !!widgetData,
|
|
1084
|
+
tool
|
|
1085
|
+
});
|
|
1086
|
+
|
|
1087
|
+
return (
|
|
1088
|
+
<div className="relative group/widget">
|
|
1089
|
+
{/* Widget - No frame, just the widget */}
|
|
1090
|
+
{componentUri && widgetData && (
|
|
1091
|
+
<div className="rounded-lg overflow-hidden max-w-5xl">
|
|
1092
|
+
<WidgetRenderer uri={componentUri} data={widgetData} className="widget-in-chat" />
|
|
1093
|
+
</div>
|
|
1094
|
+
)}
|
|
1095
|
+
|
|
1096
|
+
{/* 3-dots menu button - positioned absolutely in top-right */}
|
|
1097
|
+
<button
|
|
1098
|
+
onClick={() => setShowArgs(!showArgs)}
|
|
1099
|
+
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"
|
|
1100
|
+
title="View tool details"
|
|
1101
|
+
>
|
|
1102
|
+
<MoreVertical className="w-4 h-4 text-muted-foreground" />
|
|
1103
|
+
</button>
|
|
1104
|
+
|
|
1105
|
+
{/* Arguments Modal/Dropdown - appears when 3-dots clicked */}
|
|
1106
|
+
{showArgs && (
|
|
1107
|
+
<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">
|
|
1108
|
+
<div className="flex items-center justify-between mb-3">
|
|
1109
|
+
<div className="flex items-center gap-2">
|
|
1110
|
+
<div className="w-6 h-6 rounded-md bg-primary/10 flex items-center justify-center">
|
|
1111
|
+
<Wrench className="w-3.5 h-3.5 text-primary" />
|
|
1112
|
+
</div>
|
|
1113
|
+
<span className="font-semibold text-sm text-foreground">{toolCall.name}</span>
|
|
1114
|
+
</div>
|
|
1115
|
+
<button
|
|
1116
|
+
onClick={() => setShowArgs(false)}
|
|
1117
|
+
className="w-6 h-6 rounded-md flex items-center justify-center hover:bg-muted transition-colors"
|
|
1118
|
+
>
|
|
1119
|
+
<X className="w-4 h-4 text-muted-foreground" />
|
|
1120
|
+
</button>
|
|
1121
|
+
</div>
|
|
1122
|
+
<div>
|
|
1123
|
+
<p className="text-xs font-medium text-muted-foreground mb-2">Arguments:</p>
|
|
1124
|
+
<pre className="p-3 rounded-lg overflow-auto bg-background border border-border/30 font-mono text-xs text-foreground max-h-60">
|
|
1125
|
+
{JSON.stringify(toolCall.arguments, null, 2)}
|
|
1126
|
+
</pre>
|
|
1127
|
+
</div>
|
|
1128
|
+
</div>
|
|
1129
|
+
)}
|
|
1130
|
+
</div>
|
|
1131
|
+
);
|
|
1132
|
+
}
|
|
1133
|
+
|