Rick Winfrey

Animating Generative AI Text with Promises and the Y-Combinator

April 15, 2023 | 13 minutes (2366 words)

The core concept was simple: take some AI-generated text, iterate over its characters or words, and animate each by fading it from fully transparent to fully opaque in the DOM. To make things more interesting, I created an abstraction over JavaScript's Promise constructor, using a series of combinators. The result is a sort of mini-promise engine for declarative chaining that uses the Y-combinator for recursion. This has no practical purpose beyond satisfying my curiosity about JavaScript promises. (Note: this code is not intended for production use!)

For this experiment, I created three animations described below. Some technical details about the implementation follow after the animations.


Character-by-character animation

This animation uses a character-by-character iterator. The "Delay" slider controls the amount of delay (in milliseconds) between each iteration of the input string representing text produced by a generative AI system. In this case, the input string is a poem created by a LLM.

On each iteration a new character is added to a queue. The "Opacity steps" slider controls the number of "steps" a character's opacity is updated from 0 (transparent) to 100 (opaque). Each character’s opacity increases on every animation tick until it reaches 100%, at which point it’s committed to the DOM and removed from the queue. At that point the character is "set" in the DOM without any further updates and is dropped from the iterator's queue.

Feel free to experiment with different values of "Delay" and "Opacity steps". More delay means a longer pause before the next iteration, and provides the illusion of a slower overall animation of the input string. More opacity steps adjust a variable opacity value added to each character per iteration, and is equivalent to character.opacity += steps / 100, and provides the illusion of a slower "fade-in" of each character.

20
5


Word-by-word animation

This animation works like the character-based one but iterates over words instead of individual characters.

In this animation, the "Delay" and "Opacity steps" sliders are more impactful compared with the character-by-character animation.

Also feel free to experiment with the different buttons. The animation plays by default, but you can pause and resume it at any time. This behavior is a byproduct of the underlying implementation, which uses anonymous recursive promises to chain the iteration and animation updates. The "Clear" button stops the animation and resets the animation state and clears the output stream.

20
20


Word-by-word with probability animation

This animation indicates the probability of the generating word as it is added to the output stream. It also reveals the word's probability on hover.

20
20


Animating with promises

This experiment started by creating a lightweight combinator abstracting over JavaScript's Promise constructor (i.e. new Promise((resolve, reject) => ...)). My motivation was to both explore animating text streamed from an imaginary generative AI system, and learn about JavaScript promises. My goal for this collection of combinators was to provide a declarative combinator DSL, making it possible to separate the promise-based logic and management from the animation iteration.

Combinator building blocks

At the heart of this experiment is a collection of combinators that abstract promise construction, and resolving promises:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// Returns a promise that resolves after executing the given operation.
function promise(op) {
  return new Promise((resolve, reject) => op(resolve, reject))
}

// Returns a function that resolves a promise with the given operation.
function resolveWith(op) {
  return ((resolve) => op(resolve));
}

// Returns a function that resolves a promise with the given operation,
// allowing the operation to be called recursively.
function resolveWithFix(op) {
  return ((resolve) => op(op, resolve));
}

The resolveWithFix combinator is the key to enabling recursive operations, allowing the operation to call itself with the same parameters. This mirrors the idea behind fixed-point combinators like the Y-combinator, which allows for recursion without named functions. In this case, the fixed-point combinator enables recursion using anonymous promise-based operations instead of named functions. The resolveWithFix combinator allows recursive promise operations until the recurring operation fully resolves. In this context, the op is one of the three presented iterators. This fixed-point combinator enables stateful iteration without imperative loops.

The combinators alone are not sufficient to create the animations. We need to define how the operations will be executed and how they will interact with the DOM. There are many possible ways to implement this, but I've chosen to focus on recursive async iteration.

Recursive iterators for animation

I wanted to push promises all the way down the stack, and created iterators that use the above combinators to manage animation state and DOM updates in which every iteration is the resolution of a single promise. The simplest of the iterators is the charIterator which iterates over each character in a string, updating the opacity of each character over time. The wordIterator does the same for words, and the wordWithProbabilityIterator adds a probability value to each word, revealing it on hover. Below is the charIterator implementation:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
function charIterator(target, input, externalOpts = {}) {
  // Initialize and close over the iterator state.
  const opts = {
    ...externalOpts,
    index: 0,
    input: input,
    queue: [],
    output: "",
    outputWithOpacity: "",
    initialOpacity: 0, // Initial opacity for characters.
    steps: externalOpts.steps ?? 20, // Number of steps for the animation. Increasing this will make the animation appear "smoother" and its duration longer.
    delay: externalOpts.delay ?? 20, // Delay applied to each recursive call in milliseconds. Increasing this will make the animation appear slower.
    status: "running",
  };
  function reset() {
    opts.index = 0;
    opts.queue = [];
    opts.outputWithOpacity = "";
    opts.status = "paused";
  }
  var domCtx = document.getElementById(target);

  const runner = function(op, next) {
    promise(
      resolveWith((next) => {
        setTimeout(() => {
          promise(
            resolveWith((next) => {
              opts.output = "";
              if (opts.index < opts.input.length) {
                const char = input[opts.index];
                opts.queue.push({ char: char, opacity: opts.initialOpacity });
              }
              return next();
            })
          ).then(() => {
            opts.queue = opts.queue.filter((current) => {
              if (current.opacity >= 100) {
                if (current.char === '\n') {
                  opts.outputWithOpacity += `<br>`;
                  return false;
                }

                opts.outputWithOpacity += `<span class="set" style="opacity: ${current.opacity}%">${current.char}</span>`;
                return false;
              }

              current.opacity += (100 / opts.steps);
              if (current.char === '\n') {
                opts.output += `<br>`;
              } else {
                opts.output += `<span style="opacity: ${current.opacity}%">${current.char}</span>`;
              }

              return true;
            });
          })
          return next();
        }, opts.delay);
      })
    ).then(() =>
      promise(
          resolveWith((next) => {
            domCtx.innerHTML = opts.outputWithOpacity + opts.output;
            opts.index++;
            return next();
          })
      )
    ).then(() => {
      // If the status is paused or canceled, we abort the recursion.
      if (opts.status !== "running") return next();

      // If the index exceeds the input string length and the queue is empty, we can abort the recursion,
      // or we can reset the state of the iterator, and loop the animation endlessly.
      if (opts.index >= opts.input.length && opts.queue.length == 0) {
        //return next(); // This is where we would abort.
        reset();
        opts.status = "running";
        domCtx.innerHTML = "";
      }

      // Otherwise, we use a Y-combinator to continue the recursion.
      op(op, next, opts)
    })
  }

  return { runner, opts, reset };
}

The charIterator is invoked using one last combinator named animate:

1
2
3
4
// Animate a target element using the provided iterator and input string.
function animate(target, input, iterator, opts) {
  return resolveWithFix(iterator(target, input, opts));
}

What I find interesting about this implementation is the side-effect of an iterator for which every iteration is an async operation. This asynchrony affords very precise control over the animation, and the promise chain used by the iterators allows decomposing the animation sequence into separate promise resolutions. The first promise resolution represents a single iteration, management of the work queue and its elements' states. The second promise resolution updates the iterator's index position, and updates the DOM. The third promise resolution applies the recursive step or aborts if the base cases are met. Although the iterator is essentially a sequential, imperative program, it is a good way to exercise asynchronous semantics and to separate concerns across distinct stages of the promise chain.

Why this matters

I'm a strong believer in curiosity-driven development and creative play. This experiment was both a challenging and fun dive into an unusual application of asynchronous programming, modeling iterator evaluation entirely through JavaScript promises. Surprisingly, the combinator-based approach to wrapping and resolving promises allowed for a declarative DSL for managing promises. Exploring the interplay between UI buttons as an asynchronous signal, and the interruption or resuming of a recursive iterator promise chain also revealed how asynchronous execution allows separating layers related to some computation into distinct stages or steps of an async workflow. I hope you found something here that sparked a new idea, or at least enjoyed playing with the animations.