mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-27 17:58:07 +00:00
Optional skill for driving the tldraw offline desktop app via its local
HTTP control API (the same curl-based path the app's own agent skills use
for Codex/Claude Code/Cursor/Gemini) — read the canvas, make live edits,
and write embedded document scripts.
Grounded in the app's bundled script-context.d.ts and agent playbook, and
in the real tldraw SDK v5 shape schema:
- document-script contract: export default function ({ editor, helpers, signal })
- HTTP API: /api/search, /api/doc/:id/exec, /api/doc/:id/script-workspace,
/api/doc/:id/script-status (bearer token from server.json, re-read per call)
- shape schema table validated against @tldraw/tlschema (scripts/validate_shapes.mjs, 3/3)
- interactive-UI example (scripts/counter.js) + diagram-generation (scripts/main.js)
- honest verification boundary: click->state logic verified via /exec dispatch
(0->1->2->1->0), with documented host caveats (inotify watcher, Electron
background-click rejection)
Tests: tests/skills/test_tldraw_offline_skill.py (15 passing).
114 lines
4.2 KiB
JavaScript
114 lines
4.2 KiB
JavaScript
// Interactive Counter — a tldraw offline document script.
|
|
//
|
|
// HOW TO RUN IT ON YOUR MACHINE:
|
|
// 1. Open tldraw offline, create or open a document.
|
|
// 2. Develop → Reveal Script… (creates script/main.js + a workspace folder)
|
|
// 3. Replace the contents of script/main.js with THIS file, and save.
|
|
// 4. The app reruns the script automatically. You'll see a "Counter" panel
|
|
// with MINUS / RESET / PLUS buttons — click them; the number updates live.
|
|
// 5. File → Save to persist the script into the .tldraw file. Now the file
|
|
// *is* a little app: reopen it anywhere and the buttons still work.
|
|
//
|
|
// This is the document-script contract (from the app's script-context.d.ts):
|
|
// export default function ({ editor, helpers, signal }) { ... }
|
|
// editor — the live tldraw Editor
|
|
// helpers — editor-bound conveniences (richTextToPlainText, etc.)
|
|
// signal — an AbortSignal fired before the script reruns / on close;
|
|
// register ALL cleanup on it so re-saving never leaks listeners.
|
|
|
|
import { createShapeId, toRichText } from 'tldraw'
|
|
|
|
export default function ({ editor, helpers, signal }) {
|
|
// Stable ids => idempotent: re-running reuses shapes instead of duplicating.
|
|
const IDS = {
|
|
title: createShapeId('counter-title'),
|
|
display: createShapeId('counter-display'),
|
|
dec: createShapeId('counter-btn-dec'),
|
|
reset: createShapeId('counter-btn-reset'),
|
|
inc: createShapeId('counter-btn-inc'),
|
|
}
|
|
|
|
// Create-if-missing helper (leaves user edits intact on rerun).
|
|
function ensure(partial) {
|
|
if (editor.getShape(partial.id)) return
|
|
editor.createShape(partial)
|
|
}
|
|
|
|
editor.run(() => {
|
|
ensure({
|
|
id: IDS.title, type: 'text', x: 40, y: 20,
|
|
props: { richText: toRichText('Counter'), size: 'xl', font: 'draw', color: 'black' },
|
|
})
|
|
ensure({
|
|
id: IDS.display, type: 'geo', x: 40, y: 80,
|
|
props: { geo: 'rectangle', w: 360, h: 160, color: 'black', fill: 'none', richText: toRichText('0'), size: 'xl' },
|
|
meta: { ui: 'display', count: 0 },
|
|
})
|
|
// Button labels are load-bearing — the click handler finds buttons by text.
|
|
ensure({
|
|
id: IDS.dec, type: 'geo', x: 40, y: 270,
|
|
props: { geo: 'rectangle', w: 100, h: 80, color: 'red', fill: 'solid', richText: toRichText('MINUS'), size: 'l' },
|
|
meta: { ui: 'button', action: 'MINUS' },
|
|
})
|
|
ensure({
|
|
id: IDS.reset, type: 'geo', x: 170, y: 270,
|
|
props: { geo: 'rectangle', w: 100, h: 80, color: 'grey', fill: 'solid', richText: toRichText('RESET'), size: 'l' },
|
|
meta: { ui: 'button', action: 'RESET' },
|
|
})
|
|
ensure({
|
|
id: IDS.inc, type: 'geo', x: 300, y: 270,
|
|
props: { geo: 'rectangle', w: 100, h: 80, color: 'green', fill: 'solid', richText: toRichText('PLUS'), size: 'l' },
|
|
meta: { ui: 'button', action: 'PLUS' },
|
|
})
|
|
})
|
|
|
|
const STEP = { MINUS: -1, PLUS: +1 }
|
|
|
|
function displayShape() {
|
|
return editor.getCurrentPageShapes().find((s) => s.meta && s.meta.ui === 'display')
|
|
}
|
|
function setCount(n) {
|
|
const d = displayShape()
|
|
editor.run(
|
|
() =>
|
|
editor.updateShape({
|
|
id: d.id, type: 'geo',
|
|
props: { richText: toRichText(String(n)) },
|
|
meta: { ...d.meta, count: n },
|
|
}),
|
|
{ history: 'ignore' } // keep script writes out of the user's undo stack
|
|
)
|
|
}
|
|
function runAction(label) {
|
|
const d = displayShape()
|
|
const cur = d.meta && typeof d.meta.count === 'number' ? d.meta.count : 0
|
|
if (label === 'RESET') setCount(0)
|
|
else if (label in STEP) setCount(cur + STEP[label])
|
|
}
|
|
|
|
function bounds(s) {
|
|
return { x: s.x, y: s.y, w: s.props.w ?? 0, h: s.props.h ?? 0 }
|
|
}
|
|
function inside(b, p) {
|
|
return p.x >= b.x && p.x <= b.x + b.w && p.y >= b.y && p.y <= b.y + b.h
|
|
}
|
|
|
|
function onEvent(info) {
|
|
if (!info || info.name !== 'pointer_down') return
|
|
let p = null
|
|
try {
|
|
if (info.point && editor.screenToPage) p = editor.screenToPage(info.point)
|
|
} catch {}
|
|
p = p ?? editor.inputs?.currentPagePoint
|
|
if (!p) return
|
|
const hit = editor
|
|
.getCurrentPageShapes()
|
|
.find((s) => s.meta && s.meta.ui === 'button' && inside(bounds(s), p))
|
|
if (hit) runAction(hit.meta.action)
|
|
}
|
|
|
|
editor.on('event', onEvent)
|
|
signal.addEventListener('abort', () => editor.off('event', onEvent)) // required cleanup
|
|
|
|
editor.zoomToFit({ animation: { duration: 200 } })
|
|
}
|