-
Notifications
You must be signed in to change notification settings - Fork 74
Particles
Particles is a UI toolkit designed to answer the following question: "Now that LLMs can generate UI code, what's the right way to do this"? The word "right" is load bearing here and contains various intuitions and practices that guide the overall design -- aka "the Opinion".
The following assertions comprise the guiding principles of the toolkit design:
-
Sandbox the LLM -- Code that is generated by LLMs will have to be sandboxed to run safely. More specifically and as applies to the UI, code that drives UI:
- must be able to do so asynchronously (though with minimal latency) from the code that renders UI;
- must not be able to operate directly on the UI.
- Separate rendering from semantics -- The oldie-but-goodie from HTML, but now with feeling. Code that renders UI must have control in how UI is presented. Code that drives UI must be able to provide the semantics of what it's trying to convey, but not the exact rendering.
- Lean on signals -- it looks like signals are the future of Web UI, so might as well wholeheartedly adopt them.
- Prefer concrete use cases -- use the concrete use cases from implementations (like the Breadboard project) to inform priorities and overall shape of design. Theoretical explorations are great, but practical problems are better.
Because the sandbox is the key design constraint, it figures prominently in the overall approach. Very loosely speaking, Particles splits the typical MVC setup into two layers:
- The Emitter, a Model + Controller combination, located inside of the sandbox
- The Receiver, a View, located outside of the sandbox.
The atomic unit is a Particle, representing a chunk of information to be presented in the UI. Each particle has semantics associated with it. Emitter produces particles and Receiver consumes them.
Additionally, there's a Pipe layer that handles the shuttling of particles between the Emitter and the Receiver layers.
flowchart BT
u["UI code"]
v["Receiver (View)"]
t["Pipe"]
mc["Emitter (Model + Controller)"]
l["LLM-generated code"]
subgraph Sandbox
l -- "Calls to produce particles" --> mc
end
mc -- "Uses to send particles" --> t
t -- "Provides received particles" --> v
subgraph Outside of sandbox
v -- "Presents signal-backed structure for rendering" --> u
end
The communication also goes in the other direction, facilitating events, sent by the UI back to code that drives the UI
// TODO: Invent this.
Particles are meant to be very lightweight data structures.
There are three types of particle:
- Text particle represents text information of various types: HTML, markdown, plain text, JSON, etc.
- Data particle represents binary information, like media (audio/video/image) or anything that requires rendering-specific handling (PDF document, Google Drive file, etc.)
-
Group particle represents a logical grouping of particles (think
div).
Here are their type definitions:
type TextParticle = {
/**
* Content of the particle.
*/
text: string;
/**
* The type of the content. If omitted, "text/markdown" is assumed.
*/
mimeType?: string;
}
type DataParticle = {
/**
* A URL that points to the data.
*/
data: string;
/**
* The type of the data.
*/
mimeType: string;
}
type GroupParticle = {
/**
* The sub-particles that are part of this group.
*/
group: Map<ParticleIdentifier, Particle>;
/**
* The type of a group. Allows the particle to be bound to a particular
* UI element. Optional. If not specified, the group particle doesn't have
* an opinion about its type (think "generic grouping").
*/
type?: string;
};
type Particle =
| TextParticle
| DataParticle
| GroupParticle;
type ParticleIdentifier = string;The Group particle plays a significant role at the Receiver layer. In particular, the group property being a Map is where the signals come in: its concrete implementation is a SignalMap, which allows UI to have both reactivity and stable rendering order for the elements of the group using Map's keys (using Lit repeat directive, for example).
Also, because Group particle may contain other particles, it allows producing a Particle Tree: a hierarchy of particles that represents the information that the Emitter wants to convey.
Thus, the structure that Receiver presents to its consumer is one or more instances of particle trees, with the Group particle serving as the root of each tree.
One of the key desired properties of the design is the ability for the Emitter to continue updating a particle tree that it drives, and for these changes to be reflected on the Receiver side.
To achieve this, the Emitter sends particles as a stream of changes to the Pipe layer, and then these changes are applied to the tree by the Receiver.
So, the Emitter layer API for sending particles acts more like writing to a stream, while the Receiver layer API looks like reading a tree (the particle tree).
The API for the Emitter layer combines stream-writing-like properties and DOM manipulation in one API. It allows creating Particle Beams: the instances that are backed by streams that connect to the Pipe layer.
Here's an API sketch (TBD):
// creates a new Group particle implicitly
// "type" allows specifying Group type.
const beam = new Beam({ type: "info" });
beam.open();
// adds a new particle to the beam's root Group
beam.append("config", Particle.json({ icon, title }));
// and another one
beam.append("input", Particle.json(input));
// and another one
beam.append("output", Particle.fromLLLMContent(content));
/// now insert something in front of another particle
beam.insertBefore("input", "progress", Particle.text("updating"));
// ... do some work
// remove the input progress indicator.
beam.remove("progress");
// close the stream
beam.close();