From 183e0911b76025a1f2a82e979d9834fec2131d0e Mon Sep 17 00:00:00 2001 From: Frank Date: Fri, 8 Aug 2025 13:22:54 -0400 Subject: wip: gateway --- cloud/core/drizzle.config.ts | 20 + cloud/core/migrations/0000_amused_mojo.sql | 66 + cloud/core/migrations/0001_thankful_chat.sql | 8 + cloud/core/migrations/0002_stale_jackal.sql | 14 + .../migrations/0003_tranquil_spencer_smythe.sql | 1 + cloud/core/migrations/meta/0000_snapshot.json | 461 +++++++ cloud/core/migrations/meta/0001_snapshot.json | 515 ++++++++ cloud/core/migrations/meta/0002_snapshot.json | 615 ++++++++++ cloud/core/migrations/meta/0003_snapshot.json | 609 +++++++++ cloud/core/migrations/meta/_journal.json | 34 + cloud/core/package.json | 22 + cloud/core/src/account.ts | 67 + cloud/core/src/actor.ts | 75 ++ cloud/core/src/billing.ts | 71 ++ cloud/core/src/context.ts | 21 + cloud/core/src/drizzle/index.ts | 94 ++ cloud/core/src/drizzle/types.ts | 29 + cloud/core/src/identifier.ts | 26 + cloud/core/src/schema/account.sql.ts | 12 + cloud/core/src/schema/billing.sql.ts | 45 + cloud/core/src/schema/key.sql.ts | 16 + cloud/core/src/schema/user.sql.ts | 16 + cloud/core/src/schema/workspace.sql.ts | 25 + cloud/core/src/util/fn.ts | 14 + cloud/core/src/util/log.ts | 55 + cloud/core/src/util/price.ts | 3 + cloud/core/src/workspace.ts | 48 + cloud/core/sst-env.d.ts | 9 + cloud/core/tsconfig.json | 9 + cloud/function/package.json | 23 + cloud/function/src/auth.ts | 68 ++ cloud/function/src/gateway.ts | 887 ++++++++++++++ cloud/function/sst-env.d.ts | 88 ++ cloud/function/tsconfig.json | 9 + cloud/web/.gitignore | 2 + cloud/web/index.html | 38 + cloud/web/npm-debug.log | 29 + cloud/web/package.json | 32 + cloud/web/public/favicon-dark.svg | 3 + cloud/web/public/favicon.ico | Bin 0 -> 171515 bytes cloud/web/public/favicon.svg | 3 + cloud/web/public/social-share.png | Bin 0 -> 25855 bytes cloud/web/scripts/render.mjs | 24 + cloud/web/src/app.tsx | 42 + cloud/web/src/assets/screenshot.png | Bin 0 -> 449252 bytes cloud/web/src/components/context-account.tsx | 99 ++ cloud/web/src/components/context-openauth.tsx | 180 +++ cloud/web/src/components/context-theme.tsx | 39 + cloud/web/src/entry-client.tsx | 13 + cloud/web/src/entry-server.tsx | 7 + cloud/web/src/pages/[workspace].tsx | 11 + cloud/web/src/pages/[workspace]/billing.module.css | 56 + cloud/web/src/pages/[workspace]/billing.tsx | 132 ++ .../src/pages/[workspace]/components/system.txt | 11 + cloud/web/src/pages/[workspace]/components/tool.ts | 271 ++++ cloud/web/src/pages/[workspace]/index.module.css | 239 ++++ cloud/web/src/pages/[workspace]/index.tsx | 18 + cloud/web/src/pages/[workspace]/keys.module.css | 97 ++ cloud/web/src/pages/[workspace]/keys.tsx | 151 +++ cloud/web/src/pages/components/context-api.tsx | 24 + .../web/src/pages/components/context-workspace.tsx | 38 + cloud/web/src/pages/components/layout.module.css | 199 +++ cloud/web/src/pages/components/layout.tsx | 96 ++ cloud/web/src/pages/index.tsx | 36 + cloud/web/src/pages/lander.module.css | 169 +++ cloud/web/src/pages/test/design.module.css | 204 ++++ cloud/web/src/pages/test/design.tsx | 562 +++++++++ cloud/web/src/sst-env.d.ts | 12 + cloud/web/src/ui/button.tsx | 24 + cloud/web/src/ui/context-dialog.tsx | 120 ++ cloud/web/src/ui/dialog-select.module.css | 36 + cloud/web/src/ui/dialog-select.tsx | 124 ++ cloud/web/src/ui/dialog-string.tsx | 70 ++ cloud/web/src/ui/dialog.tsx | 27 + cloud/web/src/ui/style/component/button.css | 78 ++ cloud/web/src/ui/style/component/dialog.css | 84 ++ cloud/web/src/ui/style/component/input.css | 34 + cloud/web/src/ui/style/component/label.css | 17 + cloud/web/src/ui/style/component/title-bar.css | 32 + cloud/web/src/ui/style/index.css | 50 + cloud/web/src/ui/style/token/animation.css | 23 + cloud/web/src/ui/style/token/color.css | 88 ++ cloud/web/src/ui/style/token/font.css | 20 + cloud/web/src/ui/style/token/reset.css | 212 ++++ cloud/web/src/ui/style/token/space.css | 38 + cloud/web/src/ui/svg/icons.tsx | 1292 ++++++++++++++++++++ cloud/web/src/ui/svg/index.tsx | 67 + cloud/web/src/util/context.tsx | 26 + cloud/web/sst-env.d.ts | 9 + cloud/web/tsconfig.json | 12 + cloud/web/vite.config.ts | 63 + 91 files changed, 9458 insertions(+) create mode 100644 cloud/core/drizzle.config.ts create mode 100644 cloud/core/migrations/0000_amused_mojo.sql create mode 100644 cloud/core/migrations/0001_thankful_chat.sql create mode 100644 cloud/core/migrations/0002_stale_jackal.sql create mode 100644 cloud/core/migrations/0003_tranquil_spencer_smythe.sql create mode 100644 cloud/core/migrations/meta/0000_snapshot.json create mode 100644 cloud/core/migrations/meta/0001_snapshot.json create mode 100644 cloud/core/migrations/meta/0002_snapshot.json create mode 100644 cloud/core/migrations/meta/0003_snapshot.json create mode 100644 cloud/core/migrations/meta/_journal.json create mode 100644 cloud/core/package.json create mode 100644 cloud/core/src/account.ts create mode 100644 cloud/core/src/actor.ts create mode 100644 cloud/core/src/billing.ts create mode 100644 cloud/core/src/context.ts create mode 100644 cloud/core/src/drizzle/index.ts create mode 100644 cloud/core/src/drizzle/types.ts create mode 100644 cloud/core/src/identifier.ts create mode 100644 cloud/core/src/schema/account.sql.ts create mode 100644 cloud/core/src/schema/billing.sql.ts create mode 100644 cloud/core/src/schema/key.sql.ts create mode 100644 cloud/core/src/schema/user.sql.ts create mode 100644 cloud/core/src/schema/workspace.sql.ts create mode 100644 cloud/core/src/util/fn.ts create mode 100644 cloud/core/src/util/log.ts create mode 100644 cloud/core/src/util/price.ts create mode 100644 cloud/core/src/workspace.ts create mode 100644 cloud/core/sst-env.d.ts create mode 100644 cloud/core/tsconfig.json create mode 100644 cloud/function/package.json create mode 100644 cloud/function/src/auth.ts create mode 100644 cloud/function/src/gateway.ts create mode 100644 cloud/function/sst-env.d.ts create mode 100644 cloud/function/tsconfig.json create mode 100644 cloud/web/.gitignore create mode 100644 cloud/web/index.html create mode 100644 cloud/web/npm-debug.log create mode 100644 cloud/web/package.json create mode 100644 cloud/web/public/favicon-dark.svg create mode 100644 cloud/web/public/favicon.ico create mode 100644 cloud/web/public/favicon.svg create mode 100644 cloud/web/public/social-share.png create mode 100644 cloud/web/scripts/render.mjs create mode 100644 cloud/web/src/app.tsx create mode 100644 cloud/web/src/assets/screenshot.png create mode 100644 cloud/web/src/components/context-account.tsx create mode 100644 cloud/web/src/components/context-openauth.tsx create mode 100644 cloud/web/src/components/context-theme.tsx create mode 100644 cloud/web/src/entry-client.tsx create mode 100644 cloud/web/src/entry-server.tsx create mode 100644 cloud/web/src/pages/[workspace].tsx create mode 100644 cloud/web/src/pages/[workspace]/billing.module.css create mode 100644 cloud/web/src/pages/[workspace]/billing.tsx create mode 100644 cloud/web/src/pages/[workspace]/components/system.txt create mode 100644 cloud/web/src/pages/[workspace]/components/tool.ts create mode 100644 cloud/web/src/pages/[workspace]/index.module.css create mode 100644 cloud/web/src/pages/[workspace]/index.tsx create mode 100644 cloud/web/src/pages/[workspace]/keys.module.css create mode 100644 cloud/web/src/pages/[workspace]/keys.tsx create mode 100644 cloud/web/src/pages/components/context-api.tsx create mode 100644 cloud/web/src/pages/components/context-workspace.tsx create mode 100644 cloud/web/src/pages/components/layout.module.css create mode 100644 cloud/web/src/pages/components/layout.tsx create mode 100644 cloud/web/src/pages/index.tsx create mode 100644 cloud/web/src/pages/lander.module.css create mode 100644 cloud/web/src/pages/test/design.module.css create mode 100644 cloud/web/src/pages/test/design.tsx create mode 100644 cloud/web/src/sst-env.d.ts create mode 100644 cloud/web/src/ui/button.tsx create mode 100644 cloud/web/src/ui/context-dialog.tsx create mode 100644 cloud/web/src/ui/dialog-select.module.css create mode 100644 cloud/web/src/ui/dialog-select.tsx create mode 100644 cloud/web/src/ui/dialog-string.tsx create mode 100644 cloud/web/src/ui/dialog.tsx create mode 100644 cloud/web/src/ui/style/component/button.css create mode 100644 cloud/web/src/ui/style/component/dialog.css create mode 100644 cloud/web/src/ui/style/component/input.css create mode 100644 cloud/web/src/ui/style/component/label.css create mode 100644 cloud/web/src/ui/style/component/title-bar.css create mode 100644 cloud/web/src/ui/style/index.css create mode 100644 cloud/web/src/ui/style/token/animation.css create mode 100644 cloud/web/src/ui/style/token/color.css create mode 100644 cloud/web/src/ui/style/token/font.css create mode 100644 cloud/web/src/ui/style/token/reset.css create mode 100644 cloud/web/src/ui/style/token/space.css create mode 100644 cloud/web/src/ui/svg/icons.tsx create mode 100644 cloud/web/src/ui/svg/index.tsx create mode 100644 cloud/web/src/util/context.tsx create mode 100644 cloud/web/sst-env.d.ts create mode 100644 cloud/web/tsconfig.json create mode 100644 cloud/web/vite.config.ts (limited to 'cloud') diff --git a/cloud/core/drizzle.config.ts b/cloud/core/drizzle.config.ts new file mode 100644 index 000000000..c65363cb8 --- /dev/null +++ b/cloud/core/drizzle.config.ts @@ -0,0 +1,20 @@ +import { defineConfig } from "drizzle-kit" +import { Resource } from "sst" + +export default defineConfig({ + out: "./migrations/", + strict: true, + schema: ["./src/**/*.sql.ts"], + verbose: true, + dialect: "postgresql", + dbCredentials: { + database: Resource.Database.database, + host: Resource.Database.host, + user: Resource.Database.username, + password: Resource.Database.password, + port: Resource.Database.port, + ssl: { + rejectUnauthorized: false, + }, + }, +}) diff --git a/cloud/core/migrations/0000_amused_mojo.sql b/cloud/core/migrations/0000_amused_mojo.sql new file mode 100644 index 000000000..75441ad24 --- /dev/null +++ b/cloud/core/migrations/0000_amused_mojo.sql @@ -0,0 +1,66 @@ +CREATE TABLE "billing" ( + "id" varchar(30) NOT NULL, + "workspace_id" varchar(30) NOT NULL, + "time_created" timestamp with time zone DEFAULT now() NOT NULL, + "time_deleted" timestamp with time zone, + "customer_id" varchar(255), + "payment_method_id" varchar(255), + "payment_method_last4" varchar(4), + "balance" bigint NOT NULL, + "reload" boolean, + CONSTRAINT "billing_workspace_id_id_pk" PRIMARY KEY("workspace_id","id") +); +--> statement-breakpoint +CREATE TABLE "payment" ( + "id" varchar(30) NOT NULL, + "workspace_id" varchar(30) NOT NULL, + "time_created" timestamp with time zone DEFAULT now() NOT NULL, + "time_deleted" timestamp with time zone, + "customer_id" varchar(255), + "payment_id" varchar(255), + "amount" bigint NOT NULL, + CONSTRAINT "payment_workspace_id_id_pk" PRIMARY KEY("workspace_id","id") +); +--> statement-breakpoint +CREATE TABLE "usage" ( + "id" varchar(30) NOT NULL, + "workspace_id" varchar(30) NOT NULL, + "time_created" timestamp with time zone DEFAULT now() NOT NULL, + "time_deleted" timestamp with time zone, + "request_id" varchar(255), + "model" varchar(255) NOT NULL, + "input_tokens" integer NOT NULL, + "output_tokens" integer NOT NULL, + "reasoning_tokens" integer, + "cache_read_tokens" integer, + "cache_write_tokens" integer, + "cost" bigint NOT NULL, + CONSTRAINT "usage_workspace_id_id_pk" PRIMARY KEY("workspace_id","id") +); +--> statement-breakpoint +CREATE TABLE "user" ( + "id" varchar(30) NOT NULL, + "workspace_id" varchar(30) NOT NULL, + "time_created" timestamp with time zone DEFAULT now() NOT NULL, + "time_deleted" timestamp with time zone, + "email" text NOT NULL, + "name" varchar(255) NOT NULL, + "time_seen" timestamp with time zone, + "color" integer, + CONSTRAINT "user_workspace_id_id_pk" PRIMARY KEY("workspace_id","id") +); +--> statement-breakpoint +CREATE TABLE "workspace" ( + "id" varchar(30) PRIMARY KEY NOT NULL, + "slug" varchar(255), + "name" varchar(255), + "time_created" timestamp with time zone DEFAULT now() NOT NULL, + "time_deleted" timestamp with time zone +); +--> statement-breakpoint +ALTER TABLE "billing" ADD CONSTRAINT "billing_workspace_id_workspace_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspace"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "payment" ADD CONSTRAINT "payment_workspace_id_workspace_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspace"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "usage" ADD CONSTRAINT "usage_workspace_id_workspace_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspace"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "user" ADD CONSTRAINT "user_workspace_id_workspace_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspace"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +CREATE UNIQUE INDEX "user_email" ON "user" USING btree ("workspace_id","email");--> statement-breakpoint +CREATE UNIQUE INDEX "slug" ON "workspace" USING btree ("slug"); \ No newline at end of file diff --git a/cloud/core/migrations/0001_thankful_chat.sql b/cloud/core/migrations/0001_thankful_chat.sql new file mode 100644 index 000000000..9c66a6ac4 --- /dev/null +++ b/cloud/core/migrations/0001_thankful_chat.sql @@ -0,0 +1,8 @@ +CREATE TABLE "account" ( + "id" varchar(30) NOT NULL, + "time_created" timestamp with time zone DEFAULT now() NOT NULL, + "time_deleted" timestamp with time zone, + "email" varchar(255) NOT NULL +); +--> statement-breakpoint +CREATE UNIQUE INDEX "email" ON "account" USING btree ("email"); \ No newline at end of file diff --git a/cloud/core/migrations/0002_stale_jackal.sql b/cloud/core/migrations/0002_stale_jackal.sql new file mode 100644 index 000000000..267dff273 --- /dev/null +++ b/cloud/core/migrations/0002_stale_jackal.sql @@ -0,0 +1,14 @@ +CREATE TABLE "key" ( + "id" varchar(30) NOT NULL, + "workspace_id" varchar(30) NOT NULL, + "time_created" timestamp with time zone DEFAULT now() NOT NULL, + "time_deleted" timestamp with time zone, + "user_id" text NOT NULL, + "name" varchar(255) NOT NULL, + "key" varchar(255) NOT NULL, + "time_used" timestamp with time zone, + CONSTRAINT "key_workspace_id_id_pk" PRIMARY KEY("workspace_id","id") +); +--> statement-breakpoint +ALTER TABLE "key" ADD CONSTRAINT "key_workspace_id_workspace_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspace"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +CREATE UNIQUE INDEX "global_key" ON "key" USING btree ("key"); \ No newline at end of file diff --git a/cloud/core/migrations/0003_tranquil_spencer_smythe.sql b/cloud/core/migrations/0003_tranquil_spencer_smythe.sql new file mode 100644 index 000000000..4f57f779f --- /dev/null +++ b/cloud/core/migrations/0003_tranquil_spencer_smythe.sql @@ -0,0 +1 @@ +ALTER TABLE "usage" DROP COLUMN "request_id"; \ No newline at end of file diff --git a/cloud/core/migrations/meta/0000_snapshot.json b/cloud/core/migrations/meta/0000_snapshot.json new file mode 100644 index 000000000..3b86bed25 --- /dev/null +++ b/cloud/core/migrations/meta/0000_snapshot.json @@ -0,0 +1,461 @@ +{ + "id": "9b5cec8c-8b59-4d7a-bb5c-76ade1c83d6f", + "prevId": "00000000-0000-0000-0000-000000000000", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.billing": { + "name": "billing", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "customer_id": { + "name": "customer_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "payment_method_id": { + "name": "payment_method_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "payment_method_last4": { + "name": "payment_method_last4", + "type": "varchar(4)", + "primaryKey": false, + "notNull": false + }, + "balance": { + "name": "balance", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "reload": { + "name": "reload", + "type": "boolean", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "billing_workspace_id_workspace_id_fk": { + "name": "billing_workspace_id_workspace_id_fk", + "tableFrom": "billing", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "billing_workspace_id_id_pk": { + "name": "billing_workspace_id_id_pk", + "columns": [ + "workspace_id", + "id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.payment": { + "name": "payment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "customer_id": { + "name": "customer_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "payment_id": { + "name": "payment_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "amount": { + "name": "amount", + "type": "bigint", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "payment_workspace_id_workspace_id_fk": { + "name": "payment_workspace_id_workspace_id_fk", + "tableFrom": "payment", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "payment_workspace_id_id_pk": { + "name": "payment_workspace_id_id_pk", + "columns": [ + "workspace_id", + "id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.usage": { + "name": "usage", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "request_id": { + "name": "request_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "input_tokens": { + "name": "input_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "output_tokens": { + "name": "output_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "reasoning_tokens": { + "name": "reasoning_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "cache_read_tokens": { + "name": "cache_read_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "cache_write_tokens": { + "name": "cache_write_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "cost": { + "name": "cost", + "type": "bigint", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "usage_workspace_id_workspace_id_fk": { + "name": "usage_workspace_id_workspace_id_fk", + "tableFrom": "usage", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "usage_workspace_id_id_pk": { + "name": "usage_workspace_id_id_pk", + "columns": [ + "workspace_id", + "id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "time_seen": { + "name": "time_seen", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "color": { + "name": "color", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "user_email": { + "name": "user_email", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_workspace_id_workspace_id_fk": { + "name": "user_workspace_id_workspace_id_fk", + "tableFrom": "user", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_workspace_id_id_pk": { + "name": "user_workspace_id_id_pk", + "columns": [ + "workspace_id", + "id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace": { + "name": "workspace", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(30)", + "primaryKey": true, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "slug": { + "name": "slug", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/cloud/core/migrations/meta/0001_snapshot.json b/cloud/core/migrations/meta/0001_snapshot.json new file mode 100644 index 000000000..69d66ebc6 --- /dev/null +++ b/cloud/core/migrations/meta/0001_snapshot.json @@ -0,0 +1,515 @@ +{ + "id": "bf9e9084-4073-4ecb-8e56-5610816c9589", + "prevId": "9b5cec8c-8b59-4d7a-bb5c-76ade1c83d6f", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "email": { + "name": "email", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.billing": { + "name": "billing", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "customer_id": { + "name": "customer_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "payment_method_id": { + "name": "payment_method_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "payment_method_last4": { + "name": "payment_method_last4", + "type": "varchar(4)", + "primaryKey": false, + "notNull": false + }, + "balance": { + "name": "balance", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "reload": { + "name": "reload", + "type": "boolean", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "billing_workspace_id_workspace_id_fk": { + "name": "billing_workspace_id_workspace_id_fk", + "tableFrom": "billing", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "billing_workspace_id_id_pk": { + "name": "billing_workspace_id_id_pk", + "columns": [ + "workspace_id", + "id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.payment": { + "name": "payment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "customer_id": { + "name": "customer_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "payment_id": { + "name": "payment_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "amount": { + "name": "amount", + "type": "bigint", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "payment_workspace_id_workspace_id_fk": { + "name": "payment_workspace_id_workspace_id_fk", + "tableFrom": "payment", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "payment_workspace_id_id_pk": { + "name": "payment_workspace_id_id_pk", + "columns": [ + "workspace_id", + "id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.usage": { + "name": "usage", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "request_id": { + "name": "request_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "input_tokens": { + "name": "input_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "output_tokens": { + "name": "output_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "reasoning_tokens": { + "name": "reasoning_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "cache_read_tokens": { + "name": "cache_read_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "cache_write_tokens": { + "name": "cache_write_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "cost": { + "name": "cost", + "type": "bigint", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "usage_workspace_id_workspace_id_fk": { + "name": "usage_workspace_id_workspace_id_fk", + "tableFrom": "usage", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "usage_workspace_id_id_pk": { + "name": "usage_workspace_id_id_pk", + "columns": [ + "workspace_id", + "id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "time_seen": { + "name": "time_seen", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "color": { + "name": "color", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "user_email": { + "name": "user_email", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_workspace_id_workspace_id_fk": { + "name": "user_workspace_id_workspace_id_fk", + "tableFrom": "user", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_workspace_id_id_pk": { + "name": "user_workspace_id_id_pk", + "columns": [ + "workspace_id", + "id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace": { + "name": "workspace", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(30)", + "primaryKey": true, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "slug": { + "name": "slug", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/cloud/core/migrations/meta/0002_snapshot.json b/cloud/core/migrations/meta/0002_snapshot.json new file mode 100644 index 000000000..7d970ab02 --- /dev/null +++ b/cloud/core/migrations/meta/0002_snapshot.json @@ -0,0 +1,615 @@ +{ + "id": "351e4956-74e0-4282-a23b-02f1a73fa38c", + "prevId": "bf9e9084-4073-4ecb-8e56-5610816c9589", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "email": { + "name": "email", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.billing": { + "name": "billing", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "customer_id": { + "name": "customer_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "payment_method_id": { + "name": "payment_method_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "payment_method_last4": { + "name": "payment_method_last4", + "type": "varchar(4)", + "primaryKey": false, + "notNull": false + }, + "balance": { + "name": "balance", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "reload": { + "name": "reload", + "type": "boolean", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "billing_workspace_id_workspace_id_fk": { + "name": "billing_workspace_id_workspace_id_fk", + "tableFrom": "billing", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "billing_workspace_id_id_pk": { + "name": "billing_workspace_id_id_pk", + "columns": [ + "workspace_id", + "id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.payment": { + "name": "payment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "customer_id": { + "name": "customer_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "payment_id": { + "name": "payment_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "amount": { + "name": "amount", + "type": "bigint", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "payment_workspace_id_workspace_id_fk": { + "name": "payment_workspace_id_workspace_id_fk", + "tableFrom": "payment", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "payment_workspace_id_id_pk": { + "name": "payment_workspace_id_id_pk", + "columns": [ + "workspace_id", + "id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.usage": { + "name": "usage", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "request_id": { + "name": "request_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "input_tokens": { + "name": "input_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "output_tokens": { + "name": "output_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "reasoning_tokens": { + "name": "reasoning_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "cache_read_tokens": { + "name": "cache_read_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "cache_write_tokens": { + "name": "cache_write_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "cost": { + "name": "cost", + "type": "bigint", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "usage_workspace_id_workspace_id_fk": { + "name": "usage_workspace_id_workspace_id_fk", + "tableFrom": "usage", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "usage_workspace_id_id_pk": { + "name": "usage_workspace_id_id_pk", + "columns": [ + "workspace_id", + "id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.key": { + "name": "key", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "time_used": { + "name": "time_used", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "global_key": { + "name": "global_key", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "key_workspace_id_workspace_id_fk": { + "name": "key_workspace_id_workspace_id_fk", + "tableFrom": "key", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "key_workspace_id_id_pk": { + "name": "key_workspace_id_id_pk", + "columns": [ + "workspace_id", + "id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "time_seen": { + "name": "time_seen", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "color": { + "name": "color", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "user_email": { + "name": "user_email", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_workspace_id_workspace_id_fk": { + "name": "user_workspace_id_workspace_id_fk", + "tableFrom": "user", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_workspace_id_id_pk": { + "name": "user_workspace_id_id_pk", + "columns": [ + "workspace_id", + "id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace": { + "name": "workspace", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(30)", + "primaryKey": true, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "slug": { + "name": "slug", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/cloud/core/migrations/meta/0003_snapshot.json b/cloud/core/migrations/meta/0003_snapshot.json new file mode 100644 index 000000000..e1202ddbf --- /dev/null +++ b/cloud/core/migrations/meta/0003_snapshot.json @@ -0,0 +1,609 @@ +{ + "id": "fa935883-9e51-4811-90c7-8967eefe458c", + "prevId": "351e4956-74e0-4282-a23b-02f1a73fa38c", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "email": { + "name": "email", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.billing": { + "name": "billing", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "customer_id": { + "name": "customer_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "payment_method_id": { + "name": "payment_method_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "payment_method_last4": { + "name": "payment_method_last4", + "type": "varchar(4)", + "primaryKey": false, + "notNull": false + }, + "balance": { + "name": "balance", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "reload": { + "name": "reload", + "type": "boolean", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "billing_workspace_id_workspace_id_fk": { + "name": "billing_workspace_id_workspace_id_fk", + "tableFrom": "billing", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "billing_workspace_id_id_pk": { + "name": "billing_workspace_id_id_pk", + "columns": [ + "workspace_id", + "id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.payment": { + "name": "payment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "customer_id": { + "name": "customer_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "payment_id": { + "name": "payment_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "amount": { + "name": "amount", + "type": "bigint", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "payment_workspace_id_workspace_id_fk": { + "name": "payment_workspace_id_workspace_id_fk", + "tableFrom": "payment", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "payment_workspace_id_id_pk": { + "name": "payment_workspace_id_id_pk", + "columns": [ + "workspace_id", + "id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.usage": { + "name": "usage", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "input_tokens": { + "name": "input_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "output_tokens": { + "name": "output_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "reasoning_tokens": { + "name": "reasoning_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "cache_read_tokens": { + "name": "cache_read_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "cache_write_tokens": { + "name": "cache_write_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "cost": { + "name": "cost", + "type": "bigint", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "usage_workspace_id_workspace_id_fk": { + "name": "usage_workspace_id_workspace_id_fk", + "tableFrom": "usage", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "usage_workspace_id_id_pk": { + "name": "usage_workspace_id_id_pk", + "columns": [ + "workspace_id", + "id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.key": { + "name": "key", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "time_used": { + "name": "time_used", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "global_key": { + "name": "global_key", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "key_workspace_id_workspace_id_fk": { + "name": "key_workspace_id_workspace_id_fk", + "tableFrom": "key", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "key_workspace_id_id_pk": { + "name": "key_workspace_id_id_pk", + "columns": [ + "workspace_id", + "id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "time_seen": { + "name": "time_seen", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "color": { + "name": "color", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "user_email": { + "name": "user_email", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_workspace_id_workspace_id_fk": { + "name": "user_workspace_id_workspace_id_fk", + "tableFrom": "user", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_workspace_id_id_pk": { + "name": "user_workspace_id_id_pk", + "columns": [ + "workspace_id", + "id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace": { + "name": "workspace", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(30)", + "primaryKey": true, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "slug": { + "name": "slug", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/cloud/core/migrations/meta/_journal.json b/cloud/core/migrations/meta/_journal.json new file mode 100644 index 000000000..ceba11e26 --- /dev/null +++ b/cloud/core/migrations/meta/_journal.json @@ -0,0 +1,34 @@ +{ + "version": "7", + "dialect": "postgresql", + "entries": [ + { + "idx": 0, + "version": "7", + "when": 1754518198186, + "tag": "0000_amused_mojo", + "breakpoints": true + }, + { + "idx": 1, + "version": "7", + "when": 1754609655262, + "tag": "0001_thankful_chat", + "breakpoints": true + }, + { + "idx": 2, + "version": "7", + "when": 1754627626945, + "tag": "0002_stale_jackal", + "breakpoints": true + }, + { + "idx": 3, + "version": "7", + "when": 1754672464106, + "tag": "0003_tranquil_spencer_smythe", + "breakpoints": true + } + ] +} \ No newline at end of file diff --git a/cloud/core/package.json b/cloud/core/package.json new file mode 100644 index 000000000..eeebf4392 --- /dev/null +++ b/cloud/core/package.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://json.schemastore.org/package.json", + "name": "@opencode/cloud-core", + "version": "0.0.0", + "private": true, + "type": "module", + "dependencies": { + "@aws-sdk/client-sts": "3.782.0", + "drizzle-orm": "0.41.0", + "stripe": "18.0.0", + "ulid": "3.0.0" + }, + "exports": { + "./*": "./src/*" + }, + "scripts": { + "db": "sst shell drizzle-kit" + }, + "devDependencies": { + "drizzle-kit": "0.30.5" + } +} diff --git a/cloud/core/src/account.ts b/cloud/core/src/account.ts new file mode 100644 index 000000000..cb123e048 --- /dev/null +++ b/cloud/core/src/account.ts @@ -0,0 +1,67 @@ +import { z } from "zod" +import { and, eq, getTableColumns, isNull } from "drizzle-orm" +import { fn } from "./util/fn" +import { Database } from "./drizzle" +import { Identifier } from "./identifier" +import { AccountTable } from "./schema/account.sql" +import { Actor } from "./actor" +import { WorkspaceTable } from "./schema/workspace.sql" +import { UserTable } from "./schema/user.sql" + +export namespace Account { + export const create = fn( + z.object({ + email: z.string().email(), + id: z.string().optional(), + }), + async (input) => + Database.transaction(async (tx) => { + const id = input.id ?? Identifier.create("account") + await tx.insert(AccountTable).values({ + id, + email: input.email, + }) + return id + }), + ) + + export const fromID = fn(z.string(), async (id) => + Database.transaction(async (tx) => { + return tx + .select() + .from(AccountTable) + .where(eq(AccountTable.id, id)) + .execute() + .then((rows) => rows[0]) + }), + ) + + export const fromEmail = fn(z.string().email(), async (email) => + Database.transaction(async (tx) => { + return tx + .select() + .from(AccountTable) + .where(eq(AccountTable.email, email)) + .execute() + .then((rows) => rows[0]) + }), + ) + + export const workspaces = async () => { + const actor = Actor.assert("account") + return Database.transaction(async (tx) => + tx + .select(getTableColumns(WorkspaceTable)) + .from(WorkspaceTable) + .innerJoin(UserTable, eq(UserTable.workspaceID, WorkspaceTable.id)) + .where( + and( + eq(UserTable.email, actor.properties.email), + isNull(UserTable.timeDeleted), + isNull(WorkspaceTable.timeDeleted), + ), + ) + .execute(), + ) + } +} diff --git a/cloud/core/src/actor.ts b/cloud/core/src/actor.ts new file mode 100644 index 000000000..beb292bb8 --- /dev/null +++ b/cloud/core/src/actor.ts @@ -0,0 +1,75 @@ +import { Context } from "./context" +import { Log } from "./util/log" + +export namespace Actor { + interface Account { + type: "account" + properties: { + accountID: string + email: string + } + } + + interface Public { + type: "public" + properties: {} + } + + interface User { + type: "user" + properties: { + userID: string + workspaceID: string + email: string + } + } + + interface System { + type: "system" + properties: { + workspaceID: string + } + } + + export type Info = Account | Public | User | System + + const ctx = Context.create() + export const use = ctx.use + + const log = Log.create().tag("namespace", "actor") + + export function provide( + type: T, + properties: Extract["properties"], + cb: () => R, + ) { + return ctx.provide( + { + type, + properties, + } as any, + () => { + return Log.provide({ ...properties }, () => { + log.info("provided") + return cb() + }) + }, + ) + } + + export function assert(type: T) { + const actor = use() + if (actor.type !== type) { + throw new Error(`Expected actor type ${type}, got ${actor.type}`) + } + return actor as Extract + } + + export function workspace() { + const actor = use() + if ("workspaceID" in actor.properties) { + return actor.properties.workspaceID + } + throw new Error(`actor of type "${actor.type}" is not associated with a workspace`) + } +} diff --git a/cloud/core/src/billing.ts b/cloud/core/src/billing.ts new file mode 100644 index 000000000..1a7bb2946 --- /dev/null +++ b/cloud/core/src/billing.ts @@ -0,0 +1,71 @@ +import { Resource } from "sst" +import { Stripe } from "stripe" +import { Database, eq, sql } from "./drizzle" +import { BillingTable, UsageTable } from "./schema/billing.sql" +import { Actor } from "./actor" +import { fn } from "./util/fn" +import { z } from "zod" +import { Identifier } from "./identifier" +import { centsToMicroCents } from "./util/price" + +export namespace Billing { + export const stripe = () => + new Stripe(Resource.STRIPE_SECRET_KEY.value, { + apiVersion: "2025-03-31.basil", + }) + + export const get = async () => { + return Database.use(async (tx) => + tx + .select({ + customerID: BillingTable.customerID, + paymentMethodID: BillingTable.paymentMethodID, + balance: BillingTable.balance, + reload: BillingTable.reload, + }) + .from(BillingTable) + .where(eq(BillingTable.workspaceID, Actor.workspace())) + .then((r) => r[0]), + ) + } + + export const consume = fn( + z.object({ + requestID: z.string().optional(), + model: z.string(), + inputTokens: z.number(), + outputTokens: z.number(), + reasoningTokens: z.number().optional(), + cacheReadTokens: z.number().optional(), + cacheWriteTokens: z.number().optional(), + costInCents: z.number(), + }), + async (input) => { + const workspaceID = Actor.workspace() + const cost = centsToMicroCents(input.costInCents) + + return await Database.transaction(async (tx) => { + await tx.insert(UsageTable).values({ + workspaceID, + id: Identifier.create("usage"), + requestID: input.requestID, + model: input.model, + inputTokens: input.inputTokens, + outputTokens: input.outputTokens, + reasoningTokens: input.reasoningTokens, + cacheReadTokens: input.cacheReadTokens, + cacheWriteTokens: input.cacheWriteTokens, + cost, + }) + const [updated] = await tx + .update(BillingTable) + .set({ + balance: sql`${BillingTable.balance} - ${cost}`, + }) + .where(eq(BillingTable.workspaceID, workspaceID)) + .returning() + return updated.balance + }) + }, + ) +} diff --git a/cloud/core/src/context.ts b/cloud/core/src/context.ts new file mode 100644 index 000000000..c2ca6a313 --- /dev/null +++ b/cloud/core/src/context.ts @@ -0,0 +1,21 @@ +import { AsyncLocalStorage } from "node:async_hooks" + +export namespace Context { + export class NotFound extends Error {} + + export function create() { + const storage = new AsyncLocalStorage() + return { + use() { + const result = storage.getStore() + if (!result) { + throw new NotFound() + } + return result + }, + provide(value: T, fn: () => R) { + return storage.run(value, fn) + }, + } + } +} diff --git a/cloud/core/src/drizzle/index.ts b/cloud/core/src/drizzle/index.ts new file mode 100644 index 000000000..76220f2a2 --- /dev/null +++ b/cloud/core/src/drizzle/index.ts @@ -0,0 +1,94 @@ +import { drizzle } from "drizzle-orm/postgres-js" +import { Resource } from "sst" +export * from "drizzle-orm" +import postgres from "postgres" + +function createClient() { + const client = postgres({ + idle_timeout: 30000, + connect_timeout: 30000, + host: Resource.Database.host, + database: Resource.Database.database, + user: Resource.Database.username, + password: Resource.Database.password, + port: Resource.Database.port, + ssl: { + rejectUnauthorized: false, + }, + max: 1, + }) + + return drizzle(client, {}) +} + +import { PgTransaction, type PgTransactionConfig } from "drizzle-orm/pg-core" +import type { ExtractTablesWithRelations } from "drizzle-orm" +import type { PostgresJsQueryResultHKT } from "drizzle-orm/postgres-js" +import { Context } from "../context" + +export namespace Database { + export type Transaction = PgTransaction< + PostgresJsQueryResultHKT, + Record, + ExtractTablesWithRelations> + > + + export type TxOrDb = Transaction | ReturnType + + const TransactionContext = Context.create<{ + tx: TxOrDb + effects: (() => void | Promise)[] + }>() + + export async function use(callback: (trx: TxOrDb) => Promise) { + try { + const { tx } = TransactionContext.use() + return tx.transaction(callback) + } catch (err) { + if (err instanceof Context.NotFound) { + const client = createClient() + const effects: (() => void | Promise)[] = [] + const result = await TransactionContext.provide( + { + effects, + tx: client, + }, + () => callback(client), + ) + await Promise.all(effects.map((x) => x())) + return result + } + throw err + } + } + export async function fn(callback: (input: Input, trx: TxOrDb) => Promise) { + return (input: Input) => use(async (tx) => callback(input, tx)) + } + + export async function effect(effect: () => any | Promise) { + try { + const { effects } = TransactionContext.use() + effects.push(effect) + } catch { + await effect() + } + } + + export async function transaction(callback: (tx: TxOrDb) => Promise, config?: PgTransactionConfig) { + try { + const { tx } = TransactionContext.use() + return callback(tx) + } catch (err) { + if (err instanceof Context.NotFound) { + const client = createClient() + const effects: (() => void | Promise)[] = [] + const result = await client.transaction(async (tx) => { + return TransactionContext.provide({ tx, effects }, () => callback(tx)) + }, config) + await Promise.all(effects.map((x) => x())) + return result + } + throw err + } + } +} diff --git a/cloud/core/src/drizzle/types.ts b/cloud/core/src/drizzle/types.ts new file mode 100644 index 000000000..5ae95d011 --- /dev/null +++ b/cloud/core/src/drizzle/types.ts @@ -0,0 +1,29 @@ +import { bigint, timestamp, varchar } from "drizzle-orm/pg-core" + +export const ulid = (name: string) => varchar(name, { length: 30 }) + +export const workspaceColumns = { + get id() { + return ulid("id").notNull() + }, + get workspaceID() { + return ulid("workspace_id").notNull() + }, +} + +export const id = () => ulid("id").notNull() + +export const utc = (name: string) => + timestamp(name, { + withTimezone: true, + }) + +export const currency = (name: string) => + bigint(name, { + mode: "number", + }) + +export const timestamps = { + timeCreated: utc("time_created").notNull().defaultNow(), + timeDeleted: utc("time_deleted"), +} diff --git a/cloud/core/src/identifier.ts b/cloud/core/src/identifier.ts new file mode 100644 index 000000000..f8e73852e --- /dev/null +++ b/cloud/core/src/identifier.ts @@ -0,0 +1,26 @@ +import { ulid } from "ulid" +import { z } from "zod" + +export namespace Identifier { + const prefixes = { + account: "acc", + billing: "bil", + key: "key", + payment: "pay", + usage: "usg", + user: "usr", + workspace: "wrk", + } as const + + export function create(prefix: keyof typeof prefixes, given?: string): string { + if (given) { + if (given.startsWith(prefixes[prefix])) return given + throw new Error(`ID ${given} does not start with ${prefixes[prefix]}`) + } + return [prefixes[prefix], ulid()].join("_") + } + + export function schema(prefix: keyof typeof prefixes) { + return z.string().startsWith(prefixes[prefix]) + } +} diff --git a/cloud/core/src/schema/account.sql.ts b/cloud/core/src/schema/account.sql.ts new file mode 100644 index 000000000..1733f0a15 --- /dev/null +++ b/cloud/core/src/schema/account.sql.ts @@ -0,0 +1,12 @@ +import { pgTable, uniqueIndex, varchar } from "drizzle-orm/pg-core" +import { id, timestamps } from "../drizzle/types" + +export const AccountTable = pgTable( + "account", + { + id: id(), + ...timestamps, + email: varchar("email", { length: 255 }).notNull(), + }, + (table) => [uniqueIndex("email").on(table.email)], +) diff --git a/cloud/core/src/schema/billing.sql.ts b/cloud/core/src/schema/billing.sql.ts new file mode 100644 index 000000000..96b29f5de --- /dev/null +++ b/cloud/core/src/schema/billing.sql.ts @@ -0,0 +1,45 @@ +import { bigint, boolean, integer, pgTable, varchar } from "drizzle-orm/pg-core" +import { timestamps, workspaceColumns } from "../drizzle/types" +import { workspaceIndexes } from "./workspace.sql" + +export const BillingTable = pgTable( + "billing", + { + ...workspaceColumns, + ...timestamps, + customerID: varchar("customer_id", { length: 255 }), + paymentMethodID: varchar("payment_method_id", { length: 255 }), + paymentMethodLast4: varchar("payment_method_last4", { length: 4 }), + balance: bigint("balance", { mode: "number" }).notNull(), + reload: boolean("reload"), + }, + (table) => [...workspaceIndexes(table)], +) + +export const PaymentTable = pgTable( + "payment", + { + ...workspaceColumns, + ...timestamps, + customerID: varchar("customer_id", { length: 255 }), + paymentID: varchar("payment_id", { length: 255 }), + amount: bigint("amount", { mode: "number" }).notNull(), + }, + (table) => [...workspaceIndexes(table)], +) + +export const UsageTable = pgTable( + "usage", + { + ...workspaceColumns, + ...timestamps, + model: varchar("model", { length: 255 }).notNull(), + inputTokens: integer("input_tokens").notNull(), + outputTokens: integer("output_tokens").notNull(), + reasoningTokens: integer("reasoning_tokens"), + cacheReadTokens: integer("cache_read_tokens"), + cacheWriteTokens: integer("cache_write_tokens"), + cost: bigint("cost", { mode: "number" }).notNull(), + }, + (table) => [...workspaceIndexes(table)], +) diff --git a/cloud/core/src/schema/key.sql.ts b/cloud/core/src/schema/key.sql.ts new file mode 100644 index 000000000..240736b86 --- /dev/null +++ b/cloud/core/src/schema/key.sql.ts @@ -0,0 +1,16 @@ +import { text, pgTable, varchar, uniqueIndex } from "drizzle-orm/pg-core" +import { timestamps, utc, workspaceColumns } from "../drizzle/types" +import { workspaceIndexes } from "./workspace.sql" + +export const KeyTable = pgTable( + "key", + { + ...workspaceColumns, + ...timestamps, + userID: text("user_id").notNull(), + name: varchar("name", { length: 255 }).notNull(), + key: varchar("key", { length: 255 }).notNull(), + timeUsed: utc("time_used"), + }, + (table) => [...workspaceIndexes(table), uniqueIndex("global_key").on(table.key)], +) diff --git a/cloud/core/src/schema/user.sql.ts b/cloud/core/src/schema/user.sql.ts new file mode 100644 index 000000000..34cbd6beb --- /dev/null +++ b/cloud/core/src/schema/user.sql.ts @@ -0,0 +1,16 @@ +import { text, pgTable, uniqueIndex, varchar, integer } from "drizzle-orm/pg-core" +import { timestamps, utc, workspaceColumns } from "../drizzle/types" +import { workspaceIndexes } from "./workspace.sql" + +export const UserTable = pgTable( + "user", + { + ...workspaceColumns, + ...timestamps, + email: text("email").notNull(), + name: varchar("name", { length: 255 }).notNull(), + timeSeen: utc("time_seen"), + color: integer("color"), + }, + (table) => [...workspaceIndexes(table), uniqueIndex("user_email").on(table.workspaceID, table.email)], +) diff --git a/cloud/core/src/schema/workspace.sql.ts b/cloud/core/src/schema/workspace.sql.ts new file mode 100644 index 000000000..3e9379e1f --- /dev/null +++ b/cloud/core/src/schema/workspace.sql.ts @@ -0,0 +1,25 @@ +import { primaryKey, foreignKey, pgTable, uniqueIndex, varchar } from "drizzle-orm/pg-core" +import { timestamps, ulid } from "../drizzle/types" + +export const WorkspaceTable = pgTable( + "workspace", + { + id: ulid("id").notNull().primaryKey(), + slug: varchar("slug", { length: 255 }), + name: varchar("name", { length: 255 }), + ...timestamps, + }, + (table) => [uniqueIndex("slug").on(table.slug)], +) + +export function workspaceIndexes(table: any) { + return [ + primaryKey({ + columns: [table.workspaceID, table.id], + }), + foreignKey({ + foreignColumns: [WorkspaceTable.id], + columns: [table.workspaceID], + }), + ] +} diff --git a/cloud/core/src/util/fn.ts b/cloud/core/src/util/fn.ts new file mode 100644 index 000000000..038a50719 --- /dev/null +++ b/cloud/core/src/util/fn.ts @@ -0,0 +1,14 @@ +import { z } from "zod" + +export function fn( + schema: T, + cb: (input: z.output) => Result, +) { + const result = (input: z.input) => { + const parsed = schema.parse(input) + return cb(parsed) + } + result.force = (input: z.input) => cb(input) + result.schema = schema + return result +} diff --git a/cloud/core/src/util/log.ts b/cloud/core/src/util/log.ts new file mode 100644 index 000000000..4f2d25c13 --- /dev/null +++ b/cloud/core/src/util/log.ts @@ -0,0 +1,55 @@ +import { Context } from "../context" + +export namespace Log { + const ctx = Context.create<{ + tags: Record + }>() + + export function create(tags?: Record) { + tags = tags || {} + + const result = { + info(message?: any, extra?: Record) { + const prefix = Object.entries({ + ...use().tags, + ...tags, + ...extra, + }) + .map(([key, value]) => `${key}=${value}`) + .join(" ") + console.log(prefix, message) + return result + }, + tag(key: string, value: string) { + if (tags) tags[key] = value + return result + }, + clone() { + return Log.create({ ...tags }) + }, + } + + return result + } + + export function provide(tags: Record, cb: () => R) { + const existing = use() + return ctx.provide( + { + tags: { + ...existing.tags, + ...tags, + }, + }, + cb, + ) + } + + function use() { + try { + return ctx.use() + } catch (e) { + return { tags: {} } + } + } +} diff --git a/cloud/core/src/util/price.ts b/cloud/core/src/util/price.ts new file mode 100644 index 000000000..abdbca032 --- /dev/null +++ b/cloud/core/src/util/price.ts @@ -0,0 +1,3 @@ +export function centsToMicroCents(amount: number) { + return Math.round(amount * 1000000) +} diff --git a/cloud/core/src/workspace.ts b/cloud/core/src/workspace.ts new file mode 100644 index 000000000..532b22963 --- /dev/null +++ b/cloud/core/src/workspace.ts @@ -0,0 +1,48 @@ +import { z } from "zod" +import { fn } from "./util/fn" +import { centsToMicroCents } from "./util/price" +import { Actor } from "./actor" +import { Database, eq } from "./drizzle" +import { Identifier } from "./identifier" +import { UserTable } from "./schema/user.sql" +import { BillingTable } from "./schema/billing.sql" +import { WorkspaceTable } from "./schema/workspace.sql" + +export namespace Workspace { + export const create = fn(z.void(), async () => { + const account = Actor.assert("account") + const workspaceID = Identifier.create("workspace") + await Database.transaction(async (tx) => { + await tx.insert(WorkspaceTable).values({ + id: workspaceID, + }) + await tx.insert(UserTable).values({ + workspaceID, + id: Identifier.create("user"), + email: account.properties.email, + name: "", + }) + await tx.insert(BillingTable).values({ + workspaceID, + id: Identifier.create("billing"), + balance: centsToMicroCents(100), + }) + }) + return workspaceID + }) + + export async function list() { + const account = Actor.assert("account") + return Database.use(async (tx) => { + return tx + .select({ + id: WorkspaceTable.id, + slug: WorkspaceTable.slug, + name: WorkspaceTable.name, + }) + .from(UserTable) + .innerJoin(WorkspaceTable, eq(UserTable.workspaceID, WorkspaceTable.id)) + .where(eq(UserTable.email, account.properties.email)) + }) + } +} diff --git a/cloud/core/sst-env.d.ts b/cloud/core/sst-env.d.ts new file mode 100644 index 000000000..b6a7e9066 --- /dev/null +++ b/cloud/core/sst-env.d.ts @@ -0,0 +1,9 @@ +/* This file is auto-generated by SST. Do not edit. */ +/* tslint:disable */ +/* eslint-disable */ +/* deno-fmt-ignore-file */ + +/// + +import "sst" +export {} \ No newline at end of file diff --git a/cloud/core/tsconfig.json b/cloud/core/tsconfig.json new file mode 100644 index 000000000..0faf16aab --- /dev/null +++ b/cloud/core/tsconfig.json @@ -0,0 +1,9 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "@tsconfig/node22/tsconfig.json", + "compilerOptions": { + "module": "ESNext", + "moduleResolution": "bundler", + "types": ["@cloudflare/workers-types", "node"] + } +} diff --git a/cloud/function/package.json b/cloud/function/package.json new file mode 100644 index 000000000..b48bcd741 --- /dev/null +++ b/cloud/function/package.json @@ -0,0 +1,23 @@ +{ + "name": "@opencode/cloud-function", + "version": "0.3.130", + "$schema": "https://json.schemastore.org/package.json", + "private": true, + "type": "module", + "devDependencies": { + "@cloudflare/workers-types": "4.20250522.0", + "@types/node": "catalog:", + "openai": "5.11.0", + "typescript": "catalog:" + }, + "dependencies": { + "@ai-sdk/anthropic": "2.0.0", + "@ai-sdk/openai": "2.0.2", + "@ai-sdk/openai-compatible": "1.0.1", + "@hono/zod-validator": "catalog:", + "@openauthjs/openauth": "0.0.0-20250322224806", + "ai": "catalog:", + "hono": "catalog:", + "zod": "catalog:" + } +} diff --git a/cloud/function/src/auth.ts b/cloud/function/src/auth.ts new file mode 100644 index 000000000..5eacb7a72 --- /dev/null +++ b/cloud/function/src/auth.ts @@ -0,0 +1,68 @@ +import { Resource } from "sst" +import { z } from "zod" +import { issuer } from "@openauthjs/openauth" +import { createSubjects } from "@openauthjs/openauth/subject" +import { GithubProvider } from "@openauthjs/openauth/provider/github" +import { CloudflareStorage } from "@openauthjs/openauth/storage/cloudflare" +import { Account } from "@opencode/cloud-core/account.js" + +type Env = { + AuthStorage: KVNamespace +} + +export const subjects = createSubjects({ + account: z.object({ + accountID: z.string(), + email: z.string(), + }), + user: z.object({ + userID: z.string(), + workspaceID: z.string(), + }), +}) + +export default { + async fetch(request: Request, env: Env, ctx: ExecutionContext) { + return issuer({ + providers: { + github: GithubProvider({ + clientID: Resource.GITHUB_CLIENT_ID_CONSOLE.value, + clientSecret: Resource.GITHUB_CLIENT_SECRET_CONSOLE.value, + scopes: ["read:user", "user:email"], + }), + }, + storage: CloudflareStorage({ + namespace: env.AuthStorage, + }), + subjects, + async success(ctx, response) { + console.log(response) + + let email: string | undefined + + if (response.provider === "github") { + const userResponse = await fetch("https://api.github.com/user", { + headers: { + Authorization: `Bearer ${response.tokenset.access}`, + "User-Agent": "opencode", + Accept: "application/vnd.github+json", + }, + }) + const user = (await userResponse.json()) as { email: string } + email = user.email + } else throw new Error("Unsupported provider") + + if (!email) throw new Error("No email found") + + let accountID = await Account.fromEmail(email).then((x) => x?.id) + if (!accountID) { + console.log("creating account for", email) + accountID = await Account.create({ + email: email!, + }) + } + return ctx.subject("account", accountID, { accountID, email }) + }, + }).fetch(request, env, ctx) + }, +} diff --git a/cloud/function/src/gateway.ts b/cloud/function/src/gateway.ts new file mode 100644 index 000000000..aefea7ac4 --- /dev/null +++ b/cloud/function/src/gateway.ts @@ -0,0 +1,887 @@ +import { z } from "zod" +import { Hono, MiddlewareHandler } from "hono" +import { cors } from "hono/cors" +import { HTTPException } from "hono/http-exception" +import { zValidator } from "@hono/zod-validator" +import { Resource } from "sst" +import { generateText, streamText } from "ai" +import { createAnthropic } from "@ai-sdk/anthropic" +import { createOpenAI } from "@ai-sdk/openai" +import { createOpenAICompatible } from "@ai-sdk/openai-compatible" +import type { LanguageModelV2Usage, LanguageModelV2Prompt } from "@ai-sdk/provider" +import { type ChatCompletionCreateParamsBase } from "openai/resources/chat/completions" +import { Actor } from "@opencode/cloud-core/actor.js" +import { and, Database, eq, sql } from "@opencode/cloud-core/drizzle/index.js" +import { UserTable } from "@opencode/cloud-core/schema/user.sql.js" +import { KeyTable } from "@opencode/cloud-core/schema/key.sql.js" +import { createClient } from "@openauthjs/openauth/client" +import { Log } from "@opencode/cloud-core/util/log.js" +import { Billing } from "@opencode/cloud-core/billing.js" +import { Workspace } from "@opencode/cloud-core/workspace.js" +import { BillingTable, PaymentTable, UsageTable } from "@opencode/cloud-core/schema/billing.sql.js" +import { centsToMicroCents } from "@opencode/cloud-core/util/price.js" +import { Identifier } from "../../core/src/identifier" + +type Env = {} + +let _client: ReturnType +const client = () => { + if (_client) return _client + _client = createClient({ + clientID: "api", + issuer: Resource.AUTH_API_URL.value, + }) + return _client +} + +const SUPPORTED_MODELS = { + "anthropic/claude-sonnet-4": { + input: 0.0000015, + output: 0.000006, + reasoning: 0.0000015, + cacheRead: 0.0000001, + cacheWrite: 0.0000001, + model: () => + createAnthropic({ + apiKey: Resource.ANTHROPIC_API_KEY.value, + })("claude-sonnet-4-20250514"), + }, + "openai/gpt-4.1": { + input: 0.0000015, + output: 0.000006, + reasoning: 0.0000015, + cacheRead: 0.0000001, + cacheWrite: 0.0000001, + model: () => + createOpenAI({ + apiKey: Resource.OPENAI_API_KEY.value, + })("gpt-4.1"), + }, + "zhipuai/glm-4.5-flash": { + input: 0, + output: 0, + reasoning: 0, + cacheRead: 0, + cacheWrite: 0, + model: () => + createOpenAICompatible({ + name: "Zhipu AI", + baseURL: "https://api.z.ai/api/paas/v4", + apiKey: Resource.ZHIPU_API_KEY.value, + })("glm-4.5-flash"), + }, +} + +const log = Log.create({ + namespace: "api", +}) + +const GatewayAuth: MiddlewareHandler = async (c, next) => { + const authHeader = c.req.header("authorization") + + if (!authHeader || !authHeader.startsWith("Bearer ")) { + return c.json( + { + error: { + message: "Missing API key.", + type: "invalid_request_error", + param: null, + code: "unauthorized", + }, + }, + 401, + ) + } + + const apiKey = authHeader.split(" ")[1] + + // Check against KeyTable + const keyRecord = await Database.use((tx) => + tx + .select({ + id: KeyTable.id, + workspaceID: KeyTable.workspaceID, + }) + .from(KeyTable) + .where(eq(KeyTable.key, apiKey)) + .then((rows) => rows[0]), + ) + + if (!keyRecord) { + return c.json( + { + error: { + message: "Invalid API key.", + type: "invalid_request_error", + param: null, + code: "unauthorized", + }, + }, + 401, + ) + } + + c.set("keyRecord", keyRecord) + await next() +} + +const RestAuth: MiddlewareHandler = async (c, next) => { + const authorization = c.req.header("authorization") + if (!authorization) { + return Actor.provide("public", {}, next) + } + const token = authorization.split(" ")[1] + if (!token) + throw new HTTPException(403, { + message: "Bearer token is required.", + }) + + const verified = await client().verify(token) + if (verified.err) { + throw new HTTPException(403, { + message: "Invalid token.", + }) + } + let subject = verified.subject as Actor.Info + if (subject.type === "account") { + const workspaceID = c.req.header("x-opencode-workspace") + const email = subject.properties.email + if (workspaceID) { + const user = await Database.use((tx) => + tx + .select({ + id: UserTable.id, + workspaceID: UserTable.workspaceID, + email: UserTable.email, + }) + .from(UserTable) + .where(and(eq(UserTable.email, email), eq(UserTable.workspaceID, workspaceID))) + .then((rows) => rows[0]), + ) + if (!user) + throw new HTTPException(403, { + message: "You do not have access to this workspace.", + }) + subject = { + type: "user", + properties: { + userID: user.id, + workspaceID: workspaceID, + email: user.email, + }, + } + } + } + await Actor.provide(subject.type, subject.properties, next) +} + +const app = new Hono<{ Bindings: Env; Variables: { keyRecord?: { id: string; workspaceID: string } } }>() + .get("/", (c) => c.text("Hello, world!")) + .post("/v1/chat/completions", GatewayAuth, async (c) => { + try { + const body = await c.req.json() + + console.log(body) + + const model = SUPPORTED_MODELS[body.model as keyof typeof SUPPORTED_MODELS]?.model() + if (!model) throw new Error(`Unsupported model: ${body.model}`) + + const requestBody = transformOpenAIRequestToAiSDK() + + return body.stream ? await handleStream() : await handleGenerate() + + async function handleStream() { + const result = await streamText({ + model, + ...requestBody, + }) + + const encoder = new TextEncoder() + const stream = new ReadableStream({ + async start(controller) { + const id = `chatcmpl-${Date.now()}` + const created = Math.floor(Date.now() / 1000) + + try { + for await (const chunk of result.fullStream) { + // TODO + //console.log("!!! CHUCK !!!", chunk); + switch (chunk.type) { + case "text-delta": { + const data = { + id, + object: "chat.completion.chunk", + created, + model: body.model, + choices: [ + { + index: 0, + delta: { + content: chunk.text, + }, + finish_reason: null, + }, + ], + } + controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`)) + break + } + + case "reasoning-delta": { + const data = { + id, + object: "chat.completion.chunk", + created, + model: body.model, + choices: [ + { + index: 0, + delta: { + reasoning_content: chunk.text, + }, + finish_reason: null, + }, + ], + } + controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`)) + break + } + + case "tool-call": { + const data = { + id, + object: "chat.completion.chunk", + created, + model: body.model, + choices: [ + { + index: 0, + delta: { + tool_calls: [ + { + id: chunk.toolCallId, + type: "function", + function: { + name: chunk.toolName, + arguments: JSON.stringify(chunk.input), + }, + }, + ], + }, + finish_reason: null, + }, + ], + } + controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`)) + break + } + + case "error": { + const data = { + id, + object: "chat.completion.chunk", + created, + model: body.model, + error: { + message: chunk.error, + type: "server_error", + }, + } + controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`)) + controller.enqueue(encoder.encode("data: [DONE]\n\n")) + controller.close() + break + } + + case "finish": { + const finishReason = + { + stop: "stop", + length: "length", + "content-filter": "content_filter", + "tool-calls": "tool_calls", + error: "stop", + other: "stop", + unknown: "stop", + }[chunk.finishReason] || "stop" + + const data = { + id, + object: "chat.completion.chunk", + created, + model: body.model, + choices: [ + { + index: 0, + delta: {}, + finish_reason: finishReason, + }, + ], + usage: { + prompt_tokens: chunk.totalUsage.inputTokens, + completion_tokens: chunk.totalUsage.outputTokens, + total_tokens: chunk.totalUsage.totalTokens, + completion_tokens_details: { + reasoning_tokens: chunk.totalUsage.reasoningTokens, + }, + prompt_tokens_details: { + cached_tokens: chunk.totalUsage.cachedInputTokens, + }, + }, + } + controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`)) + controller.enqueue(encoder.encode("data: [DONE]\n\n")) + controller.close() + break + } + + //case "stream-start": + //case "response-metadata": + case "start-step": + case "finish-step": + case "text-start": + case "text-end": + case "reasoning-start": + case "reasoning-end": + case "tool-input-start": + case "tool-input-delta": + case "tool-input-end": + case "raw": + default: + // Log unknown chunk types for debugging + console.warn(`Unknown chunk type: ${(chunk as any).type}`) + break + } + } + } catch (error) { + controller.error(error) + } + }, + }) + + return new Response(stream, { + headers: { + "Content-Type": "text/plain; charset=utf-8", + "Cache-Control": "no-cache", + Connection: "keep-alive", + }, + }) + } + + async function handleGenerate() { + const response = await generateText({ + model, + ...requestBody, + }) + await trackUsage(body.model, response.usage) + return c.json({ + id: `chatcmpl-${Date.now()}`, + object: "chat.completion" as const, + created: Math.floor(Date.now() / 1000), + model: body.model, + choices: [ + { + index: 0, + message: { + role: "assistant" as const, + content: response.content?.find((c) => c.type === "text")?.text ?? "", + reasoning_content: response.content?.find((c) => c.type === "reasoning")?.text, + tool_calls: response.content + ?.filter((c) => c.type === "tool-call") + .map((toolCall) => ({ + id: toolCall.toolCallId, + type: "function" as const, + function: { + name: toolCall.toolName, + arguments: toolCall.input, + }, + })), + }, + finish_reason: + ( + { + stop: "stop", + length: "length", + "content-filter": "content_filter", + "tool-calls": "tool_calls", + error: "stop", + other: "stop", + unknown: "stop", + } as const + )[response.finishReason] || "stop", + }, + ], + usage: { + prompt_tokens: response.usage?.inputTokens, + completion_tokens: response.usage?.outputTokens, + total_tokens: response.usage?.totalTokens, + completion_tokens_details: { + reasoning_tokens: response.usage?.reasoningTokens, + }, + prompt_tokens_details: { + cached_tokens: response.usage?.cachedInputTokens, + }, + }, + }) + } + + function transformOpenAIRequestToAiSDK() { + const prompt = transformMessages() + + return { + prompt, + maxOutputTokens: body.max_tokens ?? body.max_completion_tokens ?? undefined, + temperature: body.temperature ?? undefined, + topP: body.top_p ?? undefined, + frequencyPenalty: body.frequency_penalty ?? undefined, + presencePenalty: body.presence_penalty ?? undefined, + providerOptions: body.reasoning_effort + ? { + anthropic: { + reasoningEffort: body.reasoning_effort, + }, + } + : undefined, + stopSequences: (typeof body.stop === "string" ? [body.stop] : body.stop) ?? undefined, + responseFormat: (() => { + if (!body.response_format) return { type: "text" } + if (body.response_format.type === "json_schema") + return { + type: "json", + schema: body.response_format.json_schema.schema, + name: body.response_format.json_schema.name, + description: body.response_format.json_schema.description, + } + if (body.response_format.type === "json_object") return { type: "json" } + throw new Error("Unsupported response format") + })(), + seed: body.seed ?? undefined, + } + + function transformTools() { + const { tools, tool_choice } = body + + if (!tools || tools.length === 0) { + return { tools: undefined, toolChoice: undefined } + } + + const aiSdkTools = tools.reduce( + (acc, tool) => { + acc[tool.function.name] = { + type: "function" as const, + name: tool.function.name, + description: tool.function.description, + inputSchema: tool.function.parameters, + } + return acc + }, + {} as Record, + ) + + let aiSdkToolChoice + if (tool_choice == null) { + aiSdkToolChoice = undefined + } else if (tool_choice === "auto") { + aiSdkToolChoice = "auto" + } else if (tool_choice === "none") { + aiSdkToolChoice = "none" + } else if (tool_choice === "required") { + aiSdkToolChoice = "required" + } else if (tool_choice.type === "function") { + aiSdkToolChoice = { + type: "tool", + toolName: tool_choice.function.name, + } + } + + return { tools: aiSdkTools, toolChoice: aiSdkToolChoice } + } + + function transformMessages() { + const { messages } = body + const prompt: LanguageModelV2Prompt = [] + + for (const message of messages) { + switch (message.role) { + case "system": { + prompt.push({ + role: "system", + content: message.content as string, + }) + break + } + + case "user": { + if (typeof message.content === "string") { + prompt.push({ + role: "user", + content: [{ type: "text", text: message.content }], + }) + } else { + const content = message.content.map((part) => { + switch (part.type) { + case "text": + return { type: "text" as const, text: part.text } + case "image_url": + return { + type: "file" as const, + mediaType: "image/jpeg" as const, + data: part.image_url.url, + } + default: + throw new Error(`Unsupported content part type: ${(part as any).type}`) + } + }) + prompt.push({ + role: "user", + content, + }) + } + break + } + + case "assistant": { + const content: Array< + | { type: "text"; text: string } + | { + type: "tool-call" + toolCallId: string + toolName: string + input: any + } + > = [] + + if (message.content) { + content.push({ + type: "text", + text: message.content as string, + }) + } + + if (message.tool_calls) { + for (const toolCall of message.tool_calls) { + content.push({ + type: "tool-call", + toolCallId: toolCall.id, + toolName: toolCall.function.name, + input: JSON.parse(toolCall.function.arguments), + }) + } + } + + prompt.push({ + role: "assistant", + content, + }) + break + } + + case "tool": { + prompt.push({ + role: "tool", + content: [ + { + type: "tool-result", + toolName: "placeholder", + toolCallId: message.tool_call_id, + output: { + type: "text", + value: message.content as string, + }, + }, + ], + }) + break + } + + default: { + throw new Error(`Unsupported message role: ${message.role}`) + } + } + } + + return prompt + } + } + + async function trackUsage(model: string, usage: LanguageModelV2Usage) { + const keyRecord = c.get("keyRecord") + if (!keyRecord) return + + const modelData = SUPPORTED_MODELS[model as keyof typeof SUPPORTED_MODELS] + if (!modelData) throw new Error(`Unsupported model: ${model}`) + + const inputCost = modelData.input * (usage.inputTokens ?? 0) + const outputCost = modelData.output * (usage.outputTokens ?? 0) + const reasoningCost = modelData.reasoning * (usage.reasoningTokens ?? 0) + const cacheReadCost = modelData.cacheRead * (usage.cachedInputTokens ?? 0) + const cacheWriteCost = modelData.cacheWrite * (usage.outputTokens ?? 0) + + const totalCost = inputCost + outputCost + reasoningCost + cacheReadCost + cacheWriteCost + + await Actor.provide("system", { workspaceID: keyRecord.workspaceID }, async () => { + await Billing.consume({ + model, + inputTokens: usage.inputTokens ?? 0, + outputTokens: usage.outputTokens ?? 0, + reasoningTokens: usage.reasoningTokens ?? 0, + cacheReadTokens: usage.cachedInputTokens ?? 0, + cacheWriteTokens: usage.outputTokens ?? 0, + costInCents: totalCost * 100, + }) + }) + + await Database.use((tx) => + tx + .update(KeyTable) + .set({ timeUsed: sql`now()` }) + .where(eq(KeyTable.id, keyRecord.id)), + ) + } + } catch (error: any) { + return c.json({ error: { message: error.message } }, 500) + } + }) + .use("/*", cors()) + .use(RestAuth) + .get("/rest/account", async (c) => { + const account = Actor.assert("account") + let workspaces = await Workspace.list() + if (workspaces.length === 0) { + await Workspace.create() + workspaces = await Workspace.list() + } + return c.json({ + id: account.properties.accountID, + email: account.properties.email, + workspaces, + }) + }) + .get("/billing/info", async (c) => { + const billing = await Billing.get() + const payments = await Database.use((tx) => + tx + .select() + .from(PaymentTable) + .where(eq(PaymentTable.workspaceID, Actor.workspace())) + .orderBy(sql`${PaymentTable.timeCreated} DESC`) + .limit(100), + ) + const usage = await Database.use((tx) => + tx + .select() + .from(UsageTable) + .where(eq(UsageTable.workspaceID, Actor.workspace())) + .orderBy(sql`${UsageTable.timeCreated} DESC`) + .limit(100), + ) + return c.json({ billing, payments, usage }) + }) + .post( + "/billing/checkout", + zValidator( + "json", + z.custom<{ + success_url: string + cancel_url: string + }>(), + ), + async (c) => { + const account = Actor.assert("user") + + const body = await c.req.json() + + const customer = await Billing.get() + const session = await Billing.stripe().checkout.sessions.create({ + mode: "payment", + line_items: [ + { + price_data: { + currency: "usd", + product_data: { + name: "OpenControl credits", + }, + unit_amount: 2000, // $20 minimum + }, + quantity: 1, + }, + ], + payment_intent_data: { + setup_future_usage: "on_session", + }, + ...(customer.customerID + ? { customer: customer.customerID } + : { + customer_email: account.properties.email, + customer_creation: "always", + }), + metadata: { + workspaceID: Actor.workspace(), + }, + currency: "usd", + payment_method_types: ["card"], + success_url: body.success_url, + cancel_url: body.cancel_url, + }) + + return c.json({ + url: session.url, + }) + }, + ) + .post("/billing/portal", async (c) => { + const body = await c.req.json() + + const customer = await Billing.get() + if (!customer?.customerID) { + throw new Error("No stripe customer ID") + } + + const session = await Billing.stripe().billingPortal.sessions.create({ + customer: customer.customerID, + return_url: body.return_url, + }) + + return c.json({ + url: session.url, + }) + }) + .post("/stripe/webhook", async (c) => { + const body = await Billing.stripe().webhooks.constructEventAsync( + await c.req.text(), + c.req.header("stripe-signature")!, + Resource.STRIPE_WEBHOOK_SECRET.value, + ) + + console.log(body.type, JSON.stringify(body, null, 2)) + if (body.type === "checkout.session.completed") { + const workspaceID = body.data.object.metadata?.workspaceID + const customerID = body.data.object.customer as string + const paymentID = body.data.object.payment_intent as string + const amount = body.data.object.amount_total + + if (!workspaceID) throw new Error("Workspace ID not found") + if (!customerID) throw new Error("Customer ID not found") + if (!amount) throw new Error("Amount not found") + if (!paymentID) throw new Error("Payment ID not found") + + await Actor.provide("system", { workspaceID }, async () => { + const customer = await Billing.get() + if (customer?.customerID && customer.customerID !== customerID) throw new Error("Customer ID mismatch") + + // set customer metadata + if (!customer?.customerID) { + await Billing.stripe().customers.update(customerID, { + metadata: { + workspaceID, + }, + }) + } + + // get payment method for the payment intent + const paymentIntent = await Billing.stripe().paymentIntents.retrieve(paymentID, { + expand: ["payment_method"], + }) + const paymentMethod = paymentIntent.payment_method + if (!paymentMethod || typeof paymentMethod === "string") throw new Error("Payment method not expanded") + + await Database.transaction(async (tx) => { + await tx + .update(BillingTable) + .set({ + balance: sql`${BillingTable.balance} + ${centsToMicroCents(amount)}`, + customerID, + paymentMethodID: paymentMethod.id, + paymentMethodLast4: paymentMethod.card!.last4, + }) + .where(eq(BillingTable.workspaceID, workspaceID)) + await tx.insert(PaymentTable).values({ + workspaceID, + id: Identifier.create("payment"), + amount: centsToMicroCents(amount), + paymentID, + customerID, + }) + }) + }) + } + + console.log("finished handling") + + return c.json("ok", 200) + }) + .get("/keys", async (c) => { + const user = Actor.assert("user") + + const keys = await Database.use((tx) => + tx + .select({ + id: KeyTable.id, + name: KeyTable.name, + key: KeyTable.key, + userID: KeyTable.userID, + timeCreated: KeyTable.timeCreated, + timeUsed: KeyTable.timeUsed, + }) + .from(KeyTable) + .where(eq(KeyTable.workspaceID, user.properties.workspaceID)) + .orderBy(sql`${KeyTable.timeCreated} DESC`), + ) + + return c.json({ keys }) + }) + .post("/keys", zValidator("json", z.object({ name: z.string().min(1).max(255) })), async (c) => { + const user = Actor.assert("user") + const { name } = c.req.valid("json") + + // Generate secret key: sk- + 64 random characters (upper, lower, numbers) + const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789" + let randomPart = "" + for (let i = 0; i < 64; i++) { + randomPart += chars.charAt(Math.floor(Math.random() * chars.length)) + } + const secretKey = `sk-${randomPart}` + + const keyRecord = await Database.use((tx) => + tx + .insert(KeyTable) + .values({ + id: Identifier.create("key"), + workspaceID: user.properties.workspaceID, + userID: user.properties.userID, + name, + key: secretKey, + timeUsed: null, + }) + .returning(), + ) + + return c.json({ + key: secretKey, + id: keyRecord[0].id, + name: keyRecord[0].name, + created: keyRecord[0].timeCreated, + }) + }) + .delete("/keys/:id", async (c) => { + const user = Actor.assert("user") + const keyId = c.req.param("id") + + const result = await Database.use((tx) => + tx + .delete(KeyTable) + .where(and(eq(KeyTable.id, keyId), eq(KeyTable.workspaceID, user.properties.workspaceID))) + .returning({ id: KeyTable.id }), + ) + + if (result.length === 0) { + return c.json({ error: "Key not found" }, 404) + } + + return c.json({ success: true, id: result[0].id }) + }) + .all("*", (c) => c.text("Not Found")) + +export type ApiType = typeof app + +export default app diff --git a/cloud/function/sst-env.d.ts b/cloud/function/sst-env.d.ts new file mode 100644 index 000000000..4e2b15921 --- /dev/null +++ b/cloud/function/sst-env.d.ts @@ -0,0 +1,88 @@ +/* This file is auto-generated by SST. Do not edit. */ +/* tslint:disable */ +/* eslint-disable */ +/* deno-fmt-ignore-file */ + +import "sst" +declare module "sst" { + export interface Resource { + "ANTHROPIC_API_KEY": { + "type": "sst.sst.Secret" + "value": string + } + "AUTH_API_URL": { + "type": "sst.sst.Linkable" + "value": string + } + "Console": { + "type": "sst.cloudflare.StaticSite" + "url": string + } + "DATABASE_PASSWORD": { + "type": "sst.sst.Secret" + "value": string + } + "DATABASE_USERNAME": { + "type": "sst.sst.Secret" + "value": string + } + "Database": { + "database": string + "host": string + "password": string + "port": number + "type": "sst.sst.Linkable" + "username": string + } + "GITHUB_APP_ID": { + "type": "sst.sst.Secret" + "value": string + } + "GITHUB_APP_PRIVATE_KEY": { + "type": "sst.sst.Secret" + "value": string + } + "GITHUB_CLIENT_ID_CONSOLE": { + "type": "sst.sst.Secret" + "value": string + } + "GITHUB_CLIENT_SECRET_CONSOLE": { + "type": "sst.sst.Secret" + "value": string + } + "OPENAI_API_KEY": { + "type": "sst.sst.Secret" + "value": string + } + "STRIPE_SECRET_KEY": { + "type": "sst.sst.Secret" + "value": string + } + "STRIPE_WEBHOOK_SECRET": { + "type": "sst.sst.Linkable" + "value": string + } + "Web": { + "type": "sst.cloudflare.Astro" + "url": string + } + "ZHIPU_API_KEY": { + "type": "sst.sst.Secret" + "value": string + } + } +} +// cloudflare +import * as cloudflare from "@cloudflare/workers-types"; +declare module "sst" { + export interface Resource { + "Api": cloudflare.Service + "AuthApi": cloudflare.Service + "AuthStorage": cloudflare.KVNamespace + "Bucket": cloudflare.R2Bucket + "GatewayApi": cloudflare.Service + } +} + +import "sst" +export {} \ No newline at end of file diff --git a/cloud/function/tsconfig.json b/cloud/function/tsconfig.json new file mode 100644 index 000000000..0faf16aab --- /dev/null +++ b/cloud/function/tsconfig.json @@ -0,0 +1,9 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "@tsconfig/node22/tsconfig.json", + "compilerOptions": { + "module": "ESNext", + "moduleResolution": "bundler", + "types": ["@cloudflare/workers-types", "node"] + } +} diff --git a/cloud/web/.gitignore b/cloud/web/.gitignore new file mode 100644 index 000000000..76add878f --- /dev/null +++ b/cloud/web/.gitignore @@ -0,0 +1,2 @@ +node_modules +dist \ No newline at end of file diff --git a/cloud/web/index.html b/cloud/web/index.html new file mode 100644 index 000000000..55c54c1f1 --- /dev/null +++ b/cloud/web/index.html @@ -0,0 +1,38 @@ + + + + + + OpenControl + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + diff --git a/cloud/web/npm-debug.log b/cloud/web/npm-debug.log new file mode 100644 index 000000000..07b0649fe --- /dev/null +++ b/cloud/web/npm-debug.log @@ -0,0 +1,29 @@ +0 info it worked if it ends with ok +1 verbose cli [ +1 verbose cli '/usr/local/bin/node', +1 verbose cli '/Users/frank/Sites/opencode/node_modules/.bin/npm', +1 verbose cli 'run', +1 verbose cli 'dev' +1 verbose cli ] +2 info using npm@2.15.12 +3 info using node@v20.18.1 +4 verbose stack Error: Invalid name: "@opencode/cloud/web" +4 verbose stack at ensureValidName (/Users/frank/Sites/opencode/node_modules/npm/node_modules/normalize-package-data/lib/fixer.js:336:15) +4 verbose stack at Object.fixNameField (/Users/frank/Sites/opencode/node_modules/npm/node_modules/normalize-package-data/lib/fixer.js:215:5) +4 verbose stack at /Users/frank/Sites/opencode/node_modules/npm/node_modules/normalize-package-data/lib/normalize.js:32:38 +4 verbose stack at Array.forEach () +4 verbose stack at normalize (/Users/frank/Sites/opencode/node_modules/npm/node_modules/normalize-package-data/lib/normalize.js:31:15) +4 verbose stack at final (/Users/frank/Sites/opencode/node_modules/npm/node_modules/read-package-json/read-json.js:349:5) +4 verbose stack at then (/Users/frank/Sites/opencode/node_modules/npm/node_modules/read-package-json/read-json.js:124:5) +4 verbose stack at ReadFileContext. (/Users/frank/Sites/opencode/node_modules/npm/node_modules/read-package-json/read-json.js:295:20) +4 verbose stack at ReadFileContext.callback (/Users/frank/Sites/opencode/node_modules/npm/node_modules/graceful-fs/graceful-fs.js:78:16) +4 verbose stack at FSReqCallback.readFileAfterOpen [as oncomplete] (node:fs:299:13) +5 verbose cwd /Users/frank/Sites/opencode/cloud/web +6 error Darwin 24.5.0 +7 error argv "/usr/local/bin/node" "/Users/frank/Sites/opencode/node_modules/.bin/npm" "run" "dev" +8 error node v20.18.1 +9 error npm v2.15.12 +10 error Invalid name: "@opencode/cloud/web" +11 error If you need help, you may report this error at: +11 error +12 verbose exit [ 1, true ] diff --git a/cloud/web/package.json b/cloud/web/package.json new file mode 100644 index 000000000..b39a77723 --- /dev/null +++ b/cloud/web/package.json @@ -0,0 +1,32 @@ +{ + "name": "@opencode/cloud-web", + "version": "0.0.0", + "private": true, + "description": "", + "type": "module", + "scripts": { + "start": "vite", + "dev": "vite", + "build": "bun build:server && bun build:client", + "build:client": "vite build --outDir dist/client", + "build:server": "vite build --ssr src/entry-server.tsx --outDir dist/server", + "serve": "vite preview", + "sst:dev": "bun sst shell --target Console -- bun dev" + }, + "license": "MIT", + "devDependencies": { + "typescript": "catalog:", + "vite": "6.2.2", + "vite-plugin-pages": "0.32.5", + "vite-plugin-solid": "2.11.6" + }, + "dependencies": { + "@kobalte/core": "0.13.9", + "@openauthjs/solid": "0.0.0-20250322224806", + "@solid-primitives/storage": "4.3.1", + "@solidjs/meta": "0.29.4", + "@solidjs/router": "0.15.3", + "solid-js": "1.9.5", + "solid-list": "0.3.0" + } +} diff --git a/cloud/web/public/favicon-dark.svg b/cloud/web/public/favicon-dark.svg new file mode 100644 index 000000000..9b707ea49 --- /dev/null +++ b/cloud/web/public/favicon-dark.svg @@ -0,0 +1,3 @@ + + + diff --git a/cloud/web/public/favicon.ico b/cloud/web/public/favicon.ico new file mode 100644 index 000000000..0ed3bf15e Binary files /dev/null and b/cloud/web/public/favicon.ico differ diff --git a/cloud/web/public/favicon.svg b/cloud/web/public/favicon.svg new file mode 100644 index 000000000..5e7cf124f --- /dev/null +++ b/cloud/web/public/favicon.svg @@ -0,0 +1,3 @@ + + + diff --git a/cloud/web/public/social-share.png b/cloud/web/public/social-share.png new file mode 100644 index 000000000..72d36a972 Binary files /dev/null and b/cloud/web/public/social-share.png differ diff --git a/cloud/web/scripts/render.mjs b/cloud/web/scripts/render.mjs new file mode 100644 index 000000000..5ccb35ff1 --- /dev/null +++ b/cloud/web/scripts/render.mjs @@ -0,0 +1,24 @@ +import fs from "fs" +import path from "path" +import { generateHydrationScript, getAssets } from "solid-js/web" + +const dist = import.meta.resolve("../dist").replace("file://", "") +const serverEntry = await import("../dist/server/entry-server.js") +const template = fs.readFileSync(path.join(dist, "client/index.html"), "utf-8") +fs.writeFileSync(path.join(dist, "client/fallback.html"), template) + +const routes = ["/", "/foo"] +for (const route of routes) { + const { app } = serverEntry.render({ url: route }) + const html = template + .replace("", app) + .replace("", generateHydrationScript()) + .replace("", getAssets()) + const filePath = dist + `/client${route === "/" ? "/index" : route}.html` + fs.mkdirSync(path.dirname(filePath), { + recursive: true, + }) + fs.writeFileSync(filePath, html) + + console.log(`Pre-rendered: ${filePath}`) +} diff --git a/cloud/web/src/app.tsx b/cloud/web/src/app.tsx new file mode 100644 index 000000000..aae71ddde --- /dev/null +++ b/cloud/web/src/app.tsx @@ -0,0 +1,42 @@ +/// + +import { Router } from "@solidjs/router" +import routes from "~solid-pages" +import "./ui/style/index.css" +import { MetaProvider } from "@solidjs/meta" +import { AccountProvider } from "./components/context-account" +import { DialogProvider } from "./ui/context-dialog" +import { DialogString } from "./ui/dialog-string" +import { DialogSelect } from "./ui/dialog-select" +import { ThemeProvider } from "./components/context-theme" +import { Suspense } from "solid-js" +import { OpenAuthProvider } from "./components/context-openauth" + +export function App(props: { url?: string }) { + return ( + + + + + + + + + { + return <>{props.children} + }} + /> + + + + + + + ) +} diff --git a/cloud/web/src/assets/screenshot.png b/cloud/web/src/assets/screenshot.png new file mode 100644 index 000000000..5b6ad2ec6 Binary files /dev/null and b/cloud/web/src/assets/screenshot.png differ diff --git a/cloud/web/src/components/context-account.tsx b/cloud/web/src/components/context-account.tsx new file mode 100644 index 000000000..e6aabafd3 --- /dev/null +++ b/cloud/web/src/components/context-account.tsx @@ -0,0 +1,99 @@ +import { createContext, createEffect, ParentProps, Suspense, useContext } from "solid-js" +import { makePersisted } from "@solid-primitives/storage" +import { createStore } from "solid-js/store" +import { useOpenAuth } from "./context-openauth" +import { createAsync } from "@solidjs/router" +import { isServer } from "solid-js/web" + +type Storage = { + accounts: Record< + string, + { + id: string + email: string + workspaces: { + id: string + name: string + slug: string + }[] + } + > +} + +const context = createContext>() + +function init() { + const auth = useOpenAuth() + const [store, setStore] = makePersisted( + createStore({ + accounts: {}, + }), + { + name: "opencontrol.account", + }, + ) + + async function refresh(id: string) { + return fetch(import.meta.env.VITE_API_URL + "/rest/account", { + headers: { + authorization: `Bearer ${await auth.access(id)}`, + }, + }) + .then((val) => val.json()) + .then((val) => setStore("accounts", id, val as any)) + } + + createEffect((previous: string[]) => { + if (Object.keys(auth.all).length === 0) { + return [] + } + for (const item of Object.values(auth.all)) { + if (previous.includes(item.id)) continue + refresh(item.id) + } + return Object.keys(auth.all) + }, [] as string[]) + + const result = { + get all() { + return Object.keys(auth.all) + .map((id) => store.accounts[id]) + .filter(Boolean) + }, + get current() { + if (!auth.subject) return undefined + return store.accounts[auth.subject.id] + }, + refresh, + get ready() { + return Object.keys(auth.all).length === result.all.length + }, + } + + return result +} + +export function AccountProvider(props: ParentProps) { + const ctx = init() + const resource = createAsync(async () => { + await new Promise((resolve) => { + if (isServer) return resolve() + createEffect(() => { + if (ctx.ready) resolve() + }) + }) + return null + }) + return ( + + {resource()} + {props.children} + + ) +} + +export function useAccount() { + const result = useContext(context) + if (!result) throw new Error("no account context") + return result +} diff --git a/cloud/web/src/components/context-openauth.tsx b/cloud/web/src/components/context-openauth.tsx new file mode 100644 index 000000000..bd6a45dd1 --- /dev/null +++ b/cloud/web/src/components/context-openauth.tsx @@ -0,0 +1,180 @@ +import { createClient } from "@openauthjs/openauth/client" +import { makePersisted } from "@solid-primitives/storage" +import { createAsync } from "@solidjs/router" +import { + batch, + createContext, + createEffect, + createResource, + createSignal, + onMount, + ParentProps, + Show, + Suspense, + useContext, +} from "solid-js" +import { createStore, produce } from "solid-js/store" +import { isServer } from "solid-js/web" + +interface Storage { + subjects: Record + current?: string +} + +interface Context { + all: Record + subject?: SubjectInfo + switch(id: string): void + logout(id: string): void + access(id?: string): Promise + authorize(opts?: AuthorizeOptions): void +} + +export interface AuthorizeOptions { + redirectPath?: string + provider?: string +} + +interface SubjectInfo { + id: string + refresh: string +} + +interface AuthContextOpts { + issuer: string + clientID: string +} + +const context = createContext() + +export function OpenAuthProvider(props: ParentProps) { + const client = createClient({ + issuer: props.issuer, + clientID: props.clientID, + }) + const [storage, setStorage] = makePersisted( + createStore({ + subjects: {}, + }), + { + name: `${props.issuer}.auth`, + }, + ) + + const resource = createAsync(async () => { + if (isServer) return true + const hash = new URLSearchParams(window.location.search.substring(1)) + const code = hash.get("code") + const state = hash.get("state") + if (code && state) { + const oldState = sessionStorage.getItem("openauth.state") + const verifier = sessionStorage.getItem("openauth.verifier") + const redirect = sessionStorage.getItem("openauth.redirect") + if (redirect && verifier && oldState === state) { + const result = await client.exchange(code, redirect, verifier) + if (!result.err) { + const id = result.tokens.refresh.split(":").slice(0, -1).join(":") + batch(() => { + setStorage("subjects", id, { + id: id, + refresh: result.tokens.refresh, + }) + setStorage("current", id) + }) + } + } + } + return true + }) + + async function authorize(opts?: AuthorizeOptions) { + const redirect = new URL(window.location.origin + (opts?.redirectPath ?? "/")).toString() + const authorize = await client.authorize(redirect, "code", { + pkce: true, + provider: opts?.provider, + }) + sessionStorage.setItem("openauth.state", authorize.challenge.state) + sessionStorage.setItem("openauth.redirect", redirect) + if (authorize.challenge.verifier) sessionStorage.setItem("openauth.verifier", authorize.challenge.verifier) + window.location.href = authorize.url + } + + const accessCache = new Map() + const pendingRequests = new Map>() + async function access(id: string) { + const pending = pendingRequests.get(id) + if (pending) return pending + const promise = (async () => { + const existing = accessCache.get(id) + const subject = storage.subjects[id] + const access = await client.refresh(subject.refresh, { + access: existing, + }) + if (access.err) { + pendingRequests.delete(id) + ctx.logout(id) + return + } + if (access.tokens) { + setStorage("subjects", id, "refresh", access.tokens.refresh) + accessCache.set(id, access.tokens.access) + } + pendingRequests.delete(id) + return access.tokens?.access || existing! + })() + pendingRequests.set(id, promise) + return promise + } + + const ctx: Context = { + get all() { + return storage.subjects + }, + get subject() { + if (!storage.current) return + return storage.subjects[storage.current!] + }, + switch(id: string) { + if (!storage.subjects[id]) return + setStorage("current", id) + }, + authorize, + logout(id: string) { + if (!storage.subjects[id]) return + setStorage( + produce((s) => { + delete s.subjects[id] + if (s.current === id) s.current = Object.keys(s.subjects)[0] + }), + ) + }, + async access(id?: string) { + id = id || storage.current + if (!id) return + return access(id || storage.current!) + }, + } + + createEffect(() => { + if (!resource()) return + if (storage.current) return + const [first] = Object.keys(storage.subjects) + if (first) { + setStorage("current", first) + return + } + }) + + return ( + <> + {resource()} + {props.children} + + ) +} + +export function useOpenAuth() { + const result = useContext(context) + if (!result) throw new Error("no auth context") + return result +} diff --git a/cloud/web/src/components/context-theme.tsx b/cloud/web/src/components/context-theme.tsx new file mode 100644 index 000000000..7800aeca0 --- /dev/null +++ b/cloud/web/src/components/context-theme.tsx @@ -0,0 +1,39 @@ +import { createStore } from "solid-js/store" +import { makePersisted } from "@solid-primitives/storage" +import { createEffect } from "solid-js" +import { createInitializedContext } from "../util/context" +import { isServer } from "solid-js/web" + +interface Storage { + mode: "light" | "dark" +} + +export const { provider: ThemeProvider, use: useTheme } = + createInitializedContext("ThemeContext", () => { + const [store, setStore] = makePersisted( + createStore({ + mode: + !isServer && + window.matchMedia && + window.matchMedia("(prefers-color-scheme: dark)").matches + ? "dark" + : "light", + }), + { + name: "theme", + }, + ) + createEffect(() => { + document.documentElement.setAttribute("data-color-mode", store.mode) + }) + + return { + setMode(mode: Storage["mode"]) { + setStore("mode", mode) + }, + get mode() { + return store.mode + }, + ready: true, + } + }) diff --git a/cloud/web/src/entry-client.tsx b/cloud/web/src/entry-client.tsx new file mode 100644 index 000000000..169e45a1e --- /dev/null +++ b/cloud/web/src/entry-client.tsx @@ -0,0 +1,13 @@ +/* @refresh reload */ + +import { hydrate, render } from "solid-js/web" +import { App } from "./app" + +if (import.meta.env.DEV) { + render(() => , document.getElementById("root")!) +} + +if (!import.meta.env.DEV) { + if ("_$HY" in window) hydrate(() => , document.getElementById("root")!) + else render(() => , document.getElementById("root")!) +} diff --git a/cloud/web/src/entry-server.tsx b/cloud/web/src/entry-server.tsx new file mode 100644 index 000000000..5dd33a149 --- /dev/null +++ b/cloud/web/src/entry-server.tsx @@ -0,0 +1,7 @@ +import { renderToStringAsync } from "solid-js/web" +import { App } from "./app" + +export async function render(props: { url: string }) { + const app = await renderToStringAsync(() => ) + return { app } +} diff --git a/cloud/web/src/pages/[workspace].tsx b/cloud/web/src/pages/[workspace].tsx new file mode 100644 index 000000000..c7481cb0d --- /dev/null +++ b/cloud/web/src/pages/[workspace].tsx @@ -0,0 +1,11 @@ +import { WorkspaceProvider } from "./components/context-workspace" +import { ParentProps } from "solid-js" +import Layout from "./components/layout" + +export default function Index(props: ParentProps) { + return ( + + {props.children} + + ) +} diff --git a/cloud/web/src/pages/[workspace]/billing.module.css b/cloud/web/src/pages/[workspace]/billing.module.css new file mode 100644 index 000000000..5e58892a5 --- /dev/null +++ b/cloud/web/src/pages/[workspace]/billing.module.css @@ -0,0 +1,56 @@ +.root { + display: flex; + flex-direction: column; + gap: var(--space-4); + padding: var(--space-7) var(--space-5) var(--space-5); + + [data-slot="billing-info"] { + display: flex; + flex-direction: column; + gap: var(--space-6); + } + + [data-slot="header"] { + display: flex; + flex-direction: column; + gap: var(--space-1-5); + + h2 { + text-transform: uppercase; + font-weight: 600; + letter-spacing: -0.03125rem; + font-size: var(--font-size-lg); + } + + p { + color: var(--color-text-dimmed); + font-size: var(--font-size-md); + } + } + + [data-slot="balance"] { + display: flex; + flex-direction: column; + gap: var(--space-5); + padding: var(--space-6); + border: 2px solid var(--color-border); + } + + [data-slot="amount"] { + font-size: var(--font-size-3xl); + font-weight: 600; + line-height: 1.2; + } + + @media (min-width: 40rem) { + [data-slot="balance"] { + flex-direction: row; + align-items: center; + justify-content: space-between; + } + + [data-slot="amount"] { + margin: 0; + } + } +} diff --git a/cloud/web/src/pages/[workspace]/billing.tsx b/cloud/web/src/pages/[workspace]/billing.tsx new file mode 100644 index 000000000..88bef5800 --- /dev/null +++ b/cloud/web/src/pages/[workspace]/billing.tsx @@ -0,0 +1,132 @@ +import { Button } from "../../ui/button" +import { useApi } from "../components/context-api" +import { createEffect, createSignal, createResource, For } from "solid-js" +import { useWorkspace } from "../components/context-workspace" +import style from "./billing.module.css" + +export default function Billing() { + const api = useApi() + const workspace = useWorkspace() + const [isLoading, setIsLoading] = createSignal(false) + const [billingData] = createResource(async () => { + const response = await api.billing.info.$get() + return response.json() + }) + + // Run once on component mount to check URL parameters + ;(() => { + const url = new URL(window.location.href) + const result = url.hash + + console.log("STRIPE RESULT", result) + + if (url.hash === "#success") { + setIsLoading(true) + // Remove the hash from the URL + window.history.replaceState(null, "", window.location.pathname + window.location.search) + } + })() + + createEffect((old?: number) => { + if (old && old !== billingData()?.billing?.balance) { + setIsLoading(false) + } + return billingData()?.billing?.balance + }) + + const handleBuyCredits = async () => { + try { + setIsLoading(true) + const baseUrl = window.location.href + const successUrl = new URL(baseUrl) + successUrl.hash = "success" + + const response = await api.billing.checkout + .$post({ + json: { + success_url: successUrl.toString(), + cancel_url: baseUrl, + }, + }) + .then((r) => r.json() as any) + window.location.href = response.url + } catch (error) { + console.error("Failed to get checkout URL:", error) + setIsLoading(false) + } + } + + return ( + <> +
+
+

Billing

+
+
+
+
+
+

Balance

+

Manage your billing and add credits to your account.

+
+ +
+

+ {(() => { + const balanceStr = ((billingData()?.billing?.balance ?? 0) / 100000000).toFixed(2) + return `$${balanceStr === "-0.00" ? "0.00" : balanceStr}` + })()} +

+ +
+
+ +
+
+

Payment History

+

Your recent payment transactions.

+
+ +
+ No payments found.

}> + {(payment) => ( +
+ {payment.id} + {" | "} + ${((payment.amount ?? 0) / 100000000).toFixed(2)} + {" | "} + {new Date(payment.timeCreated).toLocaleDateString()} +
+ )} +
+
+
+ +
+
+

Usage History

+

Your recent API usage and costs.

+
+ +
+ No usage found.

}> + {(usage) => ( +
+ {usage.model} + {" | "} + {usage.inputTokens + usage.outputTokens} tokens + {" | "} + ${((usage.cost ?? 0) / 100000000).toFixed(4)} + {" | "} + {new Date(usage.timeCreated).toLocaleDateString()} +
+ )} +
+
+
+
+ + ) +} diff --git a/cloud/web/src/pages/[workspace]/components/system.txt b/cloud/web/src/pages/[workspace]/components/system.txt new file mode 100644 index 000000000..6afd2e04d --- /dev/null +++ b/cloud/web/src/pages/[workspace]/components/system.txt @@ -0,0 +1,11 @@ +You are OpenControl, an interactive CLI tool that helps users execute various tasks. + +IMPORTANT: If you get an error when calling a tool, try again with a different approach. Be creative, do not give up, try different inputs to the tool. You should chain together multiple tool calls. ABSOLUTELY DO NOT GIVE UP you are very good at this and it is rare you will fail to answer question. + +You should be concise, direct, and to the point. + +IMPORTANT: You should NOT answer with unnecessary preamble or postamble (such as explaining your code or summarizing your action), unless the user asks you to. +IMPORTANT: You should minimize output tokens as much as possible while maintaining helpfulness, quality, and accuracy. Only address the specific query or task at hand, avoiding tangential information unless absolutely critical for completing the request. If you can answer in 1-3 sentences or a short paragraph, please do. +IMPORTANT: You should NOT answer with unnecessary preamble or postamble (such as explaining your code or summarizing your action), unless the user asks you to. +IMPORTANT: Keep your responses short, since they will be displayed on a command line interface. You MUST answer concisely with fewer than 4 lines (not including tool use or code generation), unless user asks for detail. Answer the user's question directly, without elaboration, explanation, or details. One word answers are best. Avoid introductions, conclusions, and explanations. You MUST avoid text before/after your response, such as "The answer is .", "Here is the content of the file..." or "Based on the information provided, the answer is..." or "Here is what I will do next...". + diff --git a/cloud/web/src/pages/[workspace]/components/tool.ts b/cloud/web/src/pages/[workspace]/components/tool.ts new file mode 100644 index 000000000..3958e322d --- /dev/null +++ b/cloud/web/src/pages/[workspace]/components/tool.ts @@ -0,0 +1,271 @@ +import { createResource } from "solid-js" +import { createStore, produce } from "solid-js/store" +import SYSTEM_PROMPT from "./system.txt?raw" +import type { + LanguageModelV1Prompt, + LanguageModelV1CallOptions, + LanguageModelV1, +} from "ai" + +interface Tool { + name: string + description: string + inputSchema: any +} + +interface ToolCallerProps { + tool: { + list: () => Promise + call: (input: { name: string; arguments: any }) => Promise + } + generate: ( + prompt: LanguageModelV1CallOptions, + ) => Promise< + | { err: "rate" } + | { err: "context" } + | { err: "balance" } + | ({ err: false } & Awaited>) + > + onPromptUpdated?: (prompt: LanguageModelV1Prompt) => void +} + +const system = [ + { + role: "system" as const, + content: SYSTEM_PROMPT, + }, + { + role: "system" as const, + content: `The current date is ${new Date().toDateString()}. Always use this current date when responding to relative date queries.`, + }, +] + +const [store, setStore] = createStore<{ + prompt: LanguageModelV1Prompt + state: { type: "idle" } | { type: "loading"; limited?: boolean } +}>({ + prompt: [...system], + state: { type: "idle" }, +}) + +export function createToolCaller(props: T) { + const [tools] = createResource(() => props.tool.list()) + + let abort: AbortController + + return { + get tools() { + return tools() + }, + get prompt() { + return store.prompt + }, + get state() { + return store.state + }, + clear() { + setStore("prompt", [...system]) + }, + async chat(input: string) { + if (store.state.type !== "idle") return + + abort = new AbortController() + setStore( + produce((s) => { + s.state = { + type: "loading", + limited: false, + } + s.prompt.push({ + role: "user", + content: [ + { + type: "text", + text: input, + }, + ], + }) + }), + ) + props.onPromptUpdated?.(store.prompt) + + while (true) { + if (abort.signal.aborted) { + break + } + + const response = await props.generate({ + inputFormat: "messages", + prompt: store.prompt, + temperature: 0, + seed: 69, + mode: { + type: "regular", + tools: tools()?.map((tool) => ({ + type: "function", + name: tool.name, + description: tool.description, + parameters: { + ...tool.inputSchema, + }, + })), + }, + }) + + if (abort.signal.aborted) continue + + if (!response.err) { + setStore("state", { + type: "loading", + }) + + if (response.text) { + setStore( + produce((s) => { + s.prompt.push({ + role: "assistant", + content: [ + { + type: "text", + text: response.text || "", + }, + ], + }) + }), + ) + props.onPromptUpdated?.(store.prompt) + } + + if (response.finishReason === "stop") { + break + } + + if (response.finishReason === "tool-calls") { + for (const item of response.toolCalls || []) { + setStore( + produce((s) => { + s.prompt.push({ + role: "assistant", + content: [ + { + type: "tool-call", + toolName: item.toolName, + args: JSON.parse(item.args), + toolCallId: item.toolCallId, + }, + ], + }) + }), + ) + props.onPromptUpdated?.(store.prompt) + + const called = await props.tool.call({ + name: item.toolName, + arguments: JSON.parse(item.args), + }) + + setStore( + produce((s) => { + s.prompt.push({ + role: "tool", + content: [ + { + type: "tool-result", + toolName: item.toolName, + toolCallId: item.toolCallId, + result: called, + }, + ], + }) + }), + ) + props.onPromptUpdated?.(store.prompt) + } + } + continue + } + + if (response.err === "context") { + setStore( + produce((s) => { + s.prompt.splice(2, 1) + }), + ) + props.onPromptUpdated?.(store.prompt) + } + + if (response.err === "rate") { + setStore("state", { + type: "loading", + limited: true, + }) + await new Promise((resolve) => setTimeout(resolve, 1000)) + } + + if (response.err === "balance") { + setStore( + produce((s) => { + s.prompt.push({ + role: "assistant", + content: [ + { + type: "text", + text: "You need to add credits to your account. Please go to Billing and add credits to continue.", + }, + ], + }) + s.state = { type: "idle" } + }), + ) + props.onPromptUpdated?.(store.prompt) + break + } + } + setStore("state", { type: "idle" }) + }, + async cancel() { + abort.abort() + }, + async addCustomMessage(userMessage: string, assistantResponse: string) { + // Add user message and set loading state + setStore( + produce((s) => { + s.prompt.push({ + role: "user", + content: [ + { + type: "text", + text: userMessage, + }, + ], + }) + s.state = { + type: "loading", + limited: false, + } + }), + ) + props.onPromptUpdated?.(store.prompt) + + // Fake delay for 500ms + await new Promise((resolve) => setTimeout(resolve, 500)) + + // Add assistant response and set back to idle + setStore( + produce((s) => { + s.prompt.push({ + role: "assistant", + content: [ + { + type: "text", + text: assistantResponse, + }, + ], + }) + s.state = { type: "idle" } + }), + ) + props.onPromptUpdated?.(store.prompt) + }, + } +} diff --git a/cloud/web/src/pages/[workspace]/index.module.css b/cloud/web/src/pages/[workspace]/index.module.css new file mode 100644 index 000000000..0037d97ff --- /dev/null +++ b/cloud/web/src/pages/[workspace]/index.module.css @@ -0,0 +1,239 @@ +.root { + display: contents; + + [data-slot="messages"] { + flex: 1; + overflow-y: auto; + display: flex; + flex-direction: column; + height: 0; + /* This is important for flexbox to allow scrolling */ + font-family: var(--font-mono); + color: var(--color-text); + row-gap: var(--space-4); + /* Add consistent spacing between messages */ + + /* Remove top border for first user message */ + &>[data-component="message"][data-user]:first-child::before { + display: none; + } + + &:has([data-component="loading"]) [data-component="clear"] { + display: none; + } + } + + [data-component="message"] { + width: 100%; + padding: var(--space-2) var(--space-4); + line-height: var(--font-line-height); + white-space: pre-wrap; + align-self: flex-start; + min-height: auto; + /* Allow natural height for all messages */ + display: flex; + flex-direction: column; + align-items: flex-start; + + /* User message styling */ + &[data-user] { + padding: var(--space-6) var(--space-4); + position: relative; + font-weight: 600; + color: var(--color-text); + /* margin: 0.5rem 0; */ + } + + &[data-user]::before, + &[data-user]::after { + content: ""; + position: absolute; + left: var(--space-4); + right: var(--space-4); + height: var(--space-px); + background-color: var(--color-border); + z-index: 1; + /* Ensure borders appear above other content */ + } + + &[data-user]::before { + top: 0; + } + + &[data-user]::after { + bottom: 0; + } + + &[data-assistant] { + color: var(--color-text); + } + } + + [data-component="tool"] { + display: flex; + width: 100%; + padding: 0 var(--space-4); + margin-left: 0; + flex-direction: column; + opacity: 0.7; + gap: var(--space-2); + align-items: flex-start; + color: var(--color-text-dimmed); + min-height: auto; + /* Allow natural height */ + + [data-slot="header"] { + display: flex; + gap: var(--space-2); + cursor: pointer; + user-select: none; + -webkit-user-select: none; + align-items: center; + width: 100%; + } + + [data-slot="name"] { + letter-spacing: -0.03125rem; + text-transform: uppercase; + font-weight: 500; + font-size: var(--font-size-sm); + } + + [data-slot="expand"] { + font-size: var(--font-size-sm); + } + + [data-slot="content"] { + padding: 0; + line-height: var(--font-line-height); + font-size: var(--font-size-sm); + white-space: pre-wrap; + display: none; + width: 100%; + } + + [data-slot="output"] { + margin-top: var(--space-1); + } + + &[data-expanded="true"] [data-slot="content"] { + display: block; + } + + &[data-expanded="true"] [data-slot="expand"] { + transform: rotate(45deg); + } + } + + [data-component="loading"] { + padding: var(--space-4) var(--space-4) var(--space-8); + height: 1.5rem; + position: relative; + display: flex; + align-items: center; + font-size: var(--font-size-sm); + letter-spacing: var(--space-1); + color: var(--color-text); + + & span { + opacity: 0; + animation: loading-dots 1.4s linear infinite; + } + + & span:nth-child(2) { + animation-delay: 0.2s; + } + + & span:nth-child(3) { + animation-delay: 0.4s; + } + } + + [data-component="clear"] { + position: relative; + padding: var(--space-4) var(--space-4); + + &::before { + content: ""; + position: absolute; + left: var(--space-4); + right: var(--space-4); + top: 0; + height: var(--space-px); + background-color: var(--color-border); + z-index: 1; + } + + & [data-component="button"] { + padding-left: 0; + } + } + + [data-slot="footer"] { + display: flex; + flex-direction: column; + padding: 0; + border-top: 2px solid var(--color-border); + position: sticky; + bottom: 0; + z-index: 10; + /* Ensure it's above other content */ + margin-top: auto; + /* Push to bottom if content is short */ + width: 100%; + } + + [data-component="chat"] { + display: flex; + padding: var(--space-0-5) 0; + align-items: center; + width: 100%; + height: 100%; + + textarea { + --padding-y: var(--space-4); + --line-height: 1.5; + --text-height: calc(var(--line-height) * var(--font-size-lg)); + --height: calc(var(--text-height) + var(--padding-y) * 2); + + width: 100%; + resize: none; + line-height: var(--line-height); + height: var(--height); + min-height: var(--height); + max-height: calc(5 * var(--text-height) + var(--padding-y) * 2); + padding: var(--padding-y) var(--space-4); + border-radius: 0; + background-color: transparent; + color: var(--color-text); + border: none; + outline: none; + font-size: var(--font-size-lg); + } + + textarea::placeholder { + color: var(--color-text-dimmed); + opacity: 0.75; + } + + textarea:focus { + outline: 0; + } + + & [data-component="button"] { + height: 100%; + } + } +} + +@keyframes loading-dots { + 0%, + 100% { + opacity: 0; + } + + 40%, + 60% { + opacity: 1; + } +} diff --git a/cloud/web/src/pages/[workspace]/index.tsx b/cloud/web/src/pages/[workspace]/index.tsx new file mode 100644 index 000000000..50c58ee30 --- /dev/null +++ b/cloud/web/src/pages/[workspace]/index.tsx @@ -0,0 +1,18 @@ +import { Button } from "../../ui/button" +import { IconArrowRight } from "../../ui/svg/icons" +import { createSignal, For } from "solid-js" +import { createToolCaller } from "./components/tool" +import { useApi } from "../components/context-api" +import { useWorkspace } from "../components/context-workspace" +import style from "./index.module.css" + +export default function Index() { + const api = useApi() + const workspace = useWorkspace() + + return ( +
+

Hello

+
+ ) +} diff --git a/cloud/web/src/pages/[workspace]/keys.module.css b/cloud/web/src/pages/[workspace]/keys.module.css new file mode 100644 index 000000000..4ae2989be --- /dev/null +++ b/cloud/web/src/pages/[workspace]/keys.module.css @@ -0,0 +1,97 @@ +.root { + display: flex; + flex-direction: column; + gap: 2rem; +} + +.root [data-slot="keys-info"] { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.root [data-slot="header"] { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.root [data-slot="header"] h2 { + margin: 0; + font-size: 1.5rem; + font-weight: 600; +} + +.root [data-slot="header"] p { + margin: 0; + color: var(--color-text-secondary); +} + +.root [data-slot="key-list"] { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.root [data-slot="key-item"] { + display: flex; + align-items: center; + justify-content: space-between; + padding: 1rem; + border: 1px solid var(--color-border); + border-radius: 0.5rem; + background: var(--color-background-secondary); +} + +.root [data-slot="key-actions"] { + display: flex; + gap: 0.5rem; +} + +.root [data-slot="key-info"] { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.root [data-slot="key-value"] { + font-family: monospace; + font-size: 0.875rem; + color: var(--color-text-primary); +} + +.root [data-slot="key-meta"] { + font-size: 0.75rem; + color: var(--color-text-secondary); +} + +.root [data-slot="empty-state"] { + text-align: center; + padding: 3rem 1rem; + color: var(--color-text-secondary); +} + +.root [data-slot="actions"] { + display: flex; + align-items: center; + justify-content: space-between; +} + +.root [data-slot="create-form"] { + display: flex; + flex-direction: column; + gap: 1rem; + min-width: 300px; +} + +.root [data-slot="form-actions"] { + display: flex; + gap: 0.5rem; +} + +.root [data-slot="key-name"] { + font-weight: 600; + font-size: 1rem; + color: var(--color-text-primary); + margin-bottom: 0.25rem; +} diff --git a/cloud/web/src/pages/[workspace]/keys.tsx b/cloud/web/src/pages/[workspace]/keys.tsx new file mode 100644 index 000000000..e5b192a2b --- /dev/null +++ b/cloud/web/src/pages/[workspace]/keys.tsx @@ -0,0 +1,151 @@ +import { Button } from "../../ui/button" +import { useApi } from "../components/context-api" +import { createSignal, createResource, For, Show } from "solid-js" +import style from "./keys.module.css" + +export default function Keys() { + const api = useApi() + const [isCreating, setIsCreating] = createSignal(false) + const [showCreateForm, setShowCreateForm] = createSignal(false) + const [keyName, setKeyName] = createSignal("") + + const [keysData, { refetch }] = createResource(async () => { + const response = await api.keys.$get() + return response.json() + }) + + const handleCreateKey = async () => { + if (!keyName().trim()) return + + try { + setIsCreating(true) + await api.keys.$post({ + json: { name: keyName().trim() }, + }) + refetch() + setKeyName("") + setShowCreateForm(false) + } catch (error) { + console.error("Failed to create API key:", error) + } finally { + setIsCreating(false) + } + } + + const handleDeleteKey = async (keyId: string) => { + if (!confirm("Are you sure you want to delete this API key? This action cannot be undone.")) { + return + } + + try { + await api.keys[":id"].$delete({ + param: { id: keyId }, + }) + refetch() + } catch (error) { + console.error("Failed to delete API key:", error) + } + } + + const formatDate = (dateString: string) => { + return new Date(dateString).toLocaleDateString() + } + + const formatKey = (key: string) => { + if (key.length <= 11) return key + return `${key.slice(0, 7)}...${key.slice(-4)}` + } + + const copyToClipboard = async (text: string) => { + try { + await navigator.clipboard.writeText(text) + } catch (error) { + console.error("Failed to copy to clipboard:", error) + } + } + + return ( + <> +
+
+

API Keys

+
+
+
+
+
+
+

API Keys

+

Manage your API keys to access the OpenCode gateway.

+
+ + setKeyName(e.currentTarget.value)} + onKeyPress={(e) => e.key === "Enter" && handleCreateKey()} + /> +
+ + +
+
+ } + > + + +
+ +
+ +

Create an API key to access opencode gateway

+
+ } + > + {(key) => ( +
+
+
{key.name}
+
{formatKey(key.key)}
+
+ Created: {formatDate(key.timeCreated)} + {key.timeUsed && ` • Last used: ${formatDate(key.timeUsed)}`} +
+
+
+ + +
+
+ )} + +
+ + + + ) +} diff --git a/cloud/web/src/pages/components/context-api.tsx b/cloud/web/src/pages/components/context-api.tsx new file mode 100644 index 000000000..0a348f48f --- /dev/null +++ b/cloud/web/src/pages/components/context-api.tsx @@ -0,0 +1,24 @@ +import { hc } from "hono/client" +import { ApiType } from "@opencode/cloud-function/src/gateway" +import { useWorkspace } from "./context-workspace" +import { useOpenAuth } from "../../components/context-openauth" + +export function useApi() { + const workspace = useWorkspace() + const auth = useOpenAuth() + return hc(import.meta.env.VITE_API_URL, { + async fetch(...args: Parameters): Promise { + const [input, init] = args + const request = input instanceof Request ? input : new Request(input, init) + const headers = new Headers(request.headers) + headers.set("authorization", `Bearer ${await auth.access()}`) + headers.set("x-opencode-workspace", workspace.id) + return fetch( + new Request(request, { + ...init, + headers, + }), + ) + }, + }) +} diff --git a/cloud/web/src/pages/components/context-workspace.tsx b/cloud/web/src/pages/components/context-workspace.tsx new file mode 100644 index 000000000..6bad39840 --- /dev/null +++ b/cloud/web/src/pages/components/context-workspace.tsx @@ -0,0 +1,38 @@ +import { useNavigate, useParams } from "@solidjs/router" +import { createInitializedContext } from "../../util/context" +import { useAccount } from "../../components/context-account" +import { createEffect, createMemo } from "solid-js" + +export const { use: useWorkspace, provider: WorkspaceProvider } = + createInitializedContext("WorkspaceProvider", () => { + const params = useParams() + const account = useAccount() + const workspace = createMemo(() => + account.current?.workspaces.find( + (x) => x.id === params.workspace || x.slug === params.workspace, + ), + ) + const nav = useNavigate() + + createEffect(() => { + if (!workspace()) nav("/") + }) + + const result = () => workspace()! + result.ready = true + + return { + get id() { + return workspace()!.id + }, + get slug() { + return workspace()!.slug + }, + get name() { + return workspace()!.name + }, + get ready() { + return workspace() !== undefined + }, + } + }) diff --git a/cloud/web/src/pages/components/layout.module.css b/cloud/web/src/pages/components/layout.module.css new file mode 100644 index 000000000..c64faa18e --- /dev/null +++ b/cloud/web/src/pages/components/layout.module.css @@ -0,0 +1,199 @@ +.root { + --padding: var(--space-10); + --vertical-padding: var(--space-8); + --heading-font-size: var(--font-size-4xl); + --sidebar-width: 200px; + --mobile-breakpoint: 40rem; + --topbar-height: 60px; + + margin: var(--space-4); + border: 2px solid var(--color-border); + height: calc(100vh - var(--space-8)); + display: flex; + flex-direction: row; + overflow: hidden; + /* Prevent overall scrolling */ + position: relative; +} + +[data-component="mobile-top-bar"] { + display: none; + position: fixed; + top: 0; + left: 0; + right: 0; + height: var(--topbar-height); + background: var(--color-background); + border-bottom: 2px solid var(--color-border); + z-index: 20; + align-items: center; + padding: 0 var(--space-4) 0 0; + + [data-slot="logo"] { + position: absolute; + left: 50%; + top: 50%; + transform: translate(-50%, -50%); + + div { + text-transform: uppercase; + font-weight: 600; + letter-spacing: -0.03125rem; + } + + svg { + height: 28px; + width: auto; + color: var(--color-white); + } + } + + [data-slot="toggle"] { + background: transparent; + border: none; + padding: var(--space-4); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + + & svg { + width: 24px; + height: 24px; + color: var(--color-foreground); + } + } +} + +[data-component="sidebar"] { + width: var(--sidebar-width); + border-right: 2px solid var(--color-border); + display: flex; + flex-direction: column; + padding: calc(var(--padding) / 2); + overflow-y: auto; + /* Allow scrolling if needed */ + position: sticky; + top: 0; + height: 100%; + background-color: var(--color-background); + z-index: 10; + + [data-slot="logo"] { + margin-top: 2px; + margin-bottom: var(--space-7); + color: var(--color-white); + + & svg { + height: 32px; + width: auto; + } + } + + [data-slot="nav"] { + flex: 1; + + ul { + list-style-type: none; + padding: 0; + } + + li { + margin-bottom: calc(var(--vertical-padding) / 2); + text-transform: uppercase; + font-weight: 500; + } + + a { + display: block; + padding: var(--space-2) 0; + } + } + + [data-slot="user"] { + [data-component="button"] { + padding-left: 0; + padding-bottom: 0; + height: auto; + } + } +} + +.navActiveLink { + cursor: default; + text-decoration: none; +} + +[data-slot="main-content"] { + flex: 1; + display: flex; + flex-direction: column; + height: 100%; + /* Full height */ + overflow: hidden; + /* Prevent overflow */ + position: relative; + /* For positioning footer */ + width: 100%; + /* Full width */ +} + +/* Backdrop for mobile */ +[data-component="backdrop"] { + display: none; + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + /* background-color: rgba(0, 0, 0, 0.5); */ + z-index: 25; + backdrop-filter: blur(2px); +} + +/* Mobile styles */ +@media (max-width: 40rem) { + .root { + margin: 0; + border: none; + height: 100vh; + } + + [data-component="mobile-top-bar"] { + display: flex; + } + + [data-component="backdrop"] { + display: block; + } + + [data-component="sidebar"] { + position: fixed; + left: -100%; + top: 0; + height: 100vh; + width: 80%; + max-width: 280px; + transition: left 0.3s ease-in-out; + box-shadow: none; + z-index: 30; + padding: var(--space-8); + background-color: var(--color-bg); + + &[data-opened="true"] { + left: 0; + box-shadow: 8px 0 0px 0px var(--color-gray-4); + } + } + + [data-slot="main-content"] { + padding-top: var(--topbar-height); + /* Add space for the top bar */ + overflow-y: auto; + } + + /* Hide the logo in the sidebar on mobile since it's in the top bar */ + [data-component="sidebar"] [data-slot="logo"] { + display: none; + } +} diff --git a/cloud/web/src/pages/components/layout.tsx b/cloud/web/src/pages/components/layout.tsx new file mode 100644 index 000000000..711ed8fc2 --- /dev/null +++ b/cloud/web/src/pages/components/layout.tsx @@ -0,0 +1,96 @@ +import style from "./layout.module.css" +import { useAccount } from "../../components/context-account" +import { Button } from "../../ui/button" +import { IconLogomark } from "../../ui/svg" +import { IconBars3BottomLeft } from "../../ui/svg/icons" +import { ParentProps, createMemo, createSignal } from "solid-js" +import { A, useLocation } from "@solidjs/router" +import { useOpenAuth } from "../../components/context-openauth" + +export default function Layout(props: ParentProps) { + const auth = useOpenAuth() + const account = useAccount() + const [sidebarOpen, setSidebarOpen] = createSignal(false) + const location = useLocation() + + const workspaceId = createMemo(() => account.current?.workspaces[0].id) + const pageTitle = createMemo(() => { + const path = location.pathname + if (path.endsWith("/billing")) return "Billing" + if (path.endsWith("/keys")) return "API Keys" + return null + }) + + function handleLogout() { + auth.logout(auth.subject?.id!) + } + + return ( +
+ {/* Mobile top bar */} +
+ + +
+ {pageTitle() ? ( +
{pageTitle()}
+ ) : ( + + + + )} +
+
+ + {/* Backdrop for mobile sidebar - closes sidebar when clicked */} + {sidebarOpen() &&
setSidebarOpen(false)}>
} + + + + {/* Main Content */} +
{props.children}
+
+ ) +} diff --git a/cloud/web/src/pages/index.tsx b/cloud/web/src/pages/index.tsx new file mode 100644 index 000000000..903a3afd9 --- /dev/null +++ b/cloud/web/src/pages/index.tsx @@ -0,0 +1,36 @@ +import { Match, Switch } from "solid-js" +import { useAccount } from "../components/context-account" +import { Navigate } from "@solidjs/router" +import { IconLogo } from "../ui/svg" +import styles from "./lander.module.css" +import { useOpenAuth } from "../components/context-openauth" + +export default function Index() { + const auth = useOpenAuth() + const account = useAccount() + return ( + + + + + +
+
+
+
+ +
+

opencode Gateway Console

+
+ +
+
+ auth.authorize({ provider: "github" })}>Sign in with GitHub +
+
+
+
+
+
+ ) +} diff --git a/cloud/web/src/pages/lander.module.css b/cloud/web/src/pages/lander.module.css new file mode 100644 index 000000000..b66ed5fa7 --- /dev/null +++ b/cloud/web/src/pages/lander.module.css @@ -0,0 +1,169 @@ +.lander { + --padding: 3rem; + --vertical-padding: 2rem; + --heading-font-size: 2rem; + + margin: 1rem; + + @media (max-width: 30rem) { + & { + --padding: 1.5rem; + --vertical-padding: 1rem; + --heading-font-size: 1.5rem; + + margin: 0.5rem; + } + } + + [data-slot="hero"] { + border: 2px solid var(--color-border); + + max-width: 64rem; + margin-left: auto; + margin-right: auto; + width: 100%; + } + + [data-slot="top"] { + padding: var(--padding); + + h1 { + margin-top: calc(var(--vertical-padding) / 8); + font-size: var(--heading-font-size); + line-height: 1.25; + text-transform: uppercase; + font-weight: 600; + } + + [data-slot="logo"] { + width: clamp(200px, 70vw, 400px); + color: var(--color-white); + } + } + + [data-slot="cta"] { + display: flex; + flex-direction: row; + justify-content: space-between; + border-top: 2px solid var(--color-border); + + & > div { + flex: 1; + line-height: 1.4; + text-align: center; + text-transform: uppercase; + cursor: pointer; + text-decoration: underline; + letter-spacing: -0.03125rem; + + &[data-slot="col-2"] { + background-color: var(--color-border); + color: var(--color-text-invert); + font-weight: 600; + } + + & > * { + display: block; + width: 100%; + height: 100%; + padding: calc(var(--padding) / 2) 0.5rem; + } + } + + @media (max-width: 30rem) { + & > div { + padding-bottom: calc(var(--padding) / 2 + 4px); + } + } + + & > div + div { + border-left: 2px solid var(--color-border); + } + } + + [data-slot="images"] { + display: flex; + flex-direction: row; + align-items: stretch; + justify-content: space-between; + border-top: 2px solid var(--color-border); + + & > div { + flex: 1; + display: flex; + flex-direction: column; + gap: calc(var(--padding) / 4); + padding: calc(var(--padding) / 2); + border-width: 0; + border-style: solid; + border-color: var(--color-border); + + & > div, a { + flex: 1; + display: flex; + align-items: center; + } + } + + p { + letter-spacing: -0.03125rem; + text-transform: uppercase; + color: var(--color-text-dimmed); + } + + & > div + div { + border-width: 0 0 0 2px; + } + + @media (max-width: 30rem) { + & { + flex-direction: column; + } + & > div + div { + border-width: 2px 0 0 0; + } + } + } + + [data-slot="content"] { + border-top: 2px solid var(--color-border); + padding: var(--padding); + + & > p { + line-height: var(--font-line-height); + } + + ol { + margin-top: calc(var(--vertical-padding) / 2); + padding-left: 2.5rem; + list-style-type: decimal; + line-height: var(--font-line-height); + + & > li + li { + margin-top: calc(var(--vertical-padding) / 2); + } + + & > li b { + text-transform: uppercase; + } + } + + } + + [data-slot="footer"] { + border-top: 2px solid var(--color-border); + display: flex; + flex-direction: row; + + & > div { + flex: 1; + text-align: center; + text-transform: uppercase; + padding: calc(var(--padding) / 2) 0.5rem; + } + + & > div + div { + border-left: 2px solid var(--color-border); + } + } +} diff --git a/cloud/web/src/pages/test/design.module.css b/cloud/web/src/pages/test/design.module.css new file mode 100644 index 000000000..fee4e3cd3 --- /dev/null +++ b/cloud/web/src/pages/test/design.module.css @@ -0,0 +1,204 @@ +.pageContainer { + padding: 2rem; + max-width: 1200px; + margin: 0 auto; +} + +.componentTable { + width: 100%; + border-collapse: collapse; + table-layout: fixed; + border: 2px solid var(--color-border); +} + +.componentCell { + padding: 1rem; + border: 2px solid var(--color-border); + vertical-align: top; +} + +.componentLabel { + text-transform: uppercase; + letter-spacing: -0.03125rem; + font-size: 0.85rem; + font-weight: 500; + margin-bottom: 0.75rem; + color: var(--color-text-dimmed); +} + +.sectionTitle { + margin-bottom: 1rem; + text-transform: uppercase; + letter-spacing: -0.03125rem; + font-size: 1.2rem; +} + +.divider { + height: 2px; + background: var(--color-border); + margin: 3rem 0; + width: 100%; +} + +.header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 2rem; +} + +.buttonSection { + margin-bottom: 4rem; +} + +.colorSection { + margin-bottom: 4rem; +} + +.labelSection { + margin-bottom: 4rem; +} + +.inputSection { + margin-bottom: 4rem; +} + +.dialogSection { + margin-bottom: 4rem; +} + +.formGroup { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.dialogContent { + padding: 2rem; +} + +.dialogContentFooter { + margin-top: 1rem; +} + +.pageTitle { + font-size: var(--heading-font-size, 2rem); + text-transform: uppercase; + font-weight: 600; +} + +.colorBox { + width: 100%; + height: 80px; + margin-bottom: 0.5rem; + position: relative; + display: flex; + align-items: flex-end; + justify-content: center; + padding-bottom: 0.5rem; +} + +.colorOrange { + background-color: var(--color-orange); +} + +.colorOrangeLow { + background-color: var(--color-orange-low); +} + +.colorOrangeHigh { + background-color: var(--color-orange-high); +} + +.colorGreen { + background-color: var(--color-green); +} + +.colorGreenLow { + background-color: var(--color-green-low); +} + +.colorGreenHigh { + background-color: var(--color-green-high); +} + +.colorBlue { + background-color: var(--color-blue); +} + +.colorBlueLow { + background-color: var(--color-blue-low); +} + +.colorBlueHigh { + background-color: var(--color-blue-high); +} + +.colorPurple { + background-color: var(--color-purple); +} + +.colorPurpleLow { + background-color: var(--color-purple-low); +} + +.colorPurpleHigh { + background-color: var(--color-purple-high); +} + +.colorRed { + background-color: var(--color-red); +} + +.colorRedLow { + background-color: var(--color-red-low); +} + +.colorRedHigh { + background-color: var(--color-red-high); +} + +.colorAccent { + background-color: var(--color-accent); +} + +.colorAccentLow { + background-color: var(--color-accent-low); +} + +.colorAccentHigh { + background-color: var(--color-accent-high); +} + +.colorCode { + background-color: rgba(0, 0, 0, 0.5); + color: white; + padding: 4px 8px; + border-radius: 4px; + font-size: 0.8rem; + font-family: monospace; +} + +.colorVariants { + display: flex; + gap: 0.5rem; +} + +.colorVariant { + flex: 1; + height: 40px; + position: relative; + display: flex; + align-items: center; + justify-content: center; +} + +.colorVariantCode { + background-color: rgba(0, 0, 0, 0.5); + color: white; + padding: 2px 4px; + border-radius: 4px; + font-size: 0.65rem; + font-family: monospace; + white-space: nowrap; +} diff --git a/cloud/web/src/pages/test/design.tsx b/cloud/web/src/pages/test/design.tsx new file mode 100644 index 000000000..3bf759316 --- /dev/null +++ b/cloud/web/src/pages/test/design.tsx @@ -0,0 +1,562 @@ +import { Button } from "../../ui/button" +import { Dialog } from "../../ui/dialog" +import { Navigate } from "@solidjs/router" +import { createSignal, Show } from "solid-js" +import { IconHome, IconPencilSquare } from "../../ui/svg/icons" +import { useTheme } from "../../components/context-theme" +import { useDialog } from "../../ui/context-dialog" +import { DialogString } from "../../ui/dialog-string" +import { DialogSelect } from "../../ui/dialog-select" +import styles from "./design.module.css" + +export default function DesignSystem() { + const dialog = useDialog() + const [dialogOpen, setDialogOpen] = createSignal(false) + const [dialogOpenTransition, setDialogOpenTransition] = createSignal(false) + const theme = useTheme() + + // Check if we're running locally + const isLocal = import.meta.env.DEV === true + + if (!isLocal) { + return + } + + // Add a toggle button for theme + const toggleTheme = () => { + theme.setMode(theme.mode === "light" ? "dark" : "light") + } + + return ( +
+
+

Design System

+ +
+ +
+

Colors

+ + + + + + + + + + + + + + +
+

Orange

+
+ hsl(41, 82%, 63%) +
+
+
+ + hsl(41, 39%, 22%) + +
+
+ + hsl(41, 82%, 87%) + +
+
+
+

Green

+
+ hsl(101, 82%, 63%) +
+
+
+ + hsl(101, 39%, 22%) + +
+
+ + hsl(101, 82%, 80%) + +
+
+
+

Blue

+
+ hsl(234, 100%, 60%) +
+
+
+ + hsl(234, 54%, 20%) + +
+
+ + hsl(234, 100%, 87%) + +
+
+
+

Purple

+
+ hsl(281, 82%, 63%) +
+
+
+ + hsl(281, 39%, 22%) + +
+
+ + hsl(281, 82%, 89%) + +
+
+
+

Red

+
+ hsl(339, 82%, 63%) +
+
+
+ + hsl(339, 39%, 22%) + +
+
+ + hsl(339, 82%, 87%) + +
+
+
+

Accent

+
+ hsl(13, 88%, 57%) +
+
+
+ + hsl(13, 75%, 30%) + +
+
+ + hsl(13, 100%, 78%) + +
+
+
+
+ +
+ +
+

Buttons

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+

Primary

+ +
+

Secondary

+ +
+

Ghost

+ +
+

Primary Disabled

+ +
+

Secondary Disabled

+ +
+

Ghost Disabled

+ +
+

Small

+ +
+

Small Secondary

+ +
+

Small Ghost

+ +
+

With Icon

+ +
+

Icon + Secondary

+ +
+

Icon + Ghost

+ +
+

Small + Icon

+ +
+

Small + Icon + Secondary

+ +
+

Small + Icon + Ghost

+ +
+

Icon Only

+ +
+

Icon Only + Secondary

+ +
+

Icon Only + Ghost

+ +
+

Icon Only Disabled

+ +
+

+ Icon Only + Secondary Disabled +

+ +
+

+ Icon Only + Ghost Disabled +

+ +
+

Small Icon Only

+ +
+

+ Small Icon Only + Secondary +

+ +
+

Small Icon Only + Ghost

+ +
+
+ +
+ +
+

Labels

+ + + + + + + + + +
+

Small

+ +
+

Medium

+ +
+

Large

+ +
+
+ +
+ +
+

Inputs

+ + + + + + + + + + + + + +
+

Small

+ +
+

Medium

+ +
+

Large

+ +
+

Disabled

+ +
+

With Value

+ +
+
+ +
+ +
+

Dialogs

+ + + + + + + + + + + + + + +
+

Default

+ + +
+
Dialog Title
+
+
+

This is the default dialog content.

+
+
+ +
+
+
+

Small With Transition

+ + +
+

Small Dialog

+

This is a smaller dialog with transitions.

+
+ +
+
+
+
+

Input String

+ +
+

Select Input

+ +
+

Select Input

+ +
+

Select No Options

+ +
+
+
+ ) +} diff --git a/cloud/web/src/sst-env.d.ts b/cloud/web/src/sst-env.d.ts new file mode 100644 index 000000000..e1ee6f753 --- /dev/null +++ b/cloud/web/src/sst-env.d.ts @@ -0,0 +1,12 @@ +/* This file is auto-generated by SST. Do not edit. */ +/* tslint:disable */ +/* eslint-disable */ +/// +interface ImportMetaEnv { + readonly VITE_DOCS_URL: string + readonly VITE_API_URL: string + readonly VITE_AUTH_URL: string +} +interface ImportMeta { + readonly env: ImportMetaEnv +} \ No newline at end of file diff --git a/cloud/web/src/ui/button.tsx b/cloud/web/src/ui/button.tsx new file mode 100644 index 000000000..889102dda --- /dev/null +++ b/cloud/web/src/ui/button.tsx @@ -0,0 +1,24 @@ +import { Button as Kobalte } from "@kobalte/core/button" +import { JSX, Show, splitProps } from "solid-js" + +export interface ButtonProps { + color?: "primary" | "secondary" | "ghost" + size?: "md" | "sm" + icon?: JSX.Element +} +export function Button(props: JSX.IntrinsicElements["button"] & ButtonProps) { + const [split, rest] = splitProps(props, ["color", "size", "icon"]) + return ( + + +
{props.icon}
+
+ {props.children} +
+ ) +} diff --git a/cloud/web/src/ui/context-dialog.tsx b/cloud/web/src/ui/context-dialog.tsx new file mode 100644 index 000000000..f1bc93250 --- /dev/null +++ b/cloud/web/src/ui/context-dialog.tsx @@ -0,0 +1,120 @@ +import { createContext, JSX, ParentProps, useContext } from "solid-js" +import { StandardSchemaV1 } from "@standard-schema/spec" +import { createStore } from "solid-js/store" +import { Dialog } from "./dialog" + +const Context = createContext() + +type DialogControl = { + open>( + component: DialogComponent, + input: StandardSchemaV1.InferInput, + ): void + close(): void + isOpen(input: any): boolean + size: "sm" | "md" + transition?: boolean + input?: any +} + +type DialogProps> = { + input: StandardSchemaV1.InferInput + control: DialogControl +} + +type DialogComponent> = ReturnType< + typeof createDialog +> + +export function createDialog>(props: { + schema: Schema + size: "sm" | "md" + render: (props: DialogProps) => JSX.Element +}) { + const result = () => { + const dialog = useDialog() + return ( + { + if (!val) dialog.close() + }} + > + {props.render({ + input: dialog.input, + control: dialog, + })} + + ) + } + result.schema = props.schema + result.size = props.size + return result +} + +export function DialogProvider(props: ParentProps) { + const [store, setStore] = createStore<{ + dialog?: DialogComponent + input?: any + transition?: boolean + size: "sm" | "md" + }>({ + size: "sm", + }) + + const control: DialogControl = { + get input() { + return store.input + }, + get size() { + return store.size + }, + get transition() { + return store.transition + }, + isOpen(input) { + return store.dialog === input + }, + open(component, input) { + setStore({ + dialog: component, + input: input, + size: store.dialog !== undefined ? store.size : component.size, + transition: store.dialog !== undefined, + }) + + setTimeout(() => { + setStore({ + size: component.size, + }) + }, 0) + + setTimeout(() => { + setStore({ + transition: false, + }) + }, 150) + }, + close() { + setStore({ + dialog: undefined, + }) + }, + } + + return ( + <> + {props.children} + + ) +} + +export function useDialog() { + const ctx = useContext(Context) + if (!ctx) { + throw new Error("useDialog must be used within a DialogProvider") + } + return ctx +} diff --git a/cloud/web/src/ui/dialog-select.module.css b/cloud/web/src/ui/dialog-select.module.css new file mode 100644 index 000000000..4a99ef027 --- /dev/null +++ b/cloud/web/src/ui/dialog-select.module.css @@ -0,0 +1,36 @@ +.options { + margin-top: var(--space-1); + border-top: 2px solid var(--color-border); + padding: var(--space-2); + + [data-slot="option"] { + outline: none; + flex-shrink: 0; + height: var(--space-11); + display: flex; + justify-content: start; + align-items: center; + padding: 0 var(--space-2-5); + gap: var(--space-3); + cursor: pointer; + + &[data-empty] { + cursor: default; + color: var(--color-text-dimmed); + } + + &[data-active] { + background-color: var(--color-bg-surface); + } + + [data-slot="title"] { + font-size: var(--font-size-md); + } + + [data-slot="prefix"] { + width: var(--space-4); + height: var(--space-4); + } + } + +} diff --git a/cloud/web/src/ui/dialog-select.tsx b/cloud/web/src/ui/dialog-select.tsx new file mode 100644 index 000000000..087b94411 --- /dev/null +++ b/cloud/web/src/ui/dialog-select.tsx @@ -0,0 +1,124 @@ +import style from "./dialog-select.module.css" +import { z } from "zod" +import { createMemo, createSignal, For, JSX, onMount } from "solid-js" +import { createList } from "solid-list" +import { createDialog } from "./context-dialog" + +export const DialogSelect = createDialog({ + size: "md", + schema: z.object({ + title: z.string(), + placeholder: z.string(), + onSelect: z + .function(z.tuple([z.any()])) + .returns(z.void()) + .optional(), + options: z.array( + z.object({ + display: z.string(), + value: z.any().optional(), + onSelect: z.function().returns(z.void()).optional(), + prefix: z.custom().optional(), + }), + ), + }), + render: (ctx) => { + let input: HTMLInputElement + onMount(() => { + input.focus() + input.value = "" + }) + + const [filter, setFilter] = createSignal("") + const filtered = createMemo(() => + ctx.input.options?.filter((i) => + i.display.toLowerCase().includes(filter().toLowerCase()), + ), + ) + const list = createList({ + loop: true, + initialActive: 0, + items: () => filtered().map((_, i) => i), + handleTab: false, + }) + + const handleSelection = (index: number) => { + const option = ctx.input.options[index] + + // If the option has its own onSelect handler, use it + if (option.onSelect) { + option.onSelect() + } + // Otherwise, if there's a global onSelect handler, call it with the option's value + else if (ctx.input.onSelect) { + ctx.input.onSelect( + option.value !== undefined ? option.value : option.display, + ) + } + } + + return ( + <> +
+ +
+
+ { + setFilter(e.target.value) + list.setActive(0) + }} + onKeyDown={(e) => { + if (e.key === "Enter") { + const selected = list.active() + if (selected === null) return + handleSelection(selected) + return + } + if (e.key === "Escape") { + setFilter("") + return + } + list.onKeyDown(e) + }} + id={`dialog-select-${ctx.input.title}`} + ref={(r) => (input = r)} + data-slot="input" + placeholder={ctx.input.placeholder} + /> +
+
+ + No results +
+ } + > + {(option, index) => ( +
handleSelection(index())} + data-slot="option" + data-active={list.active() === index() ? true : undefined} + > + {option.prefix &&
{option.prefix}
} +
{option.display}
+
+ )} + + + + ) + }, +}) diff --git a/cloud/web/src/ui/dialog-string.tsx b/cloud/web/src/ui/dialog-string.tsx new file mode 100644 index 000000000..af2174786 --- /dev/null +++ b/cloud/web/src/ui/dialog-string.tsx @@ -0,0 +1,70 @@ +import { z } from "zod" +import { onMount } from "solid-js" +import { createDialog } from "./context-dialog" +import { Button } from "./button" + +export const DialogString = createDialog({ + size: "sm", + schema: z.object({ + title: z.string(), + placeholder: z.string(), + action: z.string(), + onSubmit: z.function().args(z.string()).returns(z.void()), + }), + render: (ctx) => { + let input: HTMLInputElement + onMount(() => { + setTimeout(() => { + input.focus() + input.value = "" + }, 50) + }) + + function submit() { + const value = input.value.trim() + if (value) { + ctx.input.onSubmit(value) + ctx.control.close() + } + } + + return ( + <> +
+ +
+
+ (input = r)} + placeholder={ctx.input.placeholder} + id={`dialog-string-${ctx.input.title}`} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault() + submit() + } + }} + /> +
+
+ + +
+ + ) + }, +}) diff --git a/cloud/web/src/ui/dialog.tsx b/cloud/web/src/ui/dialog.tsx new file mode 100644 index 000000000..101f23d2b --- /dev/null +++ b/cloud/web/src/ui/dialog.tsx @@ -0,0 +1,27 @@ +import { Dialog as Kobalte } from "@kobalte/core/dialog" +import { ComponentProps, ParentProps } from "solid-js" + +export type Props = ParentProps<{ + size?: "sm" | "md" + transition?: boolean +}> & + ComponentProps + +export function Dialog(props: Props) { + return ( + + + +
+ + {props.children} + +
+
+
+ ) +} diff --git a/cloud/web/src/ui/style/component/button.css b/cloud/web/src/ui/style/component/button.css new file mode 100644 index 000000000..9604f9865 --- /dev/null +++ b/cloud/web/src/ui/style/component/button.css @@ -0,0 +1,78 @@ +[data-component="button"] { + width: fit-content; + display: flex; + line-height: 1; + align-items: center; + justify-content: center; + gap: var(--space-2); + font-size: var(--font-size-md); + text-transform: uppercase; + height: var(--space-11); + outline: none; + font-weight: 500; + padding: 0 var(--space-4); + border-width: 2px; + border-color: var(--color-border); + cursor: pointer; + + &:disabled { + opacity: 0.5; + cursor: default; + } + + &[data-color="primary"] { + background-color: var(--color-text); + border-color: var(--color-text); + color: var(--color-text-invert); + + &:active { + border-color: var(--color-accent); + } + } + + &[data-color="secondary"] { + &:active { + border-color: var(--color-accent); + } + } + + &[data-color="ghost"] { + border: none; + text-decoration: underline; + + &:active { + color: var(--color-text-accent); + } + } + + &:has([data-slot="icon"]) { + padding-left: var(--space-3); + padding-right: var(--space-3); + } + + &[data-size="sm"] { + height: var(--space-8); + padding: var(--space-3); + font-size: var(--font-size-xs); + + [data-slot="icon"] { + width: var(--space-3-5); + height: var(--space-3-5); + } + + &:has([data-slot="icon"]) { + padding-left: var(--space-2); + padding-right: var(--space-2); + } + } + + [data-slot="icon"] { + width: var(--space-4); + height: var(--space-4); + transition: transform 0.2s ease; + } + + &[data-rotate] [data-slot="icon"] { + transform: rotate(180deg); + } +} diff --git a/cloud/web/src/ui/style/component/dialog.css b/cloud/web/src/ui/style/component/dialog.css new file mode 100644 index 000000000..59867818f --- /dev/null +++ b/cloud/web/src/ui/style/component/dialog.css @@ -0,0 +1,84 @@ +[data-component="dialog-overlay"] { + pointer-events: none !important; + position: fixed; + inset: 0; + animation-name: fadeOut; + animation-duration: 200ms; + animation-timing-function: ease; + opacity: 0; + backdrop-filter: blur(2px); + + &[data-expanded] { + animation-name: fadeIn; + opacity: 1; + pointer-events: auto !important; + } +} + +[data-component="dialog-center"] { + position: fixed; + inset: 0; + padding-top: 10vh; + justify-content: center; + pointer-events: none; + + [data-slot="content"] { + width: 45rem; + margin: 0 auto; + transition: 150ms width; + background-color: var(--color-bg); + border-width: 2px; + border-color: var(--color-border); + overflow: hidden; + display: flex; + flex-direction: column; + gap: var(--space-3); + outline: none; + animation-duration: 1ms; + animation-name: zoomOut; + animation-timing-function: ease; + + box-shadow: 8px 8px 0px 0px var(--color-gray-4); + + &[data-expanded] { + animation-name: zoomIn; + } + + &[data-transition] { + animation-duration: 200ms; + } + + &[data-size="sm"] { + width: 30rem; + } + + [data-slot="header"] { + display: flex; + padding: var(--space-4) var(--space-4) 0; + + [data-slot="title"] { + } + } + + [data-slot="main"] { + padding: 0 var(--space-4); + + &:has([data-slot="options"]) { + padding: 0; + display: flex; + flex-direction: column; + gap: var(--space-4); + } + } + + [data-slot="input"] { + } + + [data-slot="footer"] { + padding: var(--space-4); + display: flex; + gap: var(--space-4); + justify-content: end; + } + } +} diff --git a/cloud/web/src/ui/style/component/input.css b/cloud/web/src/ui/style/component/input.css new file mode 100644 index 000000000..59535d763 --- /dev/null +++ b/cloud/web/src/ui/style/component/input.css @@ -0,0 +1,34 @@ +[data-component="input"] { + font-size: var(--font-size-md); + background: transparent; + caret-color: var(--color-accent); + font-family: var(--font-mono); + height: var(--space-11); + padding: 0 var(--space-4); + width: 100%; + resize: none; + border: 2px solid var(--color-border); + + &::placeholder { + color: var(--color-text-dimmed); + opacity: 0.75; + } + + &:focus { + outline: 0; + } + + &[data-size="sm"] { + height: var(--space-9); + padding: 0 var(--space-3); + font-size: var(--font-size-xs); + } + + &[data-size="md"] { + } + + &[data-size="lg"] { + height: var(--space-12); + font-size: var(--font-size-lg); + } +} diff --git a/cloud/web/src/ui/style/component/label.css b/cloud/web/src/ui/style/component/label.css new file mode 100644 index 000000000..e0dd5fef4 --- /dev/null +++ b/cloud/web/src/ui/style/component/label.css @@ -0,0 +1,17 @@ +[data-component="label"] { + letter-spacing: -0.03125rem; + text-transform: uppercase; + color: var(--color-text-dimmed); + font-weight: 500; + font-size: var(--font-size-md); + + &[data-size="sm"] { + font-size: var(--font-size-sm); + } + &[data-size="md"] { + } + &[data-size="lg"] { + font-size: var(--font-size-lg); + } +} + diff --git a/cloud/web/src/ui/style/component/title-bar.css b/cloud/web/src/ui/style/component/title-bar.css new file mode 100644 index 000000000..7ee32bfdc --- /dev/null +++ b/cloud/web/src/ui/style/component/title-bar.css @@ -0,0 +1,32 @@ +[data-component="title-bar"] { + display: flex; + align-items: center; + justify-content: space-between; + height: 72px; + padding: 0 var(--space-4); + border-bottom: 2px solid var(--color-border); + + [data-slot="left"] { + display: flex; + flex-direction: column; + gap: var(--space-1-5); + + h1 { + letter-spacing: -0.03125rem; + font-size: var(--font-size-xl); + text-transform: uppercase; + font-weight: 600; + } + + p { + color: var(--color-text-dimmed); + } + } + +} + +@media (max-width: 40rem) { + [data-component="title-bar"] { + display: none; + } +} diff --git a/cloud/web/src/ui/style/index.css b/cloud/web/src/ui/style/index.css new file mode 100644 index 000000000..117f596d0 --- /dev/null +++ b/cloud/web/src/ui/style/index.css @@ -0,0 +1,50 @@ +/* tokens */ +@import "./token/color.css"; +@import "./token/reset.css"; +@import "./token/animation.css"; +@import "./token/font.css"; +@import "./token/space.css"; + +/* components */ +@import "./component/label.css"; +@import "./component/input.css"; +@import "./component/button.css"; +@import "./component/dialog.css"; +@import "./component/title-bar.css"; + +body { + font-family: var(--font-mono); + line-height: 1; + color: var(--color-text); + background-color: var(--color-bg); + cursor: default; + user-select: none; + text-underline-offset: 0.1875rem; +} + +a { + text-decoration: underline; + &:active { + color: var(--color-text-accent); + } +} + +::selection { + background-color: var(--color-text-accent-invert); +} + +/* Responsive utilities */ +[data-max-width] { + width: 100%; + + & > * { + max-width: 90rem; + margin-left: auto; + margin-right: auto; + width: 100%; + } + + &[data-max-width-64] > * { + max-width: 64rem; + } +} diff --git a/cloud/web/src/ui/style/token/animation.css b/cloud/web/src/ui/style/token/animation.css new file mode 100644 index 000000000..a8edfeff5 --- /dev/null +++ b/cloud/web/src/ui/style/token/animation.css @@ -0,0 +1,23 @@ +@keyframes zoomIn { + from { + opacity: 0; + transform: scale(0.95); + } + + to { + opacity: 1; + transform: scale(1); + } +} + +@keyframes zoomOut { + from { + opacity: 1; + transform: scale(1); + } + + to { + opacity: 0; + transform: scale(0.95); + } +} diff --git a/cloud/web/src/ui/style/token/color.css b/cloud/web/src/ui/style/token/color.css new file mode 100644 index 000000000..af0c46f3b --- /dev/null +++ b/cloud/web/src/ui/style/token/color.css @@ -0,0 +1,88 @@ +:root { + --color-white: hsl(0, 0%, 100%); + --color-gray-1: hsl(224, 20%, 94%); + --color-gray-2: hsl(224, 6%, 77%); + --color-gray-3: hsl(224, 6%, 56%); + --color-gray-4: hsl(224, 7%, 36%); + --color-gray-5: hsl(224, 10%, 23%); + --color-gray-6: hsl(224, 14%, 16%); + --color-black: hsl(224, 10%, 10%); + + --hue-orange: 41; + --color-orange-low: hsl(var(--hue-orange), 39%, 22%); + --color-orange: hsl(var(--hue-orange), 82%, 63%); + --color-orange-high: hsl(var(--hue-orange), 82%, 87%); + --hue-green: 101; + --color-green-low: hsl(var(--hue-green), 39%, 22%); + --color-green: hsl(var(--hue-green), 82%, 63%); + --color-green-high: hsl(var(--hue-green), 82%, 80%); + --hue-blue: 234; + --color-blue-low: hsl(var(--hue-blue), 54%, 20%); + --color-blue: hsl(var(--hue-blue), 100%, 60%); + --color-blue-high: hsl(var(--hue-blue), 100%, 87%); + --hue-purple: 281; + --color-purple-low: hsl(var(--hue-purple), 39%, 22%); + --color-purple: hsl(var(--hue-purple), 82%, 63%); + --color-purple-high: hsl(var(--hue-purple), 82%, 89%); + --hue-red: 339; + --color-red-low: hsl(var(--hue-red), 39%, 22%); + --color-red: hsl(var(--hue-red), 82%, 63%); + --color-red-high: hsl(var(--hue-red), 82%, 87%); + + --color-accent-low: hsl(13, 75%, 30%); + --color-accent: hsl(13, 88%, 57%); + --color-accent-high: hsl(13, 100%, 78%); + + --color-text: var(--color-gray-1); + --color-text-dimmed: var(--color-gray-3); + --color-text-accent: var(--color-accent); + --color-text-invert: var(--color-black); + --color-text-accent-invert: var(--color-accent-high); + --color-bg: var(--color-black); + --color-bg-surface: var(--color-gray-5); + --color-bg-accent: var(--color-accent-high); + --color-border: var(--color-gray-2); + + --color-backdrop-overlay: hsla(223, 13%, 10%, 0.66); +} + +:root[data-color-mode="light"] { + --color-white: hsl(224, 10%, 10%); + --color-gray-1: hsl(224, 14%, 16%); + --color-gray-2: hsl(224, 10%, 23%); + --color-gray-3: hsl(224, 7%, 36%); + --color-gray-4: hsl(224, 6%, 56%); + --color-gray-5: hsl(224, 6%, 77%); + --color-gray-6: hsl(224, 20%, 94%); + --color-gray-7: hsl(224, 19%, 97%); + --color-black: hsl(0, 0%, 100%); + + --color-orange-high: hsl(var(--hue-orange), 80%, 25%); + --color-orange: hsl(var(--hue-orange), 90%, 60%); + --color-orange-low: hsl(var(--hue-orange), 90%, 88%); + --color-green-high: hsl(var(--hue-green), 80%, 22%); + --color-green: hsl(var(--hue-green), 90%, 46%); + --color-green-low: hsl(var(--hue-green), 85%, 90%); + --color-blue-high: hsl(var(--hue-blue), 80%, 30%); + --color-blue: hsl(var(--hue-blue), 90%, 60%); + --color-blue-low: hsl(var(--hue-blue), 88%, 90%); + --color-purple-high: hsl(var(--hue-purple), 90%, 30%); + --color-purple: hsl(var(--hue-purple), 90%, 60%); + --color-purple-low: hsl(var(--hue-purple), 80%, 90%); + --color-red-high: hsl(var(--hue-red), 80%, 30%); + --color-red: hsl(var(--hue-red), 90%, 60%); + --color-red-low: hsl(var(--hue-red), 80%, 90%); + + --color-accent-high: hsl(13, 75%, 26%); + --color-accent: hsl(13, 88%, 60%); + --color-accent-low: hsl(13, 100%, 89%); + + --color-text-accent: var(--color-accent); + --color-text-dimmed: var(--color-gray-4); + --color-text-invert: var(--color-black); + --color-text-accent-invert: var(--color-accent-low); + --color-bg-surface: var(--color-gray-6); + --color-bg-accent: var(--color-accent); + + --color-backdrop-overlay: hsla(225, 9%, 36%, 0.66); +} diff --git a/cloud/web/src/ui/style/token/font.css b/cloud/web/src/ui/style/token/font.css new file mode 100644 index 000000000..24b2db3f2 --- /dev/null +++ b/cloud/web/src/ui/style/token/font.css @@ -0,0 +1,20 @@ +:root { + --font-size-2xs: 0.6875rem; + --font-size-xs: 0.75rem; + --font-size-sm: 0.8125rem; + --font-size-md: 0.9375rem; + --font-size-lg: 1.125rem; + --font-size-xl: 1.25rem; + --font-size-2xl: 1.5rem; + --font-size-3xl: 1.875rem; + --font-size-4xl: 2.25rem; + --font-size-5xl: 3rem; + --font-size-6xl: 3.75rem; + --font-size-7xl: 4.5rem; + --font-size-8xl: 6rem; + --font-size-9xl: 8rem; + --font-mono: IBM Plex Mono, monospace; + --font-sans: Rubik, sans-serif; + + --font-line-height: 1.75; +} diff --git a/cloud/web/src/ui/style/token/reset.css b/cloud/web/src/ui/style/token/reset.css new file mode 100644 index 000000000..f4aa1a0a9 --- /dev/null +++ b/cloud/web/src/ui/style/token/reset.css @@ -0,0 +1,212 @@ +* { + margin: 0; + padding: 0; + font: inherit; +} + +*, +*::before, +*::after { + box-sizing: border-box; + border-width: 0; + border-style: solid; + border-color: var(--global-color-border, currentColor); +} + +html { + line-height: 1.5; + --font-fallback: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, + "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, + "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; + -webkit-text-size-adjust: 100%; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + -moz-tab-size: 4; + tab-size: 4; + font-family: var(--global-font-body, var(--font-fallback)); +} + +hr { + height: 0; + color: inherit; + border-top-width: 1px; +} + +body { + height: 100%; + line-height: inherit; +} + +img { + border-style: none; +} + +img, +svg, +video, +canvas, +audio, +iframe, +embed, +object { + display: block; + vertical-align: middle; +} + +img, +video { + max-width: 100%; + height: auto; +} + +p, +h1, +h2, +h3, +h4, +h5, +h6 { + overflow-wrap: break-word; +} + +ol, +ul { + list-style: none; +} + +code, +kbd, +pre, +samp { + font-size: 1em; +} + +button, +[type="button"], +[type="reset"], +[type="submit"] { + -webkit-appearance: button; + background-color: transparent; + background-image: none; +} + +button, +input, +optgroup, +select, +textarea { + color: inherit; +} + +button, +select { + text-transform: none; +} + +table { + text-indent: 0; + border-color: inherit; + border-collapse: collapse; +} + +input::placeholder, +textarea::placeholder { + opacity: 1; + color: var(--global-color-placeholder, #9ca3af); +} + +textarea { + resize: vertical; +} + +summary { + display: list-item; +} + +small { + font-size: 80%; +} + +sub, +sup { + font-size: 75%; + line-height: 0; + position: relative; + vertical-align: baseline; +} + +sub { + bottom: -0.25em; +} + +sup { + top: -0.5em; +} + +dialog { + padding: 0; +} + +a { + color: inherit; + text-decoration: inherit; +} + +abbr:where([title]) { + text-decoration: underline dotted; +} + +b, +strong { + font-weight: bolder; +} + +code, +kbd, +samp, +pre { + font-size: 1em; + --font-mono-fallback: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, + "Liberation Mono", "Courier New"; + font-family: var(--global-font-mono, var(--font-fallback)); +} + +input[type="text"], +input[type="email"], +input[type="search"], +input[type="password"] { + -webkit-appearance: none; + -moz-appearance: none; +} + +input[type="search"] { + -webkit-appearance: textfield; + outline-offset: -2px; +} + +::-webkit-search-decoration, +::-webkit-search-cancel-button { + -webkit-appearance: none; +} + +::-webkit-file-upload-button { + -webkit-appearance: button; + font: inherit; +} + +input[type="number"]::-webkit-inner-spin-button, +input[type="number"]::-webkit-outer-spin-button { + height: auto; +} + +input[type="number"] { + -moz-appearance: textfield; +} + +:-moz-ui-invalid { + box-shadow: none; +} + +:-moz-focusring { + outline: auto; +} diff --git a/cloud/web/src/ui/style/token/space.css b/cloud/web/src/ui/style/token/space.css new file mode 100644 index 000000000..b1e492f49 --- /dev/null +++ b/cloud/web/src/ui/style/token/space.css @@ -0,0 +1,38 @@ +:root { + --space-0: 0; + --space-px: 1px; + --space-0-5: 0.125rem; + --space-1: 0.25rem; + --space-1-5: 0.375rem; + --space-2: 0.5rem; + --space-2-5: 0.625rem; + --space-3: 0.75rem; + --space-3-5: 0.875rem; + --space-4: 1rem; + --space-4-5: 1.125rem; + --space-5: 1.25rem; + --space-6: 1.5rem; + --space-7: 1.75rem; + --space-8: 2rem; + --space-9: 2.25rem; + --space-10: 2.5rem; + --space-11: 2.75rem; + --space-12: 3rem; + --space-14: 3.5rem; + --space-16: 4rem; + --space-20: 5rem; + --space-24: 6rem; + --space-28: 7rem; + --space-32: 8rem; + --space-36: 9rem; + --space-40: 10rem; + --space-44: 11rem; + --space-48: 12rem; + --space-52: 13rem; + --space-56: 14rem; + --space-60: 15rem; + --space-64: 16rem; + --space-72: 18rem; + --space-80: 20rem; + --space-96: 24rem; +} diff --git a/cloud/web/src/ui/svg/icons.tsx b/cloud/web/src/ui/svg/icons.tsx new file mode 100644 index 000000000..c09bbc47a --- /dev/null +++ b/cloud/web/src/ui/svg/icons.tsx @@ -0,0 +1,1292 @@ +import { JSX } from "solid-js" + +export function IconPencilSquare(props: JSX.SvgSVGAttributes) { + return ( + + + + + ) +} + +export function IconHome(props: JSX.SvgSVGAttributes) { + return ( + + + + + ) +} + +export function IconPlus(props: JSX.SvgSVGAttributes) { + return ( + + + + ) +} + +export function IconDocument(props: JSX.SvgSVGAttributes) { + return ( + + + + + ) +} + +export function IconChat(props: JSX.SvgSVGAttributes) { + return ( + + + + ) +} + +export function IconBell(props: JSX.SvgSVGAttributes) { + return ( + + + + ) +} + +export function IconCheck(props: JSX.SvgSVGAttributes) { + return ( + + + + ) +} + +export function IconChevronDown(props: JSX.SvgSVGAttributes) { + return ( + + + + ) +} + +export function IconChevronUp(props: JSX.SvgSVGAttributes) { + return ( + + + + ) +} + +export function IconChevronLeft(props: JSX.SvgSVGAttributes) { + return ( + + + + ) +} + +export function IconChevronRight(props: JSX.SvgSVGAttributes) { + return ( + + + + ) +} + +export function IconTrash(props: JSX.SvgSVGAttributes) { + return ( + + + + ) +} + +export function IconUser(props: JSX.SvgSVGAttributes) { + return ( + + + + ) +} + +export function IconCog(props: JSX.SvgSVGAttributes) { + return ( + + + + ) +} + +export function IconExclamationCircle( + props: JSX.SvgSVGAttributes, +) { + return ( + + + + ) +} + +export function IconInformationCircle( + props: JSX.SvgSVGAttributes, +) { + return ( + + + + ) +} + +export function IconArrowPath(props: JSX.SvgSVGAttributes) { + return ( + + + + ) +} + +export function IconArrowUpRight(props: JSX.SvgSVGAttributes) { + return ( + + + + ) +} + +export function IconEllipsisVertical( + props: JSX.SvgSVGAttributes, +) { + return ( + + + + ) +} + +export function IconEllipsisHorizontal( + props: JSX.SvgSVGAttributes, +) { + return ( + + + + ) +} + +export function IconXMark(props: JSX.SvgSVGAttributes) { + return ( + + + + ) +} + +export function IconAcademicCap(props: JSX.SvgSVGAttributes) { + return ( + + + + + + ) +} + +export function IconBolt(props: JSX.SvgSVGAttributes) { + return ( + + + + ) +} + +export function IconCalendar(props: JSX.SvgSVGAttributes) { + return ( + + + + ) +} + +export function IconCamera(props: JSX.SvgSVGAttributes) { + return ( + + + + + ) +} + +export function IconClock(props: JSX.SvgSVGAttributes) { + return ( + + + + ) +} + +export function IconCloud(props: JSX.SvgSVGAttributes) { + return ( + + + + ) +} + +export function IconCreditCard(props: JSX.SvgSVGAttributes) { + return ( + + + + + ) +} + +export function IconEnvelope(props: JSX.SvgSVGAttributes) { + return ( + + + + + ) +} + +export function IconEye(props: JSX.SvgSVGAttributes) { + return ( + + + + + ) +} + +export function IconFlag(props: JSX.SvgSVGAttributes) { + return ( + + + + ) +} + +export function IconFolder(props: JSX.SvgSVGAttributes) { + return ( + + + + ) +} + +export function IconGlobe(props: JSX.SvgSVGAttributes) { + return ( + + + + ) +} + +export function IconHeart(props: JSX.SvgSVGAttributes) { + return ( + + + + ) +} + +export function IconKey(props: JSX.SvgSVGAttributes) { + return ( + + + + ) +} + +export function IconLink(props: JSX.SvgSVGAttributes) { + return ( + + + + ) +} + +export function IconLock(props: JSX.SvgSVGAttributes) { + return ( + + + + ) +} + +export function IconMap(props: JSX.SvgSVGAttributes) { + return ( + + + + ) +} + +export function IconMicrophone(props: JSX.SvgSVGAttributes) { + return ( + + + + + ) +} + +export function IconPhone(props: JSX.SvgSVGAttributes) { + return ( + + + + ) +} + +export function IconPhoto(props: JSX.SvgSVGAttributes) { + return ( + + + + ) +} + +export function IconQuestionMarkCircle( + props: JSX.SvgSVGAttributes, +) { + return ( + + + + ) +} + +export function IconMagnifyingGlass( + props: JSX.SvgSVGAttributes, +) { + return ( + + + + ) +} + +export function IconShieldCheck(props: JSX.SvgSVGAttributes) { + return ( + + + + ) +} + +export function IconShoppingCart(props: JSX.SvgSVGAttributes) { + return ( + + + + ) +} + +export function IconStar(props: JSX.SvgSVGAttributes) { + return ( + + + + ) +} + +export function IconTag(props: JSX.SvgSVGAttributes) { + return ( + + + + ) +} + +export function IconUserCircle(props: JSX.SvgSVGAttributes) { + return ( + + + + ) +} + +export function IconVideoCamera(props: JSX.SvgSVGAttributes) { + return ( + + + + ) +} + +export function IconWifi(props: JSX.SvgSVGAttributes) { + return ( + + + + ) +} + +export function IconAdjustmentsVertical( + props: JSX.SvgSVGAttributes, +) { + return ( + + + + ) +} + +export function IconArchiveBox(props: JSX.SvgSVGAttributes) { + return ( + + + + + ) +} + +export function IconArrowDown(props: JSX.SvgSVGAttributes) { + return ( + + + + ) +} + +export function IconArrowLeft(props: JSX.SvgSVGAttributes) { + return ( + + + + ) +} + +export function IconArrowLongDown(props: JSX.SvgSVGAttributes) { + return ( + + + + ) +} + +export function IconArrowLongLeft(props: JSX.SvgSVGAttributes) { + return ( + + + + ) +} + +export function IconArrowLongRight(props: JSX.SvgSVGAttributes) { + return ( + + + + ) +} + +export function IconArrowLongUp(props: JSX.SvgSVGAttributes) { + return ( + + + + ) +} + +export function IconArrowRight(props: JSX.SvgSVGAttributes) { + return ( + + + + ) +} + +export function IconArrowSmallDown(props: JSX.SvgSVGAttributes) { + return ( + + + + + ) +} + +export function IconArrowSmallLeft(props: JSX.SvgSVGAttributes) { + return ( + + + + + ) +} + +export function IconArrowSmallRight( + props: JSX.SvgSVGAttributes, +) { + return ( + + + + + ) +} + +export function IconArrowSmallUp(props: JSX.SvgSVGAttributes) { + return ( + + + + + ) +} + +export function IconArrowTopRightOnSquare( + props: JSX.SvgSVGAttributes, +) { + return ( + + + + ) +} + +export function IconArrowTrendingDown( + props: JSX.SvgSVGAttributes, +) { + return ( + + + + + ) +} + +export function IconArrowTrendingUp( + props: JSX.SvgSVGAttributes, +) { + return ( + + + + ) +} + +export function IconArrowUp(props: JSX.SvgSVGAttributes) { + return ( + + + + ) +} + +export function IconArrowUpCircle(props: JSX.SvgSVGAttributes) { + return ( + + + + ) +} + +export function IconArrowUpLeft(props: JSX.SvgSVGAttributes) { + return ( + + + + ) +} + +export function IconArrowUpOnSquare( + props: JSX.SvgSVGAttributes, +) { + return ( + + + + + ) +} + +export function IconArrowUpTray(props: JSX.SvgSVGAttributes) { + return ( + + + + ) +} + +export function IconArrowsPointingIn( + props: JSX.SvgSVGAttributes, +) { + return ( + + + + ) +} + +export function IconArrowsPointingOut( + props: JSX.SvgSVGAttributes, +) { + return ( + + + + ) +} + +export function IconArrowsRightLeft( + props: JSX.SvgSVGAttributes, +) { + return ( + + + + ) +} + +export function IconBars3BottomLeft( + props: JSX.SvgSVGAttributes, +) { + return ( + + + + ) +} diff --git a/cloud/web/src/ui/svg/index.tsx b/cloud/web/src/ui/svg/index.tsx new file mode 100644 index 000000000..23dd74c6e --- /dev/null +++ b/cloud/web/src/ui/svg/index.tsx @@ -0,0 +1,67 @@ +import { JSX } from "solid-js" + +export function IconLogomark(props: JSX.SvgSVGAttributes) { + return ( + + + + ) +} + +export function IconLogo(props: JSX.SvgSVGAttributes) { + return ( + + + + + + + + + + + + + + + ) +} diff --git a/cloud/web/src/util/context.tsx b/cloud/web/src/util/context.tsx new file mode 100644 index 000000000..d1c6f4e7f --- /dev/null +++ b/cloud/web/src/util/context.tsx @@ -0,0 +1,26 @@ +import { ParentProps, Show, createContext, useContext } from "solid-js" + +export function createInitializedContext< + Name extends string, + T extends { ready: boolean }, +>(name: Name, cb: () => T) { + const ctx = createContext() + + return { + use: () => { + const context = useContext(ctx) + if (!context) throw new Error(`No ${name} context`) + return context + }, + provider: (props: ParentProps) => { + const value = cb() + return ( + + + {props.children} + + + ) + }, + } +} diff --git a/cloud/web/sst-env.d.ts b/cloud/web/sst-env.d.ts new file mode 100644 index 000000000..b6a7e9066 --- /dev/null +++ b/cloud/web/sst-env.d.ts @@ -0,0 +1,9 @@ +/* This file is auto-generated by SST. Do not edit. */ +/* tslint:disable */ +/* eslint-disable */ +/* deno-fmt-ignore-file */ + +/// + +import "sst" +export {} \ No newline at end of file diff --git a/cloud/web/tsconfig.json b/cloud/web/tsconfig.json new file mode 100644 index 000000000..98d5b9cea --- /dev/null +++ b/cloud/web/tsconfig.json @@ -0,0 +1,12 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "@tsconfig/node22/tsconfig.json", + "compilerOptions": { + "jsx": "preserve", + "jsxImportSource": "solid-js", + "module": "esnext", + "moduleResolution": "bundler", + "lib": ["ESNext", "DOM", "DOM.Iterable"], + "types": ["vite/client"] + } +} diff --git a/cloud/web/vite.config.ts b/cloud/web/vite.config.ts new file mode 100644 index 000000000..8a569641e --- /dev/null +++ b/cloud/web/vite.config.ts @@ -0,0 +1,63 @@ +import { defineConfig } from "vite" +import solidPlugin from "vite-plugin-solid" +import pages from "vite-plugin-pages" +import fs from "fs" +import path from "path" +import { generateHydrationScript, getAssets } from "solid-js/web" + +export default defineConfig({ + plugins: [ + pages({ + exclude: ["**/~*", "**/components/*"], + }), + solidPlugin({ ssr: true }), + { + name: "vite-plugin-solid-ssr-render", + apply: (config, env) => { + return env.command === "build" && !config.build?.ssr + }, + closeBundle: async () => { + console.log("Pre-rendering pages...") + const dist = path.resolve("dist") + try { + const serverEntryPath = path.join(dist, "server/entry-server.js") + const serverEntry = await import(serverEntryPath + "?t=" + Date.now()) + + const template = fs.readFileSync( + path.join(dist, "client/index.html"), + "utf-8", + ) + fs.writeFileSync(path.join(dist, "client/fallback.html"), template) + + const routes = ["/"] + for (const route of routes) { + const { app } = await serverEntry.render({ url: route }) + const html = template + .replace("", app) + .replace("", generateHydrationScript()) + .replace("", getAssets()) + const filePath = path.join( + dist, + `client${route === "/" ? "/index" : route}.html`, + ) + fs.mkdirSync(path.dirname(filePath), { + recursive: true, + }) + fs.writeFileSync(filePath, html) + + console.log(`Pre-rendered: ${filePath}`) + } + } catch (error) { + console.error("Error during pre-rendering:", error) + } + }, + }, + ], + server: { + port: 3000, + host: "0.0.0.0", + }, + build: { + target: "esnext", + }, +}) -- cgit v1.2.3