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,88 @@
# deixis
The UIWorker grounds in what the user just selected. Highlight a
paragraph in the article and ask "explain this" — the worker reads your
selection from the snapshot and answers about that specific content.
## What it shows
- The **read direction**: the client captures `window.getSelection()`
and emits a `<selection ref="...">selected text</selection>` block
inside `<ui_state>`. The `UIWorker` treats it as the deictic referent
for "this", "that", "this paragraph". Asking "what does this mean?"
with a paragraph selected resolves cleanly.
- The **write direction**: the worker says "this paragraph" and issues a
`select_text=ref` command. The client puts the page's text selection
on that element, so the user sees exactly which paragraph the worker
means.
- `ReplyToolMixin` covering reading-style apps: the same bundled tool
pointing uses also has `select_text` (durable selection) alongside
`highlight` (brief flash).
## What it adds vs. `pointing`
`pointing` proved the worker can act visually on the page (scroll,
highlight). This one proves it can read the user's pointer (text
selection) and point back in the same idiom (programmatic selection).
Same skeleton; the new parts are the `select_text` command and the
matching client handler.
## Run
Two terminals.
**Terminal 1 — bot:**
```bash
cd examples/multi-worker/ui-worker/deixis
uv run python bot.py
```
The bot starts on `http://localhost:7860`.
**Terminal 2 — client:**
```bash
cd examples/multi-worker/ui-worker/deixis/client
npm install # one-time
npm run dev
```
Open `http://localhost:5173` and click **Connect**.
## What to try
The page renders a short essay on octopus cognition with selectable
paragraphs.
**Read direction (user selects, worker grounds):**
- Select the paragraph about RNA editing → _"What does this mean?"_
- Select any paragraph → _"Explain this in one sentence."_
**Write direction (worker points back):**
- _"Where does it talk about how octopuses solve problems?"_ (no
selection) — the worker finds the paragraph, speaks a brief reply, and
selects it for you.
- _"How many neurons does an octopus have?"_ — answers and selects the
source paragraph.
**Conversational without pointing:**
- _"What's this article about?"_ — a one-sentence summary, no selection.
## Requirements
- `OPENAI_API_KEY`
- `DEEPGRAM_API_KEY`
- `CARTESIA_API_KEY`
A `.env` in the example folder is the easiest way to set these (see
`examples/multi-worker/env.example`).
## What this example _doesn't_ show
Form filling (see `form-fill/`), async task cards (see `async-tasks/`),
or custom command handlers beyond `scroll_to` / `highlight` /
`select_text`.

View File

@@ -0,0 +1,291 @@
#
# Copyright (c) 2026, Daily
#
# SPDX-License-Identifier: BSD 2-Clause License
#
"""Deixis — the UIWorker grounds in what the user just selected.
The page renders an article. The user selects a paragraph (or any span
of text) and asks "explain this", "rephrase that", "where does it talk
about RNA editing?", and so on. The client captures
``window.getSelection()`` and emits a ``<selection ref="...">selected
text</selection>`` block in the snapshot. The UIWorker reads it as a
deictic reference: "this paragraph" resolves to the selected element.
Two directions:
- **Read**: user selects text → ``<selection>`` block in ``<ui_state>``
→ the worker grounds its answer in the selected content.
- **Write**: the worker says "this paragraph" → ``select_text=ref`` puts
the page's text selection on that element → the user sees what the
worker is referring to.
Same skeleton as ``pointing``. ``DeixisWorker`` composes
``ReplyToolMixin``: the ``reply(answer, scroll_to, highlight,
select_text)`` bundle covers both pointing-style and reading-style apps.
Architecture::
Main worker (PipelineWorker, owns transport + RTVI):
transport.in → STT → user_agg → LLM → TTS → transport.out → assistant_agg
└── answer_about_screen(query) tool
└── params.pipeline_worker.job("ui", name="respond", payload={query})
DeixisWorker (ReplyToolMixin + UIWorker):
└── inherited: reply(answer, scroll_to, highlight, select_text)
Run::
uv run python bot.py
Then open the client at ``http://localhost:5173`` (see ``README.md``).
Requirements:
- OPENAI_API_KEY
- DEEPGRAM_API_KEY
- CARTESIA_API_KEY
"""
import os
from dotenv import load_dotenv
from loguru import logger
from pipecat.adapters.schemas.tools_schema import ToolsSchema
from pipecat.audio.vad.silero import SileroVADAnalyzer
from pipecat.frames.frames import LLMRunFrame
from pipecat.pipeline.job_context import JobError
from pipecat.pipeline.pipeline import Pipeline
from pipecat.pipeline.runner import PipelineRunner
from pipecat.pipeline.worker import PipelineParams, PipelineWorker
from pipecat.processors.aggregators.llm_context import LLMContext
from pipecat.processors.aggregators.llm_response_universal import (
LLMContextAggregatorPair,
LLMUserAggregatorParams,
)
from pipecat.runner.types import RunnerArguments
from pipecat.runner.utils import create_transport
from pipecat.services.cartesia.tts import CartesiaTTSService
from pipecat.services.deepgram.stt import DeepgramSTTService
from pipecat.services.llm_service import FunctionCallParams
from pipecat.services.openai.llm import OpenAILLMService
from pipecat.transports.base_transport import BaseTransport, TransportParams
from pipecat.transports.daily.transport import DailyParams
from pipecat.workers.ui import ReplyToolMixin, UIWorker
load_dotenv(override=True)
MAIN_NAME = "main"
transport_params = {
"daily": lambda: DailyParams(audio_in_enabled=True, audio_out_enabled=True),
"webrtc": lambda: TransportParams(audio_in_enabled=True, audio_out_enabled=True),
}
VOICE_PROMPT = """\
You are the voice layer of a screen-aware reading assistant. A \
separate UI layer sees the page (and the user's selection) and \
writes the spoken reply.
For every user utterance about the article, call \
``answer_about_screen`` with the user's request verbatim. The tool's \
response is the spoken reply, already TTS-ready.
Only respond directly for pure pleasantries (greetings, thanks, \
goodbyes). Keep direct replies to one short spoken sentence."""
# The UI wire-format guide (UI_STATE_PROMPT_GUIDE) is appended to the LLM's
# system instruction automatically by UIWorker, so this prompt only needs the
# app-specific behavior.
UI_PROMPT = """\
You help the user read and understand an article. The current \
``<ui_state>`` block is in your context, and may contain a \
``<selection>`` block when the user has highlighted text.
## Tool: reply
Every turn calls ``reply`` exactly once. One tool call per turn, no \
chaining.
``reply(answer, scroll_to=None, highlight=None, select_text=None)``:
- ``answer`` (REQUIRED): the spoken reply, plain language, two short \
sentences max. No markdown, no symbols, no quoting long passages.
- ``scroll_to`` (OPTIONAL): a snapshot ref. Set when the paragraph \
you want to point at is tagged ``[offscreen]``.
- ``highlight`` (OPTIONAL): a list of snapshot refs to flash briefly. \
Use for short emphasis: "look at this fact". Don't use it for a \
whole paragraph; ``select_text`` is better for that.
- ``select_text`` (OPTIONAL): a single snapshot ref. Sets the page's \
text selection to that element. Use this when you say "this \
paragraph" or "the section that talks about X" so the user sees \
exactly what you're referring to.
## Reading the user's selection
If ``<ui_state>`` contains a ``<selection ref="...">selected \
text</selection>`` block, the user has highlighted something. Treat \
that selection as the deictic referent for words like "this", \
"that", "this paragraph", "what I selected". Ground your answer in \
the selected content, not the article as a whole.
When answering about the user's selection, do NOT also call \
``select_text`` — they already selected it; pointing back at the \
same span is redundant.
## Decision rules
- User has a selection AND asks something deictic ("explain this", \
"rephrase that", "what does this mean") → ground in the selection. \
Just ``answer``; no visual fields.
- User asks "where does it say X?" or "show me the part about X"\
find the matching paragraph, ``answer`` briefly, set \
``select_text=ref`` to point at it, and ``scroll_to=ref`` if it's \
``[offscreen]``.
- User asks a content question without selection → ``answer`` with \
the relevant fact. Optionally set ``select_text=ref`` if the \
answer is sourced from one specific paragraph.
## Examples
(refs are illustrative; use the actual refs from the current \
``<ui_state>``)
- User selects the third paragraph, asks "explain this"\
``reply(answer="The skin acts as its own light sensor. Even though \
octopuses are colorblind, their skin can detect light directly, \
which is how they match colors so accurately.")``
- "Where does it talk about RNA editing?" (paragraph e15, offscreen) \
→ ``reply(answer="Here, in the paragraph about RNA editing.", \
scroll_to="e15", select_text="e15")``
- "How many neurons does an octopus have?" (no selection) → \
``reply(answer="About five hundred million, with two thirds of \
them in the arms.", select_text="e7")``
- "Hi, what's this article about?" (no selection) → \
``reply(answer="It's a short essay on octopus cognition. Select any \
paragraph and I'll explain it.")``"""
class DeixisWorker(ReplyToolMixin, UIWorker):
"""UIWorker that grounds in the user's selection and points back via select_text.
Composes ``ReplyToolMixin``, which exposes a single
``reply(answer, scroll_to=None, highlight=None, select_text=None, ...)``
LLM tool. The same bundle pointing apps use also covers
reading-style apps: ``select_text`` is for "this paragraph" / "the
section about X" (durable text selection), while ``highlight``
flashes briefly for short emphasis.
"""
def __init__(self):
llm = OpenAILLMService(
api_key=os.environ["OPENAI_API_KEY"],
settings=OpenAILLMService.Settings(system_instruction=UI_PROMPT),
)
super().__init__("ui", llm=llm)
async def answer_about_screen(params: FunctionCallParams, query: str):
"""Ask the screen-aware UI worker to answer about the article / selection.
Args:
query (str): The user's request, passed verbatim.
"""
logger.info(f"answer_about_screen('{query}')")
try:
async with params.pipeline_worker.job(
"ui", name="respond", payload={"query": query}, timeout=10
) as t:
pass
except JobError as e:
logger.warning(f"ui job failed: {e}")
await params.result_callback("Something went wrong on my side.")
return
speak = (t.response or {}).get("speak")
await params.result_callback(speak or "I'm not sure how to answer that.")
async def run_bot(transport: BaseTransport, runner_args: RunnerArguments):
logger.info("Starting deixis bot")
runner = PipelineRunner(handle_sigint=runner_args.handle_sigint)
stt = DeepgramSTTService(api_key=os.environ["DEEPGRAM_API_KEY"])
tts = CartesiaTTSService(
api_key=os.environ["CARTESIA_API_KEY"],
settings=CartesiaTTSService.Settings(
voice=os.getenv("CARTESIA_VOICE_ID", "71a7ad14-091c-4e8e-a314-022ece01c121"),
),
)
llm = OpenAILLMService(
api_key=os.environ["OPENAI_API_KEY"],
settings=OpenAILLMService.Settings(system_instruction=VOICE_PROMPT),
)
llm.register_direct_function(answer_about_screen, cancel_on_interruption=False, timeout_secs=30)
context = LLMContext(tools=ToolsSchema(standard_tools=[answer_about_screen]))
aggregators = LLMContextAggregatorPair(
context,
user_params=LLMUserAggregatorParams(vad_analyzer=SileroVADAnalyzer()),
)
pipeline = Pipeline(
[
transport.input(),
stt,
aggregators.user(),
llm,
tts,
transport.output(),
aggregators.assistant(),
]
)
worker = PipelineWorker(
pipeline,
name=MAIN_NAME,
params=PipelineParams(enable_metrics=True, enable_usage_metrics=True),
idle_timeout_secs=runner_args.pipeline_idle_timeout_secs,
)
@transport.event_handler("on_client_connected")
async def on_client_connected(transport, client):
logger.info("Client connected")
context.add_message(
{
"role": "developer",
"content": (
"Greet the user briefly. Tell them they can select a "
"paragraph and ask you to explain or rephrase it. One "
"short sentence."
),
}
)
await worker.queue_frame(LLMRunFrame())
@transport.event_handler("on_client_disconnected")
async def on_client_disconnected(transport, client):
logger.info("Client disconnected")
await runner.cancel()
await runner.launch_worker(DeixisWorker())
await runner.launch_worker(worker)
await runner.run()
async def bot(runner_args: RunnerArguments):
"""Main bot entry point compatible with Pipecat Cloud."""
transport = await create_transport(runner_args, transport_params)
await run_bot(transport, runner_args)
if __name__ == "__main__":
from pipecat.runner.run import main
main()

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,
},
});