236 lines
7.5 KiB
TypeScript
236 lines
7.5 KiB
TypeScript
'use client';
|
|
|
|
import { useState, useEffect, useRef, useCallback } from 'react';
|
|
import { CheckCircle2, Circle, ArrowRight, Loader2 } from 'lucide-react';
|
|
|
|
interface DiscoveredTool {
|
|
name: string;
|
|
method: string;
|
|
description: string;
|
|
paramCount: number;
|
|
enabled: boolean;
|
|
}
|
|
|
|
interface AnalysisStreamProps {
|
|
/** The analysis ID or spec input to stream */
|
|
analysisInput: { type: 'url' | 'raw'; value: string };
|
|
/** Called when analysis completes with selected tools */
|
|
onComplete: (tools: DiscoveredTool[]) => void;
|
|
/** Called if user clicks Continue */
|
|
onContinue: (tools: DiscoveredTool[]) => void;
|
|
}
|
|
|
|
const METHOD_COLORS: Record<string, string> = {
|
|
GET: 'bg-emerald-500/20 text-emerald-400 border-emerald-500/30',
|
|
POST: 'bg-blue-500/20 text-blue-400 border-blue-500/30',
|
|
PUT: 'bg-amber-500/20 text-amber-400 border-amber-500/30',
|
|
PATCH: 'bg-orange-500/20 text-orange-400 border-orange-500/30',
|
|
DELETE: 'bg-red-500/20 text-red-400 border-red-500/30',
|
|
};
|
|
|
|
export function AnalysisStream({
|
|
analysisInput,
|
|
onComplete,
|
|
onContinue,
|
|
}: AnalysisStreamProps) {
|
|
const [tools, setTools] = useState<DiscoveredTool[]>([]);
|
|
const [progress, setProgress] = useState(0);
|
|
const [step, setStep] = useState('Initializing...');
|
|
const [done, setDone] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const eventSourceRef = useRef<EventSource | null>(null);
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
|
|
useEffect(() => {
|
|
const params = new URLSearchParams();
|
|
params.set('type', analysisInput.type);
|
|
params.set('value', analysisInput.value);
|
|
|
|
const es = new EventSource(`/api/analyze?${params.toString()}`);
|
|
eventSourceRef.current = es;
|
|
|
|
es.addEventListener('progress', (e) => {
|
|
try {
|
|
const data = JSON.parse(e.data);
|
|
setProgress(data.percent || 0);
|
|
setStep(data.step || '');
|
|
} catch {}
|
|
});
|
|
|
|
es.addEventListener('tool_found', (e) => {
|
|
try {
|
|
const data = JSON.parse(e.data);
|
|
const tool: DiscoveredTool = {
|
|
name: data.name || 'Unknown',
|
|
method: data.method || 'GET',
|
|
description: data.description || '',
|
|
paramCount: data.paramCount || Object.keys(data.inputSchema?.properties || {}).length || 0,
|
|
enabled: true,
|
|
};
|
|
setTools((prev) => [...prev, tool]);
|
|
} catch {}
|
|
});
|
|
|
|
es.addEventListener('complete', (e) => {
|
|
try {
|
|
const data = JSON.parse(e.data);
|
|
setProgress(100);
|
|
setStep('Analysis complete');
|
|
setDone(true);
|
|
|
|
// If complete event includes all tools, sync them
|
|
if (data.tools && Array.isArray(data.tools)) {
|
|
const allTools: DiscoveredTool[] = data.tools.map((t: any) => ({
|
|
name: t.name || 'Unknown',
|
|
method: t.method || 'GET',
|
|
description: t.description || '',
|
|
paramCount: t.paramCount || Object.keys(t.inputSchema?.properties || {}).length || 0,
|
|
enabled: true,
|
|
}));
|
|
setTools(allTools);
|
|
onComplete(allTools);
|
|
}
|
|
} catch {}
|
|
es.close();
|
|
});
|
|
|
|
es.addEventListener('error', (e) => {
|
|
try {
|
|
const data = JSON.parse((e as any).data || '{}');
|
|
setError(data.message || 'Analysis failed');
|
|
} catch {
|
|
setError('Connection lost. Analysis may have failed.');
|
|
}
|
|
es.close();
|
|
});
|
|
|
|
es.onerror = () => {
|
|
if (!done) {
|
|
setError('Connection lost');
|
|
es.close();
|
|
}
|
|
};
|
|
|
|
return () => {
|
|
es.close();
|
|
};
|
|
}, [analysisInput, done, onComplete]);
|
|
|
|
// Auto-scroll as tools appear
|
|
useEffect(() => {
|
|
if (containerRef.current) {
|
|
containerRef.current.scrollTop = containerRef.current.scrollHeight;
|
|
}
|
|
}, [tools]);
|
|
|
|
const toggleTool = useCallback((index: number) => {
|
|
setTools((prev) =>
|
|
prev.map((t, i) => (i === index ? { ...t, enabled: !t.enabled } : t)),
|
|
);
|
|
}, []);
|
|
|
|
const enabledCount = tools.filter((t) => t.enabled).length;
|
|
|
|
return (
|
|
<div className="w-full space-y-6">
|
|
{/* Progress bar */}
|
|
<div className="space-y-2">
|
|
<div className="flex items-center justify-between text-sm">
|
|
<span className="text-gray-400">{step}</span>
|
|
<span className="text-gray-500 font-mono">{Math.round(progress)}%</span>
|
|
</div>
|
|
<div className="w-full h-2 bg-gray-800 rounded-full overflow-hidden">
|
|
<div
|
|
className="h-full bg-gradient-to-r from-indigo-600 to-indigo-400 rounded-full
|
|
transition-all duration-500 ease-out"
|
|
style={{ width: `${progress}%` }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Error state */}
|
|
{error && (
|
|
<div className="p-4 rounded-xl bg-red-500/10 border border-red-500/20 text-red-400 text-sm">
|
|
{error}
|
|
</div>
|
|
)}
|
|
|
|
{/* Tool list */}
|
|
<div
|
|
ref={containerRef}
|
|
className="space-y-2 max-h-[400px] overflow-y-auto pr-1"
|
|
style={{ scrollbarWidth: 'thin', scrollbarColor: '#374151 transparent' }}
|
|
>
|
|
{tools.map((tool, i) => (
|
|
<div
|
|
key={`${tool.name}-${i}`}
|
|
className="flex items-center gap-3 p-3 rounded-lg bg-gray-800/50 border border-gray-800
|
|
animate-in slide-in-from-bottom-2 duration-300"
|
|
style={{ animationDelay: `${i * 50}ms`, animationFillMode: 'both' }}
|
|
>
|
|
{/* Checkbox */}
|
|
<button
|
|
onClick={() => toggleTool(i)}
|
|
className="flex-shrink-0"
|
|
>
|
|
{tool.enabled ? (
|
|
<CheckCircle2 className="h-5 w-5 text-indigo-400" />
|
|
) : (
|
|
<Circle className="h-5 w-5 text-gray-600" />
|
|
)}
|
|
</button>
|
|
|
|
{/* Method badge */}
|
|
<span
|
|
className={`
|
|
flex-shrink-0 px-2 py-0.5 rounded text-xs font-mono font-bold border
|
|
${METHOD_COLORS[tool.method] || METHOD_COLORS.GET}
|
|
`}
|
|
>
|
|
{tool.method}
|
|
</span>
|
|
|
|
{/* Tool info */}
|
|
<div className="flex-1 min-w-0">
|
|
<span className="text-sm font-medium text-gray-200">{tool.name}</span>
|
|
<p className="text-xs text-gray-500 truncate">{tool.description}</p>
|
|
</div>
|
|
|
|
{/* Param count */}
|
|
<span className="flex-shrink-0 text-xs text-gray-600 font-mono">
|
|
{tool.paramCount}p
|
|
</span>
|
|
</div>
|
|
))}
|
|
|
|
{/* Loading indicator while streaming */}
|
|
{!done && !error && (
|
|
<div className="flex items-center gap-2 p-3 text-gray-500 text-sm">
|
|
<Loader2 className="h-4 w-4 animate-spin" />
|
|
Discovering tools...
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Summary + Continue */}
|
|
{done && tools.length > 0 && (
|
|
<div className="flex items-center justify-between pt-4 border-t border-gray-800">
|
|
<span className="text-sm text-gray-400">
|
|
{enabledCount} of {tools.length} tools selected
|
|
</span>
|
|
<button
|
|
onClick={() => onContinue(tools.filter((t) => t.enabled))}
|
|
className="inline-flex items-center gap-2 px-6 py-2.5 rounded-lg
|
|
font-medium text-sm bg-indigo-600 text-white
|
|
hover:bg-indigo-500 transition-colors
|
|
shadow-lg shadow-indigo-600/20"
|
|
>
|
|
Continue to Editor
|
|
<ArrowRight className="h-4 w-4" />
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|