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.
- package/CHANGELOG.md +15 -0
- package/dist/cli/index.js +4 -1
- package/dist/cli/index.js.map +1 -1
- package/package.json +1 -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 +123 -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 +85 -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 +160 -0
- package/src/studio/app/auth/page.tsx +543 -0
- package/src/studio/app/chat/page.tsx +530 -0
- package/src/studio/app/chat/page.tsx.backup +390 -0
- package/src/studio/app/globals.css +410 -0
- package/src/studio/app/health/page.tsx +177 -0
- package/src/studio/app/layout.tsx +48 -0
- package/src/studio/app/page.tsx +337 -0
- package/src/studio/app/page.tsx.backup +346 -0
- package/src/studio/app/ping/page.tsx +204 -0
- package/src/studio/app/prompts/page.tsx +228 -0
- package/src/studio/app/resources/page.tsx +313 -0
- package/src/studio/components/EnlargeModal.tsx +116 -0
- package/src/studio/components/Sidebar.tsx +133 -0
- package/src/studio/components/ToolCard.tsx +108 -0
- package/src/studio/components/WidgetRenderer.tsx +99 -0
- package/src/studio/lib/api.ts +207 -0
- package/src/studio/lib/llm-service.ts +361 -0
- package/src/studio/lib/mcp-client.ts +168 -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 +16 -0
- package/src/studio/package-lock.json +2696 -0
- package/src/studio/package.json +34 -0
- package/src/studio/postcss.config.mjs +10 -0
- package/src/studio/tailwind.config.ts +67 -0
- package/src/studio/tsconfig.json +41 -0
|
@@ -0,0 +1,390 @@
|
|
|
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
|
+
|
|
9
|
+
export default function ChatPage() {
|
|
10
|
+
const {
|
|
11
|
+
chatMessages,
|
|
12
|
+
addChatMessage,
|
|
13
|
+
clearChat,
|
|
14
|
+
currentProvider,
|
|
15
|
+
setCurrentProvider,
|
|
16
|
+
currentImage,
|
|
17
|
+
setCurrentImage,
|
|
18
|
+
jwtToken,
|
|
19
|
+
tools,
|
|
20
|
+
setTools,
|
|
21
|
+
} = useStudioStore();
|
|
22
|
+
|
|
23
|
+
const [inputValue, setInputValue] = useState('');
|
|
24
|
+
const [loading, setLoading] = useState(false);
|
|
25
|
+
const [showSettings, setShowSettings] = useState(false);
|
|
26
|
+
const messagesEndRef = useRef<HTMLDivElement>(null);
|
|
27
|
+
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
28
|
+
|
|
29
|
+
useEffect(() => {
|
|
30
|
+
loadTools();
|
|
31
|
+
}, []);
|
|
32
|
+
|
|
33
|
+
useEffect(() => {
|
|
34
|
+
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
|
35
|
+
}, [chatMessages]);
|
|
36
|
+
|
|
37
|
+
const loadTools = async () => {
|
|
38
|
+
try {
|
|
39
|
+
const data = await api.getTools();
|
|
40
|
+
setTools(data.tools || []);
|
|
41
|
+
} catch (error) {
|
|
42
|
+
console.error('Failed to load tools:', error);
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const handleImageUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
47
|
+
const file = e.target.files?.[0];
|
|
48
|
+
if (!file) return;
|
|
49
|
+
|
|
50
|
+
if (file.size > 20 * 1024 * 1024) {
|
|
51
|
+
alert('Image too large (max 20MB)');
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const reader = new FileReader();
|
|
56
|
+
reader.onload = (event) => {
|
|
57
|
+
setCurrentImage({
|
|
58
|
+
data: event.target?.result as string,
|
|
59
|
+
type: file.type,
|
|
60
|
+
name: file.name,
|
|
61
|
+
});
|
|
62
|
+
};
|
|
63
|
+
reader.readAsDataURL(file);
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const handleSend = async () => {
|
|
67
|
+
if (!inputValue.trim() && !currentImage) return;
|
|
68
|
+
|
|
69
|
+
const apiKey = localStorage.getItem(`${currentProvider}_api_key`);
|
|
70
|
+
if (!apiKey) {
|
|
71
|
+
setShowSettings(true);
|
|
72
|
+
alert('Please set your API key first');
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const userMessage: ChatMessage = {
|
|
77
|
+
role: 'user',
|
|
78
|
+
content: inputValue,
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
if (currentImage) {
|
|
82
|
+
userMessage.image = currentImage;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
addChatMessage(userMessage);
|
|
86
|
+
setInputValue('');
|
|
87
|
+
setCurrentImage(null);
|
|
88
|
+
setLoading(true);
|
|
89
|
+
|
|
90
|
+
try {
|
|
91
|
+
const response = await api.chat({
|
|
92
|
+
provider: currentProvider,
|
|
93
|
+
messages: [...chatMessages, userMessage],
|
|
94
|
+
apiKey,
|
|
95
|
+
jwtToken: jwtToken || undefined,
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
if (response.message) {
|
|
99
|
+
addChatMessage(response.message);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Handle tool calls
|
|
103
|
+
if (response.toolCalls && response.toolResults) {
|
|
104
|
+
// Check for login token
|
|
105
|
+
for (let i = 0; i < response.toolCalls.length; i++) {
|
|
106
|
+
const toolCall = response.toolCalls[i];
|
|
107
|
+
const toolResult = response.toolResults[i];
|
|
108
|
+
|
|
109
|
+
if (toolCall.name === 'login' && toolResult.content) {
|
|
110
|
+
try {
|
|
111
|
+
const parsed = JSON.parse(toolResult.content);
|
|
112
|
+
if (parsed.token) {
|
|
113
|
+
useStudioStore.getState().setJwtToken(parsed.token);
|
|
114
|
+
}
|
|
115
|
+
} catch (e) {
|
|
116
|
+
// Ignore
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Add tool results
|
|
122
|
+
for (const result of response.toolResults) {
|
|
123
|
+
addChatMessage(result);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Continue conversation
|
|
127
|
+
await continueChatWithToolResults(apiKey);
|
|
128
|
+
}
|
|
129
|
+
} catch (error) {
|
|
130
|
+
console.error('Chat error:', error);
|
|
131
|
+
addChatMessage({
|
|
132
|
+
role: 'assistant',
|
|
133
|
+
content: 'Sorry, I encountered an error. Please try again.',
|
|
134
|
+
});
|
|
135
|
+
} finally {
|
|
136
|
+
setLoading(false);
|
|
137
|
+
}
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
const continueChatWithToolResults = async (apiKey: string) => {
|
|
141
|
+
try {
|
|
142
|
+
const response = await api.chat({
|
|
143
|
+
provider: currentProvider,
|
|
144
|
+
messages: chatMessages,
|
|
145
|
+
apiKey,
|
|
146
|
+
jwtToken: jwtToken || undefined,
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
if (response.message) {
|
|
150
|
+
addChatMessage(response.message);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Recursive tool calls
|
|
154
|
+
if (response.toolCalls && response.toolResults) {
|
|
155
|
+
for (const result of response.toolResults) {
|
|
156
|
+
addChatMessage(result);
|
|
157
|
+
}
|
|
158
|
+
await continueChatWithToolResults(apiKey);
|
|
159
|
+
}
|
|
160
|
+
} catch (error) {
|
|
161
|
+
console.error('Continuation error:', error);
|
|
162
|
+
}
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
const saveApiKey = (provider: 'openai' | 'gemini') => {
|
|
166
|
+
const input = document.getElementById(`${provider}-api-key`) as HTMLInputElement;
|
|
167
|
+
const key = input?.value.trim();
|
|
168
|
+
|
|
169
|
+
if (!key || key === '••••••••') {
|
|
170
|
+
alert('Please enter a valid API key');
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
localStorage.setItem(`${provider}_api_key`, key);
|
|
175
|
+
input.value = '••••••••';
|
|
176
|
+
alert(`${provider === 'openai' ? 'OpenAI' : 'Gemini'} API key saved`);
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
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>
|
|
187
|
+
|
|
188
|
+
<div className="flex items-center gap-2">
|
|
189
|
+
<select
|
|
190
|
+
value={currentProvider}
|
|
191
|
+
onChange={(e) => setCurrentProvider(e.target.value as 'openai' | 'gemini')}
|
|
192
|
+
className="input w-32"
|
|
193
|
+
>
|
|
194
|
+
<option value="openai">OpenAI</option>
|
|
195
|
+
<option value="gemini">Gemini</option>
|
|
196
|
+
</select>
|
|
197
|
+
<button onClick={() => setShowSettings(!showSettings)} className="btn btn-secondary">
|
|
198
|
+
⚙️
|
|
199
|
+
</button>
|
|
200
|
+
<button onClick={clearChat} className="btn btn-secondary">
|
|
201
|
+
🗑️
|
|
202
|
+
</button>
|
|
203
|
+
</div>
|
|
204
|
+
</div>
|
|
205
|
+
|
|
206
|
+
{/* Settings Panel */}
|
|
207
|
+
{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>
|
|
218
|
+
</div>
|
|
219
|
+
</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>
|
|
227
|
+
</div>
|
|
228
|
+
</div>
|
|
229
|
+
</div>
|
|
230
|
+
</div>
|
|
231
|
+
)}
|
|
232
|
+
|
|
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} />
|
|
244
|
+
</div>
|
|
245
|
+
|
|
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>
|
|
261
|
+
</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>
|
|
292
|
+
</div>
|
|
293
|
+
</div>
|
|
294
|
+
</div>
|
|
295
|
+
);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function ChatMessageComponent({ message, tools }: { message: ChatMessage; tools: Tool[] }) {
|
|
299
|
+
if (message.role === 'tool') return null; // Don't render tool messages directly
|
|
300
|
+
|
|
301
|
+
const isUser = message.role === 'user';
|
|
302
|
+
|
|
303
|
+
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
|
+
/>
|
|
314
|
+
)}
|
|
315
|
+
<p className="text-sm whitespace-pre-wrap">{message.content}</p>
|
|
316
|
+
|
|
317
|
+
{/* Tool Calls */}
|
|
318
|
+
{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} />
|
|
322
|
+
))}
|
|
323
|
+
</div>
|
|
324
|
+
)}
|
|
325
|
+
</div>
|
|
326
|
+
</div>
|
|
327
|
+
);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function ToolCallComponent({ toolCall, tools }: { toolCall: ToolCall; tools: Tool[] }) {
|
|
331
|
+
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'] ||
|
|
338
|
+
tool?._meta?.['ui/template'];
|
|
339
|
+
|
|
340
|
+
// Get result data from toolCall and unwrap if needed
|
|
341
|
+
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
|
+
|
|
350
|
+
console.log('ToolCallComponent:', {
|
|
351
|
+
toolName: toolCall.name,
|
|
352
|
+
componentUri,
|
|
353
|
+
hasData: !!widgetData,
|
|
354
|
+
tool
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
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 */}
|
|
378
|
+
{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} />
|
|
385
|
+
</div>
|
|
386
|
+
)}
|
|
387
|
+
</div>
|
|
388
|
+
);
|
|
389
|
+
}
|
|
390
|
+
|