mirror of
https://github.com/hatchet-dev/hatchet.git
synced 2026-03-23 13:11:38 -05:00
307 lines
8.7 KiB
TypeScript
307 lines
8.7 KiB
TypeScript
import React, { useState, useEffect } from "react";
|
|
import { brand, state, inactive, container } from "./diagram-colors";
|
|
|
|
const PHASES = ["reason", "action", "observation"] as const;
|
|
type Phase = (typeof PHASES)[number];
|
|
|
|
const PHASE_CONFIG: Record<Phase, { label: string; color: string }> = {
|
|
reason: { label: "Reason", color: brand.blue },
|
|
action: { label: "Action", color: brand.magenta },
|
|
observation: { label: "Observation", color: state.running },
|
|
};
|
|
|
|
/** Small SVG icons rendered inline, no emojis */
|
|
const PhaseIcon: React.FC<{ phase: Phase; active: boolean }> = ({
|
|
phase,
|
|
active,
|
|
}) => {
|
|
const color = active ? PHASE_CONFIG[phase].color : inactive.text;
|
|
const size = 18;
|
|
|
|
switch (phase) {
|
|
case "reason":
|
|
return (
|
|
<svg
|
|
width={size}
|
|
height={size}
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke={color}
|
|
strokeWidth="2"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
>
|
|
<path d="M9 18h6" />
|
|
<path d="M10 22h4" />
|
|
<path d="M12 2a7 7 0 0 0-4 12.7V17h8v-2.3A7 7 0 0 0 12 2z" />
|
|
</svg>
|
|
);
|
|
case "action":
|
|
return (
|
|
<svg
|
|
width={size}
|
|
height={size}
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke={color}
|
|
strokeWidth="2"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
>
|
|
<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2" />
|
|
</svg>
|
|
);
|
|
case "observation":
|
|
return (
|
|
<svg
|
|
width={size}
|
|
height={size}
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke={color}
|
|
strokeWidth="2"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
>
|
|
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
|
|
<circle cx="12" cy="12" r="3" />
|
|
</svg>
|
|
);
|
|
}
|
|
};
|
|
|
|
const AgentLoopDiagram: React.FC = () => {
|
|
const [phaseIdx, setPhaseIdx] = useState(0);
|
|
const [iteration, setIteration] = useState(1);
|
|
|
|
useEffect(() => {
|
|
const interval = setInterval(() => {
|
|
setPhaseIdx((prev) => {
|
|
if (prev === PHASES.length - 1) {
|
|
setIteration((i) => (i >= 3 ? 1 : i + 1));
|
|
return 0;
|
|
}
|
|
return prev + 1;
|
|
});
|
|
}, 1400);
|
|
return () => clearInterval(interval);
|
|
}, []);
|
|
|
|
const phase = PHASES[phaseIdx];
|
|
|
|
const svgW = 520;
|
|
const svgH = 160;
|
|
const nodeY = 70;
|
|
const nodeSpacing = 160;
|
|
const startX = 100;
|
|
|
|
const nodes = PHASES.map((_, i) => ({
|
|
x: startX + i * nodeSpacing,
|
|
y: nodeY,
|
|
}));
|
|
|
|
return (
|
|
<div
|
|
className="my-6 rounded-xl border p-6"
|
|
style={{
|
|
borderColor: container.border,
|
|
backgroundColor: container.bg,
|
|
}}
|
|
>
|
|
<svg
|
|
viewBox={`0 0 ${svgW} ${svgH}`}
|
|
className="mx-auto w-full"
|
|
style={{ maxWidth: 560 }}
|
|
>
|
|
<defs>
|
|
<marker
|
|
id="arrow"
|
|
viewBox="0 0 10 7"
|
|
refX="9"
|
|
refY="3.5"
|
|
markerWidth="8"
|
|
markerHeight="6"
|
|
orient="auto-start-reverse"
|
|
>
|
|
<polygon points="0 0, 10 3.5, 0 7" fill={inactive.stroke} />
|
|
</marker>
|
|
<marker
|
|
id="arrow-reason"
|
|
viewBox="0 0 10 7"
|
|
refX="9"
|
|
refY="3.5"
|
|
markerWidth="8"
|
|
markerHeight="6"
|
|
orient="auto-start-reverse"
|
|
>
|
|
<polygon points="0 0, 10 3.5, 0 7" fill={brand.blue} />
|
|
</marker>
|
|
<marker
|
|
id="arrow-action"
|
|
viewBox="0 0 10 7"
|
|
refX="9"
|
|
refY="3.5"
|
|
markerWidth="8"
|
|
markerHeight="6"
|
|
orient="auto-start-reverse"
|
|
>
|
|
<polygon points="0 0, 10 3.5, 0 7" fill={brand.magenta} />
|
|
</marker>
|
|
<marker
|
|
id="arrow-observation"
|
|
viewBox="0 0 10 7"
|
|
refX="9"
|
|
refY="3.5"
|
|
markerWidth="8"
|
|
markerHeight="6"
|
|
orient="auto-start-reverse"
|
|
>
|
|
<polygon points="0 0, 10 3.5, 0 7" fill={state.running} />
|
|
</marker>
|
|
</defs>
|
|
|
|
{/* Forward arrows between nodes */}
|
|
{nodes.slice(0, -1).map((from, i) => {
|
|
const to = nodes[i + 1];
|
|
const isActive = phaseIdx === i;
|
|
const arrowMarkerId = isActive ? `arrow-${PHASES[i]}` : "arrow";
|
|
return (
|
|
<line
|
|
key={`fwd-${i}`}
|
|
x1={from.x + 34}
|
|
y1={from.y}
|
|
x2={to.x - 34}
|
|
y2={to.y}
|
|
stroke={
|
|
isActive ? PHASE_CONFIG[PHASES[i]].color : inactive.stroke
|
|
}
|
|
strokeWidth={isActive ? 2 : 1.5}
|
|
markerEnd={`url(#${arrowMarkerId})`}
|
|
opacity={isActive ? 1 : 0.5}
|
|
style={{ transition: "all 0.4s ease" }}
|
|
/>
|
|
);
|
|
})}
|
|
|
|
{/* Return arrow: curved path from Observation back to Reason */}
|
|
{(() => {
|
|
const from = nodes[nodes.length - 1];
|
|
const to = nodes[0];
|
|
const isActive = phaseIdx === PHASES.length - 1;
|
|
const curveY = nodeY + 58;
|
|
return (
|
|
<path
|
|
d={`M ${from.x} ${from.y + 30} C ${from.x} ${curveY + 10}, ${to.x} ${curveY + 10}, ${to.x} ${to.y + 30}`}
|
|
fill="none"
|
|
stroke={
|
|
isActive ? PHASE_CONFIG.observation.color : inactive.stroke
|
|
}
|
|
strokeWidth={isActive ? 2 : 1.5}
|
|
strokeDasharray="6 4"
|
|
opacity={isActive ? 1 : 0.35}
|
|
markerEnd={isActive ? "url(#arrow-observation)" : "url(#arrow)"}
|
|
style={{ transition: "all 0.4s ease" }}
|
|
/>
|
|
);
|
|
})()}
|
|
|
|
{/* Loop label on return arrow */}
|
|
<text
|
|
x={svgW / 2}
|
|
y={nodeY + 78}
|
|
textAnchor="middle"
|
|
fontSize="10"
|
|
fill={inactive.text}
|
|
fontStyle="italic"
|
|
>
|
|
iteration {iteration}/3
|
|
</text>
|
|
|
|
{/* Phase nodes */}
|
|
{PHASES.map((p, i) => {
|
|
const pos = nodes[i];
|
|
const config = PHASE_CONFIG[p];
|
|
const isActive = phase === p;
|
|
|
|
return (
|
|
<g key={p}>
|
|
{isActive && (
|
|
<rect
|
|
x={pos.x - 36}
|
|
y={pos.y - 36}
|
|
width={72}
|
|
height={72}
|
|
rx={16}
|
|
fill={config.color}
|
|
opacity={0.1}
|
|
>
|
|
<animate
|
|
attributeName="opacity"
|
|
values="0.1;0.05;0.1"
|
|
dur="1.8s"
|
|
repeatCount="indefinite"
|
|
/>
|
|
</rect>
|
|
)}
|
|
<rect
|
|
x={pos.x - 30}
|
|
y={pos.y - 30}
|
|
width={60}
|
|
height={60}
|
|
rx={14}
|
|
fill={isActive ? `${config.color}18` : brand.navy}
|
|
stroke={isActive ? config.color : inactive.stroke}
|
|
strokeWidth={isActive ? 2 : 1.5}
|
|
style={{ transition: "all 0.4s ease" }}
|
|
/>
|
|
<foreignObject
|
|
x={pos.x - 9}
|
|
y={pos.y - 18}
|
|
width={18}
|
|
height={18}
|
|
>
|
|
<PhaseIcon phase={p} active={isActive} />
|
|
</foreignObject>
|
|
<text
|
|
x={pos.x}
|
|
y={pos.y + 14}
|
|
textAnchor="middle"
|
|
fontSize="10"
|
|
fontWeight={isActive ? 600 : 400}
|
|
fill={isActive ? config.color : brand.cyanDark}
|
|
style={{ transition: "all 0.4s ease" }}
|
|
>
|
|
{config.label}
|
|
</text>
|
|
</g>
|
|
);
|
|
})}
|
|
</svg>
|
|
|
|
{/* Status indicators */}
|
|
<div className="mt-3 flex items-center justify-center gap-3">
|
|
{PHASES.map((p) => (
|
|
<div
|
|
key={p}
|
|
className="flex items-center gap-1.5 rounded-full px-3 py-1 text-xs font-medium"
|
|
style={{
|
|
backgroundColor:
|
|
phase === p
|
|
? `${PHASE_CONFIG[p].color}20`
|
|
: "rgba(10, 16, 41, 0.3)",
|
|
color: phase === p ? PHASE_CONFIG[p].color : inactive.text,
|
|
border: `1px solid ${phase === p ? `${PHASE_CONFIG[p].color}40` : "transparent"}`,
|
|
transition: "all 0.4s ease",
|
|
}}
|
|
>
|
|
<PhaseIcon phase={p} active={phase === p} />
|
|
{PHASE_CONFIG[p].label}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default AgentLoopDiagram;
|