Add deixis UIWorker example

A ReplyToolMixin UIWorker that grounds in the user's text selection (the
<selection> block in the snapshot) and points back via select_text — both
directions of deictic reference.
This commit is contained in:
Mark Backman
2026-05-21 17:21:08 -04:00
parent 81b956d963
commit f826da9ac9
8 changed files with 1956 additions and 0 deletions

View File

@@ -0,0 +1,101 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Deixis — UIAgent demo</title>
<link rel="stylesheet" href="./styles.css" />
</head>
<body>
<header>
<h1>Reading room</h1>
<button id="connect" type="button">Connect</button>
</header>
<main>
<article aria-label="Octopus cognition">
<h2>What octopuses can teach us about minds</h2>
<p class="lede">
A short tour of the strangest cognitive machine on Earth.
Select any paragraph and ask the assistant to explain it,
rephrase it, or place it in context.
</p>
<p>
An octopus has roughly five hundred million neurons, about
the same as a dog. But the wiring is unlike any vertebrate
brain. Two thirds of those neurons live in the arms, not in
the central brain. Each arm runs a substantial amount of
its own motor planning and sensory integration locally,
which is why a severed octopus arm will continue to react
to touch and even grab nearby food for several minutes.
</p>
<p>
Their skin is its own information system. Underneath are
millions of pigment cells called chromatophores, each one
opened or closed by a tiny ring of muscles. Layered below
those are iridophores and leucophores, which scatter or
reflect light. Together they let an octopus reproduce the
color and texture of a coral, a rock, or a sandy bottom in
fractions of a second. The remarkable detail is that
octopuses are mostly colorblind, and yet they match colors
accurately. The leading hypothesis is that the skin itself
has photoreceptors and senses light directly.
</p>
<p>
Cephalopods edit their RNA at extraordinary rates. Most
animals make small, occasional substitutions in messenger
RNA before it is translated into protein. Octopuses,
squids, and cuttlefish make tens of thousands of edits, and
a substantial fraction occur in the genes that build
neurons. This may be how they fine-tune neural function in
response to temperature without slow generations of natural
selection. The trade-off appears to be a much slower rate
of underlying genetic evolution.
</p>
<p>
They solve novel problems. Captive octopuses learn to open
screw-top jars, navigate mazes, distinguish individual
human caretakers, and remember which ones have been
unkind. There is at least one careful study in which an
octopus, given a transparent box containing food, opened
it the long way around rather than through the obvious
flap, suggesting some kind of mental simulation rather than
pure trial and error.
</p>
<p>
Their relationship to time is strange. Most octopus species
live one or two years, breed once, and die soon after, in a
process driven by hormonal signals from the optic gland. A
single animal can therefore acquire a remarkable range of
skills, only to lose them on a schedule. From a human
standpoint this looks tragic. From an evolutionary
standpoint it is simply the cost of investing everything in
a brief, intense life.
</p>
<p>
Studying octopuses tends to widen the definition of mind.
They evolved their cognition independently of vertebrates,
starting from a common ancestor more than five hundred
million years ago, when the most sophisticated neural
tissue on the planet was probably a nerve net. Whatever
they do with their distributed brains, they arrived at it
on a separate evolutionary line. The result is a working
example of intelligence built on a different plan, which
is exactly the kind of comparison that a single-example
field of study most needs.
</p>
</article>
</main>
<div id="status" aria-live="polite"></div>
<audio id="bot-audio" autoplay data-a11y-exclude></audio>
<script type="module" src="./main.js"></script>
</body>
</html>

View File

@@ -0,0 +1,185 @@
/**
* Deixis — vanilla JS client.
*
* Same base wiring as pointing (PipecatClient +
* managed snapshot streaming + bot audio sink), with three command
* handlers: ``scroll_to``, ``highlight``, and ``select_text``.
*
* The interesting one is ``select_text``: it puts the OS-level text
* selection on the referenced element, so when the agent says
* "this paragraph here" the user sees exactly which paragraph it
* means. The READ direction (user selection) flows the other way —
* Managed snapshot streaming automatically captures
* ``window.getSelection()`` and emits a ``<selection ref=...>...
* </selection>`` block in the snapshot the server sees.
*/
import {
PipecatClient,
RTVIEvent,
findElementByRef,
} from "@pipecat-ai/client-js";
import { SmallWebRTCTransport } from "@pipecat-ai/small-webrtc-transport";
const BOT_URL = "http://localhost:7860/api/offer";
const connectButton = document.getElementById("connect");
const status = document.getElementById("status");
const botAudio = document.getElementById("bot-audio");
let client;
let unsubscribes = [];
function setStatus(text, autoHideMs = 0) {
status.textContent = text;
status.dataset.show = text ? "1" : "0";
if (text && autoHideMs > 0) {
setTimeout(() => {
if (status.textContent === text) status.dataset.show = "0";
}, autoHideMs);
}
}
function resolveTarget(payload) {
if (payload?.ref) {
const el = findElementByRef(payload.ref);
if (el) return el;
}
if (payload?.target_id) {
return document.getElementById(payload.target_id);
}
return null;
}
function handleScrollTo(payload) {
const el = resolveTarget(payload);
if (!el) return;
const behavior =
payload?.behavior === "instant" || payload?.behavior === "smooth"
? payload.behavior
: "smooth";
el.scrollIntoView({ behavior, block: "center", inline: "nearest" });
}
function handleHighlight(payload) {
// Pointing's pulse style isn't appropriate for an article
// (paragraphs don't want to scale). Use a brief background flash
// by briefly setting a class. Highlight is used here mostly for
// emphasizing single phrases the agent named — see CSS.
const el = resolveTarget(payload);
if (!el) return;
el.classList.remove("flash");
void el.offsetWidth;
el.classList.add("flash");
const duration = payload?.duration_ms ?? 1500;
setTimeout(() => el.classList.remove("flash"), duration);
}
/**
* Programmatically select an element's text on the page.
*
* Build a ``Range`` covering the element's children, replace the
* window selection with it, and scroll the element into view. This
* is the WRITE side of the deixis story: the agent says "this
* paragraph" and the page shows the text actually selected.
*/
function handleSelectText(payload) {
const el = resolveTarget(payload);
if (!el) return;
const range = document.createRange();
range.selectNodeContents(el);
const sel = window.getSelection();
if (!sel) return;
sel.removeAllRanges();
sel.addRange(range);
// Scroll the selection into view if it isn't already, so the user
// actually sees the agent's pointer.
el.scrollIntoView({ behavior: "smooth", block: "center" });
}
function onUICommand(command, handler) {
const listener = (data) => {
if (data.command !== command) return;
handler(data.payload);
};
client.on(RTVIEvent.UICommand, listener);
return () => client.off(RTVIEvent.UICommand, listener);
}
async function connect() {
connectButton.disabled = true;
setStatus("Connecting…");
client = new PipecatClient({
transport: new SmallWebRTCTransport(),
enableMic: true,
enableCam: false,
});
client.on(RTVIEvent.BotConnected, () => setStatus("Bot connected", 1500));
client.on(RTVIEvent.Disconnected, () => {
setStatus("Disconnected", 2000);
connectButton.dataset.state = "";
connectButton.textContent = "Connect";
connectButton.disabled = false;
teardownUI();
});
client.on(RTVIEvent.TrackStarted, (track, participant) => {
if (track.kind !== "audio") return;
if (participant?.local) return;
botAudio.srcObject = new MediaStream([track]);
});
unsubscribes = [
onUICommand("scroll_to", handleScrollTo),
onUICommand("highlight", handleHighlight),
onUICommand("select_text", handleSelectText),
];
try {
await client.connect({ webrtcUrl: BOT_URL });
client.startUISnapshotStream();
connectButton.dataset.state = "connected";
connectButton.textContent = "Disconnect";
connectButton.disabled = false;
setStatus("Connected. Try selecting a paragraph and asking 'explain this.'", 5000);
} catch (err) {
console.error("Connect failed:", err);
setStatus(`Connect failed: ${err.message ?? err}`, 4000);
teardownUI();
connectButton.disabled = false;
}
}
async function disconnect() {
connectButton.disabled = true;
setStatus("Disconnecting…");
try {
await client?.disconnect();
} finally {
teardownUI();
connectButton.dataset.state = "";
connectButton.textContent = "Connect";
connectButton.disabled = false;
}
}
function teardownUI() {
client?.stopUISnapshotStream();
unsubscribes.forEach((unsubscribe) => unsubscribe());
unsubscribes = [];
if (botAudio.srcObject) botAudio.srcObject = null;
client = undefined;
}
connectButton.addEventListener("click", () => {
if (connectButton.dataset.state === "connected") {
disconnect();
} else {
connect();
}
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,18 @@
{
"name": "deixis-client",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@pipecat-ai/client-js": "1.9.0",
"@pipecat-ai/small-webrtc-transport": "^1.10.2"
},
"devDependencies": {
"vite": "^8"
}
}

View File

@@ -0,0 +1,138 @@
:root {
color-scheme: light;
font-family:
Charter,
Georgia,
"Iowan Old Style",
serif;
--border: #d4d4d8;
--muted: #71717a;
--selection: #fde68a;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
background: #fafafa;
color: #18181b;
}
header {
position: sticky;
top: 0;
z-index: 10;
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 1.5rem;
border-bottom: 1px solid var(--border);
background: #fff;
font-family: system-ui, -apple-system, sans-serif;
}
header h1 {
font-size: 1.125rem;
margin: 0;
letter-spacing: 0.01em;
}
#connect {
padding: 0.5rem 1rem;
border: 1px solid var(--border);
background: #fff;
border-radius: 6px;
cursor: pointer;
font-size: 0.875rem;
font-family: system-ui, -apple-system, sans-serif;
}
#connect:hover {
background: #f4f4f5;
}
#connect[data-state="connected"] {
background: #ef4444;
color: white;
border-color: #ef4444;
}
main {
max-width: 720px;
margin: 0 auto;
padding: 2rem 1.5rem 4rem;
}
article h2 {
margin: 0 0 1rem;
font-size: 1.875rem;
line-height: 1.2;
letter-spacing: -0.01em;
}
article p {
margin: 0 0 1.25rem;
font-size: 1.125rem;
line-height: 1.7;
color: #27272a;
}
article p.lede {
font-size: 1rem;
line-height: 1.6;
color: var(--muted);
font-style: italic;
margin-bottom: 2rem;
border-left: 3px solid var(--border);
padding-left: 1rem;
}
/* Make the user's text selection (and the agent's programmatic
selection) visually distinct. The same color is used for both —
the agent and the user are pointing at the same thing. */
::selection {
background: var(--selection);
color: #18181b;
}
/* Brief background flash when the agent calls ``highlight`` on a
paragraph. Distinct from ``select_text`` (which uses the OS-level
text selection) so the agent has two different visual idioms:
"I'm pointing at this content" (select) vs "look at this fact"
(flash). */
@keyframes flash-fade {
0% {
background: var(--selection);
}
100% {
background: transparent;
}
}
.flash {
animation: flash-fade 1.5s ease-out;
border-radius: 4px;
margin-left: -0.25rem;
padding-left: 0.25rem;
}
#status {
position: fixed;
bottom: 1rem;
right: 1rem;
padding: 0.5rem 0.75rem;
border-radius: 6px;
font-size: 0.8125rem;
font-family: system-ui, -apple-system, sans-serif;
background: #18181b;
color: white;
opacity: 0;
transition: opacity 0.2s;
pointer-events: none;
}
#status[data-show="1"] {
opacity: 1;
}

View File

@@ -0,0 +1,7 @@
import { defineConfig } from "vite";
export default defineConfig({
server: {
port: 5173,
},
});