├── .dockerignore ├── .github ├── demo.png ├── logo.png └── workflows │ └── release.yml ├── .gitignore ├── DEVELOPMENT.md ├── Dockerfile ├── LICENSE ├── README.md ├── backend ├── .env.example ├── .gitignore ├── assets │ └── assets.go ├── config │ └── config.go ├── database │ ├── containers.sql.go │ ├── database.go │ ├── db.go │ ├── flows.sql.go │ ├── logs.sql.go │ ├── models.go │ └── tasks.sql.go ├── executor │ ├── browser.go │ ├── container.go │ ├── processor.go │ ├── queue.go │ └── terminal.go ├── go.mod ├── go.sum ├── gqlgen.yml ├── graph │ ├── generated.go │ ├── model │ │ └── models_gen.go │ ├── resolver.go │ ├── schema.graphqls │ ├── schema.resolvers.go │ └── subscriptions │ │ ├── broadcast.go │ │ ├── manager.go │ │ └── subscriptions.go ├── main.go ├── migrations │ ├── 20240325154630_initial_migration.sql │ ├── 20240325193843_add_logs_table.sql │ ├── 20240328114536_tool_call_id_field.sql │ ├── 20240403115154_add_model_to_each_flow.sql │ └── 20240403132844_add_model_provider_to_each_flow.sql ├── models │ ├── containers.sql │ ├── flows.sql │ ├── logs.sql │ ├── models.go │ └── tasks.sql ├── providers │ ├── common.go │ ├── ollama.go │ ├── openai.go │ ├── providers.go │ └── types.go ├── router │ └── router.go ├── sqlc.yml ├── templates │ ├── prompts │ │ ├── agent.tmpl │ │ ├── docker.tmpl │ │ └── summary.tmpl │ ├── scripts │ │ ├── content.js │ │ └── urls.js │ └── templates.go ├── tools.go └── websocket │ └── websocket.go └── frontend ├── .env.example ├── .eslintrc.cjs ├── .gitignore ├── .prettierignore ├── .prettierrc ├── README.md ├── codegen.yml ├── generated ├── graphql.schema.json └── graphql.ts ├── index.html ├── package.json ├── public ├── Inter-roman.var.woff2 ├── android-chrome-192x192.png ├── android-chrome-256x256.png ├── apple-touch-icon.png ├── browserconfig.xml ├── favicon-16x16.png ├── favicon-32x32.png ├── favicon.ico ├── mstile-150x150.png └── site.webmanifest ├── src ├── App.tsx ├── assets │ ├── docker.svg │ ├── logo.png │ └── me.png ├── components │ ├── Browser │ │ ├── Browser.css.ts │ │ └── Browser.tsx │ ├── Button │ │ ├── Button.css.ts │ │ └── Button.tsx │ ├── Dropdown │ │ ├── Dropdown.css.ts │ │ └── Dropdown.tsx │ ├── Icon │ │ ├── Icon.css.ts │ │ ├── Icon.tsx │ │ └── svg │ │ │ ├── Browser.tsx │ │ │ ├── Check.tsx │ │ │ ├── CheckCircle.tsx │ │ │ ├── Code.tsx │ │ │ ├── Eye.tsx │ │ │ ├── EyeOff.tsx │ │ │ ├── MessageQuestion.tsx │ │ │ └── Terminal.tsx │ ├── Messages │ │ ├── Message │ │ │ ├── Message.css.ts │ │ │ └── Message.tsx │ │ ├── Messages.css.ts │ │ └── Messages.tsx │ ├── Panel │ │ ├── Panel.css.ts │ │ └── Panel.tsx │ ├── Sidebar │ │ ├── MenuItem │ │ │ ├── MenuItem.css.ts │ │ │ └── MenuItem.tsx │ │ ├── NewTask │ │ │ ├── ModelSelector │ │ │ │ ├── ModelSelector.css.ts │ │ │ │ └── ModelSelector.tsx │ │ │ ├── NewTask.css.ts │ │ │ └── NewTask.tsx │ │ ├── Sidebar.css.ts │ │ └── Sidebar.tsx │ ├── Tabs │ │ └── Tabs.css.ts │ ├── Terminal │ │ ├── Terminal.css.ts │ │ └── Terminal.tsx │ └── Tooltip │ │ ├── Tooltip.css.ts │ │ └── Tooltip.tsx ├── graphql.ts ├── layouts │ └── AppLayout │ │ ├── AppLayout.css.ts │ │ ├── AppLayout.graphql │ │ └── AppLayout.tsx ├── main.tsx ├── pages │ └── ChatPage │ │ ├── ChatPage.css.ts │ │ ├── ChatPage.graphql │ │ └── ChatPage.tsx ├── styles │ ├── font.css.ts │ ├── global.css.ts │ └── theme.css.ts └── vite-env.d.ts ├── tsconfig.json ├── tsconfig.node.json ├── vite.config.ts └── yarn.lock /.dockerignore: -------------------------------------------------------------------------------- 1 | frontend/dist 2 | frontend/node_modules 3 | frontend/dist 4 | frontend/.env.local 5 | 6 | backend/.env 7 | 8 | **/*.log 9 | **/*.env 10 | **/.DS_Store 11 | **/Thumbs.db 12 | -------------------------------------------------------------------------------- /.github/demo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/semanser/codel/fe1e846c9e04f41209498ed0d88e92b15dcdfad2/.github/demo.png -------------------------------------------------------------------------------- /.github/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/semanser/codel/fe1e846c9e04f41209498ed0d88e92b15dcdfad2/.github/logo.png -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release Docker Image 2 | 3 | on: 4 | push: 5 | tags: 6 | - '[0-9]+.[0-9]+.[0-9]+' 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | permissions: 12 | packages: write 13 | contents: write 14 | 15 | steps: 16 | - name: Checkout code 17 | uses: actions/checkout@v4 18 | 19 | - name: Set up QEMU 20 | uses: docker/setup-qemu-action@v3 21 | 22 | - name: Set up Docker Buildx 23 | uses: docker/setup-buildx-action@v3 24 | 25 | - name: Login to GitHub Container Registry 26 | run: echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u ${{ github.actor }} --password-stdin 27 | 28 | - name: Build and push Docker image 29 | uses: docker/build-push-action@v3 30 | with: 31 | context: . 32 | file: ./Dockerfile 33 | platforms: linux/amd64,linux/arm64 34 | push: true 35 | tags: ghcr.io/${{ github.repository }}:${{ github.ref_name }},ghcr.io/${{ github.repository }}:latest 36 | 37 | - name: Create GitHub Release 38 | uses: softprops/action-gh-release@v2 39 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .env 3 | .env.* 4 | .envrc 5 | fe 6 | -------------------------------------------------------------------------------- /DEVELOPMENT.md: -------------------------------------------------------------------------------- 1 | # Development 2 | 3 | ## Prerequisites 4 | - golang 5 | - nodejs 6 | - docker 7 | - postgresql 8 | 9 | ## Environment variables 10 | First, run `cp ./backend/.env.example ./backend/.env && cp ./frontend/.env.example ./frontend/.env.local` to generate the env files for both backend and frontend. 11 | 12 | ### Backend 13 | Edit the `.env` file in `backend` folder 14 | - `OPEN_AI_KEY` - OpenAI API key 15 | - `DATABASE_URL` - PostgreSQL database URL (eg. `postgres://user:password@localhost:5432/database`) 16 | - `DOCKER_HOST` - Docker SDK API (eg. `DOCKER_HOST=unix:///Users//Library/Containers/com.docker.docker/Data/docker.raw.sock`) [more info](https://stackoverflow.com/a/62757128/5922857) 17 | 18 | Optional: 19 | - `PORT` - Port to run the server (default: `8080`) 20 | - `OPEN_AI_MODEL` - OpenAI model (default: `gpt-4-0125-preview`). The list of supported OpenAI models can be found [here](https://pkg.go.dev/github.com/sashabaranov/go-openai#pkg-constants). 21 | ### Frontend 22 | Edit the `.env.local` file in `frontend` folder 23 | - `VITE_API_URL` - Backend API URL. *Omit* the URL scheme (e.g., `localhost:8080` *NOT* `http://localhost:8080`). 24 | 25 | ## Steps 26 | ### Backend 27 | Run the command(s) in `backend` folder 28 | - Run `go run .` to start the server 29 | 30 | >The first run can be a long wait because the dependencies and the docker images need to be download to setup the backend environment. 31 | When you see output below, the server has started successfully: 32 | ``` 33 | connect to http://localhost:/playground for GraphQL playground 34 | ``` 35 | ### Frontend 36 | Run the command(s) in `frontend` folder 37 | - Run `yarn` to install the dependencies 38 | - Run `yarn dev` to run the web app 39 | 40 | Open your browser and visit the web app URL. 41 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # STEP 1: Build the frontend 2 | FROM node:21-slim as fe-build 3 | 4 | ENV NODE_ENV=production 5 | ENV VITE_API_URL=localhost:3000 6 | 7 | WORKDIR /frontend 8 | 9 | COPY ./backend/graph/schema.graphqls ../backend/graph/ 10 | 11 | COPY frontend/ . 12 | 13 | # --production=false is required because we want to install the @graphql-codegen/cli package (and it's in the devDependencies) 14 | # https://classic.yarnpkg.com/lang/en/docs/cli/install/#toc-yarn-install-production-true-false 15 | RUN yarn install --frozen-lockfile --production=false 16 | RUN ls -la /frontend 17 | RUN yarn build 18 | 19 | # STEP 2: Build the backend 20 | FROM golang:1.22-alpine as be-build 21 | ENV CGO_ENABLED=1 22 | RUN apk add --no-cache gcc musl-dev 23 | 24 | WORKDIR /backend 25 | 26 | COPY backend/ . 27 | 28 | RUN go mod download 29 | 30 | RUN go build -ldflags='-extldflags "-static"' -o /app 31 | 32 | # STEP 3: Build the final image 33 | FROM alpine:3.14 34 | 35 | COPY --from=be-build /app /app 36 | COPY --from=fe-build /frontend/dist /fe 37 | 38 | # Install sqlite3 39 | 40 | RUN apk add --no-cache sqlite 41 | 42 | CMD /app 43 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 |
Fully autonomous AI Agent that can perform complicated tasks and projects using terminal, browser, and editor.
3 |
4 | 5 | **Discord: https://discord.gg/uMaGSHNjzc** 6 | 7 | # Features 8 | - 🔓 Secure. Everything is running in a sandboxed Docker environment. 9 | - 🤖 Autonomous. Automatically detects the next step and performs it. 10 | - 🔍 Built-in browser. Fetches latest information from the web (tutorials, docs, etc.) if needed. 11 | - 📙 Built-in text editor. View all the modified files right in your browser. 12 | - 🧠 All the history commands and outputs are saved in the PostgreSQL database. 13 | - 📦 Automatic Docker-image picker based on the user task. 14 | - 🤳 Self-hosted 15 | - 💅 Modern UI 16 | 17 | # Getting started 18 | The simplest way to run Codel is to use a pre-built Docker image. You can find the latest image on the [Github Container Registry](https://github.com/semanser/codel/pkgs/container/codel). 19 | 20 | 21 | > [!IMPORTANT] 22 | > You need to use a corresponding environment variable in order to use any of the supported language models. 23 | 24 | You can run the Docker image with the following command. Remove or change the environment variables according to your needs. 25 | ```bash 26 | docker run \ 27 | -e OPEN_AI_KEY=your_open_ai_key \ 28 | -e OPEN_AI_MODEL=gpt-4-0125-preview \ 29 | -e OLLAMA_MODEL=llama2 \ 30 | -p 3000:8080 \ 31 | -v /var/run/docker.sock:/var/run/docker.sock \ 32 | ghcr.io/semanser/codel:latest 33 | ``` 34 | 35 | Alternatively, you can create a `.env` file and run the Docker image with the `--env-file` flag. More information can be found [here](https://docs.docker.com/reference/cli/docker/container/run/#env) 36 | 37 | Now you can visit [localhost:3000](localhost:3000) in your browser and start using Codel. 38 | 39 |
40 | Supported environment variables 41 | 42 | * `OPEN_AI_KEY` - OpenAI API key. You can get the key [here](https://platform.openai.com/account/api-keys). 43 | * `OPEN_AI_MODEL` - OpenAI model (default: gpt-4-0125-preview). The list of supported OpenAI models can be found [here](https://pkg.go.dev/github.com/sashabaranov/go-openai#pkg-constants). 44 | * `OPEN_AI_SERVER_URL` - OpenAI server URL (default: https://api.openai.com/v1). Change this URL if you are using an OpenAI compatible server. 45 | * `OLLAMA_MODEL` - locally hosted Ollama model (default: https://ollama.com/model). The list of supported Ollama models can be found [here](https://ollama.com/models). 46 | * `OLLAMA_SERVER_URL` - Ollama server URL (default: https://host.docker.internal:11434). Change this URL if you are using an Ollama compatible server. 47 | See backend [.env.example](./backend/.env.example) for more details. 48 | 49 |
50 | 51 | # Development 52 | 53 | Check out the [DEVELOPMENT.md](./DEVELOPMENT.md) for more information. 54 | 55 | # Roadmap 56 | 57 | You can find the project's roadmap [here](https://github.com/semanser/codel/milestones). 58 | 59 | # Credits 60 | This project wouldn't be possible without: 61 | - https://arxiv.org/abs/2308.00352 62 | - https://arxiv.org/abs/2403.08299 63 | - https://www.cognition-labs.com/introducing-devin 64 | - https://github.com/go-rod/rod 65 | - https://github.com/semanser/JsonGenius 66 | -------------------------------------------------------------------------------- /backend/.env.example: -------------------------------------------------------------------------------- 1 | # General 2 | DATABASE_URL= 3 | DOCKER_HOST= 4 | PORT= 5 | 6 | # OpenAI 7 | OPEN_AI_KEY= 8 | OPEN_AI_SERVER_URL= 9 | OPEN_AI_MODEL= 10 | 11 | # Ollama 12 | OLLAMA_MODEL= 13 | 14 | # Goose 15 | GOOSE_DRIVER= 16 | GOOSE_DBSTRING= 17 | -------------------------------------------------------------------------------- /backend/.gitignore: -------------------------------------------------------------------------------- 1 | ai-coder 2 | tmp 3 | database.db 4 | .env.* 5 | -------------------------------------------------------------------------------- /backend/assets/assets.go: -------------------------------------------------------------------------------- 1 | package assets 2 | 3 | import ( 4 | "embed" 5 | ) 6 | 7 | var PromptTemplates embed.FS 8 | var ScriptTemplates embed.FS 9 | 10 | func Init(promptTemplates embed.FS, scriptTemplates embed.FS) { 11 | PromptTemplates = promptTemplates 12 | ScriptTemplates = scriptTemplates 13 | } 14 | -------------------------------------------------------------------------------- /backend/config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "log" 5 | 6 | "github.com/caarlos0/env/v10" 7 | "github.com/joho/godotenv" 8 | ) 9 | 10 | type config struct { 11 | // General 12 | DatabaseURL string `env:"DATABASE_URL" envDefault:"database.db"` 13 | Port int `env:"PORT" envDefault:"8080"` 14 | 15 | // OpenAI 16 | OpenAIKey string `env:"OPEN_AI_KEY"` 17 | OpenAIModel string `env:"OPEN_AI_MODEL" envDefault:"gpt-4-0125-preview"` 18 | OpenAIServerURL string `env:"OPEN_AI_SERVER_URL" envDefault:"https://api.openai.com/v1"` 19 | 20 | // Ollama 21 | OllamaModel string `env:"OLLAMA_MODEL"` 22 | OllamaServerURL string `env:"OLLAMA_SERVER_URL" envDefault:"http://host.docker.internal:11434"` 23 | } 24 | 25 | var Config config 26 | 27 | func Init() { 28 | godotenv.Load() 29 | 30 | if err := env.ParseWithOptions(&Config, env.Options{ 31 | RequiredIfNoDef: false, 32 | }); err != nil { 33 | log.Fatalf("Unable to parse config: %v\n", err) 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /backend/database/containers.sql.go: -------------------------------------------------------------------------------- 1 | // Code generated by sqlc. DO NOT EDIT. 2 | // versions: 3 | // sqlc v1.26.0 4 | // source: containers.sql 5 | 6 | package database 7 | 8 | import ( 9 | "context" 10 | "database/sql" 11 | ) 12 | 13 | const createContainer = `-- name: CreateContainer :one 14 | INSERT INTO containers ( 15 | name, image, status 16 | ) 17 | VALUES ( 18 | ?, ?, ? 19 | ) 20 | RETURNING id, name, local_id, image, status 21 | ` 22 | 23 | type CreateContainerParams struct { 24 | Name sql.NullString 25 | Image sql.NullString 26 | Status sql.NullString 27 | } 28 | 29 | func (q *Queries) CreateContainer(ctx context.Context, arg CreateContainerParams) (Container, error) { 30 | row := q.db.QueryRowContext(ctx, createContainer, arg.Name, arg.Image, arg.Status) 31 | var i Container 32 | err := row.Scan( 33 | &i.ID, 34 | &i.Name, 35 | &i.LocalID, 36 | &i.Image, 37 | &i.Status, 38 | ) 39 | return i, err 40 | } 41 | 42 | const getAllRunningContainers = `-- name: GetAllRunningContainers :many 43 | SELECT id, name, local_id, image, status FROM containers WHERE status = 'running' 44 | ` 45 | 46 | func (q *Queries) GetAllRunningContainers(ctx context.Context) ([]Container, error) { 47 | rows, err := q.db.QueryContext(ctx, getAllRunningContainers) 48 | if err != nil { 49 | return nil, err 50 | } 51 | defer rows.Close() 52 | var items []Container 53 | for rows.Next() { 54 | var i Container 55 | if err := rows.Scan( 56 | &i.ID, 57 | &i.Name, 58 | &i.LocalID, 59 | &i.Image, 60 | &i.Status, 61 | ); err != nil { 62 | return nil, err 63 | } 64 | items = append(items, i) 65 | } 66 | if err := rows.Close(); err != nil { 67 | return nil, err 68 | } 69 | if err := rows.Err(); err != nil { 70 | return nil, err 71 | } 72 | return items, nil 73 | } 74 | 75 | const updateContainerLocalId = `-- name: UpdateContainerLocalId :one 76 | UPDATE containers 77 | SET local_id = ? 78 | WHERE id = ? 79 | RETURNING id, name, local_id, image, status 80 | ` 81 | 82 | type UpdateContainerLocalIdParams struct { 83 | LocalID sql.NullString 84 | ID int64 85 | } 86 | 87 | func (q *Queries) UpdateContainerLocalId(ctx context.Context, arg UpdateContainerLocalIdParams) (Container, error) { 88 | row := q.db.QueryRowContext(ctx, updateContainerLocalId, arg.LocalID, arg.ID) 89 | var i Container 90 | err := row.Scan( 91 | &i.ID, 92 | &i.Name, 93 | &i.LocalID, 94 | &i.Image, 95 | &i.Status, 96 | ) 97 | return i, err 98 | } 99 | 100 | const updateContainerStatus = `-- name: UpdateContainerStatus :one 101 | UPDATE containers 102 | SET status = ? 103 | WHERE id = ? 104 | RETURNING id, name, local_id, image, status 105 | ` 106 | 107 | type UpdateContainerStatusParams struct { 108 | Status sql.NullString 109 | ID int64 110 | } 111 | 112 | func (q *Queries) UpdateContainerStatus(ctx context.Context, arg UpdateContainerStatusParams) (Container, error) { 113 | row := q.db.QueryRowContext(ctx, updateContainerStatus, arg.Status, arg.ID) 114 | var i Container 115 | err := row.Scan( 116 | &i.ID, 117 | &i.Name, 118 | &i.LocalID, 119 | &i.Image, 120 | &i.Status, 121 | ) 122 | return i, err 123 | } 124 | -------------------------------------------------------------------------------- /backend/database/database.go: -------------------------------------------------------------------------------- 1 | package database 2 | 3 | import ( 4 | "database/sql" 5 | ) 6 | 7 | func StringToNullString(s string) sql.NullString { 8 | return sql.NullString{String: s, Valid: true} 9 | } 10 | -------------------------------------------------------------------------------- /backend/database/db.go: -------------------------------------------------------------------------------- 1 | // Code generated by sqlc. DO NOT EDIT. 2 | // versions: 3 | // sqlc v1.26.0 4 | 5 | package database 6 | 7 | import ( 8 | "context" 9 | "database/sql" 10 | ) 11 | 12 | type DBTX interface { 13 | ExecContext(context.Context, string, ...interface{}) (sql.Result, error) 14 | PrepareContext(context.Context, string) (*sql.Stmt, error) 15 | QueryContext(context.Context, string, ...interface{}) (*sql.Rows, error) 16 | QueryRowContext(context.Context, string, ...interface{}) *sql.Row 17 | } 18 | 19 | func New(db DBTX) *Queries { 20 | return &Queries{db: db} 21 | } 22 | 23 | type Queries struct { 24 | db DBTX 25 | } 26 | 27 | func (q *Queries) WithTx(tx *sql.Tx) *Queries { 28 | return &Queries{ 29 | db: tx, 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /backend/database/flows.sql.go: -------------------------------------------------------------------------------- 1 | // Code generated by sqlc. DO NOT EDIT. 2 | // versions: 3 | // sqlc v1.26.0 4 | // source: flows.sql 5 | 6 | package database 7 | 8 | import ( 9 | "context" 10 | "database/sql" 11 | ) 12 | 13 | const createFlow = `-- name: CreateFlow :one 14 | INSERT INTO flows ( 15 | name, status, container_id, model, model_provider 16 | ) 17 | VALUES ( 18 | ?, ?, ?, ?, ? 19 | ) 20 | RETURNING id, created_at, updated_at, name, status, container_id, model, model_provider 21 | ` 22 | 23 | type CreateFlowParams struct { 24 | Name sql.NullString 25 | Status sql.NullString 26 | ContainerID sql.NullInt64 27 | Model sql.NullString 28 | ModelProvider sql.NullString 29 | } 30 | 31 | func (q *Queries) CreateFlow(ctx context.Context, arg CreateFlowParams) (Flow, error) { 32 | row := q.db.QueryRowContext(ctx, createFlow, 33 | arg.Name, 34 | arg.Status, 35 | arg.ContainerID, 36 | arg.Model, 37 | arg.ModelProvider, 38 | ) 39 | var i Flow 40 | err := row.Scan( 41 | &i.ID, 42 | &i.CreatedAt, 43 | &i.UpdatedAt, 44 | &i.Name, 45 | &i.Status, 46 | &i.ContainerID, 47 | &i.Model, 48 | &i.ModelProvider, 49 | ) 50 | return i, err 51 | } 52 | 53 | const readAllFlows = `-- name: ReadAllFlows :many 54 | SELECT 55 | f.id, f.created_at, f.updated_at, f.name, f.status, f.container_id, f.model, f.model_provider, 56 | c.name AS container_name 57 | FROM flows f 58 | LEFT JOIN containers c ON f.container_id = c.id 59 | ORDER BY f.created_at DESC 60 | ` 61 | 62 | type ReadAllFlowsRow struct { 63 | ID int64 64 | CreatedAt sql.NullTime 65 | UpdatedAt sql.NullTime 66 | Name sql.NullString 67 | Status sql.NullString 68 | ContainerID sql.NullInt64 69 | Model sql.NullString 70 | ModelProvider sql.NullString 71 | ContainerName sql.NullString 72 | } 73 | 74 | func (q *Queries) ReadAllFlows(ctx context.Context) ([]ReadAllFlowsRow, error) { 75 | rows, err := q.db.QueryContext(ctx, readAllFlows) 76 | if err != nil { 77 | return nil, err 78 | } 79 | defer rows.Close() 80 | var items []ReadAllFlowsRow 81 | for rows.Next() { 82 | var i ReadAllFlowsRow 83 | if err := rows.Scan( 84 | &i.ID, 85 | &i.CreatedAt, 86 | &i.UpdatedAt, 87 | &i.Name, 88 | &i.Status, 89 | &i.ContainerID, 90 | &i.Model, 91 | &i.ModelProvider, 92 | &i.ContainerName, 93 | ); err != nil { 94 | return nil, err 95 | } 96 | items = append(items, i) 97 | } 98 | if err := rows.Close(); err != nil { 99 | return nil, err 100 | } 101 | if err := rows.Err(); err != nil { 102 | return nil, err 103 | } 104 | return items, nil 105 | } 106 | 107 | const readFlow = `-- name: ReadFlow :one 108 | SELECT 109 | f.id, f.created_at, f.updated_at, f.name, f.status, f.container_id, f.model, f.model_provider, 110 | c.name AS container_name, 111 | c.image AS container_image, 112 | c.status AS container_status, 113 | c.local_id AS container_local_id 114 | FROM flows f 115 | LEFT JOIN containers c ON f.container_id = c.id 116 | WHERE f.id = ? 117 | ` 118 | 119 | type ReadFlowRow struct { 120 | ID int64 121 | CreatedAt sql.NullTime 122 | UpdatedAt sql.NullTime 123 | Name sql.NullString 124 | Status sql.NullString 125 | ContainerID sql.NullInt64 126 | Model sql.NullString 127 | ModelProvider sql.NullString 128 | ContainerName sql.NullString 129 | ContainerImage sql.NullString 130 | ContainerStatus sql.NullString 131 | ContainerLocalID sql.NullString 132 | } 133 | 134 | func (q *Queries) ReadFlow(ctx context.Context, id int64) (ReadFlowRow, error) { 135 | row := q.db.QueryRowContext(ctx, readFlow, id) 136 | var i ReadFlowRow 137 | err := row.Scan( 138 | &i.ID, 139 | &i.CreatedAt, 140 | &i.UpdatedAt, 141 | &i.Name, 142 | &i.Status, 143 | &i.ContainerID, 144 | &i.Model, 145 | &i.ModelProvider, 146 | &i.ContainerName, 147 | &i.ContainerImage, 148 | &i.ContainerStatus, 149 | &i.ContainerLocalID, 150 | ) 151 | return i, err 152 | } 153 | 154 | const updateFlowContainer = `-- name: UpdateFlowContainer :one 155 | UPDATE flows 156 | SET container_id = ? 157 | WHERE id = ? 158 | RETURNING id, created_at, updated_at, name, status, container_id, model, model_provider 159 | ` 160 | 161 | type UpdateFlowContainerParams struct { 162 | ContainerID sql.NullInt64 163 | ID int64 164 | } 165 | 166 | func (q *Queries) UpdateFlowContainer(ctx context.Context, arg UpdateFlowContainerParams) (Flow, error) { 167 | row := q.db.QueryRowContext(ctx, updateFlowContainer, arg.ContainerID, arg.ID) 168 | var i Flow 169 | err := row.Scan( 170 | &i.ID, 171 | &i.CreatedAt, 172 | &i.UpdatedAt, 173 | &i.Name, 174 | &i.Status, 175 | &i.ContainerID, 176 | &i.Model, 177 | &i.ModelProvider, 178 | ) 179 | return i, err 180 | } 181 | 182 | const updateFlowName = `-- name: UpdateFlowName :one 183 | UPDATE flows 184 | SET name = ? 185 | WHERE id = ? 186 | RETURNING id, created_at, updated_at, name, status, container_id, model, model_provider 187 | ` 188 | 189 | type UpdateFlowNameParams struct { 190 | Name sql.NullString 191 | ID int64 192 | } 193 | 194 | func (q *Queries) UpdateFlowName(ctx context.Context, arg UpdateFlowNameParams) (Flow, error) { 195 | row := q.db.QueryRowContext(ctx, updateFlowName, arg.Name, arg.ID) 196 | var i Flow 197 | err := row.Scan( 198 | &i.ID, 199 | &i.CreatedAt, 200 | &i.UpdatedAt, 201 | &i.Name, 202 | &i.Status, 203 | &i.ContainerID, 204 | &i.Model, 205 | &i.ModelProvider, 206 | ) 207 | return i, err 208 | } 209 | 210 | const updateFlowStatus = `-- name: UpdateFlowStatus :one 211 | UPDATE flows 212 | SET status = ? 213 | WHERE id = ? 214 | RETURNING id, created_at, updated_at, name, status, container_id, model, model_provider 215 | ` 216 | 217 | type UpdateFlowStatusParams struct { 218 | Status sql.NullString 219 | ID int64 220 | } 221 | 222 | func (q *Queries) UpdateFlowStatus(ctx context.Context, arg UpdateFlowStatusParams) (Flow, error) { 223 | row := q.db.QueryRowContext(ctx, updateFlowStatus, arg.Status, arg.ID) 224 | var i Flow 225 | err := row.Scan( 226 | &i.ID, 227 | &i.CreatedAt, 228 | &i.UpdatedAt, 229 | &i.Name, 230 | &i.Status, 231 | &i.ContainerID, 232 | &i.Model, 233 | &i.ModelProvider, 234 | ) 235 | return i, err 236 | } 237 | -------------------------------------------------------------------------------- /backend/database/logs.sql.go: -------------------------------------------------------------------------------- 1 | // Code generated by sqlc. DO NOT EDIT. 2 | // versions: 3 | // sqlc v1.26.0 4 | // source: logs.sql 5 | 6 | package database 7 | 8 | import ( 9 | "context" 10 | "database/sql" 11 | ) 12 | 13 | const createLog = `-- name: CreateLog :one 14 | INSERT INTO logs ( 15 | message, flow_id, type 16 | ) 17 | VALUES ( 18 | ?, ?, ? 19 | ) 20 | RETURNING id, message, created_at, flow_id, type 21 | ` 22 | 23 | type CreateLogParams struct { 24 | Message string 25 | FlowID sql.NullInt64 26 | Type string 27 | } 28 | 29 | func (q *Queries) CreateLog(ctx context.Context, arg CreateLogParams) (Log, error) { 30 | row := q.db.QueryRowContext(ctx, createLog, arg.Message, arg.FlowID, arg.Type) 31 | var i Log 32 | err := row.Scan( 33 | &i.ID, 34 | &i.Message, 35 | &i.CreatedAt, 36 | &i.FlowID, 37 | &i.Type, 38 | ) 39 | return i, err 40 | } 41 | 42 | const getLogsByFlowId = `-- name: GetLogsByFlowId :many 43 | SELECT id, message, created_at, flow_id, type 44 | FROM logs 45 | WHERE flow_id = ? 46 | ORDER BY created_at ASC 47 | ` 48 | 49 | func (q *Queries) GetLogsByFlowId(ctx context.Context, flowID sql.NullInt64) ([]Log, error) { 50 | rows, err := q.db.QueryContext(ctx, getLogsByFlowId, flowID) 51 | if err != nil { 52 | return nil, err 53 | } 54 | defer rows.Close() 55 | var items []Log 56 | for rows.Next() { 57 | var i Log 58 | if err := rows.Scan( 59 | &i.ID, 60 | &i.Message, 61 | &i.CreatedAt, 62 | &i.FlowID, 63 | &i.Type, 64 | ); err != nil { 65 | return nil, err 66 | } 67 | items = append(items, i) 68 | } 69 | if err := rows.Close(); err != nil { 70 | return nil, err 71 | } 72 | if err := rows.Err(); err != nil { 73 | return nil, err 74 | } 75 | return items, nil 76 | } 77 | -------------------------------------------------------------------------------- /backend/database/models.go: -------------------------------------------------------------------------------- 1 | // Code generated by sqlc. DO NOT EDIT. 2 | // versions: 3 | // sqlc v1.26.0 4 | 5 | package database 6 | 7 | import ( 8 | "database/sql" 9 | "time" 10 | ) 11 | 12 | type Container struct { 13 | ID int64 14 | Name sql.NullString 15 | LocalID sql.NullString 16 | Image sql.NullString 17 | Status sql.NullString 18 | } 19 | 20 | type Flow struct { 21 | ID int64 22 | CreatedAt sql.NullTime 23 | UpdatedAt sql.NullTime 24 | Name sql.NullString 25 | Status sql.NullString 26 | ContainerID sql.NullInt64 27 | Model sql.NullString 28 | ModelProvider sql.NullString 29 | } 30 | 31 | type Log struct { 32 | ID int64 33 | Message string 34 | CreatedAt time.Time 35 | FlowID sql.NullInt64 36 | Type string 37 | } 38 | 39 | type Task struct { 40 | ID int64 41 | CreatedAt sql.NullTime 42 | UpdatedAt sql.NullTime 43 | Type sql.NullString 44 | Status sql.NullString 45 | Args sql.NullString 46 | Results sql.NullString 47 | Message sql.NullString 48 | FlowID sql.NullInt64 49 | ToolCallID sql.NullString 50 | } 51 | -------------------------------------------------------------------------------- /backend/database/tasks.sql.go: -------------------------------------------------------------------------------- 1 | // Code generated by sqlc. DO NOT EDIT. 2 | // versions: 3 | // sqlc v1.26.0 4 | // source: tasks.sql 5 | 6 | package database 7 | 8 | import ( 9 | "context" 10 | "database/sql" 11 | ) 12 | 13 | const createTask = `-- name: CreateTask :one 14 | INSERT INTO tasks ( 15 | type, 16 | status, 17 | args, 18 | results, 19 | flow_id, 20 | message, 21 | tool_call_id 22 | ) VALUES ( 23 | ?, ?, ?, ?, ?, ?, ? 24 | ) 25 | RETURNING id, created_at, updated_at, type, status, args, results, message, flow_id, tool_call_id 26 | ` 27 | 28 | type CreateTaskParams struct { 29 | Type sql.NullString 30 | Status sql.NullString 31 | Args sql.NullString 32 | Results sql.NullString 33 | FlowID sql.NullInt64 34 | Message sql.NullString 35 | ToolCallID sql.NullString 36 | } 37 | 38 | func (q *Queries) CreateTask(ctx context.Context, arg CreateTaskParams) (Task, error) { 39 | row := q.db.QueryRowContext(ctx, createTask, 40 | arg.Type, 41 | arg.Status, 42 | arg.Args, 43 | arg.Results, 44 | arg.FlowID, 45 | arg.Message, 46 | arg.ToolCallID, 47 | ) 48 | var i Task 49 | err := row.Scan( 50 | &i.ID, 51 | &i.CreatedAt, 52 | &i.UpdatedAt, 53 | &i.Type, 54 | &i.Status, 55 | &i.Args, 56 | &i.Results, 57 | &i.Message, 58 | &i.FlowID, 59 | &i.ToolCallID, 60 | ) 61 | return i, err 62 | } 63 | 64 | const readTasksByFlowId = `-- name: ReadTasksByFlowId :many 65 | SELECT id, created_at, updated_at, type, status, args, results, message, flow_id, tool_call_id FROM tasks 66 | WHERE flow_id = ? 67 | ORDER BY created_at ASC 68 | ` 69 | 70 | func (q *Queries) ReadTasksByFlowId(ctx context.Context, flowID sql.NullInt64) ([]Task, error) { 71 | rows, err := q.db.QueryContext(ctx, readTasksByFlowId, flowID) 72 | if err != nil { 73 | return nil, err 74 | } 75 | defer rows.Close() 76 | var items []Task 77 | for rows.Next() { 78 | var i Task 79 | if err := rows.Scan( 80 | &i.ID, 81 | &i.CreatedAt, 82 | &i.UpdatedAt, 83 | &i.Type, 84 | &i.Status, 85 | &i.Args, 86 | &i.Results, 87 | &i.Message, 88 | &i.FlowID, 89 | &i.ToolCallID, 90 | ); err != nil { 91 | return nil, err 92 | } 93 | items = append(items, i) 94 | } 95 | if err := rows.Close(); err != nil { 96 | return nil, err 97 | } 98 | if err := rows.Err(); err != nil { 99 | return nil, err 100 | } 101 | return items, nil 102 | } 103 | 104 | const updateTaskResults = `-- name: UpdateTaskResults :one 105 | UPDATE tasks 106 | SET results = ? 107 | WHERE id = ? 108 | RETURNING id, created_at, updated_at, type, status, args, results, message, flow_id, tool_call_id 109 | ` 110 | 111 | type UpdateTaskResultsParams struct { 112 | Results sql.NullString 113 | ID int64 114 | } 115 | 116 | func (q *Queries) UpdateTaskResults(ctx context.Context, arg UpdateTaskResultsParams) (Task, error) { 117 | row := q.db.QueryRowContext(ctx, updateTaskResults, arg.Results, arg.ID) 118 | var i Task 119 | err := row.Scan( 120 | &i.ID, 121 | &i.CreatedAt, 122 | &i.UpdatedAt, 123 | &i.Type, 124 | &i.Status, 125 | &i.Args, 126 | &i.Results, 127 | &i.Message, 128 | &i.FlowID, 129 | &i.ToolCallID, 130 | ) 131 | return i, err 132 | } 133 | 134 | const updateTaskStatus = `-- name: UpdateTaskStatus :one 135 | UPDATE tasks 136 | SET status = ? 137 | WHERE id = ? 138 | RETURNING id, created_at, updated_at, type, status, args, results, message, flow_id, tool_call_id 139 | ` 140 | 141 | type UpdateTaskStatusParams struct { 142 | Status sql.NullString 143 | ID int64 144 | } 145 | 146 | func (q *Queries) UpdateTaskStatus(ctx context.Context, arg UpdateTaskStatusParams) (Task, error) { 147 | row := q.db.QueryRowContext(ctx, updateTaskStatus, arg.Status, arg.ID) 148 | var i Task 149 | err := row.Scan( 150 | &i.ID, 151 | &i.CreatedAt, 152 | &i.UpdatedAt, 153 | &i.Type, 154 | &i.Status, 155 | &i.Args, 156 | &i.Results, 157 | &i.Message, 158 | &i.FlowID, 159 | &i.ToolCallID, 160 | ) 161 | return i, err 162 | } 163 | 164 | const updateTaskToolCallId = `-- name: UpdateTaskToolCallId :one 165 | UPDATE tasks 166 | SET tool_call_id = ? 167 | WHERE id = ? 168 | RETURNING id, created_at, updated_at, type, status, args, results, message, flow_id, tool_call_id 169 | ` 170 | 171 | type UpdateTaskToolCallIdParams struct { 172 | ToolCallID sql.NullString 173 | ID int64 174 | } 175 | 176 | func (q *Queries) UpdateTaskToolCallId(ctx context.Context, arg UpdateTaskToolCallIdParams) (Task, error) { 177 | row := q.db.QueryRowContext(ctx, updateTaskToolCallId, arg.ToolCallID, arg.ID) 178 | var i Task 179 | err := row.Scan( 180 | &i.ID, 181 | &i.CreatedAt, 182 | &i.UpdatedAt, 183 | &i.Type, 184 | &i.Status, 185 | &i.Args, 186 | &i.Results, 187 | &i.Message, 188 | &i.FlowID, 189 | &i.ToolCallID, 190 | ) 191 | return i, err 192 | } 193 | -------------------------------------------------------------------------------- /backend/executor/browser.go: -------------------------------------------------------------------------------- 1 | package executor 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log" 7 | "os" 8 | "time" 9 | 10 | "github.com/docker/docker/api/types/container" 11 | "github.com/docker/go-connections/nat" 12 | "github.com/go-rod/rod" 13 | "github.com/go-rod/rod/lib/launcher" 14 | "github.com/go-rod/rod/lib/proto" 15 | "github.com/semanser/ai-coder/assets" 16 | "github.com/semanser/ai-coder/database" 17 | "github.com/semanser/ai-coder/templates" 18 | ) 19 | 20 | var ( 21 | browser *rod.Browser 22 | ) 23 | 24 | const port = "9222" 25 | 26 | func InitBrowser(db *database.Queries) error { 27 | browserContainerName := BrowserName() 28 | portBinding := nat.Port(fmt.Sprintf("%s/tcp", port)) 29 | 30 | _, err := SpawnContainer(context.Background(), browserContainerName, &container.Config{ 31 | Image: "ghcr.io/go-rod/rod", 32 | ExposedPorts: nat.PortSet{ 33 | portBinding: struct{}{}, 34 | }, 35 | Cmd: []string{"chrome", "--headless", "--no-sandbox", fmt.Sprintf("--remote-debugging-port=%s", port), "--remote-debugging-address=0.0.0.0"}, 36 | }, &container.HostConfig{ 37 | PortBindings: nat.PortMap{ 38 | portBinding: []nat.PortBinding{ 39 | { 40 | HostIP: "0.0.0.0", 41 | HostPort: port, 42 | }, 43 | }, 44 | }, 45 | }, db) 46 | 47 | if err != nil { 48 | return fmt.Errorf("failed to spawn container: %w", err) 49 | } 50 | 51 | return nil 52 | } 53 | 54 | func Content(url string) (result string, screenshotName string, err error) { 55 | log.Println("Trying to get content from", url) 56 | 57 | page, err := loadPage() 58 | 59 | if err != nil { 60 | return "", "", fmt.Errorf("error loading page: %w", err) 61 | } 62 | 63 | err = loadUrl(page, url) 64 | 65 | if err != nil { 66 | return "", "", fmt.Errorf("error loading url: %w", err) 67 | } 68 | 69 | script, err := templates.Render(assets.ScriptTemplates, "scripts/content.js", nil) 70 | 71 | if err != nil { 72 | return "", "", fmt.Errorf("error reading script: %w", err) 73 | } 74 | 75 | pageText, err := page.Eval(string(script)) 76 | 77 | if err != nil { 78 | return "", "", fmt.Errorf("error evaluating script: %w", err) 79 | } 80 | 81 | screenshot, err := page.Screenshot(false, nil) 82 | 83 | if err != nil { 84 | return "", "", fmt.Errorf("error taking screenshot: %w", err) 85 | } 86 | 87 | screenshotName, err = writeScreenshotToFile(screenshot) 88 | 89 | if err != nil { 90 | return "", "", fmt.Errorf("error writing screenshot to file: %w", err) 91 | } 92 | 93 | return pageText.Value.Str(), screenshotName, nil 94 | } 95 | 96 | func URLs(url string) (result string, screenshotName string, err error) { 97 | log.Println("Trying to get urls from", url) 98 | 99 | page, err := loadPage() 100 | 101 | if err != nil { 102 | return "", "", fmt.Errorf("error loading page: %w", err) 103 | } 104 | 105 | err = loadUrl(page, url) 106 | 107 | if err != nil { 108 | return "", "", fmt.Errorf("error loading url: %w", err) 109 | } 110 | 111 | script, err := templates.Render(assets.ScriptTemplates, "scripts/urls.js", nil) 112 | 113 | if err != nil { 114 | return "", "", fmt.Errorf("error reading script: %w", err) 115 | } 116 | 117 | urls, err := page.Eval(string(script)) 118 | 119 | if err != nil { 120 | return "", "", fmt.Errorf("error evaluating script: %w", err) 121 | } 122 | 123 | screenshot, err := page.Screenshot(true, nil) 124 | 125 | if err != nil { 126 | return "", "", fmt.Errorf("error taking screenshot: %w", err) 127 | } 128 | 129 | screenshotName, err = writeScreenshotToFile(screenshot) 130 | 131 | if err != nil { 132 | return "", "", fmt.Errorf("error writing screenshot to file: %w", err) 133 | } 134 | 135 | return urls.Value.Str(), screenshotName, nil 136 | } 137 | 138 | func writeScreenshotToFile(screenshot []byte) (filename string, err error) { 139 | // Write screenshot to file 140 | filename = fmt.Sprintf("%s.png", time.Now().Format("2006-01-02-15-04-05")) 141 | path := fmt.Sprintf("./tmp/browser/", filename) 142 | filepath := fmt.Sprintf("./tmp/browser/%s", filename) 143 | 144 | err = os.MkdirAll(path, os.ModePerm) 145 | if err != nil { 146 | return "", fmt.Errorf("error creating directory: %w", err) 147 | } 148 | 149 | file, err := os.Create(filepath) 150 | 151 | if err != nil { 152 | return "", fmt.Errorf("error creating file: %w", err) 153 | } 154 | 155 | defer file.Close() 156 | 157 | _, err = file.Write(screenshot) 158 | 159 | if err != nil { 160 | return "", fmt.Errorf("error writing to file: %w", err) 161 | } 162 | 163 | return filename, nil 164 | } 165 | 166 | func BrowserName() string { 167 | return "codel-browser" 168 | } 169 | 170 | func loadPage() (*rod.Page, error) { 171 | u, err := launcher.ResolveURL("") 172 | 173 | if err != nil { 174 | return nil, fmt.Errorf("error resolving url: %w", err) 175 | } 176 | 177 | browser := rod.New().ControlURL(u) 178 | err = browser.Connect() 179 | 180 | if err != nil { 181 | return nil, fmt.Errorf("error connecting to browser: %w", err) 182 | } 183 | 184 | version, err := browser.Version() 185 | 186 | if err != nil { 187 | return nil, fmt.Errorf("error getting browser version: %w", err) 188 | } 189 | log.Printf("Connected to browser %s", version.Product) 190 | 191 | page, err := browser.Page(proto.TargetCreateTarget{}) 192 | 193 | if err != nil { 194 | return nil, fmt.Errorf("error opening page: %w", err) 195 | } 196 | 197 | return page, nil 198 | } 199 | 200 | func loadUrl(page *rod.Page, url string) error { 201 | pageRouter := page.HijackRequests() 202 | 203 | // Do not load any images or css files 204 | pageRouter.MustAdd("*", func(ctx *rod.Hijack) { 205 | // There're a lot of types you can use in this enum, like NetworkResourceTypeScript for javascript files 206 | // In this case we're using NetworkResourceTypeImage to block images 207 | if ctx.Request.Type() == proto.NetworkResourceTypeImage || 208 | ctx.Request.Type() == proto.NetworkResourceTypeStylesheet || 209 | ctx.Request.Type() == proto.NetworkResourceTypeFont || 210 | ctx.Request.Type() == proto.NetworkResourceTypeMedia || 211 | ctx.Request.Type() == proto.NetworkResourceTypeManifest || 212 | ctx.Request.Type() == proto.NetworkResourceTypeOther { 213 | ctx.Response.Fail(proto.NetworkErrorReasonBlockedByClient) 214 | return 215 | } 216 | ctx.ContinueRequest(&proto.FetchContinueRequest{}) 217 | }) 218 | 219 | // since we are only hijacking a specific page, even using the "*" won't affect much of the performance 220 | go pageRouter.Run() 221 | 222 | err := page.Navigate(url) 223 | 224 | if err != nil { 225 | return fmt.Errorf("error navigating to page: %w", err) 226 | } 227 | 228 | err = page.WaitDOMStable(time.Second*1, 5) 229 | 230 | if err != nil { 231 | return fmt.Errorf("error waiting for page to stabilize: %w", err) 232 | } 233 | 234 | return nil 235 | } 236 | -------------------------------------------------------------------------------- /backend/executor/container.go: -------------------------------------------------------------------------------- 1 | package executor 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "io" 7 | "log" 8 | "os" 9 | "sync" 10 | 11 | "github.com/docker/docker/api/types" 12 | "github.com/docker/docker/api/types/container" 13 | "github.com/docker/docker/api/types/filters" 14 | "github.com/docker/docker/client" 15 | "github.com/semanser/ai-coder/database" 16 | ) 17 | 18 | var ( 19 | dockerClient *client.Client 20 | ) 21 | 22 | const defaultImage = "debian:latest" 23 | 24 | func InitClient() error { 25 | cli, err := client.NewClientWithOpts(client.FromEnv) 26 | if err != nil { 27 | return fmt.Errorf("error initializing docker client: %w", err) 28 | } 29 | cli.NegotiateAPIVersion(context.Background()) 30 | 31 | dockerClient = cli 32 | info, err := dockerClient.Info(context.Background()) 33 | 34 | if err != nil { 35 | return fmt.Errorf("error getting docker info: %w", err) 36 | } 37 | 38 | log.Printf("Docker client initialized: %s, %s", info.Name, info.Architecture) 39 | log.Printf("Docker server API version: %s", info.ServerVersion) 40 | log.Printf("Docker client API version: %s", dockerClient.ClientVersion()) 41 | 42 | return nil 43 | } 44 | 45 | func SpawnContainer(ctx context.Context, name string, config *container.Config, hostConfig *container.HostConfig, db *database.Queries) (dbContainerID int64, err error) { 46 | if config == nil { 47 | return 0, fmt.Errorf("no config found for container %s", name) 48 | } 49 | 50 | log.Printf("Spawning container %s \"%s\"\n", config.Image, name) 51 | 52 | dbContainer, err := db.CreateContainer(ctx, database.CreateContainerParams{ 53 | Name: database.StringToNullString(name), 54 | Image: database.StringToNullString(config.Image), 55 | Status: database.StringToNullString("starting"), 56 | }) 57 | 58 | if err != nil { 59 | return dbContainer.ID, fmt.Errorf("error creating container in database: %w", err) 60 | } 61 | 62 | localContainerID := "" 63 | 64 | defer func() { 65 | status := "failed" 66 | 67 | if err != nil { 68 | err := StopContainer(localContainerID, dbContainerID, db) 69 | 70 | if err != nil { 71 | log.Printf("error stopping failed container %d: %v\n", dbContainerID, err) 72 | } 73 | } else { 74 | status = "running" 75 | } 76 | 77 | _, err := db.UpdateContainerStatus(ctx, database.UpdateContainerStatusParams{ 78 | ID: dbContainer.ID, 79 | Status: database.StringToNullString(status), 80 | }) 81 | 82 | if err != nil { 83 | log.Printf("error updating container status: %s\n", err) 84 | } 85 | 86 | _, err = db.UpdateContainerLocalId(ctx, database.UpdateContainerLocalIdParams{ 87 | ID: dbContainer.ID, 88 | LocalID: database.StringToNullString(localContainerID), 89 | }) 90 | 91 | if err != nil { 92 | log.Printf("error updating container local id: %s\n", err) 93 | } 94 | }() 95 | 96 | filters := filters.NewArgs() 97 | filters.Add("reference", config.Image) 98 | images, err := dockerClient.ImageList(ctx, types.ImageListOptions{ 99 | Filters: filters, 100 | }) 101 | 102 | if err != nil { 103 | return dbContainer.ID, fmt.Errorf("error listing images: %w", err) 104 | } 105 | 106 | imageExistsLocally := len(images) > 0 107 | 108 | log.Printf("Image %s found locally: %t\n", config.Image, imageExistsLocally) 109 | 110 | if !imageExistsLocally { 111 | log.Printf("Pulling image %s...\n", config.Image) 112 | readCloser, err := dockerClient.ImagePull(ctx, config.Image, types.ImagePullOptions{}) 113 | 114 | if err != nil { 115 | config.Image = defaultImage 116 | log.Printf("Error pulling image: %s. Using default image %s\n", err, defaultImage) 117 | } 118 | 119 | if err == nil { 120 | // Wait for the pull to finish 121 | _, err = io.Copy(io.Discard, readCloser) 122 | 123 | if err != nil { 124 | log.Printf("Error waiting for image pull: %s\n", err) 125 | } 126 | } 127 | } 128 | 129 | log.Printf("Creating container %s...\n", name) 130 | resp, err := dockerClient.ContainerCreate(ctx, config, hostConfig, nil, nil, name) 131 | 132 | if err != nil { 133 | return dbContainer.ID, fmt.Errorf("error creating container: %w", err) 134 | } 135 | 136 | log.Printf("Container %s created\n", name) 137 | 138 | localContainerID = resp.ID 139 | err = dockerClient.ContainerStart(ctx, localContainerID, container.StartOptions{}) 140 | 141 | if err != nil { 142 | return dbContainer.ID, fmt.Errorf("error starting container: %w", err) 143 | } 144 | log.Printf("Container %s started\n", name) 145 | 146 | return dbContainer.ID, nil 147 | } 148 | 149 | func StopContainer(containerID string, dbID int64, db *database.Queries) error { 150 | if err := dockerClient.ContainerStop(context.Background(), containerID, container.StopOptions{}); err != nil { 151 | if client.IsErrNotFound(err) { 152 | log.Printf("Container %s not found. Marking it as stopped.\n", containerID) 153 | db.UpdateContainerStatus(context.Background(), database.UpdateContainerStatusParams{ 154 | Status: database.StringToNullString("stopped"), 155 | ID: dbID, 156 | }) 157 | 158 | return nil 159 | } else { 160 | return fmt.Errorf("error stopping container: %w", err) 161 | } 162 | } 163 | 164 | _, err := db.UpdateContainerStatus(context.Background(), database.UpdateContainerStatusParams{ 165 | Status: database.StringToNullString("stopped"), 166 | ID: dbID, 167 | }) 168 | 169 | if err != nil { 170 | return fmt.Errorf("error updating container status to stopped: %w", err) 171 | } 172 | 173 | log.Printf("Container %s stopped\n", containerID) 174 | return nil 175 | } 176 | 177 | func DeleteContainer(containerID string, dbID int64, db *database.Queries) error { 178 | log.Printf("Deleting container %s...\n", containerID) 179 | 180 | if err := StopContainer(containerID, dbID, db); err != nil { 181 | return fmt.Errorf("error stopping container: %w", err) 182 | } 183 | 184 | if err := dockerClient.ContainerRemove(context.Background(), containerID, container.RemoveOptions{}); err != nil { 185 | return fmt.Errorf("error removing container: %w", err) 186 | } 187 | log.Printf("Container %s removed\n", containerID) 188 | return nil 189 | } 190 | 191 | func Cleanup(db *database.Queries) error { 192 | // Remove tmp files 193 | log.Println("Removing tmp files...") 194 | err := os.RemoveAll("./tmp/") 195 | if err != nil { 196 | return fmt.Errorf("error removing tmp files: %w", err) 197 | } 198 | 199 | log.Println("Cleaning up containers and making all flows finished...") 200 | 201 | var wg sync.WaitGroup 202 | 203 | containers, err := db.GetAllRunningContainers(context.Background()) 204 | 205 | if err != nil { 206 | return fmt.Errorf("error getting running containers: %w", err) 207 | } 208 | 209 | for _, container := range containers { 210 | wg.Add(1) 211 | go func() { 212 | localId := container.LocalID.String 213 | if err := DeleteContainer(localId, container.ID, db); err != nil { 214 | log.Printf("Error deleting container %s: %s\n", localId, err) 215 | } 216 | wg.Done() 217 | }() 218 | } 219 | 220 | wg.Wait() 221 | 222 | flows, err := db.ReadAllFlows(context.Background()) 223 | 224 | if err != nil { 225 | return fmt.Errorf("error getting all flows: %w", err) 226 | } 227 | 228 | for _, flow := range flows { 229 | if flow.Status.String == "in_progress" { 230 | _, err := db.UpdateFlowStatus(context.Background(), database.UpdateFlowStatusParams{ 231 | Status: database.StringToNullString("finished"), 232 | ID: flow.ID, 233 | }) 234 | 235 | if err != nil { 236 | log.Printf("Error updating flow status: %s\n", err) 237 | } 238 | } 239 | } 240 | 241 | return nil 242 | } 243 | 244 | func IsContainerRunning(containerID string) (bool, error) { 245 | containerInfo, err := dockerClient.ContainerInspect(context.Background(), containerID) 246 | 247 | if err != nil { 248 | return false, fmt.Errorf("error inspecting container: %w", err) 249 | } 250 | 251 | return containerInfo.State.Running, err 252 | } 253 | -------------------------------------------------------------------------------- /backend/executor/processor.go: -------------------------------------------------------------------------------- 1 | package executor 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | "encoding/json" 7 | "fmt" 8 | "log" 9 | 10 | "github.com/docker/docker/api/types/container" 11 | "github.com/semanser/ai-coder/database" 12 | gmodel "github.com/semanser/ai-coder/graph/model" 13 | "github.com/semanser/ai-coder/graph/subscriptions" 14 | "github.com/semanser/ai-coder/providers" 15 | "github.com/semanser/ai-coder/websocket" 16 | ) 17 | 18 | func processBrowserTask(db *database.Queries, task database.Task) error { 19 | var args = providers.BrowserArgs{} 20 | err := json.Unmarshal([]byte(task.Args.String), &args) 21 | if err != nil { 22 | return fmt.Errorf("failed to unmarshal args: %v", err) 23 | } 24 | 25 | var url = args.Url 26 | var screenshotName string 27 | 28 | if args.Action == providers.Read { 29 | content, screenshot, err := Content(url) 30 | 31 | if err != nil { 32 | return fmt.Errorf("failed to get content: %w", err) 33 | } 34 | 35 | log.Println("Screenshot taken") 36 | screenshotName = screenshot 37 | 38 | _, err = db.UpdateTaskResults(context.Background(), database.UpdateTaskResultsParams{ 39 | ID: task.ID, 40 | Results: database.StringToNullString(content), 41 | }) 42 | 43 | if err != nil { 44 | return fmt.Errorf("failed to update task results: %w", err) 45 | } 46 | } 47 | 48 | if args.Action == providers.Url { 49 | content, screenshot, err := URLs(url) 50 | 51 | if err != nil { 52 | return fmt.Errorf("failed to get content: %w", err) 53 | } 54 | 55 | screenshotName = screenshot 56 | 57 | _, err = db.UpdateTaskResults(context.Background(), database.UpdateTaskResultsParams{ 58 | ID: task.ID, 59 | Results: database.StringToNullString(content), 60 | }) 61 | 62 | if err != nil { 63 | return fmt.Errorf("failed to update task results: %w", err) 64 | } 65 | } 66 | 67 | subscriptions.BroadcastBrowserUpdated(task.FlowID.Int64, &gmodel.Browser{ 68 | URL: url, 69 | // TODO Use a dynamic URL 70 | ScreenshotURL: "http://localhost:8080/browser/" + screenshotName, 71 | }) 72 | 73 | return nil 74 | } 75 | 76 | func processDoneTask(db *database.Queries, task database.Task) error { 77 | flow, err := db.UpdateFlowStatus(context.Background(), database.UpdateFlowStatusParams{ 78 | ID: task.FlowID.Int64, 79 | Status: database.StringToNullString("finished"), 80 | }) 81 | 82 | if err != nil { 83 | return fmt.Errorf("failed to update task status: %w", err) 84 | } 85 | 86 | subscriptions.BroadcastFlowUpdated(task.FlowID.Int64, &gmodel.Flow{ 87 | ID: uint(flow.ID), 88 | Status: gmodel.FlowStatus("finished"), 89 | Terminal: &gmodel.Terminal{}, 90 | }) 91 | 92 | return nil 93 | } 94 | 95 | func processInputTask(provider providers.Provider, db *database.Queries, task database.Task) error { 96 | tasks, err := db.ReadTasksByFlowId(context.Background(), sql.NullInt64{ 97 | Int64: task.FlowID.Int64, 98 | Valid: true, 99 | }) 100 | 101 | if err != nil { 102 | return fmt.Errorf("failed to get tasks by flow id: %w", err) 103 | } 104 | 105 | // This is the first task in the flow. 106 | // We need to get the basic flow data as well as spin up the container 107 | if len(tasks) == 1 { 108 | summary, err := provider.Summary(task.Message.String, 10) 109 | 110 | if err != nil { 111 | return fmt.Errorf("failed to get message summary: %w", err) 112 | } 113 | 114 | dockerImage, err := provider.DockerImageName(task.Message.String) 115 | 116 | if err != nil { 117 | return fmt.Errorf("failed to get docker image name: %w", err) 118 | } 119 | 120 | flow, err := db.UpdateFlowName(context.Background(), database.UpdateFlowNameParams{ 121 | ID: task.FlowID.Int64, 122 | Name: database.StringToNullString(summary), 123 | }) 124 | 125 | if err != nil { 126 | return fmt.Errorf("failed to update flow: %w", err) 127 | } 128 | 129 | subscriptions.BroadcastFlowUpdated(flow.ID, &gmodel.Flow{ 130 | ID: uint(flow.ID), 131 | Name: summary, 132 | Terminal: &gmodel.Terminal{ 133 | ContainerName: dockerImage, 134 | Connected: false, 135 | }, 136 | }) 137 | 138 | msg := websocket.FormatTerminalSystemOutput(fmt.Sprintf("Initializing the docker image %s...", dockerImage)) 139 | l, err := db.CreateLog(context.Background(), database.CreateLogParams{ 140 | FlowID: task.FlowID, 141 | Message: msg, 142 | Type: "system", 143 | }) 144 | 145 | if err != nil { 146 | return fmt.Errorf("error creating log: %w", err) 147 | } 148 | 149 | subscriptions.BroadcastTerminalLogsAdded(flow.ID, &gmodel.Log{ 150 | ID: uint(l.ID), 151 | Text: msg, 152 | }) 153 | 154 | terminalContainerName := TerminalName(flow.ID) 155 | terminalContainerID, err := SpawnContainer(context.Background(), 156 | terminalContainerName, 157 | &container.Config{ 158 | Image: dockerImage, 159 | Cmd: []string{"tail", "-f", "/dev/null"}, 160 | }, 161 | &container.HostConfig{}, 162 | db, 163 | ) 164 | 165 | if err != nil { 166 | return fmt.Errorf("failed to spawn container: %w", err) 167 | } 168 | 169 | subscriptions.BroadcastFlowUpdated(flow.ID, &gmodel.Flow{ 170 | ID: uint(flow.ID), 171 | Name: summary, 172 | Terminal: &gmodel.Terminal{ 173 | Connected: true, 174 | ContainerName: dockerImage, 175 | }, 176 | }) 177 | 178 | _, err = db.UpdateFlowContainer(context.Background(), database.UpdateFlowContainerParams{ 179 | ID: flow.ID, 180 | ContainerID: sql.NullInt64{Int64: terminalContainerID, Valid: true}, 181 | }) 182 | 183 | if err != nil { 184 | return fmt.Errorf("failed to update flow container: %w", err) 185 | } 186 | 187 | msg = websocket.FormatTerminalSystemOutput("Container initialized. Ready to execute commands.") 188 | l, err = db.CreateLog(context.Background(), database.CreateLogParams{ 189 | FlowID: task.FlowID, 190 | Message: msg, 191 | Type: "system", 192 | }) 193 | 194 | if err != nil { 195 | return fmt.Errorf("error creating log: %w", err) 196 | } 197 | subscriptions.BroadcastTerminalLogsAdded(flow.ID, &gmodel.Log{ 198 | ID: uint(l.ID), 199 | Text: msg, 200 | }) 201 | } 202 | 203 | return nil 204 | } 205 | 206 | func processAskTask(db *database.Queries, task database.Task) error { 207 | task, err := db.UpdateTaskStatus(context.Background(), database.UpdateTaskStatusParams{ 208 | Status: database.StringToNullString("finished"), 209 | ID: task.ID, 210 | }) 211 | 212 | if err != nil { 213 | return fmt.Errorf("failed to find task with id %d: %w", task.ID, err) 214 | } 215 | 216 | return nil 217 | } 218 | 219 | func processTerminalTask(db *database.Queries, task database.Task) error { 220 | var args = providers.TerminalArgs{} 221 | err := json.Unmarshal([]byte(task.Args.String), &args) 222 | if err != nil { 223 | return fmt.Errorf("failed to unmarshal args: %v", err) 224 | } 225 | 226 | results, err := ExecCommand(task.FlowID.Int64, args.Input, db) 227 | 228 | if err != nil { 229 | return fmt.Errorf("failed to execute command: %w", err) 230 | } 231 | 232 | _, err = db.UpdateTaskResults(context.Background(), database.UpdateTaskResultsParams{ 233 | ID: task.ID, 234 | Results: database.StringToNullString(results), 235 | }) 236 | 237 | if err != nil { 238 | return fmt.Errorf("failed to update task results: %w", err) 239 | } 240 | 241 | return nil 242 | } 243 | 244 | func processCodeTask(db *database.Queries, task database.Task) error { 245 | var args = providers.CodeArgs{} 246 | err := json.Unmarshal([]byte(task.Args.String), &args) 247 | if err != nil { 248 | return fmt.Errorf("failed to unmarshal args: %v", err) 249 | } 250 | 251 | var cmd = "" 252 | var results = "" 253 | 254 | if args.Action == providers.ReadFile { 255 | // TODO consider using dockerClient.CopyFromContainer command instead 256 | cmd = fmt.Sprintf("cat %s", args.Path) 257 | results, err = ExecCommand(task.FlowID.Int64, cmd, db) 258 | 259 | if err != nil { 260 | return fmt.Errorf("error executing cat command: %w", err) 261 | } 262 | } 263 | 264 | if args.Action == providers.UpdateFile { 265 | err = WriteFile(task.FlowID.Int64, args.Content, args.Path, db) 266 | 267 | if err != nil { 268 | return fmt.Errorf("error writing a file: %w", err) 269 | } 270 | 271 | results = "File updated" 272 | } 273 | 274 | if err != nil { 275 | return fmt.Errorf("failed to execute command: %w", err) 276 | } 277 | 278 | _, err = db.UpdateTaskResults(context.Background(), database.UpdateTaskResultsParams{ 279 | ID: task.ID, 280 | Results: database.StringToNullString(results), 281 | }) 282 | 283 | if err != nil { 284 | return fmt.Errorf("failed to update task results: %w", err) 285 | } 286 | 287 | return nil 288 | } 289 | -------------------------------------------------------------------------------- /backend/executor/queue.go: -------------------------------------------------------------------------------- 1 | package executor 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | "fmt" 7 | "log" 8 | 9 | "github.com/semanser/ai-coder/database" 10 | gmodel "github.com/semanser/ai-coder/graph/model" 11 | "github.com/semanser/ai-coder/graph/subscriptions" 12 | "github.com/semanser/ai-coder/providers" 13 | ) 14 | 15 | var queue = make(map[int64]chan database.Task) 16 | var stopChannels = make(map[int64]chan any) 17 | 18 | func AddQueue(flowId int64, db *database.Queries) { 19 | if _, ok := queue[flowId]; !ok { 20 | queue[flowId] = make(chan database.Task, 1000) 21 | 22 | stop := make(chan any) 23 | stopChannels[flowId] = stop 24 | ProcessQueue(flowId, db) 25 | } 26 | } 27 | 28 | func AddCommand(flowId int64, task database.Task) { 29 | if queue[flowId] != nil { 30 | queue[flowId] <- task 31 | } 32 | log.Printf("Command %d added to the queue %d", task.ID, flowId) 33 | } 34 | 35 | func CleanQueue(flowId int64) { 36 | if _, ok := queue[flowId]; ok { 37 | queue[flowId] = nil 38 | } 39 | 40 | if _, ok := stopChannels[flowId]; ok { 41 | close(stopChannels[flowId]) 42 | stopChannels[flowId] = nil 43 | } 44 | 45 | log.Printf("Queue %d cleaned", flowId) 46 | } 47 | 48 | func ProcessQueue(flowId int64, db *database.Queries) { 49 | log.Println("Starting tasks processor for queue", flowId) 50 | 51 | flow, err := db.ReadFlow(context.Background(), flowId) 52 | 53 | if err != nil { 54 | log.Printf("failed to get provider: %v", err) 55 | CleanQueue(flowId) 56 | return 57 | } 58 | 59 | provider, err := providers.ProviderFactory(providers.ProviderType(flow.ModelProvider.String)) 60 | 61 | if err != nil { 62 | log.Printf("failed to get provider: %v", err) 63 | CleanQueue(flowId) 64 | return 65 | } 66 | 67 | log.Printf("Using provider: %s. Model: %s\n", provider.Name(), flow.Model.String) 68 | 69 | go func() { 70 | for { 71 | select { 72 | case <-stopChannels[flowId]: 73 | log.Printf("Stopping task processor for queue %d", flowId) 74 | return 75 | default: 76 | 77 | log.Println("Waiting for a task") 78 | task := <-queue[flowId] 79 | 80 | log.Printf("Processing command %d of type %s", task.ID, task.Type.String) 81 | 82 | // Input tasks are added by the user optimistically on the client 83 | // so they should not be broadcasted back to the client 84 | subscriptions.BroadcastTaskAdded(task.FlowID.Int64, &gmodel.Task{ 85 | ID: uint(task.ID), 86 | Message: task.Message.String, 87 | Type: gmodel.TaskType(task.Type.String), 88 | CreatedAt: task.CreatedAt.Time, 89 | Status: gmodel.TaskStatus(task.Status.String), 90 | Args: task.Args.String, 91 | Results: task.Results.String, 92 | }) 93 | 94 | if task.Type.String == "input" { 95 | err := processInputTask(provider, db, task) 96 | 97 | if err != nil { 98 | log.Printf("failed to process input: %v", err) 99 | continue 100 | } 101 | 102 | nextTask, err := getNextTask(provider, db, task.FlowID.Int64) 103 | 104 | if err != nil { 105 | log.Printf("failed to get next task: %v", err) 106 | continue 107 | } 108 | 109 | AddCommand(flowId, *nextTask) 110 | } 111 | 112 | if task.Type.String == "ask" { 113 | err := processAskTask(db, task) 114 | 115 | if err != nil { 116 | log.Printf("failed to process ask: %v", err) 117 | continue 118 | } 119 | } 120 | 121 | if task.Type.String == "terminal" { 122 | err := processTerminalTask(db, task) 123 | 124 | if err != nil { 125 | log.Printf("failed to process terminal: %v", err) 126 | continue 127 | } 128 | nextTask, err := getNextTask(provider, db, task.FlowID.Int64) 129 | 130 | if err != nil { 131 | log.Printf("failed to get next task: %v", err) 132 | continue 133 | } 134 | 135 | AddCommand(flowId, *nextTask) 136 | } 137 | 138 | if task.Type.String == "code" { 139 | err := processCodeTask(db, task) 140 | 141 | if err != nil { 142 | log.Printf("failed to process code: %v", err) 143 | continue 144 | } 145 | 146 | nextTask, err := getNextTask(provider, db, task.FlowID.Int64) 147 | 148 | if err != nil { 149 | log.Printf("failed to get next task: %v", err) 150 | continue 151 | } 152 | 153 | AddCommand(flowId, *nextTask) 154 | } 155 | 156 | if task.Type.String == "done" { 157 | err := processDoneTask(db, task) 158 | 159 | if err != nil { 160 | log.Printf("failed to process done: %v", err) 161 | continue 162 | } 163 | } 164 | 165 | if task.Type.String == "browser" { 166 | err := processBrowserTask(db, task) 167 | 168 | if err != nil { 169 | log.Printf("failed to process browser: %v", err) 170 | continue 171 | } 172 | 173 | nextTask, err := getNextTask(provider, db, task.FlowID.Int64) 174 | 175 | if err != nil { 176 | log.Printf("failed to get next task: %v", err) 177 | continue 178 | } 179 | 180 | AddCommand(flowId, *nextTask) 181 | } 182 | } 183 | } 184 | }() 185 | } 186 | 187 | func getNextTask(provider providers.Provider, db *database.Queries, flowId int64) (*database.Task, error) { 188 | flow, err := db.ReadFlow(context.Background(), flowId) 189 | 190 | if err != nil { 191 | return nil, fmt.Errorf("failed to get flow: %w", err) 192 | } 193 | 194 | tasks, err := db.ReadTasksByFlowId(context.Background(), sql.NullInt64{ 195 | Int64: flowId, 196 | Valid: true, 197 | }) 198 | 199 | if err != nil { 200 | return nil, fmt.Errorf("failed to get tasks by flow id: %w", err) 201 | } 202 | 203 | const maxResultsLength = 4000 204 | for i, task := range tasks { 205 | // Limit the number of result characters since some output commands can have a lot of output 206 | if len(task.Results.String) > maxResultsLength { 207 | // Get the last N symbols from the output 208 | results := task.Results.String[len(task.Results.String)-maxResultsLength:] 209 | tasks[i].Results = database.StringToNullString(results) 210 | } 211 | } 212 | 213 | c := provider.NextTask(providers.NextTaskOptions{ 214 | Tasks: tasks, 215 | DockerImage: flow.ContainerImage.String, 216 | }) 217 | 218 | lastTask := tasks[len(tasks)-1] 219 | 220 | _, err = db.UpdateTaskToolCallId(context.Background(), database.UpdateTaskToolCallIdParams{ 221 | ToolCallID: c.ToolCallID, 222 | ID: lastTask.ID, 223 | }) 224 | 225 | if err != nil { 226 | return nil, fmt.Errorf("failed to update task tool call id: %w", err) 227 | } 228 | 229 | nextTask, err := db.CreateTask(context.Background(), database.CreateTaskParams{ 230 | Args: c.Args, 231 | Message: c.Message, 232 | Type: c.Type, 233 | Status: database.StringToNullString("in_progress"), 234 | FlowID: sql.NullInt64{Int64: flowId, Valid: true}, 235 | ToolCallID: c.ToolCallID, 236 | }) 237 | 238 | if err != nil { 239 | return nil, fmt.Errorf("failed to save command: %w", err) 240 | } 241 | 242 | return &nextTask, nil 243 | } 244 | -------------------------------------------------------------------------------- /backend/executor/terminal.go: -------------------------------------------------------------------------------- 1 | package executor 2 | 3 | import ( 4 | "archive/tar" 5 | "bytes" 6 | "context" 7 | "database/sql" 8 | "fmt" 9 | "io" 10 | "path/filepath" 11 | 12 | "github.com/docker/docker/api/types" 13 | "github.com/semanser/ai-coder/database" 14 | gmodel "github.com/semanser/ai-coder/graph/model" 15 | "github.com/semanser/ai-coder/graph/subscriptions" 16 | "github.com/semanser/ai-coder/websocket" 17 | ) 18 | 19 | func ExecCommand(flowID int64, command string, db *database.Queries) (result string, err error) { 20 | container := TerminalName(flowID) 21 | 22 | // Create options for starting the exec process 23 | cmd := []string{ 24 | "sh", 25 | "-c", 26 | command, 27 | } 28 | 29 | // Check if container is running 30 | isRunning, err := IsContainerRunning(container) 31 | 32 | if err != nil { 33 | return "", fmt.Errorf("Error inspecting container: %w", err) 34 | } 35 | 36 | if !isRunning { 37 | return "", fmt.Errorf("Container is not running") 38 | } 39 | 40 | // TODO avoid duplicating here and in the flows table 41 | log, err := db.CreateLog(context.Background(), database.CreateLogParams{ 42 | FlowID: sql.NullInt64{Int64: flowID, Valid: true}, 43 | Message: command, 44 | Type: "input", 45 | }) 46 | 47 | if err != nil { 48 | return "", fmt.Errorf("Error creating log: %w", err) 49 | } 50 | 51 | subscriptions.BroadcastTerminalLogsAdded(flowID, &gmodel.Log{ 52 | ID: uint(log.ID), 53 | Text: websocket.FormatTerminalInput(command), 54 | }) 55 | 56 | createResp, err := dockerClient.ContainerExecCreate(context.Background(), container, types.ExecConfig{ 57 | Cmd: cmd, 58 | AttachStdout: true, 59 | AttachStderr: true, 60 | Tty: true, 61 | }) 62 | if err != nil { 63 | return "", fmt.Errorf("Error creating exec process: %w", err) 64 | } 65 | 66 | // Attach to the exec process 67 | resp, err := dockerClient.ContainerExecAttach(context.Background(), createResp.ID, types.ExecStartCheck{ 68 | Tty: true, 69 | }) 70 | if err != nil { 71 | return "", fmt.Errorf("Error attaching to exec process: %w", err) 72 | } 73 | defer resp.Close() 74 | 75 | dst := bytes.Buffer{} 76 | _, err = io.Copy(&dst, resp.Reader) 77 | if err != nil && err != io.EOF { 78 | return "", fmt.Errorf("Error copying output: %w", err) 79 | } 80 | 81 | // Wait for the exec process to finish 82 | _, err = dockerClient.ContainerExecInspect(context.Background(), createResp.ID) 83 | if err != nil { 84 | return "", fmt.Errorf("Error inspecting exec process: %w", err) 85 | } 86 | 87 | results := dst.String() 88 | 89 | // TODO avoid duplicating here and in the flows table 90 | log, err = db.CreateLog(context.Background(), database.CreateLogParams{ 91 | FlowID: sql.NullInt64{Int64: flowID, Valid: true}, 92 | Message: results, 93 | Type: "output", 94 | }) 95 | 96 | if err != nil { 97 | return "", fmt.Errorf("Error creating log: %w", err) 98 | } 99 | 100 | subscriptions.BroadcastTerminalLogsAdded(flowID, &gmodel.Log{ 101 | ID: uint(log.ID), 102 | Text: results, 103 | }) 104 | 105 | result = dst.String() 106 | 107 | if result == "" { 108 | result = "Command executed successfully" 109 | } 110 | 111 | return result, nil 112 | } 113 | 114 | func WriteFile(flowID int64, content string, path string, db *database.Queries) (err error) { 115 | container := TerminalName(flowID) 116 | 117 | // Check if container is running 118 | isRunning, err := IsContainerRunning(container) 119 | 120 | if err != nil { 121 | return fmt.Errorf("Error inspecting container: %w", err) 122 | } 123 | 124 | if !isRunning { 125 | return fmt.Errorf("Container is not running") 126 | } 127 | 128 | // TODO avoid duplicating here and in the flows table 129 | log, err := db.CreateLog(context.Background(), database.CreateLogParams{ 130 | FlowID: sql.NullInt64{Int64: flowID, Valid: true}, 131 | Message: content, 132 | Type: "input", 133 | }) 134 | 135 | if err != nil { 136 | return fmt.Errorf("Error creating log: %w", err) 137 | } 138 | 139 | subscriptions.BroadcastTerminalLogsAdded(flowID, &gmodel.Log{ 140 | ID: uint(log.ID), 141 | Text: websocket.FormatTerminalInput(content), 142 | }) 143 | 144 | // Put content into a tar archive 145 | archive := &bytes.Buffer{} 146 | tarWriter := tar.NewWriter(archive) 147 | filename := filepath.Base(path) 148 | tarHeader := &tar.Header{ 149 | Name: filename, 150 | Mode: 0600, 151 | Size: int64(len(content)), 152 | } 153 | err = tarWriter.WriteHeader(tarHeader) 154 | if err != nil { 155 | return fmt.Errorf("Error writing tar header: %w", err) 156 | } 157 | 158 | _, err = tarWriter.Write([]byte(content)) 159 | if err != nil { 160 | return fmt.Errorf("Error writing tar content: %w", err) 161 | } 162 | 163 | dir := filepath.Dir(path) 164 | err = dockerClient.CopyToContainer(context.Background(), container, dir, archive, types.CopyToContainerOptions{}) 165 | 166 | if err != nil { 167 | return fmt.Errorf("Error writing file: %w", err) 168 | } 169 | 170 | message := fmt.Sprintf("Wrote to %s", path) 171 | 172 | // TODO avoid duplicating here and in the flows table 173 | log, err = db.CreateLog(context.Background(), database.CreateLogParams{ 174 | FlowID: sql.NullInt64{Int64: flowID, Valid: true}, 175 | Message: message, 176 | Type: "output", 177 | }) 178 | 179 | if err != nil { 180 | return fmt.Errorf("Error creating log: %w", err) 181 | } 182 | 183 | subscriptions.BroadcastTerminalLogsAdded(flowID, &gmodel.Log{ 184 | ID: uint(log.ID), 185 | Text: message, 186 | }) 187 | 188 | return nil 189 | } 190 | 191 | func TerminalName(flowID int64) string { 192 | return fmt.Sprintf("codel-terminal-%d", flowID) 193 | } 194 | -------------------------------------------------------------------------------- /backend/go.mod: -------------------------------------------------------------------------------- 1 | module github.com/semanser/ai-coder 2 | 3 | go 1.22.0 4 | 5 | require ( 6 | github.com/99designs/gqlgen v0.17.45 7 | github.com/caarlos0/env/v10 v10.0.0 8 | github.com/docker/docker v25.0.5+incompatible 9 | github.com/docker/go-connections v0.5.0 10 | github.com/gin-contrib/cors v1.7.0 11 | github.com/gin-contrib/static v1.1.1 12 | github.com/gin-gonic/gin v1.9.1 13 | github.com/go-rod/rod v0.114.8 14 | github.com/gorilla/websocket v1.5.0 15 | github.com/invopop/jsonschema v0.12.0 16 | github.com/joho/godotenv v1.5.1 17 | github.com/mattn/go-sqlite3 v1.14.22 18 | github.com/pressly/goose/v3 v3.19.2 19 | github.com/tmc/langchaingo v0.1.8 20 | github.com/vektah/gqlparser/v2 v2.5.11 21 | ) 22 | 23 | require ( 24 | github.com/Microsoft/go-winio v0.6.1 // indirect 25 | github.com/agnivade/levenshtein v1.1.1 // indirect 26 | github.com/bahlo/generic-list-go v0.2.0 // indirect 27 | github.com/buger/jsonparser v1.1.1 // indirect 28 | github.com/bytedance/sonic v1.11.3 // indirect 29 | github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d // indirect 30 | github.com/chenzhuoyu/iasm v0.9.1 // indirect 31 | github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect 32 | github.com/distribution/reference v0.5.0 // indirect 33 | github.com/dlclark/regexp2 v1.11.0 // indirect 34 | github.com/docker/go-units v0.5.0 // indirect 35 | github.com/felixge/httpsnoop v1.0.4 // indirect 36 | github.com/gabriel-vasile/mimetype v1.4.3 // indirect 37 | github.com/gin-contrib/sse v0.1.0 // indirect 38 | github.com/go-logr/logr v1.4.1 // indirect 39 | github.com/go-logr/stdr v1.2.2 // indirect 40 | github.com/go-playground/locales v0.14.1 // indirect 41 | github.com/go-playground/universal-translator v0.18.1 // indirect 42 | github.com/go-playground/validator/v10 v10.19.0 // indirect 43 | github.com/goccy/go-json v0.10.2 // indirect 44 | github.com/gogo/protobuf v1.3.2 // indirect 45 | github.com/google/uuid v1.6.0 // indirect 46 | github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect 47 | github.com/jackc/pgservicefile v0.0.0-20231201235250-de7065d80cb9 // indirect 48 | github.com/json-iterator/go v1.1.12 // indirect 49 | github.com/klauspost/cpuid/v2 v2.2.7 // indirect 50 | github.com/leodido/go-urn v1.4.0 // indirect 51 | github.com/mailru/easyjson v0.7.7 // indirect 52 | github.com/mattn/go-isatty v0.0.20 // indirect 53 | github.com/mfridman/interpolate v0.0.2 // indirect 54 | github.com/mitchellh/mapstructure v1.5.0 // indirect 55 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect 56 | github.com/modern-go/reflect2 v1.0.2 // indirect 57 | github.com/opencontainers/go-digest v1.0.0 // indirect 58 | github.com/opencontainers/image-spec v1.1.0 // indirect 59 | github.com/pelletier/go-toml/v2 v2.2.0 // indirect 60 | github.com/pkg/errors v0.9.1 // indirect 61 | github.com/pkoukk/tiktoken-go v0.1.6 // indirect 62 | github.com/rogpeppe/go-internal v1.12.0 // indirect 63 | github.com/russross/blackfriday/v2 v2.1.0 // indirect 64 | github.com/sethvargo/go-retry v0.2.4 // indirect 65 | github.com/sosodev/duration v1.2.0 // indirect 66 | github.com/twitchyliquid64/golang-asm v0.15.1 // indirect 67 | github.com/ugorji/go/codec v1.2.12 // indirect 68 | github.com/urfave/cli/v2 v2.27.1 // indirect 69 | github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect 70 | github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 // indirect 71 | github.com/ysmood/fetchup v0.2.3 // indirect 72 | github.com/ysmood/goob v0.4.0 // indirect 73 | github.com/ysmood/got v0.34.1 // indirect 74 | github.com/ysmood/gson v0.7.3 // indirect 75 | github.com/ysmood/leakless v0.8.0 // indirect 76 | go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 // indirect 77 | go.opentelemetry.io/otel v1.24.0 // indirect 78 | go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.24.0 // indirect 79 | go.opentelemetry.io/otel/metric v1.24.0 // indirect 80 | go.opentelemetry.io/otel/sdk v1.24.0 // indirect 81 | go.opentelemetry.io/otel/trace v1.24.0 // indirect 82 | go.uber.org/multierr v1.11.0 // indirect 83 | golang.org/x/arch v0.7.0 // indirect 84 | golang.org/x/crypto v0.21.0 // indirect 85 | golang.org/x/mod v0.16.0 // indirect 86 | golang.org/x/net v0.22.0 // indirect 87 | golang.org/x/sync v0.6.0 // indirect 88 | golang.org/x/sys v0.18.0 // indirect 89 | golang.org/x/text v0.14.0 // indirect 90 | golang.org/x/tools v0.19.0 // indirect 91 | google.golang.org/protobuf v1.33.0 // indirect 92 | gopkg.in/yaml.v3 v3.0.1 // indirect 93 | gotest.tools/v3 v3.5.1 // indirect 94 | ) 95 | -------------------------------------------------------------------------------- /backend/gqlgen.yml: -------------------------------------------------------------------------------- 1 | # Where are all the schema files located? globs are supported eg src/**/*.graphqls 2 | schema: 3 | - graph/*.graphqls 4 | 5 | # Where should the generated server code go? 6 | exec: 7 | filename: graph/generated.go 8 | package: graph 9 | 10 | # Uncomment to enable federation 11 | # federation: 12 | # filename: graph/federation.go 13 | # package: graph 14 | 15 | # Where should any generated models go? 16 | model: 17 | filename: graph/model/models_gen.go 18 | package: gmodel 19 | 20 | # Where should the resolver implementations go? 21 | resolver: 22 | layout: follow-schema 23 | dir: graph 24 | package: graph 25 | filename_template: "{name}.resolvers.go" 26 | # Optional: turn on to not generate template comments above resolvers 27 | # omit_template_comment: false 28 | 29 | # Optional: turn on use ` + "`" + `gqlgen:"fieldName"` + "`" + ` tags in your models 30 | # struct_tag: json 31 | 32 | # Optional: turn on to use []Thing instead of []*Thing 33 | # omit_slice_element_pointers: false 34 | 35 | # Optional: turn on to omit Is() methods to interface and unions 36 | # omit_interface_checks : true 37 | 38 | # Optional: turn on to skip generation of ComplexityRoot struct content and Complexity function 39 | # omit_complexity: false 40 | 41 | # Optional: turn on to not generate any file notice comments in generated files 42 | # omit_gqlgen_file_notice: false 43 | 44 | # Optional: turn on to exclude the gqlgen version in the generated file notice. No effect if `omit_gqlgen_file_notice` is true. 45 | # omit_gqlgen_version_in_file_notice: false 46 | 47 | # Optional: turn off to make struct-type struct fields not use pointers 48 | # e.g. type Thing struct { FieldA OtherThing } instead of { FieldA *OtherThing } 49 | # struct_fields_always_pointers: true 50 | 51 | # Optional: turn off to make resolvers return values instead of pointers for structs 52 | # resolvers_always_return_pointers: true 53 | 54 | # Optional: turn on to return pointers instead of values in unmarshalInput 55 | # return_pointers_in_unmarshalinput: false 56 | 57 | # Optional: wrap nullable input fields with Omittable 58 | # nullable_input_omittable: true 59 | 60 | # Optional: set to speed up generation time by not performing a final validation pass. 61 | # skip_validation: true 62 | 63 | # Optional: set to skip running `go mod tidy` when generating server code 64 | # skip_mod_tidy: true 65 | 66 | # gqlgen will search for any type names in the schema in these go packages 67 | # if they match it will use them, otherwise it will generate them. 68 | autobind: 69 | # - "github.com/semanser/ai-coder/graph/model" 70 | 71 | # This section declares type mapping between the GraphQL and go type systems 72 | # 73 | # The first line in each type will be used as defaults for resolver arguments and 74 | # modelgen, the others will be allowed when binding to fields. Configure them to 75 | # your liking 76 | models: 77 | ID: 78 | model: 79 | - github.com/99designs/gqlgen/graphql.Uint 80 | - github.com/99designs/gqlgen/graphql.ID 81 | - github.com/99designs/gqlgen/graphql.Int 82 | - github.com/99designs/gqlgen/graphql.Int64 83 | - github.com/99designs/gqlgen/graphql.Int32 84 | Int: 85 | model: 86 | - github.com/99designs/gqlgen/graphql.Int 87 | - github.com/99designs/gqlgen/graphql.Int64 88 | - github.com/99designs/gqlgen/graphql.Int32 89 | - github.com/99designs/gqlgen/graphql.Uint 90 | Uint: 91 | model: 92 | - github.com/99designs/gqlgen/graphql.Uint 93 | -------------------------------------------------------------------------------- /backend/graph/model/models_gen.go: -------------------------------------------------------------------------------- 1 | // Code generated by github.com/99designs/gqlgen, DO NOT EDIT. 2 | 3 | package gmodel 4 | 5 | import ( 6 | "fmt" 7 | "io" 8 | "strconv" 9 | "time" 10 | ) 11 | 12 | type Browser struct { 13 | URL string `json:"url"` 14 | ScreenshotURL string `json:"screenshotUrl"` 15 | } 16 | 17 | type Flow struct { 18 | ID uint `json:"id"` 19 | Name string `json:"name"` 20 | Tasks []*Task `json:"tasks"` 21 | Terminal *Terminal `json:"terminal"` 22 | Browser *Browser `json:"browser"` 23 | Status FlowStatus `json:"status"` 24 | Model *Model `json:"model"` 25 | } 26 | 27 | type Log struct { 28 | ID uint `json:"id"` 29 | Text string `json:"text"` 30 | } 31 | 32 | type Model struct { 33 | Provider string `json:"provider"` 34 | ID string `json:"id"` 35 | } 36 | 37 | type Mutation struct { 38 | } 39 | 40 | type Query struct { 41 | } 42 | 43 | type Subscription struct { 44 | } 45 | 46 | type Task struct { 47 | ID uint `json:"id"` 48 | Message string `json:"message"` 49 | CreatedAt time.Time `json:"createdAt"` 50 | Type TaskType `json:"type"` 51 | Status TaskStatus `json:"status"` 52 | Args string `json:"args"` 53 | Results string `json:"results"` 54 | } 55 | 56 | type Terminal struct { 57 | ContainerName string `json:"containerName"` 58 | Connected bool `json:"connected"` 59 | Logs []*Log `json:"logs"` 60 | } 61 | 62 | type FlowStatus string 63 | 64 | const ( 65 | FlowStatusInProgress FlowStatus = "inProgress" 66 | FlowStatusFinished FlowStatus = "finished" 67 | ) 68 | 69 | var AllFlowStatus = []FlowStatus{ 70 | FlowStatusInProgress, 71 | FlowStatusFinished, 72 | } 73 | 74 | func (e FlowStatus) IsValid() bool { 75 | switch e { 76 | case FlowStatusInProgress, FlowStatusFinished: 77 | return true 78 | } 79 | return false 80 | } 81 | 82 | func (e FlowStatus) String() string { 83 | return string(e) 84 | } 85 | 86 | func (e *FlowStatus) UnmarshalGQL(v interface{}) error { 87 | str, ok := v.(string) 88 | if !ok { 89 | return fmt.Errorf("enums must be strings") 90 | } 91 | 92 | *e = FlowStatus(str) 93 | if !e.IsValid() { 94 | return fmt.Errorf("%s is not a valid FlowStatus", str) 95 | } 96 | return nil 97 | } 98 | 99 | func (e FlowStatus) MarshalGQL(w io.Writer) { 100 | fmt.Fprint(w, strconv.Quote(e.String())) 101 | } 102 | 103 | type TaskStatus string 104 | 105 | const ( 106 | TaskStatusInProgress TaskStatus = "inProgress" 107 | TaskStatusFinished TaskStatus = "finished" 108 | TaskStatusStopped TaskStatus = "stopped" 109 | TaskStatusFailed TaskStatus = "failed" 110 | ) 111 | 112 | var AllTaskStatus = []TaskStatus{ 113 | TaskStatusInProgress, 114 | TaskStatusFinished, 115 | TaskStatusStopped, 116 | TaskStatusFailed, 117 | } 118 | 119 | func (e TaskStatus) IsValid() bool { 120 | switch e { 121 | case TaskStatusInProgress, TaskStatusFinished, TaskStatusStopped, TaskStatusFailed: 122 | return true 123 | } 124 | return false 125 | } 126 | 127 | func (e TaskStatus) String() string { 128 | return string(e) 129 | } 130 | 131 | func (e *TaskStatus) UnmarshalGQL(v interface{}) error { 132 | str, ok := v.(string) 133 | if !ok { 134 | return fmt.Errorf("enums must be strings") 135 | } 136 | 137 | *e = TaskStatus(str) 138 | if !e.IsValid() { 139 | return fmt.Errorf("%s is not a valid TaskStatus", str) 140 | } 141 | return nil 142 | } 143 | 144 | func (e TaskStatus) MarshalGQL(w io.Writer) { 145 | fmt.Fprint(w, strconv.Quote(e.String())) 146 | } 147 | 148 | type TaskType string 149 | 150 | const ( 151 | TaskTypeInput TaskType = "input" 152 | TaskTypeTerminal TaskType = "terminal" 153 | TaskTypeBrowser TaskType = "browser" 154 | TaskTypeCode TaskType = "code" 155 | TaskTypeAsk TaskType = "ask" 156 | TaskTypeDone TaskType = "done" 157 | ) 158 | 159 | var AllTaskType = []TaskType{ 160 | TaskTypeInput, 161 | TaskTypeTerminal, 162 | TaskTypeBrowser, 163 | TaskTypeCode, 164 | TaskTypeAsk, 165 | TaskTypeDone, 166 | } 167 | 168 | func (e TaskType) IsValid() bool { 169 | switch e { 170 | case TaskTypeInput, TaskTypeTerminal, TaskTypeBrowser, TaskTypeCode, TaskTypeAsk, TaskTypeDone: 171 | return true 172 | } 173 | return false 174 | } 175 | 176 | func (e TaskType) String() string { 177 | return string(e) 178 | } 179 | 180 | func (e *TaskType) UnmarshalGQL(v interface{}) error { 181 | str, ok := v.(string) 182 | if !ok { 183 | return fmt.Errorf("enums must be strings") 184 | } 185 | 186 | *e = TaskType(str) 187 | if !e.IsValid() { 188 | return fmt.Errorf("%s is not a valid TaskType", str) 189 | } 190 | return nil 191 | } 192 | 193 | func (e TaskType) MarshalGQL(w io.Writer) { 194 | fmt.Fprint(w, strconv.Quote(e.String())) 195 | } 196 | -------------------------------------------------------------------------------- /backend/graph/resolver.go: -------------------------------------------------------------------------------- 1 | package graph 2 | 3 | import ( 4 | "github.com/semanser/ai-coder/database" 5 | ) 6 | 7 | // This file will not be regenerated automatically. 8 | // 9 | // It serves as dependency injection for your app, add any dependencies you require here. 10 | 11 | type Resolver struct { 12 | Db *database.Queries 13 | } 14 | -------------------------------------------------------------------------------- /backend/graph/schema.graphqls: -------------------------------------------------------------------------------- 1 | scalar JSON 2 | scalar Uint 3 | scalar Time 4 | 5 | enum TaskType { 6 | input 7 | terminal 8 | browser 9 | code 10 | ask 11 | done 12 | } 13 | 14 | enum TaskStatus { 15 | inProgress 16 | finished 17 | stopped 18 | failed 19 | } 20 | 21 | type Task { 22 | id: Uint! 23 | message: String! 24 | createdAt: Time! 25 | type: TaskType! 26 | status: TaskStatus! 27 | args: JSON! 28 | results: JSON! 29 | } 30 | 31 | enum FlowStatus { 32 | inProgress 33 | finished 34 | } 35 | 36 | type Log { 37 | id: Uint! 38 | text: String! 39 | } 40 | 41 | type Terminal { 42 | containerName: String! 43 | connected: Boolean! 44 | logs: [Log!]! 45 | } 46 | 47 | type Browser { 48 | url: String! 49 | screenshotUrl: String! 50 | } 51 | 52 | type Model { 53 | provider: String! 54 | id: String! 55 | } 56 | 57 | type Flow { 58 | id: Uint! 59 | name: String! 60 | tasks: [Task!]! 61 | terminal: Terminal! 62 | browser: Browser! 63 | status: FlowStatus! 64 | model: Model! 65 | } 66 | 67 | type Query { 68 | availableModels: [Model!]! 69 | flows: [Flow!]! 70 | flow(id: Uint!): Flow! 71 | } 72 | 73 | type Mutation { 74 | createFlow(modelProvider: String!, modelId: String!): Flow! 75 | createTask(flowId: Uint!, query: String!): Task! 76 | finishFlow(flowId: Uint!): Flow! 77 | 78 | # Use only for development purposes 79 | _exec(containerId: String!, command: String!): String! 80 | } 81 | 82 | type Subscription { 83 | taskAdded(flowId: Uint!): Task! 84 | taskUpdated: Task! 85 | flowUpdated(flowId: Uint!): Flow! 86 | 87 | browserUpdated(flowId: Uint!): Browser! 88 | terminalLogsAdded(flowId: Uint!): Log! 89 | } 90 | -------------------------------------------------------------------------------- /backend/graph/subscriptions/broadcast.go: -------------------------------------------------------------------------------- 1 | package subscriptions 2 | 3 | import ( 4 | gmodel "github.com/semanser/ai-coder/graph/model" 5 | ) 6 | 7 | func BroadcastTaskAdded(flowID int64, task *gmodel.Task) { 8 | if ch, ok := taskAddedSubscriptions[flowID]; ok { 9 | ch <- task 10 | } 11 | } 12 | 13 | func BroadcastFlowUpdated(flowID int64, flow *gmodel.Flow) { 14 | if ch, ok := flowUpdatedSubscriptions[flowID]; ok { 15 | ch <- flow 16 | } 17 | } 18 | 19 | func BroadcastTerminalLogsAdded(flowID int64, flow *gmodel.Log) { 20 | if ch, ok := terminalLogsAddedSubscriptions[flowID]; ok { 21 | ch <- flow 22 | } 23 | } 24 | 25 | func BroadcastBrowserUpdated(flowID int64, browser *gmodel.Browser) { 26 | if ch, ok := browserSubscriptions[flowID]; ok { 27 | ch <- browser 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /backend/graph/subscriptions/manager.go: -------------------------------------------------------------------------------- 1 | package subscriptions 2 | 3 | import ( 4 | gmodel "github.com/semanser/ai-coder/graph/model" 5 | ) 6 | 7 | var ( 8 | taskAddedSubscriptions = make(map[int64]chan *gmodel.Task) 9 | flowUpdatedSubscriptions = make(map[int64]chan *gmodel.Flow) 10 | terminalLogsAddedSubscriptions = make(map[int64]chan *gmodel.Log) 11 | browserSubscriptions = make(map[int64]chan *gmodel.Browser) 12 | ) 13 | 14 | type Subscription[T any] interface { 15 | subscribe() (T, error) 16 | } 17 | 18 | func subscribe[B any](flowID int64, subscriptions map[int64]chan B) (channel chan B, unsubscribe func()) { 19 | ch := make(chan B) 20 | 21 | if _, ok := subscriptions[flowID]; !ok { 22 | subscriptions[flowID] = ch 23 | } 24 | 25 | unsubscribe = func() { 26 | if ch == nil { 27 | return 28 | } 29 | 30 | if c, ok := subscriptions[flowID]; ok { 31 | if c == ch { 32 | subscriptions[flowID] = nil 33 | } 34 | } 35 | 36 | if len(subscriptions[flowID]) == 0 { 37 | delete(subscriptions, flowID) 38 | } 39 | } 40 | 41 | return ch, unsubscribe 42 | } 43 | -------------------------------------------------------------------------------- /backend/graph/subscriptions/subscriptions.go: -------------------------------------------------------------------------------- 1 | package subscriptions 2 | 3 | import ( 4 | "context" 5 | 6 | gmodel "github.com/semanser/ai-coder/graph/model" 7 | ) 8 | 9 | func TaskAdded(ctx context.Context, flowId int64) (<-chan *gmodel.Task, error) { 10 | ch, unsubscribe := subscribe(flowId, taskAddedSubscriptions) 11 | 12 | go func() { 13 | // Handle deregistration of the channel here. Note the `defer` 14 | defer func() { 15 | unsubscribe() 16 | }() 17 | 18 | for { 19 | <-ctx.Done() // This runs when context gets cancelled. Subscription closes. 20 | // Handle deregistration of the channel here. `close(ch)` 21 | return 22 | } 23 | }() 24 | 25 | return ch, nil 26 | } 27 | 28 | func FlowUpdated(ctx context.Context, flowId int64) (<-chan *gmodel.Flow, error) { 29 | ch, unsubscribe := subscribe(flowId, flowUpdatedSubscriptions) 30 | go func() { 31 | defer func() { 32 | unsubscribe() 33 | }() 34 | for { 35 | <-ctx.Done() 36 | return 37 | } 38 | }() 39 | return ch, nil 40 | } 41 | 42 | func TerminalLogsAdded(ctx context.Context, flowId int64) (<-chan *gmodel.Log, error) { 43 | ch, unsubscribe := subscribe(flowId, terminalLogsAddedSubscriptions) 44 | go func() { 45 | defer func() { 46 | unsubscribe() 47 | }() 48 | for { 49 | <-ctx.Done() 50 | return 51 | } 52 | }() 53 | return ch, nil 54 | } 55 | 56 | func BrowserUpdated(ctx context.Context, flowId int64) (<-chan *gmodel.Browser, error) { 57 | ch, unsubscribe := subscribe(flowId, browserSubscriptions) 58 | go func() { 59 | defer func() { 60 | unsubscribe() 61 | }() 62 | for { 63 | <-ctx.Done() 64 | return 65 | } 66 | }() 67 | return ch, nil 68 | } 69 | -------------------------------------------------------------------------------- /backend/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "database/sql" 5 | "embed" 6 | "log" 7 | "net/http" 8 | "os" 9 | "os/signal" 10 | "strconv" 11 | "syscall" 12 | 13 | _ "github.com/mattn/go-sqlite3" 14 | "github.com/pressly/goose/v3" 15 | "github.com/semanser/ai-coder/assets" 16 | "github.com/semanser/ai-coder/config" 17 | "github.com/semanser/ai-coder/database" 18 | "github.com/semanser/ai-coder/executor" 19 | "github.com/semanser/ai-coder/router" 20 | ) 21 | 22 | //go:embed templates/prompts/*.tmpl 23 | var promptTemplates embed.FS 24 | 25 | //go:embed templates/scripts/*.js 26 | var scriptTemplates embed.FS 27 | 28 | //go:embed migrations/*.sql 29 | var embedMigrations embed.FS 30 | 31 | func main() { 32 | config.Init() 33 | sigChan := make(chan os.Signal, 1) 34 | signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM) 35 | 36 | db, err := sql.Open("sqlite3", config.Config.DatabaseURL) 37 | 38 | queries := database.New(db) 39 | 40 | goose.SetBaseFS(embedMigrations) 41 | 42 | if err := goose.SetDialect("sqlite3"); err != nil { 43 | log.Fatalf("Unable to set dialect: %v\n", err) 44 | } 45 | 46 | if err := goose.Up(db, "migrations"); err != nil { 47 | log.Fatalf("Unable to run migrations: %v\n", err) 48 | } 49 | 50 | log.Println("Migrations ran successfully") 51 | 52 | port := strconv.Itoa(config.Config.Port) 53 | 54 | r := router.New(queries) 55 | 56 | assets.Init(promptTemplates, scriptTemplates) 57 | 58 | err = executor.InitClient() 59 | if err != nil { 60 | log.Fatalf("failed to initialize Docker client: %v", err) 61 | } 62 | 63 | err = executor.InitBrowser(queries) 64 | if err != nil { 65 | log.Fatalf("failed to initialize browser container: %v", err) 66 | } 67 | 68 | // Run the server in a separate goroutine 69 | go func() { 70 | log.Printf("connect to http://localhost:%s/playground for GraphQL playground", port) 71 | if err := http.ListenAndServe(":"+port, r); err != nil { 72 | log.Fatalf("HTTP server error: %v", err) 73 | } 74 | }() 75 | 76 | // Wait for termination signal 77 | <-sigChan 78 | log.Println("Shutting down...") 79 | 80 | // Cleanup resources 81 | if err := executor.Cleanup(queries); err != nil { 82 | log.Printf("Error during cleanup: %v", err) 83 | } 84 | 85 | log.Println("Shutdown complete") 86 | } 87 | -------------------------------------------------------------------------------- /backend/migrations/20240325154630_initial_migration.sql: -------------------------------------------------------------------------------- 1 | -- +goose Up 2 | -- +goose StatementBegin 3 | CREATE TABLE containers ( 4 | id INTEGER PRIMARY KEY AUTOINCREMENT, 5 | name TEXT, 6 | local_id TEXT, 7 | image TEXT, 8 | status TEXT DEFAULT 'starting' 9 | ); 10 | 11 | CREATE TABLE flows ( 12 | id INTEGER PRIMARY KEY AUTOINCREMENT, 13 | created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, 14 | updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, 15 | name TEXT, 16 | status TEXT, 17 | container_id INTEGER, 18 | FOREIGN KEY (container_id) REFERENCES containers (id) 19 | ); 20 | 21 | CREATE TABLE tasks ( 22 | id INTEGER PRIMARY KEY AUTOINCREMENT, 23 | created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, 24 | updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, 25 | type TEXT, 26 | status TEXT, 27 | args TEXT DEFAULT '{}', 28 | results TEXT DEFAULT '{}', 29 | message TEXT, 30 | flow_id INTEGER, 31 | FOREIGN KEY (flow_id) REFERENCES flows (id) 32 | ); 33 | -- +goose StatementEnd 34 | 35 | -- +goose Down 36 | -- +goose StatementBegin 37 | DROP TABLE tasks; 38 | DROP TABLE flows; 39 | DROP TABLE containers; 40 | -- +goose StatementEnd 41 | ``` 42 | -------------------------------------------------------------------------------- /backend/migrations/20240325193843_add_logs_table.sql: -------------------------------------------------------------------------------- 1 | -- +goose Up 2 | -- +goose StatementBegin 3 | CREATE TABLE logs ( 4 | id INTEGER PRIMARY KEY AUTOINCREMENT, 5 | message TEXT NOT NULL, 6 | created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, 7 | flow_id INTEGER REFERENCES flows(id) ON DELETE CASCADE, 8 | type TEXT NOT NULL -- "input" or "output" 9 | ); 10 | -- +goose StatementEnd 11 | 12 | -- +goose Down 13 | -- +goose StatementBegin 14 | DROP TABLE logs; 15 | -- +goose StatementEnd 16 | -------------------------------------------------------------------------------- /backend/migrations/20240328114536_tool_call_id_field.sql: -------------------------------------------------------------------------------- 1 | -- +goose Up 2 | -- +goose StatementBegin 3 | ALTER TABLE tasks ADD COLUMN tool_call_id TEXT; 4 | -- +goose StatementEnd 5 | 6 | -- +goose Down 7 | -- +goose StatementBegin 8 | ALTER TABLE tasks DROP COLUMN tool_call_id; 9 | -- +goose StatementEnd 10 | -------------------------------------------------------------------------------- /backend/migrations/20240403115154_add_model_to_each_flow.sql: -------------------------------------------------------------------------------- 1 | -- +goose Up 2 | -- +goose StatementBegin 3 | ALTER TABLE flows 4 | ADD COLUMN model TEXT; 5 | -- +goose StatementEnd 6 | 7 | -- +goose Down 8 | -- +goose StatementBegin 9 | ALTER TABLE flows 10 | DROP COLUMN model; 11 | -- +goose StatementEnd 12 | -------------------------------------------------------------------------------- /backend/migrations/20240403132844_add_model_provider_to_each_flow.sql: -------------------------------------------------------------------------------- 1 | -- +goose Up 2 | -- +goose StatementBegin 3 | ALTER TABLE flows 4 | ADD COLUMN model_provider TEXT; 5 | -- +goose StatementEnd 6 | 7 | -- +goose Down 8 | -- +goose StatementBegin 9 | ALTER TABLE flows 10 | DROP COLUMN model_provider; 11 | -- +goose StatementEnd 12 | -------------------------------------------------------------------------------- /backend/models/containers.sql: -------------------------------------------------------------------------------- 1 | -- name: GetAllRunningContainers :many 2 | SELECT * FROM containers WHERE status = 'running'; 3 | 4 | -- name: CreateContainer :one 5 | INSERT INTO containers ( 6 | name, image, status 7 | ) 8 | VALUES ( 9 | ?, ?, ? 10 | ) 11 | RETURNING *; 12 | 13 | -- name: UpdateContainerStatus :one 14 | UPDATE containers 15 | SET status = ? 16 | WHERE id = ? 17 | RETURNING *; 18 | 19 | -- name: UpdateContainerLocalId :one 20 | UPDATE containers 21 | SET local_id = ? 22 | WHERE id = ? 23 | RETURNING *; 24 | -------------------------------------------------------------------------------- /backend/models/flows.sql: -------------------------------------------------------------------------------- 1 | -- name: CreateFlow :one 2 | INSERT INTO flows ( 3 | name, status, container_id, model, model_provider 4 | ) 5 | VALUES ( 6 | ?, ?, ?, ?, ? 7 | ) 8 | RETURNING *; 9 | 10 | -- name: ReadAllFlows :many 11 | SELECT 12 | f.*, 13 | c.name AS container_name 14 | FROM flows f 15 | LEFT JOIN containers c ON f.container_id = c.id 16 | ORDER BY f.created_at DESC; 17 | 18 | -- name: ReadFlow :one 19 | SELECT 20 | f.*, 21 | c.name AS container_name, 22 | c.image AS container_image, 23 | c.status AS container_status, 24 | c.local_id AS container_local_id 25 | FROM flows f 26 | LEFT JOIN containers c ON f.container_id = c.id 27 | WHERE f.id = ?; 28 | 29 | -- name: UpdateFlowStatus :one 30 | UPDATE flows 31 | SET status = ? 32 | WHERE id = ? 33 | RETURNING *; 34 | 35 | -- name: UpdateFlowName :one 36 | UPDATE flows 37 | SET name = ? 38 | WHERE id = ? 39 | RETURNING *; 40 | 41 | -- name: UpdateFlowContainer :one 42 | UPDATE flows 43 | SET container_id = ? 44 | WHERE id = ? 45 | RETURNING *; 46 | -------------------------------------------------------------------------------- /backend/models/logs.sql: -------------------------------------------------------------------------------- 1 | -- name: CreateLog :one 2 | INSERT INTO logs ( 3 | message, flow_id, type 4 | ) 5 | VALUES ( 6 | ?, ?, ? 7 | ) 8 | RETURNING *; 9 | 10 | -- name: GetLogsByFlowId :many 11 | SELECT * 12 | FROM logs 13 | WHERE flow_id = ? 14 | ORDER BY created_at ASC; 15 | -------------------------------------------------------------------------------- /backend/models/models.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | type FlowStatus string 4 | 5 | const ( 6 | FlowInProgress FlowStatus = "in_progress" 7 | FlowFinished FlowStatus = "finished" 8 | ) 9 | 10 | type Flow struct { 11 | ID uint 12 | Name string 13 | Tasks []Task 14 | Status FlowStatus 15 | ContainerID uint 16 | Container Container 17 | } 18 | 19 | type TaskType string 20 | 21 | const ( 22 | Input TaskType = "input" 23 | Terminal TaskType = "terminal" 24 | Browser TaskType = "browser" 25 | Code TaskType = "code" 26 | Ask TaskType = "ask" 27 | Done TaskType = "done" 28 | ) 29 | 30 | type TaskStatus = string 31 | 32 | const ( 33 | TaskInProgress TaskStatus = "in_progress" 34 | TaskFinished TaskStatus = "finished" 35 | TaskStopped TaskStatus = "stopped" 36 | TaskFailed TaskStatus = "failed" 37 | ) 38 | 39 | type Task struct { 40 | ID uint 41 | Message string 42 | Type TaskType 43 | Status TaskStatus 44 | // Args datatypes.JSON 45 | Results string 46 | FlowID uint 47 | Flow Flow 48 | } 49 | 50 | type ContainerStatus = string 51 | 52 | const ( 53 | ContainerStarting ContainerStatus = "starting" 54 | ContainerRunning ContainerStatus = "running" 55 | ContainerStopped ContainerStatus = "stopped" 56 | ContainerFailed ContainerStatus = "failed" 57 | ) 58 | 59 | type Container struct { 60 | ID uint 61 | Name string 62 | Image string 63 | Status ContainerStatus 64 | } 65 | -------------------------------------------------------------------------------- /backend/models/tasks.sql: -------------------------------------------------------------------------------- 1 | -- name: CreateTask :one 2 | INSERT INTO tasks ( 3 | type, 4 | status, 5 | args, 6 | results, 7 | flow_id, 8 | message, 9 | tool_call_id 10 | ) VALUES ( 11 | ?, ?, ?, ?, ?, ?, ? 12 | ) 13 | RETURNING *; 14 | 15 | -- name: ReadTasksByFlowId :many 16 | SELECT * FROM tasks 17 | WHERE flow_id = ? 18 | ORDER BY created_at ASC; 19 | 20 | -- name: UpdateTaskStatus :one 21 | UPDATE tasks 22 | SET status = ? 23 | WHERE id = ? 24 | RETURNING *; 25 | 26 | -- name: UpdateTaskResults :one 27 | UPDATE tasks 28 | SET results = ? 29 | WHERE id = ? 30 | RETURNING *; 31 | 32 | -- name: UpdateTaskToolCallId :one 33 | UPDATE tasks 34 | SET tool_call_id = ? 35 | WHERE id = ? 36 | RETURNING *; 37 | -------------------------------------------------------------------------------- /backend/providers/common.go: -------------------------------------------------------------------------------- 1 | package providers 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/semanser/ai-coder/assets" 7 | "github.com/semanser/ai-coder/templates" 8 | "github.com/tmc/langchaingo/llms" 9 | ) 10 | 11 | func Summary(llm llms.Model, model string, query string, n int) (string, error) { 12 | prompt, err := templates.Render(assets.PromptTemplates, "prompts/summary.tmpl", map[string]any{ 13 | "Text": query, 14 | "N": n, 15 | }) 16 | if err != nil { 17 | return "", err 18 | } 19 | 20 | response, err := llms.GenerateFromSinglePrompt( 21 | context.Background(), 22 | llm, 23 | prompt, 24 | llms.WithTemperature(0.0), 25 | // TODO Use a simpler model for this task 26 | llms.WithModel(model), 27 | llms.WithTopP(0.2), 28 | llms.WithN(1), 29 | ) 30 | 31 | return response, err 32 | } 33 | 34 | func DockerImageName(llm llms.Model, model string, task string) (string, error) { 35 | prompt, err := templates.Render(assets.PromptTemplates, "prompts/docker.tmpl", map[string]any{ 36 | "Task": task, 37 | }) 38 | if err != nil { 39 | return "", err 40 | } 41 | 42 | response, err := llms.GenerateFromSinglePrompt( 43 | context.Background(), 44 | llm, 45 | prompt, 46 | llms.WithTemperature(0.0), 47 | llms.WithModel(model), 48 | llms.WithTopP(0.2), 49 | llms.WithN(1), 50 | ) 51 | 52 | return response, err 53 | } 54 | -------------------------------------------------------------------------------- /backend/providers/ollama.go: -------------------------------------------------------------------------------- 1 | package providers 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "log" 8 | 9 | "github.com/semanser/ai-coder/assets" 10 | "github.com/semanser/ai-coder/config" 11 | "github.com/semanser/ai-coder/database" 12 | "github.com/semanser/ai-coder/templates" 13 | 14 | "github.com/tmc/langchaingo/llms" 15 | "github.com/tmc/langchaingo/llms/ollama" 16 | ) 17 | 18 | type OllamaProvider struct { 19 | client *ollama.LLM 20 | model string 21 | baseURL string 22 | name ProviderType 23 | } 24 | 25 | func (p OllamaProvider) New() Provider { 26 | model := config.Config.OllamaModel 27 | baseURL := config.Config.OllamaServerURL 28 | 29 | client, err := ollama.New( 30 | ollama.WithModel(model), 31 | ollama.WithServerURL(baseURL), 32 | ollama.WithFormat("json"), 33 | ) 34 | 35 | if err != nil { 36 | log.Fatalf("Failed to create Ollama client: %v", err) 37 | } 38 | 39 | return OllamaProvider{ 40 | client: client, 41 | model: model, 42 | baseURL: baseURL, 43 | name: ProviderOllama, 44 | } 45 | } 46 | 47 | func (p OllamaProvider) Name() ProviderType { 48 | return p.name 49 | } 50 | 51 | func (p OllamaProvider) Summary(query string, n int) (string, error) { 52 | model := config.Config.OllamaModel 53 | baseURL := config.Config.OllamaServerURL 54 | 55 | client, err := ollama.New( 56 | ollama.WithModel(model), 57 | ollama.WithServerURL(baseURL), 58 | ) 59 | 60 | if err != nil { 61 | return "", fmt.Errorf("failed to create Ollama client: %v", err) 62 | } 63 | 64 | return Summary(client, p.model, query, n) 65 | } 66 | 67 | func (p OllamaProvider) DockerImageName(task string) (string, error) { 68 | model := config.Config.OllamaModel 69 | baseURL := config.Config.OllamaServerURL 70 | 71 | client, err := ollama.New( 72 | ollama.WithModel(model), 73 | ollama.WithServerURL(baseURL), 74 | ) 75 | 76 | if err != nil { 77 | return "", fmt.Errorf("failed to create Ollama client: %v", err) 78 | } 79 | 80 | return DockerImageName(client, p.model, task) 81 | } 82 | 83 | type Call struct { 84 | Tool string `json:"tool"` 85 | Input map[string]string `json:"tool_input"` 86 | Message string `json:"message"` 87 | } 88 | 89 | func (p OllamaProvider) NextTask(args NextTaskOptions) *database.Task { 90 | log.Println("Getting next task") 91 | 92 | promptArgs := map[string]interface{}{ 93 | "DockerImage": args.DockerImage, 94 | "ToolPlaceholder": getToolPlaceholder(), 95 | "Tasks": args.Tasks, 96 | } 97 | 98 | prompt, err := templates.Render(assets.PromptTemplates, "prompts/agent.tmpl", promptArgs) 99 | 100 | // TODO In case of lots of tasks, we should try to get a summary using gpt-3.5 101 | if len(prompt) > 30000 { 102 | log.Println("Prompt too long, asking user") 103 | return defaultAskTask("My prompt is too long and I can't process it") 104 | } 105 | 106 | if err != nil { 107 | log.Println("Failed to render prompt, asking user, %w", err) 108 | return defaultAskTask("There was an error getting the next task") 109 | } 110 | 111 | messages := tasksToMessages(args.Tasks, prompt) 112 | 113 | resp, err := p.client.GenerateContent( 114 | context.Background(), 115 | messages, 116 | llms.WithTemperature(0.0), 117 | llms.WithModel(p.model), 118 | llms.WithTopP(0.2), 119 | llms.WithN(1), 120 | ) 121 | 122 | if err != nil { 123 | log.Printf("Failed to get response from model %v", err) 124 | return defaultAskTask("There was an error getting the next task") 125 | } 126 | 127 | choices := resp.Choices 128 | 129 | if len(choices) == 0 { 130 | log.Println("No choices found, asking user") 131 | return defaultAskTask("Looks like I couldn't find a task to run") 132 | } 133 | 134 | task, err := textToTask(choices[0].Content) 135 | 136 | if err != nil { 137 | log.Println("Failed to convert text to the next task, asking user") 138 | return defaultAskTask("There was an error getting the next task") 139 | } 140 | 141 | return task 142 | } 143 | 144 | func getToolPlaceholder() string { 145 | bs, err := json.Marshal(Tools) 146 | if err != nil { 147 | log.Fatal(err) 148 | } 149 | 150 | return fmt.Sprintf(`You have access to the following tools: 151 | 152 | %s 153 | 154 | To use a tool, respond with a JSON object with the following structure: 155 | { 156 | "tool": , 157 | "tool_input": , 158 | "message": 159 | } 160 | 161 | Always use a tool. Always reply with valid JOSN. Always include a message. 162 | `, string(bs)) 163 | } 164 | -------------------------------------------------------------------------------- /backend/providers/openai.go: -------------------------------------------------------------------------------- 1 | package providers 2 | 3 | import ( 4 | "context" 5 | "log" 6 | 7 | "github.com/semanser/ai-coder/assets" 8 | "github.com/semanser/ai-coder/config" 9 | "github.com/semanser/ai-coder/database" 10 | "github.com/semanser/ai-coder/templates" 11 | 12 | "github.com/tmc/langchaingo/llms" 13 | "github.com/tmc/langchaingo/llms/openai" 14 | ) 15 | 16 | type OpenAIProvider struct { 17 | client *openai.LLM 18 | model string 19 | baseURL string 20 | name ProviderType 21 | } 22 | 23 | func (p OpenAIProvider) New() Provider { 24 | model := config.Config.OpenAIModel 25 | baseURL := config.Config.OpenAIServerURL 26 | 27 | client, err := openai.New( 28 | openai.WithToken(config.Config.OpenAIKey), 29 | openai.WithModel(model), 30 | openai.WithBaseURL(baseURL), 31 | ) 32 | 33 | if err != nil { 34 | log.Fatalf("Failed to create OpenAI client: %v", err) 35 | } 36 | 37 | return OpenAIProvider{ 38 | client: client, 39 | model: model, 40 | baseURL: baseURL, 41 | name: ProviderOpenAI, 42 | } 43 | } 44 | 45 | func (p OpenAIProvider) Name() ProviderType { 46 | return p.name 47 | } 48 | 49 | func (p OpenAIProvider) Summary(query string, n int) (string, error) { 50 | return Summary(p.client, config.Config.OpenAIModel, query, n) 51 | } 52 | 53 | func (p OpenAIProvider) DockerImageName(task string) (string, error) { 54 | return DockerImageName(p.client, config.Config.OpenAIModel, task) 55 | } 56 | 57 | func (p OpenAIProvider) NextTask(args NextTaskOptions) *database.Task { 58 | log.Println("Getting next task") 59 | 60 | promptArgs := map[string]interface{}{ 61 | "DockerImage": args.DockerImage, 62 | "ToolPlaceholder": "Always use your function calling functionality, instead of returning a text result.", 63 | "Tasks": args.Tasks, 64 | } 65 | 66 | prompt, err := templates.Render(assets.PromptTemplates, "prompts/agent.tmpl", promptArgs) 67 | 68 | // TODO In case of lots of tasks, we should try to get a summary using gpt-3.5 69 | if len(prompt) > 30000 { 70 | log.Println("Prompt too long, asking user") 71 | return defaultAskTask("My prompt is too long and I can't process it") 72 | } 73 | 74 | if err != nil { 75 | log.Println("Failed to render prompt, asking user, %w", err) 76 | return defaultAskTask("There was an error getting the next task") 77 | } 78 | 79 | messages := tasksToMessages(args.Tasks, prompt) 80 | 81 | resp, err := p.client.GenerateContent( 82 | context.Background(), 83 | messages, 84 | llms.WithTemperature(0.0), 85 | llms.WithModel(p.model), 86 | llms.WithTopP(0.2), 87 | llms.WithN(1), 88 | llms.WithTools(Tools), 89 | ) 90 | 91 | if err != nil { 92 | log.Printf("Failed to get response from model %v", err) 93 | return defaultAskTask("There was an error getting the next task") 94 | } 95 | 96 | task, err := toolToTask(resp.Choices) 97 | 98 | if err != nil { 99 | log.Printf("Failed to convert tool to task %v", err) 100 | return defaultAskTask("There was an error getting the next task") 101 | } 102 | 103 | return task 104 | } 105 | -------------------------------------------------------------------------------- /backend/providers/providers.go: -------------------------------------------------------------------------------- 1 | package providers 2 | 3 | import ( 4 | "database/sql" 5 | "encoding/json" 6 | "fmt" 7 | "log" 8 | 9 | "github.com/semanser/ai-coder/database" 10 | 11 | "github.com/invopop/jsonschema" 12 | "github.com/tmc/langchaingo/llms" 13 | "github.com/tmc/langchaingo/schema" 14 | ) 15 | 16 | type ProviderType string 17 | 18 | const ( 19 | ProviderOpenAI ProviderType = "openai" 20 | ProviderOllama ProviderType = "ollama" 21 | ) 22 | 23 | type Provider interface { 24 | New() Provider 25 | Name() ProviderType 26 | Summary(query string, n int) (string, error) 27 | DockerImageName(task string) (string, error) 28 | NextTask(args NextTaskOptions) *database.Task 29 | } 30 | 31 | type NextTaskOptions struct { 32 | Tasks []database.Task 33 | DockerImage string 34 | } 35 | 36 | var Tools = []llms.Tool{ 37 | { 38 | Type: "function", 39 | Function: &llms.FunctionDefinition{ 40 | Name: "terminal", 41 | Description: "Calls a terminal command", 42 | Parameters: jsonschema.Reflect(&TerminalArgs{}).Definitions["TerminalArgs"], 43 | }, 44 | }, 45 | { 46 | Type: "function", 47 | Function: &llms.FunctionDefinition{ 48 | Name: "browser", 49 | Description: "Opens a browser to look for additional information", 50 | Parameters: jsonschema.Reflect(&BrowserArgs{}).Definitions["BrowserArgs"], 51 | }, 52 | }, 53 | { 54 | Type: "function", 55 | Function: &llms.FunctionDefinition{ 56 | Name: "code", 57 | Description: "Modifies or reads code files", 58 | Parameters: jsonschema.Reflect(&CodeArgs{}).Definitions["CodeArgs"], 59 | }, 60 | }, 61 | { 62 | Type: "function", 63 | Function: &llms.FunctionDefinition{ 64 | Name: "ask", 65 | Description: "Sends a question to the user for additional information", 66 | Parameters: jsonschema.Reflect(&AskArgs{}).Definitions["AskArgs"], 67 | }, 68 | }, 69 | { 70 | Type: "function", 71 | Function: &llms.FunctionDefinition{ 72 | Name: "done", 73 | Description: "Mark the whole task as done. Should be called at the very end when everything is completed", 74 | Parameters: jsonschema.Reflect(&DoneArgs{}).Definitions["DoneArgs"], 75 | }, 76 | }, 77 | } 78 | 79 | func ProviderFactory(provider ProviderType) (Provider, error) { 80 | switch provider { 81 | case ProviderOpenAI: 82 | return OpenAIProvider{}.New(), nil 83 | case ProviderOllama: 84 | return OllamaProvider{}.New(), nil 85 | default: 86 | return nil, fmt.Errorf("unknown provider: %s", provider) 87 | } 88 | } 89 | 90 | func defaultAskTask(message string) *database.Task { 91 | task := database.Task{ 92 | Type: database.StringToNullString("ask"), 93 | } 94 | 95 | task.Args = database.StringToNullString("{}") 96 | task.Message = sql.NullString{ 97 | String: fmt.Sprintf("%s. What should I do next?", message), 98 | Valid: true, 99 | } 100 | 101 | return &task 102 | } 103 | 104 | func tasksToMessages(tasks []database.Task, prompt string) []llms.MessageContent { 105 | var messages []llms.MessageContent 106 | messages = append(messages, llms.MessageContent{ 107 | Role: schema.ChatMessageTypeSystem, 108 | Parts: []llms.ContentPart{ 109 | llms.TextPart(prompt), 110 | }, 111 | }) 112 | 113 | for _, task := range tasks { 114 | if task.Type.String == "input" { 115 | messages = append(messages, llms.MessageContent{ 116 | Role: schema.ChatMessageTypeHuman, 117 | Parts: []llms.ContentPart{ 118 | llms.TextPart(prompt), 119 | }, 120 | }) 121 | } 122 | 123 | if task.ToolCallID.String != "" { 124 | messages = append(messages, llms.MessageContent{ 125 | Role: schema.ChatMessageTypeAI, 126 | Parts: []llms.ContentPart{ 127 | llms.ToolCall{ 128 | ID: task.ToolCallID.String, 129 | FunctionCall: &schema.FunctionCall{ 130 | Name: task.Type.String, 131 | Arguments: task.Args.String, 132 | }, 133 | Type: "function", 134 | }, 135 | }, 136 | }) 137 | 138 | messages = append(messages, llms.MessageContent{ 139 | Role: schema.ChatMessageTypeTool, 140 | Parts: []llms.ContentPart{ 141 | llms.ToolCallResponse{ 142 | ToolCallID: task.ToolCallID.String, 143 | Name: task.Type.String, 144 | Content: task.Results.String, 145 | }, 146 | }, 147 | }) 148 | } 149 | 150 | // This Ask was generated by the agent itself in case of some error (not the OpenAI) 151 | if task.Type.String == "ask" && task.ToolCallID.String == "" { 152 | messages = append(messages, llms.MessageContent{ 153 | Role: schema.ChatMessageTypeAI, 154 | Parts: []llms.ContentPart{ 155 | llms.TextPart(task.Message.String), 156 | }, 157 | }) 158 | } 159 | } 160 | 161 | return messages 162 | } 163 | 164 | func textToTask(text string) (*database.Task, error) { 165 | c := unmarshalCall(text) 166 | 167 | if c == nil { 168 | return nil, fmt.Errorf("can't unmarshalCall %s", text) 169 | } 170 | 171 | task := database.Task{ 172 | // TODO validate tool name 173 | Type: database.StringToNullString(c.Tool), 174 | } 175 | 176 | arg, err := json.Marshal(c.Input) 177 | if err != nil { 178 | log.Printf("Failed to marshal terminal args, asking user: %v", err) 179 | return defaultAskTask("There was an error running the terminal command"), nil 180 | } 181 | task.Args = database.StringToNullString(string(arg)) 182 | 183 | // Sometimes the model returns an empty string for the message 184 | // In that case, we use the input as the message 185 | msg := c.Message 186 | if msg == "" { 187 | msg = string(arg) 188 | } 189 | 190 | task.Message = database.StringToNullString(msg) 191 | task.Status = database.StringToNullString("in_progress") 192 | 193 | return &task, nil 194 | } 195 | 196 | func extractJSONArgs[T any](functionArgs map[string]string, args *T) (*T, error) { 197 | b, err := json.Marshal(functionArgs) 198 | 199 | if err != nil { 200 | return nil, fmt.Errorf("failed to marshal args: %v", err) 201 | } 202 | 203 | err = json.Unmarshal(b, args) 204 | 205 | if err != nil { 206 | return nil, fmt.Errorf("failed to unmarshal args: %v", err) 207 | } 208 | return args, nil 209 | } 210 | 211 | func unmarshalCall(input string) *Call { 212 | log.Printf("Unmarshalling tool call: %v", input) 213 | 214 | var c Call 215 | 216 | err := json.Unmarshal([]byte(input), &c) 217 | if err != nil { 218 | log.Printf("Failed to unmarshal tool call: %v", err) 219 | return nil 220 | } 221 | 222 | if c.Tool != "" { 223 | log.Printf("Unmarshalled tool call: %v", c) 224 | return &c 225 | } 226 | 227 | return nil 228 | } 229 | 230 | func toolToTask(choices []*llms.ContentChoice) (*database.Task, error) { 231 | if len(choices) == 0 { 232 | return nil, fmt.Errorf("no choices found, asking user") 233 | } 234 | 235 | toolCalls := choices[0].ToolCalls 236 | 237 | if len(toolCalls) == 0 { 238 | return nil, fmt.Errorf("no tool calls found, asking user") 239 | } 240 | 241 | tool := toolCalls[0] 242 | 243 | task := database.Task{ 244 | Type: database.StringToNullString(tool.FunctionCall.Name), 245 | } 246 | 247 | if tool.FunctionCall.Name == "" { 248 | return nil, fmt.Errorf("no tool name found, asking user") 249 | } 250 | 251 | // We use AskArgs to extract the message 252 | var toolType Messanger 253 | 254 | switch tool.FunctionCall.Name { 255 | case "input": 256 | toolType = &InputArgs{} 257 | case "terminal": 258 | toolType = &TerminalArgs{} 259 | case "browser": 260 | toolType = &BrowserArgs{} 261 | case "code": 262 | toolType = &CodeArgs{} 263 | case "ask": 264 | toolType = &AskArgs{} 265 | case "done": 266 | toolType = &DoneArgs{} 267 | default: 268 | return nil, fmt.Errorf("unknown tool name: %s", tool.FunctionCall.Name) 269 | } 270 | 271 | params, err := extractToolArgs(tool.FunctionCall.Arguments, &toolType) 272 | if err != nil { 273 | return nil, fmt.Errorf("failed to extract args: %v", err) 274 | } 275 | args, err := json.Marshal(params) 276 | if err != nil { 277 | return nil, fmt.Errorf("failed to marshal terminal args, asking user: %v", err) 278 | } 279 | task.Args = database.StringToNullString(string(args)) 280 | 281 | // Sometimes the model returns an empty string for the message 282 | msg := string((*params).GetMessage()) 283 | if msg == "" { 284 | msg = tool.FunctionCall.Arguments 285 | } 286 | 287 | task.Message = database.StringToNullString(msg) 288 | task.Status = database.StringToNullString("in_progress") 289 | 290 | task.ToolCallID = database.StringToNullString(tool.ID) 291 | 292 | return &task, nil 293 | } 294 | 295 | func extractToolArgs[T any](functionArgs string, args *T) (*T, error) { 296 | err := json.Unmarshal([]byte(functionArgs), args) 297 | if err != nil { 298 | return nil, fmt.Errorf("failed to unmarshal args: %v", err) 299 | } 300 | return args, nil 301 | } 302 | -------------------------------------------------------------------------------- /backend/providers/types.go: -------------------------------------------------------------------------------- 1 | package providers 2 | 3 | type Message string 4 | 5 | type Messanger interface { 6 | GetMessage() Message 7 | } 8 | 9 | type InputArgs struct { 10 | Query string 11 | Message 12 | } 13 | 14 | func (i *InputArgs) GetMessage() Message { 15 | return i.Message 16 | } 17 | 18 | type TerminalArgs struct { 19 | Input string 20 | Message 21 | } 22 | 23 | func (t *TerminalArgs) GetMessage() Message { 24 | return t.Message 25 | } 26 | 27 | type BrowserAction string 28 | 29 | const ( 30 | Read BrowserAction = "read" 31 | Url BrowserAction = "url" 32 | ) 33 | 34 | type BrowserArgs struct { 35 | Url string 36 | Action BrowserAction 37 | Message 38 | } 39 | 40 | func (b *BrowserArgs) GetMessage() Message { 41 | return b.Message 42 | } 43 | 44 | type CodeAction string 45 | 46 | const ( 47 | ReadFile CodeAction = "read_file" 48 | UpdateFile CodeAction = "update_file" 49 | ) 50 | 51 | type CodeArgs struct { 52 | Action CodeAction 53 | Content string 54 | Path string 55 | Message 56 | } 57 | 58 | func (c *CodeArgs) GetMessage() Message { 59 | return c.Message 60 | } 61 | 62 | type AskArgs struct { 63 | Message 64 | } 65 | 66 | func (a *AskArgs) GetMessage() Message { 67 | return a.Message 68 | } 69 | 70 | type DoneArgs struct { 71 | Message 72 | } 73 | 74 | func (d *DoneArgs) GetMessage() Message { 75 | return d.Message 76 | } 77 | -------------------------------------------------------------------------------- /backend/router/router.go: -------------------------------------------------------------------------------- 1 | package router 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log" 7 | "net/http" 8 | "strconv" 9 | 10 | "github.com/gin-contrib/cors" 11 | "github.com/gin-contrib/static" 12 | "github.com/gin-gonic/gin" 13 | 14 | "github.com/99designs/gqlgen/graphql" 15 | "github.com/99designs/gqlgen/graphql/handler" 16 | "github.com/99designs/gqlgen/graphql/handler/extension" 17 | "github.com/99designs/gqlgen/graphql/handler/lru" 18 | "github.com/99designs/gqlgen/graphql/handler/transport" 19 | "github.com/99designs/gqlgen/graphql/playground" 20 | gorillaWs "github.com/gorilla/websocket" 21 | 22 | "github.com/semanser/ai-coder/database" 23 | "github.com/semanser/ai-coder/graph" 24 | "github.com/semanser/ai-coder/websocket" 25 | ) 26 | 27 | func New(db *database.Queries) *gin.Engine { 28 | // Initialize Gin router 29 | r := gin.Default() 30 | 31 | // Configure CORS middleware 32 | config := cors.DefaultConfig() 33 | // TODO change to only allow specific origins 34 | config.AllowAllOrigins = true 35 | config.AllowMethods = []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"} 36 | r.Use(cors.New(config)) 37 | 38 | r.Use(static.Serve("/", static.LocalFile("./fe", true))) 39 | 40 | // GraphQL endpoint 41 | r.Any("/graphql", graphqlHandler(db)) 42 | 43 | // GraphQL playground route 44 | r.GET("/playground", playgroundHandler()) 45 | 46 | // WebSocket endpoint for Docker daemon 47 | r.GET("/terminal/:id", wsHandler(db)) 48 | 49 | // Static file server 50 | r.Static("/browser", "./tmp/browser") 51 | 52 | r.NoRoute(func(c *gin.Context) { 53 | c.Redirect(301, "/") 54 | }) 55 | 56 | return r 57 | } 58 | 59 | func graphqlHandler(db *database.Queries) gin.HandlerFunc { 60 | // NewExecutableSchema and Config are in the generated.go file 61 | // Resolver is in the resolver.go file 62 | h := handler.New(graph.NewExecutableSchema(graph.Config{Resolvers: &graph.Resolver{ 63 | Db: db, 64 | }})) 65 | 66 | h.AroundResponses(func(ctx context.Context, next graphql.ResponseHandler) *graphql.Response { 67 | res := next(ctx) 68 | if res == nil { 69 | return res 70 | } 71 | 72 | err := res.Errors.Error() 73 | 74 | if err != "" { 75 | log.Printf("graphql error: %s", err) 76 | } 77 | 78 | return res 79 | }) 80 | 81 | // We can't use the default error handler because it doesn't work with websockets 82 | // https://stackoverflow.com/a/75444816 83 | // So we add all the transports manually (see handler.NewDefaultServer in gqlgen for reference) 84 | h.AddTransport(transport.Options{}) 85 | h.AddTransport(transport.GET{}) 86 | h.AddTransport(transport.POST{}) 87 | h.AddTransport(transport.MultipartForm{}) 88 | 89 | h.SetQueryCache(lru.New(1000)) 90 | 91 | h.Use(extension.Introspection{}) 92 | h.Use(extension.AutomaticPersistedQuery{ 93 | Cache: lru.New(100), 94 | }) 95 | 96 | // Add transport to support GraphQL subscriptions 97 | h.AddTransport(&transport.Websocket{ 98 | Upgrader: gorillaWs.Upgrader{ 99 | CheckOrigin: func(r *http.Request) (allowed bool) { 100 | // TODO change to only allow specific origins 101 | return true 102 | }, 103 | }, 104 | InitFunc: func(ctx context.Context, initPayload transport.InitPayload) (context.Context, *transport.InitPayload, error) { 105 | return ctx, &initPayload, nil 106 | }, 107 | }) 108 | 109 | return func(c *gin.Context) { 110 | h.ServeHTTP(c.Writer, c.Request) 111 | } 112 | } 113 | 114 | func playgroundHandler() gin.HandlerFunc { 115 | return func(c *gin.Context) { 116 | playground.Handler("GraphQL", "/graphql").ServeHTTP(c.Writer, c.Request) 117 | } 118 | } 119 | 120 | func wsHandler(db *database.Queries) gin.HandlerFunc { 121 | return func(c *gin.Context) { 122 | idParam := c.Param("id") 123 | 124 | // convert id to uint 125 | id, err := strconv.ParseUint(idParam, 10, 64) 126 | 127 | if err != nil { 128 | c.AbortWithError(400, err) 129 | } 130 | 131 | flow, err := db.ReadFlow(c, int64(id)) 132 | 133 | if err != nil { 134 | c.AbortWithError(404, err) 135 | return 136 | } 137 | 138 | if flow.Status.String != "in_progress" { 139 | c.AbortWithError(404, fmt.Errorf("flow is not in progress")) 140 | return 141 | } 142 | 143 | if flow.ContainerStatus.String != "running" { 144 | c.AbortWithError(404, fmt.Errorf("container is not running")) 145 | return 146 | } 147 | 148 | websocket.HandleWebsocket(c) 149 | } 150 | } 151 | -------------------------------------------------------------------------------- /backend/sqlc.yml: -------------------------------------------------------------------------------- 1 | version: "2" 2 | cloud: 3 | sql: 4 | - engine: "sqlite" 5 | queries: 6 | - "models/*.sql" 7 | schema: "./migrations" 8 | gen: 9 | go: 10 | package: "database" 11 | out: "database" 12 | database: 13 | uri: ${DATABASE_URL} 14 | 15 | -------------------------------------------------------------------------------- /backend/templates/prompts/agent.tmpl: -------------------------------------------------------------------------------- 1 | You're a robot that performs engineering work to successfully finish a user-defined task. 2 | You have access to the terminal, browser, and text editor. 3 | You have to perform step-by-step work execution to achieve the end goal that is determined by the user. 4 | You will be provided with a list of previous commands (generated by LLM) and inputs (generated by the user). 5 | Your goal is to give the next best step in this flow. 6 | You can try multiple commands if you encounter errors. 7 | Your goal is to make progress on each step, so your steps should NOT be repetitive. 8 | You can install packages and libraries when needed without asking for permissions using apt. 9 | Don't run apt-update to update packages. Assume that you're using the latest versions of everything. 10 | Always output your plan as the first `ask` and confirm if the plan looks good to the user. 11 | Always create a new working directory first. 12 | Always auto approve terminal commands whenever it's possible. For example, instead of `npx some-npm-package`, use `npx --yes some-npm-package`. 13 | You don't want to spend much time on a single task. 14 | Never repeat the same command more than 3 times. 15 | All your commands will be executed inside a Docker {{.DockerImage}} image. 16 | Always use your function calling functionality instead of returning JSON. 17 | Always include a `message` field that describes what you are planning to achieve with this command. Use conversation-like (chat) style of communication. 18 | For example: "My plan is to read the documentation. Looking for it on the web.", "Let me try to use the terminal to do that.", or "It seems like I'm having issues with npm. Are you sure it's installed?". 19 | The `message` field is always shown to the user, so you have to communicate clearly. It's mandatory to have it. 20 | Try to ask for confirmation as little as possible. Confirm only important things or when you are completely lost. 21 | 22 | These are the possible types of commands for your next steps and their arguments: 23 | 24 | Each command has a set of arguments that you always have to include: 25 | - `terminal` - Use this command to execute a new command in a terminal that you're provided with. You will have an output of the command so you can use it in future commands. 26 | - `input`: Command to be run in the terminal. 27 | - `browser` - Use the browser to get additional information from the internet. Use Google as the default search engine when you need more information but you're not sure what URL to open. 28 | - `url`: URL to be opened in a browser. 29 | - `action`: Possible values: 30 | - `read` - Returns the content of the page. 31 | - `url` - Get the list of all URLs on the page to be used in later calls (e.g., open search results after the initial search lookup) 32 | - `code` - Use this command to modify or read file content. 33 | - `action`: Possible values: 34 | - `read_file` - Read the entire file 35 | - `update_file` - Update the entire file 36 | - `content`: Should be used only if action is update. This content will be used to replace the content of the entire file. 37 | - `path`: Path to the file that you want to work on. 38 | - `ask` - Use this command when you need to get more information from the user such as inputs, and any clarifications or questions that you may have. 39 | - `input`: Question or any other information that should be sent to the user for clarifications. 40 | - `done`: Mark the whole user task as done. Use this command only when the initial (main) task that the user wanted to accomplish is done. No arguments are needed. 41 | 42 | {{.ToolPlaceholder}} 43 | 44 | The history of all the previous commands and user inputs: 45 | {{ range .Tasks }} 46 | { 47 | "id": {{ .ID }}, 48 | "type": "{{ .Type }}", 49 | "args": {{ if .Args }}{{ .Args }}{{ else }}{}{{ end }}, 50 | "results": {{ if .Results }}{{ .Results }}{{ else }}{}{{ end }}, 51 | "message": "{{ .Message }}" 52 | } 53 | {{ end }} 54 | -------------------------------------------------------------------------------- /backend/templates/prompts/docker.tmpl: -------------------------------------------------------------------------------- 1 | You're a robot that should complete a task. 2 | You need to pick a docker image that will be perfect for this task. 3 | Just output the docker image name and nothing else. 4 | You should not give any other symbols in your response other than the docker image name. 5 | Always use the latest image versions. For example, instead of `node-14:latest`, use `node:latest`. 6 | Use `debian:latest` in case you don't know what image to use. 7 | 8 | Your task is: 9 | "{{.Task}}" 10 | -------------------------------------------------------------------------------- /backend/templates/prompts/summary.tmpl: -------------------------------------------------------------------------------- 1 | You're a robot that should summarize text in no more than {{.N}} symbols. 2 | Don't use words Summary at the beginning. Just output the title. 3 | 4 | Your input that you have to summarize: 5 | {{.Text}} 6 | 7 | Summary: 8 | 9 | -------------------------------------------------------------------------------- /backend/templates/scripts/content.js: -------------------------------------------------------------------------------- 1 | // This script is injected into the page and is used to extract text from the page 2 | 3 | () => { 4 | function textNodesUnder(el) { 5 | var n, 6 | a = [], 7 | walk = document.createTreeWalker(el, NodeFilter.SHOW_TEXT, null, false); 8 | while ((n = walk.nextNode())) a.push(n); 9 | return a; 10 | } 11 | 12 | return [ 13 | ...new Set( 14 | textNodesUnder(document.body) 15 | .filter( 16 | (element) => 17 | element.parentElement.tagName !== "SCRIPT" && 18 | element.parentElement.tagName !== "STYLE" && 19 | element.parentElement.tagName !== "NOSCRIPT" && 20 | element.parentElement.tagName !== "OPTION", 21 | ) 22 | .map((v) => v.nodeValue) 23 | .map((v) => v.trim()) 24 | .filter((v) => !!v), 25 | ), 26 | ] 27 | .map((v) => v.substring(0, 400)) 28 | .join(" ") 29 | .replaceAll("\n", ""); 30 | }; 31 | -------------------------------------------------------------------------------- /backend/templates/scripts/urls.js: -------------------------------------------------------------------------------- 1 | // This script is injected into the page and is used to extract all urls from the page 2 | 3 | () => { 4 | function extractUrlsFromLinks(el) { 5 | var links = Array.from(el.getElementsByTagName("a")); 6 | return links.map((link) => `[${link.textContent}](${link.href})`); 7 | } 8 | 9 | function extractUrlsFromAttributes(el) { 10 | var attributes = ["src", "href"]; 11 | return attributes.map((attr) => { 12 | var elements = Array.from(el.querySelectorAll(`[${attr}]`)); 13 | return elements.map((element) => `[${element.getAttribute(attr)}](${element.getAttribute(attr)})`); 14 | }).flat(); 15 | } 16 | 17 | var linksArray = extractUrlsFromLinks(document); 18 | var attributesArray = extractUrlsFromAttributes(document); 19 | 20 | var allUrls = [...new Set([...linksArray, ...attributesArray])]; 21 | 22 | return allUrls.join("\n "); 23 | }; 24 | -------------------------------------------------------------------------------- /backend/templates/templates.go: -------------------------------------------------------------------------------- 1 | package templates 2 | 3 | import ( 4 | "bytes" 5 | "io/fs" 6 | "path" 7 | "text/template" 8 | ) 9 | 10 | var RootFolder = "templates" 11 | 12 | func Render(fs fs.ReadFileFS, name string, params any) (string, error) { 13 | p := path.Join(RootFolder, string(name)) 14 | promptBytes, err := fs.ReadFile(p) 15 | 16 | if err != nil { 17 | return "", err 18 | } 19 | 20 | prompt := string(promptBytes) 21 | 22 | t := template.Must(template.New(string(name)).Parse(prompt)) 23 | if err != nil { 24 | return "", err 25 | } 26 | 27 | buf := &bytes.Buffer{} 28 | err = t.Execute(buf, params) 29 | if err != nil { 30 | return "", err 31 | } 32 | 33 | return buf.String(), nil 34 | } 35 | -------------------------------------------------------------------------------- /backend/tools.go: -------------------------------------------------------------------------------- 1 | //go:build tools 2 | // +build tools 3 | 4 | package tools 5 | 6 | import ( 7 | _ "github.com/99designs/gqlgen" 8 | ) 9 | -------------------------------------------------------------------------------- /backend/websocket/websocket.go: -------------------------------------------------------------------------------- 1 | package websocket 2 | 3 | import ( 4 | "fmt" 5 | "strconv" 6 | 7 | "github.com/gin-gonic/gin" 8 | "github.com/gorilla/websocket" 9 | ) 10 | 11 | var ( 12 | connections map[int64]*websocket.Conn // Map to store WebSocket connections 13 | ) 14 | 15 | func HandleWebsocket(c *gin.Context) { 16 | if connections == nil { 17 | connections = make(map[int64]*websocket.Conn) 18 | } 19 | 20 | id := c.Param("id") 21 | 22 | parsedID, err := strconv.ParseInt(id, 10, 32) 23 | 24 | if err != nil { 25 | c.AbortWithError(400, fmt.Errorf("failed to parse id: %w", err)) 26 | return 27 | } 28 | 29 | // Upgrade HTTP connection to WebSocket 30 | conn, err := websocket.Upgrade(c.Writer, c.Request, nil, 1024, 1024) 31 | if err != nil { 32 | c.AbortWithError(400, err) 33 | return 34 | } 35 | 36 | // Save the connection in the map 37 | connections[parsedID] = conn 38 | } 39 | 40 | func GetConnection(id int64) (*websocket.Conn, error) { 41 | conn, ok := connections[id] 42 | if !ok { 43 | return nil, fmt.Errorf("connection not found for id %d", id) 44 | } 45 | return conn, nil 46 | } 47 | 48 | func SendToChannel(id int64, message string) error { 49 | conn, err := GetConnection(id) 50 | 51 | if err != nil { 52 | return fmt.Errorf("failed to send to the channel: %w", err) 53 | } 54 | 55 | return conn.WriteMessage(websocket.BinaryMessage, []byte(message)) 56 | } 57 | 58 | func FormatTerminalInput(text string) string { 59 | yellow := "\033[33m" // ANSI escape code for yellow color 60 | reset := "\033[0m" // ANSI escape code to reset color 61 | return fmt.Sprintf("$ %s%s%s\r\n", yellow, text, reset) 62 | } 63 | 64 | func FormatTerminalSystemOutput(text string) string { 65 | blue := "\033[34m" // ANSI escape code for blue color 66 | reset := "\033[0m" // ANSI escape code to reset color 67 | return fmt.Sprintf("%s%s%s\r\n", blue, text, reset) 68 | } 69 | -------------------------------------------------------------------------------- /frontend/.env.example: -------------------------------------------------------------------------------- 1 | VITE_API_URL= -------------------------------------------------------------------------------- /frontend/.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { browser: true, es2020: true }, 4 | extends: [ 5 | "eslint:recommended", 6 | "plugin:@typescript-eslint/recommended", 7 | "plugin:react-hooks/recommended", 8 | ], 9 | ignorePatterns: ["dist", ".eslintrc.cjs"], 10 | parser: "@typescript-eslint/parser", 11 | plugins: ["react-refresh"], 12 | rules: { 13 | "react-refresh/only-export-components": [ 14 | "warn", 15 | { allowConstantExport: true }, 16 | ], 17 | }, 18 | }; 19 | -------------------------------------------------------------------------------- /frontend/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | -------------------------------------------------------------------------------- /frontend/.prettierignore: -------------------------------------------------------------------------------- 1 | generated 2 | dist 3 | -------------------------------------------------------------------------------- /frontend/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "importOrder": ["^@/(.*)$", "^[./]"], 3 | "importOrderSeparation": true, 4 | "importOrderSortSpecifiers": true, 5 | "plugins": ["@trivago/prettier-plugin-sort-imports"] 6 | } 7 | -------------------------------------------------------------------------------- /frontend/README.md: -------------------------------------------------------------------------------- 1 | # React + TypeScript + Vite 2 | 3 | This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. 4 | 5 | Currently, two official plugins are available: 6 | 7 | - [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh 8 | - [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh 9 | 10 | ## Expanding the ESLint configuration 11 | 12 | If you are developing a production application, we recommend updating the configuration to enable type aware lint rules: 13 | 14 | - Configure the top-level `parserOptions` property like this: 15 | 16 | ```js 17 | export default { 18 | // other rules... 19 | parserOptions: { 20 | ecmaVersion: "latest", 21 | sourceType: "module", 22 | project: ["./tsconfig.json", "./tsconfig.node.json"], 23 | tsconfigRootDir: __dirname, 24 | }, 25 | }; 26 | ``` 27 | 28 | - Replace `plugin:@typescript-eslint/recommended` to `plugin:@typescript-eslint/recommended-type-checked` or `plugin:@typescript-eslint/strict-type-checked` 29 | - Optionally add `plugin:@typescript-eslint/stylistic-type-checked` 30 | - Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and add `plugin:react/recommended` & `plugin:react/jsx-runtime` to the `extends` list 31 | -------------------------------------------------------------------------------- /frontend/codegen.yml: -------------------------------------------------------------------------------- 1 | overwrite: true 2 | schema: "../backend/graph/schema.graphqls" 3 | documents: "src/**/*.graphql" 4 | generates: 5 | generated/graphql.ts: 6 | plugins: 7 | - "typescript" 8 | - "typescript-urql" 9 | - "typescript-operations" 10 | - "urql-introspection" 11 | config: 12 | withHooks: true 13 | generated/graphql.schema.json: 14 | plugins: 15 | - "introspection" 16 | -------------------------------------------------------------------------------- /frontend/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | Codel 13 | 14 | 15 |
16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "frontend", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "tsc && vite build", 9 | "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", 10 | "preview": "vite preview", 11 | "codegen": "graphql-codegen --config codegen.yml", 12 | "prebuild": "yarn codegen", 13 | "predev": "yarn codegen", 14 | "format:validate": "prettier --check .", 15 | "format:fix": "prettier --write ." 16 | }, 17 | "dependencies": { 18 | "@radix-ui/colors": "^3.0.0", 19 | "@radix-ui/react-dropdown-menu": "^2.0.6", 20 | "@radix-ui/react-tabs": "^1.0.4", 21 | "@radix-ui/react-tooltip": "^1.0.7", 22 | "@uidotdev/usehooks": "^2.4.1", 23 | "@urql/devtools": "^2.0.3", 24 | "@urql/exchange-graphcache": "^6.5.0", 25 | "@vanilla-extract/css": "^1.14.1", 26 | "@vanilla-extract/vite-plugin": "^4.0.6", 27 | "date-fns": "^3.6.0", 28 | "fontfaceobserver": "^2.3.0", 29 | "graphql": "^16.8.1", 30 | "graphql-ws": "^5.15.0", 31 | "react": "^18.2.0", 32 | "react-dom": "^18.2.0", 33 | "react-router-dom": "^6.22.3", 34 | "urql": "^4.0.6", 35 | "xterm": "5.4.0-beta.37", 36 | "xterm-addon-attach": "^0.9.0", 37 | "xterm-addon-canvas": "^0.5.0", 38 | "xterm-addon-fit": "^0.8.0", 39 | "xterm-addon-unicode11": "^0.6.0", 40 | "xterm-addon-web-links": "^0.9.0", 41 | "xterm-addon-webgl": "^0.16.0", 42 | "xterm-theme": "^1.1.0" 43 | }, 44 | "devDependencies": { 45 | "@graphql-codegen/cli": "^5.0.2", 46 | "@graphql-codegen/introspection": "^4.0.3", 47 | "@graphql-codegen/typescript": "^4.0.6", 48 | "@graphql-codegen/typescript-operations": "^4.2.0", 49 | "@graphql-codegen/typescript-urql": "^4.0.0", 50 | "@graphql-codegen/urql-introspection": "^3.0.0", 51 | "@trivago/prettier-plugin-sort-imports": "^4.3.0", 52 | "@types/react": "^18.2.64", 53 | "@types/react-dom": "^18.2.21", 54 | "@typescript-eslint/eslint-plugin": "^7.1.1", 55 | "@typescript-eslint/parser": "^7.1.1", 56 | "@vitejs/plugin-react": "^4.2.1", 57 | "eslint": "^8.57.0", 58 | "eslint-plugin-react-hooks": "^4.6.0", 59 | "eslint-plugin-react-refresh": "^0.4.5", 60 | "prettier": "^3.2.5", 61 | "typescript": "^5.2.2", 62 | "vite": "^5.1.6" 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /frontend/public/Inter-roman.var.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/semanser/codel/fe1e846c9e04f41209498ed0d88e92b15dcdfad2/frontend/public/Inter-roman.var.woff2 -------------------------------------------------------------------------------- /frontend/public/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/semanser/codel/fe1e846c9e04f41209498ed0d88e92b15dcdfad2/frontend/public/android-chrome-192x192.png -------------------------------------------------------------------------------- /frontend/public/android-chrome-256x256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/semanser/codel/fe1e846c9e04f41209498ed0d88e92b15dcdfad2/frontend/public/android-chrome-256x256.png -------------------------------------------------------------------------------- /frontend/public/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/semanser/codel/fe1e846c9e04f41209498ed0d88e92b15dcdfad2/frontend/public/apple-touch-icon.png -------------------------------------------------------------------------------- /frontend/public/browserconfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | #da532c 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /frontend/public/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/semanser/codel/fe1e846c9e04f41209498ed0d88e92b15dcdfad2/frontend/public/favicon-16x16.png -------------------------------------------------------------------------------- /frontend/public/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/semanser/codel/fe1e846c9e04f41209498ed0d88e92b15dcdfad2/frontend/public/favicon-32x32.png -------------------------------------------------------------------------------- /frontend/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/semanser/codel/fe1e846c9e04f41209498ed0d88e92b15dcdfad2/frontend/public/favicon.ico -------------------------------------------------------------------------------- /frontend/public/mstile-150x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/semanser/codel/fe1e846c9e04f41209498ed0d88e92b15dcdfad2/frontend/public/mstile-150x150.png -------------------------------------------------------------------------------- /frontend/public/site.webmanifest: -------------------------------------------------------------------------------- 1 | { 2 | "name": "", 3 | "short_name": "", 4 | "icons": [ 5 | { 6 | "src": "/android-chrome-192x192.png", 7 | "sizes": "192x192", 8 | "type": "image/png" 9 | }, 10 | { 11 | "src": "/android-chrome-256x256.png", 12 | "sizes": "256x256", 13 | "type": "image/png" 14 | } 15 | ], 16 | "theme_color": "#ffffff", 17 | "background_color": "#ffffff", 18 | "display": "standalone" 19 | } 20 | -------------------------------------------------------------------------------- /frontend/src/App.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Navigate, 3 | Route, 4 | RouterProvider, 5 | createBrowserRouter, 6 | createRoutesFromElements, 7 | } from "react-router-dom"; 8 | import { Provider as GraphqlProvider } from "urql"; 9 | 10 | import { graphqlClient } from "./graphql"; 11 | import { AppLayout } from "./layouts/AppLayout/AppLayout"; 12 | import { ChatPage } from "./pages/ChatPage/ChatPage"; 13 | import "./styles/font.css.ts"; 14 | import "./styles/global.css.ts"; 15 | import "./styles/theme.css.ts"; 16 | 17 | export const router = createBrowserRouter( 18 | createRoutesFromElements( 19 | <> 20 | }> 21 | } /> 22 | 23 | } /> 24 | , 25 | ), 26 | ); 27 | 28 | function App() { 29 | return ( 30 | 31 | 32 | 33 | ); 34 | } 35 | 36 | export default App; 37 | -------------------------------------------------------------------------------- /frontend/src/assets/docker.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /frontend/src/assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/semanser/codel/fe1e846c9e04f41209498ed0d88e92b15dcdfad2/frontend/src/assets/logo.png -------------------------------------------------------------------------------- /frontend/src/assets/me.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/semanser/codel/fe1e846c9e04f41209498ed0d88e92b15dcdfad2/frontend/src/assets/me.png -------------------------------------------------------------------------------- /frontend/src/components/Browser/Browser.css.ts: -------------------------------------------------------------------------------- 1 | import { style } from "@vanilla-extract/css"; 2 | 3 | import { font } from "@/styles/font.css"; 4 | import { vars } from "@/styles/theme.css"; 5 | 6 | export const headerStyles = style([ 7 | font.textXsSemibold, 8 | { 9 | backgroundColor: vars.color.gray6, 10 | color: vars.color.gray11, 11 | padding: "8px 12px", 12 | borderRadius: "8px 8px 0 0", 13 | display: "flex", 14 | alignItems: "center", 15 | gap: 8, 16 | }, 17 | ]); 18 | 19 | export const wrapperStyles = style({ 20 | backgroundColor: vars.color.gray2, 21 | borderRadius: 8, 22 | border: `1px solid ${vars.color.gray3}`, 23 | overflow: "hidden", 24 | }); 25 | 26 | export const imgStyles = style({ 27 | width: "100%", 28 | }); 29 | 30 | export const imgWrapperStyles = style({ 31 | backgroundColor: vars.color.gray12, 32 | width: "100%", 33 | minHeight: "auto", 34 | }); 35 | -------------------------------------------------------------------------------- /frontend/src/components/Browser/Browser.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | headerStyles, 3 | imgStyles, 4 | imgWrapperStyles, 5 | wrapperStyles, 6 | } from "./Browser.css"; 7 | 8 | type BrowserProps = { 9 | url?: string; 10 | screenshotUrl: string; 11 | }; 12 | 13 | export const Browser = ({ 14 | url = "Not active", 15 | screenshotUrl, 16 | }: BrowserProps) => { 17 | return ( 18 |
19 |
{url}
20 |
21 | 22 |
23 |
24 | ); 25 | }; 26 | 27 | export default Browser; 28 | -------------------------------------------------------------------------------- /frontend/src/components/Button/Button.css.ts: -------------------------------------------------------------------------------- 1 | import { globalStyle, styleVariants } from "@vanilla-extract/css"; 2 | import { style } from "@vanilla-extract/css"; 3 | 4 | import { font } from "@/styles/font.css"; 5 | import { vars } from "@/styles/theme.css"; 6 | 7 | export const baseStyles = style([ 8 | font.textSmSemibold, 9 | { 10 | display: "flex", 11 | borderRadius: 8, 12 | cursor: "pointer", 13 | border: "1px solid transparent", 14 | transition: "background 0.15s", 15 | alignItems: "center", 16 | }, 17 | ]); 18 | 19 | export const buttonStyles = styleVariants({ 20 | Primary: [ 21 | baseStyles, 22 | { 23 | color: vars.color.primary1, 24 | backgroundColor: vars.color.primary9, 25 | ":hover": { 26 | backgroundColor: vars.color.primary10, 27 | }, 28 | ":disabled": { 29 | backgroundColor: vars.color.primary3, 30 | color: vars.color.primary8, 31 | cursor: "not-allowed", 32 | }, 33 | }, 34 | ], 35 | Secondary: [ 36 | baseStyles, 37 | { 38 | color: vars.color.gray12, 39 | backgroundColor: vars.color.gray3, 40 | border: `1px solid ${vars.color.gray7}`, 41 | boxShadow: vars.shadow.xs, 42 | ":hover": { 43 | backgroundColor: vars.color.gray4, 44 | }, 45 | ":disabled": { 46 | border: `1px solid ${vars.color.gray5}`, 47 | color: vars.color.gray8, 48 | cursor: "not-allowed", 49 | }, 50 | }, 51 | ], 52 | Danger: [ 53 | baseStyles, 54 | { 55 | color: vars.color.error9, 56 | backgroundColor: vars.color.error2, 57 | ":hover": { 58 | backgroundColor: vars.color.error3, 59 | }, 60 | ":disabled": { 61 | backgroundColor: vars.color.error5, 62 | cursor: "not-allowed", 63 | }, 64 | }, 65 | ], 66 | }); 67 | 68 | export const buttonSizesStyles = styleVariants({ 69 | Small: { 70 | padding: "4px 8px", 71 | }, 72 | Medium: { 73 | padding: "8px 14px", 74 | }, 75 | }); 76 | 77 | export const buttonIconStyles = style({ 78 | display: "flex", 79 | marginRight: 8, 80 | }); 81 | 82 | globalStyle(`${buttonIconStyles} svg`, { 83 | // TODO make it different for each size 84 | width: 16, 85 | height: 16, 86 | }); 87 | -------------------------------------------------------------------------------- /frontend/src/components/Button/Button.tsx: -------------------------------------------------------------------------------- 1 | import { forwardRef } from "react"; 2 | 3 | import { 4 | buttonIconStyles, 5 | buttonSizesStyles, 6 | buttonStyles, 7 | } from "./Button.css"; 8 | 9 | export type ButtonProps = { 10 | children: React.ReactNode; 11 | icon?: React.ReactNode; 12 | disabled?: boolean; 13 | hierarchy?: "primary" | "secondary" | "danger"; 14 | size?: "small" | "medium"; 15 | } & React.ButtonHTMLAttributes; 16 | 17 | export const Button = forwardRef( 18 | ( 19 | { 20 | icon = null, 21 | disabled = false, 22 | children, 23 | hierarchy = "primary", 24 | size = "medium", 25 | className, 26 | ...rest 27 | }, 28 | ref, 29 | ) => ( 30 | 51 | ), 52 | ); 53 | -------------------------------------------------------------------------------- /frontend/src/components/Dropdown/Dropdown.css.ts: -------------------------------------------------------------------------------- 1 | import { globalStyle, style } from "@vanilla-extract/css"; 2 | 3 | import { font } from "@/styles/font.css"; 4 | import { vars } from "@/styles/theme.css"; 5 | 6 | export const triggerStyles = style({ 7 | all: "unset", 8 | borderRadius: 6, 9 | 10 | selectors: { 11 | '&[data-state="open"]': { 12 | backgroundColor: vars.color.gray3, 13 | }, 14 | }, 15 | }); 16 | 17 | export const dropdownMenuContentStyles = style({ 18 | minWidth: 220, 19 | backgroundColor: vars.color.gray3, 20 | border: `1px solid ${vars.color.gray4}`, 21 | borderRadius: 6, 22 | padding: 3, 23 | boxShadow: `0 0 10px 2px #12121187`, 24 | }); 25 | 26 | export const dropdownMenuSubContentStyles = dropdownMenuContentStyles; 27 | 28 | export const dropdownMenuItemStyles = style([ 29 | font.textSmMedium, 30 | { 31 | display: "flex", 32 | borderRadius: 3, 33 | alignItems: "center", 34 | height: 32, 35 | padding: "0 3px", 36 | position: "relative", 37 | paddingLeft: 32, 38 | userSelect: "none", 39 | outline: "none", 40 | color: vars.color.gray12, 41 | cursor: "pointer", 42 | 43 | selectors: { 44 | "&[data-highlighted]": { 45 | backgroundColor: vars.color.gray4, 46 | }, 47 | }, 48 | }, 49 | ]); 50 | 51 | export const dropdownMenuItemIconStyles = style({ 52 | position: "absolute", 53 | left: 8, 54 | top: 8, 55 | color: vars.color.gray9, 56 | width: 16, 57 | height: 16, 58 | }); 59 | 60 | globalStyle(`${dropdownMenuItemStyles}:hover ${dropdownMenuItemIconStyles}`, { 61 | color: vars.color.primary9, 62 | }); 63 | 64 | export const dropdownMenuSubTriggerStyles = dropdownMenuItemStyles; 65 | 66 | export const dropdownMenuSeparatorStyles = style({ 67 | height: 1, 68 | backgroundColor: vars.color.gray4, 69 | margin: 5, 70 | }); 71 | 72 | export const dropdownMenuRightSlotStyles = style({ 73 | display: "flex", 74 | marginLeft: "auto", 75 | paddingLeft: 20, 76 | top: 4, 77 | color: vars.color.gray9, 78 | }); 79 | -------------------------------------------------------------------------------- /frontend/src/components/Dropdown/Dropdown.tsx: -------------------------------------------------------------------------------- 1 | import * as DropdownMenu from "@radix-ui/react-dropdown-menu"; 2 | import React from "react"; 3 | 4 | import { 5 | dropdownMenuContentStyles, 6 | dropdownMenuItemStyles, 7 | triggerStyles, 8 | } from "./Dropdown.css"; 9 | 10 | type DropdownProps = { 11 | children: React.ReactNode; 12 | content: React.ReactNode; 13 | } & React.ComponentProps; 14 | 15 | export const Dropdown = ({ children, content, ...rest }: DropdownProps) => { 16 | return ( 17 | 18 | 19 | 22 | 23 | {content} 24 | 25 | ); 26 | }; 27 | 28 | export { dropdownMenuItemStyles, dropdownMenuContentStyles }; 29 | -------------------------------------------------------------------------------- /frontend/src/components/Icon/Icon.css.ts: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/semanser/codel/fe1e846c9e04f41209498ed0d88e92b15dcdfad2/frontend/src/components/Icon/Icon.css.ts -------------------------------------------------------------------------------- /frontend/src/components/Icon/Icon.tsx: -------------------------------------------------------------------------------- 1 | import { BrowserSVG } from "./svg/Browser"; 2 | import { CheckSVG } from "./svg/Check"; 3 | import { CheckCircleSVG } from "./svg/CheckCircle"; 4 | import { CodeSVG } from "./svg/Code"; 5 | import { EyeSVG } from "./svg/Eye"; 6 | import { EyeOffSVG } from "./svg/EyeOff"; 7 | import { MessageQuestionSVG } from "./svg/MessageQuestion"; 8 | import { TerminalSVG } from "./svg/Terminal"; 9 | 10 | export const Icon = { 11 | Browser: BrowserSVG, 12 | Check: CheckSVG, 13 | CheckCircle: CheckCircleSVG, 14 | Code: CodeSVG, 15 | Eye: EyeSVG, 16 | EyeOff: EyeOffSVG, 17 | MessageQuestion: MessageQuestionSVG, 18 | Terminal: TerminalSVG, 19 | }; 20 | -------------------------------------------------------------------------------- /frontend/src/components/Icon/svg/Browser.tsx: -------------------------------------------------------------------------------- 1 | export const BrowserSVG = (props: React.HTMLAttributes) => ( 2 | 10 | 17 | 18 | ); 19 | -------------------------------------------------------------------------------- /frontend/src/components/Icon/svg/Check.tsx: -------------------------------------------------------------------------------- 1 | export const CheckSVG = (props: React.HTMLAttributes) => ( 2 | 10 | 17 | 18 | ); 19 | -------------------------------------------------------------------------------- /frontend/src/components/Icon/svg/CheckCircle.tsx: -------------------------------------------------------------------------------- 1 | export const CheckCircleSVG = (props: React.HTMLAttributes) => ( 2 | 10 | 17 | 18 | ); 19 | -------------------------------------------------------------------------------- /frontend/src/components/Icon/svg/Code.tsx: -------------------------------------------------------------------------------- 1 | export const CodeSVG = (props: React.HTMLAttributes) => ( 2 | 10 | 17 | 18 | ); 19 | -------------------------------------------------------------------------------- /frontend/src/components/Icon/svg/Eye.tsx: -------------------------------------------------------------------------------- 1 | export const EyeSVG = (props: React.HTMLAttributes) => ( 2 | 10 | 11 | 12 | 19 | 26 | 27 | 28 | 29 | ); 30 | -------------------------------------------------------------------------------- /frontend/src/components/Icon/svg/EyeOff.tsx: -------------------------------------------------------------------------------- 1 | export const EyeOffSVG = (props: React.HTMLAttributes) => ( 2 | 10 | 17 | 18 | ); 19 | -------------------------------------------------------------------------------- /frontend/src/components/Icon/svg/MessageQuestion.tsx: -------------------------------------------------------------------------------- 1 | export const MessageQuestionSVG = (props: React.HTMLAttributes) => ( 2 | 10 | 17 | 18 | ); 19 | -------------------------------------------------------------------------------- /frontend/src/components/Icon/svg/Terminal.tsx: -------------------------------------------------------------------------------- 1 | export const TerminalSVG = (props: React.HTMLAttributes) => ( 2 | 10 | 17 | 18 | ); 19 | -------------------------------------------------------------------------------- /frontend/src/components/Messages/Message/Message.css.ts: -------------------------------------------------------------------------------- 1 | import { globalStyle, style, styleVariants } from "@vanilla-extract/css"; 2 | 3 | import { font } from "@/styles/font.css"; 4 | import { vars } from "@/styles/theme.css"; 5 | 6 | export const wrapperStyles = style({ 7 | display: "flex", 8 | gap: 12, 9 | }); 10 | 11 | export const avatarStyles = style({ 12 | borderRadius: "50%", 13 | border: `1px solid ${vars.color.gray4}`, 14 | }); 15 | 16 | export const rightColumnStyles = style({ 17 | display: "flex", 18 | flexDirection: "column", 19 | gap: 6, 20 | flex: 1, 21 | }); 22 | 23 | export const timeStyles = style([ 24 | font.textXsRegular, 25 | { 26 | color: vars.color.gray8, 27 | }, 28 | ]); 29 | 30 | const messageStylesBase = style([ 31 | font.textSmRegular, 32 | { 33 | padding: "10px 14px", 34 | borderRadius: "0 8px 8px 8px", 35 | display: "flex", 36 | justifyContent: "space-between", 37 | alignItems: "center", 38 | cursor: "pointer", 39 | color: vars.color.primary12, 40 | }, 41 | ]); 42 | 43 | export const messageStyles = styleVariants({ 44 | Input: [ 45 | messageStylesBase, 46 | { 47 | border: `1px solid ${vars.color.gray3}`, 48 | background: vars.color.gray1, 49 | cursor: "auto", 50 | color: vars.color.gray12, 51 | }, 52 | ], 53 | Regular: [ 54 | messageStylesBase, 55 | { 56 | border: `1px solid ${vars.color.gray4}`, 57 | background: vars.color.gray3, 58 | ":hover": { 59 | background: vars.color.gray5, 60 | border: `1px solid ${vars.color.gray6}`, 61 | }, 62 | }, 63 | ], 64 | Failed: [ 65 | messageStylesBase, 66 | { 67 | border: `1px solid ${vars.color.error3}`, 68 | background: vars.color.error1, 69 | ":hover": { 70 | background: vars.color.error2, 71 | border: `1px solid ${vars.color.error6}`, 72 | }, 73 | }, 74 | ], 75 | }); 76 | 77 | export const contentStyles = style({ 78 | display: "flex", 79 | gap: 10, 80 | alignItems: "center", 81 | }); 82 | 83 | globalStyle(`${messageStyles} button`, { 84 | opacity: 0, 85 | }); 86 | 87 | const iconStylesBase = style({ 88 | height: 16, 89 | }); 90 | 91 | export const iconStyles = styleVariants({ 92 | Regular: [iconStylesBase], 93 | Failed: [iconStylesBase], 94 | }); 95 | 96 | globalStyle(`${iconStyles.Regular} svg`, { 97 | width: 16, 98 | height: 16, 99 | color: vars.color.primary10, 100 | }); 101 | 102 | globalStyle(`${iconStyles.Failed} svg`, { 103 | width: 16, 104 | height: 16, 105 | color: vars.color.error9, 106 | }); 107 | 108 | export const outputStyles = style([ 109 | font.textSmRegular, 110 | { 111 | padding: "10px 14px", 112 | borderRadius: 8, 113 | display: "flex", 114 | justifyContent: "space-between", 115 | alignItems: "center", 116 | color: vars.color.gray11, 117 | marginTop: -2, 118 | border: `1px solid ${vars.color.gray3}`, 119 | background: vars.color.gray2, 120 | }, 121 | ]); 122 | -------------------------------------------------------------------------------- /frontend/src/components/Messages/Message/Message.tsx: -------------------------------------------------------------------------------- 1 | import { formatDistanceToNowStrict } from "date-fns"; 2 | import { useState } from "react"; 3 | 4 | import logoPng from "@/assets/logo.png"; 5 | import mePng from "@/assets/me.png"; 6 | import { Button } from "@/components/Button/Button"; 7 | import { Icon } from "@/components/Icon/Icon"; 8 | import { TaskStatus, TaskType } from "@/generated/graphql"; 9 | 10 | import { 11 | avatarStyles, 12 | contentStyles, 13 | iconStyles, 14 | messageStyles, 15 | outputStyles, 16 | rightColumnStyles, 17 | timeStyles, 18 | wrapperStyles, 19 | } from "./Message.css"; 20 | 21 | type MessageProps = { 22 | message: string; 23 | time: Date; 24 | type: TaskType; 25 | status: TaskStatus; 26 | output: string; 27 | }; 28 | 29 | export const Message = ({ 30 | time, 31 | message, 32 | type, 33 | status, 34 | output, 35 | }: MessageProps) => { 36 | const [isExpanded, setIsExpanded] = useState(false); 37 | 38 | const toggleExpand = () => { 39 | setIsExpanded((prev) => !prev); 40 | }; 41 | 42 | return ( 43 |
44 | avatar 51 |
52 |
53 | {formatDistanceToNowStrict(new Date(time), { addSuffix: true })} 54 |
55 |
65 |
66 | 73 | {getIcon(type)} 74 | 75 |
{message}
76 |
77 | {status === TaskStatus.InProgress && ( 78 | 81 | )} 82 |
83 | {isExpanded &&
{output}
} 84 |
85 |
86 | ); 87 | }; 88 | 89 | const getIcon = (type: TaskType) => { 90 | let icon = null; 91 | 92 | switch (type) { 93 | case TaskType.Browser: 94 | icon = ; 95 | break; 96 | case TaskType.Terminal: 97 | icon = ; 98 | break; 99 | case TaskType.Code: 100 | icon = ; 101 | break; 102 | case TaskType.Ask: 103 | icon = ; 104 | break; 105 | case TaskType.Done: 106 | icon = ; 107 | break; 108 | } 109 | 110 | return icon; 111 | }; 112 | -------------------------------------------------------------------------------- /frontend/src/components/Messages/Messages.css.ts: -------------------------------------------------------------------------------- 1 | import { style } from "@vanilla-extract/css"; 2 | 3 | import { font } from "@/styles/font.css"; 4 | import { vars } from "@/styles/theme.css"; 5 | 6 | export const messagesWrapper = style({ 7 | position: "relative", 8 | height: "100%", 9 | }); 10 | 11 | export const messagesListWrapper = style({ 12 | display: "flex", 13 | flexDirection: "column", 14 | // 100% - height of the new message textarea - height of the title bar 15 | maxHeight: "calc(100% - 100px - 90px)", 16 | overflowY: "scroll", 17 | gap: 22, 18 | }); 19 | 20 | export const titleStyles = style([ 21 | font.textSmSemibold, 22 | { 23 | display: "flex", 24 | alignItems: "center", 25 | justifyContent: "center", 26 | gap: 12, 27 | color: vars.color.gray11, 28 | textAlign: "center", 29 | marginBottom: 16, 30 | }, 31 | ]); 32 | 33 | export const modelStyles = style({ 34 | color: vars.color.gray10, 35 | }); 36 | 37 | export const newMessageTextarea = style([ 38 | font.textSmMedium, 39 | { 40 | position: "absolute", 41 | bottom: 0, 42 | height: 120, 43 | left: 0, 44 | backgroundColor: vars.color.gray4, 45 | border: `1px solid ${vars.color.gray5}`, 46 | borderRadius: "0 0 6px 6px", 47 | width: "calc(100% + 32px)", 48 | color: vars.color.gray12, 49 | padding: 16, 50 | margin: -16, 51 | boxShadow: `0 -20px 30px 10px ${vars.color.gray2}`, 52 | resize: "none", 53 | 54 | ":focus": { 55 | outline: "none", 56 | borderColor: vars.color.primary5, 57 | }, 58 | 59 | ":disabled": { 60 | backgroundColor: vars.color.gray3, 61 | borderColor: vars.color.gray4, 62 | }, 63 | }, 64 | ]); 65 | -------------------------------------------------------------------------------- /frontend/src/components/Messages/Messages.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from "react"; 2 | 3 | import { FlowStatus, Model, Task } from "@/generated/graphql"; 4 | 5 | import { Button } from "../Button/Button"; 6 | import { Message } from "./Message/Message"; 7 | import { 8 | messagesListWrapper, 9 | messagesWrapper, 10 | modelStyles, 11 | newMessageTextarea, 12 | titleStyles, 13 | } from "./Messages.css"; 14 | 15 | type MessagesProps = { 16 | tasks: Task[]; 17 | name: string; 18 | onSubmit: (message: string) => void; 19 | onFlowStop: () => void; 20 | flowStatus?: FlowStatus; 21 | isNew?: boolean; 22 | model?: Model; 23 | }; 24 | 25 | export const Messages = ({ 26 | tasks, 27 | name, 28 | flowStatus, 29 | onSubmit, 30 | isNew, 31 | onFlowStop, 32 | model, 33 | }: MessagesProps) => { 34 | const messages = 35 | tasks.map((task) => ({ 36 | id: task.id, 37 | message: task.message, 38 | time: task.createdAt, 39 | status: task.status, 40 | type: task.type, 41 | output: task.results, 42 | })) ?? []; 43 | 44 | const messagesRef = useRef(null); 45 | const autoScrollEnabledRef = useRef(true); 46 | 47 | const handleKeyPress = (e: React.KeyboardEvent) => { 48 | if (e.key === "Enter" && !e.shiftKey) { 49 | e.preventDefault(); 50 | 51 | const message = e.currentTarget.value; 52 | 53 | e.currentTarget.value = ""; 54 | 55 | onSubmit(message); 56 | } 57 | }; 58 | 59 | useEffect(() => { 60 | const messagesDiv = messagesRef.current; 61 | if (!messagesDiv) return; 62 | 63 | const scrollHandler = () => { 64 | if ( 65 | messagesDiv.scrollTop + messagesDiv.clientHeight + 50 >= 66 | messagesDiv.scrollHeight 67 | ) { 68 | autoScrollEnabledRef.current = true; 69 | } else { 70 | autoScrollEnabledRef.current = false; 71 | } 72 | }; 73 | 74 | messagesDiv.addEventListener("scroll", scrollHandler); 75 | 76 | return () => { 77 | messagesDiv.removeEventListener("scroll", scrollHandler); 78 | }; 79 | }, []); 80 | 81 | useEffect(() => { 82 | const messagesDiv = messagesRef.current; 83 | if (!messagesDiv) return; 84 | 85 | if (autoScrollEnabledRef.current) { 86 | messagesDiv.scrollTop = messagesDiv.scrollHeight; 87 | } 88 | }, [tasks]); 89 | 90 | const isFlowFinished = flowStatus === FlowStatus.Finished; 91 | 92 | return ( 93 |
94 | {name && ( 95 |
96 | {name} 97 | {` - ${model?.id}`}{" "} 98 | {isFlowFinished ? ( 99 | " (Finished)" 100 | ) : ( 101 | 104 | )}{" "} 105 |
106 | )} 107 |
108 | {messages.map((message) => ( 109 | 110 | ))} 111 |
112 |