feat: initial commit — extracted from comfy-cloud
Anything-can-drive-the-UI command bus. Single dispatch point for LLM tool
calls, scripts, and (optional) WebSocket remote control. JSON canonical
form plus a plain-text DSL sugar layer. Includes a visible virtual cursor
that animates to elements before commands act on them.
Modules:
- bus.ts — CommandBus class, dispatch, handlers, vars, history,
listActions, readState. Built-in handlers: navigate, click,
fill, submit, select, wait, wait_for, scroll, read, expect,
set
- parser.ts — DSL ↔ JSON, with quoted values, # comments, # speed:
directive, $name = <cmd> assignment, $var interpolation,
expect assertions
- script.ts — script runner; loads /scripts/<name>.script, supports
sub-scripts via `run` command
- cursor.ts — virtual cursor + ripple, speed-controlled, aria-hidden
- provider.tsx — React glue: registers `navigate` via react-router, mounts
cursor, exposes window.commandBus / runScript /
runScriptText for console use
- ws.ts — optional WebSocket producer (connectCommandSocket)
- llm-bridge.ts — buildSystemPrompt (DSL ref + live action snapshot),
extractActionBlocks, runActionBlocks, estimateTokens,
trimMessages
- index.tsx — barrel re-exports
Peer deps: react, react-router (consuming app provides).
Originally inline in `comfy-cloud/app/lib/`. Extracted as part of the
crema-app-aifirst-template scaffolding.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
165
README.md
Normal file
165
README.md
Normal file
@@ -0,0 +1,165 @@
|
||||
# @crema/action-bus
|
||||
|
||||
The "anything can drive the UI" command bus. One dispatch point for LLM tool
|
||||
calls, scripts, and (optional) remote control over WebSocket — all funnel
|
||||
through the same handlers, run against the same DOM contract.
|
||||
|
||||
Pairs with [`lib-aifirst-shell-ui`](https://git.sky-ai.com/CremaUIStudio/lib-aifirst-shell-ui)
|
||||
which provides a `[data-action]`-tagged shell. Non-AI apps that want
|
||||
scriptable / e2e-testable UIs can use this lib alone.
|
||||
|
||||
## Public API
|
||||
|
||||
```ts
|
||||
import {
|
||||
commandBus, // singleton
|
||||
CommandBusProvider, // mounts cursor + registers `navigate`
|
||||
parseScript, // DSL ↔ JSON
|
||||
runScript, runScriptText,
|
||||
buildSystemPrompt, // LLM bridge
|
||||
extractActionBlocks, runActionBlocks,
|
||||
estimateTokens, trimMessages,
|
||||
connectCommandSocket, // optional WebSocket producer
|
||||
} from "@crema/action-bus"
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
Wrap your app:
|
||||
|
||||
```tsx
|
||||
// app/root.tsx
|
||||
import { CommandBusProvider } from "@crema/action-bus"
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<CommandBusProvider>
|
||||
<Outlet />
|
||||
</CommandBusProvider>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
Tag interactive elements:
|
||||
|
||||
```tsx
|
||||
<button data-action="save-button">Save</button>
|
||||
<input data-action="search-input" />
|
||||
```
|
||||
|
||||
Drive the UI:
|
||||
|
||||
```ts
|
||||
commandBus.dispatch({ type: "click", target: "save-button" })
|
||||
commandBus.dispatch({ type: "fill", target: "search-input", value: "acme" })
|
||||
|
||||
// Or as text DSL:
|
||||
import { runScriptText } from "@crema/action-bus"
|
||||
runScriptText(`
|
||||
navigate /resources
|
||||
wait_for resources-table
|
||||
fill search-input "acme"
|
||||
`)
|
||||
```
|
||||
|
||||
## Built-in commands
|
||||
|
||||
| Command | Args | Purpose |
|
||||
|---|---|---|
|
||||
| `navigate` | `path` | React Router navigation |
|
||||
| `click` | `target` | Click `[data-action=target]` |
|
||||
| `fill` | `target`, `value` | Set input value, fire input + change events |
|
||||
| `submit` | `target` | Submit the form containing target |
|
||||
| `select` | `target`, `value` | Set `<select>` value |
|
||||
| `wait` | `ms` | Sleep |
|
||||
| `wait_for` | `target`, `timeout?` | Poll until element exists |
|
||||
| `scroll` | `target?` | Scroll element into view |
|
||||
| `read` | `target?` | Return innerText |
|
||||
| `expect` | `target`, `op`, `value?` | `to_contain` / `to_be_visible` / `to_have_value` |
|
||||
| `set` | `name`, `value` | Set a variable |
|
||||
|
||||
Register your own:
|
||||
|
||||
```ts
|
||||
commandBus.register("highlight", async (cmd) => {
|
||||
document.querySelector(`[data-action="${cmd.target}"]`)?.classList.add("hot")
|
||||
})
|
||||
```
|
||||
|
||||
## DSL syntax
|
||||
|
||||
Plain text, one command per line. Quote values with spaces. `#` starts a
|
||||
comment. `# speed: <n>` at the top adjusts cursor animation. `$name = <cmd>`
|
||||
captures a result; `$name` interpolates.
|
||||
|
||||
```
|
||||
# speed: 0.7
|
||||
|
||||
click sidebar-toggle
|
||||
wait 500
|
||||
$id = read row-1
|
||||
click $id
|
||||
expect detail-panel to_contain "Acme"
|
||||
```
|
||||
|
||||
Round-trips: `parseScript(dsl)` → `{ options, commands }`, `stringifyScript(commands, options)` → text.
|
||||
|
||||
## LLM integration
|
||||
|
||||
```ts
|
||||
import { buildSystemPrompt, runActionBlocks } from "@crema/action-bus"
|
||||
|
||||
// 1. Inject into the model's system prompt — includes a tight DSL ref +
|
||||
// every visible [data-action] on screen right now.
|
||||
const system = buildSystemPrompt({ path: window.location.pathname })
|
||||
|
||||
// 2. Extract and run any ```action ... ``` blocks the model emits.
|
||||
const { ran, errors } = await runActionBlocks(message.content)
|
||||
```
|
||||
|
||||
Plus token-budgeting helpers (`estimateTokens`, `trimMessages`) for keeping
|
||||
chat history within a model's context window.
|
||||
|
||||
See [comfy-cloud's `docs/AI_FIRST.md`](https://git.sky-ai.com/CremaUIStudio/crema-app-aifirst-template/raw/branch/main/docs/AI_FIRST.md) for the full system tour.
|
||||
|
||||
## Visible cursor
|
||||
|
||||
By default, `CommandBusProvider` hooks a virtual cursor into the bus —
|
||||
animates to the target element before each command, ripples on click. Makes
|
||||
LLM-driven sessions legible to a watching human and gives demo recordings
|
||||
for free. Speed is controlled by the `# speed:` directive in DSL scripts;
|
||||
the `setCursorSpeed(n)` API sets it programmatically.
|
||||
|
||||
To run silently (e.g. tests), pass `{ silent: true }` to `dispatch()` /
|
||||
`run()`.
|
||||
|
||||
## Optional WebSocket producer
|
||||
|
||||
```ts
|
||||
import { connectCommandSocket } from "@crema/action-bus"
|
||||
const sock = connectCommandSocket("ws://localhost:9229/ui")
|
||||
// sock.close() to disconnect
|
||||
```
|
||||
|
||||
Server messages: `{ id?, command }` | `{ id?, script }` | `{ id?, dsl }`.
|
||||
Replies with `{ id, ok: true }` or `{ id, ok: false, error }`.
|
||||
|
||||
Not auto-connected — call when ready. **Add origin checks and an opt-in
|
||||
toggle in production**; the lib provides the transport, not the policy.
|
||||
|
||||
## Conventions
|
||||
|
||||
- **`[data-action]` is the contract.** Every interactive element opts in by
|
||||
declaring an id. The bus introspects the DOM at dispatch time — no central
|
||||
registry to maintain.
|
||||
- **No global state besides the singleton.** The bus is a class; you can
|
||||
instantiate your own if you want isolation.
|
||||
- **Theme-agnostic.** Cursor and ripple read CSS variables (`--primary`),
|
||||
fall back to neutral colors.
|
||||
|
||||
## Peer dependencies
|
||||
|
||||
- `react`
|
||||
- `react-router` (for `CommandBusProvider`'s `navigate` handler)
|
||||
|
||||
Both come from the consuming app.
|
||||
Reference in New Issue
Block a user