@codrstudio/openclaude-chat 0.1.0 → 0.1.9
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/dist/components/StreamingIndicator.js +5 -5
- package/dist/display/DisplayReactRenderer.js +12 -12
- package/dist/display/react-sandbox/bootstrap.js +150 -150
- package/dist/styles.css +1 -2
- package/package.json +64 -61
- package/src/components/Chat.tsx +107 -107
- package/src/components/ErrorNote.tsx +35 -35
- package/src/components/LazyRender.tsx +42 -42
- package/src/components/Markdown.tsx +114 -114
- package/src/components/MessageBubble.tsx +107 -107
- package/src/components/MessageInput.tsx +421 -421
- package/src/components/MessageList.tsx +153 -153
- package/src/components/StreamingIndicator.tsx +19 -19
- package/src/display/AlertRenderer.tsx +23 -23
- package/src/display/CarouselRenderer.tsx +141 -141
- package/src/display/ChartRenderer.tsx +195 -195
- package/src/display/ChoiceButtonsRenderer.tsx +114 -114
- package/src/display/CodeBlockRenderer.tsx +49 -49
- package/src/display/ComparisonTableRenderer.tsx +132 -132
- package/src/display/DataTableRenderer.tsx +144 -144
- package/src/display/DisplayReactRenderer.tsx +269 -269
- package/src/display/FileCardRenderer.tsx +55 -55
- package/src/display/GalleryRenderer.tsx +65 -65
- package/src/display/ImageViewerRenderer.tsx +114 -114
- package/src/display/LinkPreviewRenderer.tsx +74 -74
- package/src/display/MapViewRenderer.tsx +75 -75
- package/src/display/MetricCardRenderer.tsx +29 -29
- package/src/display/PriceHighlightRenderer.tsx +62 -62
- package/src/display/ProductCardRenderer.tsx +112 -112
- package/src/display/ProgressStepsRenderer.tsx +59 -59
- package/src/display/SourcesListRenderer.tsx +47 -47
- package/src/display/SpreadsheetRenderer.tsx +86 -86
- package/src/display/StepTimelineRenderer.tsx +75 -75
- package/src/display/index.ts +21 -21
- package/src/display/react-sandbox/bootstrap.ts +155 -155
- package/src/display/registry.ts +84 -84
- package/src/display/sdk-types.ts +217 -217
- package/src/hooks/ChatProvider.tsx +21 -21
- package/src/hooks/useIsMobile.ts +15 -15
- package/src/hooks/useOpenClaudeChat.ts +476 -476
- package/src/index.ts +76 -76
- package/src/lib/utils.ts +6 -6
- package/src/parts/PartErrorBoundary.tsx +51 -51
- package/src/parts/PartRenderer.tsx +145 -145
- package/src/parts/ReasoningBlock.tsx +41 -41
- package/src/parts/ToolActivity.tsx +78 -78
- package/src/parts/ToolResult.tsx +79 -79
- package/src/styles.css +2 -2
- package/src/types.ts +41 -41
- package/src/ui/alert.tsx +77 -77
- package/src/ui/badge.tsx +36 -36
- package/src/ui/button.tsx +54 -54
- package/src/ui/card.tsx +68 -68
- package/src/ui/collapsible.tsx +7 -7
- package/src/ui/dialog.tsx +122 -122
- package/src/ui/dropdown-menu.tsx +76 -76
- package/src/ui/input.tsx +24 -24
- package/src/ui/progress.tsx +36 -36
- package/src/ui/scroll-area.tsx +48 -48
- package/src/ui/separator.tsx +31 -31
- package/src/ui/skeleton.tsx +9 -9
- package/src/ui/table.tsx +114 -114
|
@@ -1,269 +1,269 @@
|
|
|
1
|
-
import { memo, useEffect, useMemo, useRef, useState } from "react"
|
|
2
|
-
import { transform as sucraseTransform } from "sucrase"
|
|
3
|
-
import { SANDBOX_BOOTSTRAP } from "./react-sandbox/bootstrap.js"
|
|
4
|
-
|
|
5
|
-
// ─── Types ────────────────────────────────────────────────────────────────────
|
|
6
|
-
|
|
7
|
-
const WHITELIST_MODULES = [
|
|
8
|
-
"react",
|
|
9
|
-
"react-dom",
|
|
10
|
-
"react-dom/client",
|
|
11
|
-
"framer-motion",
|
|
12
|
-
"recharts",
|
|
13
|
-
"lucide-react",
|
|
14
|
-
] as const
|
|
15
|
-
|
|
16
|
-
type WhitelistedModule = (typeof WHITELIST_MODULES)[number]
|
|
17
|
-
|
|
18
|
-
interface ImportDecl {
|
|
19
|
-
module: WhitelistedModule
|
|
20
|
-
symbols: string[]
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
interface Layout {
|
|
24
|
-
height?: number | "auto"
|
|
25
|
-
aspectRatio?: string
|
|
26
|
-
maxWidth?: number
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
export interface DisplayReactRendererProps {
|
|
30
|
-
// Flattened DisplayReactSchema payload (PartRenderer spreads args as props)
|
|
31
|
-
version?: "1"
|
|
32
|
-
title?: string
|
|
33
|
-
description?: string
|
|
34
|
-
code: string
|
|
35
|
-
language?: "jsx" | "tsx"
|
|
36
|
-
entry?: "default"
|
|
37
|
-
imports?: ImportDecl[]
|
|
38
|
-
initialProps?: Record<string, unknown>
|
|
39
|
-
layout?: Layout
|
|
40
|
-
theme?: "light" | "dark" | "auto"
|
|
41
|
-
// Sandbox bundle base — consumers can override (default: "/sandbox")
|
|
42
|
-
sandboxBase?: string
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
// ─── Size limits (mirror SDK schema) ──────────────────────────────────────────
|
|
46
|
-
|
|
47
|
-
const MAX_CODE_BYTES = 8 * 1024
|
|
48
|
-
const MAX_PROPS_BYTES = 32 * 1024
|
|
49
|
-
|
|
50
|
-
// ─── Module-level bundle cache ────────────────────────────────────────────────
|
|
51
|
-
|
|
52
|
-
interface SandboxBundles {
|
|
53
|
-
react: string
|
|
54
|
-
reactDom: string
|
|
55
|
-
framerMotion: string
|
|
56
|
-
recharts: string
|
|
57
|
-
lucideReact: string
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
const bundleCache = new Map<string, Promise<SandboxBundles>>()
|
|
61
|
-
|
|
62
|
-
function loadBundles(base: string): Promise<SandboxBundles> {
|
|
63
|
-
const key = base
|
|
64
|
-
const cached = bundleCache.get(key)
|
|
65
|
-
if (cached) return cached
|
|
66
|
-
const p = (async () => {
|
|
67
|
-
const [react, reactDom, framerMotion, recharts, lucideReact] = await Promise.all([
|
|
68
|
-
fetch(`${base}/react.js`).then((r) => r.text()),
|
|
69
|
-
fetch(`${base}/react-dom.js`).then((r) => r.text()),
|
|
70
|
-
fetch(`${base}/framer-motion.js`).then((r) => r.text()),
|
|
71
|
-
fetch(`${base}/recharts.js`).then((r) => r.text()),
|
|
72
|
-
fetch(`${base}/lucide-react.js`).then((r) => r.text()),
|
|
73
|
-
])
|
|
74
|
-
return { react, reactDom, framerMotion, recharts, lucideReact }
|
|
75
|
-
})()
|
|
76
|
-
bundleCache.set(key, p)
|
|
77
|
-
return p
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
// ─── Validation helpers ──────────────────────────────────────────────────────
|
|
81
|
-
|
|
82
|
-
function byteLength(s: string): number {
|
|
83
|
-
return new TextEncoder().encode(s).length
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
function validatePayload(props: DisplayReactRendererProps): string | null {
|
|
87
|
-
if (!props.code || typeof props.code !== "string") return "missing code"
|
|
88
|
-
if (byteLength(props.code) > MAX_CODE_BYTES) return `code exceeds ${MAX_CODE_BYTES} bytes`
|
|
89
|
-
if (props.initialProps) {
|
|
90
|
-
try {
|
|
91
|
-
const s = JSON.stringify(props.initialProps)
|
|
92
|
-
if (byteLength(s) > MAX_PROPS_BYTES) return `initialProps exceeds ${MAX_PROPS_BYTES} bytes`
|
|
93
|
-
} catch {
|
|
94
|
-
return "initialProps not JSON-serializable"
|
|
95
|
-
}
|
|
96
|
-
}
|
|
97
|
-
if (props.imports) {
|
|
98
|
-
for (const imp of props.imports) {
|
|
99
|
-
if (!WHITELIST_MODULES.includes(imp.module)) {
|
|
100
|
-
return `import not in whitelist: ${imp.module}`
|
|
101
|
-
}
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
return null
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
// ─── Transpile user code ─────────────────────────────────────────────────────
|
|
108
|
-
|
|
109
|
-
function compile(code: string, language: "jsx" | "tsx"): { ok: true; code: string } | { ok: false; error: string } {
|
|
110
|
-
try {
|
|
111
|
-
const transforms: ("jsx" | "typescript" | "imports")[] = ["jsx", "imports"]
|
|
112
|
-
if (language === "tsx") transforms.unshift("typescript")
|
|
113
|
-
const out = sucraseTransform(code, {
|
|
114
|
-
transforms,
|
|
115
|
-
production: true,
|
|
116
|
-
jsxRuntime: "classic",
|
|
117
|
-
})
|
|
118
|
-
return { ok: true, code: out.code }
|
|
119
|
-
} catch (e) {
|
|
120
|
-
return { ok: false, error: e instanceof Error ? e.message : String(e) }
|
|
121
|
-
}
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
// ─── Build srcdoc ────────────────────────────────────────────────────────────
|
|
125
|
-
|
|
126
|
-
function buildSrcDoc(bundles: SandboxBundles): string {
|
|
127
|
-
return `<!doctype html><html><head><meta charset="utf-8"><style>
|
|
128
|
-
html,body{margin:0;padding:0;}
|
|
129
|
-
body{font:14px/1.5 system-ui,-apple-system,sans-serif;padding:12px;box-sizing:border-box;}
|
|
130
|
-
#root{min-height:0;}
|
|
131
|
-
*{box-sizing:border-box;}
|
|
132
|
-
</style></head><body><div id="root"></div>
|
|
133
|
-
<script>${bundles.react}<\/script>
|
|
134
|
-
<script>${bundles.reactDom}<\/script>
|
|
135
|
-
<script>${bundles.framerMotion}<\/script>
|
|
136
|
-
<script>${bundles.recharts}<\/script>
|
|
137
|
-
<script>${bundles.lucideReact}<\/script>
|
|
138
|
-
<script>${SANDBOX_BOOTSTRAP}<\/script>
|
|
139
|
-
</body></html>`
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
// ─── Component ───────────────────────────────────────────────────────────────
|
|
143
|
-
|
|
144
|
-
export const DisplayReactRenderer = memo(function DisplayReactRenderer(props: DisplayReactRendererProps) {
|
|
145
|
-
const {
|
|
146
|
-
code,
|
|
147
|
-
language = "jsx",
|
|
148
|
-
initialProps,
|
|
149
|
-
layout,
|
|
150
|
-
theme = "auto",
|
|
151
|
-
title,
|
|
152
|
-
description,
|
|
153
|
-
sandboxBase = "/sandbox",
|
|
154
|
-
} = props
|
|
155
|
-
|
|
156
|
-
const iframeRef = useRef<HTMLIFrameElement | null>(null)
|
|
157
|
-
const [height, setHeight] = useState<number | null>(null)
|
|
158
|
-
const [error, setError] = useState<string | null>(null)
|
|
159
|
-
const [status, setStatus] = useState<"loading" | "ready" | "error">("loading")
|
|
160
|
-
|
|
161
|
-
// Validate + compile once per payload
|
|
162
|
-
const compiled = useMemo<{ ok: true; code: string } | { ok: false; error: string }>(() => {
|
|
163
|
-
const validationError = validatePayload(props)
|
|
164
|
-
if (validationError) return { ok: false, error: validationError }
|
|
165
|
-
return compile(code, language)
|
|
166
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
167
|
-
}, [code, language, JSON.stringify(props.imports), JSON.stringify(initialProps)])
|
|
168
|
-
|
|
169
|
-
// Build + inject srcdoc once bundles are available
|
|
170
|
-
useEffect(() => {
|
|
171
|
-
if (compiled.ok === false) {
|
|
172
|
-
setError(compiled.error)
|
|
173
|
-
setStatus("error")
|
|
174
|
-
return
|
|
175
|
-
}
|
|
176
|
-
let cancelled = false
|
|
177
|
-
loadBundles(sandboxBase)
|
|
178
|
-
.then((bundles) => {
|
|
179
|
-
if (cancelled) return
|
|
180
|
-
const iframe = iframeRef.current
|
|
181
|
-
if (!iframe) return
|
|
182
|
-
iframe.srcdoc = buildSrcDoc(bundles)
|
|
183
|
-
})
|
|
184
|
-
.catch((e) => {
|
|
185
|
-
if (cancelled) return
|
|
186
|
-
setError(`failed to load sandbox bundles: ${e instanceof Error ? e.message : String(e)}`)
|
|
187
|
-
setStatus("error")
|
|
188
|
-
})
|
|
189
|
-
return () => {
|
|
190
|
-
cancelled = true
|
|
191
|
-
}
|
|
192
|
-
}, [compiled, sandboxBase])
|
|
193
|
-
|
|
194
|
-
// postMessage pump: wait for sandbox-boot → send compiled code. Also capture height + ready.
|
|
195
|
-
useEffect(() => {
|
|
196
|
-
const handler = (ev: MessageEvent) => {
|
|
197
|
-
const iframe = iframeRef.current
|
|
198
|
-
if (!iframe || ev.source !== iframe.contentWindow) return
|
|
199
|
-
const data = ev.data as { type?: string; height?: number } | undefined
|
|
200
|
-
if (!data || typeof data !== "object" || !data.type) return
|
|
201
|
-
if (data.type === "sandbox-boot") {
|
|
202
|
-
if (!compiled.ok) return
|
|
203
|
-
iframe.contentWindow?.postMessage(
|
|
204
|
-
{
|
|
205
|
-
type: "sandbox-render",
|
|
206
|
-
compiledCode: compiled.code,
|
|
207
|
-
payload: {
|
|
208
|
-
initialProps: initialProps ?? {},
|
|
209
|
-
theme,
|
|
210
|
-
},
|
|
211
|
-
},
|
|
212
|
-
"*",
|
|
213
|
-
)
|
|
214
|
-
} else if (data.type === "sandbox-ready") {
|
|
215
|
-
setStatus("ready")
|
|
216
|
-
} else if (data.type === "sandbox-height" && typeof data.height === "number") {
|
|
217
|
-
if (layout?.height !== "auto" && typeof layout?.height === "number") return
|
|
218
|
-
setHeight(data.height + 24) // padding from body
|
|
219
|
-
}
|
|
220
|
-
}
|
|
221
|
-
window.addEventListener("message", handler)
|
|
222
|
-
return () => window.removeEventListener("message", handler)
|
|
223
|
-
}, [compiled, initialProps, theme, layout?.height])
|
|
224
|
-
|
|
225
|
-
// Compute iframe style
|
|
226
|
-
const iframeStyle: React.CSSProperties = {
|
|
227
|
-
width: "100%",
|
|
228
|
-
maxWidth: layout?.maxWidth ? `${layout.maxWidth}px` : undefined,
|
|
229
|
-
aspectRatio: layout?.aspectRatio,
|
|
230
|
-
height:
|
|
231
|
-
typeof layout?.height === "number"
|
|
232
|
-
? `${layout.height}px`
|
|
233
|
-
: height !== null
|
|
234
|
-
? `${height}px`
|
|
235
|
-
: "120px",
|
|
236
|
-
border: "1px solid var(--border, #e5e5e5)",
|
|
237
|
-
borderRadius: "8px",
|
|
238
|
-
display: "block",
|
|
239
|
-
background: "var(--bg, #fff)",
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
if (error || (compiled.ok === false)) {
|
|
243
|
-
const msg = error ?? (compiled.ok === false ? compiled.error : "unknown")
|
|
244
|
-
return (
|
|
245
|
-
<div className="rounded-lg border border-red-300 bg-red-50 text-red-900 p-3 text-xs">
|
|
246
|
-
<div className="font-semibold mb-1">React sandbox error</div>
|
|
247
|
-
<pre className="whitespace-pre-wrap break-words font-mono text-[11px]">{msg}</pre>
|
|
248
|
-
</div>
|
|
249
|
-
)
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
return (
|
|
253
|
-
<div className="flex flex-col gap-1.5">
|
|
254
|
-
{(title || description) && (
|
|
255
|
-
<div className="flex flex-col gap-0.5 px-0.5">
|
|
256
|
-
{title && <div className="text-sm font-semibold text-foreground">{title}</div>}
|
|
257
|
-
{description && <div className="text-xs text-muted-foreground">{description}</div>}
|
|
258
|
-
</div>
|
|
259
|
-
)}
|
|
260
|
-
<iframe
|
|
261
|
-
ref={iframeRef}
|
|
262
|
-
sandbox="allow-scripts"
|
|
263
|
-
title={title ?? "react sandbox"}
|
|
264
|
-
style={iframeStyle}
|
|
265
|
-
aria-busy={status === "loading"}
|
|
266
|
-
/>
|
|
267
|
-
</div>
|
|
268
|
-
)
|
|
269
|
-
})
|
|
1
|
+
import { memo, useEffect, useMemo, useRef, useState } from "react"
|
|
2
|
+
import { transform as sucraseTransform } from "sucrase"
|
|
3
|
+
import { SANDBOX_BOOTSTRAP } from "./react-sandbox/bootstrap.js"
|
|
4
|
+
|
|
5
|
+
// ─── Types ────────────────────────────────────────────────────────────────────
|
|
6
|
+
|
|
7
|
+
const WHITELIST_MODULES = [
|
|
8
|
+
"react",
|
|
9
|
+
"react-dom",
|
|
10
|
+
"react-dom/client",
|
|
11
|
+
"framer-motion",
|
|
12
|
+
"recharts",
|
|
13
|
+
"lucide-react",
|
|
14
|
+
] as const
|
|
15
|
+
|
|
16
|
+
type WhitelistedModule = (typeof WHITELIST_MODULES)[number]
|
|
17
|
+
|
|
18
|
+
interface ImportDecl {
|
|
19
|
+
module: WhitelistedModule
|
|
20
|
+
symbols: string[]
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface Layout {
|
|
24
|
+
height?: number | "auto"
|
|
25
|
+
aspectRatio?: string
|
|
26
|
+
maxWidth?: number
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface DisplayReactRendererProps {
|
|
30
|
+
// Flattened DisplayReactSchema payload (PartRenderer spreads args as props)
|
|
31
|
+
version?: "1"
|
|
32
|
+
title?: string
|
|
33
|
+
description?: string
|
|
34
|
+
code: string
|
|
35
|
+
language?: "jsx" | "tsx"
|
|
36
|
+
entry?: "default"
|
|
37
|
+
imports?: ImportDecl[]
|
|
38
|
+
initialProps?: Record<string, unknown>
|
|
39
|
+
layout?: Layout
|
|
40
|
+
theme?: "light" | "dark" | "auto"
|
|
41
|
+
// Sandbox bundle base — consumers can override (default: "/sandbox")
|
|
42
|
+
sandboxBase?: string
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ─── Size limits (mirror SDK schema) ──────────────────────────────────────────
|
|
46
|
+
|
|
47
|
+
const MAX_CODE_BYTES = 8 * 1024
|
|
48
|
+
const MAX_PROPS_BYTES = 32 * 1024
|
|
49
|
+
|
|
50
|
+
// ─── Module-level bundle cache ────────────────────────────────────────────────
|
|
51
|
+
|
|
52
|
+
interface SandboxBundles {
|
|
53
|
+
react: string
|
|
54
|
+
reactDom: string
|
|
55
|
+
framerMotion: string
|
|
56
|
+
recharts: string
|
|
57
|
+
lucideReact: string
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const bundleCache = new Map<string, Promise<SandboxBundles>>()
|
|
61
|
+
|
|
62
|
+
function loadBundles(base: string): Promise<SandboxBundles> {
|
|
63
|
+
const key = base
|
|
64
|
+
const cached = bundleCache.get(key)
|
|
65
|
+
if (cached) return cached
|
|
66
|
+
const p = (async () => {
|
|
67
|
+
const [react, reactDom, framerMotion, recharts, lucideReact] = await Promise.all([
|
|
68
|
+
fetch(`${base}/react.js`).then((r) => r.text()),
|
|
69
|
+
fetch(`${base}/react-dom.js`).then((r) => r.text()),
|
|
70
|
+
fetch(`${base}/framer-motion.js`).then((r) => r.text()),
|
|
71
|
+
fetch(`${base}/recharts.js`).then((r) => r.text()),
|
|
72
|
+
fetch(`${base}/lucide-react.js`).then((r) => r.text()),
|
|
73
|
+
])
|
|
74
|
+
return { react, reactDom, framerMotion, recharts, lucideReact }
|
|
75
|
+
})()
|
|
76
|
+
bundleCache.set(key, p)
|
|
77
|
+
return p
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ─── Validation helpers ──────────────────────────────────────────────────────
|
|
81
|
+
|
|
82
|
+
function byteLength(s: string): number {
|
|
83
|
+
return new TextEncoder().encode(s).length
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function validatePayload(props: DisplayReactRendererProps): string | null {
|
|
87
|
+
if (!props.code || typeof props.code !== "string") return "missing code"
|
|
88
|
+
if (byteLength(props.code) > MAX_CODE_BYTES) return `code exceeds ${MAX_CODE_BYTES} bytes`
|
|
89
|
+
if (props.initialProps) {
|
|
90
|
+
try {
|
|
91
|
+
const s = JSON.stringify(props.initialProps)
|
|
92
|
+
if (byteLength(s) > MAX_PROPS_BYTES) return `initialProps exceeds ${MAX_PROPS_BYTES} bytes`
|
|
93
|
+
} catch {
|
|
94
|
+
return "initialProps not JSON-serializable"
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
if (props.imports) {
|
|
98
|
+
for (const imp of props.imports) {
|
|
99
|
+
if (!WHITELIST_MODULES.includes(imp.module)) {
|
|
100
|
+
return `import not in whitelist: ${imp.module}`
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return null
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ─── Transpile user code ─────────────────────────────────────────────────────
|
|
108
|
+
|
|
109
|
+
function compile(code: string, language: "jsx" | "tsx"): { ok: true; code: string } | { ok: false; error: string } {
|
|
110
|
+
try {
|
|
111
|
+
const transforms: ("jsx" | "typescript" | "imports")[] = ["jsx", "imports"]
|
|
112
|
+
if (language === "tsx") transforms.unshift("typescript")
|
|
113
|
+
const out = sucraseTransform(code, {
|
|
114
|
+
transforms,
|
|
115
|
+
production: true,
|
|
116
|
+
jsxRuntime: "classic",
|
|
117
|
+
})
|
|
118
|
+
return { ok: true, code: out.code }
|
|
119
|
+
} catch (e) {
|
|
120
|
+
return { ok: false, error: e instanceof Error ? e.message : String(e) }
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ─── Build srcdoc ────────────────────────────────────────────────────────────
|
|
125
|
+
|
|
126
|
+
function buildSrcDoc(bundles: SandboxBundles): string {
|
|
127
|
+
return `<!doctype html><html><head><meta charset="utf-8"><style>
|
|
128
|
+
html,body{margin:0;padding:0;}
|
|
129
|
+
body{font:14px/1.5 system-ui,-apple-system,sans-serif;padding:12px;box-sizing:border-box;}
|
|
130
|
+
#root{min-height:0;}
|
|
131
|
+
*{box-sizing:border-box;}
|
|
132
|
+
</style></head><body><div id="root"></div>
|
|
133
|
+
<script>${bundles.react}<\/script>
|
|
134
|
+
<script>${bundles.reactDom}<\/script>
|
|
135
|
+
<script>${bundles.framerMotion}<\/script>
|
|
136
|
+
<script>${bundles.recharts}<\/script>
|
|
137
|
+
<script>${bundles.lucideReact}<\/script>
|
|
138
|
+
<script>${SANDBOX_BOOTSTRAP}<\/script>
|
|
139
|
+
</body></html>`
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// ─── Component ───────────────────────────────────────────────────────────────
|
|
143
|
+
|
|
144
|
+
export const DisplayReactRenderer = memo(function DisplayReactRenderer(props: DisplayReactRendererProps) {
|
|
145
|
+
const {
|
|
146
|
+
code,
|
|
147
|
+
language = "jsx",
|
|
148
|
+
initialProps,
|
|
149
|
+
layout,
|
|
150
|
+
theme = "auto",
|
|
151
|
+
title,
|
|
152
|
+
description,
|
|
153
|
+
sandboxBase = "/sandbox",
|
|
154
|
+
} = props
|
|
155
|
+
|
|
156
|
+
const iframeRef = useRef<HTMLIFrameElement | null>(null)
|
|
157
|
+
const [height, setHeight] = useState<number | null>(null)
|
|
158
|
+
const [error, setError] = useState<string | null>(null)
|
|
159
|
+
const [status, setStatus] = useState<"loading" | "ready" | "error">("loading")
|
|
160
|
+
|
|
161
|
+
// Validate + compile once per payload
|
|
162
|
+
const compiled = useMemo<{ ok: true; code: string } | { ok: false; error: string }>(() => {
|
|
163
|
+
const validationError = validatePayload(props)
|
|
164
|
+
if (validationError) return { ok: false, error: validationError }
|
|
165
|
+
return compile(code, language)
|
|
166
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
167
|
+
}, [code, language, JSON.stringify(props.imports), JSON.stringify(initialProps)])
|
|
168
|
+
|
|
169
|
+
// Build + inject srcdoc once bundles are available
|
|
170
|
+
useEffect(() => {
|
|
171
|
+
if (compiled.ok === false) {
|
|
172
|
+
setError(compiled.error)
|
|
173
|
+
setStatus("error")
|
|
174
|
+
return
|
|
175
|
+
}
|
|
176
|
+
let cancelled = false
|
|
177
|
+
loadBundles(sandboxBase)
|
|
178
|
+
.then((bundles) => {
|
|
179
|
+
if (cancelled) return
|
|
180
|
+
const iframe = iframeRef.current
|
|
181
|
+
if (!iframe) return
|
|
182
|
+
iframe.srcdoc = buildSrcDoc(bundles)
|
|
183
|
+
})
|
|
184
|
+
.catch((e) => {
|
|
185
|
+
if (cancelled) return
|
|
186
|
+
setError(`failed to load sandbox bundles: ${e instanceof Error ? e.message : String(e)}`)
|
|
187
|
+
setStatus("error")
|
|
188
|
+
})
|
|
189
|
+
return () => {
|
|
190
|
+
cancelled = true
|
|
191
|
+
}
|
|
192
|
+
}, [compiled, sandboxBase])
|
|
193
|
+
|
|
194
|
+
// postMessage pump: wait for sandbox-boot → send compiled code. Also capture height + ready.
|
|
195
|
+
useEffect(() => {
|
|
196
|
+
const handler = (ev: MessageEvent) => {
|
|
197
|
+
const iframe = iframeRef.current
|
|
198
|
+
if (!iframe || ev.source !== iframe.contentWindow) return
|
|
199
|
+
const data = ev.data as { type?: string; height?: number } | undefined
|
|
200
|
+
if (!data || typeof data !== "object" || !data.type) return
|
|
201
|
+
if (data.type === "sandbox-boot") {
|
|
202
|
+
if (!compiled.ok) return
|
|
203
|
+
iframe.contentWindow?.postMessage(
|
|
204
|
+
{
|
|
205
|
+
type: "sandbox-render",
|
|
206
|
+
compiledCode: compiled.code,
|
|
207
|
+
payload: {
|
|
208
|
+
initialProps: initialProps ?? {},
|
|
209
|
+
theme,
|
|
210
|
+
},
|
|
211
|
+
},
|
|
212
|
+
"*",
|
|
213
|
+
)
|
|
214
|
+
} else if (data.type === "sandbox-ready") {
|
|
215
|
+
setStatus("ready")
|
|
216
|
+
} else if (data.type === "sandbox-height" && typeof data.height === "number") {
|
|
217
|
+
if (layout?.height !== "auto" && typeof layout?.height === "number") return
|
|
218
|
+
setHeight(data.height + 24) // padding from body
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
window.addEventListener("message", handler)
|
|
222
|
+
return () => window.removeEventListener("message", handler)
|
|
223
|
+
}, [compiled, initialProps, theme, layout?.height])
|
|
224
|
+
|
|
225
|
+
// Compute iframe style
|
|
226
|
+
const iframeStyle: React.CSSProperties = {
|
|
227
|
+
width: "100%",
|
|
228
|
+
maxWidth: layout?.maxWidth ? `${layout.maxWidth}px` : undefined,
|
|
229
|
+
aspectRatio: layout?.aspectRatio,
|
|
230
|
+
height:
|
|
231
|
+
typeof layout?.height === "number"
|
|
232
|
+
? `${layout.height}px`
|
|
233
|
+
: height !== null
|
|
234
|
+
? `${height}px`
|
|
235
|
+
: "120px",
|
|
236
|
+
border: "1px solid var(--border, #e5e5e5)",
|
|
237
|
+
borderRadius: "8px",
|
|
238
|
+
display: "block",
|
|
239
|
+
background: "var(--bg, #fff)",
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
if (error || (compiled.ok === false)) {
|
|
243
|
+
const msg = error ?? (compiled.ok === false ? compiled.error : "unknown")
|
|
244
|
+
return (
|
|
245
|
+
<div className="rounded-lg border border-red-300 bg-red-50 text-red-900 p-3 text-xs">
|
|
246
|
+
<div className="font-semibold mb-1">React sandbox error</div>
|
|
247
|
+
<pre className="whitespace-pre-wrap break-words font-mono text-[11px]">{msg}</pre>
|
|
248
|
+
</div>
|
|
249
|
+
)
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
return (
|
|
253
|
+
<div className="flex flex-col gap-1.5">
|
|
254
|
+
{(title || description) && (
|
|
255
|
+
<div className="flex flex-col gap-0.5 px-0.5">
|
|
256
|
+
{title && <div className="text-sm font-semibold text-foreground">{title}</div>}
|
|
257
|
+
{description && <div className="text-xs text-muted-foreground">{description}</div>}
|
|
258
|
+
</div>
|
|
259
|
+
)}
|
|
260
|
+
<iframe
|
|
261
|
+
ref={iframeRef}
|
|
262
|
+
sandbox="allow-scripts"
|
|
263
|
+
title={title ?? "react sandbox"}
|
|
264
|
+
style={iframeStyle}
|
|
265
|
+
aria-busy={status === "loading"}
|
|
266
|
+
/>
|
|
267
|
+
</div>
|
|
268
|
+
)
|
|
269
|
+
})
|
|
@@ -1,55 +1,55 @@
|
|
|
1
|
-
import type { DisplayFile } from "./sdk-types.js";
|
|
2
|
-
import {
|
|
3
|
-
Download,
|
|
4
|
-
File,
|
|
5
|
-
FileCode,
|
|
6
|
-
FileImage,
|
|
7
|
-
FileMinus,
|
|
8
|
-
FileText,
|
|
9
|
-
FileVideo,
|
|
10
|
-
Music,
|
|
11
|
-
} from "lucide-react";
|
|
12
|
-
import { Button } from "../ui/button.js";
|
|
13
|
-
import { Card } from "../ui/card.js";
|
|
14
|
-
|
|
15
|
-
function getFileIcon(type: string) {
|
|
16
|
-
const mime = type.toLowerCase();
|
|
17
|
-
if (mime.startsWith("image/")) return FileImage;
|
|
18
|
-
if (mime.startsWith("video/")) return FileVideo;
|
|
19
|
-
if (mime.startsWith("audio/")) return Music;
|
|
20
|
-
if (mime === "application/pdf" || mime === "text/plain" || mime.includes("document")) return FileText;
|
|
21
|
-
if (mime.includes("spreadsheet") || mime.includes("csv")) return FileMinus;
|
|
22
|
-
if (mime.includes("javascript") || mime.includes("typescript") || mime.includes("json") || mime.includes("html") || mime.includes("css") || mime.includes("xml")) return FileCode;
|
|
23
|
-
return File;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
function formatSize(bytes: number): string {
|
|
27
|
-
if (bytes < 1024) return `${bytes} B`;
|
|
28
|
-
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
29
|
-
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
export function FileCardRenderer({ name, type, size, url }: DisplayFile) {
|
|
33
|
-
const Icon = getFileIcon(type);
|
|
34
|
-
|
|
35
|
-
return (
|
|
36
|
-
<Card className="flex items-center gap-3 p-3">
|
|
37
|
-
<div className="shrink-0 text-primary">
|
|
38
|
-
<Icon className="h-8 w-8" />
|
|
39
|
-
</div>
|
|
40
|
-
<div className="flex-1 min-w-0">
|
|
41
|
-
<p className="font-medium text-sm truncate">{name}</p>
|
|
42
|
-
<p className="text-xs text-muted-foreground">
|
|
43
|
-
{type}{size !== undefined && ` · ${formatSize(size)}`}
|
|
44
|
-
</p>
|
|
45
|
-
</div>
|
|
46
|
-
{url && (
|
|
47
|
-
<Button variant="ghost" size="icon" asChild>
|
|
48
|
-
<a href={url} download={name} aria-label={`Baixar ${name}`}>
|
|
49
|
-
<Download className="h-4 w-4" />
|
|
50
|
-
</a>
|
|
51
|
-
</Button>
|
|
52
|
-
)}
|
|
53
|
-
</Card>
|
|
54
|
-
);
|
|
55
|
-
}
|
|
1
|
+
import type { DisplayFile } from "./sdk-types.js";
|
|
2
|
+
import {
|
|
3
|
+
Download,
|
|
4
|
+
File,
|
|
5
|
+
FileCode,
|
|
6
|
+
FileImage,
|
|
7
|
+
FileMinus,
|
|
8
|
+
FileText,
|
|
9
|
+
FileVideo,
|
|
10
|
+
Music,
|
|
11
|
+
} from "lucide-react";
|
|
12
|
+
import { Button } from "../ui/button.js";
|
|
13
|
+
import { Card } from "../ui/card.js";
|
|
14
|
+
|
|
15
|
+
function getFileIcon(type: string) {
|
|
16
|
+
const mime = type.toLowerCase();
|
|
17
|
+
if (mime.startsWith("image/")) return FileImage;
|
|
18
|
+
if (mime.startsWith("video/")) return FileVideo;
|
|
19
|
+
if (mime.startsWith("audio/")) return Music;
|
|
20
|
+
if (mime === "application/pdf" || mime === "text/plain" || mime.includes("document")) return FileText;
|
|
21
|
+
if (mime.includes("spreadsheet") || mime.includes("csv")) return FileMinus;
|
|
22
|
+
if (mime.includes("javascript") || mime.includes("typescript") || mime.includes("json") || mime.includes("html") || mime.includes("css") || mime.includes("xml")) return FileCode;
|
|
23
|
+
return File;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function formatSize(bytes: number): string {
|
|
27
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
28
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
29
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function FileCardRenderer({ name, type, size, url }: DisplayFile) {
|
|
33
|
+
const Icon = getFileIcon(type);
|
|
34
|
+
|
|
35
|
+
return (
|
|
36
|
+
<Card className="flex items-center gap-3 p-3">
|
|
37
|
+
<div className="shrink-0 text-primary">
|
|
38
|
+
<Icon className="h-8 w-8" />
|
|
39
|
+
</div>
|
|
40
|
+
<div className="flex-1 min-w-0">
|
|
41
|
+
<p className="font-medium text-sm truncate">{name}</p>
|
|
42
|
+
<p className="text-xs text-muted-foreground">
|
|
43
|
+
{type}{size !== undefined && ` · ${formatSize(size)}`}
|
|
44
|
+
</p>
|
|
45
|
+
</div>
|
|
46
|
+
{url && (
|
|
47
|
+
<Button variant="ghost" size="icon" asChild>
|
|
48
|
+
<a href={url} download={name} aria-label={`Baixar ${name}`}>
|
|
49
|
+
<Download className="h-4 w-4" />
|
|
50
|
+
</a>
|
|
51
|
+
</Button>
|
|
52
|
+
)}
|
|
53
|
+
</Card>
|
|
54
|
+
);
|
|
55
|
+
}
|