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,138 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useStudioStore } from '@/lib/store';
|
|
4
|
+
import { WidgetRenderer } from './WidgetRenderer';
|
|
5
|
+
import { X, MessageSquare } from 'lucide-react';
|
|
6
|
+
import { useRouter } from 'next/navigation';
|
|
7
|
+
|
|
8
|
+
export function EnlargeModal() {
|
|
9
|
+
const { enlargeModal, closeEnlargeModal, setCurrentTab } = useStudioStore();
|
|
10
|
+
const router = useRouter();
|
|
11
|
+
|
|
12
|
+
if (!enlargeModal.open || !enlargeModal.item) return null;
|
|
13
|
+
|
|
14
|
+
const { type, item } = enlargeModal;
|
|
15
|
+
|
|
16
|
+
// Get widget URI
|
|
17
|
+
const componentUri =
|
|
18
|
+
type === 'tool'
|
|
19
|
+
? (item.widget?.route || item.outputTemplate || item._meta?.['openai/outputTemplate'] || item._meta?.['ui/template'])
|
|
20
|
+
: item.uri;
|
|
21
|
+
|
|
22
|
+
// Get data from item's examples or responseData - check both examples and _meta
|
|
23
|
+
const widgetData =
|
|
24
|
+
item.examples?.response ||
|
|
25
|
+
item._meta?.['tool/examples']?.response ||
|
|
26
|
+
item.responseData ||
|
|
27
|
+
{};
|
|
28
|
+
|
|
29
|
+
// Debug logging
|
|
30
|
+
console.log('EnlargeModal - Widget info:', {
|
|
31
|
+
type,
|
|
32
|
+
itemName: item.name,
|
|
33
|
+
componentUri,
|
|
34
|
+
hasWidgetData: !!widgetData && Object.keys(widgetData).length > 0,
|
|
35
|
+
widgetDataType: widgetData ? typeof widgetData : 'none',
|
|
36
|
+
itemExamples: item.examples,
|
|
37
|
+
itemMeta: item._meta,
|
|
38
|
+
metaExamples: item._meta?.['tool/examples'],
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
const handleUseInChat = () => {
|
|
42
|
+
if (type !== 'tool') return;
|
|
43
|
+
|
|
44
|
+
closeEnlargeModal();
|
|
45
|
+
|
|
46
|
+
// Build the tool execution message
|
|
47
|
+
const toolMessage = `Use the ${item.name} tool`;
|
|
48
|
+
|
|
49
|
+
// Store both the tool name and the message
|
|
50
|
+
if (typeof window !== 'undefined') {
|
|
51
|
+
window.localStorage.setItem('suggestedTool', item.name);
|
|
52
|
+
window.localStorage.setItem('chatInput', toolMessage);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
setCurrentTab('chat');
|
|
56
|
+
router.push('/chat');
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
return (
|
|
60
|
+
<div
|
|
61
|
+
className="fixed inset-0 z-50 flex items-center justify-center animate-fade-in"
|
|
62
|
+
style={{ backgroundColor: 'rgba(0, 0, 0, 0.85)' }}
|
|
63
|
+
onClick={closeEnlargeModal}
|
|
64
|
+
>
|
|
65
|
+
<div
|
|
66
|
+
className="relative w-[95vw] max-w-7xl h-[90vh] bg-card border border-border rounded-xl sm:rounded-2xl shadow-2xl overflow-hidden animate-scale-in"
|
|
67
|
+
onClick={(e) => e.stopPropagation()}
|
|
68
|
+
>
|
|
69
|
+
{/* Header */}
|
|
70
|
+
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between p-4 sm:p-6 border-b border-border bg-muted/30 gap-3 sm:gap-0">
|
|
71
|
+
<div className="flex items-start sm:items-center gap-3 sm:gap-4 flex-1 min-w-0">
|
|
72
|
+
<span className="text-2xl sm:text-3xl flex-shrink-0">{type === 'tool' ? '⚡' : '🎨'}</span>
|
|
73
|
+
<div className="min-w-0 flex-1">
|
|
74
|
+
<h2 className="text-lg sm:text-xl font-bold text-foreground truncate">{item.name}</h2>
|
|
75
|
+
<p className="text-xs sm:text-sm text-muted-foreground mt-1 line-clamp-2">
|
|
76
|
+
{item.description || 'No description available'}
|
|
77
|
+
</p>
|
|
78
|
+
</div>
|
|
79
|
+
</div>
|
|
80
|
+
|
|
81
|
+
<div className="flex items-center gap-2 w-full sm:w-auto">
|
|
82
|
+
{type === 'tool' && (
|
|
83
|
+
<button
|
|
84
|
+
onClick={handleUseInChat}
|
|
85
|
+
className="btn btn-primary flex items-center gap-2 flex-1 sm:flex-none text-sm"
|
|
86
|
+
>
|
|
87
|
+
<MessageSquare className="w-4 h-4" />
|
|
88
|
+
<span className="hidden sm:inline">Use in Chat</span>
|
|
89
|
+
<span className="sm:hidden">Chat</span>
|
|
90
|
+
</button>
|
|
91
|
+
)}
|
|
92
|
+
<button
|
|
93
|
+
onClick={closeEnlargeModal}
|
|
94
|
+
className="btn btn-ghost w-10 h-10 p-0 flex items-center justify-center flex-shrink-0"
|
|
95
|
+
aria-label="Close"
|
|
96
|
+
>
|
|
97
|
+
<X className="w-5 h-5" />
|
|
98
|
+
</button>
|
|
99
|
+
</div>
|
|
100
|
+
</div>
|
|
101
|
+
|
|
102
|
+
{/* Widget Content */}
|
|
103
|
+
<div className="h-[calc(100%-120px)] sm:h-[calc(100%-100px)] p-3 sm:p-6 overflow-auto bg-background">
|
|
104
|
+
{componentUri && widgetData ? (
|
|
105
|
+
<div className="w-full h-full rounded-xl overflow-hidden border border-border shadow-inner">
|
|
106
|
+
<WidgetRenderer uri={componentUri} data={widgetData} className="w-full h-full" />
|
|
107
|
+
</div>
|
|
108
|
+
) : (
|
|
109
|
+
<div className="flex items-center justify-center h-full">
|
|
110
|
+
<div className="text-center">
|
|
111
|
+
<p className="text-muted-foreground mb-2">
|
|
112
|
+
{!componentUri ? 'No widget URI available' : 'No example data available'}
|
|
113
|
+
</p>
|
|
114
|
+
<p className="text-xs text-muted-foreground">
|
|
115
|
+
{type === 'tool' ? 'This tool does not have a UI widget attached' : 'No preview data found'}
|
|
116
|
+
</p>
|
|
117
|
+
</div>
|
|
118
|
+
</div>
|
|
119
|
+
)}
|
|
120
|
+
</div>
|
|
121
|
+
|
|
122
|
+
{/* Metadata Footer */}
|
|
123
|
+
{type === 'tool' && item.inputSchema && (
|
|
124
|
+
<div className="absolute bottom-0 left-0 right-0 p-4 border-t border-border glass">
|
|
125
|
+
<details className="text-sm">
|
|
126
|
+
<summary className="cursor-pointer text-muted-foreground hover:text-foreground font-medium">
|
|
127
|
+
Input Schema
|
|
128
|
+
</summary>
|
|
129
|
+
<pre className="mt-2 p-3 bg-muted rounded-lg text-xs text-foreground overflow-auto max-h-40 font-mono">
|
|
130
|
+
{JSON.stringify(item.inputSchema, null, 2)}
|
|
131
|
+
</pre>
|
|
132
|
+
</details>
|
|
133
|
+
</div>
|
|
134
|
+
)}
|
|
135
|
+
</div>
|
|
136
|
+
</div>
|
|
137
|
+
);
|
|
138
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState } from 'react';
|
|
4
|
+
import { Copy, Check, ChevronDown, ChevronRight } from 'lucide-react';
|
|
5
|
+
|
|
6
|
+
interface LogMessageProps {
|
|
7
|
+
message: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function LogMessage({ message }: LogMessageProps) {
|
|
11
|
+
const [copied, setCopied] = useState(false);
|
|
12
|
+
const [isExpanded, setIsExpanded] = useState(true);
|
|
13
|
+
|
|
14
|
+
const handleCopy = async (text: string) => {
|
|
15
|
+
try {
|
|
16
|
+
await navigator.clipboard.writeText(text);
|
|
17
|
+
setCopied(true);
|
|
18
|
+
setTimeout(() => setCopied(false), 2000);
|
|
19
|
+
} catch (error) {
|
|
20
|
+
console.error('Failed to copy:', error);
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
// Try to parse and format JSON
|
|
25
|
+
const tryParseJSON = (text: string): { isJSON: boolean; formatted?: string; parsed?: any } => {
|
|
26
|
+
try {
|
|
27
|
+
const trimmed = text.trim();
|
|
28
|
+
if (!trimmed.startsWith('{') && !trimmed.startsWith('[')) {
|
|
29
|
+
return { isJSON: false };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const parsed = JSON.parse(trimmed);
|
|
33
|
+
|
|
34
|
+
// Pretty print with proper indentation
|
|
35
|
+
const formatted = JSON.stringify(parsed, (key, value) => {
|
|
36
|
+
// If value is a string that looks like JSON, try to parse it
|
|
37
|
+
if (typeof value === 'string' && (value.trim().startsWith('{') || value.trim().startsWith('['))) {
|
|
38
|
+
try {
|
|
39
|
+
return JSON.parse(value);
|
|
40
|
+
} catch {
|
|
41
|
+
return value;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return value;
|
|
45
|
+
}, 2);
|
|
46
|
+
|
|
47
|
+
return { isJSON: true, formatted, parsed };
|
|
48
|
+
} catch {
|
|
49
|
+
return { isJSON: false };
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
// Syntax highlight JSON
|
|
54
|
+
const highlightJSON = (jsonString: string) => {
|
|
55
|
+
return jsonString
|
|
56
|
+
.replace(/("(\\u[a-zA-Z0-9]{4}|\\[^u]|[^\\"])*"(\s*:)?)/g, (match) => {
|
|
57
|
+
let cls = 'json-string';
|
|
58
|
+
if (/:$/.test(match)) {
|
|
59
|
+
cls = 'json-key';
|
|
60
|
+
}
|
|
61
|
+
return `<span class="${cls}">${match}</span>`;
|
|
62
|
+
})
|
|
63
|
+
.replace(/\b(true|false|null)\b/g, '<span class="json-boolean">$1</span>')
|
|
64
|
+
.replace(/\b(-?\d+\.?\d*)\b/g, '<span class="json-number">$1</span>')
|
|
65
|
+
.replace(/([{}[\],])/g, '<span class="json-punctuation">$1</span>');
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const result = tryParseJSON(message);
|
|
69
|
+
|
|
70
|
+
if (!result.isJSON) {
|
|
71
|
+
// Plain text message
|
|
72
|
+
return <span className="text-slate-300 break-all">{message}</span>;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// JSON message - collapsible with syntax highlighting
|
|
76
|
+
return (
|
|
77
|
+
<div className="flex-1 min-w-0">
|
|
78
|
+
<div className="flex items-center gap-2 mb-1">
|
|
79
|
+
<button
|
|
80
|
+
onClick={() => setIsExpanded(!isExpanded)}
|
|
81
|
+
className="text-slate-400 hover:text-slate-200 transition-colors"
|
|
82
|
+
>
|
|
83
|
+
{isExpanded ? (
|
|
84
|
+
<ChevronDown className="w-4 h-4" />
|
|
85
|
+
) : (
|
|
86
|
+
<ChevronRight className="w-4 h-4" />
|
|
87
|
+
)}
|
|
88
|
+
</button>
|
|
89
|
+
<span className="text-slate-500 text-xs font-semibold">JSON Response</span>
|
|
90
|
+
<button
|
|
91
|
+
onClick={() => handleCopy(result.formatted || message)}
|
|
92
|
+
className="ml-auto text-slate-500 hover:text-slate-300 transition-colors p-1 rounded hover:bg-slate-800"
|
|
93
|
+
title={copied ? 'Copied!' : 'Copy JSON'}
|
|
94
|
+
>
|
|
95
|
+
{copied ? (
|
|
96
|
+
<Check className="w-3.5 h-3.5 text-green-400" />
|
|
97
|
+
) : (
|
|
98
|
+
<Copy className="w-3.5 h-3.5" />
|
|
99
|
+
)}
|
|
100
|
+
</button>
|
|
101
|
+
</div>
|
|
102
|
+
|
|
103
|
+
{isExpanded && (
|
|
104
|
+
<div className="json-container">
|
|
105
|
+
<pre
|
|
106
|
+
className="json-content"
|
|
107
|
+
dangerouslySetInnerHTML={{ __html: highlightJSON(result.formatted || message) }}
|
|
108
|
+
/>
|
|
109
|
+
</div>
|
|
110
|
+
)}
|
|
111
|
+
|
|
112
|
+
<style jsx>{`
|
|
113
|
+
.json-container {
|
|
114
|
+
background: rgb(15, 23, 42);
|
|
115
|
+
border: 1px solid rgb(51, 65, 85);
|
|
116
|
+
border-radius: 0.5rem;
|
|
117
|
+
padding: 0.75rem;
|
|
118
|
+
overflow-x: auto;
|
|
119
|
+
margin-top: 0.5rem;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
.json-content {
|
|
123
|
+
margin: 0;
|
|
124
|
+
font-family: 'JetBrains Mono', 'Courier New', monospace;
|
|
125
|
+
font-size: 0.75rem;
|
|
126
|
+
line-height: 1.5;
|
|
127
|
+
color: rgb(226, 232, 240);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
:global(.json-key) {
|
|
131
|
+
color: rgb(96, 165, 250); /* Blue */
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
:global(.json-string) {
|
|
135
|
+
color: rgb(134, 239, 172); /* Green */
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
:global(.json-number) {
|
|
139
|
+
color: rgb(251, 146, 60); /* Orange */
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
:global(.json-boolean) {
|
|
143
|
+
color: rgb(167, 139, 250); /* Purple */
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
:global(.json-punctuation) {
|
|
147
|
+
color: rgb(148, 163, 184); /* Gray */
|
|
148
|
+
}
|
|
149
|
+
`}</style>
|
|
150
|
+
</div>
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
|
|
@@ -0,0 +1,410 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState } from 'react';
|
|
4
|
+
import { Copy, Check } from 'lucide-react';
|
|
5
|
+
|
|
6
|
+
interface MarkdownRendererProps {
|
|
7
|
+
content: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function MarkdownRenderer({ content }: MarkdownRendererProps) {
|
|
11
|
+
const [copied, setCopied] = useState(false);
|
|
12
|
+
const [copiedCode, setCopiedCode] = useState<string | null>(null);
|
|
13
|
+
|
|
14
|
+
const handleCopy = async () => {
|
|
15
|
+
try {
|
|
16
|
+
await navigator.clipboard.writeText(content);
|
|
17
|
+
setCopied(true);
|
|
18
|
+
setTimeout(() => setCopied(false), 2000);
|
|
19
|
+
} catch (error) {
|
|
20
|
+
console.error('Failed to copy:', error);
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const handleCopyCode = async (code: string, blockId: string) => {
|
|
25
|
+
try {
|
|
26
|
+
await navigator.clipboard.writeText(code);
|
|
27
|
+
setCopiedCode(blockId);
|
|
28
|
+
setTimeout(() => setCopiedCode(null), 2000);
|
|
29
|
+
} catch (error) {
|
|
30
|
+
console.error('Failed to copy code:', error);
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
// Simple syntax highlighting for common languages
|
|
35
|
+
const highlightCode = (code: string, language: string) => {
|
|
36
|
+
let highlighted = escapeHtml(code);
|
|
37
|
+
|
|
38
|
+
// Keywords for different languages
|
|
39
|
+
const keywords: Record<string, string[]> = {
|
|
40
|
+
javascript: ['function', 'const', 'let', 'var', 'return', 'if', 'else', 'for', 'while', 'class', 'import', 'export', 'from', 'async', 'await', 'new', 'this', 'try', 'catch'],
|
|
41
|
+
typescript: ['function', 'const', 'let', 'var', 'return', 'if', 'else', 'for', 'while', 'class', 'import', 'export', 'from', 'async', 'await', 'interface', 'type', 'enum', 'new', 'this', 'try', 'catch'],
|
|
42
|
+
python: ['def', 'class', 'return', 'if', 'else', 'elif', 'for', 'while', 'import', 'from', 'try', 'except', 'with', 'as', 'pass', 'break', 'continue', 'lambda', 'yield'],
|
|
43
|
+
java: ['public', 'private', 'protected', 'class', 'interface', 'void', 'return', 'if', 'else', 'for', 'while', 'new', 'this', 'static', 'final', 'try', 'catch'],
|
|
44
|
+
go: ['func', 'var', 'const', 'return', 'if', 'else', 'for', 'range', 'struct', 'interface', 'type', 'package', 'import', 'go', 'defer', 'chan'],
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const lang = language.toLowerCase();
|
|
48
|
+
const langKeywords = keywords[lang] || keywords['javascript'];
|
|
49
|
+
|
|
50
|
+
// Comments
|
|
51
|
+
highlighted = highlighted.replace(/(\/\/.*$|\/\*[\s\S]*?\*\/)/gm, '<span class="syntax-comment">$1</span>');
|
|
52
|
+
highlighted = highlighted.replace(/(#.*$)/gm, '<span class="syntax-comment">$1</span>');
|
|
53
|
+
|
|
54
|
+
// Strings
|
|
55
|
+
highlighted = highlighted.replace(/("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|`(?:[^`\\]|\\.)*`)/g, '<span class="syntax-string">$1</span>');
|
|
56
|
+
|
|
57
|
+
// Numbers
|
|
58
|
+
highlighted = highlighted.replace(/\b(\d+\.?\d*)\b/g, '<span class="syntax-number">$1</span>');
|
|
59
|
+
|
|
60
|
+
// Keywords
|
|
61
|
+
langKeywords.forEach(keyword => {
|
|
62
|
+
const regex = new RegExp(`\\b${keyword}\\b`, 'g');
|
|
63
|
+
highlighted = highlighted.replace(regex, `<span class="syntax-keyword">${keyword}</span>`);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
// Function calls
|
|
67
|
+
highlighted = highlighted.replace(/\b([a-zA-Z_][a-zA-Z0-9_]*)\s*\(/g, '<span class="syntax-function">$1</span>(');
|
|
68
|
+
|
|
69
|
+
return highlighted;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
// Simple markdown rendering using regex (basic implementation)
|
|
73
|
+
const renderMarkdown = (text: string) => {
|
|
74
|
+
let html = text;
|
|
75
|
+
let codeBlockCounter = 0;
|
|
76
|
+
|
|
77
|
+
// Code blocks (```language\ncode\n```)
|
|
78
|
+
html = html.replace(/```(\w+)?\n([\s\S]*?)```/g, (_, lang, code) => {
|
|
79
|
+
const blockId = `code-block-${codeBlockCounter++}`;
|
|
80
|
+
const language = lang || 'text';
|
|
81
|
+
const trimmedCode = code.trim();
|
|
82
|
+
const highlightedCode = language !== 'text' ? highlightCode(trimmedCode, language) : escapeHtml(trimmedCode);
|
|
83
|
+
|
|
84
|
+
return `<div class="code-block-wrapper">
|
|
85
|
+
<div class="code-block-header">
|
|
86
|
+
<span class="code-language">${language}</span>
|
|
87
|
+
<button class="copy-code-btn" data-code="${escapeHtml(trimmedCode)}" data-block-id="${blockId}" onclick="window.copyCodeBlock('${blockId}', \`${escapeHtml(trimmedCode).replace(/`/g, '\\`')}\`)">
|
|
88
|
+
<svg class="copy-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
89
|
+
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
|
|
90
|
+
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
|
|
91
|
+
</svg>
|
|
92
|
+
<span class="copy-text">Copy code</span>
|
|
93
|
+
</button>
|
|
94
|
+
</div>
|
|
95
|
+
<pre class="code-block"><code class="language-${language}">${highlightedCode}</code></pre>
|
|
96
|
+
</div>`;
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
// Inline code (`code`)
|
|
100
|
+
html = html.replace(/`([^`]+)`/g, '<code class="inline-code">$1</code>');
|
|
101
|
+
|
|
102
|
+
// Bold (**text** or __text__)
|
|
103
|
+
html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
|
|
104
|
+
html = html.replace(/__(.+?)__/g, '<strong>$1</strong>');
|
|
105
|
+
|
|
106
|
+
// Italic (*text* or _text_)
|
|
107
|
+
html = html.replace(/\*(.+?)\*/g, '<em>$1</em>');
|
|
108
|
+
html = html.replace(/_(.+?)_/g, '<em>$1</em>');
|
|
109
|
+
|
|
110
|
+
// Headers (# text)
|
|
111
|
+
html = html.replace(/^### (.+)$/gm, '<h3 class="md-h3">$1</h3>');
|
|
112
|
+
html = html.replace(/^## (.+)$/gm, '<h2 class="md-h2">$1</h2>');
|
|
113
|
+
html = html.replace(/^# (.+)$/gm, '<h1 class="md-h1">$1</h1>');
|
|
114
|
+
|
|
115
|
+
// Links ([text](url))
|
|
116
|
+
html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" class="md-link" target="_blank" rel="noopener noreferrer">$1</a>');
|
|
117
|
+
|
|
118
|
+
// Unordered lists (- item or * item)
|
|
119
|
+
html = html.replace(/^[\*\-] (.+)$/gm, '<li class="md-li">$1</li>');
|
|
120
|
+
html = html.replace(/(<li class="md-li">.*<\/li>\n?)+/g, '<ul class="md-ul">$&</ul>');
|
|
121
|
+
|
|
122
|
+
// Ordered lists (1. item)
|
|
123
|
+
html = html.replace(/^\d+\. (.+)$/gm, '<li class="md-li">$1</li>');
|
|
124
|
+
|
|
125
|
+
// Blockquotes (> text)
|
|
126
|
+
html = html.replace(/^> (.+)$/gm, '<blockquote class="md-blockquote">$1</blockquote>');
|
|
127
|
+
|
|
128
|
+
// Horizontal rule (---)
|
|
129
|
+
html = html.replace(/^---$/gm, '<hr class="md-hr" />');
|
|
130
|
+
|
|
131
|
+
// Line breaks
|
|
132
|
+
html = html.replace(/\n/g, '<br />');
|
|
133
|
+
|
|
134
|
+
return html;
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
const escapeHtml = (text: string) => {
|
|
138
|
+
const div = document.createElement('div');
|
|
139
|
+
div.textContent = text;
|
|
140
|
+
return div.innerHTML;
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
// Check if content has markdown syntax
|
|
144
|
+
const hasMarkdown = (text: string) => {
|
|
145
|
+
const markdownPatterns = [
|
|
146
|
+
/```[\s\S]*?```/, // Code blocks
|
|
147
|
+
/`[^`]+`/, // Inline code
|
|
148
|
+
/\*\*[^*]+\*\*/, // Bold
|
|
149
|
+
/__[^_]+__/, // Bold
|
|
150
|
+
/\*[^*]+\*/, // Italic
|
|
151
|
+
/_[^_]+_/, // Italic
|
|
152
|
+
/^#{1,6} .+$/m, // Headers
|
|
153
|
+
/\[.+\]\(.+\)/, // Links
|
|
154
|
+
/^[\*\-] .+$/m, // Unordered lists
|
|
155
|
+
/^\d+\. .+$/m, // Ordered lists
|
|
156
|
+
/^> .+$/m, // Blockquotes
|
|
157
|
+
];
|
|
158
|
+
|
|
159
|
+
return markdownPatterns.some(pattern => pattern.test(text));
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
const isMarkdown = hasMarkdown(content);
|
|
163
|
+
|
|
164
|
+
if (!isMarkdown) {
|
|
165
|
+
// No markdown detected, render as plain text
|
|
166
|
+
return <div className="whitespace-pre-wrap">{content}</div>;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Set up global copy function for code blocks
|
|
170
|
+
if (typeof window !== 'undefined') {
|
|
171
|
+
(window as any).copyCodeBlock = async (blockId: string, code: string) => {
|
|
172
|
+
try {
|
|
173
|
+
await navigator.clipboard.writeText(code);
|
|
174
|
+
setCopiedCode(blockId);
|
|
175
|
+
setTimeout(() => setCopiedCode(null), 2000);
|
|
176
|
+
} catch (error) {
|
|
177
|
+
console.error('Failed to copy code:', error);
|
|
178
|
+
}
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return (
|
|
183
|
+
<div className="relative markdown-container">
|
|
184
|
+
{/* Copy All Button */}
|
|
185
|
+
<button
|
|
186
|
+
onClick={handleCopy}
|
|
187
|
+
className="absolute top-3 right-3 z-10 px-3 py-1.5 rounded-lg bg-slate-700/80 hover:bg-slate-700 border border-slate-600/50 transition-all text-xs font-medium flex items-center gap-2"
|
|
188
|
+
title={copied ? 'Copied!' : 'Copy all content'}
|
|
189
|
+
>
|
|
190
|
+
{copied ? (
|
|
191
|
+
<>
|
|
192
|
+
<Check className="w-3.5 h-3.5 text-green-400" />
|
|
193
|
+
<span className="text-green-400">Copied!</span>
|
|
194
|
+
</>
|
|
195
|
+
) : (
|
|
196
|
+
<>
|
|
197
|
+
<Copy className="w-3.5 h-3.5 text-slate-300" />
|
|
198
|
+
<span className="text-slate-300">Copy all</span>
|
|
199
|
+
</>
|
|
200
|
+
)}
|
|
201
|
+
</button>
|
|
202
|
+
|
|
203
|
+
{/* Markdown Content */}
|
|
204
|
+
<div
|
|
205
|
+
className="markdown-content"
|
|
206
|
+
dangerouslySetInnerHTML={{ __html: renderMarkdown(content) }}
|
|
207
|
+
/>
|
|
208
|
+
|
|
209
|
+
<style jsx>{`
|
|
210
|
+
.markdown-container {
|
|
211
|
+
position: relative;
|
|
212
|
+
padding: 1.25rem;
|
|
213
|
+
background: linear-gradient(to bottom, rgb(30, 41, 59), rgb(15, 23, 42));
|
|
214
|
+
border: 1px solid rgb(51, 65, 85);
|
|
215
|
+
border-radius: 0.75rem;
|
|
216
|
+
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.3), 0 2px 4px -1px rgba(0, 0, 0, 0.2);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
:global(.markdown-content) {
|
|
220
|
+
font-size: 0.875rem;
|
|
221
|
+
line-height: 1.625;
|
|
222
|
+
color: rgb(226, 232, 240);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
:global(.code-block-wrapper) {
|
|
226
|
+
position: relative;
|
|
227
|
+
margin: 1.5rem 0;
|
|
228
|
+
border-radius: 0.5rem;
|
|
229
|
+
overflow: hidden;
|
|
230
|
+
background: rgb(15, 23, 42);
|
|
231
|
+
border: 1px solid rgb(51, 65, 85);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
:global(.code-block-header) {
|
|
235
|
+
display: flex;
|
|
236
|
+
align-items: center;
|
|
237
|
+
justify-content: space-between;
|
|
238
|
+
padding: 0.5rem 1rem;
|
|
239
|
+
background: rgb(30, 41, 59);
|
|
240
|
+
border-bottom: 1px solid rgb(51, 65, 85);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
:global(.code-language) {
|
|
244
|
+
font-size: 0.75rem;
|
|
245
|
+
font-weight: 500;
|
|
246
|
+
color: rgb(148, 163, 184);
|
|
247
|
+
text-transform: lowercase;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
:global(.copy-code-btn) {
|
|
251
|
+
display: flex;
|
|
252
|
+
align-items: center;
|
|
253
|
+
gap: 0.375rem;
|
|
254
|
+
padding: 0.25rem 0.625rem;
|
|
255
|
+
font-size: 0.75rem;
|
|
256
|
+
color: rgb(203, 213, 225);
|
|
257
|
+
background: transparent;
|
|
258
|
+
border: none;
|
|
259
|
+
border-radius: 0.375rem;
|
|
260
|
+
cursor: pointer;
|
|
261
|
+
transition: all 0.2s;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
:global(.copy-code-btn:hover) {
|
|
265
|
+
background: rgb(51, 65, 85);
|
|
266
|
+
color: rgb(226, 232, 240);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
:global(.copy-icon) {
|
|
270
|
+
width: 14px;
|
|
271
|
+
height: 14px;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
:global(.markdown-content .md-h1) {
|
|
275
|
+
font-size: 1.5rem;
|
|
276
|
+
font-weight: 700;
|
|
277
|
+
margin-top: 1.5rem;
|
|
278
|
+
margin-bottom: 0.75rem;
|
|
279
|
+
color: rgb(241, 245, 249);
|
|
280
|
+
border-bottom: 1px solid rgb(51, 65, 85);
|
|
281
|
+
padding-bottom: 0.5rem;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
:global(.markdown-content .md-h2) {
|
|
285
|
+
font-size: 1.25rem;
|
|
286
|
+
font-weight: 700;
|
|
287
|
+
margin-top: 1.25rem;
|
|
288
|
+
margin-bottom: 0.5rem;
|
|
289
|
+
color: rgb(226, 232, 240);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
:global(.markdown-content .md-h3) {
|
|
293
|
+
font-size: 1.125rem;
|
|
294
|
+
font-weight: 600;
|
|
295
|
+
margin-top: 1rem;
|
|
296
|
+
margin-bottom: 0.5rem;
|
|
297
|
+
color: rgb(203, 213, 225);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
:global(.markdown-content .code-block) {
|
|
301
|
+
background: rgb(15, 23, 42);
|
|
302
|
+
padding: 1rem;
|
|
303
|
+
margin: 0;
|
|
304
|
+
overflow-x: auto;
|
|
305
|
+
font-family: 'JetBrains Mono', 'Courier New', monospace;
|
|
306
|
+
font-size: 0.8125rem;
|
|
307
|
+
line-height: 1.6;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
:global(.markdown-content .code-block code) {
|
|
311
|
+
background: transparent;
|
|
312
|
+
padding: 0;
|
|
313
|
+
border: none;
|
|
314
|
+
color: rgb(226, 232, 240);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/* Syntax highlighting colors */
|
|
318
|
+
:global(.syntax-keyword) {
|
|
319
|
+
color: rgb(96, 165, 250); /* Blue */
|
|
320
|
+
font-weight: 500;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
:global(.syntax-string) {
|
|
324
|
+
color: rgb(134, 239, 172); /* Green */
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
:global(.syntax-number) {
|
|
328
|
+
color: rgb(251, 146, 60); /* Orange */
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
:global(.syntax-comment) {
|
|
332
|
+
color: rgb(100, 116, 139); /* Gray */
|
|
333
|
+
font-style: italic;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
:global(.syntax-function) {
|
|
337
|
+
color: rgb(253, 224, 71); /* Yellow */
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
:global(.markdown-content .inline-code) {
|
|
341
|
+
background: rgb(30, 41, 59);
|
|
342
|
+
color: rgb(251, 146, 60);
|
|
343
|
+
padding: 0.125rem 0.5rem;
|
|
344
|
+
border-radius: 0.25rem;
|
|
345
|
+
font-family: 'JetBrains Mono', 'Courier New', monospace;
|
|
346
|
+
font-size: 0.875em;
|
|
347
|
+
border: 1px solid rgb(51, 65, 85);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
:global(.markdown-content strong) {
|
|
351
|
+
font-weight: 600;
|
|
352
|
+
color: rgb(241, 245, 249);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
:global(.markdown-content em) {
|
|
356
|
+
font-style: italic;
|
|
357
|
+
color: rgb(203, 213, 225);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
:global(.markdown-content .md-link) {
|
|
361
|
+
color: rgb(96, 165, 250);
|
|
362
|
+
text-decoration: underline;
|
|
363
|
+
text-decoration-color: transparent;
|
|
364
|
+
transition: text-decoration-color 0.2s;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
:global(.markdown-content .md-link:hover) {
|
|
368
|
+
text-decoration-color: rgb(96, 165, 250);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
:global(.markdown-content .md-ul) {
|
|
372
|
+
list-style-type: disc;
|
|
373
|
+
margin-left: 1.5rem;
|
|
374
|
+
margin-top: 0.5rem;
|
|
375
|
+
margin-bottom: 0.5rem;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
:global(.markdown-content .md-li) {
|
|
379
|
+
margin-top: 0.25rem;
|
|
380
|
+
margin-bottom: 0.25rem;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
:global(.markdown-content .md-blockquote) {
|
|
384
|
+
border-left: 3px solid rgb(96, 165, 250);
|
|
385
|
+
padding-left: 1rem;
|
|
386
|
+
padding-top: 0.5rem;
|
|
387
|
+
padding-bottom: 0.5rem;
|
|
388
|
+
margin: 1rem 0;
|
|
389
|
+
background: rgb(30, 41, 59);
|
|
390
|
+
color: rgb(148, 163, 184);
|
|
391
|
+
font-style: italic;
|
|
392
|
+
border-radius: 0 0.25rem 0.25rem 0;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
:global(.markdown-content .md-hr) {
|
|
396
|
+
border: none;
|
|
397
|
+
border-top: 1px solid rgb(51, 65, 85);
|
|
398
|
+
margin: 1.5rem 0;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
:global(.markdown-content br) {
|
|
402
|
+
display: block;
|
|
403
|
+
content: "";
|
|
404
|
+
margin-top: 0.5rem;
|
|
405
|
+
}
|
|
406
|
+
`}</style>
|
|
407
|
+
</div>
|
|
408
|
+
);
|
|
409
|
+
}
|
|
410
|
+
|