160 lines
3.9 KiB
TypeScript
160 lines
3.9 KiB
TypeScript
import { useRef, useEffect, useCallback, useState } from 'react'
|
|
import { IconAppWindow, IconLoader } from '@tabler/icons-react'
|
|
|
|
interface MCPAppRendererProps {
|
|
resourceUri: string
|
|
data: Record<string, unknown>
|
|
csp?: string
|
|
onToolCall?: (toolName: string, args: Record<string, unknown>) => void
|
|
onSendMessage?: (content: string) => void
|
|
}
|
|
|
|
export function MCPAppRenderer({
|
|
resourceUri,
|
|
data,
|
|
onToolCall,
|
|
onSendMessage,
|
|
}: MCPAppRendererProps) {
|
|
const iframeRef = useRef<HTMLIFrameElement>(null)
|
|
const [loading, setLoading] = useState(true)
|
|
const [error, setError] = useState<string | null>(null)
|
|
const [iframeHeight, setIframeHeight] = useState(300)
|
|
|
|
const appName = resourceUri.replace(/^app:\/\//, '')
|
|
|
|
useEffect(() => {
|
|
let cancelled = false
|
|
setLoading(true)
|
|
|
|
fetch(`/api/apps/${appName}`, { credentials: 'include' })
|
|
.then((res) => {
|
|
if (!res.ok) throw new Error(`Failed to load app: ${res.status}`)
|
|
return res.text()
|
|
})
|
|
.then((html) => {
|
|
if (cancelled) return
|
|
if (iframeRef.current) {
|
|
iframeRef.current.srcdoc = html
|
|
}
|
|
})
|
|
.catch((err: unknown) => {
|
|
if (cancelled) return
|
|
setError(err instanceof Error ? err.message : String(err))
|
|
})
|
|
.finally(() => {
|
|
if (!cancelled) setLoading(false)
|
|
})
|
|
|
|
return () => {
|
|
cancelled = true
|
|
}
|
|
}, [appName])
|
|
|
|
const handleMessage = useCallback(
|
|
(event: MessageEvent) => {
|
|
if (!iframeRef.current?.contentWindow) return
|
|
|
|
const msg = event.data
|
|
if (!msg || msg.jsonrpc !== '2.0') return
|
|
|
|
const params = msg.params ?? {}
|
|
|
|
switch (msg.method) {
|
|
case 'ui/initialize':
|
|
iframeRef.current.contentWindow.postMessage(
|
|
{ jsonrpc: '2.0', method: 'tool/input', params: data },
|
|
'*'
|
|
)
|
|
break
|
|
|
|
case 'tools/call':
|
|
onToolCall?.(params.name, params.arguments ?? {})
|
|
iframeRef.current.contentWindow.postMessage(
|
|
{ jsonrpc: '2.0', id: msg.id, result: { status: 'accepted' } },
|
|
'*'
|
|
)
|
|
break
|
|
|
|
case 'ui/sendMessage':
|
|
if (onSendMessage && params.content) {
|
|
onSendMessage(params.content)
|
|
}
|
|
break
|
|
|
|
case 'ui/resize':
|
|
if (typeof params.height === 'number') {
|
|
setIframeHeight(Math.min(params.height, 800))
|
|
}
|
|
break
|
|
|
|
default:
|
|
break
|
|
}
|
|
},
|
|
[data, onToolCall, onSendMessage]
|
|
)
|
|
|
|
useEffect(() => {
|
|
window.addEventListener('message', handleMessage)
|
|
return () => {
|
|
window.removeEventListener('message', handleMessage)
|
|
}
|
|
}, [handleMessage])
|
|
|
|
if (error) {
|
|
return (
|
|
<div
|
|
className='tool-call-card'
|
|
style={{ color: '#f7607a', fontSize: '0.8rem' }}
|
|
>
|
|
<IconAppWindow size={14} style={{ verticalAlign: 'middle', marginRight: '4px' }} />
|
|
MCP App error: {error}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div
|
|
className='card'
|
|
style={{
|
|
margin: '0.5rem 0',
|
|
border: '1px solid #2a2a4a',
|
|
borderRadius: '8px',
|
|
overflow: 'hidden',
|
|
background: '#0a0a1a',
|
|
}}
|
|
>
|
|
<div
|
|
style={{
|
|
padding: '0.3rem 0.6rem',
|
|
borderBottom: '1px solid #2a2a4a',
|
|
display: 'flex',
|
|
gap: '0.3rem',
|
|
alignItems: 'center',
|
|
fontSize: '0.75rem',
|
|
color: '#7aa2f7',
|
|
background: '#12192e',
|
|
}}
|
|
>
|
|
{loading ? (
|
|
<IconLoader size={14} />
|
|
) : (
|
|
<IconAppWindow size={14} />
|
|
)}
|
|
{appName}
|
|
</div>
|
|
<iframe
|
|
ref={iframeRef}
|
|
title={`MCP App: ${appName}`}
|
|
sandbox='allow-scripts allow-forms allow-popups'
|
|
style={{
|
|
width: '100%',
|
|
height: iframeHeight + 'px',
|
|
border: 'none',
|
|
background: '#0a0a1a',
|
|
}}
|
|
/>
|
|
</div>
|
|
)
|
|
}
|