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.
|
||||
340
src/bus.ts
Normal file
340
src/bus.ts
Normal file
@@ -0,0 +1,340 @@
|
||||
// Command bus — single dispatch point for LLM tool calls, scripts, and remote control.
|
||||
// Canonical JSON layer. The DSL parser (command-parser.ts) produces these shapes.
|
||||
|
||||
export type Command =
|
||||
| { type: "navigate"; path: string; as?: string }
|
||||
| { type: "click"; target: string; as?: string }
|
||||
| { type: "fill"; target: string; value: string; as?: string }
|
||||
| { type: "submit"; target: string; as?: string }
|
||||
| { type: "select"; target: string; value: string; as?: string }
|
||||
| { type: "wait"; ms: number; as?: string }
|
||||
| { type: "wait_for"; target: string; timeout?: number; as?: string }
|
||||
| { type: "scroll"; target?: string; as?: string }
|
||||
| { type: "read"; target?: string; as?: string }
|
||||
| {
|
||||
type: "expect"
|
||||
target: string
|
||||
op: "to_contain" | "to_be_visible" | "to_have_value"
|
||||
value?: string
|
||||
as?: string
|
||||
}
|
||||
| { type: "set"; name: string; value: string; as?: string }
|
||||
| { type: "run"; script: string; as?: string }
|
||||
| { type: string; [k: string]: unknown }
|
||||
|
||||
export type DispatchOptions = {
|
||||
signal?: AbortSignal
|
||||
/** Skip cursor animation for this dispatch (e.g. tests). */
|
||||
silent?: boolean
|
||||
}
|
||||
|
||||
export type Handler = (
|
||||
cmd: Command,
|
||||
ctx: HandlerContext,
|
||||
) => Promise<unknown> | unknown
|
||||
|
||||
export type HandlerContext = {
|
||||
vars: Record<string, unknown>
|
||||
signal?: AbortSignal
|
||||
silent: boolean
|
||||
bus: CommandBus
|
||||
}
|
||||
|
||||
export type ActionDescriptor = {
|
||||
id: string
|
||||
label: string
|
||||
tag: string
|
||||
visible: boolean
|
||||
}
|
||||
|
||||
export type HistoryEntry = {
|
||||
cmd: Command
|
||||
result?: unknown
|
||||
error?: string
|
||||
at: number
|
||||
}
|
||||
|
||||
const DEFAULT_TIMEOUT = 5000
|
||||
|
||||
const sleep = (ms: number, signal?: AbortSignal) =>
|
||||
new Promise<void>((resolve, reject) => {
|
||||
if (signal?.aborted) return reject(new DOMException("Aborted", "AbortError"))
|
||||
const t = setTimeout(resolve, ms)
|
||||
signal?.addEventListener("abort", () => {
|
||||
clearTimeout(t)
|
||||
reject(new DOMException("Aborted", "AbortError"))
|
||||
})
|
||||
})
|
||||
|
||||
export function findTarget(target: string): HTMLElement {
|
||||
if (target.startsWith("#")) {
|
||||
const el = document.querySelector(target) as HTMLElement | null
|
||||
if (!el) throw new Error(`No element matches selector ${target}`)
|
||||
return el
|
||||
}
|
||||
const el = document.querySelector<HTMLElement>(`[data-action="${target}"]`)
|
||||
if (!el) throw new Error(`No element with data-action="${target}"`)
|
||||
return el
|
||||
}
|
||||
|
||||
/**
|
||||
* True visibility check — handles hidden parents, display:none, off-screen,
|
||||
* and portal-hosted closed dropdowns/sheets (where `display: none` may not
|
||||
* be set but `offsetParent` is null).
|
||||
*/
|
||||
function isElementVisible(el: HTMLElement): boolean {
|
||||
// Modern API — best signal when available
|
||||
const anyEl = el as unknown as { checkVisibility?: (opts?: object) => boolean }
|
||||
if (typeof anyEl.checkVisibility === "function") {
|
||||
if (
|
||||
!anyEl.checkVisibility({
|
||||
opacityProperty: true,
|
||||
visibilityProperty: true,
|
||||
contentVisibilityAuto: true,
|
||||
})
|
||||
)
|
||||
return false
|
||||
}
|
||||
// Fallbacks
|
||||
if (el.offsetParent === null && getComputedStyle(el).position !== "fixed") return false
|
||||
const r = el.getBoundingClientRect()
|
||||
if (r.width === 0 || r.height === 0) return false
|
||||
return true
|
||||
}
|
||||
|
||||
function dispatchInputEvents(el: HTMLElement) {
|
||||
el.dispatchEvent(new Event("input", { bubbles: true }))
|
||||
el.dispatchEvent(new Event("change", { bubbles: true }))
|
||||
}
|
||||
|
||||
function interpolate(value: unknown, vars: Record<string, unknown>): unknown {
|
||||
if (typeof value !== "string") return value
|
||||
return value.replace(/\$([a-zA-Z_][\w]*)/g, (_, name) => {
|
||||
const v = vars[name]
|
||||
return v == null ? "" : String(v)
|
||||
})
|
||||
}
|
||||
|
||||
function interpolateCommand(
|
||||
cmd: Command,
|
||||
vars: Record<string, unknown>,
|
||||
): Command {
|
||||
const out: Record<string, unknown> = { type: cmd.type }
|
||||
for (const [k, v] of Object.entries(cmd)) {
|
||||
if (k === "type" || k === "as") continue
|
||||
out[k] = interpolate(v, vars)
|
||||
}
|
||||
if ("as" in cmd && cmd.as) out.as = cmd.as
|
||||
return out as Command
|
||||
}
|
||||
|
||||
export class CommandBus {
|
||||
private handlers = new Map<string, Handler>()
|
||||
private listeners = new Set<(entry: HistoryEntry) => void>()
|
||||
readonly history: HistoryEntry[] = []
|
||||
readonly vars: Record<string, unknown> = {}
|
||||
/** Hooked by the cursor module; called before each command if not silent. */
|
||||
beforeCommand?: (cmd: Command, target: HTMLElement | null) => Promise<void>
|
||||
|
||||
constructor() {
|
||||
this.registerBuiltins()
|
||||
}
|
||||
|
||||
register(type: string, handler: Handler): () => void {
|
||||
this.handlers.set(type, handler)
|
||||
return () => {
|
||||
if (this.handlers.get(type) === handler) this.handlers.delete(type)
|
||||
}
|
||||
}
|
||||
|
||||
onHistory(fn: (entry: HistoryEntry) => void): () => void {
|
||||
this.listeners.add(fn)
|
||||
return () => {
|
||||
this.listeners.delete(fn)
|
||||
}
|
||||
}
|
||||
|
||||
async dispatch(cmd: Command, opts: DispatchOptions = {}): Promise<unknown> {
|
||||
const handler = this.handlers.get(cmd.type)
|
||||
if (!handler) throw new Error(`Unknown command: ${cmd.type}`)
|
||||
const resolved = interpolateCommand(cmd, this.vars)
|
||||
const ctx: HandlerContext = {
|
||||
vars: this.vars,
|
||||
signal: opts.signal,
|
||||
silent: !!opts.silent,
|
||||
bus: this,
|
||||
}
|
||||
const entry: HistoryEntry = { cmd: resolved, at: Date.now() }
|
||||
try {
|
||||
if (this.beforeCommand && !opts.silent) {
|
||||
const target =
|
||||
typeof (resolved as { target?: string }).target === "string"
|
||||
? document.querySelector<HTMLElement>(
|
||||
`[data-action="${(resolved as { target: string }).target}"]`,
|
||||
)
|
||||
: null
|
||||
await this.beforeCommand(resolved, target)
|
||||
}
|
||||
const result = await handler(resolved, ctx)
|
||||
entry.result = result
|
||||
if (resolved.as && typeof resolved.as === "string") {
|
||||
this.vars[resolved.as] = result
|
||||
}
|
||||
return result
|
||||
} catch (e) {
|
||||
entry.error = e instanceof Error ? e.message : String(e)
|
||||
throw e
|
||||
} finally {
|
||||
this.history.push(entry)
|
||||
this.listeners.forEach((l) => {
|
||||
try {
|
||||
l(entry)
|
||||
} catch {
|
||||
/* ignore listener errors */
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/** Run a sequence; stops on first error. */
|
||||
async run(commands: Command[], opts: DispatchOptions = {}): Promise<void> {
|
||||
for (const cmd of commands) {
|
||||
if (opts.signal?.aborted) throw new DOMException("Aborted", "AbortError")
|
||||
await this.dispatch(cmd, opts)
|
||||
}
|
||||
}
|
||||
|
||||
/** DOM introspection — every interactive element with [data-action]. */
|
||||
listActions(): ActionDescriptor[] {
|
||||
if (typeof document === "undefined") return []
|
||||
const els = Array.from(
|
||||
document.querySelectorAll<HTMLElement>("[data-action]"),
|
||||
)
|
||||
return els.map((el) => {
|
||||
return {
|
||||
id: el.getAttribute("data-action") ?? "",
|
||||
label: (el.getAttribute("aria-label") ?? el.textContent ?? "").trim().slice(0, 80),
|
||||
tag: el.tagName.toLowerCase(),
|
||||
visible: isElementVisible(el),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
readState(target?: string): string {
|
||||
if (typeof document === "undefined") return ""
|
||||
if (!target) return document.body.innerText.slice(0, 4000)
|
||||
const el = findTarget(target)
|
||||
return (el.innerText || (el as HTMLInputElement).value || "").slice(0, 4000)
|
||||
}
|
||||
|
||||
private registerBuiltins() {
|
||||
this.register("click", async (cmd) => {
|
||||
const c = cmd as Extract<Command, { type: "click" }>
|
||||
const el = findTarget(c.target)
|
||||
el.scrollIntoView({ block: "center", behavior: "smooth" })
|
||||
el.click()
|
||||
return c.target
|
||||
})
|
||||
|
||||
this.register("fill", async (cmd) => {
|
||||
const c = cmd as Extract<Command, { type: "fill" }>
|
||||
const el = findTarget(c.target) as HTMLInputElement | HTMLTextAreaElement
|
||||
el.focus()
|
||||
const proto =
|
||||
el.tagName === "TEXTAREA"
|
||||
? HTMLTextAreaElement.prototype
|
||||
: HTMLInputElement.prototype
|
||||
const setter = Object.getOwnPropertyDescriptor(proto, "value")?.set
|
||||
if (setter) setter.call(el, c.value)
|
||||
else el.value = c.value
|
||||
dispatchInputEvents(el)
|
||||
return c.value
|
||||
})
|
||||
|
||||
this.register("submit", async (cmd) => {
|
||||
const c = cmd as Extract<Command, { type: "submit" }>
|
||||
const el = findTarget(c.target)
|
||||
const form = el.closest("form")
|
||||
if (!form) throw new Error(`submit target ${c.target} not inside a form`)
|
||||
form.requestSubmit()
|
||||
return c.target
|
||||
})
|
||||
|
||||
this.register("select", async (cmd) => {
|
||||
const c = cmd as Extract<Command, { type: "select" }>
|
||||
const el = findTarget(c.target) as HTMLSelectElement
|
||||
el.value = c.value
|
||||
dispatchInputEvents(el)
|
||||
return c.value
|
||||
})
|
||||
|
||||
this.register("wait", async (cmd, ctx) => {
|
||||
const c = cmd as Extract<Command, { type: "wait" }>
|
||||
await sleep(Number(c.ms) || 0, ctx.signal)
|
||||
})
|
||||
|
||||
this.register("wait_for", async (cmd, ctx) => {
|
||||
const c = cmd as Extract<Command, { type: "wait_for" }>
|
||||
const timeout = Number(c.timeout) || DEFAULT_TIMEOUT
|
||||
const start = Date.now()
|
||||
while (Date.now() - start < timeout) {
|
||||
if (ctx.signal?.aborted) throw new DOMException("Aborted", "AbortError")
|
||||
const el = document.querySelector(`[data-action="${c.target}"]`)
|
||||
if (el) return c.target
|
||||
await sleep(80, ctx.signal)
|
||||
}
|
||||
throw new Error(`wait_for timed out after ${timeout}ms: ${c.target}`)
|
||||
})
|
||||
|
||||
this.register("scroll", async (cmd) => {
|
||||
const c = cmd as Extract<Command, { type: "scroll" }>
|
||||
if (c.target) findTarget(c.target).scrollIntoView({ block: "center", behavior: "smooth" })
|
||||
else window.scrollTo({ top: document.body.scrollHeight, behavior: "smooth" })
|
||||
})
|
||||
|
||||
this.register("read", async (cmd, ctx) => {
|
||||
const c = cmd as Extract<Command, { type: "read" }>
|
||||
return ctx.bus.readState(c.target)
|
||||
})
|
||||
|
||||
this.register("expect", async (cmd) => {
|
||||
const c = cmd as Extract<Command, { type: "expect" }>
|
||||
const el = findTarget(c.target)
|
||||
switch (c.op) {
|
||||
case "to_contain": {
|
||||
const text = el.innerText || ""
|
||||
if (!text.includes(String(c.value ?? ""))) {
|
||||
throw new Error(
|
||||
`expect ${c.target} to_contain "${c.value}" — got "${text.slice(0, 80)}"`,
|
||||
)
|
||||
}
|
||||
return true
|
||||
}
|
||||
case "to_be_visible": {
|
||||
const r = el.getBoundingClientRect()
|
||||
if (r.width === 0 || r.height === 0) {
|
||||
throw new Error(`expect ${c.target} to_be_visible — element has zero size`)
|
||||
}
|
||||
return true
|
||||
}
|
||||
case "to_have_value": {
|
||||
const v = (el as HTMLInputElement).value
|
||||
if (v !== c.value) {
|
||||
throw new Error(
|
||||
`expect ${c.target} to_have_value "${c.value}" — got "${v}"`,
|
||||
)
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
this.register("set", async (cmd, ctx) => {
|
||||
const c = cmd as Extract<Command, { type: "set" }>
|
||||
ctx.vars[c.name] = c.value
|
||||
return c.value
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export const commandBus = new CommandBus()
|
||||
118
src/cursor.ts
Normal file
118
src/cursor.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
// Visible cursor that animates to elements before commands act on them.
|
||||
// Speed-controlled: a higher `speed` shortens animations.
|
||||
|
||||
const CURSOR_ID = "__virtual_cursor__"
|
||||
const RIPPLE_ID = "__virtual_cursor_ripple__"
|
||||
const BASE_MOVE_MS = 600
|
||||
const BASE_RIPPLE_MS = 350
|
||||
|
||||
let position: { x: number; y: number } | null = null
|
||||
let speed = 1
|
||||
|
||||
function getPosition() {
|
||||
if (!position) {
|
||||
position =
|
||||
typeof window === "undefined"
|
||||
? { x: 0, y: 0 }
|
||||
: { x: window.innerWidth / 2, y: window.innerHeight / 2 }
|
||||
}
|
||||
return position
|
||||
}
|
||||
|
||||
const ARROW_SVG = `
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M3 2 L3 18 L7 14 L10 21 L13 20 L10 13 L17 13 Z"
|
||||
fill="white" stroke="black" stroke-width="1.2" stroke-linejoin="round"/>
|
||||
</svg>`
|
||||
|
||||
export function setCursorSpeed(s: number) {
|
||||
if (Number.isFinite(s) && s > 0) speed = s
|
||||
}
|
||||
|
||||
export function ensureCursor(): HTMLElement {
|
||||
let el = document.getElementById(CURSOR_ID)
|
||||
if (el) return el
|
||||
el = document.createElement("div")
|
||||
el.id = CURSOR_ID
|
||||
el.setAttribute("aria-hidden", "true")
|
||||
el.setAttribute("role", "presentation")
|
||||
el.innerHTML = ARROW_SVG
|
||||
const p = getPosition()
|
||||
Object.assign(el.style, {
|
||||
position: "fixed",
|
||||
left: "0",
|
||||
top: "0",
|
||||
width: "24px",
|
||||
height: "24px",
|
||||
pointerEvents: "none",
|
||||
zIndex: "2147483647",
|
||||
transform: `translate(${p.x}px, ${p.y}px)`,
|
||||
transition: "transform 600ms cubic-bezier(0.22, 1, 0.36, 1)",
|
||||
filter: "drop-shadow(0 2px 4px rgba(0,0,0,0.3))",
|
||||
opacity: "0",
|
||||
} as CSSStyleDeclaration)
|
||||
document.body.appendChild(el)
|
||||
return el
|
||||
}
|
||||
|
||||
export function showCursor() {
|
||||
ensureCursor().style.opacity = "1"
|
||||
}
|
||||
|
||||
export function hideCursor() {
|
||||
const el = document.getElementById(CURSOR_ID)
|
||||
if (el) el.style.opacity = "0"
|
||||
}
|
||||
|
||||
export function removeCursor() {
|
||||
document.getElementById(CURSOR_ID)?.remove()
|
||||
document.getElementById(RIPPLE_ID)?.remove()
|
||||
position = null
|
||||
}
|
||||
|
||||
export async function moveCursorTo(x: number, y: number): Promise<void> {
|
||||
const el = ensureCursor()
|
||||
el.style.opacity = "1"
|
||||
const dur = Math.max(60, Math.round(BASE_MOVE_MS / speed))
|
||||
el.style.transition = `transform ${dur}ms cubic-bezier(0.22, 1, 0.36, 1)`
|
||||
position = { x, y }
|
||||
el.style.transform = `translate(${x}px, ${y}px)`
|
||||
await new Promise((r) => setTimeout(r, dur))
|
||||
}
|
||||
|
||||
export async function moveCursorToElement(target: HTMLElement): Promise<void> {
|
||||
const rect = target.getBoundingClientRect()
|
||||
const x = rect.left + rect.width / 2 - 4
|
||||
const y = rect.top + rect.height / 2 - 4
|
||||
await moveCursorTo(x, y)
|
||||
}
|
||||
|
||||
export async function clickRipple(): Promise<void> {
|
||||
const p = getPosition()
|
||||
const ripple = document.createElement("div")
|
||||
ripple.id = RIPPLE_ID
|
||||
const dur = Math.max(120, Math.round(BASE_RIPPLE_MS / speed))
|
||||
Object.assign(ripple.style, {
|
||||
position: "fixed",
|
||||
left: `${p.x - 12}px`,
|
||||
top: `${p.y - 12}px`,
|
||||
width: "24px",
|
||||
height: "24px",
|
||||
borderRadius: "50%",
|
||||
border: "2px solid var(--primary, rgba(59,130,246,.9))",
|
||||
background: "color-mix(in oklch, var(--primary, rgb(59,130,246)) 20%, transparent)",
|
||||
pointerEvents: "none",
|
||||
zIndex: "2147483646",
|
||||
transform: "scale(0.4)",
|
||||
opacity: "1",
|
||||
transition: `transform ${dur}ms ease-out, opacity ${dur}ms ease-out`,
|
||||
} as CSSStyleDeclaration)
|
||||
ripple.setAttribute("aria-hidden", "true")
|
||||
document.body.appendChild(ripple)
|
||||
requestAnimationFrame(() => {
|
||||
ripple.style.transform = "scale(1.6)"
|
||||
ripple.style.opacity = "0"
|
||||
})
|
||||
await new Promise((r) => setTimeout(r, dur))
|
||||
ripple.remove()
|
||||
}
|
||||
30
src/index.tsx
Normal file
30
src/index.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
// PURPOSE: Anything-can-drive-the-UI command bus. Single dispatch point for
|
||||
// LLM tool calls, scripts, and 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, so
|
||||
// LLM-driven sessions are legible to a watching human.
|
||||
// ===========================================================================
|
||||
// EXPORTS
|
||||
// Bus: commandBus (singleton), CommandBus (class)
|
||||
// Types: Command, Handler, HandlerContext, DispatchOptions,
|
||||
// ActionDescriptor, HistoryEntry
|
||||
// Parser: parseScript, parseLine, stringifyScript, ScriptOptions, ParsedScript
|
||||
// Runner: runScript, runScriptText, RunScriptOptions
|
||||
// Cursor: ensureCursor, showCursor, hideCursor, removeCursor,
|
||||
// moveCursorTo, moveCursorToElement, clickRipple, setCursorSpeed
|
||||
// Provider: CommandBusProvider — registers `navigate` via react-router,
|
||||
// mounts the cursor, exposes window.commandBus
|
||||
// WebSocket: connectCommandSocket, CommandSocket
|
||||
// LLM bridge: buildSystemPrompt, extractActionBlocks, runActionBlocks,
|
||||
// estimateTokens, trimMessages, RunActionBlocksResult,
|
||||
// SystemPromptContext
|
||||
// ===========================================================================
|
||||
"use client";
|
||||
|
||||
export * from "./bus"
|
||||
export * from "./parser"
|
||||
export * from "./script"
|
||||
export * from "./cursor"
|
||||
export * from "./ws"
|
||||
export * from "./llm-bridge"
|
||||
export { CommandBusProvider } from "./provider"
|
||||
102
src/llm-bridge.ts
Normal file
102
src/llm-bridge.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
// LLM ↔ command bus glue.
|
||||
// - buildSystemPrompt() teaches the model the DSL and lists the actions
|
||||
// currently visible on screen.
|
||||
// - extractActionBlocks() pulls ```action ... ``` blocks from assistant text.
|
||||
// - runActionBlocks() executes them through the script runner.
|
||||
|
||||
import { commandBus } from "./bus"
|
||||
import { runScriptText } from "./script"
|
||||
|
||||
const DSL_REFERENCE = `To act on the UI, emit a fenced \`\`\`action ... \`\`\` block. One command per line.
|
||||
|
||||
Commands: navigate <path> | click <target> | fill <target> "<value>" | submit <target> | select <target> <value> | wait <ms> | wait_for <target> | scroll [<target>] | read [<target>] | expect <target> to_contain "<text>" | expect <target> to_be_visible | expect <target> to_have_value "<text>"
|
||||
|
||||
Rules: only emit a block when asked to do something. Use only target ids from "Available actions". Short sentence + block. Quote values with spaces. Comments start with #.
|
||||
|
||||
Example — User: "go to resources" → "On it.\n\n\`\`\`action\nnavigate /resources\n\`\`\`"`
|
||||
|
||||
export type SystemPromptContext = {
|
||||
/** Optional preface specific to the app/persona. */
|
||||
preface?: string
|
||||
/** Current route pathname. */
|
||||
path?: string
|
||||
/** Whether to inject the live action snapshot. */
|
||||
includeActions?: boolean
|
||||
}
|
||||
|
||||
export function buildSystemPrompt(ctx: SystemPromptContext = {}): string {
|
||||
const parts: string[] = []
|
||||
parts.push(
|
||||
ctx.preface ??
|
||||
"You are the assistant in Comfy Cloud. Answer concisely and drive the UI when asked.",
|
||||
)
|
||||
parts.push(DSL_REFERENCE)
|
||||
|
||||
if (ctx.includeActions !== false) {
|
||||
const actions = commandBus.listActions().filter((a) => a.visible)
|
||||
const path = ctx.path ?? (typeof window !== "undefined" ? window.location.pathname : "")
|
||||
parts.push(`Route: ${path || "?"}\nAvailable actions:\n${
|
||||
actions.length === 0
|
||||
? "(none)"
|
||||
: actions.map((a) => `- ${a.id}${a.label ? `: ${a.label}` : ""}`).join("\n")
|
||||
}`)
|
||||
}
|
||||
|
||||
return parts.join("\n\n")
|
||||
}
|
||||
|
||||
/** Rough token estimate: ~4 chars per token. Good enough for budgeting. */
|
||||
export function estimateTokens(text: string): number {
|
||||
return Math.ceil(text.length / 4)
|
||||
}
|
||||
|
||||
/** Trim a message list to fit a token budget, preserving the most recent turns. */
|
||||
export function trimMessages<T extends { content: string }>(
|
||||
messages: T[],
|
||||
budgetTokens: number,
|
||||
): T[] {
|
||||
let used = 0
|
||||
const kept: T[] = []
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const t = estimateTokens(messages[i].content)
|
||||
if (used + t > budgetTokens) break
|
||||
kept.unshift(messages[i])
|
||||
used += t
|
||||
}
|
||||
return kept
|
||||
}
|
||||
|
||||
const ACTION_BLOCK_RE = /```action\s*\n([\s\S]*?)```/g
|
||||
|
||||
export function extractActionBlocks(text: string): string[] {
|
||||
const blocks: string[] = []
|
||||
let m: RegExpExecArray | null
|
||||
ACTION_BLOCK_RE.lastIndex = 0
|
||||
while ((m = ACTION_BLOCK_RE.exec(text)) !== null) {
|
||||
blocks.push(m[1].trim())
|
||||
}
|
||||
return blocks
|
||||
}
|
||||
|
||||
export type RunActionBlocksResult = {
|
||||
ran: number
|
||||
errors: string[]
|
||||
}
|
||||
|
||||
export async function runActionBlocks(
|
||||
text: string,
|
||||
opts: { signal?: AbortSignal } = {},
|
||||
): Promise<RunActionBlocksResult> {
|
||||
const blocks = extractActionBlocks(text)
|
||||
const errors: string[] = []
|
||||
let ran = 0
|
||||
for (const block of blocks) {
|
||||
try {
|
||||
await runScriptText(block, { signal: opts.signal })
|
||||
ran++
|
||||
} catch (e) {
|
||||
errors.push(e instanceof Error ? e.message : String(e))
|
||||
}
|
||||
}
|
||||
return { ran, errors }
|
||||
}
|
||||
244
src/parser.ts
Normal file
244
src/parser.ts
Normal file
@@ -0,0 +1,244 @@
|
||||
// DSL ↔ JSON. The DSL is plain text, one command per line:
|
||||
//
|
||||
// # comment
|
||||
// # speed: 0.7 <- script header (parsed into ScriptOptions)
|
||||
// navigate /resources
|
||||
// click nav-resources
|
||||
// fill appbar-search "hello world"
|
||||
// wait 400
|
||||
// wait_for results-table
|
||||
// $id = read row-1
|
||||
// click $id
|
||||
// expect resources-table to_contain "Acme"
|
||||
// run another-script
|
||||
|
||||
import type { Command } from "./bus"
|
||||
|
||||
export type ScriptOptions = {
|
||||
/** Multiplier for cursor / animation timing. 1 = default. <1 slower, >1 faster. */
|
||||
speed?: number
|
||||
}
|
||||
|
||||
export type ParsedScript = {
|
||||
options: ScriptOptions
|
||||
commands: Command[]
|
||||
}
|
||||
|
||||
const HEADER_RE = /^#\s*(\w+)\s*:\s*(.+)$/
|
||||
|
||||
export function parseScript(text: string): ParsedScript {
|
||||
const options: ScriptOptions = {}
|
||||
const commands: Command[] = []
|
||||
const lines = text.split(/\r?\n/)
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const raw = lines[i]
|
||||
const line = raw.trim()
|
||||
if (!line) continue
|
||||
|
||||
if (line.startsWith("#")) {
|
||||
const m = HEADER_RE.exec(line)
|
||||
if (m) {
|
||||
const key = m[1].toLowerCase()
|
||||
const value = m[2].trim()
|
||||
if (key === "speed") {
|
||||
const n = Number(value)
|
||||
if (Number.isFinite(n) && n > 0) options.speed = n
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
commands.push(parseLine(line))
|
||||
} catch (e) {
|
||||
throw new Error(
|
||||
`parse error on line ${i + 1}: ${(e as Error).message}\n > ${raw}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return { options, commands }
|
||||
}
|
||||
|
||||
/** Parse a single non-empty, non-comment line into a Command. */
|
||||
export function parseLine(line: string): Command {
|
||||
// Optional assignment: `$name = <command...>`
|
||||
let as: string | undefined
|
||||
const assign = /^\$([a-zA-Z_][\w]*)\s*=\s*(.+)$/.exec(line)
|
||||
let body = line
|
||||
if (assign) {
|
||||
as = assign[1]
|
||||
body = assign[2]
|
||||
}
|
||||
|
||||
const tokens = tokenize(body)
|
||||
if (tokens.length === 0) throw new Error("empty command")
|
||||
const head = tokens[0].toLowerCase()
|
||||
const rest = tokens.slice(1)
|
||||
|
||||
const cmd = buildCommand(head, rest)
|
||||
if (as) (cmd as { as?: string }).as = as
|
||||
return cmd
|
||||
}
|
||||
|
||||
function buildCommand(head: string, args: string[]): Command {
|
||||
switch (head) {
|
||||
case "navigate":
|
||||
requireArgs(head, args, 1)
|
||||
return { type: "navigate", path: args[0] }
|
||||
case "click":
|
||||
requireArgs(head, args, 1)
|
||||
return { type: "click", target: args[0] }
|
||||
case "fill":
|
||||
requireArgs(head, args, 2)
|
||||
return { type: "fill", target: args[0], value: args[1] }
|
||||
case "submit":
|
||||
requireArgs(head, args, 1)
|
||||
return { type: "submit", target: args[0] }
|
||||
case "select":
|
||||
requireArgs(head, args, 2)
|
||||
return { type: "select", target: args[0], value: args[1] }
|
||||
case "wait":
|
||||
requireArgs(head, args, 1)
|
||||
return { type: "wait", ms: Number(args[0]) }
|
||||
case "wait_for":
|
||||
requireArgs(head, args, 1)
|
||||
return {
|
||||
type: "wait_for",
|
||||
target: args[0],
|
||||
...(args[1] ? { timeout: Number(args[1]) } : {}),
|
||||
}
|
||||
case "scroll":
|
||||
return args[0] ? { type: "scroll", target: args[0] } : { type: "scroll" }
|
||||
case "read":
|
||||
return args[0] ? { type: "read", target: args[0] } : { type: "read" }
|
||||
case "set":
|
||||
requireArgs(head, args, 2)
|
||||
return { type: "set", name: args[0], value: args[1] }
|
||||
case "run":
|
||||
requireArgs(head, args, 1)
|
||||
return { type: "run", script: args[0] }
|
||||
case "expect": {
|
||||
// expect <target> <op> [value]
|
||||
requireArgs(head, args, 2)
|
||||
const target = args[0]
|
||||
const op = args[1]
|
||||
if (op !== "to_contain" && op !== "to_be_visible" && op !== "to_have_value") {
|
||||
throw new Error(`unknown expect op: ${op}`)
|
||||
}
|
||||
const value = args[2]
|
||||
return op === "to_be_visible"
|
||||
? { type: "expect", target, op }
|
||||
: { type: "expect", target, op, value }
|
||||
}
|
||||
default:
|
||||
throw new Error(`unknown command: ${head}`)
|
||||
}
|
||||
}
|
||||
|
||||
function requireArgs(head: string, args: string[], min: number) {
|
||||
if (args.length < min) {
|
||||
throw new Error(`${head} expects at least ${min} arg(s), got ${args.length}`)
|
||||
}
|
||||
}
|
||||
|
||||
/** Whitespace-split, with double-quoted strings preserved (supports \" and \\). */
|
||||
function tokenize(input: string): string[] {
|
||||
const out: string[] = []
|
||||
let i = 0
|
||||
while (i < input.length) {
|
||||
const c = input[i]
|
||||
if (c === " " || c === "\t") {
|
||||
i++
|
||||
continue
|
||||
}
|
||||
if (c === '"') {
|
||||
let s = ""
|
||||
i++
|
||||
while (i < input.length) {
|
||||
const ch = input[i]
|
||||
if (ch === "\\" && i + 1 < input.length) {
|
||||
s += input[i + 1]
|
||||
i += 2
|
||||
continue
|
||||
}
|
||||
if (ch === '"') {
|
||||
i++
|
||||
break
|
||||
}
|
||||
s += ch
|
||||
i++
|
||||
}
|
||||
out.push(s)
|
||||
continue
|
||||
}
|
||||
let s = ""
|
||||
while (i < input.length && input[i] !== " " && input[i] !== "\t") {
|
||||
s += input[i]
|
||||
i++
|
||||
}
|
||||
out.push(s)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** Inverse: turn JSON commands back into DSL text. Useful for logging/round-trip. */
|
||||
export function stringifyScript(commands: Command[], options: ScriptOptions = {}): string {
|
||||
const lines: string[] = []
|
||||
if (options.speed) lines.push(`# speed: ${options.speed}`)
|
||||
for (const cmd of commands) {
|
||||
lines.push(stringifyCommand(cmd))
|
||||
}
|
||||
return lines.join("\n") + "\n"
|
||||
}
|
||||
|
||||
function quoteIfNeeded(v: unknown): string {
|
||||
const s = String(v)
|
||||
if (/[\s"]/.test(s)) return `"${s.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`
|
||||
return s
|
||||
}
|
||||
|
||||
function stringifyCommand(cmd: Command): string {
|
||||
const prefix = (cmd as { as?: string }).as ? `$${(cmd as { as: string }).as} = ` : ""
|
||||
switch (cmd.type) {
|
||||
case "navigate":
|
||||
return `${prefix}navigate ${quoteIfNeeded((cmd as { path: string }).path)}`
|
||||
case "click":
|
||||
case "submit":
|
||||
return `${prefix}${cmd.type} ${quoteIfNeeded((cmd as { target: string }).target)}`
|
||||
case "fill":
|
||||
case "select": {
|
||||
const c = cmd as { target: string; value: string }
|
||||
return `${prefix}${cmd.type} ${quoteIfNeeded(c.target)} ${quoteIfNeeded(c.value)}`
|
||||
}
|
||||
case "wait":
|
||||
return `${prefix}wait ${(cmd as { ms: number }).ms}`
|
||||
case "wait_for": {
|
||||
const c = cmd as { target: string; timeout?: number }
|
||||
return `${prefix}wait_for ${quoteIfNeeded(c.target)}${c.timeout ? ` ${c.timeout}` : ""}`
|
||||
}
|
||||
case "scroll": {
|
||||
const t = (cmd as { target?: string }).target
|
||||
return `${prefix}scroll${t ? ` ${quoteIfNeeded(t)}` : ""}`
|
||||
}
|
||||
case "read": {
|
||||
const t = (cmd as { target?: string }).target
|
||||
return `${prefix}read${t ? ` ${quoteIfNeeded(t)}` : ""}`
|
||||
}
|
||||
case "set": {
|
||||
const c = cmd as { name: string; value: string }
|
||||
return `${prefix}set ${c.name} ${quoteIfNeeded(c.value)}`
|
||||
}
|
||||
case "run":
|
||||
return `${prefix}run ${quoteIfNeeded((cmd as { script: string }).script)}`
|
||||
case "expect": {
|
||||
const c = cmd as { target: string; op: string; value?: string }
|
||||
return `${prefix}expect ${quoteIfNeeded(c.target)} ${c.op}${
|
||||
c.value !== undefined ? ` ${quoteIfNeeded(c.value)}` : ""
|
||||
}`
|
||||
}
|
||||
default:
|
||||
return `${prefix}${cmd.type} ${JSON.stringify(cmd)}`
|
||||
}
|
||||
}
|
||||
58
src/provider.tsx
Normal file
58
src/provider.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
// Mounts the virtual cursor, registers `navigate` (uses react-router), and
|
||||
// exposes `window.commandBus` + helpers for ad-hoc dispatch from the console.
|
||||
|
||||
import { useEffect } from "react"
|
||||
import { useNavigate } from "react-router"
|
||||
|
||||
import { commandBus } from "./bus"
|
||||
import {
|
||||
clickRipple,
|
||||
ensureCursor,
|
||||
hideCursor,
|
||||
moveCursorToElement,
|
||||
showCursor,
|
||||
} from "./cursor"
|
||||
import { runScript, runScriptText } from "./script"
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
commandBus: typeof commandBus
|
||||
runScript: typeof runScript
|
||||
runScriptText: typeof runScriptText
|
||||
}
|
||||
}
|
||||
|
||||
export function CommandBusProvider({ children }: { children: React.ReactNode }) {
|
||||
const navigate = useNavigate()
|
||||
|
||||
useEffect(() => {
|
||||
// navigate handler — react-router aware
|
||||
const offNav = commandBus.register("navigate", async (cmd) => {
|
||||
const path = (cmd as { path: string }).path
|
||||
navigate(path)
|
||||
await new Promise((r) => setTimeout(r, 60))
|
||||
return path
|
||||
})
|
||||
|
||||
// cursor: animate to target before any command, ripple on click
|
||||
commandBus.beforeCommand = async (cmd, target) => {
|
||||
ensureCursor()
|
||||
showCursor()
|
||||
if (target) await moveCursorToElement(target)
|
||||
if (cmd.type === "click") await clickRipple()
|
||||
}
|
||||
|
||||
// expose for console / external triggers
|
||||
window.commandBus = commandBus
|
||||
window.runScript = runScript
|
||||
window.runScriptText = runScriptText
|
||||
|
||||
return () => {
|
||||
offNav()
|
||||
commandBus.beforeCommand = undefined
|
||||
hideCursor()
|
||||
}
|
||||
}, [navigate])
|
||||
|
||||
return <>{children}</>
|
||||
}
|
||||
49
src/script.ts
Normal file
49
src/script.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
// Run a parsed script against the bus. Handles scroll-into-view, cursor speed,
|
||||
// and `run` (subscript include) by fetching from /scripts/<name>.script.
|
||||
|
||||
import { commandBus, type Command } from "./bus"
|
||||
import { parseScript, type ScriptOptions } from "./parser"
|
||||
import { setCursorSpeed } from "./cursor"
|
||||
|
||||
export type RunScriptOptions = {
|
||||
signal?: AbortSignal
|
||||
silent?: boolean
|
||||
}
|
||||
|
||||
export async function runScriptText(text: string, opts: RunScriptOptions = {}): Promise<void> {
|
||||
const { options, commands } = parseScript(text)
|
||||
await runParsed({ options, commands }, opts)
|
||||
}
|
||||
|
||||
export async function runScript(
|
||||
name: string,
|
||||
opts: RunScriptOptions = {},
|
||||
): Promise<void> {
|
||||
const text = await loadScript(name)
|
||||
return runScriptText(text, opts)
|
||||
}
|
||||
|
||||
async function loadScript(name: string): Promise<string> {
|
||||
const path = name.startsWith("/")
|
||||
? name
|
||||
: `/scripts/${name.endsWith(".script") ? name : `${name}.script`}`
|
||||
const res = await fetch(path)
|
||||
if (!res.ok) throw new Error(`script ${path}: ${res.status} ${res.statusText}`)
|
||||
return res.text()
|
||||
}
|
||||
|
||||
async function runParsed(
|
||||
parsed: { options: ScriptOptions; commands: Command[] },
|
||||
opts: RunScriptOptions,
|
||||
): Promise<void> {
|
||||
if (parsed.options.speed) setCursorSpeed(parsed.options.speed)
|
||||
for (const cmd of parsed.commands) {
|
||||
if (opts.signal?.aborted) throw new DOMException("Aborted", "AbortError")
|
||||
if (cmd.type === "run") {
|
||||
const child = await loadScript((cmd as { script: string }).script)
|
||||
await runScriptText(child, opts)
|
||||
continue
|
||||
}
|
||||
await commandBus.dispatch(cmd, opts)
|
||||
}
|
||||
}
|
||||
72
src/ws.ts
Normal file
72
src/ws.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
// Optional WebSocket producer — pipes external JSON commands into the bus.
|
||||
// Server -> client message shapes:
|
||||
// { id?: string, command: Command }
|
||||
// { id?: string, script: Command[] }
|
||||
// { id?: string, dsl: string }
|
||||
// Client -> server replies: { id, ok: true } | { id, ok: false, error: string }
|
||||
|
||||
import { commandBus, type Command } from "./bus"
|
||||
import { runScriptText } from "./script"
|
||||
|
||||
type ServerMessage =
|
||||
| { id?: string; command: Command }
|
||||
| { id?: string; script: Command[] }
|
||||
| { id?: string; dsl: string }
|
||||
|
||||
export type CommandSocket = {
|
||||
close: () => void
|
||||
socket: () => WebSocket | null
|
||||
}
|
||||
|
||||
export function connectCommandSocket(
|
||||
url: string,
|
||||
opts: { reconnect?: boolean; reconnectDelayMs?: number } = {},
|
||||
): CommandSocket {
|
||||
const { reconnect = true, reconnectDelayMs = 2000 } = opts
|
||||
let ws: WebSocket | null = null
|
||||
let closed = false
|
||||
|
||||
const open = () => {
|
||||
ws = new WebSocket(url)
|
||||
|
||||
ws.addEventListener("open", () => {
|
||||
console.log("[command-ws] connected", url)
|
||||
})
|
||||
|
||||
ws.addEventListener("message", async (event) => {
|
||||
let msg: ServerMessage
|
||||
try {
|
||||
msg = JSON.parse(event.data)
|
||||
} catch (err) {
|
||||
ws?.send(JSON.stringify({ error: "invalid_json", detail: String(err) }))
|
||||
return
|
||||
}
|
||||
try {
|
||||
if ("dsl" in msg) await runScriptText(msg.dsl)
|
||||
else if ("script" in msg) await commandBus.run(msg.script)
|
||||
else if ("command" in msg) await commandBus.dispatch(msg.command)
|
||||
else throw new Error("missing 'command', 'script', or 'dsl'")
|
||||
ws?.send(JSON.stringify({ id: msg.id, ok: true }))
|
||||
} catch (err) {
|
||||
ws?.send(JSON.stringify({ id: msg.id, ok: false, error: String(err) }))
|
||||
}
|
||||
})
|
||||
|
||||
ws.addEventListener("close", () => {
|
||||
if (closed || !reconnect) return
|
||||
setTimeout(open, reconnectDelayMs)
|
||||
})
|
||||
|
||||
ws.addEventListener("error", () => ws?.close())
|
||||
}
|
||||
|
||||
open()
|
||||
|
||||
return {
|
||||
close: () => {
|
||||
closed = true
|
||||
ws?.close()
|
||||
},
|
||||
socket: () => ws,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user