├── README.md ├── backend ├── .air.toml ├── .gitignore ├── Makefile ├── README.md ├── cmd │ └── api │ │ └── main.go ├── go.mod ├── go.sum └── internal │ ├── ai │ ├── prompts.go │ ├── test_prompt.go │ ├── tldraw_tool.go │ └── tldraw_tool_test.go │ └── server │ ├── routes.go │ └── server.go ├── demo.png └── frontend ├── .eslintrc.json ├── .gitignore ├── README.md ├── app ├── favicon.ico ├── globals.css ├── layout.tsx └── page.tsx ├── components.json ├── components ├── Toolbar.tsx ├── tldraw-custom-tools │ ├── index.ts │ ├── tools.json │ └── youtube-player │ │ ├── tool.ts │ │ └── util.tsx └── ui │ ├── button.tsx │ ├── card.tsx │ ├── input.tsx │ ├── textarea.tsx │ ├── toggle-group.tsx │ └── toggle.tsx ├── lib └── utils.ts ├── next.config.mjs ├── package-lock.json ├── package.json ├── postcss.config.mjs ├── public ├── custom-tool-icons │ └── youtube-player.svg ├── next.svg └── vercel.svg ├── tailwind.config.ts └── tsconfig.json /README.md: -------------------------------------------------------------------------------- 1 | # ✨ tlcrazy 2 | 3 | A fun experiment to create tldraw tools when you need them. 4 | 5 | ![Demo](demo.png) 6 | -------------------------------------------------------------------------------- /backend/.air.toml: -------------------------------------------------------------------------------- 1 | root = "." 2 | testdata_dir = "testdata" 3 | tmp_dir = "tmp" 4 | 5 | [build] 6 | args_bin = [] 7 | bin = "./main" 8 | cmd = "make build" 9 | delay = 1000 10 | exclude_dir = ["assets", "tmp", "vendor", "testdata"] 11 | exclude_file = [] 12 | exclude_regex = ["_test.go"] 13 | exclude_unchanged = false 14 | follow_symlink = false 15 | full_bin = "" 16 | include_dir = [] 17 | include_ext = ["go", "tpl", "tmpl", "html"] 18 | include_file = [".env"] 19 | kill_delay = "0s" 20 | log = "build-errors.log" 21 | poll = false 22 | poll_interval = 0 23 | post_cmd = [] 24 | pre_cmd = [] 25 | rerun = false 26 | rerun_delay = 500 27 | send_interrupt = false 28 | stop_on_error = false 29 | 30 | [color] 31 | app = "" 32 | build = "yellow" 33 | main = "magenta" 34 | runner = "green" 35 | watcher = "cyan" 36 | 37 | [log] 38 | main_only = false 39 | time = false 40 | 41 | [misc] 42 | clean_on_exit = false 43 | 44 | [screen] 45 | clear_on_rebuild = false 46 | keep_scroll = true 47 | -------------------------------------------------------------------------------- /backend/.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.exe~ 4 | *.dll 5 | *.so 6 | *.dylib 7 | 8 | # Test binary, built with "go test -c" 9 | *.test 10 | 11 | # Output of the go coverage tool, specifically when used with LiteIDE 12 | *.out 13 | 14 | # Dependency directories (remove the comment below to include it) 15 | # vendor/ 16 | 17 | # Go workspace file 18 | go.work 19 | tmp/ 20 | 21 | # IDE specific files 22 | .vscode 23 | .idea 24 | 25 | # .env file 26 | .env 27 | 28 | # Project build 29 | main 30 | *templ.go 31 | -------------------------------------------------------------------------------- /backend/Makefile: -------------------------------------------------------------------------------- 1 | # Simple Makefile for a Go project 2 | 3 | # Build the application 4 | all: build 5 | 6 | build: 7 | @echo "Building..." 8 | 9 | 10 | @go build -o main cmd/api/main.go 11 | 12 | # Run the application 13 | run: 14 | @go run cmd/api/main.go 15 | 16 | 17 | 18 | # Test the application 19 | test: 20 | @echo "Testing..." 21 | @go test ./... -v 22 | 23 | 24 | 25 | # Clean the binary 26 | clean: 27 | @echo "Cleaning..." 28 | @rm -f main 29 | 30 | # Live Reload 31 | 32 | watch: 33 | @if command -v air > /dev/null; then \ 34 | air; \ 35 | echo "Watching...";\ 36 | else \ 37 | read -p "Go's 'air' is not installed on your machine. Do you want to install it? [Y/n] " choice; \ 38 | if [ "$$choice" != "n" ] && [ "$$choice" != "N" ]; then \ 39 | go install github.com/air-verse/air@latest; \ 40 | air; \ 41 | echo "Watching...";\ 42 | else \ 43 | echo "You chose not to install air. Exiting..."; \ 44 | exit 1; \ 45 | fi; \ 46 | fi 47 | 48 | 49 | .PHONY: all build run test clean watch 50 | -------------------------------------------------------------------------------- /backend/README.md: -------------------------------------------------------------------------------- 1 | # Project tlcrazy-backend 2 | 3 | One Paragraph of project description goes here 4 | 5 | ## Getting Started 6 | 7 | These instructions will get you a copy of the project up and running on your local machine for development and testing purposes. See deployment for notes on how to deploy the project on a live system. 8 | 9 | ## MakeFile 10 | 11 | run all make commands with clean tests 12 | ```bash 13 | make all build 14 | ``` 15 | 16 | build the application 17 | ```bash 18 | make build 19 | ``` 20 | 21 | run the application 22 | ```bash 23 | make run 24 | ``` 25 | 26 | Create DB container 27 | ```bash 28 | make docker-run 29 | ``` 30 | 31 | Shutdown DB container 32 | ```bash 33 | make docker-down 34 | ``` 35 | 36 | live reload the application 37 | ```bash 38 | make watch 39 | ``` 40 | 41 | run the test suite 42 | ```bash 43 | make test 44 | ``` 45 | 46 | clean up binary from the last build 47 | ```bash 48 | make clean 49 | ``` -------------------------------------------------------------------------------- /backend/cmd/api/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "tlcrazy-backend/internal/server" 6 | ) 7 | 8 | func main() { 9 | 10 | server := server.NewServer() 11 | 12 | err := server.ListenAndServe() 13 | if err != nil { 14 | panic(fmt.Sprintf("cannot start server: %s", err)) 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /backend/go.mod: -------------------------------------------------------------------------------- 1 | module tlcrazy-backend 2 | 3 | go 1.22.6 4 | 5 | require ( 6 | github.com/go-chi/chi/v5 v5.1.0 7 | github.com/joho/godotenv v1.5.1 8 | ) 9 | 10 | require ( 11 | github.com/go-chi/cors v1.2.1 // indirect 12 | github.com/liushuangls/go-anthropic/v2 v2.4.1 // indirect 13 | ) 14 | -------------------------------------------------------------------------------- /backend/go.sum: -------------------------------------------------------------------------------- 1 | github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw= 2 | github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= 3 | github.com/go-chi/cors v1.2.1 h1:xEC8UT3Rlp2QuWNEr4Fs/c2EAGVKBwy/1vHx3bppil4= 4 | github.com/go-chi/cors v1.2.1/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58= 5 | github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= 6 | github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= 7 | github.com/liushuangls/go-anthropic/v2 v2.4.1 h1:NrqITJX+zQ2kBYx6jPU+wqEK2GPDu4tnoJORhcyUHCM= 8 | github.com/liushuangls/go-anthropic/v2 v2.4.1/go.mod h1:8BKv/fkeTaL5R9R9bGkaknYBueyw2WxY20o7bImbOek= 9 | -------------------------------------------------------------------------------- /backend/internal/ai/prompts.go: -------------------------------------------------------------------------------- 1 | package ai 2 | 3 | const SystemPromptGenTldrawTool = ` 4 | You are an expert at generating tldraw tools. 5 | You will recieve an query describing a tool from the user. 6 | 7 | Then you will generate the id for the tool and 2 code files for the tldraw tool: 8 | - tool.ts 9 | - icon.svg 10 | 11 | Rules to follow: 12 | - The icon svg should ALWAYS be outlined and have a transparent fill 13 | - The output should always be in the format given in the example below and no extra text 14 | - The package is "tldraw" NOT "@tldraw/tldraw" 15 | - Use react to render the tool, NOT tldraw shapes 16 | - Use default exports NOT named exports 17 | - Set "pointerEvents" style value to "all" for the tool util in HTMLContainer 18 | - When adding interactivity like file uploads or click events DO NOT do them in the "tool.ts" file, ALWAYS use React and do them in the "util.tsx" file 19 | - For any styling and colors in the tool, use tailwind 20 | - To update shape data from the tool util, ALWAYS use "this.editor.updateShape" 21 | - DO NOT USE tldraw theme variables for styles or colors, ALWAYS use tailwind 22 | 23 | Here is an example output 24 | 25 | 26 | 27 | import { BaseBoxShapeTool, TLClickEvent } from 'tldraw' 28 | export class CardShapeTool extends BaseBoxShapeTool { 29 | static override id = 'card' 30 | static override initial = 'idle' 31 | override shapeType = 'card' 32 | } 33 | 34 | /* 35 | This file contains our custom tool. The tool is a StateNode with the id "card". 36 | 37 | We get a lot of functionality for free by extending the BaseBoxShapeTool. but we can 38 | handle events in out own way by overriding methods like onDoubleClick. For an example 39 | of a tool with more custom functionality, check out the screenshot-tool example. 40 | 41 | */ 42 | 43 | 44 | 45 | import { useState } from 'react' 46 | import { 47 | HTMLContainer, 48 | Rectangle2d, 49 | ShapeUtil, 50 | TLOnResizeHandler, 51 | getDefaultColorTheme, 52 | resizeBox, 53 | } from 'tldraw' 54 | import { cardShapeMigrations } from './card-shape-migrations' 55 | import { cardShapeProps } from './card-shape-props' 56 | import { ICardShape } from './card-shape-types' 57 | 58 | // There's a guide at the bottom of this file! 59 | 60 | export class CardShapeUtil extends ShapeUtil { 61 | static override type = 'card' as const 62 | // [1] 63 | static override props = cardShapeProps 64 | // [2] 65 | static override migrations = cardShapeMigrations 66 | 67 | // [3] 68 | override isAspectRatioLocked = (_shape: ICardShape) => false 69 | override canResize = (_shape: ICardShape) => true 70 | 71 | // [4] 72 | getDefaultProps(): ICardShape['props'] { 73 | return { 74 | w: 300, 75 | h: 300, 76 | color: 'black', 77 | } 78 | } 79 | 80 | // [5] 81 | getGeometry(shape: ICardShape) { 82 | return new Rectangle2d({ 83 | width: shape.props.w, 84 | height: shape.props.h, 85 | isFilled: true, 86 | }) 87 | } 88 | 89 | // [6] 90 | component(shape: ICardShape) { 91 | const bounds = this.editor.getShapeGeometry(shape).bounds 92 | const theme = getDefaultColorTheme({ isDarkMode: this.editor.user.getIsDarkMode() }) 93 | 94 | //[a] 95 | // eslint-disable-next-line react-hooks/rules-of-hooks 96 | const [count, setCount] = useState(0) 97 | 98 | return ( 99 | 112 |

Clicks: {count}

113 | 120 |
121 | ) 122 | } 123 | 124 | // [7] 125 | indicator(shape: ICardShape) { 126 | return 127 | } 128 | 129 | // [8] 130 | override onResize: TLOnResizeHandler = (shape, info) => { 131 | return resizeBox(shape, info) 132 | } 133 | } 134 | /* 135 | A utility class for the card shape. This is where you define the shape's behavior, 136 | how it renders (its component and indicator), and how it handles different events. 137 | 138 | [1] 139 | A validation schema for the shape's props (optional) 140 | Check out card-shape-props.ts for more info. 141 | 142 | [2] 143 | Migrations for upgrading shapes (optional) 144 | Check out card-shape-migrations.ts for more info. 145 | 146 | [3] 147 | Letting the editor know if the shape's aspect ratio is locked, and whether it 148 | can be resized or bound to other shapes. 149 | 150 | [4] 151 | The default props the shape will be rendered with when click-creating one. 152 | 153 | [5] 154 | We use this to calculate the shape's geometry for hit-testing, bindings and 155 | doing other geometric calculations. 156 | 157 | [6] 158 | Render method — the React component that will be rendered for the shape. It takes the 159 | shape as an argument. HTMLContainer is just a div that's being used to wrap our text 160 | and button. We can get the shape's bounds using our own getGeometry method. 161 | 162 | - [a] Check it out! We can do normal React stuff here like using setState. 163 | Annoying: eslint sometimes thinks this is a class component, but it's not. 164 | 165 | - [b] You need to stop the pointer down event on buttons, otherwise the editor will 166 | think you're trying to select drag the shape. 167 | 168 | [7] 169 | Indicator — used when hovering over a shape or when it's selected; must return only SVG elements here 170 | 171 | [8] 172 | Resize handler — called when the shape is resized. Sometimes you'll want to do some 173 | custom logic here, but for our purposes, this is fine. 174 | */ 175 |
176 | 177 | 178 | ... 179 | 180 |
181 | ` 182 | -------------------------------------------------------------------------------- /backend/internal/ai/test_prompt.go: -------------------------------------------------------------------------------- 1 | package ai 2 | 3 | const testPrompt = ` 4 | 5 | 6 | import { BaseBoxShapeTool, TLClickEvent } from 'tldraw' 7 | 8 | export class PolaroidShapeTool extends BaseBoxShapeTool { 9 | static override id = 'polaroid' 10 | static override initial = 'idle' 11 | override shapeType = 'polaroid' 12 | 13 | override onDoubleClick: TLClickEvent = (info) => { 14 | const shape = this.editor.getShapeByElement(info.target) 15 | if (shape && shape.type === 'polaroid') { 16 | // Open a file picker dialog when double-clicked 17 | const input = document.createElement('input') 18 | input.type = 'file' 19 | input.accept = 'image/*' 20 | input.onchange = (e: Event) => { 21 | const file = (e.target as HTMLInputElement).files?.[0] 22 | if (file) { 23 | const reader = new FileReader() 24 | reader.onload = (e) => { 25 | const imageUrl = e.target?.result as string 26 | this.editor.updateShape({ 27 | id: shape.id, 28 | type: 'polaroid', 29 | props: { ...shape.props, imageUrl }, 30 | }) 31 | } 32 | reader.readAsDataURL(file) 33 | } 34 | } 35 | input.click() 36 | } 37 | } 38 | } 39 | 40 | 41 | 42 | import { useState, useEffect } from 'react' 43 | import { 44 | HTMLContainer, 45 | Rectangle2d, 46 | ShapeUtil, 47 | TLOnResizeHandler, 48 | getDefaultColorTheme, 49 | resizeBox, 50 | } from 'tldraw' 51 | 52 | interface IPolaroidShape { 53 | type: 'polaroid' 54 | props: { 55 | w: number 56 | h: number 57 | title: string 58 | imageUrl: string 59 | } 60 | } 61 | 62 | export class PolaroidShapeUtil extends ShapeUtil { 63 | static override type = 'polaroid' as const 64 | 65 | getDefaultProps(): IPolaroidShape['props'] { 66 | return { 67 | w: 200, 68 | h: 240, 69 | title: 'New Polaroid', 70 | imageUrl: '', 71 | } 72 | } 73 | 74 | getGeometry(shape: IPolaroidShape) { 75 | return new Rectangle2d({ 76 | width: shape.props.w, 77 | height: shape.props.h, 78 | isFilled: true, 79 | }) 80 | } 81 | 82 | component(shape: IPolaroidShape) { 83 | const bounds = this.editor.getShapeGeometry(shape).bounds 84 | const theme = getDefaultColorTheme({ isDarkMode: this.editor.user.getIsDarkMode() }) 85 | 86 | // eslint-disable-next-line react-hooks/rules-of-hooks 87 | const [title, setTitle] = useState(shape.props.title) 88 | 89 | // eslint-disable-next-line react-hooks/rules-of-hooks 90 | useEffect(() => { 91 | setTitle(shape.props.title) 92 | }, [shape.props.title]) 93 | 94 | const handleTitleChange = (e: React.ChangeEvent) => { 95 | const newTitle = e.target.value 96 | setTitle(newTitle) 97 | this.editor.updateShape({ 98 | id: shape.id, 99 | type: 'polaroid', 100 | props: { ...shape.props, title: newTitle }, 101 | }) 102 | } 103 | 104 | return ( 105 | 118 |
129 | {shape.props.imageUrl ? ( 130 | Polaroid 135 | ) : ( 136 | Double-click to add image 137 | )} 138 |
139 | e.stopPropagation()} 152 | /> 153 |
154 | ) 155 | } 156 | 157 | indicator(shape: IPolaroidShape) { 158 | return 159 | } 160 | 161 | override onResize: TLOnResizeHandler = (shape, info) => { 162 | return resizeBox(shape, info) 163 | } 164 | } 165 |
166 | 167 | 168 | 169 | 170 | 171 | 172 | 173 | 174 |
175 | ` 176 | -------------------------------------------------------------------------------- /backend/internal/ai/tldraw_tool.go: -------------------------------------------------------------------------------- 1 | package ai 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "errors" 7 | "fmt" 8 | "log" 9 | "os" 10 | "path/filepath" 11 | "sort" 12 | "strings" 13 | "sync" 14 | 15 | "github.com/liushuangls/go-anthropic/v2" 16 | ) 17 | 18 | type TldrawToolOutput struct { 19 | Id string `json:"id"` 20 | Icon string `json:"icon"` 21 | Tool string `json:"tool"` 22 | Util string `json:"util"` 23 | } 24 | 25 | func GenTldrawTool(query string) (TldrawToolOutput, error) { 26 | api_key := os.Getenv("ANTHROPIC_API_KEY") 27 | if api_key == "" { 28 | return TldrawToolOutput{}, errors.New("anthropic API Key env var not found") 29 | } 30 | 31 | anthropic_client := anthropic.NewClient(api_key) 32 | 33 | resp, err := anthropic_client.CreateMessages(context.Background(), anthropic.MessagesRequest{ 34 | Model: anthropic.ModelClaude3Dot5Sonnet20240620, 35 | MaxTokens: 4096, 36 | Messages: []anthropic.Message{ 37 | anthropic.NewUserTextMessage(query), 38 | }, 39 | System: SystemPromptGenTldrawTool, 40 | }) 41 | if err != nil { 42 | return TldrawToolOutput{}, err 43 | } 44 | 45 | log.Println("API Resp", resp.Content[0].GetText()) 46 | 47 | tool, err := parseTldrawToolXML(resp.Content[0].GetText()) 48 | if err != nil { 49 | return TldrawToolOutput{}, err 50 | } 51 | 52 | writeToolFiles(tool, "/Users/13point5/projects/tlcrazy/frontend") 53 | 54 | return tool, nil 55 | } 56 | 57 | type TldrawXML struct { 58 | Id string 59 | Files []TldrawXMLFile 60 | } 61 | 62 | type TldrawXMLFile struct { 63 | Name string 64 | Content string 65 | } 66 | 67 | func customXMLParser(xmlString string) (TldrawXML, error) { 68 | var tldraw TldrawXML 69 | 70 | // Find the tag and extract the id attribute 71 | toolStart := strings.Index(xmlString, "") 73 | if toolStart == -1 || toolEnd == -1 { 74 | return tldraw, fmt.Errorf("invalid XML: missing tag") 75 | } 76 | 77 | // Extract the id attribute from 78 | toolTag := xmlString[toolStart:toolEnd] 79 | idIndex := strings.Index(toolTag, `id="`) 80 | if idIndex != -1 { 81 | idStart := idIndex + len(`id="`) 82 | idEnd := strings.Index(toolTag[idStart:], `"`) 83 | tldraw.Id = toolTag[idStart : idStart+idEnd] 84 | } 85 | 86 | // Process each tag 87 | for { 88 | fileStart := strings.Index(xmlString, "") 94 | if fileEnd == -1 { 95 | return tldraw, fmt.Errorf("invalid XML: malformed tag") 96 | } 97 | fileEnd += fileStart 98 | 99 | // Extract the name attribute from 100 | fileTag := xmlString[fileStart:fileEnd] 101 | nameIndex := strings.Index(fileTag, `name="`) 102 | var file TldrawXMLFile 103 | if nameIndex != -1 { 104 | nameStart := nameIndex + len(`name="`) 105 | nameEnd := strings.Index(fileTag[nameStart:], `"`) 106 | file.Name = fileTag[nameStart : nameStart+nameEnd] 107 | } 108 | 109 | // Find the closing tag 110 | fileCloseStart := strings.Index(xmlString[fileEnd:], "") 111 | if fileCloseStart == -1 { 112 | return tldraw, fmt.Errorf("invalid XML: missing tag") 113 | } 114 | fileCloseEnd := fileCloseStart + len("") 115 | fileContent := xmlString[fileEnd+1 : fileEnd+fileCloseStart] 116 | 117 | // Assign content and append to list of files 118 | file.Content = fileContent 119 | tldraw.Files = append(tldraw.Files, file) 120 | 121 | // Move the cursor forward 122 | xmlString = xmlString[fileEnd+fileCloseEnd:] 123 | } 124 | 125 | return tldraw, nil 126 | } 127 | 128 | func parseTldrawToolXML(xmlString string) (TldrawToolOutput, error) { 129 | parsedXML, err := customXMLParser(xmlString) 130 | if err != nil { 131 | return TldrawToolOutput{}, err 132 | } 133 | 134 | out := TldrawToolOutput{Id: parsedXML.Id} 135 | 136 | for _, file := range parsedXML.Files { 137 | if file.Name == "tool.ts" { 138 | out.Tool = file.Content 139 | } 140 | 141 | if file.Name == "util.tsx" { 142 | out.Util = file.Content 143 | } 144 | 145 | if file.Name == "icon.svg" { 146 | out.Icon = file.Content 147 | } 148 | } 149 | 150 | return out, nil 151 | } 152 | 153 | type WriteFileResult struct { 154 | path string 155 | err error 156 | } 157 | 158 | func writeToolFiles(tool TldrawToolOutput, appPath string) (bool, []error) { 159 | errors := []error{} 160 | 161 | // Check if appPath is valid 162 | if _, err := os.Stat(appPath); err != nil { 163 | errors = append(errors, err) 164 | return false, errors 165 | } 166 | 167 | toolsJSONPath := filepath.Join(appPath, "components/tldraw-custom-tools/tools.json") 168 | iconPath := filepath.Join(appPath, "public/custom-tool-icons", fmt.Sprintf("%s.svg", tool.Id)) 169 | 170 | toolFolderPath := filepath.Join(appPath, "components/tldraw-custom-tools", tool.Id) 171 | if err := ensureDirectoryExists(toolFolderPath); err != nil { 172 | errors = append(errors, err) 173 | return false, errors 174 | } 175 | 176 | toolPath := filepath.Join(toolFolderPath, "tool.ts") 177 | utilPath := filepath.Join(toolFolderPath, "util.tsx") 178 | 179 | files := []string{toolsJSONPath, toolPath, utilPath, iconPath} 180 | 181 | // Write to files concurrently and store errors in a channel 182 | wg := sync.WaitGroup{} 183 | resChan := make(chan WriteFileResult, len(files)) 184 | 185 | wg.Add(len(files)) 186 | go appendToolId(toolsJSONPath, tool.Id, resChan, &wg) 187 | go writeToolFile(toolPath, tool.Tool, resChan, &wg) 188 | go writeToolFile(utilPath, tool.Util, resChan, &wg) 189 | go writeToolFile(iconPath, tool.Icon, resChan, &wg) 190 | wg.Wait() 191 | 192 | for range len(files) { 193 | writeRes := <-resChan 194 | if writeRes.err != nil { 195 | errors = append(errors, writeRes.err) 196 | log.Println("ERROR:", writeRes.err.Error()) 197 | } 198 | } 199 | 200 | // TODO: undo operations if errors exist 201 | 202 | close(resChan) 203 | 204 | return true, errors 205 | } 206 | 207 | func writeToolFile(path, content string, resChan chan WriteFileResult, wg *sync.WaitGroup) { 208 | defer wg.Done() 209 | 210 | log.Println("Writing to", path) 211 | 212 | err := os.WriteFile(path, []byte(content), 0644) 213 | resChan <- WriteFileResult{path, err} 214 | if err == nil { 215 | log.Println("Finished writing to", path) 216 | } 217 | } 218 | 219 | type ToolsFileContent struct { 220 | Ids []string `json:"ids"` 221 | } 222 | 223 | func appendToolId(path, toolId string, resChan chan WriteFileResult, wg *sync.WaitGroup) { 224 | defer wg.Done() 225 | 226 | log.Printf("Appending Tool ID: %s", toolId) 227 | 228 | // Read file content as string 229 | fileContent, err := os.ReadFile(path) 230 | if err != nil { 231 | resChan <- WriteFileResult{path, err} 232 | return 233 | } 234 | 235 | // Parse string as struct 236 | var data ToolsFileContent 237 | if err := json.Unmarshal(fileContent, &data); err != nil { 238 | resChan <- WriteFileResult{path, err} 239 | return 240 | } 241 | 242 | // Prevent duplicates 243 | toolIdIndex := sort.SearchStrings(data.Ids, toolId) 244 | fmt.Println("data before", data.Ids) 245 | fmt.Println(toolIdIndex, len(data.Ids)) 246 | if toolIdIndex == len(data.Ids) { 247 | data.Ids = append(data.Ids, toolId) 248 | } 249 | fmt.Println("data after", data.Ids) 250 | 251 | // Convert struct to string 252 | dataStr, err := json.Marshal(data) 253 | if err != nil { 254 | resChan <- WriteFileResult{path, err} 255 | return 256 | } 257 | 258 | // Write new data 259 | err = os.WriteFile(path, []byte(dataStr), 0644) 260 | resChan <- WriteFileResult{path, err} 261 | if err == nil { 262 | log.Printf("Finished appending Tool ID: %s", toolId) 263 | } 264 | } 265 | 266 | func ensureDirectoryExists(toolFolderPath string) error { 267 | // Check if the directory exists 268 | if _, err := os.Stat(toolFolderPath); os.IsNotExist(err) { 269 | // Directory does not exist, create it 270 | err = os.MkdirAll(toolFolderPath, os.ModePerm) 271 | if err != nil { 272 | return fmt.Errorf("failed to create directory: %v", err) 273 | } 274 | fmt.Println("Directory created:", toolFolderPath) 275 | } else if err != nil { 276 | // Some other error occurred 277 | return fmt.Errorf("failed to check directory: %v", err) 278 | } else { 279 | fmt.Println("Directory already exists:", toolFolderPath) 280 | } 281 | return nil 282 | } 283 | -------------------------------------------------------------------------------- /backend/internal/ai/tldraw_tool_test.go: -------------------------------------------------------------------------------- 1 | package ai 2 | 3 | import ( 4 | "fmt" 5 | "reflect" 6 | "testing" 7 | ) 8 | 9 | const ( 10 | exampleToolId = "sticker" 11 | 12 | exampleToolFile = ` 13 | import { StateNode } from "tldraw"; 14 | 15 | const OFFSET = 12; 16 | class StickerTool extends StateNode { 17 | static override id = 'sticker'; 18 | 19 | override onEnter = () => { 20 | this.editor.setCursor({ type: "cross", rotation: 0 }); 21 | }; 22 | 23 | override onPointerDown = () => { 24 | const { currentPagePoint } = this.editor.inputs; 25 | this.editor.createShape({ 26 | type: "text", 27 | x: currentPagePoint.x - OFFSET, 28 | y: currentPagePoint.y - OFFSET, 29 | props: { text: "❤️" }, 30 | }); 31 | }; 32 | } 33 | 34 | export default StickerTool; 35 | ` 36 | 37 | exampleToolIcon = ` 38 | 39 | 45 | 46 | ` 47 | ) 48 | 49 | func TestParseTldrawToolRawOutput(t *testing.T) { 50 | 51 | testcase := TldrawToolOutput{ 52 | Id: exampleToolId, 53 | Tool: exampleToolFile, 54 | Icon: exampleToolIcon, 55 | } 56 | 57 | toolXML := fmt.Sprintf(` 58 | 59 | %s 60 | 61 | %s 62 | 63 | `, testcase.Id, testcase.Tool, testcase.Icon) 64 | 65 | t.Run("Check if ID, Tool, and Icons are extracted correctly", func(t *testing.T) { 66 | out, err := parseTldrawToolXML(toolXML) 67 | if err != nil { 68 | t.Fatal("Got an error but didn't expect one", err) 69 | } 70 | 71 | if !reflect.DeepEqual(testcase, out) { 72 | t.Errorf("Expected %q\nbut got %q", testcase, out) 73 | } 74 | }) 75 | } 76 | -------------------------------------------------------------------------------- /backend/internal/server/routes.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "encoding/json" 5 | "log" 6 | "net/http" 7 | 8 | "tlcrazy-backend/internal/ai" 9 | 10 | "github.com/go-chi/chi/v5" 11 | "github.com/go-chi/chi/v5/middleware" 12 | "github.com/go-chi/cors" 13 | ) 14 | 15 | func (s *Server) RegisterRoutes() http.Handler { 16 | r := chi.NewRouter() 17 | r.Use(cors.Handler(cors.Options{ 18 | AllowedOrigins: []string{"http://localhost:3000"}, 19 | })) 20 | r.Use(middleware.Logger) 21 | 22 | r.Post("/tldraw-tool", s.GenerateToolHandler) 23 | 24 | return r 25 | } 26 | 27 | type GenerateToolRequest struct { 28 | Query string `json:"query"` 29 | } 30 | 31 | func (s *Server) GenerateToolHandler(w http.ResponseWriter, r *http.Request) { 32 | decoder := json.NewDecoder(r.Body) 33 | 34 | body := GenerateToolRequest{} 35 | err := decoder.Decode(&body) 36 | if err != nil { 37 | w.WriteHeader(http.StatusBadRequest) 38 | return 39 | } 40 | 41 | tool, err := ai.GenTldrawTool(body.Query) 42 | if err != nil { 43 | log.Printf("Error generating tool: %s", err) 44 | w.WriteHeader(500) 45 | return 46 | } 47 | 48 | resp, err := json.Marshal(tool) 49 | if err != nil { 50 | log.Printf("Error marshalling JSON: %s", err) 51 | w.WriteHeader(500) 52 | return 53 | } 54 | 55 | w.Header().Set("Content-Type", "application/json") 56 | w.WriteHeader(200) 57 | w.Write(resp) 58 | } 59 | -------------------------------------------------------------------------------- /backend/internal/server/server.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "os" 7 | "strconv" 8 | 9 | _ "github.com/joho/godotenv/autoload" 10 | ) 11 | 12 | type Server struct { 13 | port int 14 | } 15 | 16 | func NewServer() *http.Server { 17 | port, _ := strconv.Atoi(os.Getenv("PORT")) 18 | NewServer := &Server{ 19 | port: port, 20 | } 21 | 22 | // Declare Server config 23 | server := &http.Server{ 24 | Addr: fmt.Sprintf(":%d", NewServer.port), 25 | Handler: NewServer.RegisterRoutes(), 26 | } 27 | 28 | return server 29 | } 30 | -------------------------------------------------------------------------------- /demo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/13point5/tlcrazy/e99d1f71b489d61bf794ba283ccd6aa9e4f36758/demo.png -------------------------------------------------------------------------------- /frontend/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /frontend/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | .yarn/install-state.gz 8 | 9 | # testing 10 | /coverage 11 | 12 | # next.js 13 | /.next/ 14 | /out/ 15 | 16 | # production 17 | /build 18 | 19 | # misc 20 | .DS_Store 21 | *.pem 22 | 23 | # debug 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | 28 | # local env files 29 | .env*.local 30 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | next-env.d.ts 37 | -------------------------------------------------------------------------------- /frontend/README.md: -------------------------------------------------------------------------------- 1 | This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app). 2 | 3 | ## Getting Started 4 | 5 | First, run the development server: 6 | 7 | ```bash 8 | npm run dev 9 | # or 10 | yarn dev 11 | # or 12 | pnpm dev 13 | # or 14 | bun dev 15 | ``` 16 | 17 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. 18 | 19 | You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. 20 | 21 | This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to automatically optimize and load Inter, a custom Google Font. 22 | 23 | ## Learn More 24 | 25 | To learn more about Next.js, take a look at the following resources: 26 | 27 | - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. 28 | - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. 29 | 30 | You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome! 31 | 32 | ## Deploy on Vercel 33 | 34 | The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. 35 | 36 | Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details. 37 | -------------------------------------------------------------------------------- /frontend/app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/13point5/tlcrazy/e99d1f71b489d61bf794ba283ccd6aa9e4f36758/frontend/app/favicon.ico -------------------------------------------------------------------------------- /frontend/app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | -------------------------------------------------------------------------------- /frontend/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from "next"; 2 | import { Inter } from "next/font/google"; 3 | import "./globals.css"; 4 | 5 | const inter = Inter({ subsets: ["latin"] }); 6 | 7 | export const metadata: Metadata = { 8 | title: "tlcrazy", 9 | description: "tldraw with some craziness", 10 | }; 11 | 12 | export default function RootLayout({ 13 | children, 14 | }: Readonly<{ 15 | children: React.ReactNode; 16 | }>) { 17 | return ( 18 | 19 | {children} 20 | 21 | ); 22 | } 23 | -------------------------------------------------------------------------------- /frontend/app/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { 4 | toolIcons, 5 | loadTools, 6 | loadUtils, 7 | } from "@/components/tldraw-custom-tools"; 8 | import { Toolbar } from "@/components/Toolbar"; 9 | import { Button } from "@/components/ui/button"; 10 | import { Input } from "@/components/ui/input"; 11 | import { Textarea } from "@/components/ui/textarea"; 12 | import axios from "axios"; 13 | import { Loader2Icon, PencilIcon } from "lucide-react"; 14 | import { useEffect, useState } from "react"; 15 | import { 16 | DefaultToolbar, 17 | DefaultToolbarContent, 18 | TLAnyShapeUtilConstructor, 19 | TLComponents, 20 | TLStateNodeConstructor, 21 | Tldraw, 22 | TldrawUiMenuItem, 23 | useEditor, 24 | } from "tldraw"; 25 | import "tldraw/tldraw.css"; 26 | import toolsData from "@/components/tldraw-custom-tools/tools.json"; 27 | 28 | const numOfTools = toolsData.ids.length; 29 | 30 | export default function Home() { 31 | const [loadedTools, setLoadedTools] = useState([]); 32 | const [loadedUtils, setLoadedUtils] = useState( 33 | [] 34 | ); 35 | const [loadedComponents, setLoadedComponents] = useState( 36 | null 37 | ); 38 | const [query, setQuery] = useState(""); 39 | const [creatingTool, setCreatingTool] = useState(false); 40 | 41 | const loadAllResources = async () => { 42 | const [tools, utils] = await Promise.all([loadTools(), loadUtils()]); 43 | 44 | setLoadedTools(tools); 45 | setLoadedComponents(loadComponents(tools)); 46 | setLoadedUtils(utils); 47 | }; 48 | 49 | useEffect(() => { 50 | loadAllResources(); 51 | }, [toolsData.ids]); 52 | 53 | const handleCreateTool = async () => { 54 | setCreatingTool(true); 55 | 56 | try { 57 | const res = await axios.post("http://localhost:8080/tldraw-tool", { 58 | query, 59 | }); 60 | console.log("res.data", res.data); 61 | } catch (error) { 62 | console.error(error); 63 | } finally { 64 | setCreatingTool(false); 65 | } 66 | }; 67 | 68 | console.log({ 69 | loadedTools, 70 | loadedUtils, 71 | loadedComponents, 72 | numOfTools, 73 | toolsData, 74 | }); 75 | 76 | if ( 77 | loadedTools.length !== numOfTools || 78 | loadedUtils.length !== numOfTools || 79 | loadedComponents === null 80 | ) { 81 | return
Loading...
; 82 | } 83 | 84 | return ( 85 |
86 | 96 |
97 |
98 |

Make a Tool

99 | 100 |
101 |