nitrostack 1.0.1 → 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/dist/cli/index.js +4 -1
  3. package/dist/cli/index.js.map +1 -1
  4. package/package.json +1 -1
  5. package/src/studio/README.md +140 -0
  6. package/src/studio/app/api/auth/fetch-metadata/route.ts +71 -0
  7. package/src/studio/app/api/auth/register-client/route.ts +67 -0
  8. package/src/studio/app/api/chat/route.ts +123 -0
  9. package/src/studio/app/api/health/checks/route.ts +42 -0
  10. package/src/studio/app/api/health/route.ts +13 -0
  11. package/src/studio/app/api/init/route.ts +85 -0
  12. package/src/studio/app/api/ping/route.ts +13 -0
  13. package/src/studio/app/api/prompts/[name]/route.ts +21 -0
  14. package/src/studio/app/api/prompts/route.ts +13 -0
  15. package/src/studio/app/api/resources/[...uri]/route.ts +18 -0
  16. package/src/studio/app/api/resources/route.ts +13 -0
  17. package/src/studio/app/api/roots/route.ts +13 -0
  18. package/src/studio/app/api/sampling/route.ts +14 -0
  19. package/src/studio/app/api/tools/[name]/call/route.ts +41 -0
  20. package/src/studio/app/api/tools/route.ts +23 -0
  21. package/src/studio/app/api/widget-examples/route.ts +44 -0
  22. package/src/studio/app/auth/callback/page.tsx +160 -0
  23. package/src/studio/app/auth/page.tsx +543 -0
  24. package/src/studio/app/chat/page.tsx +530 -0
  25. package/src/studio/app/chat/page.tsx.backup +390 -0
  26. package/src/studio/app/globals.css +410 -0
  27. package/src/studio/app/health/page.tsx +177 -0
  28. package/src/studio/app/layout.tsx +48 -0
  29. package/src/studio/app/page.tsx +337 -0
  30. package/src/studio/app/page.tsx.backup +346 -0
  31. package/src/studio/app/ping/page.tsx +204 -0
  32. package/src/studio/app/prompts/page.tsx +228 -0
  33. package/src/studio/app/resources/page.tsx +313 -0
  34. package/src/studio/components/EnlargeModal.tsx +116 -0
  35. package/src/studio/components/Sidebar.tsx +133 -0
  36. package/src/studio/components/ToolCard.tsx +108 -0
  37. package/src/studio/components/WidgetRenderer.tsx +99 -0
  38. package/src/studio/lib/api.ts +207 -0
  39. package/src/studio/lib/llm-service.ts +361 -0
  40. package/src/studio/lib/mcp-client.ts +168 -0
  41. package/src/studio/lib/store.ts +192 -0
  42. package/src/studio/lib/theme-provider.tsx +50 -0
  43. package/src/studio/lib/types.ts +107 -0
  44. package/src/studio/lib/widget-loader.ts +90 -0
  45. package/src/studio/middleware.ts +27 -0
  46. package/src/studio/next.config.js +16 -0
  47. package/src/studio/package-lock.json +2696 -0
  48. package/src/studio/package.json +34 -0
  49. package/src/studio/postcss.config.mjs +10 -0
  50. package/src/studio/tailwind.config.ts +67 -0
  51. package/src/studio/tsconfig.json +41 -0
@@ -0,0 +1,530 @@
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 type { ChatMessage, Tool, ToolCall } from '@/lib/types';
8
+ import {
9
+ Bot,
10
+ Settings,
11
+ Trash2,
12
+ Image as ImageIcon,
13
+ Send,
14
+ Wrench,
15
+ Save,
16
+ X
17
+ } from 'lucide-react';
18
+
19
+ export default function ChatPage() {
20
+ const {
21
+ chatMessages,
22
+ addChatMessage,
23
+ clearChat,
24
+ currentProvider,
25
+ setCurrentProvider,
26
+ currentImage,
27
+ setCurrentImage,
28
+ jwtToken,
29
+ apiKey: mcpApiKey, // Rename to avoid conflict with LLM apiKey
30
+ tools,
31
+ setTools,
32
+ } = useStudioStore();
33
+
34
+ const [inputValue, setInputValue] = useState('');
35
+ const [loading, setLoading] = useState(false);
36
+ const [showSettings, setShowSettings] = useState(false);
37
+ const messagesEndRef = useRef<HTMLDivElement>(null);
38
+ const fileInputRef = useRef<HTMLInputElement>(null);
39
+
40
+ useEffect(() => {
41
+ loadTools();
42
+ }, []);
43
+
44
+ useEffect(() => {
45
+ messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
46
+ }, [chatMessages]);
47
+
48
+ const loadTools = async () => {
49
+ try {
50
+ const data = await api.getTools();
51
+ setTools(data.tools || []);
52
+ } catch (error) {
53
+ console.error('Failed to load tools:', error);
54
+ }
55
+ };
56
+
57
+ const handleImageUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
58
+ const file = e.target.files?.[0];
59
+ if (!file) return;
60
+
61
+ if (file.size > 20 * 1024 * 1024) {
62
+ alert('Image too large (max 20MB)');
63
+ return;
64
+ }
65
+
66
+ const reader = new FileReader();
67
+ reader.onload = (event) => {
68
+ setCurrentImage({
69
+ data: event.target?.result as string,
70
+ type: file.type,
71
+ name: file.name,
72
+ });
73
+ };
74
+ reader.readAsDataURL(file);
75
+ };
76
+
77
+ const handleSend = async () => {
78
+ if (!inputValue.trim() && !currentImage) return;
79
+
80
+ const apiKey = localStorage.getItem(`${currentProvider}_api_key`);
81
+ if (!apiKey) {
82
+ setShowSettings(true);
83
+ alert('Please set your API key first');
84
+ return;
85
+ }
86
+
87
+ const userMessage: ChatMessage = {
88
+ role: 'user',
89
+ content: inputValue,
90
+ };
91
+
92
+ if (currentImage) {
93
+ userMessage.image = currentImage;
94
+ }
95
+
96
+ addChatMessage(userMessage);
97
+ setInputValue('');
98
+ setCurrentImage(null);
99
+ setLoading(true);
100
+
101
+ try {
102
+ const messagesToSend = [...chatMessages, userMessage];
103
+
104
+ // Clean messages to ensure they're serializable
105
+ const cleanedMessages = messagesToSend.map(msg => {
106
+ const cleaned: any = {
107
+ role: msg.role,
108
+ content: msg.content || '',
109
+ };
110
+
111
+ if (msg.toolCalls && msg.toolCalls.length > 0) {
112
+ cleaned.toolCalls = msg.toolCalls;
113
+ }
114
+
115
+ if (msg.toolCallId) {
116
+ cleaned.toolCallId = msg.toolCallId;
117
+ }
118
+
119
+ // Skip image property for now (not supported by OpenAI chat completions)
120
+ // if (msg.image) {
121
+ // cleaned.image = msg.image;
122
+ // }
123
+
124
+ return cleaned;
125
+ });
126
+
127
+ console.log('Sending messages to API:', cleanedMessages);
128
+ console.log('Original messages:', messagesToSend);
129
+ console.log('Cleaned messages JSON:', JSON.stringify(cleanedMessages));
130
+
131
+ const response = await api.chat({
132
+ provider: currentProvider,
133
+ messages: cleanedMessages,
134
+ apiKey, // LLM API key (OpenAI/Gemini)
135
+ jwtToken: jwtToken || undefined,
136
+ mcpApiKey: mcpApiKey || undefined, // MCP server API key
137
+ });
138
+
139
+ // Handle tool calls FIRST (before adding the message)
140
+ if (response.toolCalls && response.toolResults) {
141
+ // Attach results to tool calls for widget rendering
142
+ const toolCallsWithResults = response.toolCalls.map((tc, i) => {
143
+ const toolResult = response.toolResults[i];
144
+
145
+ // Parse the result content
146
+ let parsedResult;
147
+ if (toolResult.content) {
148
+ try {
149
+ parsedResult = JSON.parse(toolResult.content);
150
+
151
+ // Unwrap if response was wrapped by TransformInterceptor
152
+ if (parsedResult.success !== undefined && parsedResult.data !== undefined) {
153
+ parsedResult = parsedResult.data;
154
+ }
155
+ } catch {
156
+ parsedResult = { content: toolResult.content };
157
+ }
158
+ }
159
+
160
+ return {
161
+ ...tc,
162
+ result: parsedResult,
163
+ };
164
+ });
165
+
166
+ // Add assistant message with tool calls
167
+ if (response.message) {
168
+ response.message.toolCalls = toolCallsWithResults;
169
+ addChatMessage(response.message);
170
+ }
171
+
172
+ // Extract JWT token from ANY tool response (not just 'login')
173
+ for (let i = 0; i < response.toolCalls.length; i++) {
174
+ const toolCall = response.toolCalls[i];
175
+ const toolResult = response.toolResults[i];
176
+
177
+ if (toolResult.content) {
178
+ try {
179
+ const parsed = JSON.parse(toolResult.content);
180
+ // Check for token in multiple possible locations
181
+ const token = parsed.token || parsed.access_token || parsed.jwt || parsed.data?.token;
182
+ if (token) {
183
+ console.log('🔐 Token received from tool in chat, saving to global state');
184
+ useStudioStore.getState().setJwtToken(token);
185
+ break; // Stop after first token found
186
+ }
187
+ } catch (e) {
188
+ // Ignore parsing errors
189
+ }
190
+ }
191
+ }
192
+
193
+ // Add tool results to messages
194
+ const toolResultMessages: ChatMessage[] = [];
195
+ for (const result of response.toolResults) {
196
+ addChatMessage(result);
197
+ toolResultMessages.push(result);
198
+ }
199
+
200
+ // Continue conversation and WAIT for it to complete before allowing new messages
201
+ // Build the full message history to pass to continuation
202
+ const messagesForContinuation = [
203
+ ...chatMessages,
204
+ response.message!, // Assistant message with tool calls
205
+ ...toolResultMessages, // Tool result messages
206
+ ];
207
+ await continueChatWithToolResults(apiKey, messagesForContinuation);
208
+ } else {
209
+ // No tool calls, just add the message
210
+ if (response.message) {
211
+ addChatMessage(response.message);
212
+ }
213
+ }
214
+
215
+ // Set loading to false AFTER all async operations complete
216
+ setLoading(false);
217
+ } catch (error) {
218
+ console.error('Chat error:', error);
219
+ addChatMessage({
220
+ role: 'assistant',
221
+ content: 'Sorry, I encountered an error. Please try again.',
222
+ });
223
+ setLoading(false);
224
+ }
225
+ };
226
+
227
+ const continueChatWithToolResults = async (apiKey: string, messages?: Message[]) => {
228
+ try {
229
+ // Use provided messages or fall back to store (for recursive calls)
230
+ const messagesToUse = messages || chatMessages;
231
+
232
+ // Clean messages before sending
233
+ const cleanedMessages = messagesToUse.map(msg => {
234
+ const cleaned: any = {
235
+ role: msg.role,
236
+ content: msg.content || '',
237
+ };
238
+
239
+ if (msg.toolCalls && msg.toolCalls.length > 0) {
240
+ cleaned.toolCalls = msg.toolCalls;
241
+ }
242
+
243
+ if (msg.toolCallId) {
244
+ cleaned.toolCallId = msg.toolCallId;
245
+ }
246
+
247
+ return cleaned;
248
+ });
249
+
250
+ console.log('Continue with cleaned messages:', JSON.stringify(cleanedMessages));
251
+
252
+ const response = await api.chat({
253
+ provider: currentProvider,
254
+ messages: cleanedMessages,
255
+ apiKey, // LLM API key (OpenAI/Gemini)
256
+ jwtToken: jwtToken || undefined,
257
+ mcpApiKey: mcpApiKey || undefined, // MCP server API key
258
+ });
259
+
260
+ if (response.message) {
261
+ addChatMessage(response.message);
262
+ }
263
+
264
+ // Recursive tool calls
265
+ if (response.toolCalls && response.toolResults) {
266
+ const newToolResults: Message[] = [];
267
+ for (const result of response.toolResults) {
268
+ addChatMessage(result);
269
+ newToolResults.push(result);
270
+ }
271
+
272
+ // Build messages for next iteration
273
+ const nextMessages = [
274
+ ...(messages || chatMessages),
275
+ response.message!,
276
+ ...newToolResults,
277
+ ];
278
+ await continueChatWithToolResults(apiKey, nextMessages);
279
+ }
280
+ } catch (error) {
281
+ console.error('Continuation error:', error);
282
+ }
283
+ };
284
+
285
+ const saveApiKey = (provider: 'openai' | 'gemini') => {
286
+ const input = document.getElementById(`${provider}-api-key`) as HTMLInputElement;
287
+ const key = input?.value.trim();
288
+
289
+ if (!key || key === '••••••••') {
290
+ alert('Please enter a valid API key');
291
+ return;
292
+ }
293
+
294
+ localStorage.setItem(`${provider}_api_key`, key);
295
+ input.value = '••••••••';
296
+ alert(`${provider === 'openai' ? 'OpenAI' : 'Gemini'} API key saved`);
297
+ };
298
+
299
+ return (
300
+ <div className="h-screen flex flex-col bg-background">
301
+ {/* Header */}
302
+ <div className="border-b border-border p-6 flex items-center justify-between bg-card">
303
+ <div className="flex items-center gap-3">
304
+ <div className="w-10 h-10 rounded-lg bg-gradient-to-br from-primary to-amber-500 flex items-center justify-center">
305
+ <Bot className="w-6 h-6 text-black" />
306
+ </div>
307
+ <div>
308
+ <h1 className="text-2xl font-bold text-foreground">AI Chat</h1>
309
+ <p className="text-muted-foreground text-sm">Chat with tools integration</p>
310
+ </div>
311
+ </div>
312
+
313
+ <div className="flex items-center gap-2">
314
+ <select
315
+ value={currentProvider}
316
+ onChange={(e) => setCurrentProvider(e.target.value as 'openai' | 'gemini')}
317
+ className="input w-36"
318
+ >
319
+ <option value="gemini">Gemini</option>
320
+ <option value="openai">OpenAI</option>
321
+ </select>
322
+ <button
323
+ onClick={() => setShowSettings(!showSettings)}
324
+ className={`btn ${showSettings ? 'btn-primary' : 'btn-secondary'}`}
325
+ >
326
+ <Settings className="w-4 h-4" />
327
+ </button>
328
+ <button onClick={clearChat} className="btn btn-secondary">
329
+ <Trash2 className="w-4 h-4" />
330
+ </button>
331
+ </div>
332
+ </div>
333
+
334
+ {/* Settings Panel */}
335
+ {showSettings && (
336
+ <div className="border-b border-border p-6 bg-muted/30">
337
+ <h3 className="font-semibold mb-4 text-foreground flex items-center gap-2">
338
+ <Settings className="w-5 h-5" />
339
+ API Keys
340
+ </h3>
341
+ <div className="grid grid-cols-2 gap-4">
342
+ <div>
343
+ <label className="text-sm mb-2 block text-foreground font-medium">OpenAI API Key</label>
344
+ <div className="flex gap-2">
345
+ <input
346
+ id="openai-api-key"
347
+ type="password"
348
+ className="input flex-1"
349
+ placeholder="sk-..."
350
+ />
351
+ <button onClick={() => saveApiKey('openai')} className="btn btn-primary">
352
+ <Save className="w-4 h-4 mr-2" />
353
+ Save
354
+ </button>
355
+ </div>
356
+ </div>
357
+ <div>
358
+ <label className="text-sm mb-2 block text-foreground font-medium">Gemini API Key</label>
359
+ <div className="flex gap-2">
360
+ <input
361
+ id="gemini-api-key"
362
+ type="password"
363
+ className="input flex-1"
364
+ placeholder="AIza..."
365
+ />
366
+ <button onClick={() => saveApiKey('gemini')} className="btn btn-primary">
367
+ <Save className="w-4 h-4 mr-2" />
368
+ Save
369
+ </button>
370
+ </div>
371
+ </div>
372
+ </div>
373
+ </div>
374
+ )}
375
+
376
+ {/* Messages */}
377
+ <div className="flex-1 overflow-y-auto p-4 space-y-4">
378
+ {chatMessages.map((msg, idx) => (
379
+ <ChatMessageComponent key={idx} message={msg} tools={tools} />
380
+ ))}
381
+ {loading && (
382
+ <div className="flex items-center gap-2 text-text-secondary">
383
+ <div className="animate-pulse">Thinking...</div>
384
+ </div>
385
+ )}
386
+ <div ref={messagesEndRef} />
387
+ </div>
388
+
389
+ {/* Input */}
390
+ <div className="border-t border-border p-6 bg-card">
391
+ {currentImage && (
392
+ <div className="mb-3 relative inline-block">
393
+ <img
394
+ src={currentImage.data}
395
+ alt="Upload preview"
396
+ className="h-24 rounded-lg border border-border shadow-sm"
397
+ />
398
+ <button
399
+ onClick={() => setCurrentImage(null)}
400
+ className="absolute -top-2 -right-2 bg-destructive text-white rounded-full w-7 h-7 flex items-center justify-center hover:bg-destructive/90 transition-colors shadow-md"
401
+ >
402
+ <X className="w-4 h-4" />
403
+ </button>
404
+ </div>
405
+ )}
406
+ <div className="flex gap-3">
407
+ <input
408
+ type="file"
409
+ ref={fileInputRef}
410
+ onChange={handleImageUpload}
411
+ accept="image/*"
412
+ className="hidden"
413
+ />
414
+ <button
415
+ onClick={() => fileInputRef.current?.click()}
416
+ className="btn btn-secondary"
417
+ title="Upload image"
418
+ >
419
+ <ImageIcon className="w-5 h-5" />
420
+ </button>
421
+ <textarea
422
+ value={inputValue}
423
+ onChange={(e) => setInputValue(e.target.value)}
424
+ onKeyDown={(e) => {
425
+ if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
426
+ handleSend();
427
+ }
428
+ }}
429
+ placeholder="Type a message... (Cmd/Ctrl + Enter to send)"
430
+ className="input flex-1 resize-none"
431
+ rows={2}
432
+ />
433
+ <button onClick={handleSend} className="btn btn-primary px-6 gap-2" disabled={loading}>
434
+ <Send className="w-4 h-4" />
435
+ Send
436
+ </button>
437
+ </div>
438
+ </div>
439
+ </div>
440
+ );
441
+ }
442
+
443
+ function ChatMessageComponent({ message, tools }: { message: ChatMessage; tools: Tool[] }) {
444
+ if (message.role === 'tool') return null; // Don't render tool messages directly
445
+
446
+ const isUser = message.role === 'user';
447
+
448
+ return (
449
+ <div
450
+ className={`flex ${isUser ? 'justify-end' : 'justify-start'} animate-fade-in`}
451
+ >
452
+ <div className={`max-w-[70%] ${isUser ? 'bg-primary/10 border-primary/30' : 'bg-card'} rounded-2xl p-5 border border-border shadow-sm`}>
453
+ {message.image && (
454
+ <img
455
+ src={message.image.data}
456
+ alt={message.image.name}
457
+ className="rounded-lg mb-3 max-w-full border border-border"
458
+ />
459
+ )}
460
+ <p className="text-sm whitespace-pre-wrap text-foreground">{message.content}</p>
461
+
462
+ {/* Tool Calls */}
463
+ {message.toolCalls && message.toolCalls.length > 0 && (
464
+ <div className="mt-4 space-y-3">
465
+ {message.toolCalls.map((toolCall) => (
466
+ <ToolCallComponent key={toolCall.id} toolCall={toolCall} tools={tools} />
467
+ ))}
468
+ </div>
469
+ )}
470
+ </div>
471
+ </div>
472
+ );
473
+ }
474
+
475
+ function ToolCallComponent({ toolCall, tools }: { toolCall: ToolCall; tools: Tool[] }) {
476
+ const tool = tools.find((t) => t.name === toolCall.name);
477
+
478
+ // Get widget URI from multiple possible sources
479
+ const componentUri =
480
+ tool?.widget?.route ||
481
+ tool?.outputTemplate ||
482
+ tool?._meta?.['openai/outputTemplate'] ||
483
+ tool?._meta?.['ui/template'];
484
+
485
+ // Get result data from toolCall and unwrap if needed
486
+ let widgetData = toolCall.result || toolCall.arguments;
487
+
488
+ // Unwrap if response was wrapped by TransformInterceptor
489
+ // Check if it has the interceptor's structure: { success, data, metadata }
490
+ if (widgetData && typeof widgetData === 'object' &&
491
+ widgetData.success !== undefined && widgetData.data !== undefined) {
492
+ widgetData = widgetData.data; // Return the unwrapped data
493
+ }
494
+
495
+ console.log('ToolCallComponent:', {
496
+ toolName: toolCall.name,
497
+ componentUri,
498
+ hasData: !!widgetData,
499
+ tool
500
+ });
501
+
502
+ return (
503
+ <div className="rounded-lg p-4 border border-border bg-muted/30">
504
+ <div className="flex items-center gap-2 mb-3">
505
+ <Wrench className="w-4 h-4 text-primary" />
506
+ <span className="font-semibold text-sm text-foreground">{toolCall.name}</span>
507
+ </div>
508
+
509
+ {/* Arguments */}
510
+ <details className="text-xs text-muted-foreground">
511
+ <summary className="cursor-pointer hover:text-foreground transition-colors font-medium">
512
+ Arguments
513
+ </summary>
514
+ <pre className="mt-2 p-3 rounded-lg overflow-auto bg-background border border-border font-mono text-foreground">
515
+ {JSON.stringify(toolCall.arguments, null, 2)}
516
+ </pre>
517
+ </details>
518
+
519
+ {/* Widget if available */}
520
+ {componentUri && widgetData && (
521
+ <div className="mt-3 rounded-lg overflow-hidden border border-border bg-background shadow-inner" style={{
522
+ height: '320px'
523
+ }}>
524
+ <WidgetRenderer uri={componentUri} data={widgetData} />
525
+ </div>
526
+ )}
527
+ </div>
528
+ );
529
+ }
530
+