메뉴
HN
Hacker News 22일 전

에이전트 드로우: 당신이 말하는 동안 AI가 그려주는 도구

IMP
6/10
핵심 요약

tldraw(React 무한 캔버스 SDK)를 기반으로 구축된 'Agent Draw'는 사용자가 프레젠테이션을 진행하는 동안 음성으로 지시한 내용을 AI 에이전트가 실시간으로 캔버스에 그려주는 혁신적인 도구입니다. 이 프로젝트는 AI 모델의 성능에 따라 그림의 완성도와 표현력이 크게 달라짐을 보여주며, Claude Opus 모델이 복잡한 스케치까지 훌륭하게 수행하는 반면 상대적으로 가벼운 모델들은 단순한 도형 배열에 머무는 한계를 보입니다. 개발자들은 상태 관리 기반의 캔버스 도구와 음성 인식 파이프라인, 큐(Queue) 시스템을 결합하여 발표자의 업무를 돕는 에이전트를 구현했습니다.

번역된 본문

우리는 최근 React를 위한 무한 캔버스 SDK인 tldraw 기반의 2draw를 개발했습니다. 이는 사용자들이 공유 캔버스에 그림을 그리고 서로의 그림이 무엇인지 맞추는 경주를 하는 Drawful 스타일의 게임입니다. 우리는 상대방이나 경쟁 추측자로서 그 루프 안에 에이전트를 투입하려면 어떻게 해야 할지 궁금해졌고, 에이전트가 tldraw 캔버스를 어떻게 읽고 그릴 수 있는지 깊이 파고들었습니다. 그 연구는 결국 'Agent Draw'라는 도구로 탄생했습니다. 이 도구는 사용자가 프레젠테이션을 하는 동안 에이전트가 대신 캔버스에 그림을 그려줍니다. 여기서 에이전트가 3학년 화학 수업 데모 발표를 보조하는 모습을 볼 수 있습니다. 지금 바로 사용해 보거나 소스 코드를 받아볼 수 있습니다.

라이브 데모: tldraw-agent-draw-demo.james-664.workers.dev 소스 코드: github.com/ritza-co/tldraw-agent-draw-demo

Agent Draw는 당신이 발표하는 동안 그림을 그려주는 에이전트입니다. 캔버스에 사각형을 드래그하고 그 안에 무엇을 원하는지 말만 하세요. 그리고 다시 돌아보았을 때면 당신이 계속 말을 하는 동안 AI 에이전트가 그것을 그려 캔버스에 넣어둡니다. 연속으로 몇 개의 사각형을 드래그하면 큐(Queue)에 쌓여 차례대로 그려집니다. 이 모든 것은 tldraw라는 무한 캔버스 도구에서 이루어집니다.

tldraw란 무엇인가요? tldraw는 React를 위한 무한 캔버스 SDK입니다. 사용자가 마우스로 조작하는 것과 동일한 에디터 API를 에이전트가 코드로 구동하여 도형을 만들고, 이동시키며, 도형 사이에 화살표를 그릴 수 있습니다. 우리는 에이전트를 처음부터 새로 만들지 않았습니다. tldraw는 이미 Cloudflare Worker 기반의 채팅 패널을 통해 도형을 그리고 배치하는 공식 Agent 스타터 키트를 제공하고 있습니다. 우리는 그 위에 Agent Draw를 구축했습니다.

에이전트는 그림을 얼마나 잘 그릴까요? tldraw의 기본 요소인 사각형, 마름모, 화살표 등으로 이루어진 간단한 구성은 대부분의 모델이 잘 다룹니다. 더 지능적인 모델은 구성에서 더 야심 찬 모습을 보이며, 캔버스 내 배치에서도 더 뛰어난 모습을 보입니다. 우리는 한 세션에서 각 모델에 동일한 두 가지 요청, 즉 의사결정 다이어그램 그리기와 크리켓을 하는 사람 그리기를 주어 이를 테스트했습니다. claude-opus-4.8은 두 가지 모두를 잘 처리했습니다. 기본 요소로 만든 깔끔한 의사결정 다이어그램과, 더 흥미롭게도 펜 도구로 스케치한 완벽하게 구현된 크리켓 장면을 보여주었습니다.

에이전트가 어려움을 겪는 부분 이는 사용 가능한 가장 유능한 모델 중 하나인 claude-opus-4.8의 결과였습니다. 동일한 요청에 대해 더 작은 모델을 사용하면 결과는 다르게 나타납니다.

더 작은 모델은 수준을 낮춰 타협합니다. 더 작은 모델에게 동일한 두 가지 요청을 주면, 야심은 빠르게 떨어집니다. claude-haiku-4.5는 의사결정 다이어그램에서 Opus와 비슷한 성능을 보이지만, 크리켓 요청에 대해서는 펜 도구를 사용하는 대신 기본 요소에만 머물며, 동적인 스케치 대신 라벨이 붙은 더 단순하고 정적인 구성에 만족합니다.

약한 모델은 작업을 포기할 수 있습니다. google/gemini-2.5-flash-lite로 더 낮추면, 두 가지 요청 모두에 일찍 포기하는 것처럼 보입니다.

우리가 Agent Draw를 만든 방법 전체 기능은 새로운 캔버스 도구, 음성 파이프라인, 직렬화된 그리기 큐(Queue), 그리고 프롬프트 섹션으로 구성되어 있습니다. 다음은 저장소의 실제 코드를 바탕으로 한 각 부분에 대한 설명입니다.

사용자가 그리는 영역 캡처하기 tldraw 도구는 상태 머신입니다. StateNode를 서브클래싱하고 하위 상태를 정의하면, tldraw는 포인터 이벤트를 활성 상태로 라우팅합니다. 우리의 AreaCaptureTool에는 세 가지 상태(idle → pointing → dragging)가 있으며, 드래그한 사각형이 완성되는 pointer-up 이벤트에서 실제 작업을 수행합니다.

class AreaCaptureDragging extends StateNode { static override id = 'dragging' private bounds: BoxModel | undefined = undefined

override onPointerUp() { this.editor.updateInstanceState({ brush: null }) if (!this.bounds) throw new Error('Bounds not set')

// 캡처된 사각형(페이지 좌표 기준)을 캡처 세션으로 전달합니다.
startCaptureSession(this.bounds)
this.parent.transition('idle')

}

updateBounds() { if (!this.initialPagePoint) return const currentPagePoint = this.editor.inputs.getCurrentPagePoint() const x = Math.min(this.initialPagePoint.x, currentPagePoint.x) const y = Math.min(this.initialPagePoint.y, currentPagePoint.y) const w = Math.abs(currentPagePoint.x - this.

원문 보기
원문 보기 (영어)
We recently built 2draw , a Drawful-style game where players draw on a shared canvas and race to guess each other&#x27;s drawings, on tldraw , an infinite-canvas SDK for React. We started wondering what it would take to put an agent in that loop, as an opponent or a rival guesser, and dug into how an agent could read and draw on a tldraw canvas. That research turned into: Agent draw , a tool that lets an agent draw to the canvas for you while you present. Here you can see the agent assisting me in my demo presentation of a third grade chemistry class. You can try it right now, or grab the source: Live demo: tldraw-agent-draw-demo.james-664.workers.dev Source: github.com/ritza-co/tldraw-agent-draw-demo Agent Draw is an agent that draws while you present ​ Drag a rectangle on the canvas, say what you want inside it, and by the time you look back it&#x27;s there, drawn by an AI agent while you kept talking. Drag a few rectangles in a row and they queue up, each drawing in turn. All of this happens on an infinite canvas tool called tldraw. What is tldraw? ​ tldraw is an infinite-canvas SDK for React: the same editor API a user drives with a mouse, an agent can drive in code, creating shapes, moving them, drawing arrows between them. We did not build our agent from scratch. tldraw already publishes an official Agent starter kit that draws and arranges shapes through a chat panel, backed by a Cloudflare Worker. We built Agent Draw on top of that. How good is an agent at drawing? ​ A simple composition built on top of tldraw&#x27;s primitives, rectangles, diamonds, arrows, is something most models handle well. A more intelligent model tends to get more ambitious with the composition, and is better at placement on the canvas. We tested this by giving each model the same two requests in one session: draw a decision diagram, and draw a person playing cricket. claude-opus-4.8 handled both well, a clean decision diagram built from primitives, and, more interestingly, a fully realised cricket scene sketched with the pen tool: Where it struggles ​ That was claude-opus-4.8 , one of the more capable models available. The result looks different with a smaller model behind the same requests. A smaller model settles for less ​ Give the same two requests to a smaller model, and the ambition drops off fast. claude-haiku-4.5 matches Opus on the decision diagram, but for the cricket request it stays with primitives instead of reaching for the pen, and settles for a simpler, static composition with a label rather than a dynamic sketch: A weaker model can give up on the task ​ Drop down further to google/gemini-2.5-flash-lite , and it seems to give up early on both requests: How we made agent draw ​ The whole feature is a new canvas tool, a speech pipeline, a serialized draw queue, and a prompt section, and here is each piece, with the actual code from the repo . Capturing the region you draw ​ tldraw tools are state machines. You subclass StateNode and define child states; tldraw routes pointer events to whichever state is active. Our AreaCaptureTool has three states ( idle → pointing → dragging ) and does its real work on pointer-up, when the dragged rectangle is final: class AreaCaptureDragging extends StateNode { static override id = &#x27;dragging&#x27; private bounds : BoxModel | undefined = undefined override onPointerUp ( ) { this . editor . updateInstanceState ( { brush : null } ) if ( ! this . bounds ) throw new Error ( &#x27;Bounds not set&#x27; ) // Hand the captured rectangle (in page coordinates) to the capture session. startCaptureSession ( this . bounds ) this . parent . transition ( &#x27;idle&#x27; ) } updateBounds ( ) { if ( ! this . initialPagePoint ) return const currentPagePoint = this . editor . inputs . getCurrentPagePoint ( ) const x = Math . min ( this . initialPagePoint . x , currentPagePoint . x ) const y = Math . min ( this . initialPagePoint . y , currentPagePoint . y ) const w = Math . abs ( currentPagePoint . x - this . initialPagePoint . x ) const h = Math . abs ( currentPagePoint . y - this . initialPagePoint . y ) // Show tldraw&#x27;s native selection brush while dragging. this . editor . updateInstanceState ( { brush : { x , y , w , h } } ) this . bounds = { x , y , w , h } } } We get the live selection-brush rectangle for free by writing to editor.updateInstanceState({ brush }) , the same instance state tldraw&#x27;s own select tool uses. The bounds are in page coordinates, so they stay correct no matter how the user has panned or zoomed. Listening while you talk ​ The moment a capture starts, we open the mic. AreaRecorder is a thin wrapper over the browser&#x27;s MediaRecorder , deliberately with no knowledge of the agent or transcription, just start() and stop() : export class AreaRecorder { async start ( ) : Promise < void > { this . stream = await navigator . mediaDevices . getUserMedia ( { audio : true } ) const recorder = new MediaRecorder ( this . stream , { mimeType : this . mimeType } ) this . chunks = [ ] recorder . ondataavailable = ( event ) => { if ( event . data . size > 0 ) this . chunks . push ( event . data ) } recorder . start ( ) this . recorder = recorder } async stop ( ) : Promise < Blob > { // ...stop the recorder, release the mic, resolve with the recorded clip... return new Blob ( this . chunks , { type : this . mimeType || &#x27;audio/webm&#x27; } ) } } Turning speech into text ​ The audio blob is posted to a new /transcribe route on the same Cloudflare Worker that already serves the agent. The route just forwards the audio to Mistral&#x27;s Voxtral transcription model and returns the text, so a server-held key covers every demo visitor by default: export async function transcribe ( request : IRequest , env : Environment ) { const form = await request . formData ( ) const file = form . get ( &#x27;file&#x27; ) // A visitor&#x27;s own key (if they&#x27;ve added one) takes priority over the server&#x27;s. const mistralKey = request . headers . get ( &#x27;x-mistral-api-key&#x27; ) || env . MISTRAL_API_KEY const outForm = new FormData ( ) outForm . append ( &#x27;file&#x27; , file ) outForm . append ( &#x27;model&#x27; , &#x27;voxtral-mini-transcribe-2507&#x27; ) const mistralResponse = await fetch ( &#x27;https://api.mistral.ai/v1/audio/transcriptions&#x27; , { method : &#x27;POST&#x27; , headers : { Authorization : ` Bearer ${ mistralKey } ` } , body : outForm , } ) const data = ( await mistralResponse . json ( ) ) as { text ? : string } return new Response ( JSON . stringify ( { text : data . text ?? &#x27;&#x27; } ) , { headers : { &#x27;Content-Type&#x27; : &#x27;application/json&#x27; } , } ) } Handling more than one drawing at a time ​ This is the part that makes multiple captures work. You can drag a second rectangle while the first is still drawing, and the captures draw in order rather than fighting over the canvas. The whole thing is a module-level state machine over a tldraw atom : a single active recorder, a FIFO queue, and a single-consumer worker that handles one session at a time. Starting a new capture auto-stops the one still recording and queues it: export function startCaptureSession ( bounds : BoxModel ) : string { // Drawing a new capture ends the audio of the one still recording. if ( recordingId ) finalizeRecording ( recordingId ) const id = nextId ( ) sessions . set ( [ ... sessions . get ( ) , { id , bounds , status : &#x27;recording&#x27; } ] ) const rec = new AreaRecorder ( ) recorder = rec recordingId = id rec . start ( ) . catch ( /* surface a mic-permission error on the session */ ) return id } The consumer drains the queue one session at a time, moving each through transcribing → drawing . Serializing here is the whole point: the agent runs one request at a time, so a newer capture simply waits its turn: async function processQueue ( ) : Promise < void > { if ( processing ) return // single consumer processing = true try { while ( queue . length > 0 ) { const id = queue . shift ( )