2c3cfdbedc
- Fix a2ui-format.md schema to match renderer: rename 'options' → 'filters' in
filters block, replace 'active: boolean' with 'value: string' in filter options,
update form select options from string[] to {label, value}[] objects (Critical #1)
- Fix 3 failing tests in test_researcher_agent.sh to use @mention paths without .md
extension (Important #2)
- Complete pop-out feature in VisualBlock.handlePopOut() by posting render message
to popup window after 500ms initialization delay (Important #3)
- Update test_context_files.sh to match new a2ui-format.md schema (filters array
key check, value field check)
263 lines
7.6 KiB
TypeScript
263 lines
7.6 KiB
TypeScript
import { useState, useRef, useEffect } from 'react'
|
|
import type { KeyboardEvent } from 'react'
|
|
import ReactMarkdown from 'react-markdown'
|
|
import { IconSend } from '@tabler/icons-react'
|
|
import { ToolCallCard } from './ToolCallCard'
|
|
import { MCPAppRenderer } from './MCPAppRenderer'
|
|
import { A2UIRenderer, parseA2UIBlocks } from './A2UIRenderer'
|
|
import type { ChatMessage } from '../hooks/useChat'
|
|
|
|
interface ChatPanelProps {
|
|
messages: ChatMessage[]
|
|
isStreaming: boolean
|
|
onSend: (content: string) => void
|
|
sessionId: string | null
|
|
}
|
|
|
|
// ─── Visual Block Types ────────────────────────────────────────────────────────
|
|
|
|
interface VisualBlockPayload {
|
|
title?: string
|
|
code: string
|
|
data?: Record<string, unknown>
|
|
height?: number
|
|
}
|
|
|
|
function parseVisualBlocks(content: string): VisualBlockPayload[] {
|
|
const blocks: VisualBlockPayload[] = []
|
|
const regex = /```visual\s*\n([\s\S]*?)```/g
|
|
let match: RegExpExecArray | null
|
|
|
|
while ((match = regex.exec(content)) !== null) {
|
|
try {
|
|
const parsed = JSON.parse(match[1]) as VisualBlockPayload
|
|
if (parsed && typeof parsed.code === 'string') {
|
|
blocks.push(parsed)
|
|
}
|
|
} catch {
|
|
// Silently skip invalid JSON
|
|
}
|
|
}
|
|
|
|
return blocks
|
|
}
|
|
|
|
// ─── VisualBlock Component ─────────────────────────────────────────────────────
|
|
|
|
const controlBtnStyle: React.CSSProperties = {
|
|
background: 'none',
|
|
border: '1px solid #3a3a5a',
|
|
color: '#8888aa',
|
|
borderRadius: '4px',
|
|
padding: '0.15rem 0.4rem',
|
|
fontSize: '0.75rem',
|
|
cursor: 'pointer',
|
|
}
|
|
|
|
function VisualBlock({
|
|
payload,
|
|
onSend,
|
|
}: {
|
|
payload: VisualBlockPayload
|
|
onSend: (msg: string) => void
|
|
}) {
|
|
const [showSource, setShowSource] = useState(false)
|
|
|
|
function handlePopOut() {
|
|
const w = window.open(
|
|
'',
|
|
'_blank',
|
|
`width=900,height=${(payload.height ?? 300) + 120}`
|
|
)
|
|
if (w) {
|
|
fetch('/api/apps/runtime')
|
|
.then(r => r.text())
|
|
.then(html => {
|
|
w.document.open()
|
|
w.document.write(html)
|
|
w.document.close()
|
|
// Give the iframe time to initialize, then send the render message
|
|
setTimeout(() => {
|
|
w.postMessage(
|
|
{
|
|
jsonrpc: '2.0',
|
|
method: 'render',
|
|
params: { code: payload.code, data: payload.data ?? {} },
|
|
},
|
|
'*'
|
|
)
|
|
}, 500)
|
|
})
|
|
.catch(() => {
|
|
w.close()
|
|
})
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className='visual-block' style={{ margin: '0.5rem 0' }}>
|
|
{/* Header bar */}
|
|
<div
|
|
style={{
|
|
padding: '0.3rem 0.6rem',
|
|
borderBottom: '1px solid #2a2a4a',
|
|
display: 'flex',
|
|
justifyContent: 'space-between',
|
|
alignItems: 'center',
|
|
background: '#12192e',
|
|
borderRadius: '4px 4px 0 0',
|
|
border: '1px solid #2a2a4a',
|
|
}}
|
|
>
|
|
<span style={{ color: '#7aa2f7', fontSize: '0.8rem' }}>
|
|
{payload.title ?? 'Visual'}
|
|
</span>
|
|
<div style={{ display: 'flex', gap: '0.4rem' }}>
|
|
<button
|
|
onClick={() => setShowSource(s => !s)}
|
|
style={controlBtnStyle}
|
|
>
|
|
{showSource ? 'Hide Source' : 'View Source'}
|
|
</button>
|
|
<button onClick={handlePopOut} style={controlBtnStyle}>
|
|
Pop Out
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Source viewer */}
|
|
{showSource && (
|
|
<pre
|
|
style={{
|
|
background: '#0a0a1a',
|
|
padding: '0.5rem',
|
|
fontSize: '0.75rem',
|
|
color: '#c0c0d0',
|
|
overflowX: 'auto',
|
|
margin: 0,
|
|
borderLeft: '1px solid #2a2a4a',
|
|
borderRight: '1px solid #2a2a4a',
|
|
}}
|
|
>
|
|
{payload.code}
|
|
</pre>
|
|
)}
|
|
|
|
{/* Runtime iframe via MCPAppRenderer */}
|
|
<MCPAppRenderer
|
|
resourceUri='app://runtime'
|
|
data={{ ...payload, runtime: 'visual-artifact' }}
|
|
onSendMessage={onSend}
|
|
/>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ─── ChatPanel ─────────────────────────────────────────────────────────────────
|
|
|
|
export function ChatPanel({ messages, isStreaming, onSend, sessionId }: ChatPanelProps) {
|
|
const [input, setInput] = useState('')
|
|
const messagesEndRef = useRef<HTMLDivElement>(null)
|
|
|
|
useEffect(() => {
|
|
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
|
|
}, [messages])
|
|
|
|
function handleSend() {
|
|
if (input.trim() && !isStreaming) {
|
|
onSend(input.trim())
|
|
setInput('')
|
|
}
|
|
}
|
|
|
|
function handleKeyDown(e: KeyboardEvent<HTMLInputElement>) {
|
|
if (e.key === 'Enter' && !e.shiftKey) {
|
|
e.preventDefault()
|
|
handleSend()
|
|
}
|
|
}
|
|
|
|
if (sessionId === null) {
|
|
return (
|
|
<div className='chat-panel'>
|
|
<div
|
|
style={{
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
height: '100%',
|
|
}}
|
|
>
|
|
<span style={{ color: '#6a6a8a', fontSize: '1.1rem' }}>
|
|
Select or create a session to start researching
|
|
</span>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className='chat-panel'>
|
|
<div className='chat-messages'>
|
|
{messages.map(message => (
|
|
<div key={message.id} className={`chat-message ${message.role}`}>
|
|
{message.role === 'assistant' ? (
|
|
<div>
|
|
<ReactMarkdown>{message.content}</ReactMarkdown>
|
|
|
|
{/* A2UI interactive blocks */}
|
|
{parseA2UIBlocks(message.content).map((block, i) => (
|
|
<A2UIRenderer
|
|
key={i}
|
|
block={block}
|
|
onAction={(action, data) =>
|
|
onSend(`[${action}] ${JSON.stringify(data)}`)
|
|
}
|
|
/>
|
|
))}
|
|
|
|
{/* Visual artifact blocks */}
|
|
{parseVisualBlocks(message.content).map((payload, i) => (
|
|
<VisualBlock key={i} payload={payload} onSend={onSend} />
|
|
))}
|
|
|
|
{/* Tool call cards / MCP app renderers */}
|
|
{message.toolCalls?.map(tc =>
|
|
tc._meta?.ui?.resourceUri ? (
|
|
<MCPAppRenderer
|
|
key={tc.id}
|
|
resourceUri={tc._meta.ui.resourceUri}
|
|
data={tc.structuredContent ?? {}}
|
|
csp={tc._meta.ui.csp}
|
|
onSendMessage={(content) => onSend(content)}
|
|
/>
|
|
) : (
|
|
<ToolCallCard key={tc.id} toolCall={tc} />
|
|
)
|
|
)}
|
|
</div>
|
|
) : (
|
|
<div>{message.content}</div>
|
|
)}
|
|
</div>
|
|
))}
|
|
{isStreaming && messages.length > 0 && <div>Researching...</div>}
|
|
<div ref={messagesEndRef} />
|
|
</div>
|
|
<div className='chat-input-area'>
|
|
<input
|
|
placeholder='Ask a research question...'
|
|
value={input}
|
|
onChange={e => setInput(e.target.value)}
|
|
onKeyDown={handleKeyDown}
|
|
disabled={isStreaming}
|
|
autoFocus
|
|
/>
|
|
<button onClick={handleSend} disabled={isStreaming || !input.trim()}>
|
|
<IconSend size={18} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|