├── public
├── robots.txt
├── manifest.json
└── favicon.svg
├── .nvmrc
├── src
├── components
│ ├── footer
│ │ ├── footer.module.css
│ │ └── footer.tsx
│ ├── header
│ │ ├── header.module.css
│ │ └── header.tsx
│ ├── card
│ │ └── card.tsx
│ ├── router-head
│ │ └── router-head.tsx
│ ├── browser-state
│ │ └── browser-state.tsx
│ ├── loading
│ │ └── loading.tsx
│ ├── question
│ │ └── question.tsx
│ ├── prompt
│ │ └── prompt.tsx
│ ├── actions
│ │ └── actions.tsx
│ └── render-result
│ │ └── render-result.tsx
├── constants
│ └── prisma-client.ts
├── routes
│ ├── layout.tsx
│ ├── api
│ │ └── v1
│ │ │ ├── launch
│ │ │ └── index.tsx
│ │ │ ├── run
│ │ │ └── index.ts
│ │ │ └── ai
│ │ │ └── index.ts
│ ├── service-worker.ts
│ └── index.tsx
├── functions
│ ├── get-browser-state.ts
│ ├── questions.ts
│ ├── remove-nth-query-params.ts
│ ├── stream-completion.tsx
│ └── run-action.ts
├── global.css
├── entry.dev.tsx
├── entry.preview.tsx
├── plugins
│ ├── index.ts
│ ├── exec.ts
│ ├── fs.ts
│ └── github.ts
├── entry.ssr.tsx
├── root.tsx
└── prompts
│ └── get-action.ts
├── .prettierrc.json
├── postcss.config.js
├── .vscode
└── extensions.json
├── tailwind.config.js
├── .env.template
├── docker-compose.yml
├── Dockerfile
├── docker-compose.debug.yml
├── .dockerignore
├── vite.config.ts
├── .eslintignore
├── .prettierignore
├── .gitignore
├── tsconfig.json
├── prisma
└── schema.prisma
├── .eslintrc.cjs
├── package.json
└── README.md
/public/robots.txt:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/.nvmrc:
--------------------------------------------------------------------------------
1 | v18.14.0
2 |
--------------------------------------------------------------------------------
/src/components/footer/footer.module.css:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/.prettierrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "singleQuote": true
3 | }
4 |
--------------------------------------------------------------------------------
/src/components/header/header.module.css:
--------------------------------------------------------------------------------
1 | .header {
2 | }
3 |
--------------------------------------------------------------------------------
/postcss.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | plugins: {
3 | tailwindcss: {},
4 | autoprefixer: {},
5 | },
6 | };
7 |
--------------------------------------------------------------------------------
/.vscode/extensions.json:
--------------------------------------------------------------------------------
1 | {
2 | "recommendations": ["dbaeumer.vscode-eslint", "unifiedjs.vscode-mdx"],
3 | "unwantedRecommendations": []
4 | }
5 |
--------------------------------------------------------------------------------
/src/components/footer/footer.tsx:
--------------------------------------------------------------------------------
1 | import { component$ } from '@builder.io/qwik';
2 |
3 | export default component$(() => {
4 | return ;
5 | });
6 |
--------------------------------------------------------------------------------
/src/constants/prisma-client.ts:
--------------------------------------------------------------------------------
1 | import { PrismaClient } from '@prisma/client';
2 | import { isServer } from '@builder.io/qwik/build';
3 |
4 | export const prismaClient = isServer ? new PrismaClient() : null;
5 |
--------------------------------------------------------------------------------
/tailwind.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('tailwindcss').Config} */
2 | module.exports = {
3 | content: ['./src/**/*.{js,ts,jsx,tsx,mdx}'],
4 | theme: {
5 | extend: {},
6 | },
7 | plugins: [],
8 | };
9 |
--------------------------------------------------------------------------------
/src/components/header/header.tsx:
--------------------------------------------------------------------------------
1 | import { component$ } from '@builder.io/qwik';
2 | import styles from './header.module.css';
3 |
4 | export default component$(() => {
5 | return ;
6 | });
7 |
--------------------------------------------------------------------------------
/.env.template:
--------------------------------------------------------------------------------
1 | OPENAI_KEY=sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
2 | MODEL=gpt-4
3 |
4 | # Postgres DB URL
5 | DATABASE_URL="postgres://postgres:xxx@xxx:5432/postgres?schema=public"
6 |
7 | # Optional - for github plugin support
8 | GITHUB_USERNAME=xxx
9 | GITHUB_PASSWORD=xxx
--------------------------------------------------------------------------------
/docker-compose.yml:
--------------------------------------------------------------------------------
1 | version: '3.4'
2 |
3 | services:
4 | builderioaiagent:
5 | image: builderioaiagent
6 | build:
7 | context: .
8 | dockerfile: ./Dockerfile
9 | environment:
10 | NODE_ENV: production
11 | ports:
12 | - 5173:5173
13 |
--------------------------------------------------------------------------------
/public/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://json.schemastore.org/web-manifest-combined.json",
3 | "name": "qwik-project-name",
4 | "short_name": "Welcome to Qwik",
5 | "start_url": ".",
6 | "display": "standalone",
7 | "background_color": "#fff",
8 | "description": "A Qwik project app."
9 | }
10 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM node:lts-alpine
2 | ENV NODE_ENV=production
3 | WORKDIR /usr/src/app
4 | COPY ["package.json", "package-lock.json*", "npm-shrinkwrap.json*", "./"]
5 | RUN npm install --production --silent && mv node_modules ../
6 | COPY . .
7 | EXPOSE 5173
8 | RUN chown -R node /usr/src/app
9 | USER node
10 | CMD ["npm", "start"]
11 |
--------------------------------------------------------------------------------
/src/components/card/card.tsx:
--------------------------------------------------------------------------------
1 | import { Slot, component$ } from '@builder.io/qwik';
2 |
3 | export const Card = component$((props: { class?: string }) => {
4 | return (
5 |
10 |
11 |
12 | );
13 | });
14 |
--------------------------------------------------------------------------------
/docker-compose.debug.yml:
--------------------------------------------------------------------------------
1 | version: '3.4'
2 |
3 | services:
4 | builderioaiagent:
5 | image: builderioaiagent
6 | build:
7 | context: .
8 | dockerfile: ./Dockerfile
9 | environment:
10 | NODE_ENV: development
11 | ports:
12 | - 5173:5173
13 | - 9229:9229
14 | command: ["node", "--inspect=0.0.0.0:9229", "index.js"]
15 |
--------------------------------------------------------------------------------
/src/routes/layout.tsx:
--------------------------------------------------------------------------------
1 | import { component$, Slot } from '@builder.io/qwik';
2 |
3 | import Header from '~/components/header/header';
4 | import Footer from '~/components/footer/footer';
5 |
6 | export default component$(() => {
7 | return (
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 | );
16 | });
17 |
--------------------------------------------------------------------------------
/src/functions/get-browser-state.ts:
--------------------------------------------------------------------------------
1 | import { server$ } from '@builder.io/qwik-city';
2 | import type { BrowserState } from '@prisma/client';
3 | import { prismaClient } from '~/constants/prisma-client';
4 |
5 | export const getBrowserState = server$(
6 | async (): Promise => {
7 | const browserState = await prismaClient!.browserState.findFirst();
8 | return browserState;
9 | }
10 | );
11 |
--------------------------------------------------------------------------------
/.dockerignore:
--------------------------------------------------------------------------------
1 | **/.classpath
2 | **/.dockerignore
3 | **/.env
4 | **/.git
5 | **/.gitignore
6 | **/.project
7 | **/.settings
8 | **/.toolstarget
9 | **/.vs
10 | **/.vscode
11 | **/*.*proj.user
12 | **/*.dbmdl
13 | **/*.jfm
14 | **/charts
15 | **/docker-compose*
16 | **/compose*
17 | **/Dockerfile*
18 | **/node_modules
19 | **/npm-debug.log
20 | **/obj
21 | **/secrets.dev.yaml
22 | **/values.dev.yaml
23 | LICENSE
24 | README.md
25 |
--------------------------------------------------------------------------------
/vite.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from 'vite';
2 | import { qwikVite } from '@builder.io/qwik/optimizer';
3 | import { qwikCity } from '@builder.io/qwik-city/vite';
4 | import tsconfigPaths from 'vite-tsconfig-paths';
5 |
6 | export default defineConfig(() => {
7 | return {
8 | plugins: [qwikCity(), qwikVite(), tsconfigPaths()],
9 | preview: {
10 | headers: {
11 | 'Cache-Control': 'public, max-age=600',
12 | },
13 | },
14 | };
15 | });
16 |
--------------------------------------------------------------------------------
/src/global.css:
--------------------------------------------------------------------------------
1 | /**
2 | * Tailwind CSS imports
3 | * View the full documentation at https://tailwindcss.com
4 | */
5 | @tailwind base;
6 | @tailwind components;
7 | @tailwind utilities;
8 |
9 | /**
10 | * WHAT IS THIS FILE?
11 | *
12 | * Globally applied styles. No matter which components are in the page or matching route,
13 | * the styles in here will be applied to the Document, without any sort of CSS scoping.
14 | *
15 | */
16 |
17 | :root {
18 | @apply bg-gray-100;
19 | }
20 |
--------------------------------------------------------------------------------
/src/routes/api/v1/launch/index.tsx:
--------------------------------------------------------------------------------
1 | import type { RequestEvent } from '@builder.io/qwik-city';
2 | import { z } from '@builder.io/qwik-city';
3 |
4 | const schema = z.object({
5 | prompt: z.string(),
6 | });
7 |
8 | export async function onPost(request: RequestEvent) {
9 | const body = await request.parseBody();
10 | const value = schema.parse(body);
11 | return request.json(200, {
12 | redirect: `http://localhost:5173/?run=${encodeURIComponent(value.prompt)}`,
13 | });
14 | }
15 |
--------------------------------------------------------------------------------
/.eslintignore:
--------------------------------------------------------------------------------
1 | **/*.log
2 | **/.DS_Store
3 | *.
4 | .vscode/settings.json
5 | .history
6 | .yarn
7 | bazel-*
8 | bazel-bin
9 | bazel-out
10 | bazel-qwik
11 | bazel-testlogs
12 | dist
13 | dist-dev
14 | lib
15 | lib-types
16 | etc
17 | external
18 | node_modules
19 | temp
20 | tsc-out
21 | tsdoc-metadata.json
22 | target
23 | output
24 | rollup.config.js
25 | build
26 | .cache
27 | .vscode
28 | .rollup.cache
29 | dist
30 | tsconfig.tsbuildinfo
31 | vite.config.ts
32 | *.spec.tsx
33 | *.spec.ts
34 | .netlify
35 | pnpm-lock.yaml
36 | package-lock.json
37 | yarn.lock
38 | server
39 |
--------------------------------------------------------------------------------
/.prettierignore:
--------------------------------------------------------------------------------
1 | **/*.log
2 | **/.DS_Store
3 | *.
4 | .vscode/settings.json
5 | .history
6 | .yarn
7 | bazel-*
8 | bazel-bin
9 | bazel-out
10 | bazel-qwik
11 | bazel-testlogs
12 | dist
13 | dist-dev
14 | lib
15 | lib-types
16 | etc
17 | external
18 | node_modules
19 | temp
20 | tsc-out
21 | tsdoc-metadata.json
22 | target
23 | output
24 | rollup.config.js
25 | build
26 | .cache
27 | .vscode
28 | .rollup.cache
29 | dist
30 | tsconfig.tsbuildinfo
31 | vite.config.ts
32 | *.spec.tsx
33 | *.spec.ts
34 | .netlify
35 | pnpm-lock.yaml
36 | package-lock.json
37 | yarn.lock
38 | server
39 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Build
2 | /dist
3 | /lib
4 | /lib-types
5 | /server
6 | /working-dir
7 |
8 | # Development
9 | node_modules
10 |
11 | # Cache
12 | .cache
13 | .mf
14 | .rollup.cache
15 | tsconfig.tsbuildinfo
16 |
17 | # Logs
18 | logs
19 | *.log
20 | npm-debug.log*
21 | yarn-debug.log*
22 | yarn-error.log*
23 | pnpm-debug.log*
24 | lerna-debug.log*
25 |
26 | # Editor
27 | .vscode/*
28 | !.vscode/extensions.json
29 | .idea
30 | .DS_Store
31 | *.suo
32 | *.ntvs*
33 | *.njsproj
34 | *.sln
35 | *.sw?
36 |
37 | # Yarn
38 | .yarn/*
39 | !.yarn/releases
40 |
41 |
42 | .env
43 |
44 | .cookies.json
--------------------------------------------------------------------------------
/src/entry.dev.tsx:
--------------------------------------------------------------------------------
1 | /*
2 | * WHAT IS THIS FILE?
3 | *
4 | * Development entry point using only client-side modules:
5 | * - Do not use this mode in production!
6 | * - No SSR
7 | * - No portion of the application is pre-rendered on the server.
8 | * - All of the application is running eagerly in the browser.
9 | * - More code is transferred to the browser than in SSR mode.
10 | * - Optimizer/Serialization/Deserialization code is not exercised!
11 | */
12 | import { render, type RenderOptions } from '@builder.io/qwik';
13 | import Root from './root';
14 |
15 | export default function (opts: RenderOptions) {
16 | return render(document, , opts);
17 | }
18 |
--------------------------------------------------------------------------------
/src/entry.preview.tsx:
--------------------------------------------------------------------------------
1 | /*
2 | * WHAT IS THIS FILE?
3 | *
4 | * It's the bundle entry point for `npm run preview`.
5 | * That is, serving your app built in production mode.
6 | *
7 | * Feel free to modify this file, but don't remove it!
8 | *
9 | * Learn more about Vite's preview command:
10 | * - https://vitejs.dev/config/preview-options.html#preview-options
11 | *
12 | */
13 | import { createQwikCity } from '@builder.io/qwik-city/middleware/node';
14 | import qwikCityPlan from '@qwik-city-plan';
15 | import render from './entry.ssr';
16 |
17 | /**
18 | * The default export is the QwikCity adapter used by Vite preview.
19 | */
20 | export default createQwikCity({ render, qwikCityPlan });
21 |
--------------------------------------------------------------------------------
/src/functions/questions.ts:
--------------------------------------------------------------------------------
1 | import { server$ } from '@builder.io/qwik-city';
2 | import { prismaClient } from '~/constants/prisma-client';
3 |
4 | const workflowId = 'test';
5 |
6 | export const addQuestion = server$(
7 | async (question: string, answer: string, workflow = workflowId) => {
8 | return await prismaClient!.answers.create({
9 | data: {
10 | question,
11 | answer,
12 | workflow_id: workflow,
13 | },
14 | });
15 | }
16 | );
17 |
18 | export const getQuestions = server$(async (workflow = workflowId) => {
19 | return await prismaClient!.answers.findMany({
20 | where: {
21 | workflow_id: workflow,
22 | },
23 | });
24 | });
25 |
--------------------------------------------------------------------------------
/src/routes/service-worker.ts:
--------------------------------------------------------------------------------
1 | /*
2 | * WHAT IS THIS FILE?
3 | *
4 | * The service-worker.ts file is used to have state of the art prefetching.
5 | * https://qwik.builder.io/qwikcity/prefetching/overview/
6 | *
7 | * Qwik uses a service worker to speed up your site and reduce latency, ie, not used in the traditional way of offline.
8 | * You can also use this file to add more functionality that runs in the service worker.
9 | */
10 | import { setupServiceWorker } from '@builder.io/qwik-city/service-worker';
11 |
12 | setupServiceWorker();
13 |
14 | addEventListener('install', () => self.skipWaiting());
15 |
16 | addEventListener('activate', () => self.clients.claim());
17 |
18 | declare const self: ServiceWorkerGlobalScope;
19 |
--------------------------------------------------------------------------------
/src/plugins/index.ts:
--------------------------------------------------------------------------------
1 | import type { Page } from 'puppeteer';
2 | // import github from './github';
3 | import exec from './exec';
4 |
5 | type PluginContext = {
6 | page?: Page;
7 | };
8 |
9 | export type PluginAction<
10 | T extends Record,
11 | Name extends string
12 | > = {
13 | name: Name;
14 | description: string;
15 | example?: T;
16 | handler: (info: {
17 | action: T & { action: Name };
18 | context: PluginContext;
19 | }) => Promise;
20 | };
21 |
22 | export type Plugin = {
23 | name: string;
24 | requires?: string[];
25 | promptInfo?: string;
26 | actions: PluginAction[];
27 | };
28 |
29 | export const plugins: Plugin[] = [exec()].filter(Boolean);
30 |
--------------------------------------------------------------------------------
/src/functions/remove-nth-query-params.ts:
--------------------------------------------------------------------------------
1 | export function removeNthQueryParams(url: string, n: number) {
2 | // Parse the URL using the URL constructor
3 | const parsedUrl = new URL(url);
4 |
5 | // Get the search parameters from the parsed URL
6 | const searchParams = parsedUrl.searchParams;
7 |
8 | // Convert the search parameters to an array of key-value pairs
9 | const paramsArray = Array.from(searchParams.entries());
10 |
11 | // Clear all existing search parameters
12 | searchParams.forEach((value, key) => {
13 | searchParams.delete(key);
14 | });
15 |
16 | // Add back only the first n query parameters
17 | for (let i = 0; i < Math.min(n, paramsArray.length); i++) {
18 | const [key, value] = paramsArray[i];
19 | searchParams.append(key, value);
20 | }
21 |
22 | return parsedUrl.href;
23 | }
24 |
--------------------------------------------------------------------------------
/src/entry.ssr.tsx:
--------------------------------------------------------------------------------
1 | /**
2 | * WHAT IS THIS FILE?
3 | *
4 | * SSR entry point, in all cases the application is render outside the browser, this
5 | * entry point will be the common one.
6 | *
7 | * - Server (express, cloudflare...)
8 | * - npm run start
9 | * - npm run preview
10 | * - npm run build
11 | *
12 | */
13 | import {
14 | renderToStream,
15 | type RenderToStreamOptions,
16 | } from '@builder.io/qwik/server';
17 | import { manifest } from '@qwik-client-manifest';
18 | import Root from './root';
19 |
20 | export default function (opts: RenderToStreamOptions) {
21 | return renderToStream(, {
22 | manifest,
23 | ...opts,
24 | // Use container attributes to set attributes on the html tag.
25 | containerAttributes: {
26 | lang: 'en-us',
27 | ...opts.containerAttributes,
28 | },
29 | });
30 | }
31 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "allowJs": true,
4 | "target": "ES2017",
5 | "module": "ES2020",
6 | "lib": ["es2020", "DOM", "WebWorker", "DOM.Iterable"],
7 | "jsx": "react-jsx",
8 | "jsxImportSource": "@builder.io/qwik",
9 | "strict": true,
10 | "forceConsistentCasingInFileNames": true,
11 | "resolveJsonModule": true,
12 | "moduleResolution": "node",
13 | "esModuleInterop": true,
14 | "skipLibCheck": true,
15 | "incremental": true,
16 | "isolatedModules": true,
17 | "outDir": "tmp",
18 | "noEmit": true,
19 | "types": ["node", "vite/client"],
20 | "paths": {
21 | "~/*": ["./src/*"]
22 | }
23 | },
24 | "files": ["./.eslintrc.cjs", "src/functions/stream-completion.tsx", "src/functions/remove-nth-query-params.ts", "src/functions/get-browser-state.ts"],
25 | "include": ["src"]
26 | }
27 |
--------------------------------------------------------------------------------
/src/routes/api/v1/run/index.ts:
--------------------------------------------------------------------------------
1 | import type { RequestEvent } from '@builder.io/qwik-city';
2 | import { z } from '@builder.io/qwik-city';
3 | import { attempt } from '../ai';
4 | import { runAction } from '~/functions/run-action';
5 |
6 | const schema = z.object({
7 | action: z.object({
8 | id: z.string(),
9 | data: z.any(),
10 | }),
11 | persist: z.boolean().optional(),
12 | });
13 |
14 | export const onPost = async (request: RequestEvent) => {
15 | const body = await request.parseBody();
16 | const values = attempt(() => schema.parse(body));
17 | if (values instanceof Error) {
18 | return request.json(401, { error: values.message });
19 | }
20 | const { action, persist } = values;
21 | const result = await runAction(
22 | {
23 | data: action.data,
24 | id: BigInt(action.id),
25 | },
26 | persist
27 | );
28 | return request.json(200, { result });
29 | };
30 |
--------------------------------------------------------------------------------
/public/favicon.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/root.tsx:
--------------------------------------------------------------------------------
1 | import { component$ } from '@builder.io/qwik';
2 | import {
3 | QwikCityProvider,
4 | RouterOutlet,
5 | ServiceWorkerRegister,
6 | } from '@builder.io/qwik-city';
7 | import { RouterHead } from './components/router-head/router-head';
8 | import { config } from 'dotenv';
9 | config();
10 |
11 | import './global.css';
12 |
13 | export default component$(() => {
14 | /**
15 | * The root of a QwikCity site always start with the component,
16 | * immediately followed by the document's and .
17 | *
18 | * Dont remove the `` and `` elements.
19 | */
20 |
21 | return (
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 | );
34 | });
35 |
--------------------------------------------------------------------------------
/src/components/router-head/router-head.tsx:
--------------------------------------------------------------------------------
1 | import { component$ } from '@builder.io/qwik';
2 | import { useDocumentHead, useLocation } from '@builder.io/qwik-city';
3 |
4 | /**
5 | * The RouterHead component is placed inside of the document `` element.
6 | */
7 | export const RouterHead = component$(() => {
8 | const head = useDocumentHead();
9 | const loc = useLocation();
10 |
11 | return (
12 | <>
13 | {head.title}
14 |
15 |
16 |
17 |
18 |
19 | {head.meta.map((m) => (
20 |
21 | ))}
22 |
23 | {head.links.map((l) => (
24 |
25 | ))}
26 |
27 | {head.styles.map((s) => (
28 |
29 | ))}
30 | >
31 | );
32 | });
33 |
--------------------------------------------------------------------------------
/prisma/schema.prisma:
--------------------------------------------------------------------------------
1 | generator client {
2 | provider = "prisma-client-js"
3 | }
4 |
5 | datasource db {
6 | provider = "postgresql"
7 | url = env("DATABASE_URL")
8 | }
9 |
10 | model Answers {
11 | id BigInt @id @default(autoincrement())
12 | created_at DateTime? @default(now()) @db.Timestamptz(6)
13 | question String?
14 | answer String?
15 | workflow_id String
16 | }
17 |
18 | model Actions {
19 | id BigInt @id @default(autoincrement())
20 | created_at DateTime? @default(now()) @db.Timestamptz(6)
21 | workflow_id String
22 | data Json
23 | result String?
24 | }
25 |
26 | model Prompt {
27 | id BigInt @id @default(autoincrement())
28 | created_at DateTime? @default(now()) @db.Timestamptz(6)
29 | text String?
30 | }
31 |
32 | model BrowserState {
33 | id BigInt @id @default(autoincrement())
34 | created_at DateTime? @default(now()) @db.Timestamptz(6)
35 | url String?
36 | html String?
37 | workflow_id String?
38 | }
39 |
--------------------------------------------------------------------------------
/src/plugins/exec.ts:
--------------------------------------------------------------------------------
1 | import type { Plugin } from '.';
2 | import { execaCommand } from 'execa';
3 |
4 | // const basePath = process.env.FS_BASE_PATH || `${process.cwd()}/working-dir`;
5 |
6 | export default () =>
7 | ({
8 | name: 'exec',
9 | promptInfo: `
10 | When providing a shell command, be sure not to use any interactive commands, provide all options upfront.
11 | Do not run any servers, for instance do not run "npm run dev".
12 | Do all reads and writes to files (such as for code) using the shell. Do not launch an IDE or code editor.
13 | Use the directory ./working-dir as your root directory for all file reads and writes.
14 | `.trim(),
15 | actions: [
16 | {
17 | name: 'exec.shell',
18 | description: 'Execute a bash command',
19 | example: {
20 | command: 'cat ./foo.txt',
21 | },
22 | handler: async ({ action: { command } }) => {
23 | const out = await execaCommand(command, {
24 | stdio: 'inherit',
25 | shell: process.env.SHELL || true,
26 | }).catch((err) => err);
27 | return out?.all;
28 | },
29 | },
30 | ],
31 | } satisfies Plugin);
32 |
--------------------------------------------------------------------------------
/.eslintrc.cjs:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | root: true,
3 | env: {
4 | browser: true,
5 | es2021: true,
6 | node: true,
7 | },
8 | extends: [
9 | 'eslint:recommended',
10 | 'plugin:@typescript-eslint/recommended',
11 | 'plugin:qwik/recommended',
12 | ],
13 | parser: '@typescript-eslint/parser',
14 | parserOptions: {
15 | tsconfigRootDir: __dirname,
16 | project: ['./tsconfig.json'],
17 | ecmaVersion: 2021,
18 | sourceType: 'module',
19 | ecmaFeatures: {
20 | jsx: true,
21 | },
22 | },
23 | plugins: ['@typescript-eslint'],
24 | rules: {
25 | '@typescript-eslint/no-explicit-any': 'off',
26 | '@typescript-eslint/explicit-module-boundary-types': 'off',
27 | '@typescript-eslint/no-inferrable-types': 'off',
28 | '@typescript-eslint/no-non-null-assertion': 'off',
29 | '@typescript-eslint/no-empty-interface': 'off',
30 | '@typescript-eslint/no-namespace': 'off',
31 | '@typescript-eslint/no-empty-function': 'off',
32 | '@typescript-eslint/no-this-alias': 'off',
33 | '@typescript-eslint/ban-types': 'off',
34 | '@typescript-eslint/ban-ts-comment': 'off',
35 | 'prefer-spread': 'off',
36 | 'no-case-declarations': 'off',
37 | 'no-console': 'off',
38 | '@typescript-eslint/no-unused-vars': ['error'],
39 | '@typescript-eslint/consistent-type-imports': 'warn',
40 | },
41 | };
42 |
--------------------------------------------------------------------------------
/src/functions/stream-completion.tsx:
--------------------------------------------------------------------------------
1 | export async function streamCompletion(
2 | prompt: string,
3 | onData: (value: string) => void
4 | ) {
5 | const res = await fetch('/api/v1/ai', {
6 | method: 'POST',
7 | headers: {
8 | 'Content-Type': 'application/json',
9 | },
10 | body: JSON.stringify({
11 | prompt: [
12 | {
13 | content: prompt,
14 | role: 'user',
15 | },
16 | ],
17 | }),
18 | });
19 |
20 | const reader = res.body!.getReader();
21 | const decoder = new TextDecoder('utf-8');
22 | let fullString = '';
23 |
24 | // eslint-disable-next-line no-constant-condition
25 | while (true) {
26 | const { value, done } = await reader.read();
27 | if (done) {
28 | break;
29 | }
30 |
31 | const text = decoder.decode(value, { stream: true });
32 | const strings = text
33 | .split('\n\n')
34 | .map((str) => str.trim())
35 | .filter(Boolean);
36 | let newString = '';
37 | for (const string of strings) {
38 | fullString += string;
39 | const prefix = 'data: ';
40 | if (string.startsWith(prefix)) {
41 | const json = string.slice(prefix.length);
42 | if (json === '[DONE]') {
43 | break;
44 | }
45 | const content = JSON.parse(json);
46 | newString += content.choices[0].delta.content ?? '';
47 | }
48 | }
49 | onData(newString);
50 | }
51 | return fullString;
52 | }
53 |
--------------------------------------------------------------------------------
/src/components/browser-state/browser-state.tsx:
--------------------------------------------------------------------------------
1 | import { component$, useContext, useSignal, useTask$ } from '@builder.io/qwik';
2 | import { Card } from '../card/card';
3 | import { BrowserStateContext } from '~/routes';
4 | import type { BrowserState as BrowserStateType } from '@prisma/client';
5 | import { getBrowserState } from "~/functions/get-browser-state";
6 | import { Loading } from '../loading/loading';
7 |
8 | export const BrowserState = component$(() => {
9 | const browserStateContext = useContext(BrowserStateContext);
10 | const browserState = useSignal(null);
11 | const loading = useSignal(false);
12 |
13 | useTask$(async ({ track }) => {
14 | track(() => browserStateContext.value);
15 | loading.value = true;
16 | // eslint-disable-next-line qwik/valid-lexical-scope
17 | browserState.value = await getBrowserState();
18 | loading.value = false;
19 | });
20 |
21 | return (
22 | <>
23 | {loading.value && }
24 | {browserState.value && (
25 |
26 |
27 | Browser State
28 |
29 |
30 | {browserState.value.url}
31 |
32 | {browserState.value.html && (
33 | <>
34 |
35 | {browserState.value.html}
36 |
37 | >
38 | )}
39 |
40 | )}
41 | >
42 | );
43 | });
44 |
--------------------------------------------------------------------------------
/src/components/loading/loading.tsx:
--------------------------------------------------------------------------------
1 | import { component$ } from '@builder.io/qwik';
2 |
3 | export const Loading = component$(() => {
4 | return (
5 |
6 |
22 |
Loading...
23 |
24 | );
25 | });
26 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@builder.io/ai-agent",
3 | "engines": {
4 | "node": ">=15.0.0"
5 | },
6 | "private": true,
7 | "scripts": {
8 | "build": "qwik build",
9 | "build.client": "vite build",
10 | "build.preview": "vite build --ssr src/entry.preview.tsx",
11 | "build.types": "tsc --incremental --noEmit",
12 | "deploy": "echo 'Run \"npm run qwik add\" to install a server adapter'",
13 | "dev": "vite --mode ssr",
14 | "dev.debug": "node --inspect-brk ./node_modules/vite/bin/vite.js --mode ssr --force",
15 | "fmt": "prettier --write . && eslint --fix \"src/**/*.ts*\"",
16 | "fmt.check": "prettier --check .",
17 | "lint": "eslint \"src/**/*.ts*\"",
18 | "preview": "qwik build preview && vite preview --open",
19 | "start": "vite --open --mode ssr",
20 | "qwik": "qwik"
21 | },
22 | "devDependencies": {
23 | "@builder.io/qwik": "^0.102.0",
24 | "@builder.io/qwik-city": "~0.101.0",
25 | "@types/eslint": "8.37.0",
26 | "@types/node": "^18.15.9",
27 | "@typescript-eslint/eslint-plugin": "5.57.1",
28 | "@typescript-eslint/parser": "5.57.1",
29 | "autoprefixer": "^10.4.13",
30 | "eslint": "8.37.0",
31 | "eslint-plugin-qwik": "0.101.0",
32 | "postcss": "^8.4.16",
33 | "prettier": "2.8.7",
34 | "prisma": "^4.12.0",
35 | "tailwindcss": "^3.1.8",
36 | "typescript": "5.0.3",
37 | "undici": "5.21.0",
38 | "vite": "4.2.1",
39 | "vite-tsconfig-paths": "3.5.0"
40 | },
41 | "dependencies": {
42 | "@prisma/client": "^4.12.0",
43 | "cheerio": "^1.0.0-rc.12",
44 | "dotenv": "^16.0.3",
45 | "execa": "^7.1.1",
46 | "glob": "^10.1.0",
47 | "next-dark-mode": "^3.0.0",
48 | "openai": "^3.2.1",
49 | "puppeteer": "^19.8.5",
50 | "zod": "^3.21.4"
51 | }
52 | }
53 |
--------------------------------------------------------------------------------
/src/components/question/question.tsx:
--------------------------------------------------------------------------------
1 | import type { PropFunction } from '@builder.io/qwik';
2 | import { component$, useSignal, useVisibleTask$ } from '@builder.io/qwik';
3 | import { streamCompletion } from '~/functions/stream-completion';
4 | import { Loading } from '../loading/loading';
5 | import { addQuestion } from '~/functions/questions';
6 |
7 | function getFullPrompt(question: string) {
8 | return `
9 | I am going to ask a question. Please answer as if you are me. Just
10 | pretend you are Steve, a 33 year old software developer in San Francisco.
11 |
12 | How would Steve respond to the following question?
13 | Please only give the response and nothing else.
14 |
15 | The question: ${question}
16 | `;
17 | }
18 |
19 | export const Question = component$(
20 | ({
21 | question,
22 | isPartial,
23 | onUpdate$,
24 | }: {
25 | question: string;
26 | isPartial?: boolean;
27 | onUpdate$: PropFunction<(answer: string) => void>;
28 | }) => {
29 | const answer = useSignal('');
30 | const loading = useSignal(false);
31 |
32 | // TODO: checkbox to turn this off or until just a suggestion
33 | useVisibleTask$(async ({ track }) => {
34 | const partial = track(() => !isPartial);
35 |
36 | if (partial) {
37 | loading.value = true;
38 | await streamCompletion(getFullPrompt(question), (value) => {
39 | answer.value += value;
40 | });
41 | loading.value = false;
42 | }
43 | });
44 |
45 | return (
46 |
47 |
{question}
48 |
52 |
61 | {loading.value && }
62 |
63 | );
64 | }
65 | );
66 |
--------------------------------------------------------------------------------
/src/plugins/fs.ts:
--------------------------------------------------------------------------------
1 | import type { Plugin } from '.';
2 | import { access, mkdir, readFile, writeFile } from 'fs/promises';
3 | import { dirname, join, relative } from 'path';
4 | import { glob } from 'glob';
5 |
6 | const basePath = process.env.FS_BASE_PATH || `~/Desktop/gpt-assistant`;
7 |
8 | function getRealFilePath(path: string) {
9 | return join(basePath, path);
10 | }
11 |
12 | function getDiplayFilePath(path: string) {
13 | return relative(basePath, path);
14 | }
15 |
16 | async function outputFile(path: string, contents: string) {
17 | const dirPath = dirname(path);
18 | try {
19 | await access(dirPath);
20 | } catch (error) {
21 | await mkdir(dirPath, { recursive: true });
22 | }
23 | await writeFile(path, contents);
24 | }
25 |
26 | export default () =>
27 | ({
28 | name: 'fs',
29 | actions: [
30 | {
31 | name: 'fs.readFile',
32 | description: 'Read a file',
33 | example: {
34 | path: './foo.txt',
35 | },
36 | handler: async ({ action: { path } }) => {
37 | try {
38 | const contents = await readFile(getRealFilePath(path), 'utf8');
39 | return contents;
40 | } catch (err) {
41 | console.warn(err);
42 | return null;
43 | }
44 | },
45 | },
46 | {
47 | name: 'fs.writeFile',
48 | description: 'Write a file',
49 | example: {
50 | path: './foo/bar.txt',
51 | contents: 'hello world',
52 | },
53 | handler: async ({ action: { path, contents } }) => {
54 | await outputFile(getRealFilePath(path), contents);
55 | },
56 | },
57 | {
58 | name: 'fs.listFiles',
59 | description: 'List files',
60 | example: {
61 | path: '**/*.txt',
62 | },
63 | handler: async ({ action: { path } }) => {
64 | const files = await glob(path, {
65 | cwd: basePath,
66 | ignore: ['**/node_modules/**'],
67 | });
68 | return files.map((path) => getDiplayFilePath(path)).join('\n');
69 | },
70 | },
71 | ],
72 | } satisfies Plugin);
73 |
--------------------------------------------------------------------------------
/src/routes/api/v1/ai/index.ts:
--------------------------------------------------------------------------------
1 | import type { RequestEvent } from '@builder.io/qwik-city';
2 | import { OpenAIApi, Configuration } from 'openai';
3 | import type { ChatCompletionRequestMessage } from 'openai';
4 | import type { AxiosError } from 'axios';
5 | import type { IncomingMessage } from 'http';
6 | import { z } from 'zod';
7 |
8 | const schema = z.object({
9 | prompt: z.array(
10 | z.object({
11 | role: z.enum(['system', 'user', 'assistant']),
12 | content: z.string(),
13 | })
14 | ),
15 | model: z.string().optional(),
16 | number: z.number().optional(),
17 | key: z.string().optional(),
18 | });
19 |
20 | export function attempt(fn: () => T): T | ErrorType {
21 | try {
22 | return fn();
23 | } catch (err) {
24 | return err as ErrorType;
25 | }
26 | }
27 |
28 | export const onPost = async (request: RequestEvent) => {
29 | const body = await request.parseBody();
30 | const values = attempt(() => schema.parse(body));
31 |
32 | if (values instanceof Error) {
33 | return request.json(401, { error: values.message });
34 | }
35 |
36 | const { prompt, model, number } = values;
37 | const stream = await generateCompletion({ prompt, model, number });
38 |
39 | request.status(200);
40 |
41 | const response = await request.getWritableStream();
42 | const writer = response.getWriter();
43 | stream.on('data', (chunk) => {
44 | writer.write(chunk);
45 | });
46 | stream.on('end', () => {
47 | writer.close();
48 | });
49 | };
50 |
51 | function getOpenAi(key: string) {
52 | const openAi = new OpenAIApi(new Configuration({ apiKey: key }));
53 | return openAi;
54 | }
55 |
56 | export async function generateCompletion({
57 | prompt,
58 | key = process.env.OPENAI_KEY!,
59 | model,
60 | number,
61 | }: {
62 | prompt: ChatCompletionRequestMessage[];
63 | model?: string;
64 | number?: number;
65 | key?: string;
66 | }) {
67 | const openAi = getOpenAi(key);
68 | try {
69 | const completion = await openAi.createChatCompletion(
70 | {
71 | model: model || process.env.MODEL || 'gpt-4',
72 | messages: prompt,
73 | stream: true,
74 | n: number || 1,
75 | },
76 | { responseType: 'stream' }
77 | );
78 | return completion.data as unknown as IncomingMessage;
79 | } catch (err) {
80 | const error = err as AxiosError;
81 | return error.response!.data as IncomingMessage;
82 | }
83 | }
84 |
--------------------------------------------------------------------------------
/src/plugins/github.ts:
--------------------------------------------------------------------------------
1 | import type { Plugin } from '.';
2 |
3 | class NoPageError extends Error {
4 | constructor(message?: string) {
5 | super(message || 'No active browser page found');
6 | }
7 | }
8 |
9 | export default (options: { username: string; password: string }) =>
10 | ({
11 | name: 'github',
12 | requires: ['browser'],
13 | actions: [
14 | {
15 | name: 'browser.github.openRepo',
16 | description: 'Open a GitHub repo in the browser',
17 | example: {
18 | name: 'builder',
19 | },
20 | handler: async ({ action: { name }, context: { page } }) => {
21 | if (!page) {
22 | throw new NoPageError();
23 | }
24 | await page.goto('https://github.com/search');
25 | await page.type('input[name=q]', name);
26 | await page.click('#search_form button[type=submit]');
27 | await page.waitForNavigation();
28 | await page.click(`.repo-list a`);
29 | await page.waitForNavigation();
30 | },
31 | },
32 | {
33 | name: 'browser.github.login',
34 | description: 'Log in to github in the browser',
35 | handler: async ({ context: { page } }) => {
36 | if (!page) {
37 | throw new NoPageError();
38 | }
39 | await page.goto('https://github.com/login');
40 |
41 | // We are logged in already
42 | if ((await page.$('input[name=login]')) === null) {
43 | return;
44 | }
45 |
46 | await page.type('input[name=login]', options.username);
47 | await page.type('input[name=password]', options.password);
48 | await page.click('input[type=submit]');
49 | await page.waitForNavigation();
50 | await page.evaluate(() => {
51 | alert('Enter your 2fa code');
52 | });
53 | // wait for 2fa code
54 | await page.waitForNavigation({
55 | timeout: 1000 * 60 * 5,
56 | });
57 | },
58 | },
59 | {
60 | name: 'browser.github.editCurrentFile',
61 | example: {
62 | newContent: "console.log('hello world')",
63 | },
64 | description: 'Edit the currently open file in the browser',
65 | handler: async ({ action: { newContent }, context: { page } }) => {
66 | if (!page) {
67 | throw new NoPageError();
68 | }
69 | const editbutton = await page.$(
70 | '[title="Edit this file"],[aria-label="Edit this file"]'
71 | );
72 | if (editbutton) {
73 | await editbutton.click();
74 | await page.waitForNavigation();
75 | }
76 | await page.waitForSelector('.CodeMirror');
77 | await page.type('.CodeMirror-code', newContent);
78 | },
79 | },
80 | ],
81 | } satisfies Plugin);
82 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # GPT Assistant
2 |
3 | An experiment to give an autonomous GPT agent access to a browser and have it accomplish tasks.
4 |
5 | Built with [Qwik](https://qwik.builder.io/) and [Puppeteer](https://github.com/puppeteer/puppeteer).
6 |
7 | You can see it in action below in these two video examples, with a description of how it works:
8 |
9 | ## Examples
10 |
11 | | "go to the qwik repo readme and add 'steve is awesome'" |
12 | | --------------------------------------------------------------------------------------------------------------------------------- |
13 | |
|
14 |
15 | | "Book me a table for 3 at 8pm for indian food" | "What dog breed is best for me" |
16 | | --------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ |
17 | |
|
|
18 |
19 | ## Setup
20 |
21 | ### Requirements
22 |
23 | - Node.js 14+
24 | - [OpenAI API key](#add-your-openai-api-key)
25 | - [Postgres database](#add-a-postgres-database-url)
26 | - Access to GPT-4
27 |
28 | ### Clone the repo
29 |
30 | ```bash
31 | git clone https://github.com/BuilderIO/gpt-assistant.git
32 | cd gpt-assistant
33 | ```
34 |
35 | ### Install dependencies
36 |
37 | ```bash
38 | npm install
39 | ```
40 |
41 | ### Create a `.env` file
42 |
43 | Copy the `.env.example` file to `.env`
44 |
45 | ```bash
46 | cp .env.template .env
47 | ```
48 |
49 | ### Add your OpenAI API key
50 |
51 | Retrieve your API key from [OpenAI](https://platform.openai.com/account/api-keys) and add it to the `.env` file as `OPENAI_KEY`
52 |
53 | > Note: If you haven't already, you'll have to create an account and set up billing.
54 |
55 | ```diff
56 | + OPENAI_KEY=sk-...
57 | ```
58 |
59 | ### Add a Postgres database URL
60 |
61 | In `.env` add a Postgres database URL it as `DATABASE_URL`. YOu can easily set one up with [supabase](https://supabase.io/) if needed.
62 |
63 | ```diff
64 | + DATABASE_URL=postgres://user:password@host:port/database
65 | ```
66 |
67 | ### Generate the tables
68 |
69 | You can [prisma migrate](https://www.prisma.io/docs/getting-started/setup-prisma/start-from-scratch/relational-databases/using-prisma-migrate-typescript-postgres) to generate the tables in your database.
70 |
71 | ```bash
72 | npx prisma migrate dev --name init
73 | ```
74 |
75 | ## Run
76 |
77 | ```bash
78 | # Run the dev server
79 | npm run dev
80 | ```
81 |
82 | Now, go enter a prompt for the assistant, and let it run!
83 |
84 | ## Contributing
85 |
86 | If you want to help fix a bug or implement a feature, don't hesitate to send a pull request!
87 |
88 | Just know that these are the very early days of this project, documentation is sparse, and the code is a bit messy. We're working on it!
89 |
90 | ## Community
91 |
92 | Come join the [Builder.io discord](https://discord.gg/EMx6e58xnw) and chat with us over in the `#gpt-assistant` channel
93 |
94 |
95 |
96 |
97 |
98 |
99 |
100 |
101 |
102 |
103 |
104 |
--------------------------------------------------------------------------------
/src/components/prompt/prompt.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | $,
3 | component$,
4 | useContext,
5 | useSignal,
6 | useTask$,
7 | useVisibleTask$,
8 | } from '@builder.io/qwik';
9 | import { Card } from '../card/card';
10 | import { Form, globalAction$, server$, z, zod$ } from '@builder.io/qwik-city';
11 | import { Loading } from '../loading/loading';
12 | import {
13 | ActionsContext,
14 | BrowserStateContext,
15 | GetCompletionContext,
16 | ContinueRunning,
17 | } from '~/routes';
18 | import { prismaClient } from '~/constants/prisma-client';
19 |
20 | export const useUpdatePrompt = globalAction$(
21 | async ({ prompt }) => {
22 | await prismaClient!.prompt.upsert({
23 | update: { text: prompt },
24 | create: { text: prompt, id: 1 },
25 | where: { id: 1 },
26 | });
27 | },
28 | zod$({
29 | prompt: z.string(),
30 | })
31 | );
32 |
33 | export const Prompt = component$((props: { class?: string }) => {
34 | const updatePromptAction = useUpdatePrompt();
35 | const loading = useSignal(false);
36 | const prompt = useSignal('');
37 |
38 | const runCompletion = useContext(GetCompletionContext);
39 | const actionsContext = useContext(ActionsContext);
40 | const browserStateContext = useContext(BrowserStateContext);
41 | const showBigStopButton = useContext(ContinueRunning);
42 |
43 | const clearActions = $(async () => {
44 | await server$(async () => {
45 | await prismaClient!.actions.deleteMany();
46 | await prismaClient!.browserState.deleteMany();
47 | await prismaClient!.answers.deleteMany();
48 | })();
49 | actionsContext.value++;
50 | browserStateContext.value++;
51 | });
52 |
53 | const run = $(async () => {
54 | loading.value = true;
55 | await updatePromptAction.submit({
56 | prompt: prompt.value,
57 | });
58 | await clearActions();
59 | if (!prompt.value) {
60 | return;
61 | }
62 | await runCompletion();
63 | // HACK: refactor
64 | const continueButton = document.querySelector(
65 | '#continue-button'
66 | ) as HTMLElement;
67 |
68 | if (!continueButton) {
69 | loading.value = false;
70 | throw new Error(
71 | 'Cannot continue, was the streamed completion from GPT a success?'
72 | );
73 | }
74 | continueButton.click();
75 | });
76 |
77 | useVisibleTask$(() => {
78 | setTimeout(async () => {
79 | const url = new URL(location.href);
80 | const runParam = url.searchParams.get('run');
81 | if (runParam) {
82 | prompt.value = runParam;
83 |
84 | setTimeout(async () => {
85 | url.searchParams.delete('run');
86 | history.replaceState({}, '', url.toString());
87 | await run();
88 | }, 10);
89 | }
90 | });
91 | });
92 |
93 | useTask$(({ track }) => {
94 | if (track(() => showBigStopButton.value) === false) {
95 | loading.value = false;
96 | }
97 | });
98 |
99 | return (
100 |
101 | GPT Assistant
102 |
122 | {loading.value || updatePromptAction.isRunning ? (
123 |
124 | ) : (
125 |
135 | )}
136 |
137 | );
138 | });
139 |
--------------------------------------------------------------------------------
/src/prompts/get-action.ts:
--------------------------------------------------------------------------------
1 | import { server$ } from '@builder.io/qwik-city';
2 | import { getActionsWithoutId } from '~/components/actions/actions';
3 | import { prismaClient } from '~/constants/prisma-client';
4 | import { plugins } from '~/plugins';
5 | import { getBrowserState } from '~/functions/get-browser-state';
6 | import { removeNthQueryParams } from '~/functions/remove-nth-query-params';
7 |
8 | const getPreviousSteps = async () =>
9 | `
10 | ${JSON.stringify(await getActionsWithoutId())}
11 | `.trim();
12 |
13 | const getPluginActions = server$(() => {
14 | if (!plugins.length) {
15 | return '';
16 | }
17 | return plugins
18 | .map((plugin) => plugin.actions)
19 | .flat()
20 | .map((action) => {
21 | return `- ${action.description}, like: ${JSON.stringify({
22 | action: action.name,
23 | ...action.example,
24 | })}`;
25 | })
26 | .join('\n');
27 | });
28 |
29 | const getPluginPromptInfo = server$(() => {
30 | if (!plugins.length) {
31 | return '';
32 | }
33 | return plugins
34 | .map((plugin) => plugin.promptInfo)
35 | .filter(Boolean)
36 | .join('\n');
37 | });
38 |
39 | const previousActionState = server$(async () => {
40 | const lastAction = await prismaClient!.actions.findFirst({
41 | orderBy: {
42 | id: 'desc',
43 | },
44 | });
45 | const lastActionResult = lastAction?.result;
46 |
47 | if (!lastActionResult) {
48 | return '';
49 | }
50 | return `
51 | The result of the last action you took was:
52 | ${lastActionResult}
53 | `;
54 | });
55 |
56 | export const getPrompt = server$(async () => {
57 | const prompt = await prismaClient!.prompt.findFirst({});
58 | return prompt?.text;
59 | });
60 |
61 | const includeAsk = true;
62 |
63 | const getActions = async () =>
64 | `
65 | The actions you can take:
66 | - load a website, like: {"action":"browser.navigate","url":"https://www.google.com"}
67 | - click something on the website, like: {"action":"browser.click","selector":"#some-button"}
68 | - input something on the website, like: {"action":"browser.input","selector":"#some-input","text":"some text"}${
69 | !includeAsk
70 | ? ''
71 | : `
72 | - ask a question to the user to get information you require that was not yet provided, like: {"action":"ask","question":"What is your name?"}`
73 | }
74 | - terminate the program, like: {"action":"terminate","reason":"The restaurant you wanted is not available"}
75 | ${await getPluginActions()}
76 |
77 | When you provide a selector, be sure that that selector is actually on the current page you are on. It needs to be in the HTML you are provided or don't use it.
78 | ${await getPluginPromptInfo()}
79 | `.trim();
80 |
81 | const getAnswers = server$(async () => {
82 | const answers = await prismaClient!.answers.findMany();
83 | const newAnswers = answers.map((answer) => ({
84 | question: answer.question,
85 | answer: answer.answer,
86 | }));
87 | return newAnswers;
88 | });
89 |
90 | async function priorAnswers() {
91 | const answers = await getAnswers();
92 |
93 | if (!answers.length) {
94 | return '';
95 | }
96 | return `
97 | The answers to previous questions you asked are:
98 | ${JSON.stringify(answers)}
99 | `;
100 | }
101 |
102 | async function getWebsiteContent(maxLength = 18000) {
103 | const state = await getBrowserState();
104 |
105 | if (!state?.html) {
106 | return '';
107 | }
108 |
109 | return `
110 | The current website you are on is:
111 | ${removeNthQueryParams(state.url!, 2)}
112 |
113 | The HTML of the current website is:
114 | ${state.html.slice(0, maxLength)}
115 | `.trim();
116 | }
117 |
118 | export async function getBrowsePrompt() {
119 | const previousSteps = await getPreviousSteps();
120 | return `
121 |
122 | You are an assistant that takes actions based on a prompt.
123 |
124 | The prompt is: ${await getPrompt()}
125 |
126 | ${await getWebsiteContent()}
127 |
128 | ${await getActions()}
129 |
130 | ${
131 | previousSteps.length > 4
132 | ? `
133 | The previous actions you took were:
134 | ${previousSteps}
135 | `.trim()
136 | : ''
137 | }
138 |
139 | ${await previousActionState()}
140 |
141 | ${await priorAnswers()}
142 |
143 | What will the next action you will take be, from the actions provided above? Using the functions above, give me a single action to take next, like:
144 | {"action":"some.action","optionName":"optionValue"}
145 |
146 | If an action isn't explicitly listed here, it doesn't exist.
147 |
148 | Please only output one next action:
149 | `
150 | .replace(/\n{3,}/g, '\n\n')
151 | .trim();
152 | }
153 |
--------------------------------------------------------------------------------
/src/routes/index.tsx:
--------------------------------------------------------------------------------
1 | import type { QRL, Signal } from '@builder.io/qwik';
2 | import {
3 | $,
4 | component$,
5 | createContextId,
6 | useContextProvider,
7 | useSignal,
8 | useTask$,
9 | useVisibleTask$,
10 | } from '@builder.io/qwik';
11 | import type { DocumentHead } from '@builder.io/qwik-city';
12 | import {
13 | ActionWithId,
14 | Actions,
15 | getActions,
16 | } from '~/components/actions/actions';
17 | import { BrowserState } from '~/components/browser-state/browser-state';
18 | import { Loading } from '~/components/loading/loading';
19 | import { Prompt } from '~/components/prompt/prompt';
20 | import { RenderResult } from '~/components/render-result/render-result';
21 | import { getBrowsePrompt } from '~/prompts/get-action';
22 | import { streamCompletion } from '../functions/stream-completion';
23 |
24 | Error.stackTraceLimit = Infinity;
25 |
26 | function autogrow(el: HTMLTextAreaElement) {
27 | // Autogrow
28 | setTimeout(() => {
29 | el.style.height = 'auto';
30 | el.style.height = el.scrollHeight + 'px';
31 | });
32 | }
33 |
34 | export const ActionsContext = createContextId>(
35 | 'index.actionsContext'
36 | );
37 |
38 | export const BrowserStateContext = createContextId>(
39 | 'index.browserStateContext'
40 | );
41 | export const GetCompletionContext = createContextId Promise>>(
42 | 'index.getCompletionContext'
43 | );
44 | export const ContinueRunning = createContextId>(
45 | 'index.continueRunning'
46 | );
47 | export const ActionsList =
48 | createContextId>('index.actionsList');
49 |
50 | function getDefaultPrompt() {
51 | return getBrowsePrompt();
52 | }
53 |
54 | const showGptPrompt = true;
55 |
56 | const tryJsonParse = (value: string) => {
57 | try {
58 | return JSON.parse(value);
59 | } catch (e) {
60 | return null;
61 | }
62 | };
63 |
64 | export default component$(() => {
65 | const prompt = useSignal('');
66 | const output = useSignal('');
67 | const loading = useSignal(false);
68 | const hasBegun = useSignal(false);
69 | const actionsKey = useSignal(0);
70 | const browserStateKey = useSignal(0);
71 | const promptTextarea = useSignal();
72 | const isRunningContinuously = useSignal(false);
73 | const error = useSignal('');
74 | const actions = useSignal([] as ActionWithId[]);
75 |
76 | const update = $(async () => {
77 | output.value = '';
78 | error.value = '';
79 | loading.value = true;
80 | hasBegun.value = true;
81 | const finalMessage = await streamCompletion(prompt.value, (value) => {
82 | output.value += value;
83 | });
84 | const parsed = tryJsonParse(finalMessage);
85 | if (parsed && parsed.error) {
86 | error.value = JSON.stringify(parsed.error, null, 2);
87 | }
88 | loading.value = false;
89 | console.debug('Final value:', output.value);
90 | return output.value;
91 | });
92 |
93 | const hardUpdate = $(async () => {
94 | prompt.value = await getDefaultPrompt();
95 | return await update();
96 | });
97 |
98 | useContextProvider(ActionsList, actions);
99 | useContextProvider(ActionsContext, actionsKey);
100 | useContextProvider(BrowserStateContext, browserStateKey);
101 | useContextProvider(GetCompletionContext, hardUpdate);
102 | useContextProvider(ContinueRunning, isRunningContinuously);
103 |
104 | useTask$(async ({ track }) => {
105 | track(() => browserStateKey.value);
106 | prompt.value = await getDefaultPrompt();
107 | });
108 |
109 | useVisibleTask$(async () => {
110 | if (promptTextarea.value) {
111 | autogrow(promptTextarea.value);
112 | promptTextarea.value?.focus();
113 | }
114 | });
115 |
116 | useTask$(async ({ track }) => {
117 | track(() => actionsKey.value);
118 | // eslint-disable-next-line qwik/valid-lexical-scope
119 | actions.value = await getActions();
120 | });
121 |
122 | return (
123 |
124 |
125 |
126 | {showGptPrompt && (hasBegun.value || Boolean(actions.value.length)) && (
127 |
161 | )}
162 |
163 |
167 |
168 | {output.value &&
}
169 | {loading.value &&
}
170 | {error.value && (
171 |
172 | {error.value}
173 |
174 | )}
175 |
176 | {isRunningContinuously.value && (
177 |
185 | )}
186 |
187 |
188 | );
189 | });
190 |
191 | export const head: DocumentHead = {
192 | title: 'AI Agent',
193 | meta: [],
194 | };
195 |
--------------------------------------------------------------------------------
/src/components/actions/actions.tsx:
--------------------------------------------------------------------------------
1 | import { $, component$, useContext, useSignal } from '@builder.io/qwik';
2 | import { Form, globalAction$, server$, z, zod$ } from '@builder.io/qwik-city';
3 | import { prismaClient } from '~/constants/prisma-client';
4 | import type { ActionStep } from '~/functions/run-action';
5 | import {
6 | ActionsContext,
7 | ActionsList,
8 | BrowserStateContext,
9 | ContinueRunning,
10 | } from '~/routes';
11 | import { Card } from '../card/card';
12 | import { Loading } from '../loading/loading';
13 | import type { Actions as Action } from '@prisma/client';
14 |
15 | export type ActionWithId = Pick;
16 |
17 | export const getActions = server$(async () => {
18 | return await prismaClient!.actions.findMany({
19 | select: { id: true, data: true },
20 | });
21 | });
22 |
23 | export async function getActionsWithoutId() {
24 | return (await getActions()).map(({ data }) => data);
25 | }
26 |
27 | async function createAction(action: ActionStep) {
28 | return await prismaClient!.actions.create({
29 | data: action as any,
30 | });
31 | }
32 |
33 | export const useCreateTaskAction = globalAction$(async ({ action }) => {
34 | return await createAction(JSON.parse(action));
35 | }, zod$({ action: z.string() }));
36 |
37 | const showAddAction = false;
38 | const PERSIST = true;
39 |
40 | export async function runAndSave(
41 | action: Pick,
42 | persist = PERSIST
43 | ) {
44 | await fetch('/api/v1/run', {
45 | method: 'POST',
46 | headers: {
47 | 'Content-Type': 'application/json',
48 | },
49 | body: JSON.stringify({
50 | action: {
51 | ...action,
52 | id: String(action.id),
53 | },
54 | persist,
55 | }),
56 | }).then((res) => res.json());
57 | }
58 |
59 | export const Actions = component$((props: { class?: string }) => {
60 | const createTaskAction = useCreateTaskAction();
61 | const loading = useSignal(false);
62 | const actions = useContext(ActionsList);
63 | const actionsContext = useContext(ActionsContext);
64 | const browserStateContext = useContext(BrowserStateContext);
65 | const continueRunningContext = useContext(ContinueRunning);
66 | const error = useSignal('');
67 |
68 | const updateActions = $(async () => {
69 | loading.value = true;
70 | // eslint-disable-next-line qwik/valid-lexical-scope
71 | actions.value = await getActions();
72 | loading.value = false;
73 | });
74 |
75 | const clearActions = $(async () => {
76 | loading.value = true;
77 | await server$(async () => {
78 | await prismaClient!.actions.deleteMany();
79 | await prismaClient!.browserState.deleteMany();
80 | await prismaClient!.answers.deleteMany();
81 | })();
82 | actionsContext.value++;
83 | browserStateContext.value++;
84 | loading.value = false;
85 | });
86 |
87 | return !showAddAction && !actions.value.length ? null : (
88 |
89 | Actions
90 |
91 |
92 | {actions.value.map((action) => {
93 | return (
94 |
98 |
99 | {JSON.stringify(action.data, null, 2)}
100 |
101 | {!continueRunningContext.value && (
102 |
123 | )}
124 |
125 | );
126 | })}
127 |
128 |
129 | {showAddAction && (
130 |
149 | )}
150 | {error.value && (
151 |
152 | {error.value}
153 |
154 | )}
155 | {loading.value ? (
156 |
157 | ) : (
158 | !!actions.value.length &&
159 | !continueRunningContext.value && (
160 |
161 |
180 |
188 |
189 | )
190 | )}
191 |
192 | );
193 | });
194 |
--------------------------------------------------------------------------------
/src/components/render-result/render-result.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | $,
3 | component$,
4 | useContext,
5 | useSignal,
6 | useTask$,
7 | } from '@builder.io/qwik';
8 | import { server$ } from '@builder.io/qwik-city';
9 | import type { ActionStep } from '~/functions/run-action';
10 | import {
11 | ActionsContext,
12 | BrowserStateContext,
13 | GetCompletionContext,
14 | ContinueRunning,
15 | } from '~/routes';
16 | import { Loading } from '../loading/loading';
17 | import { getActions, runAndSave } from '../actions/actions';
18 | import { getBrowserState } from "~/functions/get-browser-state";
19 | import { Question } from '../question/question';
20 | import { prismaClient } from '~/constants/prisma-client';
21 |
22 | interface TextBlock {
23 | type: 'text';
24 | text: string;
25 | }
26 |
27 | interface QuestionBlock {
28 | type: 'question';
29 | question: string;
30 | isPartial?: boolean;
31 | }
32 |
33 | type Block = TextBlock | QuestionBlock;
34 |
35 | export function parseTextToBlocks(text: string): Block[] {
36 | const blocks: Block[] = [];
37 |
38 | for (const line of text.split('\n')) {
39 | const useLine = line.trim();
40 | if (line.startsWith('ask(')) {
41 | blocks.push({
42 | type: 'question',
43 | question: useLine.trim().slice(5, -2),
44 | isPartial: !useLine.endsWith(')'),
45 | });
46 | } else {
47 | blocks.push({
48 | type: 'text',
49 | text: line,
50 | });
51 | }
52 | }
53 |
54 | return blocks;
55 | }
56 |
57 | export type ResponseBlock = {
58 | thought?: string;
59 | actions: ActionStep[];
60 | };
61 |
62 | export function parseTextToResponse(text: string): ResponseBlock | undefined {
63 | try {
64 | const result = JSON.parse(text) as ResponseBlock;
65 | // Sometime an array is returned
66 | if (Array.isArray(result)) {
67 | return {
68 | thought: '',
69 | actions: result as any,
70 | } satisfies ResponseBlock;
71 | }
72 | // Sometimes we get a single actions object
73 | if (!result.actions) {
74 | return {
75 | thought: '',
76 | actions: [result as any],
77 | } satisfies ResponseBlock;
78 | }
79 | return result;
80 | } catch (err) {
81 | // That's ok, the text is likely not done streaming yet
82 | }
83 | }
84 |
85 | const insertActions = server$(async (actions: ActionStep[]) => {
86 | await prismaClient!.actions.createMany({
87 | data: actions.map((action) => ({
88 | data: action,
89 | workflow_id: '1',
90 | })),
91 | });
92 | });
93 |
94 | export const RenderResult = component$((props: { response: string }) => {
95 | const response = parseTextToResponse(props.response);
96 |
97 | const actionsContext = useContext(ActionsContext);
98 | const browserStateContext = useContext(BrowserStateContext);
99 | const getCompletionContext = useContext(GetCompletionContext);
100 | const continueContext = useContext(ContinueRunning);
101 | const continueTimes = useSignal(100);
102 | const answer = useSignal('');
103 |
104 | const loading = useSignal(false);
105 | const approved = useSignal(false);
106 |
107 | const action = response?.actions[0];
108 |
109 | useTask$(({ track }) => {
110 | if (
111 | parseTextToResponse(track(() => props.response))?.actions?.[0]?.action ===
112 | 'terminate'
113 | ) {
114 | continueContext.value = false;
115 | }
116 | });
117 |
118 | const waitForAnswerIfNeeded = $(async () => {
119 | if (action?.action !== 'ask') {
120 | return;
121 | }
122 | return new Promise((resolve) => {
123 | // HACK: Poll for answer
124 | setInterval(() => {
125 | if (answer.value) {
126 | resolve(answer.value);
127 | }
128 | answer.value = '';
129 | }, 200);
130 | });
131 | });
132 |
133 | return approved.value ? (
134 | loading.value ? (
135 |
136 | ) : null
137 | ) : (
138 | <>
139 |
140 |
Output
141 | {response ? (
142 | action?.action === 'ask' ? (
143 |
{
145 | answer.value = reply;
146 | }}
147 | question={action.question}
148 | />
149 | ) : (
150 | <>
151 | {response.thought && {response.thought}
}
152 |
153 | {JSON.stringify(response.actions, null, 2)}
154 |
155 | {loading.value ? (
156 |
157 | ) : (
158 |
164 |
212 |
{
217 | continueTimes.value = el.valueAsNumber;
218 | }}
219 | />{' '}
220 |
times
221 |
233 |
234 | )}
235 | >
236 | )
237 | ) : (
238 |
239 | {props.response}
240 |
241 | )}
242 |
243 | >
244 | );
245 | });
246 |
--------------------------------------------------------------------------------
/src/functions/run-action.ts:
--------------------------------------------------------------------------------
1 | import { server$ } from '@builder.io/qwik-city';
2 | import type { Page, Browser } from 'puppeteer';
3 | import puppeteer from 'puppeteer';
4 | import { plugins } from '~/plugins';
5 | import { promises } from 'fs';
6 | import { prismaClient } from '~/constants/prisma-client';
7 | import type { Actions } from '@prisma/client';
8 |
9 | const { readFile, writeFile } = promises;
10 |
11 | const cookiesFile = './.cookies.json';
12 |
13 | export type ActionStep =
14 | | ClickAction
15 | | InputAction
16 | | NavigateAction
17 | | AskAction
18 | | TerminateAction;
19 |
20 | export type ClickAction = {
21 | action: 'browser.click';
22 | selector: string;
23 | };
24 |
25 | export type AskAction = {
26 | action: 'ask';
27 | question: 'string';
28 | };
29 |
30 | export type TerminateAction = {
31 | action: 'terminate';
32 | reason: 'string';
33 | };
34 |
35 | export type InputAction = {
36 | action: 'browser.input';
37 | selector: string;
38 | text: string;
39 | };
40 |
41 | export type NavigateAction = {
42 | action: 'browser.navigate';
43 | url: string;
44 | };
45 |
46 | const headless = false;
47 |
48 | function decodeEntities(encodedString: string) {
49 | const translateRe = /&(nbsp|amp|quot|lt|gt);/g;
50 | const translate: Record = {
51 | nbsp: ' ',
52 | amp: '&',
53 | quot: '"',
54 | lt: '<',
55 | gt: '>',
56 | };
57 | return encodedString
58 | .replace(translateRe, function (match, entity) {
59 | return translate[entity];
60 | })
61 | .replace(/(\d+);/gi, function (match, numStr) {
62 | const num = parseInt(numStr, 10);
63 | return String.fromCharCode(num);
64 | });
65 | }
66 |
67 | async function execAction(action: PartialAction) {
68 | for (const pluginAction of pluginActions) {
69 | if (pluginAction.name === (action.data as ActionStep).action) {
70 | const result = await pluginAction.handler({
71 | context: {},
72 | action: action.data,
73 | });
74 | await prismaClient!.actions.update({
75 | where: { id: action.id },
76 | data: { result: result || '' },
77 | });
78 | return result;
79 | }
80 | }
81 | }
82 |
83 | async function getMinimalPageHtml(page: Page) {
84 | return await page.evaluate(() => {
85 | let main =
86 | document.querySelector('main') || document.querySelector('body')!;
87 | main = main.cloneNode(true) as HTMLElement;
88 |
89 | const selectorsToDelete = [
90 | 'script',
91 | 'style',
92 | 'link',
93 | 'meta',
94 | 'title',
95 | 'noscript',
96 | 'br',
97 | 'hr',
98 | 'iframe',
99 | 'template',
100 | 'picture',
101 | 'source',
102 | 'img',
103 | 'svg',
104 | 'video',
105 | 'audio',
106 | 'canvas',
107 | 'object',
108 | '[aria-hidden=true]',
109 | '[hidden]:not([hidden=false])',
110 | 'details',
111 | 'input[type=hidden]',
112 | '.CodeMirror',
113 | ];
114 |
115 | // HACK: need better HTML compression or longer prompt sizes. In the meantime, remove some sections known to not be useful
116 | // In certain places that block the main content. It is only needed on the homepage
117 | if (location.hostname === 'opentable.com' && location.pathname !== '/') {
118 | selectorsToDelete.push('header');
119 | }
120 |
121 | for (const element of selectorsToDelete) {
122 | main.querySelectorAll(element).forEach((el) => el.remove());
123 | }
124 |
125 | for (const attr of ['class', 'target', 'rel', 'ping', 'style', 'title']) {
126 | [main as Element]
127 | .concat(Array.from(main.querySelectorAll(`[${attr}]`)))
128 | .forEach((el) => el.removeAttribute(attr));
129 | }
130 |
131 | function removeNthQueryParams(url: string, n: number) {
132 | // Parse the URL using the URL constructor
133 | const parsedUrl = new URL(url, location.origin);
134 |
135 | // Get the search parameters from the parsed URL
136 | const searchParams = parsedUrl.searchParams;
137 |
138 | // Convert the search parameters to an array of key-value pairs
139 | const paramsArray = Array.from(searchParams.entries());
140 |
141 | // Clear all existing search parameters
142 | searchParams.forEach((value, key) => {
143 | searchParams.delete(key);
144 | });
145 |
146 | // Add back only the first n query parameters
147 | for (let i = 0; i < Math.min(n, paramsArray.length); i++) {
148 | const [key, value] = paramsArray[i];
149 | searchParams.append(key, value);
150 | }
151 |
152 | return parsedUrl.href;
153 | }
154 |
155 | main.querySelectorAll('*').forEach((el) => {
156 | if (el instanceof HTMLAnchorElement) {
157 | // Only keep the first two query params, to avoid pulling in high entropy
158 | // tracking params that eat up lots of tokens that are usually later in the URL
159 | const href = el.getAttribute('href');
160 | if (href) {
161 | const numParams = href.match(/&/g)?.length;
162 | if (typeof numParams === 'number' && numParams > 1) {
163 | el.setAttribute('href', removeNthQueryParams(href, 2));
164 | }
165 | }
166 | }
167 |
168 | // Remove data-* attrs
169 | if (el instanceof HTMLElement) {
170 | Object.keys(el.dataset).forEach((dataKey) => {
171 | delete el.dataset[dataKey];
172 | });
173 | }
174 |
175 | Array.from(el.attributes).forEach((attr) => {
176 | // Google adds a bunch of js* attrs
177 | if (attr.name.startsWith('js')) {
178 | el.removeAttribute(attr.name);
179 | }
180 | });
181 |
182 | // Unwrap empty divs and spans
183 | if (['div', 'span'].includes(el.tagName.toLowerCase())) {
184 | // Check has no attributes
185 | if (!el.attributes.length) {
186 | el.replaceWith(...[document.createTextNode(' '), ...el.childNodes]);
187 | }
188 | }
189 |
190 | // Remove custom elements
191 | if (el.tagName.includes('-')) {
192 | el.replaceWith(...[document.createTextNode(' '), ...el.childNodes]);
193 | }
194 | });
195 |
196 | // Add all values to the HTML directly
197 | main.querySelectorAll('input,textarea,select').forEach((_el) => {
198 | const el = _el as
199 | | HTMLInputElement
200 | | HTMLTextAreaElement
201 | | HTMLSelectElement;
202 | if (el.value) {
203 | el.setAttribute('value', el.value);
204 | }
205 | });
206 |
207 | return {
208 | html: main.innerHTML
209 | // remove HTML comments
210 | .replace(//g, '')
211 | .replace(/\s+/g, ' ')
212 | .trim(),
213 | url: location.href,
214 | };
215 | });
216 | }
217 |
218 | const debugBrowser =
219 | process.env.DEBUG === 'true' || process.env.DEBUG_BROWSER === 'true';
220 |
221 | const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
222 |
223 | let persistedBrowser: Browser | undefined;
224 | let persistedPage: Page | undefined;
225 |
226 | function modifySelector(selector: string) {
227 | return decodeEntities(selector).replace('href=', 'href^=');
228 | }
229 |
230 | const pluginActions = plugins.map((plugin) => plugin.actions).flat();
231 |
232 | export type PartialAction = Pick;
233 |
234 | export const runAction = server$(
235 | async (theAction: PartialAction, persist = false) => {
236 | const action = theAction.data as ActionStep;
237 | const needsBrowser = action.action.startsWith('browser.');
238 |
239 | if (!needsBrowser) {
240 | return await execAction(theAction);
241 | }
242 |
243 | const hasExistingBrowser = !!persistedBrowser;
244 |
245 | const browser =
246 | persist && persistedBrowser
247 | ? persistedBrowser
248 | : await puppeteer.launch({
249 | headless,
250 | });
251 | let page =
252 | persist && persistedPage ? persistedPage : await browser.newPage();
253 |
254 | if (!hasExistingBrowser) {
255 | browser.on('targetcreated', async () => {
256 | page = (await browser.pages()).at(-1)!;
257 | persistedPage = page;
258 | });
259 |
260 | if (debugBrowser) {
261 | page.on('console', (message) =>
262 | console.log(
263 | `${message.type().substring(0, 3).toUpperCase()} ${message.text()}`
264 | )
265 | );
266 | }
267 | await page.setUserAgent(
268 | 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/111.0.0.0 Safari/537.36'
269 | );
270 | await page.setViewport({ width: 1080, height: 1024 });
271 |
272 | if (persist) {
273 | persistedBrowser = browser;
274 | persistedPage = page;
275 | }
276 | }
277 |
278 | if (persist) {
279 | const cookiesString = await readFile(cookiesFile, 'utf8')
280 | // File doesn't exist, all good
281 | .catch(() => '');
282 | if (cookiesString) {
283 | const cookies = JSON.parse(cookiesString);
284 | await page.setCookie(...cookies);
285 | }
286 | }
287 |
288 | if (debugBrowser) {
289 | console.log('action', action);
290 | }
291 |
292 | if (action.action === 'browser.navigate') {
293 | await page.goto(decodeEntities(action.url), {
294 | waitUntil: 'networkidle2',
295 | });
296 | } else if (action.action === 'browser.click') {
297 | const selector = modifySelector(action.selector);
298 | await page.click(selector).catch(async (err) => {
299 | console.warn('error clicking', err);
300 | // Fall back to programmatic click, e.g. for a hidden element
301 | await page.evaluate((selector) => {
302 | const el = document.querySelector(selector) as HTMLElement;
303 | el?.click();
304 | }, selector);
305 | });
306 | } else if (action.action === 'browser.input') {
307 | await page
308 | .type(modifySelector(action.selector), action.text)
309 | .catch((err) => {
310 | // Ok to continue, often means selector not valid
311 | console.warn('error typing', err);
312 | });
313 | } else {
314 | return await execAction(theAction);
315 | }
316 | if (persist) {
317 | const cookies = await page.cookies();
318 | await writeFile(cookiesFile, JSON.stringify(cookies));
319 | }
320 |
321 | await delay(500);
322 | await page
323 | .waitForNetworkIdle({
324 | timeout: 1000,
325 | })
326 | .catch(() => {
327 | // Errors are thrown on timeout, but we don't care about that
328 | });
329 |
330 | const { html, url: currentUrl } = await getMinimalPageHtml(page);
331 | await savePageContents(html, currentUrl);
332 |
333 | if (!persist && !debugBrowser) {
334 | await page.close();
335 | await browser.close();
336 | }
337 | }
338 | );
339 |
340 | const savePageContents = server$(async (html: string, url: string) => {
341 | await prismaClient!.browserState.upsert({
342 | where: { id: 1 },
343 | update: { html, url },
344 | create: { id: 1, html, url },
345 | });
346 | });
347 |
--------------------------------------------------------------------------------