HTML Canvas 간단한 Agent – 자연어로 사각형, 원 그리기

LLM API로 사각형, 원 그리는 Agent (html + canvas)

아주ㅡ아주아주아주 간단한 수준의 Agent를 HTML Canvas + NextJS 기반의 Agent로 구현해보려고 한다

​

메시지를 ChatGPT API를 통해 전송한다

“원 하나를 그려줘”, “사각형 그려줘”, “기존 도형과 안 겹치게 사각형 그려줘”

LLM API를 통해 받아온 답변으로 화면에 도형을 그린다

LLM API로 사각형, 원 그리는 Agent (html + canvas)

 

(1) input 태그에 ‘사각형 하나 그려줘’, ‘기존 도형과 안 겹치게 원 하나 만들어줘’를 입력 후

(2) ‘메시지 전송’ 버튼을 누르면

(3) input message 값이 서버로 전달되고

(4) 서버에 만들어둔 Claude API를 통해 원을 생성하라는 명령어를 response로 받는다.

(5) 이 명령어를 해석/분기처리 해서 원 혹은 사각형을 만든다.

​

​https://youtu.be/YMlU_2W6CqY

 

 

Claude API를 사용했고, claude에서는 structured_output으로 고정하는 기술을 output_config 이라는 옵션을 모델에 주면 된다고 하고, 이 곳에 값을 넣으라고 한다

 

// lib/chat.ts
const message = await client.messages.parse({
    max_tokens: 50_000,
    messages,
    model: "claude-sonnet-4-6",
    output_config: {
        format: zodOutputFormat(CommandSchema)
    }
}, { timeout: 20 * 60 * 12_000 });

 

엥 zodOutputFormat은 어디서 나옴?

코드 윗 부분에서 이렇게 import 한다

​

 

// lib/chat.ts 전체

import Anthropic from "@anthropic-ai/sdk";
import { z } from 'zod';
import { zodOutputFormat } from "@anthropic-ai/sdk/helpers/zod";

const CommandSchema = z.object({
    type: z.string(),
    payload: z.object({
        x: z.number().optional(),
        y: z.number().optional(),
        width: z.number().optional(),
        height: z.number().optional(),
        radius: z.number().optional(),
        fillStyle: z.string().optional(),
    })
});

const client = new Anthropic({
    apiKey: process.env.CLAUDE_API_KEY
});

export let messages = [];


const DARWING_PROMPT = `
    너는 도형 그리기 전문가다.
    type: "rect" | "circle" 2개중 1개만 허용.
    canvas maxWidth=800, maxHeight=500을 고려하여, 
    canvas 사이즈 안에 들어오는 도형만 그릴 것.
    fillStyle = "#000000" should be hex value
`;
export async function chat(prompt: string) {
    messages.push({
        role: "user",
        content: DARWING_PROMPT + prompt
    });

    const message = await client.messages.parse({
        max_tokens: 50_000,
        messages,
        model: "claude-sonnet-4-6",
        output_config: {
            format: zodOutputFormat(CommandSchema)
        }
    }, { timeout: 20 * 60 * 12_000 });

    messages.push({ role: "assistant", content: message.content });

    return message.content[0].parsed_output ?? message.content[1].parsed_output
}

 

 

DRAWING_PROMPT를 안 넣으면?

‘arc’ 타입이 나오기도하고, ‘rectangle’같은 형태로 나오는데,

프론트에서 ‘circle’ | ‘rect’ 이렇게 처리하고 싶어서 PROMPT를 먼저 넣어줌.

​

영상에서는 lib/chat.ts를 가장 나중에 만들긴 하지만… 일단 계속해보겠다

초 simple한 nextjs api route.ts 코드다

​

lib/chat.ts에서 만들어 둔 chat을 POST 메서드로 받아서 body에 있는 값(message)를 꺼내 온 다음에 chat에 prompt로 넣는다. 그리고 output_config에 의해 고정해둔 commandSchema 형태로 답변이 나올 것 이다.

// app/api/chat/route.ts

import { NextRequest, NextResponse } from 'next/server';
import { messages, chat } from '../../../lib/chat';

export async function GET() {
    return messages;
}

export async function POST(req: NextRequest) {
    const { message } = await req.json();
    const command = await chat(message);
    console.log("command", command)
    return NextResponse.json({ command });
}

백엔드는 만들었고,

​

백엔드에 요청보내고 받아온 command를 실행시킬 코드 부분이다

const sendChat = async () => {
    const response = await fetch('/api/chat', {
        method: "POST",
        headers: {
            "content-type": "application/json"
        },
        body: JSON.stringify({ message })
    });
    const { command } = await response.json();
    
    if (!command || command?.type === 'unknown') {
        alert("처리할 수 없는 명령");
        return;
    }

    switch(command.type) {
        case "circle":
            drawArc(command.payload);
            break;
        case "rect":
            drawRect(command.payload);
            break;
        default:
            alert("처리할 수 없는 명령");

    }
    setMessage("");
}

const drawArc = (payload) => {
    const ctx = canvasRef?.current?.getContext('2d') ?? null;
    if (!ctx) return;
    const { x = 100, y = 100, radius = 30, fillStyle = "#000000" } = payload;
    ctx.beginPath();
    ctx.fillStyle = fillStyle;
    ctx.arc(x, y, radius, 0, Math.PI * 2);
    ctx.fill();
    console.log(fillStyle)
}

const drawRect = (payload) => {
    const ctx = canvasRef?.current?.getContext('2d') ?? null;
    if (!ctx) return;
    const { x = 100, y = 100, width = 100, height = 100, fillStyle = "#000000" } = payload;
    ctx.beginPath();
    ctx.fillStyle = fillStyle;
    ctx.rect(x, y, width, height);
    ctx.fill();
    console.log(fillStyle)
}

 

page.tsx 전체 코드

canvas는 useRef로 관리하고, message로 input tag value 관리, 핵심 함수 3개 + 간단한 UI

 

'use client';
import React, { useState, useEffect, useRef } from 'react';

function randHex() {
    let characters = "0123456789abcdef"
    let str = ""
    for(let i = 0; i < 6; i++){
        str += characters[Math.floor(Math.random() * 16)]
    }
    return "#" + str;
}
export default function Page() {
    const canvasRef = useRef(null);
    
    const [message, setMessage] = useState('');
    const [messages, setMessages] = useState([]);

    const sendChat = async () => {
        const response = await fetch('/api/chat', {
            method: "POST",
            headers: {
                "content-type": "application/json"
            },
            body: JSON.stringify({ message })
        });
        const { command } = await response.json();
        
        if (!command || command?.type === 'unknown') {
            alert("처리할 수 없는 명령");
            return;
        }

        switch(command.type) {
            case "circle":
                drawArc(command.payload);
                break;
            case "rect":
                drawRect(command.payload);
                break;
            default:
                alert("처리할 수 없는 명령");

        }
        setMessage("");
    }

    const drawArc = (payload) => {
        const ctx = canvasRef?.current?.getContext('2d') ?? null;
        if (!ctx) return;
        const { x = 100, y = 100, radius = 30, fillStyle = "#000000" } = payload;
        ctx.beginPath();
        ctx.fillStyle = fillStyle;
        ctx.arc(x, y, radius, 0, Math.PI * 2);
        ctx.fill();
        console.log(fillStyle)
    }

    const drawRect = (payload) => {
        const ctx = canvasRef?.current?.getContext('2d') ?? null;
        if (!ctx) return;
        const { x = 100, y = 100, width = 100, height = 100, fillStyle = "#000000" } = payload;
        ctx.beginPath();
        ctx.fillStyle = fillStyle;
        ctx.rect(x, y, width, height);
        ctx.fill();
        console.log(fillStyle)
    }

  return( 
    <div>
        <h1>Hello, Next.js!</h1>
        <div>
            <canvas ref={canvasRef} style={{border: "1px solid black"}} width={800} height={500}/>
            <div style={{border: '1px solid gray'}}>
                <div>button test</div>
                <button onClick={() => drawArc({x: Math.random() * 500, y: Math.random() * 400, radius: Math.random() * 50, fillStyle: randHex() })}> 
                    원 그리기
                </button>
            </div>

            <div style={{border: "1px solid blue"}}>
                <input value={message} onChange={e => setMessage(e.target.value)} />
                <button onClick={() => sendChat()}>메시지 전송</button>
            </div>
        </div>
    </div>
  );
}

 

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다