Some checks failed
phoenix-deploy Deploy failed: Command failed: bash scripts/deployment/phoenix-deploy-explorer-live-from-workspace.sh
nginx: the configuration file /et
Deploy Explorer Live / deploy (push) Failing after 3m47s
358 lines
17 KiB
TypeScript
358 lines
17 KiB
TypeScript
'use client'
|
|
|
|
import { FormEvent, useMemo, useState } from 'react'
|
|
import clsx from 'clsx'
|
|
import { Card } from '@/libs/frontend-ui-primitives'
|
|
import EntityBadge from '@/components/common/EntityBadge'
|
|
import { getExplorerApiBase } from '@/services/api/blockscout'
|
|
import type { ContractProfile, ContractSourceFile } from '@/services/api/contracts'
|
|
|
|
interface ContractCodeWorkspaceProps {
|
|
address: string
|
|
profile: ContractProfile
|
|
}
|
|
|
|
interface OutlineEntry {
|
|
type: 'contract' | 'interface' | 'library' | 'function' | 'event' | 'error'
|
|
name: string
|
|
line: number
|
|
}
|
|
|
|
const QUICK_PROMPTS = [
|
|
'What does this contract do?',
|
|
'What are the functions available in this contract?',
|
|
'Which functions can change state or move funds?',
|
|
'Who has special permissions or control in this contract?',
|
|
'What are potential risks or red flags in this contract?',
|
|
] as const
|
|
|
|
function makeFallbackSourceFile(profile: ContractProfile): ContractSourceFile | null {
|
|
if (!profile.source_code_preview && !profile.abi_full && !profile.abi) return null
|
|
return {
|
|
path: profile.contract_name ? `${profile.contract_name}.sol` : 'Contract.sol',
|
|
content: profile.source_code_full || profile.source_code_preview || profile.abi_full || profile.abi || '',
|
|
}
|
|
}
|
|
|
|
function parseOutline(content: string): OutlineEntry[] {
|
|
const entries: OutlineEntry[] = []
|
|
content.split('\n').forEach((line, index) => {
|
|
const lineNumber = index + 1
|
|
const typeMatch = line.match(/^\s*(?:abstract\s+)?(contract|interface|library)\s+([A-Za-z_][A-Za-z0-9_]*)/)
|
|
if (typeMatch) {
|
|
entries.push({
|
|
type: typeMatch[1] as OutlineEntry['type'],
|
|
name: typeMatch[2],
|
|
line: lineNumber,
|
|
})
|
|
return
|
|
}
|
|
|
|
const memberMatch = line.match(/^\s*(function|event|error)\s+([A-Za-z_][A-Za-z0-9_]*)/)
|
|
if (memberMatch) {
|
|
entries.push({
|
|
type: memberMatch[1] as OutlineEntry['type'],
|
|
name: memberMatch[2],
|
|
line: lineNumber,
|
|
})
|
|
}
|
|
})
|
|
return entries
|
|
}
|
|
|
|
function sourceExcerptForPrompt(files: ContractSourceFile[]): string {
|
|
return files
|
|
.slice(0, 4)
|
|
.map((file) => `File: ${file.path}\n${file.content.slice(0, 2600)}`)
|
|
.join('\n\n')
|
|
.slice(0, 5200)
|
|
}
|
|
|
|
export default function ContractCodeWorkspace({ address, profile }: ContractCodeWorkspaceProps) {
|
|
const files = useMemo(() => {
|
|
const normalized = profile.source_files?.length ? profile.source_files : []
|
|
const fallback = makeFallbackSourceFile(profile)
|
|
return normalized.length > 0 ? normalized : fallback ? [fallback] : []
|
|
}, [profile])
|
|
|
|
const [activeTab, setActiveTab] = useState<'source' | 'reader'>('source')
|
|
const [activePath, setActivePath] = useState(files[0]?.path || '')
|
|
const [prompt, setPrompt] = useState('What does this contract do?')
|
|
const [model, setModel] = useState('Explorer AI')
|
|
const [saveHistory, setSaveHistory] = useState(false)
|
|
const [submitting, setSubmitting] = useState(false)
|
|
const [readerAnswer, setReaderAnswer] = useState('')
|
|
const [readerError, setReaderError] = useState('')
|
|
const [expanded, setExpanded] = useState(false)
|
|
|
|
const activeFile = files.find((file) => file.path === activePath) || files[0]
|
|
const outline = useMemo(() => parseOutline(activeFile?.content || ''), [activeFile?.content])
|
|
const sourceLines = useMemo(() => (activeFile?.content || '').split('\n'), [activeFile?.content])
|
|
|
|
const selectedFiles = files
|
|
const sourceAvailable = files.length > 0 && Boolean(activeFile?.content)
|
|
|
|
const handleCopySource = async () => {
|
|
if (!activeFile?.content || typeof navigator === 'undefined') return
|
|
await navigator.clipboard?.writeText(activeFile.content)
|
|
}
|
|
|
|
const handleCopyLink = async () => {
|
|
if (typeof navigator === 'undefined' || typeof window === 'undefined') return
|
|
await navigator.clipboard?.writeText(`${window.location.href.split('#')[0]}#contract-source`)
|
|
}
|
|
|
|
const askReader = async (question: string) => {
|
|
const trimmed = question.trim()
|
|
if (!trimmed || submitting) return
|
|
setPrompt(trimmed)
|
|
setReaderError('')
|
|
setReaderAnswer('')
|
|
setSubmitting(true)
|
|
|
|
try {
|
|
const context = [
|
|
`Contract address: ${address}`,
|
|
profile.contract_name ? `Contract name: ${profile.contract_name}` : '',
|
|
profile.compiler_version ? `Compiler: ${profile.compiler_version}` : '',
|
|
profile.license_type ? `License: ${profile.license_type}` : '',
|
|
profile.proxy_type ? `Proxy type: ${profile.proxy_type}` : '',
|
|
`Read methods: ${profile.read_methods.map((method) => method.signature).slice(0, 24).join(', ') || 'none reported'}`,
|
|
`Write methods: ${profile.write_methods.map((method) => method.signature).slice(0, 24).join(', ') || 'none reported'}`,
|
|
sourceAvailable ? `Verified source excerpts:\n${sourceExcerptForPrompt(selectedFiles)}` : 'Verified source text is not available.',
|
|
].filter(Boolean).join('\n')
|
|
|
|
const response = await fetch(`${getExplorerApiBase()}/api/v1/ai/chat`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
messages: [
|
|
{
|
|
role: 'user',
|
|
content: `${trimmed}\n\nUse this contract context and answer concisely. Do not invent behavior that is not supported by the ABI or source.\n\n${context}`,
|
|
},
|
|
],
|
|
pageContext: {
|
|
path: `/addresses/${address}`,
|
|
view: 'contract-code-reader',
|
|
address,
|
|
},
|
|
}),
|
|
})
|
|
|
|
const payload = await response.json().catch(() => null)
|
|
if (!response.ok) {
|
|
throw new Error(payload?.error?.message || `AI reader returned HTTP ${response.status}`)
|
|
}
|
|
setReaderAnswer(String(payload?.reply || payload?.message?.content || 'No answer returned.'))
|
|
} catch (error) {
|
|
setReaderError(error instanceof Error ? error.message : 'Code Reader is temporarily unavailable.')
|
|
} finally {
|
|
setSubmitting(false)
|
|
}
|
|
}
|
|
|
|
const handleSubmit = async (event: FormEvent) => {
|
|
event.preventDefault()
|
|
await askReader(prompt)
|
|
}
|
|
|
|
if (!sourceAvailable && !profile.abi_available) {
|
|
return null
|
|
}
|
|
|
|
return (
|
|
<Card className="mb-6" title="Contract Source Code">
|
|
<section id="contract-source" className="space-y-4">
|
|
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
|
<div className="flex flex-wrap gap-2">
|
|
<button
|
|
type="button"
|
|
onClick={() => setActiveTab('source')}
|
|
className={clsx(
|
|
'rounded-lg px-3 py-2 text-sm font-semibold transition',
|
|
activeTab === 'source'
|
|
? 'bg-primary-600 text-white'
|
|
: 'bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-900 dark:text-gray-200 dark:hover:bg-gray-700',
|
|
)}
|
|
>
|
|
Source
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => setActiveTab('reader')}
|
|
className={clsx(
|
|
'rounded-lg px-3 py-2 text-sm font-semibold transition',
|
|
activeTab === 'reader'
|
|
? 'bg-primary-600 text-white'
|
|
: 'bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-900 dark:text-gray-200 dark:hover:bg-gray-700',
|
|
)}
|
|
>
|
|
Code Reader
|
|
</button>
|
|
</div>
|
|
<div className="flex flex-wrap gap-2">
|
|
{profile.source_verified ? <EntityBadge label="verified source" tone="success" /> : null}
|
|
{profile.abi_available ? <EntityBadge label="abi available" tone="info" /> : null}
|
|
{profile.compiler_version ? <EntityBadge label={profile.compiler_version} tone="neutral" className="normal-case tracking-normal" /> : null}
|
|
</div>
|
|
</div>
|
|
|
|
{activeTab === 'source' ? (
|
|
<div className={clsx('overflow-hidden rounded-lg border border-gray-200 dark:border-gray-700', expanded ? 'min-h-[46rem]' : '')}>
|
|
<div className="grid lg:grid-cols-[18rem_minmax(0,1fr)]">
|
|
<aside className="border-b border-gray-200 bg-gray-50 dark:border-gray-700 dark:bg-gray-900 lg:border-b-0 lg:border-r">
|
|
<div className="flex items-center justify-between border-b border-gray-200 px-4 py-3 text-xs font-semibold uppercase text-gray-500 dark:border-gray-700 dark:text-gray-400">
|
|
<span>Explorer</span>
|
|
<span>{files.length} file{files.length === 1 ? '' : 's'}</span>
|
|
</div>
|
|
<div className="max-h-72 overflow-auto p-2 lg:max-h-[34rem]">
|
|
{files.map((file) => (
|
|
<button
|
|
type="button"
|
|
key={file.path}
|
|
onClick={() => setActivePath(file.path)}
|
|
className={clsx(
|
|
'block w-full rounded-md px-3 py-2 text-left text-sm transition',
|
|
file.path === activeFile?.path
|
|
? 'bg-white font-semibold text-gray-950 shadow-sm dark:bg-gray-800 dark:text-white'
|
|
: 'text-gray-700 hover:bg-white dark:text-gray-300 dark:hover:bg-gray-800',
|
|
)}
|
|
>
|
|
<span className="block truncate">{file.path}</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
{outline.length > 0 ? (
|
|
<div className="border-t border-gray-200 p-2 dark:border-gray-700">
|
|
<div className="px-3 py-2 text-xs font-semibold uppercase text-gray-500 dark:text-gray-400">Outline</div>
|
|
<div className="max-h-64 overflow-auto">
|
|
{outline.slice(0, 80).map((entry) => (
|
|
<button
|
|
key={`${entry.type}-${entry.name}-${entry.line}`}
|
|
type="button"
|
|
onClick={() => document.getElementById(`source-line-${entry.line}`)?.scrollIntoView({ block: 'center' })}
|
|
className="flex w-full items-center gap-2 rounded-md px-3 py-1.5 text-left text-xs text-gray-700 hover:bg-white dark:text-gray-300 dark:hover:bg-gray-800"
|
|
>
|
|
<span className="w-16 uppercase text-gray-400">{entry.type}</span>
|
|
<span className="min-w-0 flex-1 truncate font-mono">{entry.name}</span>
|
|
<span className="text-gray-400">{entry.line}</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
</aside>
|
|
|
|
<div className="min-w-0 bg-gray-950 text-gray-100">
|
|
<div className="flex flex-col gap-3 border-b border-gray-800 bg-gray-900 px-4 py-3 sm:flex-row sm:items-center sm:justify-between">
|
|
<div className="min-w-0">
|
|
<div className="truncate font-mono text-sm text-white">{activeFile?.path || 'Source'}</div>
|
|
<div className="mt-1 text-xs text-gray-400">{sourceLines.length} lines</div>
|
|
</div>
|
|
<div className="flex flex-wrap gap-2">
|
|
<button type="button" onClick={handleCopySource} className="rounded-md border border-gray-700 px-3 py-1.5 text-xs font-semibold text-gray-200 hover:bg-gray-800">
|
|
Copy
|
|
</button>
|
|
<button type="button" onClick={handleCopyLink} className="rounded-md border border-gray-700 px-3 py-1.5 text-xs font-semibold text-gray-200 hover:bg-gray-800">
|
|
Link
|
|
</button>
|
|
<button type="button" onClick={() => setExpanded((value) => !value)} className="rounded-md border border-gray-700 px-3 py-1.5 text-xs font-semibold text-gray-200 hover:bg-gray-800">
|
|
{expanded ? 'Collapse' : 'Expand'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<pre className={clsx('overflow-auto p-0 text-xs leading-5', expanded ? 'max-h-[52rem]' : 'max-h-[34rem]')}>
|
|
<code className="block min-w-max py-4">
|
|
{sourceLines.map((line, index) => (
|
|
<span id={`source-line-${index + 1}`} key={`${activeFile?.path}-${index}`} className="grid grid-cols-[4.5rem_minmax(0,1fr)] px-4 hover:bg-white/5">
|
|
<span className="select-none pr-4 text-right text-gray-500">{index + 1}</span>
|
|
<span className="whitespace-pre text-gray-100">{line || ' '}</span>
|
|
</span>
|
|
))}
|
|
</code>
|
|
</pre>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="rounded-lg border border-gray-200 p-4 dark:border-gray-700">
|
|
<form onSubmit={handleSubmit} className="grid gap-5 lg:grid-cols-[28rem_minmax(0,1fr)]">
|
|
<div className="space-y-4 lg:border-r lg:border-gray-200 lg:pr-5 lg:dark:border-gray-700">
|
|
<label className="block">
|
|
<span className="mb-2 block text-sm font-semibold text-gray-900 dark:text-white">Choose Model</span>
|
|
<select
|
|
value={model}
|
|
onChange={(event) => setModel(event.target.value)}
|
|
className="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm dark:border-gray-700 dark:bg-gray-950 dark:text-white"
|
|
>
|
|
<option>Explorer AI</option>
|
|
<option>Grok</option>
|
|
</select>
|
|
</label>
|
|
<div>
|
|
<div className="mb-2 text-sm font-semibold text-gray-900 dark:text-white">File Browser</div>
|
|
<div className="space-y-2 rounded-lg bg-gray-50 p-3 dark:bg-gray-900">
|
|
{files.map((file) => (
|
|
<label key={file.path} className="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-200">
|
|
<input type="checkbox" checked readOnly className="h-4 w-4 rounded border-gray-300 text-primary-600" />
|
|
<span className="truncate">{file.path}</span>
|
|
</label>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="min-w-0 space-y-4">
|
|
<div className="flex items-center justify-between gap-3">
|
|
<div className="text-sm font-semibold text-gray-900 dark:text-white">Prompt</div>
|
|
<label className="flex items-center gap-2 text-xs text-gray-600 dark:text-gray-300">
|
|
<input type="checkbox" checked={saveHistory} onChange={(event) => setSaveHistory(event.target.checked)} className="h-4 w-4 rounded border-gray-300 text-primary-600" />
|
|
Save History
|
|
</label>
|
|
</div>
|
|
<div className="flex gap-3">
|
|
<textarea
|
|
value={prompt}
|
|
onChange={(event) => setPrompt(event.target.value)}
|
|
rows={3}
|
|
className="min-h-24 flex-1 rounded-lg border border-gray-300 px-3 py-2 text-sm dark:border-gray-700 dark:bg-gray-950 dark:text-white"
|
|
/>
|
|
<button
|
|
type="submit"
|
|
disabled={submitting || !prompt.trim()}
|
|
className="h-12 rounded-lg bg-primary-600 px-4 text-sm font-semibold text-white transition hover:bg-primary-700 disabled:cursor-not-allowed disabled:opacity-60"
|
|
>
|
|
{submitting ? '...' : 'Send'}
|
|
</button>
|
|
</div>
|
|
<div className="flex flex-wrap gap-2">
|
|
{QUICK_PROMPTS.map((quickPrompt) => (
|
|
<button
|
|
key={quickPrompt}
|
|
type="button"
|
|
onClick={() => void askReader(quickPrompt)}
|
|
className="rounded-full border border-gray-300 px-3 py-1.5 text-xs font-semibold text-gray-700 hover:border-primary-400 hover:text-primary-700 dark:border-gray-700 dark:text-gray-300 dark:hover:text-primary-300"
|
|
>
|
|
{quickPrompt}
|
|
</button>
|
|
))}
|
|
</div>
|
|
{readerAnswer ? (
|
|
<div className="whitespace-pre-wrap rounded-lg bg-gray-50 p-4 text-sm text-gray-800 dark:bg-gray-900 dark:text-gray-100">
|
|
{readerAnswer}
|
|
</div>
|
|
) : null}
|
|
{readerError ? (
|
|
<div className="rounded-lg border border-red-200 bg-red-50 p-3 text-sm text-red-700 dark:border-red-900/60 dark:bg-red-950/40 dark:text-red-200">
|
|
{readerError}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
</form>
|
|
</div>
|
|
)}
|
|
</section>
|
|
</Card>
|
|
)
|
|
}
|