diff --git a/.env.example b/.env.example
index 0f0730ba2..30f28e508 100644
--- a/.env.example
+++ b/.env.example
@@ -1 +1,14 @@
-OPENAI_API_KEY="..."
\ No newline at end of file
+## This file is an example of the .env and .env.docker files you should have before running docker-compose command
+
+PINO_PRETTIFY="true"
+DATABASE_URL=postgresql://postgres:postgres@postgres:5432/pezzo
+SUPERTOKENS_CONNECTION_URI="http://supertokens:3567"
+CONSOLE_HOST="http://pezzo-console:4200"
+KAFKA_BROKERS="kafka:9092"
+OPENSEARCH_URL="http://opensearch:9200"
+REDIS_URL="redis://redis-stack-server:6379"
+
+NX_BASE_API_URL="http://localhost:3000"
+NX_SUPERTOKENS_API_DOMAIN="http://localhost:3000"
+NX_SUPERTOKENS_WEBSITE_DOMAIN="http://localhost:4200"
+NX_DEBUG_MODE="true"
diff --git a/.eslintignore b/.eslintignore
index 4f4aa6926..e24b1c731 100644
--- a/.eslintignore
+++ b/.eslintignore
@@ -1,2 +1,3 @@
node_modules
-@generated
\ No newline at end of file
+@generated
+.next
\ No newline at end of file
diff --git a/.eslintrc.json b/.eslintrc.json
index 38d329eed..de671d112 100644
--- a/.eslintrc.json
+++ b/.eslintrc.json
@@ -1,7 +1,7 @@
{
"root": true,
"ignorePatterns": ["**/*"],
- "plugins": ["@nrwl/nx"],
+ "plugins": ["@nx"],
"overrides": [
{
"files": ["*.ts", "*.tsx", "*.js", "*.jsx"],
@@ -13,11 +13,11 @@
"allowTemplateLiterals": true
}
],
- "@nrwl/nx/enforce-module-boundaries": [
+ "@nx/enforce-module-boundaries": [
"error",
{
+ "allow": ["@pezzo/kafka"],
"enforceBuildableLibDependency": true,
- "allow": ["@pezzo/graphql", "@pezzo/integrations", "@pezzo/client"],
"depConstraints": [
{
"sourceTag": "*",
@@ -30,7 +30,7 @@
},
{
"files": ["*.ts", "*.tsx"],
- "extends": ["plugin:@nrwl/nx/typescript"],
+ "extends": ["plugin:@nx/typescript"],
"rules": {
"@typescript-eslint/quotes": [
"error",
@@ -43,7 +43,7 @@
},
{
"files": ["*.js", "*.jsx"],
- "extends": ["plugin:@nrwl/nx/javascript"],
+ "extends": ["plugin:@nx/javascript"],
"rules": {}
}
]
diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS
new file mode 100644
index 000000000..90307ecb3
--- /dev/null
+++ b/.github/CODEOWNERS
@@ -0,0 +1,2 @@
+# Global rule:
+* @arielweinberger
\ No newline at end of file
diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml
new file mode 100644
index 000000000..427019a1c
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/bug_report.yaml
@@ -0,0 +1,56 @@
+name: Report a bug đ
+description: Create a report to help us improve
+labels: "bug"
+body:
+ - type: textarea
+ attributes:
+ label: Report
+ description: "What issue have you encountered?"
+ placeholder: "A clear and concise description of what issue bug is."
+ validations:
+ required: true
+ - type: textarea
+ attributes:
+ label: Expected behavior
+ description: What did you expect to happen?
+ placeholder: What did you expect to happen?
+ validations:
+ required: false
+ - type: textarea
+ attributes:
+ label: Steps to reproduce the problem
+ description: "How can we reproduce this bug? Please walk us through it step by step."
+ value: |
+ 1.
+ 2.
+ 3.
+ validations:
+ required: false
+ - type: textarea
+ attributes:
+ label: Logs (if applicable)
+ description: "Provide any logs, if applicable. This will help us identify the issue."
+ value: |
+ ```
+ example
+ ```
+ validations:
+ required: false
+ - type: input
+ attributes:
+ label: Pezzo version
+ description: >
+ What version of Pezzo are you using?
+ validations:
+ required: false
+ - type: dropdown
+ attributes:
+ label: How do you use Pezzo?
+ description: Where
+ options:
+ - Local Development Setup
+ - Docker Compose
+ - Kubernetes Deployment
+ - Pezzo Cloud
+ validations:
+ required: false
diff --git a/.github/ISSUE_TEMPLATE/feature_request.yaml b/.github/ISSUE_TEMPLATE/feature_request.yaml
new file mode 100644
index 000000000..c9f3f98bc
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/feature_request.yaml
@@ -0,0 +1,27 @@
+name: Feature request đ§
+description: Suggest an idea for the Pezzo project
+labels: "feature-request"
+body:
+ - type: textarea
+ attributes:
+ label: Proposal
+ description: "What would you like to have as a feature"
+ placeholder: "A clear and concise description of what you want to happen."
+ validations:
+ required: true
+ - type: textarea
+ attributes:
+ label: Use-Case
+ description: "How would this help you?"
+ placeholder: "Tell us more what you'd like to achieve."
+ validations:
+ required: false
+ - type: dropdown
+ attributes:
+ label: Is this a feature you are interested in implementing yourself?
+ options:
+ - "No"
+ - "Maybe"
+ - "Yes"
+ validations:
+ required: true
diff --git a/.github/assets/banner-with-play-button.png b/.github/assets/banner-with-play-button.png
new file mode 100644
index 000000000..181f3438a
Binary files /dev/null and b/.github/assets/banner-with-play-button.png differ
diff --git a/.github/assets/banner.png b/.github/assets/banner.png
index 5a9e69a92..dafe1209b 100644
Binary files a/.github/assets/banner.png and b/.github/assets/banner.png differ
diff --git a/.github/assets/features/features-1.png b/.github/assets/features/features-1.png
new file mode 100644
index 000000000..f1ac0855a
Binary files /dev/null and b/.github/assets/features/features-1.png differ
diff --git a/.github/assets/features/features-2.png b/.github/assets/features/features-2.png
new file mode 100644
index 000000000..79ee747ed
Binary files /dev/null and b/.github/assets/features/features-2.png differ
diff --git a/.github/assets/features/features-3.png b/.github/assets/features/features-3.png
new file mode 100644
index 000000000..8a314cde9
Binary files /dev/null and b/.github/assets/features/features-3.png differ
diff --git a/.github/assets/logo-dark-mode.svg b/.github/assets/logo-dark-mode.svg
new file mode 100644
index 000000000..8bca740ba
--- /dev/null
+++ b/.github/assets/logo-dark-mode.svg
@@ -0,0 +1,22 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/.github/assets/logo-dark.svg b/.github/assets/logo-dark.svg
deleted file mode 100644
index 7808c55a3..000000000
--- a/.github/assets/logo-dark.svg
+++ /dev/null
@@ -1,9 +0,0 @@
-
-
-
-
-
-
-
-
-
diff --git a/.github/assets/logo-light-mode.svg b/.github/assets/logo-light-mode.svg
new file mode 100644
index 000000000..0588a1bb4
--- /dev/null
+++ b/.github/assets/logo-light-mode.svg
@@ -0,0 +1,22 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml
index e044c290e..af9025b1e 100644
--- a/.github/workflows/ci.yaml
+++ b/.github/workflows/ci.yaml
@@ -5,24 +5,37 @@ on:
pull_request:
branches:
- "main"
+ - "release/*"
paths-ignore:
- "**/*.md"
- ".github/workflows/release.yaml"
+ - "docs"
push:
branches:
- "main"
+ - "release/*"
paths-ignore:
- "**/*.md"
- ".github/workflows/release.yaml"
+ - "docs"
+
+env:
+ NX_CLOUD_ACCESS_TOKEN: ${{ secrets.NX_CLOUD_ACCESS_TOKEN }}
+ NX_BRANCH: ${{ github.head_ref || github.ref_name }}
+ BASE: ${{ github.base_ref || github.event.repository.default_branch }}
jobs:
ci:
name: Continuous Integration
- runs-on: ubuntu-20.04
+ runs-on: ubuntu-22.04
permissions:
packages: write
steps:
- - uses: actions/checkout@v2
+ - name: Set variables
+ id: variables
+ run: echo "::set-output name=short_sha::$(echo ${{ github.sha }} | cut -c1-7)"
+
+ - uses: actions/checkout@v3
with:
fetch-depth: 0
@@ -53,7 +66,39 @@ jobs:
run: npx nx run-many --target=test --all --parallel --maxParallel=3
- name: Build
- run: npx nx run-many --target=build --all --parallel --maxParallel=3
+ run: |
+ npx nx graphql:generate --skip-nx-cache
+ npx nx run-many --target=build --all --parallel --maxParallel=3
+
+ - name: Upload dist artifact
+ uses: actions/upload-artifact@v2
+ with:
+ name: dist-artifact
+ path: ./dist
+
+ dockerize:
+ name: Dockerize
+ needs: ci
+ runs-on: ubuntu-20.04
+ strategy:
+ matrix:
+ project: ["server", "console", "proxy"]
+ steps:
+ - uses: actions/checkout@v3
+ with:
+ fetch-depth: 0
+
+ - name: Download 'dist' folder
+ uses: actions/download-artifact@v2
+ with:
+ name: dist-artifact
+ path: ./dist
+
+ - name: Set up QEMU
+ uses: docker/setup-qemu-action@v2
+
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@v2
- name: Login to GitHub Container Registry
uses: docker/login-action@v2
@@ -62,14 +107,28 @@ jobs:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- - name: Set up QEMU
- uses: docker/setup-qemu-action@v2
- - name: Set up Docker Buildx
- uses: docker/setup-buildx-action@v2
+ - name: Docker metadata
+ id: meta
+ uses: docker/metadata-action@v4
+ with:
+ images: |
+ ghcr.io/pezzolabs/pezzo/${{ matrix.project }}
+ tags: |
+ type=raw,value=${{ github.run_id }},prefix=gh-
+ type=ref,event=branch,prefix=branch-
+ type=ref,event=pr,prefix=pr-
+ type=ref,event=tag
+ type=sha,format=short
- - name: Dockerize
- run: npx nx run-many --target=docker:build --configuration=ci --all --parallel --maxParallel=4
- env:
- INPUT_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- INPUT_TAGS: "type=raw,value=${{ github.run_id }},prefix=gh-\ntype=ref,event=branch,prefix=branch-\ntype=sha,format=short"
- INPUT_PLATFORMS: "linux/amd64"
+ - name: Build and push
+ uses: docker/build-push-action@v4
+ with:
+ context: .
+ file: ./apps/${{ matrix.project }}/Dockerfile
+ tags: ${{ steps.meta.outputs.tags }}
+ labels: ${{ steps.meta.outputs.labels }}
+ platforms: linux/amd64
+ # only push if the branch is main or a release branch
+ push: true
+ # push: ${{ startsWith(github.ref, 'refs/heads/main') || startsWith(github.ref, 'refs/heads/release/') }}
+ provenance: false
diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml
index 222eb6be6..4a8350a84 100644
--- a/.github/workflows/release.yaml
+++ b/.github/workflows/release.yaml
@@ -5,8 +5,8 @@ on:
types: [published]
jobs:
- ci:
- name: Release
+ build:
+ name: Build
runs-on: ubuntu-20.04
permissions:
packages: write
@@ -46,18 +46,112 @@ jobs:
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build
- run: npx nx run-many --target=build --all --parallel --maxParallel=3
+ run: |
+ npx nx graphql:generate --skip-nx-cache
+ npx nx run-many --target=build --all --parallel --maxParallel=3
- - name: Dockerize
- run: npx nx run-many --target=docker:build --configuration=ci --all --parallel --maxParallel=4
- env:
- INPUT_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- INPUT_TAGS: "type=semver,pattern={{version}}\ntype=raw,value=latest"
- INPUT_PLATFORMS: "linux/amd64,linux/arm64"
-
- - name: NPM Publish (@pezzo/client)
- continue-on-error: true
- run: npm publish --access public
- working-directory: dist/libs/client
+ - name: Upload dist artifact
+ uses: actions/upload-artifact@v2
+ with:
+ name: dist-artifact
+ path: ./dist
+
+ dockerize:
+ name: Dockerize
+ needs: build
+ runs-on: ubuntu-20.04
+ strategy:
+ matrix:
+ project: ["server", "console", "proxy"]
+ steps:
+ - uses: actions/checkout@v2
+ with:
+ fetch-depth: 0
+
+ - name: Download 'dist' folder
+ uses: actions/download-artifact@v2
+ with:
+ name: dist-artifact
+ path: ./dist
+
+ - name: Set up QEMU
+ uses: docker/setup-qemu-action@v2
+
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@v2
+
+ - name: Login to GitHub Container Registry
+ uses: docker/login-action@v2
+ with:
+ registry: ghcr.io
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Docker metadata
+ id: meta
+ uses: docker/metadata-action@v4
+ with:
+ images: |
+ ghcr.io/pezzolabs/pezzo/${{ matrix.project }}
+ tags: |
+ type=semver,pattern={{version}}
+ type=sha,format=short
+ type=raw,value=latest,enable=${{ contains(github.ref, 'refs/tags/v') && !contains(github.ref, 'alpha') }}
+
+ - name: Build and push
+ uses: docker/build-push-action@v4
+ with:
+ context: .
+ file: ./apps/${{ matrix.project }}/Dockerfile
+ tags: ${{ steps.meta.outputs.tags }}
+ labels: ${{ steps.meta.outputs.labels }}
+ platforms: linux/amd64
+ push: true
+ provenance: false
+
+ npm_publish:
+ name: NPM Publish
+ needs: build
+ runs-on: ubuntu-20.04
+ strategy:
+ matrix:
+ library: ["client"]
+ steps:
+ - uses: actions/checkout@v2
+ with:
+ fetch-depth: 0
+
+ - name: Download 'dist' folder
+ uses: actions/download-artifact@v2
+ with:
+ name: dist-artifact
+ path: ./dist
+
+ - uses: actions/setup-node@v3
+ with:
+ node-version: 18.x
+ registry-url: https://registry.npmjs.org/
+
+ - name: Check package version
+ id: cpv
+ uses: PostHog/check-package-version@v2
+ with:
+ path: ./dist/libs/${{ matrix.library }}
+
+ - name: Echo versions
+ run: |
+ echo "Committed version: ${{ steps.cpv.outputs.committed-version }}"
+ echo "Published version: ${{ steps.cpv.outputs.published-version }}"
+
+ - name: NPM Publish (@pezzo/${{ matrix.library }})
+ if: steps.cpv.outputs.is-new-version == 'true'
+ run: |
+ version=$(node -p "require('./package.json').version")
+ if [[ $version == *"alpha"* ]]; then
+ npm publish --access public --tag alpha
+ else
+ npm publish --access public
+ fi
+ working-directory: dist/libs/${{ matrix.library }}
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
diff --git a/.gitignore b/.gitignore
index a9443a509..4b585d7a6 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,7 +2,6 @@
# compiled output
dist
-tmp
/out-tsc
# dependencies
@@ -44,4 +43,15 @@ Thumbs.db
**/@generated
.nx-container
-.env.local
\ No newline at end of file
+.env
+.env.local
+.env.docker
+schema.graphql
+
+# temp
+temp
+tmp
+
+kms/data
+
+volumes
\ No newline at end of file
diff --git a/.nvmrc b/.nvmrc
new file mode 100644
index 000000000..8ddbc0c64
--- /dev/null
+++ b/.nvmrc
@@ -0,0 +1 @@
+v18.16.0
diff --git a/.nxignore b/.nxignore
new file mode 100644
index 000000000..a83931952
--- /dev/null
+++ b/.nxignore
@@ -0,0 +1,3 @@
+.github
+.next
+volumes
\ No newline at end of file
diff --git a/.prettierignore b/.prettierignore
index ff8fc2792..264f07c84 100644
--- a/.prettierignore
+++ b/.prettierignore
@@ -1,5 +1,9 @@
# Add files here to ignore them from prettier formatting
/dist
/coverage
-apps/server/src/schema.graphql
-**/@generated
\ No newline at end of file
+**/schema.graphql
+**/@generated
+libs/common/src/version.json
+**/.next
+/docs
+volumes
\ No newline at end of file
diff --git a/.prettierrc b/.prettierrc
index 1ca87ab7d..6c1a13a1f 100644
--- a/.prettierrc
+++ b/.prettierrc
@@ -1,3 +1,6 @@
{
- "singleQuote": false
+ "singleQuote": false,
+ "plugins": ["prettier-plugin-tailwindcss"],
+ "tailwindConfig": "./apps/console/tailwind.config.js",
+ "tailwindFunctions": ["clsx", "cva"]
}
diff --git a/.vscode/launch.json b/.vscode/launch.json
new file mode 100644
index 000000000..75bc45116
--- /dev/null
+++ b/.vscode/launch.json
@@ -0,0 +1,13 @@
+{
+ "version": "0.2.0",
+ "configurations": [
+ {
+ "name": "Attach - Examples - Task Generator App",
+ "port": 9230,
+ "request": "attach",
+ "skipFiles": ["/**"],
+ "type": "node",
+ "cwd": "${workspaceFolder}/examples/task-generator-app"
+ }
+ ]
+}
diff --git a/.vscode/settings.json b/.vscode/settings.json
new file mode 100644
index 000000000..2f1ecf384
--- /dev/null
+++ b/.vscode/settings.json
@@ -0,0 +1,3 @@
+{
+ "css.customData": [".vscode/tailwind.json"]
+}
diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md
new file mode 100644
index 000000000..403a5a65b
--- /dev/null
+++ b/CODE_OF_CONDUCT.md
@@ -0,0 +1,120 @@
+# Contributor Covenant Code of Conduct
+
+## Our Pledge
+
+We as members, contributors, and leaders pledge to make participation in our
+community a harassment-free experience for everyone, regardless of age, body
+size, visible or invisible disability, ethnicity, sex characteristics, gender
+identity and expression, level of experience, education, socio-economic status,
+nationality, personal appearance, race, religion, or sexual identity
+and orientation.
+
+We pledge to act and interact in ways that contribute to an open, welcoming,
+diverse, inclusive, and healthy community.
+
+## Our Standards
+
+Examples of behavior that contributes to a positive environment for our
+community include:
+
+- Demonstrating empathy and kindness toward other people
+- Being respectful of differing opinions, viewpoints, and experiences
+- Giving and gracefully accepting constructive feedback
+- Accepting responsibility and apologizing to those affected by our mistakes,
+ and learning from the experience
+- Focusing on what is best not just for us as individuals, but for the
+ overall community
+
+Examples of unacceptable behavior include:
+
+- The use of sexualized language or imagery, and sexual attention or
+ advances of any kind
+- Trolling, insulting or derogatory comments, and personal or political attacks
+- Public or private harassment
+- Publishing others' private information, such as a physical or email
+ address, without their explicit permission
+- Other conduct which could reasonably be considered inappropriate in a
+ professional setting
+
+## Enforcement Responsibilities
+
+Community leaders are responsible for clarifying and enforcing our standards of
+acceptable behavior and will take appropriate and fair corrective action in
+response to any behavior that they deem inappropriate, threatening, offensive,
+or harmful.
+
+Community leaders have the right and responsibility to remove, edit, or reject
+comments, commits, code, wiki edits, issues, and other contributions that are
+not aligned to this Code of Conduct, and will communicate reasons for moderation
+decisions when appropriate.
+
+## Scope
+
+This Code of Conduct applies within all community spaces, and also applies when
+an individual is officially representing the community in public spaces.
+Examples of representing our community include using an official e-mail address,
+posting via an official social media account, or acting as an appointed
+representative at an online or offline event.
+
+## Enforcement
+
+Instances of abusive, harassing, or otherwise unacceptable behavior may be
+reported to the community leaders responsible for enforcement at
+hello@pezzo.ai.
+All complaints will be reviewed and investigated promptly and fairly.
+
+All community leaders are obligated to respect the privacy and security of the
+reporter of any incident.
+
+## Enforcement Guidelines
+
+Community leaders will follow these Community Impact Guidelines in determining
+the consequences for any action they deem in violation of this Code of Conduct:
+
+### 1. Correction
+
+**Community Impact**: Use of inappropriate language or other behavior deemed
+unprofessional or unwelcome in the community.
+
+**Consequence**: A private, written warning from community leaders, providing
+clarity around the nature of the violation and an explanation of why the
+behavior was inappropriate. A public apology may be requested.
+
+### 2. Warning
+
+**Community Impact**: A violation through a single incident or series
+of actions.
+
+**Consequence**: A warning with consequences for continued behavior. No
+interaction with the people involved, including unsolicited interaction with
+those enforcing the Code of Conduct, for a specified period of time. This
+includes avoiding interactions in community spaces as well as external channels
+like social media. Violating these terms may lead to a temporary or
+permanent ban.
+
+### 3. Temporary Ban
+
+**Community Impact**: A serious violation of community standards, including
+sustained inappropriate behavior.
+
+**Consequence**: A temporary ban from any sort of interaction or public
+communication with the community for a specified period of time. No public or
+private interaction with the people involved, including unsolicited interaction
+with those enforcing the Code of Conduct, is allowed during this period.
+Violating these terms may lead to a permanent ban.
+
+### 4. Permanent Ban
+
+**Community Impact**: Demonstrating a pattern of violation of community
+standards, including sustained inappropriate behavior, harassment of an
+individual, or aggression toward or disparagement of classes of individuals.
+
+**Consequence**: A permanent ban from any sort of public interaction within
+the community.
+
+## Attribution
+
+This Code of Conduct is adapted from the [Contributor Covenant][homepage],
+version 2.0.
+
+[homepage]: http://contributor-covenant.org
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 000000000..c487ae1f1
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,175 @@
+# Contributing
+
+We opened sourced Pezzo because we believe in the power of community. We believe you can help making Pezzo better!
+We are excited to see what you will build with Pezzo and we are looking forward to your contributions. We want to make contributing to this project as easy and transparent as possible, whether it's features, bug fixes, documentation updates, guides, examples and more.
+
+## How can I contribute?
+
+Ready to contribute but seeking guidance, we have several avenues to assist you. Explore the upcoming segment for clarity on the kind of contributions we appreciate and how to jump in. Reach out directly to the Pezzo team on [Discord](https://discord.gg/h5nBW5ySqQ) for immediate assistance! Alternatively, you're welcome to raise an issue and one of our dedicated maintainers will promptly steer you in the right direction!
+
+## Found a bug?
+
+If you find a bug in the source code, you can help us by [submitting an issue](https://github.com/pezzolabs/pezzo/issues/new?assignees=&labels=bug&projects=&template=bug_report.yaml) to our GitHub Repository. Even better, you can submit a Pull Request with a fix.
+
+## Missing a feature?
+
+So, you've got an awesome feature in mind? Throw it over to us by [creating an issue](https://github.com/pezzolabs/pezzo/issues/new?assignees=&labels=feature-request&projects=&template=feature_request.yaml) on our GitHub Repo.
+
+Planning to code a feature yourself? We love the enthusiasm, but hang on, always good to have a little chinwag with us before you burn that midnight oil. Unfortunately, not every feature might fit into our plans.
+
+- Dreaming big? Kick off by opening an issue and sketch out your cool ideas. Helps us all stay on the same page, avoid doing the same thing twice, and ensures your hard work gels well into the project.
+- Cooking up something small? Just craft it and [shoot it straight as a Pull Request](#submit-pr).
+
+## What do you need to know to help?
+
+If you want to help out with a code contribution, our project uses the following stack:
+
+### Server-side
+
+- [Node.JS](https://nodejs.org/)
+- [TypeScript](https://www.typescriptlang.org/docs)
+- [NestJS](https://docs.nestjs.com/)
+- [Prisma](https://www.prisma.io/docs/) (with [PostgreSQL](https://www.postgresql.org/about/))
+- [GraphQL API](https://docs.nestjs.com/graphql/quick-start)
+- [Jest](https://docs.nestjs.com/fundamentals/testing) (for testing)
+
+### Client-side
+
+- [React](https://reactjs.org/docs/getting-started.html)
+- [TypeScript](https://www.typescriptlang.org/docs)
+- [Apollo Client](https://www.apollographql.com/docs/react/)
+
+If you don't feel ready to make a code contribution yet, no problem! You can also check out the [documentation issues](https://github.com/pezzolabs/pezzo/labels/type%3A%20docs).
+
+# How do I make a code contribution?
+
+## Good first issues
+
+Are you new to open source contribution? Wondering how contributions work in our project? Here's a quick rundown.
+
+Find an issue that you're interested in addressing, or a feature that you'd like to add.
+You can use [this view](https://github.com/pezzolabs/pezzo/issues?q=is%3Aopen+is%3Aissue+label%3A%22good+first+issue%22) which helps new contributors find easy gateways into our project.
+
+## Step 1: Make a fork
+
+Fork the Pezzo repository to your GitHub organization. This means that you'll have a copy of the repository under _your-GitHub-username/repository-name_.
+
+## Step 2: Clone the repository to your local machine
+
+```
+git clone https://github.com/{your-GitHub-username}/pezzo.git
+
+```
+
+## Step 3: Prepare the development environment
+
+Set up and run the development environment on your local machine:
+
+**BEFORE** you run the following steps make sure:
+
+1. You have typescript installed locally on you machine `npm install -g typescript`
+2. You are using node version: ^18.16.0 || ^14.0.0"
+3. You are using npm version: ^8.1.0 || ^7.3.0"
+4. You have `docker` installed and running on your machine
+
+```shell
+cd pezzo
+npm install
+```
+
+## Step 4: Create a branch
+
+Create a new branch for your changes.
+In order to keep branch names uniform and easy-to-understand, please use the following conventions for branch naming.
+Generally speaking, it is a good idea to add a group/type prefix to a branch.
+Here is a list of good examples:
+
+- for docs change : docs/{ISSUE_NUMBER}-{CUSTOM_NAME}
+- for new features : feat/{ISSUE_NUMBER}-{CUSTOM_NAME}
+- for bug fixes : fix/{ISSUE_NUMBER}-{CUSTOM_NAME}
+
+```jsx
+git checkout -b branch-name-here
+```
+
+## Step 5: Make your changes
+
+Update the code with your bug fix or new feature.
+
+## Step 6: Add the changes that are ready to be committed
+
+Stage the changes that are ready to be committed:
+
+```jsx
+git add .
+```
+
+## Step 7: Commit the changes (Git)
+
+Commit the changes with a short message. (See below for more details on how we structure our commit messages)
+
+```jsx
+git commit -m "(): "
+```
+
+## Step 8: Push the changes to the remote repository
+
+Push the changes to the remote repository using:
+
+```jsx
+git push origin branch-name-here
+```
+
+## Step 9: Create Pull Request
+
+In GitHub, do the following to submit a pull request to the upstream repository:
+
+1. Give the pull request a title and a short description of the changes made. Include also the issue or bug number associated with your change. Explain the changes that you made, any issues you think exist with the pull request you made, and any questions you have for the maintainer.
+
+Remember, it's okay if your pull request is not perfect (no pull request ever is). The reviewer will be able to help you fix any problems and improve it!
+
+2. Wait for the pull request to be reviewed by a maintainer.
+
+3. Make changes to the pull request if the reviewing maintainer recommends them.
+
+Celebrate your success after your pull request is merged :-)
+
+## Git Commit Messages
+
+We structure our commit messages like this:
+
+```
+():
+```
+
+Example
+
+```
+fix(server): missing entity on init
+```
+
+### Types:
+
+- **feat**: A new feature
+- **fix**: A bug fix
+- **docs**: Changes to the documentation
+- **style**: Changes that do not affect the meaning of the code (white-space, formatting, missing semi-colons, etc.)
+- **refactor**: A code change that neither fixes a bug nor adds a feature
+- **perf**: A code change that improves performance
+- **test**: Adding missing or correcting existing tests
+- **chore**: Changes to the build process or auxiliary tools and libraries such as documentation generation
+
+### Packages:
+
+- **server**
+- **console**
+- **client**
+- **types**
+
+## Code of conduct
+
+Please note that this project is released with a Contributor Code of Conduct. By participating in this project you agree to abide by its terms.
+
+[Code of Conduct](https://github.com/pezzolabs/pezzo/blob/main/CODE_OF_CONDUCT.md)
+
+Our Code of Conduct means that you are responsible for treating everyone on the project with respect and courtesy.
diff --git a/README.md b/README.md
index e07ce1103..88824728d 100644
--- a/README.md
+++ b/README.md
@@ -1,78 +1,124 @@
-
-
-
-
- Pezzo is an open-source AI development toolkit designed to streamline prompt design, version management, publishing, collaboration, troubleshooting, observability and more. Our mission is to empower individuals and teams to harness the power of AI with maximum productivity and visibility.
-
-
+
+
-
+
-
+
+ Pezzo is a fully cloud-native and open-source LLMOps platform. Seamlessly observe and monitor your AI operations, troubleshoot issues, save up to 90% on costs and latency, collaborate and manage your prompts in one place, and instantly deliver AI changes.
+
+
+
+
+
+
+
+
+
+
-
-
- Join Pezzo on Discord
+
+
+
+
+
+
+
+
+
+
-# Features
-
-đī¸ **Centralized Prompt Management**: Manage all AI prompts in one place for maximum visibility and efficiency.
-
-đ **Streamlined Prompt Design, Publishing & Versioning:** Create, edit, test and publish prompts with ease.
-
-đ **Observability**: Access detailed prompt execution history, stats and metrics (duration, prompt cost, completion cost, etc.) for better insights.
-
-đ ī¸ **Troubleshooting:** Effortlessly resolve issues with your prompts. Time travel to retroactively fine-tune failed prompts and commit the fix instantly.
-
-đ° **Cost Transparency**: Gain comprehensive cost transparency across all prompts and AI models.
-
-đĄ **Simplified Integration:** Reduce code overhead by 90% by consuming your AI prompts using the Pezzo Client, regardless of the model provider.
-
-# Roadmap
-
-Below you can find the roadmap with all upcoming features:
-
-| **Feature** | **Status** |
-| -------------------------- | -------------- |
-| Demo app | đ§ In Progress |
-| Documentation Site | đ§ In Progress |
-| Logger | đ§ In Progress |
-| Error Handling | đ§ In Progress |
-| Official Helm Chart | đ Coming Soon |
-| Test Coverage | đ Coming Soon |
-| Public Sandbox Environment | đ Coming Soon |
-| Pezzo Client for Python | đ Coming Soon |
-| Pezzo Client for Golang | đ Coming Soon |
-
-If you are missing features, please create an issue and we'll consider adding them to the roadmap.
-
-# Getting Started
-
-Clone the repository:
-
-```
-git clone git@github.com:pezzolabs/pezzo.git
-```
+
+
+
+
+
-## đŗ Option 1: Running Pezzo via Docker Compose
+
-This is a straightforward way to run Pezzo and start using it.
+
-Simply run the following command:
+# ⨠Features
-```
-docker-compose up
-```
+
+
+
-Pezzo should now be accessible at https://localhost:4200 đ
+
+
+
-## đšī¸ Option 2: Running Pezzo in Development Mode
+
+
+
-This method is useful for contirbutors and developers.
+# Documentation
+
+[Click here to navigate to the Official Pezzo Documentation](https://docs.pezzo.ai/)
+
+In the documentation, you can find information on how to use Pezzo, its architecture, including tutorials and recipes for varius use cases and LLM providers.
+
+# Supported Clients
+
+
+
+
+ Feature
+
+ Node.js
+ âĸ
+ Docs
+
+
+ Python
+ âĸ
+ Docs
+
+
+ LangChain
+
+
+
+
+
+ Prompt Management
+ â
+ â
+ â
+
+
+ Observability
+ â
+ â
+ â
+
+
+ Caching
+ â
+ â
+ â
+
+
+
+
+Looking for a client that's not listed here? [Open an issue](https://github.com/pezzolabs/pezzo/issues/new/choose) and let us know!
+
+# Getting Started - Docker Compose
+
+If you simply want to run the full Pezzo stack locally, check out [Running With Docker Compose](http://docs.pezzo.ai/introduction/docker-compose) in the documentation.
+
+If you want to run Pezzo in development mode, continue reading.
### Prerequisites
@@ -88,22 +134,25 @@ Install NPM dependencies by running:
npm install
```
-### Spin up development dependencies via Docker Compose
+### Set up the environment files
-Pezzo relies on a Postgres database. You can spin it up using Docker Compose:
+Pezzo uses a .env file to store environment variables.
+When using docker, you should also create a .env.docker file.
-```
-docker-compose -f docker-compose.dev.yaml up
-```
+See the .env.example file for reference.
-### Start Pezzo
+### Spin up infrastructure dependencies via Docker Compose
-Generate the Prisma client:
+Pezzo is entirely cloud-native and relies solely on open-source technologies such as [PostgreSQL](https://www.postgresql.org/), [ClickHouse](https://github.com/ClickHouse/ClickHouse), [Redis](https://github.com/redis/redis) and [Supertokens](https://supertokens.com/).
+
+You can run these dependencies via Docker Compose:
```
-npx nx prisma:generate server
+docker-compose -f docker-compose.infra.yaml up
```
+### Start Pezzo
+
Deploy Prisma migrations:
```
@@ -116,29 +165,31 @@ Run the server:
npx nx serve server
```
-The server is now running. In the background, [graphql-codegen](https://www.npmjs.com/package/@graphql-codegen/cli) has generated GraphQL types based on the actual schema. These can be found at [libs/graphql/src/@generated](libs/graphql/src/@generated). This provides excellent type safety across the monorepo.
+The server is now running. You can verify that by navigating to http://localhost:3000/api/healthz.
-In development mode, you want to run `graphql-codegen` in watch mode, so whenever you make changes to the schema, types are generated automatically. In a separate Terminal tab, run:
+In development mode, you want to run `codegen` in watch mode, so whenever you make changes to the schema, types are generated automatically. After running the server, run the following in a _separate terminal Window_:
```
-npx nx graphql:codegen graphql --watch
+npm run graphql:codegen:watch
```
+This will connect [codegen](https://the-guild.dev/graphql/codegen/docs/getting-started) directly to the server and keep your GraphQL schema up-to-date as you make changes.
+
Finally, you are ready to run the Pezzo Console:
```
npx nx serve console
```
-That's it! Pezzo is now accessible at http://localhost:4200 đ
+That's it! The Pezzo Console is now accessible at http://localhost:4200 đ
# Contributing
We welcome contributions from the community! Please feel free to submit pull requests or create issues for bugs or feature suggestions.
-# Alpha Disclaimer
+If you want to contribute but not sure how, join our [Discord](https://pezzo.cc/discord) and we'll be happy to help you out!
-Pezzo is currently in early development stages. As we strive to provide a reliable and useful platform, you may encounter bugs, performance issues or other limitations. As a result, Pezzo cannot be held responsible for any errors, data loss, or other negative outcomes that may arise from usage during this stage.
+Please check out [CONTRIBUTING.md](CONTRIBUTING.md) before contributing.
# License
diff --git a/apps/console/.babelrc b/apps/console/.babelrc
index 8b8fbf654..88ee27b14 100644
--- a/apps/console/.babelrc
+++ b/apps/console/.babelrc
@@ -1,12 +1,11 @@
{
"presets": [
[
- "@nrwl/react/babel",
+ "@nx/react/babel",
{
- "runtime": "automatic",
- "importSource": "@emotion/react"
+ "runtime": "automatic"
}
]
],
- "plugins": ["@emotion/babel-plugin"]
+ "plugins": []
}
diff --git a/apps/console/.env b/apps/console/.env
deleted file mode 100644
index bf9e6281e..000000000
--- a/apps/console/.env
+++ /dev/null
@@ -1 +0,0 @@
-NX_BASE_API_URL=http://localhost:3000/graphql
\ No newline at end of file
diff --git a/apps/console/.env.example b/apps/console/.env.example
new file mode 100644
index 000000000..985026da7
--- /dev/null
+++ b/apps/console/.env.example
@@ -0,0 +1,4 @@
+NX_BASE_API_URL="http://localhost:3000"
+NX_SUPERTOKENS_API_DOMAIN="http://localhost:3000"
+NX_SUPERTOKENS_WEBSITE_DOMAIN="http://localhost:4200"
+NX_DEBUG_MODE="true"
\ No newline at end of file
diff --git a/apps/console/.eslintrc.json b/apps/console/.eslintrc.json
index 734ddacee..854638323 100644
--- a/apps/console/.eslintrc.json
+++ b/apps/console/.eslintrc.json
@@ -1,10 +1,17 @@
{
- "extends": ["plugin:@nrwl/nx/react", "../../.eslintrc.json"],
+ "extends": ["plugin:@nx/react", "../../.eslintrc.json"],
"ignorePatterns": ["!**/*"],
"overrides": [
{
"files": ["*.ts", "*.tsx", "*.js", "*.jsx"],
- "rules": {}
+ "rules": {
+ "@nx/enforce-module-boundaries": [
+ "error",
+ {
+ "allowCircularSelfDependency": true
+ }
+ ]
+ }
},
{
"files": ["*.ts", "*.tsx"],
diff --git a/apps/console/.storybook/main.ts b/apps/console/.storybook/main.ts
new file mode 100644
index 000000000..8dcaaf895
--- /dev/null
+++ b/apps/console/.storybook/main.ts
@@ -0,0 +1,20 @@
+import type { StorybookConfig } from "@storybook/react-webpack5";
+
+const config: StorybookConfig = {
+ stories: ["../src/**/*.stories.@(js|jsx|ts|tsx|mdx)"],
+ addons: [
+ "@storybook/addon-essentials",
+ "@storybook/addon-interactions",
+ "@nx/react/plugins/storybook",
+ ],
+ framework: {
+ name: "@storybook/react-webpack5",
+ options: {},
+ },
+};
+
+export default config;
+
+// To customize your webpack configuration you can use the webpackFinal field.
+// Check https://storybook.js.org/docs/react/builders/webpack#extending-storybooks-webpack-config
+// and https://nx.dev/packages/storybook/documents/custom-builder-configs
diff --git a/.env b/apps/console/.storybook/preview.ts
similarity index 100%
rename from .env
rename to apps/console/.storybook/preview.ts
diff --git a/apps/console/jest.config.ts b/apps/console/jest.config.ts
index fd8885a3e..188215d96 100644
--- a/apps/console/jest.config.ts
+++ b/apps/console/jest.config.ts
@@ -3,7 +3,7 @@ export default {
displayName: "console",
preset: "../../jest.preset.js",
transform: {
- "^(?!.*\\.(js|jsx|ts|tsx|css|json)$)": "@nrwl/react/plugins/jest",
+ "^(?!.*\\.(js|jsx|ts|tsx|css|json)$)": "@nx/react/plugins/jest",
"^.+\\.[tj]sx?$": [
"@swc/jest",
{ jsc: { transform: { react: { runtime: "automatic" } } } },
diff --git a/apps/console/postcss.config.js b/apps/console/postcss.config.js
index 8ac4e9263..a9649690d 100644
--- a/apps/console/postcss.config.js
+++ b/apps/console/postcss.config.js
@@ -1,4 +1,3 @@
-// apps/site/postcss.config.js
const { join } = require("path");
module.exports = {
diff --git a/apps/console/project.json b/apps/console/project.json
index d26421123..ec38f1e8b 100644
--- a/apps/console/project.json
+++ b/apps/console/project.json
@@ -3,20 +3,22 @@
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "apps/console/src",
"projectType": "application",
- "implicitDependencies": ["server"],
+ "implicitDependencies": ["pezzo"],
"targets": {
"build": {
- "dependsOn": ["^graphql:codegen:offline"],
- "executor": "@nrwl/webpack:webpack",
+ "dependsOn": ["^graphql:generate", "^prebuild"],
+ "executor": "@nx/webpack:webpack",
"outputs": ["{options.outputPath}"],
"defaultConfiguration": "production",
"options": {
- "compiler": "swc",
+ "compiler": "babel",
"outputPath": "dist/apps/console",
"index": "apps/console/src/index.html",
"baseHref": "/",
"main": "apps/console/src/main.tsx",
"tsConfig": "apps/console/tsconfig.app.json",
+ "sourceMap": true,
+ "postcssConfig": "apps/console/postcss.config.js",
"assets": [
"apps/console/src/favicon.ico",
"apps/console/src/assets",
@@ -33,14 +35,14 @@
],
"styles": [],
"scripts": [],
- "isolatedConfig": true,
- "webpackConfig": "apps/console/webpack.config.js"
+ "isolatedConfig": false,
+ "webpackConfig": "apps/console/webpack.config.js",
+ "buildLibsFromSource": true
},
"configurations": {
"development": {
"extractLicenses": false,
"optimization": false,
- "sourceMap": true,
"vendorChunk": true
},
"production": {
@@ -52,7 +54,6 @@
],
"optimization": true,
"outputHashing": "all",
- "sourceMap": false,
"namedChunks": false,
"extractLicenses": true,
"vendorChunk": false
@@ -60,8 +61,8 @@
}
},
"serve": {
- "dependsOn": ["^graphql:codegen"],
- "executor": "@nrwl/webpack:dev-server",
+ "dependsOn": ["^graphql:generate"],
+ "executor": "@nx/webpack:dev-server",
"defaultConfiguration": "development",
"options": {
"buildTarget": "console:build",
@@ -78,20 +79,20 @@
}
},
"lint": {
- "executor": "@nrwl/linter:eslint",
+ "executor": "@nx/linter:eslint",
"outputs": ["{options.outputFile}"],
"options": {
"lintFilePatterns": ["apps/console/**/*.{ts,tsx,js,jsx}"]
}
},
"serve-static": {
- "executor": "@nrwl/web:file-server",
+ "executor": "@nx/web:file-server",
"options": {
"buildTarget": "console:build"
}
},
"test": {
- "executor": "@nrwl/jest:jest",
+ "executor": "@nx/jest:jest",
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
"options": {
"jestConfig": "apps/console/jest.config.ts",
@@ -113,15 +114,39 @@
"local": {
"tags": ["ghcr.io/pezzolabs/pezzo/console"],
"push": false
- },
+ }
+ }
+ },
+ "storybook": {
+ "executor": "@nx/storybook:storybook",
+ "options": {
+ "port": 4400,
+ "configDir": "apps/console/.storybook"
+ },
+ "configurations": {
"ci": {
- "push": true,
- "metadata": {
- "images": ["ghcr.io/pezzolabs/pezzo/console"],
- "platforms": ["linux/amd64", "linux/arm64"]
- }
+ "quiet": true
+ }
+ }
+ },
+ "build-storybook": {
+ "executor": "@nx/storybook:build",
+ "outputs": ["{options.outputDir}"],
+ "options": {
+ "outputDir": "dist/storybook/console",
+ "configDir": "apps/console/.storybook"
+ },
+ "configurations": {
+ "ci": {
+ "quiet": true
}
}
+ },
+ "test-storybook": {
+ "executor": "nx:run-commands",
+ "options": {
+ "command": "test-storybook -c apps/console/.storybook --url=http://localhost:4400"
+ }
}
},
"tags": []
diff --git a/apps/console/src/app.tsx b/apps/console/src/app.tsx
new file mode 100644
index 000000000..4614cc0bf
--- /dev/null
+++ b/apps/console/src/app.tsx
@@ -0,0 +1,177 @@
+import "./styles.css";
+import { Route, Routes, Navigate, Outlet } from "react-router-dom";
+import { hotjar } from "react-hotjar";
+import { HOTJAR_SITE_ID, HOTJAR_VERSION } from "~/env";
+import { Toaster } from "@pezzo/ui";
+
+// Auth
+import { QueryClientProvider } from "@tanstack/react-query";
+import { SuperTokensWrapper } from "supertokens-auth-react";
+import { SessionAuth } from "supertokens-auth-react/recipe/session";
+import { initSuperTokens } from "./lib/auth/supertokens";
+
+// Pages
+import { EnvironmentsPage } from "./pages/environments/EnvironmentsPage";
+import { PromptPage } from "./pages/prompts/PromptPage";
+import { PromptsPage } from "./pages/prompts/PromptsPage";
+import { OnboardingPage } from "./pages/organizations/onboarding";
+import { LogoutPage } from "./pages/auth/LogoutPage";
+import { RequestsPage } from "./pages/requests/RequestsPage";
+import { DashboardPage } from "./pages/projects/overview/DashboardPage";
+import { LoginPage } from "./pages/auth/LoginPage";
+import { AuthCallbackPage } from "./pages/auth/AuthCallbackPage";
+import { queryClient } from "./lib/graphql";
+import { AuthProvider } from "./lib/providers/AuthProvider";
+import { OptionalIntercomProvider } from "./lib/providers/OptionalIntercomProvider";
+import { LayoutWrapper } from "./components/layout/LayoutWrapper";
+import { AcceptInvitationPage } from "./pages/invitations/AcceptInvitationPage";
+import { CurrentPromptProvider } from "./lib/providers/CurrentPromptContext";
+import { RequiredProviderApiKeyModalProvider } from "./lib/providers/RequiredProviderApiKeyModalProvider";
+import { OrgMembersPage } from "./pages/organizations/OrgMembersPage";
+import { OrgSettingsPage } from "./pages/organizations/OrgSettingsPage";
+import { OrgApiKeysPage } from "./pages/organizations/OrgApiKeysPage";
+import { PromptEditView } from "./features/editor/PromptEditView";
+import { EditorProvider } from "./lib/providers/EditorContext";
+import { PromptTesterProvider } from "./lib/providers/PromptTesterContext";
+import { PromptVersionsView } from "./components/prompts/views/PromptVersionsView";
+import { Suspense } from "react";
+import { FullScreenLoader } from "./components/common/FullScreenLoader";
+import { OrgPage } from "./pages/projects/OrgPage";
+import { useCurrentOrganization } from "./lib/hooks/useCurrentOrganization";
+import { WaitlistWrapper } from "~/pages/WaitlistWrapper";
+
+initSuperTokens();
+
+if (HOTJAR_SITE_ID && HOTJAR_VERSION) {
+ hotjar.initialize(Number(HOTJAR_SITE_ID), Number(HOTJAR_VERSION));
+}
+
+const RootHandler = () => {
+ const { organizationId, isLoading } = useCurrentOrganization();
+
+ if (isLoading) {
+ return ;
+ }
+
+ if (organizationId) {
+ return ;
+ }
+};
+
+export function App() {
+ return (
+
+
+
+
+ {/* Non-authorized routes */}
+
+ {/* We don't render the LayoutWrapper for non-authorized routes */}
+ }
+ />
+ } />
+ } />
+
+ {/* Authorized routes */}
+
+
+
+
+
+
+
+
+ }
+ >
+ } />
+
+
+
+
+ }
+ />
+
+
+
+
+
+
+ }
+ />
+
+ {/* Organizations */}
+
+ }>
+
+
+
+
+
+ }
+ >
+ } />
+ } />
+ } />
+ } />
+
+
+ {/* In-project routes */}
+ }>
+
+
+
+
+
+
+
+
+
+
+ }
+ >
+ } />
+ } />
+ } />
+ } />
+ } />
+ }>
+ } />
+
+
+
+
+
+ }
+ />
+ } />
+
+
+
+
+
+
+
+ );
+}
+
+export default App;
diff --git a/apps/console/src/app/app.tsx b/apps/console/src/app/app.tsx
deleted file mode 100644
index 04d5a343d..000000000
--- a/apps/console/src/app/app.tsx
+++ /dev/null
@@ -1,93 +0,0 @@
-import styled from "@emotion/styled";
-import { Navigate, Route, Routes } from "react-router-dom";
-import "antd/dist/reset.css";
-import "./styles.css";
-
-import { ThemeProvider } from "./lib/providers/ThemeProvider";
-import { QueryClientProvider } from "@tanstack/react-query";
-import { queryClient } from "./lib/graphql";
-import { SideNavigation } from "./components/layout/SideNavigation";
-import { Layout } from "antd";
-import { CurrentPromptProvider } from "./lib/providers/CurrentPromptContext";
-import { PromptTesterProvider } from "./lib/providers/PromptTesterContext";
-import { EnvironmentsPage } from "./pages/environments";
-import { PromptsPage } from "./pages/prompts";
-import { PromptPage } from "./pages/prompts/[promptId]";
-import { APIKeysPage } from "./pages/api-keys";
-
-const { Content } = Layout;
-
-const StyledContent = styled(Content)`
- padding: 22px;
- min-height: 200px;
- overflow-y: auto;
-
- scrollbar-width: none; /* Firefox */
- ::-webkit-scrollbar {
- display: none; /* Chrome, Safari, Opera */
- }
- ::-ms-scrollbar {
- display: none; /* IE */
- }
-`;
-
-export function App() {
- return (
- <>
-
-
-
-
-
-
-
-
-
-
- } />
- } />
- }
- />
- }
- />
- } />
-
-
-
-
-
-
-
-
-
- >
- //
- //
-
- // {/* START: routes */}
- // {/* These routes and navigation have been generated for you */}
- // {/* Feel free to move and update them to fit your needs */}
- //
- //
- //
- //
- //
- //
- // Home
- //
- //
- // Page 2
- //
- //
- //
-
- // {/* END: routes */}
- //
- );
-}
-
-export default App;
diff --git a/apps/console/src/app/components/api-keys/APIKeyListItem.tsx b/apps/console/src/app/components/api-keys/APIKeyListItem.tsx
deleted file mode 100644
index 9cb467c99..000000000
--- a/apps/console/src/app/components/api-keys/APIKeyListItem.tsx
+++ /dev/null
@@ -1,110 +0,0 @@
-import { Avatar, Card, Row, Col, Typography, Button, Input } from "antd";
-import styled from "@emotion/styled";
-import { CloseOutlined, EditOutlined, SaveOutlined } from "@ant-design/icons";
-import { useState } from "react";
-import { useMutation } from "@tanstack/react-query";
-import { UPDATE_PROVIDER_API_KEY } from "../../graphql/mutations/provider-api-keys";
-import { gqlClient, queryClient } from "../../lib/graphql";
-import { CreateProviderApiKeyInput } from "@pezzo/graphql";
-import { useEffect } from "react";
-
-const APIKeyContainer = styled.div`
- display: flex;
- align-items: center;
- width: 600px;
-`;
-
-interface Props {
- provider: string;
- value: string | null;
- iconBase64: string;
-}
-
-export const APIKeyListItem = ({ provider, value, iconBase64 }: Props) => {
- const updateKeyMutation = useMutation({
- mutationFn: (data: CreateProviderApiKeyInput) =>
- gqlClient.request(UPDATE_PROVIDER_API_KEY, {
- data: {
- provider: data.provider,
- value: data.value,
- },
- }),
- onSuccess: (data) => {
- queryClient.invalidateQueries({ queryKey: ["providerAPIKeys"] });
- },
- });
-
- const [isEditing, setIsEditing] = useState(false);
- const [editValue, setEditValue] = useState("");
-
- useEffect(() => {
- if (!isEditing) {
- setEditValue("");
- }
- }, [isEditing]);
-
- const handleEdit = () => {
- setIsEditing(true);
- };
-
- const handleSave = async () => {
- await updateKeyMutation.mutateAsync({ provider, value: editValue });
- setIsEditing(false);
- };
-
- return (
-
-
-
-
-
-
- {provider}
-
-
-
- {isEditing ? (
- setEditValue(e.target.value)}
- autoComplete="off"
- />
- ) : (
-
- {value || "No API key provided"}
-
- )}
-
-
- {isEditing ? (
- <>
- }
- />
- setIsEditing(false)}
- loading={updateKeyMutation.isLoading}
- icon={ }
- style={{ marginLeft: 10 }}
- />
- >
- ) : (
- }
- />
- )}
-
-
-
-
- );
-};
diff --git a/apps/console/src/app/components/common/InlineCodeSnippet.tsx b/apps/console/src/app/components/common/InlineCodeSnippet.tsx
deleted file mode 100644
index 9f102b4cc..000000000
--- a/apps/console/src/app/components/common/InlineCodeSnippet.tsx
+++ /dev/null
@@ -1,12 +0,0 @@
-import styled from "@emotion/styled";
-
-const StyledSpan = styled("span")`
- padding: 6px;
- background: rgba(255, 255, 255, 0.2);
- border-radius: 6px;
- font-family: "Roboto Mono";
-`;
-
-export const InlineCodeSnippet = ({ children }) => {
- return {children} ;
-};
diff --git a/apps/console/src/app/components/environments/CreateEnvironmentModal.tsx b/apps/console/src/app/components/environments/CreateEnvironmentModal.tsx
deleted file mode 100644
index e3d98abba..000000000
--- a/apps/console/src/app/components/environments/CreateEnvironmentModal.tsx
+++ /dev/null
@@ -1,101 +0,0 @@
-import { useMutation } from "@tanstack/react-query";
-import { Modal, Form, Input, Button, Alert } from "antd";
-import { gqlClient, queryClient } from "../../lib/graphql";
-import { css } from "@emotion/css";
-import { slugify } from "../../lib/utils/string-utils";
-import { useState } from "react";
-import { CREATE_ENVIRONMENT } from "../../graphql/mutations/environments";
-
-interface Props {
- open: boolean;
- onClose: () => void;
- onCreated: (id: string) => void;
-}
-
-type Inputs = {
- name: string;
- slug: string;
-};
-
-export const CreateEnvironmentModal = ({ open, onClose, onCreated }: Props) => {
- const [form] = Form.useForm();
- const [error, setError] = useState(null);
-
- const createEnvironmentMutation = useMutation({
- mutationFn: (data: Inputs) =>
- gqlClient.request(CREATE_ENVIRONMENT, {
- data: {
- slug: data.slug,
- name: data.name,
- },
- }),
- onSuccess: (data) => {
- onCreated(data.createEnvironment.name);
- queryClient.invalidateQueries({ queryKey: ["environments"] });
- },
- onError: async ({ response }) => {
- const error = await response.errors[0].message;
- setError(error);
- },
- });
-
- const handleFormFinish = async (values) => {
- setError(null);
- createEnvironmentMutation.mutate(values);
- form.resetFields();
- };
-
- const handleFormValuesChange = () => {
- const { name } = form.getFieldsValue();
- const slug = slugify(name);
- form.setFieldsValue({ slug });
- };
-
- return (
-
- {error && }
-
-
-
-
-
-
-
-
-
-
- Create
-
-
-
-
- );
-};
diff --git a/apps/console/src/app/components/layout/SideNavigation.tsx b/apps/console/src/app/components/layout/SideNavigation.tsx
deleted file mode 100644
index 380432ffd..000000000
--- a/apps/console/src/app/components/layout/SideNavigation.tsx
+++ /dev/null
@@ -1,67 +0,0 @@
-import { BoltIcon, ServerStackIcon, KeyIcon } from "@heroicons/react/24/solid";
-import { Layout, Menu } from "antd";
-import { css } from "@emotion/css";
-import { useState } from "react";
-import LogoSquare from "../../../assets/logo-square.svg";
-import { useNavigate, useLocation } from "react-router-dom";
-
-const { Sider } = Layout;
-
-const menuItems = [
- {
- key: "prompts",
- label: "Prompts",
- icon: ,
- },
- {
- key: "environments",
- label: "Environments",
- icon: ,
- },
- {
- key: "api-keys",
- label: "API Keys",
- icon: ,
- },
-];
-
-export const SideNavigation = () => {
- const location = useLocation();
- const navigate = useNavigate();
- const [isCollapsed] = useState(true);
-
- const handleMenuClick = (item) => {
- navigate(`/${item.key}`);
- };
-
- return (
-
-
-
-
-
-
- );
-};
diff --git a/apps/console/src/app/components/prompts/CommitPromptModal.tsx b/apps/console/src/app/components/prompts/CommitPromptModal.tsx
deleted file mode 100644
index f3e4fb63b..000000000
--- a/apps/console/src/app/components/prompts/CommitPromptModal.tsx
+++ /dev/null
@@ -1,102 +0,0 @@
-import { useMutation } from "@tanstack/react-query";
-import { Modal, Form, Input, Button, Alert, FormInstance } from "antd";
-import { gqlClient, queryClient } from "../../lib/graphql";
-import { css } from "@emotion/css";
-import { useState } from "react";
-import { useCurrentPrompt } from "../../lib/providers/CurrentPromptContext";
-import { CREATE_PROMPT_VERSION } from "../../graphql/mutations/prompts";
-import { PromptEditFormInputs } from "../../lib/hooks/usePromptEdit";
-import { CreatePromptVersionInput } from "@pezzo/graphql";
-
-interface Props {
- open: boolean;
- onClose: () => void;
- onCommitted: (id: string) => void;
- form: FormInstance;
-}
-
-type Inputs = {
- message: string;
-};
-
-export const CommitPromptModal = ({
- open,
- onClose,
- onCommitted,
- form: editPromptForm,
-}: Props) => {
- const { prompt } = useCurrentPrompt();
- const [form] = Form.useForm();
- const [error, setError] = useState(null);
-
- const createPromptVersionMutation = useMutation({
- mutationFn: (data: CreatePromptVersionInput) => {
- return gqlClient.request(CREATE_PROMPT_VERSION, {
- data: {
- message: data.message,
- content: data.content,
- settings: data.settings,
- promptId: data.promptId,
- },
- });
- },
- onSuccess: () => {
- form.resetFields();
- queryClient.invalidateQueries(["prompt", prompt.id]);
- onClose();
- },
- });
-
- const handleFormFinish = async (values) => {
- setError(null);
- const editPromptValues = editPromptForm.getFieldsValue(true);
-
- createPromptVersionMutation.mutate({
- message: form.getFieldValue("message"),
- content: editPromptValues.content,
- settings: editPromptValues.settings,
- promptId: prompt.id,
- });
-
- form.resetFields();
- };
-
- return (
-
- {error && }
-
-
-
-
-
-
- Commit
-
-
-
-
- );
-};
diff --git a/apps/console/src/app/components/prompts/ConsumePromptModal.tsx b/apps/console/src/app/components/prompts/ConsumePromptModal.tsx
deleted file mode 100644
index 6641c13cb..000000000
--- a/apps/console/src/app/components/prompts/ConsumePromptModal.tsx
+++ /dev/null
@@ -1,66 +0,0 @@
-import { Modal, Typography } from "antd";
-import { useCurrentPrompt } from "../../lib/providers/CurrentPromptContext";
-import { Highlight, themes } from "prism-react-renderer";
-import { getIntegration } from "@pezzo/integrations";
-
-interface Props {
- open: boolean;
- onClose: () => void;
- variables: Record;
-}
-
-export const ConsumePromptModal = ({ open, onClose, variables }: Props) => {
- const { prompt, integration } = useCurrentPrompt();
- const codeBlock = integration.consumeInstructionsFn(prompt.name, variables);
-
- return (
-
-
- Step 1: Install the Pezzo client and Pezzo integrations
-
-
- npm install @pezzo/client
-
- npm install @pezzo/integrations
-
-
-
- Step 2: Consume the client to run your prompt
-
-
- {({ className, style, tokens, getLineProps, getTokenProps }) => (
-
- {tokens.map((line, i) => (
-
- {line.map((token, key) => (
-
- ))}
-
- ))}
-
- )}
-
-
- );
-};
diff --git a/apps/console/src/app/components/prompts/CreatePromptModal.tsx b/apps/console/src/app/components/prompts/CreatePromptModal.tsx
deleted file mode 100644
index 18f80040c..000000000
--- a/apps/console/src/app/components/prompts/CreatePromptModal.tsx
+++ /dev/null
@@ -1,91 +0,0 @@
-import { useMutation } from "@tanstack/react-query";
-import { Modal, Form, Input, Button } from "antd";
-import { CREATE_PROMPT } from "../../graphql/mutations/prompts";
-import { gqlClient, queryClient } from "../../lib/graphql";
-import { css } from "@emotion/css";
-import { PromptIntegrationSelector } from "./PromptIntegrationSelector";
-import { integrations } from "@pezzo/integrations";
-
-const integrationsArray = Object.values(integrations);
-
-interface Props {
- open: boolean;
- onClose: () => void;
- onCreated: (id: string) => void;
-}
-
-type Inputs = {
- name: string;
- integrationId: string;
-};
-
-export const CreatePromptModal = ({ open, onClose, onCreated }: Props) => {
- const [form] = Form.useForm();
- const createPromptMutation = useMutation({
- mutationFn: (data: Inputs) =>
- gqlClient.request(CREATE_PROMPT, {
- data: {
- name: data.name,
- integrationId: data.integrationId,
- },
- }),
- onSuccess: (data) => {
- onCreated(data.createPrompt.id);
- queryClient.invalidateQueries({ queryKey: ["prompts"] });
- },
- });
-
- const handleFormFinish = (data: Inputs) => {
- createPromptMutation.mutate(data);
- };
-
- return (
-
-
- form.setFieldValue("integration", value)}
- />
-
-
-
-
-
-
-
-
- Create
-
-
-
-
- );
-};
diff --git a/apps/console/src/app/components/prompts/DeletePromptConfirmationModal.tsx b/apps/console/src/app/components/prompts/DeletePromptConfirmationModal.tsx
deleted file mode 100644
index c2ad4f542..000000000
--- a/apps/console/src/app/components/prompts/DeletePromptConfirmationModal.tsx
+++ /dev/null
@@ -1,33 +0,0 @@
-import { Button, Modal, Typography } from "antd";
-
-interface Props {
- open: boolean;
- onClose: () => void;
- onConfirm: () => void;
-}
-
-export const DeletePromptConfirmationModal = ({
- open,
- onClose,
- onConfirm,
-}: Props) => {
- return (
-
- Cancel
- ,
-
- Delete
- ,
- ]}
- >
-
- Are you sure you want to delete this prompt?
-
-
- );
-};
diff --git a/apps/console/src/app/components/prompts/PromptEditor.tsx b/apps/console/src/app/components/prompts/PromptEditor.tsx
deleted file mode 100644
index cec781fdd..000000000
--- a/apps/console/src/app/components/prompts/PromptEditor.tsx
+++ /dev/null
@@ -1,48 +0,0 @@
-import CodeMirror from "@uiw/react-codemirror";
-import { materialInit } from "@uiw/codemirror-theme-material";
-import { css } from "@emotion/css";
-import { colors } from "../../lib/theme/colors";
-import { Form } from "antd";
-
-interface Props {
- value?: string;
- onChange?: (value: string) => void;
-}
-
-export const PromptEditor = ({ value, onChange }: Props) => {
- return (
- <>
-
-
-
- >
- );
-};
diff --git a/apps/console/src/app/components/prompts/PromptIntegrationSelector.tsx b/apps/console/src/app/components/prompts/PromptIntegrationSelector.tsx
deleted file mode 100644
index e107155b1..000000000
--- a/apps/console/src/app/components/prompts/PromptIntegrationSelector.tsx
+++ /dev/null
@@ -1,44 +0,0 @@
-import { Select, Typography } from "antd";
-import { integrations } from "@pezzo/integrations";
-import styled from "@emotion/styled";
-
-const Icon = styled.img`
- border-radius: 2px;
- height: 100%;
-`;
-
-const SelectItem = styled.div`
- display: flex;
- align-items: center;
- height: 28px;
- position: relative;
- padding-top: 4px;
- padding-bottom: 4px;
-`;
-
-interface Props {
- onChange: (value: string) => void;
-}
-
-const integrationsArray = Object.values(integrations);
-
-export const PromptIntegrationSelector = ({ onChange }: Props) => {
- return (
- ({
- label: (
-
-
-
- {integration.name}
-
-
- ),
- value: integration.id,
- }))}
- />
- );
-};
diff --git a/apps/console/src/app/components/prompts/PromptListItem.tsx b/apps/console/src/app/components/prompts/PromptListItem.tsx
deleted file mode 100644
index 2f3a32986..000000000
--- a/apps/console/src/app/components/prompts/PromptListItem.tsx
+++ /dev/null
@@ -1,49 +0,0 @@
-import { ArrowRightCircleIcon } from "@heroicons/react/24/outline";
-import { Card, Col, Row, Typography } from "antd";
-import { css } from "@emotion/css";
-import { getIntegration } from "@pezzo/integrations";
-
-interface Props {
- name: string;
- integrationId: string;
- onClick: () => void;
-}
-
-export const PromptListItem = ({ name, integrationId, onClick }: Props) => {
- const integration = getIntegration(integrationId);
-
- return (
-
-
-
-
-
-
-
-
-
- {name}
-
-
-
-
-
-
-
- );
-};
diff --git a/apps/console/src/app/components/prompts/PromptSettings.tsx b/apps/console/src/app/components/prompts/PromptSettings.tsx
deleted file mode 100644
index 86cc5ed38..000000000
--- a/apps/console/src/app/components/prompts/PromptSettings.tsx
+++ /dev/null
@@ -1,39 +0,0 @@
-import { getIntegration } from "@pezzo/integrations";
-import { Form, Select } from "antd";
-import { PromptSettingsSlider } from "./PromptSettingsSlider";
-
-interface Props {
- integrationId: string;
-}
-
-export const PromptSettings = ({ integrationId }: Props) => {
- const settingsSchema = getIntegration(integrationId).settingsSchema;
-
- const commonStyle = {
- marginBottom: 8,
- };
-
- return (
- <>
- {settingsSchema.map((field, index) => (
-
- {field.type === "select" && (
-
- )}
- {field.type === "slider" && (
-
- )}
-
- ))}
- >
- );
-};
diff --git a/apps/console/src/app/components/prompts/PromptSettingsSlider.tsx b/apps/console/src/app/components/prompts/PromptSettingsSlider.tsx
deleted file mode 100644
index 78686685c..000000000
--- a/apps/console/src/app/components/prompts/PromptSettingsSlider.tsx
+++ /dev/null
@@ -1,43 +0,0 @@
-import { Slider, Row, Col, InputNumber } from "antd";
-
-interface Props {
- min: number;
- max: number;
- step: number;
- value?: number;
- onChange?: (value: number) => void;
-}
-
-export const PromptSettingsSlider = ({
- min,
- max,
- step,
- value,
- onChange,
-}: Props) => {
- return (
-
-
-
-
-
-
-
-
- );
-};
diff --git a/apps/console/src/app/components/prompts/PromptTester/PromptTester.tsx b/apps/console/src/app/components/prompts/PromptTester/PromptTester.tsx
deleted file mode 100644
index a4da1a858..000000000
--- a/apps/console/src/app/components/prompts/PromptTester/PromptTester.tsx
+++ /dev/null
@@ -1,173 +0,0 @@
-import { PromptExecutionStatus } from "@prisma/client";
-import {
- Button,
- Col,
- Descriptions,
- Drawer,
- Form,
- Row,
- Space,
- Tag,
- Typography,
-} from "antd";
-import styled from "@emotion/styled";
-import {
- CheckCircleOutlined,
- CloseCircleOutlined,
- RedoOutlined,
-} from "@ant-design/icons";
-import { PromptVariables } from "../PromptVariables";
-import { PromptEditor } from "../PromptEditor";
-import { useEffect, useState } from "react";
-import { usePromptTester } from "../../../lib/providers/PromptTesterContext";
-import { isJson } from "../../../lib/utils/is-json";
-
-const StyledPre = styled.pre`
- white-space: pre-wrap;
- background: #000;
- padding: 20px;
- border-radius: 6px;
-`;
-
-const { Item } = Descriptions;
-
-export const PromptTester = () => {
- const {
- isTesterOpen,
- closeTester,
- testResult: result,
- isTestInProgress,
- runTest,
- } = usePromptTester();
- const [variables, setVariables] = useState>({});
- const [form] = Form.useForm();
-
- useEffect(() => {
- if (result?.variables) {
- setVariables(result.variables);
- }
- }, [result]);
-
- const handleRerunTest = () => {
- const { content, settings } = form.getFieldsValue(true);
- runTest({
- content,
- settings,
- variables,
- });
- };
-
- return (
- closeTester()}
- title={
-
- Prompt Test Result
-
-
- }
- >
- Re-run Prompt
-
-
-
-
- }
- width="80%"
- >
- {result && (
-
- )}
-
- );
-};
diff --git a/apps/console/src/app/components/prompts/PromptVariable.tsx b/apps/console/src/app/components/prompts/PromptVariable.tsx
deleted file mode 100644
index 45d0ab47d..000000000
--- a/apps/console/src/app/components/prompts/PromptVariable.tsx
+++ /dev/null
@@ -1,60 +0,0 @@
-import { ExpandAltOutlined } from "@ant-design/icons";
-import { css } from "@emotion/css";
-import { Input, Popover } from "antd";
-import { useRef, useState } from "react";
-import { useOnClickOutside } from "usehooks-ts";
-
-interface Props {
- name: string;
- value: string;
- onChange: (value: string) => void;
-}
-
-export const PromptVariable = ({ name, value, onChange }: Props) => {
- const [isPopoverOpen, setIsPopoverOpen] = useState(false);
- const popoverRef = useRef();
-
- useOnClickOutside(popoverRef, (e) => {
- const target = e.target as HTMLElement;
- const closest = target.closest(".ant-popover");
- if (closest === null) {
- setIsPopoverOpen(false);
- }
- });
-
- const handleChange = (e) => {
- onChange(e.target.value);
- };
-
- return (
-
- }
- >
- setIsPopoverOpen(true)} />
- }
- />
-
-
- );
-};
diff --git a/apps/console/src/app/components/prompts/PromptVariables.tsx b/apps/console/src/app/components/prompts/PromptVariables.tsx
deleted file mode 100644
index 3a10aa4ef..000000000
--- a/apps/console/src/app/components/prompts/PromptVariables.tsx
+++ /dev/null
@@ -1,34 +0,0 @@
-import { Space, Typography } from "antd";
-import { PromptVariable } from "./PromptVariable";
-import { isJson } from "../../lib/utils/is-json";
-
-interface Props {
- variables: Record;
- onVariableChange: (name: string, value: string) => void;
-}
-
-export const PromptVariables = ({ variables, onVariableChange }: Props) => {
- return (
-
- {Object.keys(variables).length === 0 && (
-
No variables found.
- )}
-
-
- {Object.keys(variables).length > 0 &&
- Object.keys(variables).map((variableName) => (
- onVariableChange(variableName, value)}
- />
- ))}
-
-
- );
-};
diff --git a/apps/console/src/app/components/prompts/PromptVersionSelector.tsx b/apps/console/src/app/components/prompts/PromptVersionSelector.tsx
deleted file mode 100644
index cd7a733f6..000000000
--- a/apps/console/src/app/components/prompts/PromptVersionSelector.tsx
+++ /dev/null
@@ -1,54 +0,0 @@
-import { CaretDownOutlined } from "@ant-design/icons";
-import { useQuery } from "@tanstack/react-query";
-import { Button, Dropdown } from "antd";
-import { GET_PROMPT_VERSIONS_LIST } from "../../graphql/queries/prompts";
-import { gqlClient } from "../../lib/graphql";
-import { useCurrentPrompt } from "../../lib/providers/CurrentPromptContext";
-import { useState } from "react";
-
-export const PromptVersionSelector = () => {
- const { prompt, currentPromptVersion, setPromptVersion } = useCurrentPrompt();
- const latestVersion = prompt.latestVersion;
- const [isDropdownOpen, setIsDropdownOpen] = useState(false);
-
- const { data: promptVersionsList } = useQuery({
- queryKey: ["promptVersionsList", prompt.id],
- queryFn: () =>
- gqlClient.request(GET_PROMPT_VERSIONS_LIST, {
- data: { id: prompt.id },
- }),
- enabled: isDropdownOpen,
- });
-
- const itemsFromVersionsList =
- promptVersionsList?.promptVersions
- .filter((version) => version.sha !== latestVersion.sha)
- .map((version) => ({
- key: version.sha,
- label: version.sha.slice(0, 7),
- onClick: () => setPromptVersion(version.sha),
- })) || [];
-
- const menu = {
- items: [
- {
- key: "latest",
- label: `Latest (${latestVersion.sha.slice(0, 7)})`,
- onClick: () => setPromptVersion("latest"),
- },
- ...itemsFromVersionsList,
- ],
- };
-
- const isLatest = currentPromptVersion.sha === latestVersion.sha;
-
- const selectedVersionLabel = isLatest
- ? `Latest (${latestVersion.sha.slice(0, 7)})`
- : `${currentPromptVersion.sha.slice(0, 7)}`;
-
- return (
-
- }>{selectedVersionLabel}
-
- );
-};
diff --git a/apps/console/src/app/components/prompts/PublishPromptModal.tsx b/apps/console/src/app/components/prompts/PublishPromptModal.tsx
deleted file mode 100644
index c6da8c490..000000000
--- a/apps/console/src/app/components/prompts/PublishPromptModal.tsx
+++ /dev/null
@@ -1,122 +0,0 @@
-import { Alert, List, Modal, Radio, Typography } from "antd";
-import { useEnvironments } from "../../lib/hooks/useEnvironments";
-import { useCurrentPrompt } from "../../lib/providers/CurrentPromptContext";
-import { useEffect, useState } from "react";
-import { useMutation } from "@tanstack/react-query";
-import { gqlClient, queryClient } from "../../lib/graphql";
-import { PUBLISH_PROMPT } from "../../graphql/mutations/prompt-environments";
-import { PublishPromptInput } from "@pezzo/graphql";
-
-interface Props {
- open: boolean;
- onClose: () => void;
-}
-
-export const PublishPromptModal = ({ open, onClose }: Props) => {
- const { currentPromptVersion, prompt } = useCurrentPrompt();
- const { environments } = useEnvironments();
- const [selectedEnvironmentSlug, setSelectedEnvironmentSlug] =
- useState(undefined);
- const [selectedEnvironmentName, setSelectedEnvironmentName] =
- useState(undefined);
-
- const publishPromptMutation = useMutation({
- mutationFn: (data: PublishPromptInput) =>
- gqlClient.request(PUBLISH_PROMPT, {
- data: {
- promptId: data.promptId,
- environmentSlug: data.environmentSlug,
- promptVersionSha: data.promptVersionSha,
- },
- }),
- mutationKey: ["publishPrompt", prompt.id, currentPromptVersion.sha],
- onSuccess: () => {
- queryClient.invalidateQueries(["promptEnvironments"]);
- },
- });
-
- const handlePublish = async () => {
- publishPromptMutation.mutate({
- promptId: prompt.id,
- environmentSlug: selectedEnvironmentSlug,
- promptVersionSha: currentPromptVersion.sha,
- });
- };
-
- useEffect(() => {
- setSelectedEnvironmentSlug(undefined);
- setSelectedEnvironmentName(undefined);
- publishPromptMutation.reset();
- }, [open]);
-
- return (
- environments && (
-
- <>
- Select the environment to publish this version to.
- {publishPromptMutation.isSuccess && (
-
- )}
-
- {publishPromptMutation.isError && (
-
- )}
-
-
- (
- {
- setSelectedEnvironmentSlug(env.slug);
- setSelectedEnvironmentName(env.name);
- }}
- >
- {env.name}
-
-
- )}
- />
-
- >
-
- )
- );
-};
diff --git a/apps/console/src/app/components/prompts/views/PromptEditView.tsx b/apps/console/src/app/components/prompts/views/PromptEditView.tsx
deleted file mode 100644
index a9a24da27..000000000
--- a/apps/console/src/app/components/prompts/views/PromptEditView.tsx
+++ /dev/null
@@ -1,173 +0,0 @@
-import { Form, Space, Button, Row, Col, Card } from "antd";
-import { PromptEditor } from "../PromptEditor";
-import { PromptSettings } from "../PromptSettings";
-import {
- CodeOutlined,
- ExperimentOutlined,
- PlayCircleOutlined,
- SendOutlined,
-} from "@ant-design/icons";
-import { css } from "@emotion/css";
-import {
- getDraftPromptData,
- usePromptEdit,
-} from "../../../lib/hooks/usePromptEdit";
-import { useCurrentPrompt } from "../../../lib/providers/CurrentPromptContext";
-import { useEffect, useState } from "react";
-import { PromptTester } from "../PromptTester/PromptTester";
-import { PromptVariables } from "../PromptVariables";
-import { usePromptTester } from "../../../lib/providers/PromptTesterContext";
-import { PromptVersionSelector } from "../PromptVersionSelector";
-import { CommitPromptModal } from "../CommitPromptModal";
-import { PublishPromptModal } from "../PublishPromptModal";
-import { ConsumePromptModal } from "../ConsumePromptModal";
-
-export const PromptEditView = () => {
- const {
- form,
- handleFormValuesChange,
- isChangesToCommit,
- variables,
- setVariable,
- } = usePromptEdit();
- const { prompt, currentPromptVersion, isDraft } = useCurrentPrompt();
- const { openTester, runTest, isTestInProgress } = usePromptTester();
- const [isCommitModalOpen, setIsCommitModalOpen] = useState(false);
- const [isPublishModalOpen, setIsPublishModalOpen] = useState(false);
- const [isConsumePromptModalOpen, setIsConsumePromptModalOpen] =
- useState(false);
-
- useEffect(() => {
- form.resetFields();
- }, [prompt.id, currentPromptVersion]);
-
- const handleTest = async () => {
- await runTest({
- content: form.getFieldValue("content"),
- settings: form.getFieldValue("settings"),
- variables,
- });
- openTester();
- };
-
- const initialValues = {
- settings: isDraft
- ? getDraftPromptData(prompt.integrationId).settings
- : currentPromptVersion.settings,
- content: isDraft
- ? getDraftPromptData(prompt.integrationId).content
- : currentPromptVersion.content,
- };
-
- return (
- <>
- setIsCommitModalOpen(false)}
- onCommitted={() => {
- form.resetFields();
- setIsCommitModalOpen(false);
- }}
- />
- {currentPromptVersion && (
- setIsPublishModalOpen(false)}
- open={isPublishModalOpen}
- />
- )}
-
-
- setIsConsumePromptModalOpen(false)}
- variables={variables}
- />
-
-
- {!isDraft && }
-
-
- setIsConsumePromptModalOpen(true)}
- icon={ }
- >
- Consume
-
- {currentPromptVersion && (
- setIsPublishModalOpen(true)}
- icon={ }
- type="primary"
- >
- Publish
-
- )}
- setIsCommitModalOpen(true)}
- icon={ }
- >
- Commit
-
-
- }
- type="default"
- >
- Test
-
-
-
-
-
-
- >
- );
-};
diff --git a/apps/console/src/app/components/prompts/views/PromptHistoryView.tsx b/apps/console/src/app/components/prompts/views/PromptHistoryView.tsx
deleted file mode 100644
index 29e189fc9..000000000
--- a/apps/console/src/app/components/prompts/views/PromptHistoryView.tsx
+++ /dev/null
@@ -1,98 +0,0 @@
-import { usePromptExecutions } from "../../../lib/hooks/usePromptExecutions";
-import { useCurrentPrompt } from "../../../lib/providers/CurrentPromptContext";
-import { Button, Space, Table, Tag, Tooltip } from "antd";
-import { PromptExecutionStatus } from "@pezzo/graphql";
-import { CheckCircleOutlined, CloseCircleOutlined } from "@ant-design/icons";
-import { InlineCodeSnippet } from "../../common/InlineCodeSnippet";
-import { PromptTester } from "../PromptTester/PromptTester";
-import { usePromptTester } from "../../../lib/providers/PromptTesterContext";
-import { MagnifyingGlassIcon } from "@heroicons/react/24/outline";
-import { css } from "@emotion/css";
-
-export const PromptHistoryView = () => {
- const { prompt } = useCurrentPrompt();
- const { promptExecutions } = usePromptExecutions(prompt.id);
- const { openTester, loadTestResult } = usePromptTester();
-
- const handleInspect = async (promptExecution) => {
- loadTestResult(promptExecution);
- openTester();
- };
-
- if (!promptExecutions) {
- return null;
- }
-
- const columns = [
- {
- title: "Timestamp",
- dataIndex: "timestamp",
- key: "timestamp",
- render: (value) => {value} ,
- },
- {
- title: "Status",
- dataIndex: "status",
- key: "status",
- render: (value: PromptExecutionStatus) =>
- value === PromptExecutionStatus.Success ? (
- } color="success">
- Success
-
- ) : (
- } color="error">
- Error
-
- ),
- },
- {
- title: "Cost",
- dataIndex: "totalCost",
- key: "totalCost",
- render: (value) => ${value} ,
- },
- {
- title: "Duration",
- dataIndex: "duration",
- key: "duration",
- render: (value) => {Math.ceil(value / 1000)}s ,
- },
- {
- title: "Version",
- dataIndex: "promptVersionSha",
- key: "promptVersionSha",
- render: (value) => (
- {value.substring(0, 7)}
- ),
- },
- {
- render: (promptExecution) => (
-
-
-
- handleInspect(promptExecution)}
- icon={ }
- />
-
-
-
- ),
- },
- ];
-
- const data = promptExecutions.map((data) => data);
-
- return (
- <>
-
-
- >
- );
-};
diff --git a/apps/console/src/app/graphql/mutations/environments.tsx b/apps/console/src/app/graphql/mutations/environments.tsx
deleted file mode 100644
index aa098a8c3..000000000
--- a/apps/console/src/app/graphql/mutations/environments.tsx
+++ /dev/null
@@ -1,10 +0,0 @@
-import { graphql } from "@pezzo/graphql";
-
-export const CREATE_ENVIRONMENT = graphql(/* GraphQL */ `
- mutation CreateEnvironment($data: EnvironmentCreateInput!) {
- createEnvironment(data: $data) {
- slug
- name
- }
- }
-`);
diff --git a/apps/console/src/app/graphql/mutations/prompts.tsx b/apps/console/src/app/graphql/mutations/prompts.tsx
deleted file mode 100644
index c5cb93461..000000000
--- a/apps/console/src/app/graphql/mutations/prompts.tsx
+++ /dev/null
@@ -1,48 +0,0 @@
-import { graphql } from "@pezzo/graphql";
-
-export const CREATE_PROMPT = graphql(/* GraphQL */ `
- mutation createPrompt($data: CreatePromptInput!) {
- createPrompt(data: $data) {
- id
- }
- }
-`);
-
-export const CREATE_PROMPT_VERSION = graphql(/* GraphQL */ `
- mutation createPromptVersion($data: CreatePromptVersionInput!) {
- createPromptVersion(data: $data) {
- sha
- }
- }
-`);
-
-export const UPDATE_PROMPT = graphql(/* GraphQL */ `
- mutation updatePrompt($data: PromptUpdateInput!) {
- updatePrompt(data: $data) {
- id
- }
- }
-`);
-
-export const TEST_PROMPT = graphql(/* GraphQL */ `
- mutation testPrompt($data: TestPromptInput!) {
- testPrompt(data: $data) {
- id
- timestamp
- status
- settings
- result
- duration
- promptTokens
- completionTokens
- totalTokens
- promptCost
- completionCost
- totalCost
- error
- content
- interpolatedContent
- variables
- }
- }
-`);
diff --git a/apps/console/src/app/graphql/mutations/provider-api-keys.tsx b/apps/console/src/app/graphql/mutations/provider-api-keys.tsx
deleted file mode 100644
index 7b800663c..000000000
--- a/apps/console/src/app/graphql/mutations/provider-api-keys.tsx
+++ /dev/null
@@ -1,10 +0,0 @@
-import { graphql } from "@pezzo/graphql";
-
-export const UPDATE_PROVIDER_API_KEY = graphql(/* GraphQL */ `
- mutation UpdateProviderAPIKey($data: CreateProviderAPIKeyInput!) {
- updateProviderAPIKey(data: $data) {
- provider
- value
- }
- }
-`);
diff --git a/apps/console/src/app/graphql/queries/environments.tsx b/apps/console/src/app/graphql/queries/environments.tsx
deleted file mode 100644
index 9d0936bc1..000000000
--- a/apps/console/src/app/graphql/queries/environments.tsx
+++ /dev/null
@@ -1,19 +0,0 @@
-import { graphql } from "@pezzo/graphql";
-
-export const GET_ALL_ENVIRONMENTS = graphql(/* GraphQL */ `
- query Environments {
- environments {
- slug
- name
- }
- }
-`);
-
-export const GET_ENVIRONMENT = graphql(/* GraphQL */ `
- query Environment($data: EnvironmentWhereUniqueInput!) {
- environment(data: $data) {
- slug
- name
- }
- }
-`);
diff --git a/apps/console/src/app/graphql/queries/prompt-executions.tsx b/apps/console/src/app/graphql/queries/prompt-executions.tsx
deleted file mode 100644
index bdf5ad804..000000000
--- a/apps/console/src/app/graphql/queries/prompt-executions.tsx
+++ /dev/null
@@ -1,48 +0,0 @@
-import { graphql } from "@pezzo/graphql";
-
-export const GET_PROMPT_EXECUTIONS = graphql(/* GraphQL */ `
- query getPromptExecutions($data: PromptExecutionWhereInput!) {
- promptExecutions(data: $data) {
- id
- timestamp
- status
- settings
- result
- duration
- promptTokens
- completionTokens
- totalTokens
- promptCost
- completionCost
- totalCost
- error
- content
- interpolatedContent
- variables
- promptVersionSha
- }
- }
-`);
-
-export const GET_PROMPT_EXECUTION = graphql(/* GraphQL */ `
- query getPromptExecution($data: PromptExecutionWhereUniqueInput!) {
- promptExecution(data: $data) {
- id
- timestamp
- status
- promptCost
- completionCost
- totalCost
- promptTokens
- completionTokens
- totalTokens
- duration
- settings
- variables
- interpolatedContent
- error
- result
- content
- }
- }
-`);
diff --git a/apps/console/src/app/graphql/queries/prompts.tsx b/apps/console/src/app/graphql/queries/prompts.tsx
deleted file mode 100644
index c5e7e8881..000000000
--- a/apps/console/src/app/graphql/queries/prompts.tsx
+++ /dev/null
@@ -1,50 +0,0 @@
-import { graphql } from "@pezzo/graphql";
-
-export const GET_ALL_PROMPTS = graphql(/* GraphQL */ `
- query getAllPrompts {
- prompts {
- id
- name
- integrationId
- }
- }
-`);
-
-export const GET_PROMPT = graphql(/* GraphQL */ `
- query getPrompt($data: GetPromptInput!) {
- prompt(data: $data) {
- id
- integrationId
- name
- latestVersion {
- sha
- content
- settings
- }
- version(data: $data) {
- sha
- content
- settings
- }
- }
- }
-`);
-
-export const GET_PROMPT_VERSIONS_LIST = graphql(/* GraphQL */ `
- query promptVersions($data: PromptWhereUniqueInput!) {
- promptVersions(data: $data) {
- sha
- message
- }
- }
-`);
-
-export const GET_PROMPT_VERSION = graphql(/* GraphQL */ `
- query getPromptVersion($data: GetPromptVersionInput!) {
- promptVersion(data: $data) {
- sha
- content
- settings
- }
- }
-`);
diff --git a/apps/console/src/app/graphql/queries/provider-api-keys.tsx b/apps/console/src/app/graphql/queries/provider-api-keys.tsx
deleted file mode 100644
index 30bc674e8..000000000
--- a/apps/console/src/app/graphql/queries/provider-api-keys.tsx
+++ /dev/null
@@ -1,11 +0,0 @@
-import { graphql } from "@pezzo/graphql";
-
-export const GET_ALL_PROVIDER_API_KEYS = graphql(/* GraphQL */ `
- query ProviderAPIKeys {
- providerAPIKeys {
- id
- provider
- value
- }
- }
-`);
diff --git a/apps/console/src/app/lib/graphql.ts b/apps/console/src/app/lib/graphql.ts
deleted file mode 100644
index 34c28cb46..000000000
--- a/apps/console/src/app/lib/graphql.ts
+++ /dev/null
@@ -1,13 +0,0 @@
-import { QueryClient } from "@tanstack/react-query";
-import { GraphQLClient } from "graphql-request";
-import { BASE_API_URL } from "../../env";
-
-export const gqlClient = new GraphQLClient(BASE_API_URL, {});
-
-export const queryClient = new QueryClient({
- defaultOptions: {
- queries: {
- staleTime: 5 * 1000,
- },
- },
-});
diff --git a/apps/console/src/app/lib/hooks/useEnvironments.ts b/apps/console/src/app/lib/hooks/useEnvironments.ts
deleted file mode 100644
index fa5afdbfe..000000000
--- a/apps/console/src/app/lib/hooks/useEnvironments.ts
+++ /dev/null
@@ -1,14 +0,0 @@
-import { useQuery } from "@tanstack/react-query";
-import { gqlClient } from "../graphql";
-import { GET_ALL_ENVIRONMENTS } from "../../graphql/queries/environments";
-
-export const useEnvironments = () => {
- const { data } = useQuery({
- queryKey: ["environments"],
- queryFn: () => gqlClient.request(GET_ALL_ENVIRONMENTS),
- });
-
- return {
- environments: data?.environments,
- };
-};
diff --git a/apps/console/src/app/lib/hooks/usePromptEdit.ts b/apps/console/src/app/lib/hooks/usePromptEdit.ts
deleted file mode 100644
index 96edefa86..000000000
--- a/apps/console/src/app/lib/hooks/usePromptEdit.ts
+++ /dev/null
@@ -1,85 +0,0 @@
-import { OpenAIChatSettings } from "@pezzo/common";
-import { Form } from "antd";
-import { useEffect, useState } from "react";
-import { useCurrentPrompt } from "../providers/CurrentPromptContext";
-import { getIntegration } from "@pezzo/integrations";
-
-export type PromptEditFormInputs = {
- content: string;
- settings: OpenAIChatSettings;
-};
-
-function findVariables(text: string): Record {
- const regex = /\{\{([\w\s]+)\}\}/g;
- const matches = text.match(regex);
- const interpolatableValues = matches
- ? matches.map((match) => match.replace(/[{}]/g, ""))
- : [];
- const uniqueValues = Array.from(new Set(interpolatableValues));
- const interpolatableObject: Record = {};
- uniqueValues.forEach((value) => {
- interpolatableObject[value] = null;
- });
- return interpolatableObject;
-}
-
-export const getDraftPromptData = (integrationId: string) => {
- return {
- content: "Start typing your prompt here...",
- settings: getIntegration(integrationId).defaultSettings,
- };
-};
-
-export const usePromptEdit = () => {
- const { prompt, currentPromptVersion, isDraft } = useCurrentPrompt();
- const [form] = Form.useForm();
- const [isFormTouched, setIsFormTouched] = useState(false);
- const [variables, setVariables] = useState<{ [key: string]: string }>({});
- const versionInitialSnapshot = isDraft
- ? getDraftPromptData(prompt.integrationId)
- : currentPromptVersion;
- const [content, setContent] = useState(
- versionInitialSnapshot.content
- );
-
- const handleFormValuesChange = () => {
- const { content } = form.getFieldsValue(true);
- const touched = form.isFieldsTouched(["content", "settings"]);
- setIsFormTouched(touched);
- setContent(content);
-
- const variables = findVariables(content);
- setVariables(variables);
- };
-
- useEffect(() => {
- if (content) {
- const variables = findVariables(content);
- setVariables(variables);
- }
- }, [content]);
-
- const setVariable = (key: string, value: string) => {
- const newVariables = { ...variables };
- newVariables[key] = value;
- setVariables(newVariables);
- };
-
- const isPromptChanged = () => {
- const contentChanged = content !== versionInitialSnapshot.content;
- const settingsChanged =
- JSON.stringify(form.getFieldValue("settings")) !==
- JSON.stringify(versionInitialSnapshot.settings);
- return contentChanged || settingsChanged;
- };
-
- return {
- form,
- handleFormValuesChange,
- isSaveDisabled: !isFormTouched,
- isSaving: false,
- isChangesToCommit: isPromptChanged(),
- variables,
- setVariable,
- };
-};
diff --git a/apps/console/src/app/lib/hooks/usePromptExecutions.ts b/apps/console/src/app/lib/hooks/usePromptExecutions.ts
deleted file mode 100644
index dba46452e..000000000
--- a/apps/console/src/app/lib/hooks/usePromptExecutions.ts
+++ /dev/null
@@ -1,21 +0,0 @@
-import { useQuery } from "@tanstack/react-query";
-import { gqlClient } from "../graphql";
-import { GET_PROMPT_EXECUTIONS } from "../../graphql/queries/prompt-executions";
-
-export const usePromptExecutions = (promptId: string) => {
- const { data: promptExecutions, isLoading } = useQuery({
- queryKey: ["promptExecutions", promptId],
- queryFn: () =>
- gqlClient.request(GET_PROMPT_EXECUTIONS, {
- data: {
- promptId: { equals: promptId },
- },
- }),
- enabled: !!promptId,
- });
-
- return {
- isLoading,
- promptExecutions: promptExecutions?.promptExecutions,
- };
-};
diff --git a/apps/console/src/app/lib/providers/CurrentPromptContext.tsx b/apps/console/src/app/lib/providers/CurrentPromptContext.tsx
deleted file mode 100644
index 161d1d4c6..000000000
--- a/apps/console/src/app/lib/providers/CurrentPromptContext.tsx
+++ /dev/null
@@ -1,69 +0,0 @@
-import { createContext, useContext, useState } from "react";
-import { gqlClient } from "../graphql";
-import { GET_PROMPT } from "../../graphql/queries/prompts";
-import { GetPromptQuery, GetPromptVersionQuery } from "@pezzo/graphql";
-import { useQuery } from "@tanstack/react-query";
-import { IntegrationDefinition, getIntegration } from "@pezzo/integrations";
-
-interface CurrentPromptContextValue {
- isDraft: boolean;
- prompt: GetPromptQuery["prompt"];
- integration: IntegrationDefinition;
- currentPromptVersion: GetPromptVersionQuery["promptVersion"];
- setCurrentPromptId: (promptId: string, version: string) => void;
- isLoading: boolean;
- setPromptVersion: (version: string) => void;
-}
-
-const CurrentPromptContext = createContext({
- isDraft: undefined,
- prompt: undefined,
- integration: undefined,
- currentPromptVersion: undefined,
- setCurrentPromptId: () => void 0,
- isLoading: false,
- setPromptVersion: () => void 0,
-});
-
-export const useCurrentPrompt = () => {
- return useContext(CurrentPromptContext);
-};
-
-export const CurrentPromptProvider = ({ children }) => {
- const [promptId, setPromptId] = useState(undefined);
- const [promptVersion, setPromptVersion] = useState(
- undefined
- );
-
- const queryKey = ["prompt", promptId, promptVersion];
-
- const { data: currentPrompt, isLoading } = useQuery({
- queryKey,
- queryFn: () =>
- gqlClient.request(GET_PROMPT, {
- data: { promptId, version: promptVersion },
- }),
- enabled: !!promptId && !!promptVersion,
- });
-
- const value = {
- isDraft: currentPrompt?.prompt.version === null,
- prompt: currentPrompt?.prompt,
- currentPromptVersion: currentPrompt?.prompt?.version,
- integration:
- currentPrompt?.prompt &&
- getIntegration(currentPrompt.prompt.integrationId),
- setCurrentPromptId: (promptId: string, version: string) => {
- setPromptId(promptId);
- setPromptVersion(version);
- },
- setPromptVersion,
- isLoading,
- };
-
- return (
-
- {children}
-
- );
-};
diff --git a/apps/console/src/app/lib/providers/PromptTesterContext.tsx b/apps/console/src/app/lib/providers/PromptTesterContext.tsx
deleted file mode 100644
index 25d8f31e2..000000000
--- a/apps/console/src/app/lib/providers/PromptTesterContext.tsx
+++ /dev/null
@@ -1,83 +0,0 @@
-import { createContext, useContext, useState } from "react";
-import { OpenAIChatSettings } from "@pezzo/common";
-import { TEST_PROMPT } from "../../graphql/mutations/prompts";
-import { gqlClient } from "../graphql";
-import { GetPromptExecutionQuery, PromptExecution } from "@pezzo/graphql";
-import { TestPromptResult } from "@pezzo/client";
-import { useCurrentPrompt } from "./CurrentPromptContext";
-
-export interface PromptTestInput {
- content: string;
- settings: OpenAIChatSettings;
- variables: Record;
-}
-
-interface PromptTesterContextValue {
- openTester: () => void;
- closeTester: () => void;
- isTesterOpen: boolean;
- runTest: (input: PromptTestInput) => Promise;
- testResult: Partial;
- isTestInProgress: boolean;
- loadTestResult: (
- data: Partial
- ) => void;
-}
-
-const CurrentPromptContext = createContext({
- openTester: () => void 0,
- closeTester: () => void 0,
- isTesterOpen: false,
- runTest: () => void 0,
- testResult: undefined,
- isTestInProgress: false,
- loadTestResult: () => void 0,
-});
-
-export const usePromptTester = () => {
- return useContext(CurrentPromptContext);
-};
-
-export const PromptTesterProvider = ({ children }) => {
- const [isTesterOpen, setIsTesterOpen] = useState(false);
- const [testResult, setTestResult] =
- useState>(undefined);
- const [isTestInProgress, setIsTestInProgress] = useState(false);
- const { integration } = useCurrentPrompt();
-
- const value = {
- isTesterOpen,
- setIsTesterOpen,
- openTester: () => {
- setIsTesterOpen(true);
- },
- closeTester: () => {
- setTestResult(null);
- setIsTesterOpen(false);
- },
- runTest: async (input: PromptTestInput) => {
- setIsTestInProgress(true);
- const result = await gqlClient.request(TEST_PROMPT, {
- data: {
- integrationId: integration.id,
- content: input.content,
- settings: input.settings,
- variables: input.variables,
- },
- });
- setTestResult(result.testPrompt);
- setIsTestInProgress(false);
- },
- testResult,
- isTestInProgress: isTestInProgress,
- loadTestResult: (data: Partial) => {
- setTestResult(data);
- },
- };
-
- return (
-
- {children}
-
- );
-};
diff --git a/apps/console/src/app/lib/providers/ThemeProvider.tsx b/apps/console/src/app/lib/providers/ThemeProvider.tsx
deleted file mode 100644
index 72cafce20..000000000
--- a/apps/console/src/app/lib/providers/ThemeProvider.tsx
+++ /dev/null
@@ -1,10 +0,0 @@
-import { ConfigProvider } from "antd";
-import { antTheme } from "../../lib/theme/ant-theme";
-
-interface Props {
- children: React.ReactNode;
-}
-
-export const ThemeProvider = ({ children }: Props) => (
- {children}
-);
diff --git a/apps/console/src/app/lib/theme/ant-theme.ts b/apps/console/src/app/lib/theme/ant-theme.ts
deleted file mode 100644
index 738342bad..000000000
--- a/apps/console/src/app/lib/theme/ant-theme.ts
+++ /dev/null
@@ -1,10 +0,0 @@
-import { ThemeConfig, theme } from "antd";
-import { colors } from "./colors";
-
-export const antTheme: ThemeConfig = {
- algorithm: theme.darkAlgorithm,
- token: {
- colorPrimary: colors.indigo["400"],
- fontFamily: "Inter",
- },
-};
diff --git a/apps/console/src/app/pages/api-keys/index.tsx b/apps/console/src/app/pages/api-keys/index.tsx
deleted file mode 100644
index 43f958dd4..000000000
--- a/apps/console/src/app/pages/api-keys/index.tsx
+++ /dev/null
@@ -1,55 +0,0 @@
-import { Space, Typography } from "antd";
-import { integrations } from "@pezzo/integrations";
-import styled from "@emotion/styled";
-import { useQuery } from "@tanstack/react-query";
-import { gqlClient } from "../../lib/graphql";
-import { GET_ALL_PROVIDER_API_KEYS } from "../../graphql/queries/provider-api-keys";
-import { APIKeyListItem } from "../../components/api-keys/APIKeyListItem";
-
-export const APIKeysPage = () => {
- const providers = Object.values(integrations).map((integration) => ({
- name: integration.name,
- iconBase64: integration.iconBase64,
- provider: integration.provider,
- }));
-
- const { data } = useQuery({
- queryKey: ["providerAPIKeys"],
- queryFn: () => gqlClient.request(GET_ALL_PROVIDER_API_KEYS),
- });
-
- const renderAPIKey = (provider) => {
- const apiKey = data.providerAPIKeys.find(
- (key) => key.provider === provider.provider
- );
-
- return (
-
- );
- };
-
- if (!data) {
- return null;
- }
-
- return (
- <>
- Provider API Keys
-
-
- In order to be able to test your prompts within the Pezzo Console, you
- must provide an API key for each provider you wish to test. This is
- optional.
-
-
-
- {providers.map((item, index) => renderAPIKey(item))}
-
- >
- );
-};
diff --git a/apps/console/src/app/pages/environments/index.tsx b/apps/console/src/app/pages/environments/index.tsx
deleted file mode 100644
index 22be091d0..000000000
--- a/apps/console/src/app/pages/environments/index.tsx
+++ /dev/null
@@ -1,52 +0,0 @@
-import { PlusOutlined } from "@ant-design/icons";
-import { useQuery } from "@tanstack/react-query";
-import { Button, List, Typography } from "antd";
-import { InlineCodeSnippet } from "../../components/common/InlineCodeSnippet";
-import { CreateEnvironmentModal } from "../../components/environments/CreateEnvironmentModal";
-import { GET_ALL_ENVIRONMENTS } from "../../graphql/queries/environments";
-import { gqlClient } from "../../lib/graphql";
-import { useState } from "react";
-
-export const EnvironmentsPage = () => {
- const [isCreateEnvironmentModalOpen, setIsCreateEnvironmentModalOpen] =
- useState(false);
-
- const { data } = useQuery({
- queryKey: ["environments"],
- queryFn: () => gqlClient.request(GET_ALL_ENVIRONMENTS),
- });
-
- return (
- <>
- setIsCreateEnvironmentModalOpen(false)}
- onCreated={() => setIsCreateEnvironmentModalOpen(false)}
- />
- Environments
-
- }
- onClick={() => setIsCreateEnvironmentModalOpen(true)}
- >
- New Environment
-
-
-
- {data?.environments && (
- (
-
-
- {item.name} {item.slug}
-
-
- )}
- />
- )}
- >
- );
-};
diff --git a/apps/console/src/app/pages/prompts/[promptId]/edit.tsx b/apps/console/src/app/pages/prompts/[promptId]/edit.tsx
deleted file mode 100644
index b319dc68b..000000000
--- a/apps/console/src/app/pages/prompts/[promptId]/edit.tsx
+++ /dev/null
@@ -1,5 +0,0 @@
-const EditPromptPage = () => {
- return Edit
;
-};
-
-export default EditPromptPage;
diff --git a/apps/console/src/app/pages/prompts/[promptId]/index.tsx b/apps/console/src/app/pages/prompts/[promptId]/index.tsx
deleted file mode 100644
index 469c6e686..000000000
--- a/apps/console/src/app/pages/prompts/[promptId]/index.tsx
+++ /dev/null
@@ -1,123 +0,0 @@
-import { Breadcrumb, Button, Col, Row, Space, Tabs, Typography } from "antd";
-import {
- EditOutlined,
- HistoryOutlined,
- DeleteOutlined,
-} from "@ant-design/icons";
-import styled from "@emotion/styled";
-import { useEffect, useState } from "react";
-import { PromptHistoryView } from "../../../components/prompts/views/PromptHistoryView";
-import { PromptEditView } from "../../../components/prompts/views/PromptEditView";
-import { css } from "@emotion/css";
-import { DeletePromptConfirmationModal } from "../../../components/prompts/DeletePromptConfirmationModal";
-import { useCurrentPrompt } from "../../../lib/providers/CurrentPromptContext";
-import { useNavigate, useParams } from "react-router-dom";
-import { IntegrationDefinition } from "@pezzo/integrations";
-
-const TabLabel = styled.div`
- display: inline-block;
- padding-left: 10px;
- padding-right: 10px;
-`;
-
-const BreadcrumbTitle = styled.span`
- cursor: pointer;
-`;
-
-export const PromptPage = () => {
- const navigate = useNavigate();
- const params = useParams();
- const { setCurrentPromptId, prompt, integration } = useCurrentPrompt();
- const [activeView, setActiveView] = useState("edit");
- const [isDeleteConfirmationModalOpen, setIsDeleteConfirmationModalOpen] =
- useState(false);
-
- useEffect(() => {
- if (params.promptId) {
- setCurrentPromptId(params.promptId as string, "latest");
- }
- }, [params.promptId]);
- const tabs = [
- {
- label: (
-
- Edit
-
- ),
- key: "edit",
- },
- {
- label: (
-
- History
-
- ),
- key: "history",
- },
- ];
-
- return (
- prompt && (
- <>
- setIsDeleteConfirmationModalOpen(false)}
- onConfirm={() => setIsDeleteConfirmationModalOpen(false)}
- />
-
-
- Prompts,
- onClick: () => navigate("/prompts"),
- },
- {
- title: (
-
-
-
- {prompt.name}
-
-
- ),
- key: "prompt",
- },
- ]}
- />
-
-
-
- }
- onClick={() => setIsDeleteConfirmationModalOpen(true)}
- >
- Delete
-
-
-
-
- setActiveView(selectedView)}
- >
-
- {activeView === "history" && }
- {activeView === "edit" && }
- >
- )
- );
-};
diff --git a/apps/console/src/app/pages/prompts/index.tsx b/apps/console/src/app/pages/prompts/index.tsx
deleted file mode 100644
index bf261cf36..000000000
--- a/apps/console/src/app/pages/prompts/index.tsx
+++ /dev/null
@@ -1,62 +0,0 @@
-import { useQuery } from "@tanstack/react-query";
-import { PromptListItem } from "../../components/prompts/PromptListItem";
-import { GET_ALL_PROMPTS } from "../../graphql/queries/prompts";
-import { gqlClient } from "../../lib/graphql";
-import { PlusOutlined } from "@ant-design/icons";
-import { CreatePromptModal } from "../../components/prompts/CreatePromptModal";
-import { useState } from "react";
-import { css } from "@emotion/css";
-import { Button, Typography } from "antd";
-import { useNavigate } from "react-router-dom";
-
-export const PromptsPage = () => {
- const navigate = useNavigate();
- const [isCreatePromptModalOpen, setIsCreatePromptModalOpen] = useState(false);
- const { data, isLoading } = useQuery({
- queryKey: ["prompts"],
- queryFn: () => gqlClient.request(GET_ALL_PROMPTS),
- });
-
- return (
- <>
- setIsCreatePromptModalOpen(false)}
- onCreated={(id) => navigate(`/prompts/${id}`)}
- />
-
- Prompts
- {isLoading && Loading...
}
-
- {data && (
-
-
- }
- onClick={() => setIsCreatePromptModalOpen(true)}
- >
- New Prompt
-
-
- {data.prompts.map((prompt) => (
-
-
navigate(`/prompts/${prompt.id}`)}
- />
-
- ))}
-
- )}
- >
- );
-};
diff --git a/apps/console/src/app/styles.css b/apps/console/src/app/styles.css
deleted file mode 100644
index 396b3a8ef..000000000
--- a/apps/console/src/app/styles.css
+++ /dev/null
@@ -1,10 +0,0 @@
-@import url("https://fonts.googleapis.com/css2?family=Roboto+Mono:wght@200;300;400;500;600;700&display=swap");
-@import url("https://fonts.googleapis.com/css2?family=Inter:wght@200;300;400;500;600;700&display=swap");
-@tailwind utilities;
-
-html,
-body {
- font-family: "Inter", sans-serif;
- font-size: 16px;
- color: theme("colors.slate.200");
-}
diff --git a/apps/console/src/assets/blurry-blurb-2.svg b/apps/console/src/assets/blurry-blurb-2.svg
new file mode 100644
index 000000000..3ee504a4c
--- /dev/null
+++ b/apps/console/src/assets/blurry-blurb-2.svg
@@ -0,0 +1,20 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/console/src/assets/blurry-blurb.svg b/apps/console/src/assets/blurry-blurb.svg
new file mode 100644
index 000000000..3c5fe1061
--- /dev/null
+++ b/apps/console/src/assets/blurry-blurb.svg
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/console/src/assets/favicon/android-chrome-192x192.png b/apps/console/src/assets/favicon/android-chrome-192x192.png
new file mode 100644
index 000000000..bc6c63fad
Binary files /dev/null and b/apps/console/src/assets/favicon/android-chrome-192x192.png differ
diff --git a/apps/console/src/assets/favicon/android-chrome-384x384.png b/apps/console/src/assets/favicon/android-chrome-384x384.png
new file mode 100644
index 000000000..86477cb02
Binary files /dev/null and b/apps/console/src/assets/favicon/android-chrome-384x384.png differ
diff --git a/apps/console/src/assets/favicon/android-chrome-96x96.png b/apps/console/src/assets/favicon/android-chrome-96x96.png
deleted file mode 100644
index f41888515..000000000
Binary files a/apps/console/src/assets/favicon/android-chrome-96x96.png and /dev/null differ
diff --git a/apps/console/src/assets/favicon/apple-touch-icon.png b/apps/console/src/assets/favicon/apple-touch-icon.png
index 951d5345b..05d40e54e 100644
Binary files a/apps/console/src/assets/favicon/apple-touch-icon.png and b/apps/console/src/assets/favicon/apple-touch-icon.png differ
diff --git a/apps/console/src/assets/favicon/favicon-16x16.png b/apps/console/src/assets/favicon/favicon-16x16.png
index e84b9deca..cc27dfc98 100644
Binary files a/apps/console/src/assets/favicon/favicon-16x16.png and b/apps/console/src/assets/favicon/favicon-16x16.png differ
diff --git a/apps/console/src/assets/favicon/favicon-32x32.png b/apps/console/src/assets/favicon/favicon-32x32.png
index 1b8880f6a..31086f016 100644
Binary files a/apps/console/src/assets/favicon/favicon-32x32.png and b/apps/console/src/assets/favicon/favicon-32x32.png differ
diff --git a/apps/console/src/assets/favicon/favicon.ico b/apps/console/src/assets/favicon/favicon.ico
index 087ce999c..c3830248f 100644
Binary files a/apps/console/src/assets/favicon/favicon.ico and b/apps/console/src/assets/favicon/favicon.ico differ
diff --git a/apps/console/src/assets/favicon/mstile-150x150.png b/apps/console/src/assets/favicon/mstile-150x150.png
index 1bde0351c..c82c549d6 100644
Binary files a/apps/console/src/assets/favicon/mstile-150x150.png and b/apps/console/src/assets/favicon/mstile-150x150.png differ
diff --git a/apps/console/src/assets/favicon/safari-pinned-tab.svg b/apps/console/src/assets/favicon/safari-pinned-tab.svg
index 9f0487aad..ccc24de9f 100644
--- a/apps/console/src/assets/favicon/safari-pinned-tab.svg
+++ b/apps/console/src/assets/favicon/safari-pinned-tab.svg
@@ -2,16 +2,52 @@
Created by potrace 1.14, written by Peter Selinger 2001-2017
-
-
+
+
+
diff --git a/apps/console/src/assets/favicon/site.webmanifest b/apps/console/src/assets/favicon/site.webmanifest
index 64aeb5a29..161c642e0 100644
--- a/apps/console/src/assets/favicon/site.webmanifest
+++ b/apps/console/src/assets/favicon/site.webmanifest
@@ -3,8 +3,13 @@
"short_name": "",
"icons": [
{
- "src": "/android-chrome-96x96.png",
- "sizes": "96x96",
+ "src": "/android-chrome-192x192.png",
+ "sizes": "192x192",
+ "type": "image/png"
+ },
+ {
+ "src": "/android-chrome-384x384.png",
+ "sizes": "384x384",
"type": "image/png"
}
],
diff --git a/apps/console/src/assets/icons/google.svg b/apps/console/src/assets/icons/google.svg
new file mode 100644
index 000000000..088288fa3
--- /dev/null
+++ b/apps/console/src/assets/icons/google.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/apps/console/src/assets/logo-loader.json b/apps/console/src/assets/logo-loader.json
new file mode 100644
index 000000000..2924cf605
--- /dev/null
+++ b/apps/console/src/assets/logo-loader.json
@@ -0,0 +1,1122 @@
+{
+ "assets": [
+ {
+ "id": "emog8Q2RYtAYjLvWYonxL",
+ "layers": [
+ {
+ "ddd": 0,
+ "ind": 2,
+ "ty": 4,
+ "ln": "layer_2",
+ "sr": 1,
+ "ks": {
+ "a": { "a": 0, "k": [103.88, 138.005] },
+ "o": { "a": 0, "k": 100 },
+ "p": { "a": 0, "k": [900, 917.75] },
+ "r": { "a": 0, "k": 0 },
+ "s": { "a": 0, "k": [133.33, 133.33] },
+ "sk": { "a": 0, "k": 0 },
+ "sa": { "a": 0, "k": 0 }
+ },
+ "ao": 0,
+ "ip": 0,
+ "op": 60,
+ "st": 0,
+ "bm": 0,
+ "shapes": [
+ {
+ "ty": "gr",
+ "nm": "surface271",
+ "it": [
+ {
+ "ty": "gr",
+ "it": [
+ {
+ "ty": "sh",
+ "d": 1,
+ "ks": {
+ "a": 0,
+ "k": {
+ "c": true,
+ "i": [
+ [0, 0],
+ [0, 0],
+ [0, 0],
+ [0, 1.2],
+ [0, 0],
+ [0, 0],
+ [0, 0],
+ [0, 0],
+ [0, -2.58],
+ [0, 0],
+ [-0.04, -0.19],
+ [-1.08, -0.54],
+ [0, 0],
+ [0, 0],
+ [0, 0],
+ [-0.06, -0.03],
+ [0, 0],
+ [0, 2.24],
+ [0, 0],
+ [0, 0.16],
+ [0, 0],
+ [0, 0],
+ [-1.8, 0],
+ [-1.12, 0.38]
+ ],
+ "o": [
+ [0, 0],
+ [0, 0],
+ [1.11, -0.47],
+ [0, 0],
+ [0, 0],
+ [0, 0],
+ [0, 0],
+ [-2.31, -1.15],
+ [0, 0],
+ [0, 0.2],
+ [0.19, 1.17],
+ [0, 0],
+ [0, 0],
+ [0, 0],
+ [0.06, 0.03],
+ [0, 0],
+ [2, 1.01],
+ [0, 0],
+ [0.02, -0.15],
+ [0, 0],
+ [0, 0],
+ [0.5, 0.38],
+ [1.2, 0.3],
+ [0, 0]
+ ],
+ "v": [
+ [144.75, 231.75],
+ [202.32, 203.31],
+ [205.92, 201.78],
+ [207.75, 199.02],
+ [207.75, 35.25],
+ [140.63, 67.13],
+ [84.07, 39.83],
+ [5.02, 0.41],
+ [0, 3.52],
+ [0, 232.84],
+ [0.05, 233.43],
+ [2.06, 236.16],
+ [65.63, 268.23],
+ [65.63, 268.25],
+ [78.59, 274.78],
+ [78.78, 274.87],
+ [79.65, 275.3],
+ [84, 272.63],
+ [84, 271.9],
+ [84.03, 271.45],
+ [84.34, 205.9],
+ [136.13, 231.38],
+ [139.88, 232.5],
+ [144.75, 231.75]
+ ]
+ }
+ }
+ },
+ {
+ "ty": "sh",
+ "d": 1,
+ "ks": {
+ "a": 0,
+ "k": {
+ "c": true,
+ "i": [
+ [0, 0],
+ [0, 0],
+ [0, 0],
+ [0, 0]
+ ],
+ "o": [
+ [0, 0],
+ [0, 0],
+ [0, 0],
+ [0, 0]
+ ],
+ "v": [
+ [65.63, 50.82],
+ [18.07, 27.1],
+ [18.07, 223.91],
+ [65.63, 247.69]
+ ]
+ }
+ }
+ },
+ {
+ "ty": "sh",
+ "d": 1,
+ "ks": {
+ "a": 0,
+ "k": {
+ "c": true,
+ "i": [
+ [0, 0],
+ [0, 0],
+ [0, 0],
+ [0, 0]
+ ],
+ "o": [
+ [0, 0],
+ [0, 0],
+ [0, 0],
+ [0, 0]
+ ],
+ "v": [
+ [85.04, 60.18],
+ [84.43, 186.21],
+ [130.5, 208.78],
+ [130.5, 82.12]
+ ]
+ }
+ }
+ },
+ {
+ "ty": "sh",
+ "d": 1,
+ "ks": {
+ "a": 0,
+ "k": {
+ "c": true,
+ "i": [
+ [0, 0],
+ [0, 0],
+ [0, 0],
+ [0, 0]
+ ],
+ "o": [
+ [0, 0],
+ [0, 0],
+ [0, 0],
+ [0, 0]
+ ],
+ "v": [
+ [148.51, 83.26],
+ [148.86, 209.06],
+ [192.7, 187.98],
+ [192.41, 62.41]
+ ]
+ }
+ }
+ },
+ {
+ "ty": "fl",
+ "c": { "a": 0, "k": [0.44, 0.82, 0.6, 1] },
+ "r": 2,
+ "o": { "a": 0, "k": 100 }
+ },
+ {
+ "ty": "tr",
+ "nm": "Transform",
+ "a": { "a": 0, "k": [0, 0] },
+ "o": { "a": 0, "k": 100 },
+ "p": { "a": 0, "k": [0, 0] },
+ "r": { "a": 0, "k": 0 },
+ "s": { "a": 0, "k": [100, 100] },
+ "sk": { "a": 0, "k": 0 },
+ "sa": { "a": 0, "k": 0 }
+ }
+ ]
+ },
+ {
+ "ty": "tr",
+ "nm": "Transform",
+ "a": { "a": 0, "k": [0, 0] },
+ "o": { "a": 0, "k": 100 },
+ "p": { "a": 0, "k": [0, 0] },
+ "r": { "a": 0, "k": 0 },
+ "s": { "a": 0, "k": [100, 100] },
+ "sk": { "a": 0, "k": 0 },
+ "sa": { "a": 0, "k": 0 }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "ddd": 0,
+ "ind": 3,
+ "ty": 4,
+ "ln": "layer_3",
+ "sr": 1,
+ "ks": {
+ "a": { "a": 0, "k": [70.125, 37.125] },
+ "o": { "a": 0, "k": 100 },
+ "p": { "a": 0, "k": [945, 747.75] },
+ "r": { "a": 0, "k": 0 },
+ "s": { "a": 0, "k": [133.33, 133.33] },
+ "sk": { "a": 0, "k": 0 },
+ "sa": { "a": 0, "k": 0 }
+ },
+ "ao": 0,
+ "ip": 0,
+ "op": 60,
+ "st": 0,
+ "bm": 0,
+ "shapes": [
+ {
+ "ty": "gr",
+ "nm": "surface276",
+ "it": [
+ {
+ "ty": "gr",
+ "it": [
+ {
+ "ty": "gr",
+ "it": [
+ {
+ "ty": "sh",
+ "d": 1,
+ "ks": {
+ "a": 0,
+ "k": {
+ "c": true,
+ "i": [
+ [0, 0],
+ [0, 0],
+ [0, 0],
+ [0, 0],
+ [0, 0],
+ [-2.48, -1.13],
+ [0, 0]
+ ],
+ "o": [
+ [0, 0],
+ [0, 0],
+ [0, 0],
+ [0, 0],
+ [0, -2.73],
+ [0, 0],
+ [0, 0]
+ ],
+ "v": [
+ [116.25, 73.88],
+ [18.38, 28.73],
+ [18.38, 60.38],
+ [0, 51.38],
+ [0, 4.32],
+ [5.3, 0.91],
+ [140.25, 62.25]
+ ]
+ }
+ }
+ },
+ {
+ "ty": "fl",
+ "c": { "a": 0, "k": [0.31, 0.67, 0.47, 1] },
+ "r": 1,
+ "o": { "a": 0, "k": 100 }
+ },
+ {
+ "ty": "tr",
+ "nm": "Transform",
+ "a": { "a": 0, "k": [0, 0] },
+ "o": { "a": 0, "k": 100 },
+ "p": { "a": 0, "k": [0, 0] },
+ "r": { "a": 0, "k": 0 },
+ "s": { "a": 0, "k": [100, 100] },
+ "sk": { "a": 0, "k": 0 },
+ "sa": { "a": 0, "k": 0 }
+ }
+ ]
+ },
+ {
+ "ty": "tr",
+ "nm": "Transform",
+ "a": { "a": 0, "k": [0, 0] },
+ "o": { "a": 0, "k": 100 },
+ "p": { "a": 0, "k": [0, 0] },
+ "r": { "a": 0, "k": 0 },
+ "s": { "a": 0, "k": [100, 100] },
+ "sk": { "a": 0, "k": 0 },
+ "sa": { "a": 0, "k": 0 }
+ }
+ ]
+ },
+ {
+ "ty": "tr",
+ "nm": "Transform",
+ "a": { "a": 0, "k": [0, 0] },
+ "o": { "a": 0, "k": 100 },
+ "p": { "a": 0, "k": [0, 0] },
+ "r": { "a": 0, "k": 0 },
+ "s": { "a": 0, "k": [100, 100] },
+ "sk": { "a": 0, "k": 0 },
+ "sa": { "a": 0, "k": 0 }
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "ddd": 0,
+ "fr": 60,
+ "h": 450,
+ "ip": 0,
+ "layers": [
+ {
+ "ddd": 0,
+ "ind": 1,
+ "ty": 0,
+ "nm": "",
+ "ln": "precomp_5MmXcI2RP4xfzjgwDmcd71",
+ "sr": 1,
+ "ks": {
+ "a": { "a": 0, "k": [900, 900] },
+ "o": {
+ "a": 1,
+ "k": [
+ {
+ "t": 0,
+ "s": [50],
+ "i": { "x": 0, "y": 0 },
+ "o": { "x": 1, "y": 1 }
+ },
+ {
+ "t": 1,
+ "s": [51.67],
+ "i": { "x": 0, "y": 0 },
+ "o": { "x": 1, "y": 1 }
+ },
+ {
+ "t": 2,
+ "s": [53.34],
+ "i": { "x": 0, "y": 0 },
+ "o": { "x": 1, "y": 1 }
+ },
+ {
+ "t": 3,
+ "s": [55.01],
+ "i": { "x": 0, "y": 0 },
+ "o": { "x": 1, "y": 1 }
+ },
+ {
+ "t": 4,
+ "s": [56.68],
+ "i": { "x": 0, "y": 0 },
+ "o": { "x": 1, "y": 1 }
+ },
+ {
+ "t": 5,
+ "s": [58.35],
+ "i": { "x": 0, "y": 0 },
+ "o": { "x": 1, "y": 1 }
+ },
+ {
+ "t": 6,
+ "s": [60.02],
+ "i": { "x": 0, "y": 0 },
+ "o": { "x": 1, "y": 1 }
+ },
+ {
+ "t": 7,
+ "s": [61.69],
+ "i": { "x": 0, "y": 0 },
+ "o": { "x": 1, "y": 1 }
+ },
+ {
+ "t": 8,
+ "s": [63.36],
+ "i": { "x": 0, "y": 0 },
+ "o": { "x": 1, "y": 1 }
+ },
+ {
+ "t": 9,
+ "s": [65.03],
+ "i": { "x": 0, "y": 0 },
+ "o": { "x": 1, "y": 1 }
+ },
+ {
+ "t": 10,
+ "s": [66.7],
+ "i": { "x": 0, "y": 0 },
+ "o": { "x": 1, "y": 1 }
+ },
+ {
+ "t": 11,
+ "s": [68.37],
+ "i": { "x": 0, "y": 0 },
+ "o": { "x": 1, "y": 1 }
+ },
+ {
+ "t": 12,
+ "s": [70.04],
+ "i": { "x": 0, "y": 0 },
+ "o": { "x": 1, "y": 1 }
+ },
+ {
+ "t": 13,
+ "s": [71.71],
+ "i": { "x": 0, "y": 0 },
+ "o": { "x": 1, "y": 1 }
+ },
+ {
+ "t": 14,
+ "s": [73.38],
+ "i": { "x": 0, "y": 0 },
+ "o": { "x": 1, "y": 1 }
+ },
+ {
+ "t": 15,
+ "s": [75.05],
+ "i": { "x": 0, "y": 0 },
+ "o": { "x": 1, "y": 1 }
+ },
+ {
+ "t": 16,
+ "s": [76.72],
+ "i": { "x": 0, "y": 0 },
+ "o": { "x": 1, "y": 1 }
+ },
+ {
+ "t": 17,
+ "s": [78.39],
+ "i": { "x": 0, "y": 0 },
+ "o": { "x": 1, "y": 1 }
+ },
+ {
+ "t": 18,
+ "s": [80.06],
+ "i": { "x": 0, "y": 0 },
+ "o": { "x": 1, "y": 1 }
+ },
+ {
+ "t": 19,
+ "s": [81.73],
+ "i": { "x": 0, "y": 0 },
+ "o": { "x": 1, "y": 1 }
+ },
+ {
+ "t": 20,
+ "s": [83.4],
+ "i": { "x": 0, "y": 0 },
+ "o": { "x": 1, "y": 1 }
+ },
+ {
+ "t": 21,
+ "s": [85.07],
+ "i": { "x": 0, "y": 0 },
+ "o": { "x": 1, "y": 1 }
+ },
+ {
+ "t": 22,
+ "s": [86.74],
+ "i": { "x": 0, "y": 0 },
+ "o": { "x": 1, "y": 1 }
+ },
+ {
+ "t": 23,
+ "s": [88.41],
+ "i": { "x": 0, "y": 0 },
+ "o": { "x": 1, "y": 1 }
+ },
+ {
+ "t": 24,
+ "s": [90.08],
+ "i": { "x": 0, "y": 0 },
+ "o": { "x": 1, "y": 1 }
+ },
+ {
+ "t": 25,
+ "s": [91.75],
+ "i": { "x": 0, "y": 0 },
+ "o": { "x": 1, "y": 1 }
+ },
+ {
+ "t": 26,
+ "s": [93.42],
+ "i": { "x": 0, "y": 0 },
+ "o": { "x": 1, "y": 1 }
+ },
+ {
+ "t": 27,
+ "s": [95.09],
+ "i": { "x": 0, "y": 0 },
+ "o": { "x": 1, "y": 1 }
+ },
+ {
+ "t": 28,
+ "s": [96.76],
+ "i": { "x": 0, "y": 0 },
+ "o": { "x": 1, "y": 1 }
+ },
+ {
+ "t": 29,
+ "s": [98.43],
+ "i": { "x": 0, "y": 0 },
+ "o": { "x": 1, "y": 1 }
+ },
+ {
+ "t": 30,
+ "s": [99.9],
+ "i": { "x": 0, "y": 0 },
+ "o": { "x": 1, "y": 1 }
+ },
+ {
+ "t": 31,
+ "s": [98.23],
+ "i": { "x": 0, "y": 0 },
+ "o": { "x": 1, "y": 1 }
+ },
+ {
+ "t": 32,
+ "s": [96.56],
+ "i": { "x": 0, "y": 0 },
+ "o": { "x": 1, "y": 1 }
+ },
+ {
+ "t": 33,
+ "s": [94.89],
+ "i": { "x": 0, "y": 0 },
+ "o": { "x": 1, "y": 1 }
+ },
+ {
+ "t": 34,
+ "s": [93.22],
+ "i": { "x": 0, "y": 0 },
+ "o": { "x": 1, "y": 1 }
+ },
+ {
+ "t": 35,
+ "s": [91.55],
+ "i": { "x": 0, "y": 0 },
+ "o": { "x": 1, "y": 1 }
+ },
+ {
+ "t": 36,
+ "s": [89.88],
+ "i": { "x": 0, "y": 0 },
+ "o": { "x": 1, "y": 1 }
+ },
+ {
+ "t": 37,
+ "s": [88.21],
+ "i": { "x": 0, "y": 0 },
+ "o": { "x": 1, "y": 1 }
+ },
+ {
+ "t": 38,
+ "s": [86.54],
+ "i": { "x": 0, "y": 0 },
+ "o": { "x": 1, "y": 1 }
+ },
+ {
+ "t": 39,
+ "s": [84.87],
+ "i": { "x": 0, "y": 0 },
+ "o": { "x": 1, "y": 1 }
+ },
+ {
+ "t": 40,
+ "s": [83.2],
+ "i": { "x": 0, "y": 0 },
+ "o": { "x": 1, "y": 1 }
+ },
+ {
+ "t": 41,
+ "s": [81.53],
+ "i": { "x": 0, "y": 0 },
+ "o": { "x": 1, "y": 1 }
+ },
+ {
+ "t": 42,
+ "s": [79.86],
+ "i": { "x": 0, "y": 0 },
+ "o": { "x": 1, "y": 1 }
+ },
+ {
+ "t": 43,
+ "s": [78.19],
+ "i": { "x": 0, "y": 0 },
+ "o": { "x": 1, "y": 1 }
+ },
+ {
+ "t": 44,
+ "s": [76.52],
+ "i": { "x": 0, "y": 0 },
+ "o": { "x": 1, "y": 1 }
+ },
+ {
+ "t": 45,
+ "s": [74.85],
+ "i": { "x": 0, "y": 0 },
+ "o": { "x": 1, "y": 1 }
+ },
+ {
+ "t": 46,
+ "s": [73.18],
+ "i": { "x": 0, "y": 0 },
+ "o": { "x": 1, "y": 1 }
+ },
+ {
+ "t": 47,
+ "s": [71.51],
+ "i": { "x": 0, "y": 0 },
+ "o": { "x": 1, "y": 1 }
+ },
+ {
+ "t": 48,
+ "s": [69.84],
+ "i": { "x": 0, "y": 0 },
+ "o": { "x": 1, "y": 1 }
+ },
+ {
+ "t": 49,
+ "s": [68.17],
+ "i": { "x": 0, "y": 0 },
+ "o": { "x": 1, "y": 1 }
+ },
+ {
+ "t": 50,
+ "s": [66.5],
+ "i": { "x": 0, "y": 0 },
+ "o": { "x": 1, "y": 1 }
+ },
+ {
+ "t": 51,
+ "s": [64.83],
+ "i": { "x": 0, "y": 0 },
+ "o": { "x": 1, "y": 1 }
+ },
+ {
+ "t": 52,
+ "s": [63.16],
+ "i": { "x": 0, "y": 0 },
+ "o": { "x": 1, "y": 1 }
+ },
+ {
+ "t": 53,
+ "s": [61.49],
+ "i": { "x": 0, "y": 0 },
+ "o": { "x": 1, "y": 1 }
+ },
+ {
+ "t": 54,
+ "s": [59.82],
+ "i": { "x": 0, "y": 0 },
+ "o": { "x": 1, "y": 1 }
+ },
+ {
+ "t": 55,
+ "s": [58.15],
+ "i": { "x": 0, "y": 0 },
+ "o": { "x": 1, "y": 1 }
+ },
+ {
+ "t": 56,
+ "s": [56.48],
+ "i": { "x": 0, "y": 0 },
+ "o": { "x": 1, "y": 1 }
+ },
+ {
+ "t": 57,
+ "s": [54.81],
+ "i": { "x": 0, "y": 0 },
+ "o": { "x": 1, "y": 1 }
+ },
+ {
+ "t": 58,
+ "s": [53.14],
+ "i": { "x": 0, "y": 0 },
+ "o": { "x": 1, "y": 1 }
+ },
+ {
+ "t": 59,
+ "s": [51.47],
+ "i": { "x": 0, "y": 0 },
+ "o": { "x": 1, "y": 1 }
+ }
+ ]
+ },
+ "p": { "a": 0, "k": [225, 225] },
+ "r": { "a": 0, "k": 0 },
+ "s": {
+ "a": 1,
+ "k": [
+ {
+ "t": 0,
+ "s": [95, 95],
+ "i": { "x": 0, "y": 0 },
+ "o": { "x": 1, "y": 1 }
+ },
+ {
+ "t": 1,
+ "s": [95.1, 95.1],
+ "i": { "x": 0, "y": 0 },
+ "o": { "x": 1, "y": 1 }
+ },
+ {
+ "t": 2,
+ "s": [95.25, 95.25],
+ "i": { "x": 0, "y": 0 },
+ "o": { "x": 1, "y": 1 }
+ },
+ {
+ "t": 3,
+ "s": [95.48, 95.48],
+ "i": { "x": 0, "y": 0 },
+ "o": { "x": 1, "y": 1 }
+ },
+ {
+ "t": 4,
+ "s": [95.76, 95.76],
+ "i": { "x": 0, "y": 0 },
+ "o": { "x": 1, "y": 1 }
+ },
+ {
+ "t": 5,
+ "s": [96.11, 96.11],
+ "i": { "x": 0, "y": 0 },
+ "o": { "x": 1, "y": 1 }
+ },
+ {
+ "t": 6,
+ "s": [96.48, 96.48],
+ "i": { "x": 0, "y": 0 },
+ "o": { "x": 1, "y": 1 }
+ },
+ {
+ "t": 7,
+ "s": [96.86, 96.86],
+ "i": { "x": 0, "y": 0 },
+ "o": { "x": 1, "y": 1 }
+ },
+ {
+ "t": 8,
+ "s": [97.23, 97.23],
+ "i": { "x": 0, "y": 0 },
+ "o": { "x": 1, "y": 1 }
+ },
+ {
+ "t": 9,
+ "s": [97.57, 97.57],
+ "i": { "x": 0, "y": 0 },
+ "o": { "x": 1, "y": 1 }
+ },
+ {
+ "t": 10,
+ "s": [97.89, 97.89],
+ "i": { "x": 0, "y": 0 },
+ "o": { "x": 1, "y": 1 }
+ },
+ {
+ "t": 11,
+ "s": [98.17, 98.17],
+ "i": { "x": 0, "y": 0 },
+ "o": { "x": 1, "y": 1 }
+ },
+ {
+ "t": 12,
+ "s": [98.42, 98.42],
+ "i": { "x": 0, "y": 0 },
+ "o": { "x": 1, "y": 1 }
+ },
+ {
+ "t": 13,
+ "s": [98.64, 98.64],
+ "i": { "x": 0, "y": 0 },
+ "o": { "x": 1, "y": 1 }
+ },
+ {
+ "t": 14,
+ "s": [98.84, 98.84],
+ "i": { "x": 0, "y": 0 },
+ "o": { "x": 1, "y": 1 }
+ },
+ {
+ "t": 15,
+ "s": [99.02, 99.02],
+ "i": { "x": 0, "y": 0 },
+ "o": { "x": 1, "y": 1 }
+ },
+ {
+ "t": 16,
+ "s": [99.17, 99.17],
+ "i": { "x": 0, "y": 0 },
+ "o": { "x": 1, "y": 1 }
+ },
+ {
+ "t": 17,
+ "s": [99.31, 99.31],
+ "i": { "x": 0, "y": 0 },
+ "o": { "x": 1, "y": 1 }
+ },
+ {
+ "t": 18,
+ "s": [99.43, 99.43],
+ "i": { "x": 0, "y": 0 },
+ "o": { "x": 1, "y": 1 }
+ },
+ {
+ "t": 19,
+ "s": [99.54, 99.54],
+ "i": { "x": 0, "y": 0 },
+ "o": { "x": 1, "y": 1 }
+ },
+ {
+ "t": 20,
+ "s": [99.63, 99.63],
+ "i": { "x": 0, "y": 0 },
+ "o": { "x": 1, "y": 1 }
+ },
+ {
+ "t": 21,
+ "s": [99.71, 99.71],
+ "i": { "x": 0, "y": 0 },
+ "o": { "x": 1, "y": 1 }
+ },
+ {
+ "t": 22,
+ "s": [99.77, 99.77],
+ "i": { "x": 0, "y": 0 },
+ "o": { "x": 1, "y": 1 }
+ },
+ {
+ "t": 23,
+ "s": [99.83, 99.83],
+ "i": { "x": 0, "y": 0 },
+ "o": { "x": 1, "y": 1 }
+ },
+ {
+ "t": 24,
+ "s": [99.88, 99.88],
+ "i": { "x": 0, "y": 0 },
+ "o": { "x": 1, "y": 1 }
+ },
+ {
+ "t": 25,
+ "s": [99.92, 99.92],
+ "i": { "x": 0, "y": 0 },
+ "o": { "x": 1, "y": 1 }
+ },
+ {
+ "t": 26,
+ "s": [99.95, 99.95],
+ "i": { "x": 0, "y": 0 },
+ "o": { "x": 1, "y": 1 }
+ },
+ {
+ "t": 27,
+ "s": [99.97, 99.97],
+ "i": { "x": 0, "y": 0 },
+ "o": { "x": 1, "y": 1 }
+ },
+ {
+ "t": 28,
+ "s": [99.99, 99.99],
+ "i": { "x": 0, "y": 0 },
+ "o": { "x": 1, "y": 1 }
+ },
+ {
+ "t": 29,
+ "s": [100, 100],
+ "i": { "x": 0, "y": 0 },
+ "o": { "x": 1, "y": 1 }
+ },
+ {
+ "t": 30,
+ "s": [100, 100],
+ "i": { "x": 0, "y": 0 },
+ "o": { "x": 1, "y": 1 }
+ },
+ {
+ "t": 31,
+ "s": [99.9, 99.9],
+ "i": { "x": 0, "y": 0 },
+ "o": { "x": 1, "y": 1 }
+ },
+ {
+ "t": 32,
+ "s": [99.74, 99.74],
+ "i": { "x": 0, "y": 0 },
+ "o": { "x": 1, "y": 1 }
+ },
+ {
+ "t": 33,
+ "s": [99.51, 99.51],
+ "i": { "x": 0, "y": 0 },
+ "o": { "x": 1, "y": 1 }
+ },
+ {
+ "t": 34,
+ "s": [99.22, 99.22],
+ "i": { "x": 0, "y": 0 },
+ "o": { "x": 1, "y": 1 }
+ },
+ {
+ "t": 35,
+ "s": [98.87, 98.87],
+ "i": { "x": 0, "y": 0 },
+ "o": { "x": 1, "y": 1 }
+ },
+ {
+ "t": 36,
+ "s": [98.5, 98.5],
+ "i": { "x": 0, "y": 0 },
+ "o": { "x": 1, "y": 1 }
+ },
+ {
+ "t": 37,
+ "s": [98.12, 98.12],
+ "i": { "x": 0, "y": 0 },
+ "o": { "x": 1, "y": 1 }
+ },
+ {
+ "t": 38,
+ "s": [97.75, 97.75],
+ "i": { "x": 0, "y": 0 },
+ "o": { "x": 1, "y": 1 }
+ },
+ {
+ "t": 39,
+ "s": [97.41, 97.41],
+ "i": { "x": 0, "y": 0 },
+ "o": { "x": 1, "y": 1 }
+ },
+ {
+ "t": 40,
+ "s": [97.1, 97.1],
+ "i": { "x": 0, "y": 0 },
+ "o": { "x": 1, "y": 1 }
+ },
+ {
+ "t": 41,
+ "s": [96.82, 96.82],
+ "i": { "x": 0, "y": 0 },
+ "o": { "x": 1, "y": 1 }
+ },
+ {
+ "t": 42,
+ "s": [96.57, 96.57],
+ "i": { "x": 0, "y": 0 },
+ "o": { "x": 1, "y": 1 }
+ },
+ {
+ "t": 43,
+ "s": [96.35, 96.35],
+ "i": { "x": 0, "y": 0 },
+ "o": { "x": 1, "y": 1 }
+ },
+ {
+ "t": 44,
+ "s": [96.15, 96.15],
+ "i": { "x": 0, "y": 0 },
+ "o": { "x": 1, "y": 1 }
+ },
+ {
+ "t": 45,
+ "s": [95.97, 95.97],
+ "i": { "x": 0, "y": 0 },
+ "o": { "x": 1, "y": 1 }
+ },
+ {
+ "t": 46,
+ "s": [95.82, 95.82],
+ "i": { "x": 0, "y": 0 },
+ "o": { "x": 1, "y": 1 }
+ },
+ {
+ "t": 47,
+ "s": [95.68, 95.68],
+ "i": { "x": 0, "y": 0 },
+ "o": { "x": 1, "y": 1 }
+ },
+ {
+ "t": 48,
+ "s": [95.56, 95.56],
+ "i": { "x": 0, "y": 0 },
+ "o": { "x": 1, "y": 1 }
+ },
+ {
+ "t": 49,
+ "s": [95.46, 95.46],
+ "i": { "x": 0, "y": 0 },
+ "o": { "x": 1, "y": 1 }
+ },
+ {
+ "t": 50,
+ "s": [95.37, 95.37],
+ "i": { "x": 0, "y": 0 },
+ "o": { "x": 1, "y": 1 }
+ },
+ {
+ "t": 51,
+ "s": [95.29, 95.29],
+ "i": { "x": 0, "y": 0 },
+ "o": { "x": 1, "y": 1 }
+ },
+ {
+ "t": 52,
+ "s": [95.22, 95.22],
+ "i": { "x": 0, "y": 0 },
+ "o": { "x": 1, "y": 1 }
+ },
+ {
+ "t": 53,
+ "s": [95.16, 95.16],
+ "i": { "x": 0, "y": 0 },
+ "o": { "x": 1, "y": 1 }
+ },
+ {
+ "t": 54,
+ "s": [95.12, 95.12],
+ "i": { "x": 0, "y": 0 },
+ "o": { "x": 1, "y": 1 }
+ },
+ {
+ "t": 55,
+ "s": [95.08, 95.08],
+ "i": { "x": 0, "y": 0 },
+ "o": { "x": 1, "y": 1 }
+ },
+ {
+ "t": 56,
+ "s": [95.05, 95.05],
+ "i": { "x": 0, "y": 0 },
+ "o": { "x": 1, "y": 1 }
+ },
+ {
+ "t": 57,
+ "s": [95.03, 95.03],
+ "i": { "x": 0, "y": 0 },
+ "o": { "x": 1, "y": 1 }
+ },
+ {
+ "t": 58,
+ "s": [95.01, 95.01],
+ "i": { "x": 0, "y": 0 },
+ "o": { "x": 1, "y": 1 }
+ },
+ {
+ "t": 59,
+ "s": [95, 95],
+ "i": { "x": 0, "y": 0 },
+ "o": { "x": 1, "y": 1 }
+ }
+ ]
+ },
+ "sk": { "a": 0, "k": 0 },
+ "sa": { "a": 0, "k": 0 }
+ },
+ "ao": 0,
+ "w": 1800,
+ "h": 1800,
+ "ip": 0,
+ "op": 60,
+ "st": 0,
+ "bm": 0,
+ "refId": "emog8Q2RYtAYjLvWYonxL"
+ }
+ ],
+ "meta": { "g": "https://jitter.video" },
+ "nm": "New-file-[copy]",
+ "op": 60,
+ "v": "5.7.4",
+ "w": 450
+}
diff --git a/apps/console/src/assets/logo-square.svg b/apps/console/src/assets/logo-square.svg
index 187e62ab6..000c79c0c 100644
--- a/apps/console/src/assets/logo-square.svg
+++ b/apps/console/src/assets/logo-square.svg
@@ -1,4 +1,21 @@
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/console/src/assets/logo-vertical.svg b/apps/console/src/assets/logo-vertical.svg
new file mode 100644
index 000000000..93bab1759
--- /dev/null
+++ b/apps/console/src/assets/logo-vertical.svg
@@ -0,0 +1,27 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/console/src/assets/logo.svg b/apps/console/src/assets/logo.svg
index ff61e8351..9f02c5b00 100644
--- a/apps/console/src/assets/logo.svg
+++ b/apps/console/src/assets/logo.svg
@@ -1,9 +1,22 @@
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/console/src/assets/openai-logo-white.svg b/apps/console/src/assets/openai-logo-white.svg
deleted file mode 100644
index d264c1100..000000000
--- a/apps/console/src/assets/openai-logo-white.svg
+++ /dev/null
@@ -1,10 +0,0 @@
-
-
-
-
-
-
-
-
-
-
diff --git a/apps/console/src/assets/providers/anthropic-logo.png b/apps/console/src/assets/providers/anthropic-logo.png
new file mode 100644
index 000000000..60dea32b7
Binary files /dev/null and b/apps/console/src/assets/providers/anthropic-logo.png differ
diff --git a/apps/console/src/assets/providers/azure-logo.png b/apps/console/src/assets/providers/azure-logo.png
new file mode 100644
index 000000000..66ebdbbdf
Binary files /dev/null and b/apps/console/src/assets/providers/azure-logo.png differ
diff --git a/apps/console/src/assets/providers/meta-logo.png b/apps/console/src/assets/providers/meta-logo.png
new file mode 100644
index 000000000..90bfc0e1a
Binary files /dev/null and b/apps/console/src/assets/providers/meta-logo.png differ
diff --git a/apps/console/src/assets/providers/mistral-logo.png b/apps/console/src/assets/providers/mistral-logo.png
new file mode 100644
index 000000000..ff9cbaaae
Binary files /dev/null and b/apps/console/src/assets/providers/mistral-logo.png differ
diff --git a/apps/console/src/assets/providers/openai-logo.png b/apps/console/src/assets/providers/openai-logo.png
new file mode 100644
index 000000000..c608558c6
Binary files /dev/null and b/apps/console/src/assets/providers/openai-logo.png differ
diff --git a/apps/console/src/assets/typescript-logo.svg b/apps/console/src/assets/typescript-logo.svg
new file mode 100644
index 000000000..a46d53d49
--- /dev/null
+++ b/apps/console/src/assets/typescript-logo.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/apps/console/src/components/api-keys/PezzoApiKeyListItem.tsx b/apps/console/src/components/api-keys/PezzoApiKeyListItem.tsx
new file mode 100644
index 000000000..01e9fe777
--- /dev/null
+++ b/apps/console/src/components/api-keys/PezzoApiKeyListItem.tsx
@@ -0,0 +1,28 @@
+import { useCopyToClipboard } from "usehooks-ts";
+import { trackEvent } from "~/lib/utils/analytics";
+import { Button, Card } from "@pezzo/ui";
+import { CopyIcon } from "lucide-react";
+
+interface Props {
+ value: string;
+}
+
+export const PezzoApiKeyListItem = ({ value }: Props) => {
+ const [copied, copy] = useCopyToClipboard();
+
+ const onCopy = () => {
+ copy(value);
+ trackEvent("organization_api_key_copied");
+ };
+
+ return (
+
+
+
{value}
+
+ {copied ? "Copied!" : }
+
+
+
+ );
+};
diff --git a/apps/console/src/components/api-keys/ProviderApiKeyListItem.tsx b/apps/console/src/components/api-keys/ProviderApiKeyListItem.tsx
new file mode 100644
index 000000000..18d3739a1
--- /dev/null
+++ b/apps/console/src/components/api-keys/ProviderApiKeyListItem.tsx
@@ -0,0 +1,199 @@
+import { useState } from "react";
+import { useMutation } from "@tanstack/react-query";
+import { UPDATE_PROVIDER_API_KEY } from "~/graphql/definitions/mutations/api-keys";
+import { gqlClient, queryClient } from "~/lib/graphql";
+import { CreateProviderApiKeyInput } from "~/@generated/graphql/graphql";
+import { useEffect } from "react";
+import { useCurrentOrganization } from "~/lib/hooks/useCurrentOrganization";
+import { trackEvent } from "~/lib/utils/analytics";
+import { providersList } from "./providers-list";
+import { useDeleteProviderApiKeyMutation } from "~/graphql/hooks/mutations";
+import {
+ Card,
+ Button,
+ Form,
+ FormField,
+ FormItem,
+ FormControl,
+ Input,
+ useToast,
+} from "@pezzo/ui";
+import { PencilIcon, SaveIcon, TrashIcon, XIcon } from "lucide-react";
+import { GenericDestructiveConfirmationModal } from "../common/GenericDestructiveConfirmationModal";
+import z from "zod";
+import { zodResolver } from "@hookform/resolvers/zod";
+import { useForm } from "react-hook-form";
+
+interface Props {
+ provider: string;
+ value: string | null;
+ onSave?: () => void;
+ initialIsEditing?: boolean;
+ canCancelEdit?: boolean;
+}
+
+const formSchema = z.strictObject({
+ apiKey: z.string().min(1, "API Key must be at least 1 character long"),
+});
+
+export const ProviderApiKeyListItem = ({
+ provider,
+ value,
+ onSave,
+ initialIsEditing = false,
+ canCancelEdit = true,
+}: Props) => {
+ const { toast } = useToast();
+ const form = useForm>({
+ resolver: zodResolver(formSchema),
+ defaultValues: {
+ apiKey: value,
+ },
+ });
+
+ const { currentOrgId } = useCurrentOrganization();
+ const { mutate: deleteProviderApiKey } = useDeleteProviderApiKeyMutation();
+ const [deletingProviderApiKey, setDeletingProviderApiKey] =
+ useState(null);
+ const updateKeyMutation = useMutation({
+ mutationFn: (data: CreateProviderApiKeyInput) =>
+ gqlClient.request(UPDATE_PROVIDER_API_KEY, {
+ data: {
+ provider: data.provider,
+ value: data.value,
+ organizationId: data.organizationId,
+ },
+ }),
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ["providerApiKeys"] });
+ onSave && onSave();
+ setIsEditing(false);
+ toast({
+ title: "API key saved!",
+ description: `The provider API key has been saved successfully.`,
+ });
+ form.reset();
+ },
+ });
+
+ const handleDeleteProvider = async (provider: string) => {
+ deleteProviderApiKey(
+ { provider, organizationId: currentOrgId },
+ {
+ onSuccess: () => {
+ trackEvent("provider_api_key_deleted", { provider });
+ setDeletingProviderApiKey(null);
+ toast({
+ title: "API key deleted!",
+ description: `The provider API key has been deleted successfully.`,
+ });
+ form.reset();
+ },
+ }
+ );
+ };
+
+ const [isEditing, setIsEditing] = useState(initialIsEditing);
+
+ useEffect(() => {
+ form.reset();
+ }, [isEditing, form]);
+
+ const onSubmit = (values: z.infer) => {
+ updateKeyMutation.mutate(
+ {
+ provider,
+ value: values.apiKey,
+ organizationId: currentOrgId,
+ },
+ {
+ onSuccess: () => {
+ trackEvent("provider_api_key_set", { provider });
+ setIsEditing(false);
+ },
+ }
+ );
+ };
+
+ const iconBase64 = providersList.find(
+ (item) => item.provider === provider
+ ).iconBase64;
+
+ return (
+ <>
+ handleDeleteProvider(deletingProviderApiKey)}
+ onCancel={() => setDeletingProviderApiKey(null)}
+ />
+
+
+
+ >
+ );
+};
diff --git a/apps/console/src/components/api-keys/ProviderApiKeysList.tsx b/apps/console/src/components/api-keys/ProviderApiKeysList.tsx
new file mode 100644
index 000000000..da456078e
--- /dev/null
+++ b/apps/console/src/components/api-keys/ProviderApiKeysList.tsx
@@ -0,0 +1,33 @@
+import { ProviderApiKeyListItem } from "./ProviderApiKeyListItem";
+import { useProviderApiKeys } from "~/graphql/hooks/queries";
+import { providersList } from "./providers-list";
+
+export const ProviderApiKeysList = () => {
+ const { providerApiKeys } = useProviderApiKeys();
+
+ const renderProviderApiKey = (provider) => {
+ const apiKey = providerApiKeys.find(
+ (key) => key.provider === provider.provider
+ );
+
+ const value = apiKey?.censoredValue
+ ? `**********${apiKey?.censoredValue}`
+ : null;
+
+ return (
+
+ );
+ };
+
+ return (
+ providerApiKeys && (
+
+ {providersList.map((item) => renderProviderApiKey(item))}
+
+ )
+ );
+};
diff --git a/apps/console/src/components/api-keys/providers-list.tsx b/apps/console/src/components/api-keys/providers-list.tsx
new file mode 100644
index 000000000..b060ccf28
--- /dev/null
+++ b/apps/console/src/components/api-keys/providers-list.tsx
@@ -0,0 +1,8 @@
+export const providersList = [
+ {
+ name: "OpenAI",
+ provider: "OpenAI",
+ iconBase64:
+ "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAMgAAADICAYAAACtWK6eAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAABe6SURBVHgB7Z1NbFZVGscPooWKjLQOBFoDaCkL2hlhBCQoHRa2C0wAFwI7mJ0uJa5mNq6cpbOThcnIYhLKxpEJLGiTgSIEaFVMWhcWKjXTQiC0EESwLpzzv+0xl/r29px7nvNx731+yRucSWnL+97/eT7P8yx47/jHvwiGYWryhGAYZk5YIAyTAQuEYTJggTBMBiwQhsmABcIwGbBAGCYDFgjDZMACYZgMWCAMkwELhGEyYIEwTAYsEIbJgAXCMBmwQBgmAxYIw2TAAmGYDFggDJMBC4RhMmCBMEwGLBCGyYAFwjAZPCkYMhY/tUg0LWsUzfK1atlzor6uLvlv0PD00se+9tHPU+Lhzz+JiQc/yD+nxPjkHTF+d0KM35sQkw/uCyYOWCCWvLh8lWhvWiOaGhpFi/xvXRY/VZe8lHDwPRSTUjTXbt9IXoNj30sx/SSYMCzgwXHmQBQtK1aKjtb25CF3zeD4qBj4blgMyT8Zv7AFMQDC6GrbZGQpKIB1wQuW5fQ3X4qB68OC8QNbEA1CCWMuWCj+YIFk0LBkqdi/ZUc0wpgNC8U97GLNwY71baJrw5+8xBh5aVjyjBRwRyLg0998xdkvB7BAZhG71ajF5rWtye/72ZWLHMgTw4XCFIg1DnfuLZQ4FLAmh159XXTKWImhgy3IDHCpdr+0TRQduIWNsrbS3d8nGHtYIBJkqDrlg0XF+N074tqtm2Lyx/tJID0mq+MAMQJcOFAvYxu8UHlHsRB1lSZZfacALhe+75Ezp5IqPZOfymexqMRx7dYNMTA6LCvfo0kbSR6QEFgn3bu25jXJQ24LhMoisaPSArEVBx68z78dEn3Dg7lFMRcQS7sUSmcbXKZnRF4gkmP955KesAb5fWDBGmW8gheof2pR8rNg7QB6w5K/NzmR/H9j6A+Tr6q2u1RWIDbicCmMWsCa2ArFFggNQumXNZeR2zdEVaikQNqa1iQZnzyck8JAce5RALcFAXgMWSr82+FKVkEslRMIXIx3du5K3A0TYDWOnu9NOmxDgnTu2zvfCGpN0pS9mr9w+1u73xcV4nDXm8bigB9+5OzJxMUIDU7vL+TDuPx3y8SKpctEaHDnBbHSlrXrp++1RPAeUVKpNC/iDlNxoDJ97HJfEJeqFriU1dm28bH7IzGg2l7gBpbJolRGIHCtTINyfMgxFdy4P8w/lRFI1waz4BaWIxZxoAXmwNYOY+sXkrL0h1VCIMhamRTeUAuAWxWaIjZOplH9YXC5eoa+EkWkEgLZs0m/xwriQEAeMuZAnLFj/YbEnSoD+Hegjea4PHSKVtUvvUBw+pq4Jj3ytJucqSaHwFWcgQcT7TD4t43fuyMeTU2JiR9/+PWBVb1heOHkh1uHajtVfxiSCo0yvV601pfSC8SksIagPFT2xcW1Xghi5PbNXyekZDE563+fGx5K/oRY8DvBTUU61waI7d3ON8WHPZ8WRiSlFgh8eJMHDr6yb1zEGUroFEVNWJyBB9PfT4nFpu1lutBZHEtSaoGYZK5OD33l1bVScQbl6CA8xKcduohpsdj0h8GSHJTBO0QSO6UWSMsKvVMZJ9nA6LfCF3i49mzcRiYMuFI9subgsw1GWam8/WGwRLvle3BCpoFjplQCUQPd8OY3y1NK9wEcGhv1Yj2o4wxk3E4ErjPAYvXLwyVPf9iO1jaZKLifdEbHSuEFAnPd1rzaylVxHXsgzoC7R3EJCvhut58PHC7/OP1pYk1ek1k4E/B3hsa+j7biXliBUJ3GaK5zZT2KGGfkBaL97OuLyZ8mLhfSykhSxBqPFE4g1G5K/3U3sQfSoihQUrWHhIgz8qCssYlI8FnC8sToahVGIK7aLqgfOGoB40RGm8Y56U4VhTwigav1xXfD0aV+CyEQvNEuJqnjw7hBdH8B7lRX20YZeLYLCmKLM0yBSOA+6cYk+FqkjWPLakUtEFiNQ9tfT0bYuABLayigFjDcqe6BvujiDFMQk6wy2JuCrBYq+DEF7NEKBD48Wrxd3n3AIAIbqNvQkbY93t8XfZxhAq4MoL2kXvNz3LFugzjx9SURC1EKhHqQ21ygWS8PcKcObN2RiJiCIsYZusAK9gx9mRQFddj8wvokGRFLLBLdbF5f4gB570/v2fgKmTgwJeWDk92lFIcCbpOuVUzillazWopLorIgPsWRFxQmKQp+iDNOSB/d9ZADVYvZIn9njDhV+w99X4eFhWzZqRmLrG9PrEgMRCMQiimHao/ffun+zN4qS0Vb02phg884o9bdEnTTbl7SmogcmSZkyny4M6rlXidghxVBfBfDzK0oBFKkKYdqZKcpPn9PPFxohpwv+wfxbFmz3tsUEhMr0i4PIhaImM5W5RVHUdKhePgwvMC1MPIUU9Pjej46e8qp22ViRdqb10aRzQoqEHygJvfFFUXJ+vhqD6Ho+YJQ/rpr30yvl7v4ZOSWnkDw++AV+vALKpA8I0DVUIWYrQZ+xx5Pbgv1HXbEJli/gMPHxSSS/tHhpGKuQ8vvVyUXtEISTCB5phwmI0DPnIy+9QJ3rn3EGa5WUyNIdhWf4GDDWgWdJEoSQwUeqRVEIHmmHBZFHMDl75i4pYR1mOyfNR2fwKKcuHKJzO0a/N9oksqdj5YVTSI0QQRiOuWwSOJwhYu7JbpgZA9eVPHJ+D292k/DkvCTJL0LJM+Uw6MXeiotDuq7JXlR40Rt3S7d4qia0xWy7cS7QEyyVnhjYg/IXUIRZ6jCJNi3tcN6r0g6LZx37q6JBVpcVyGBwEwbTTn0PIonFijulqjCZPq+/d9PdpOtc1Nzd/O4Xfjd8NLp8FXtMaHwKhCTC/1Y8VXmBr65oLhbkrUmTg2U61jXbjxgoRYQnGpbGbh+VVsoWApa7zmWyoM3gZhOOUQjX5WguFuiW5jEiYzLTH1XBxNXiaL50lVaGJZuRITDm0BMMld4g6viWlHctc87HwvvcfdM4ySV26Xik39e6BU37trf2Ax9L8SbQHSnHIIQM3J9Qxln2DZAqimJlPHJ4c69JGnhSgjEZAVBFawHRXuIi/lYKj6hcrvS8cnsthUcEEXAi0BM7lCU2XrotqFngRMV7pSrPi/lduFz2P3SNuuVB6BWfKIboD+UwXxI/FgQTffK5ZTDkFDO9FJ9UsBlM+RkUqDtJXW7EJ/ApTw9pH8I3i17Ny8+UN0tRa6mHIbCVXtIOhg+Jk97lxeL0lPcX36h1VoosJ6on+ig6iUhcS4QkxVeZRp3Q73ioBYQCq4MuL7DAdQUd6r4RAequWU2eBCInr9NOeUwNNig5HMzbToYdnnHPB2fTA/0o9lfOBfjd8MLxPnYH93sVQynBRV5xYEH2+ahwOmOIW2uT3gI5cOefydiyTtbTIdrt2+K0Li3IA16p0yZ3Ks8IA2q6hk2gbHPO+a2W6bmA9t4Q+MlSNehqh27tQZPUDx4vu6YAxfxCboDYngm3AukTk8gLk11jOjMx6J48FzfMVeo+AQ/5+CrndbZrhhG/gDnAnE1wK2o1GpDz4LiwXN5x3w2qGVRtNXHMps3msmKuMhfdrLa0OeD4sFT8Qm+R3f/OefxCZajojCYx03EKgRU0V1aPR2iG15dRhBnYAcfWvhtrw7jwcP9fJt1ZciyIT6BWFDldwWsAA6ED05153KhYfVe9Jgur0U0AllcgMszpiDO+OR8rzgis0mUWTp1nwMPno27BEvyzp93kVycykJtwc0jalTdXYp4PpwLRNd1qi9Id6cOavIj5mO53GGu4hObegTcrj0vbZMWZb/T+onagmvqMqktuKGIxoI0Bp7YQQmEkTfWyAOsCOITXFPOi4pPXLtdtVrf50NtwQ2Bc4FMaOayY5iBREWo/P0jgtZwWBHEJ9gI5UooeUSCeCTEHXb3LpZmpiSkn8n8FmSREJ+4crtU35guoTZPOReI7pCwdYGzFcxvUW4X4hMX2STEJCbJiy6Z3vZ9kHqwIPouVpncrDKh2updxCdIMJgUBbEF1yfOBWJyQvgYyJymZflKUTVs08KIT1D4o4oHcIAev9yn/fXYguszFnEuEJwOuqleivvPJsBku05vxgZObGTZbHrfqNvqB2UqPNYtuF7SvBh3rwPSeRSnw8MpfZPtK70ZE6ptxbZ+ouITivcN6xV00VmdQIUXgQyNf6/9tRSnw1COeoCP9GZsqLYVm34nCOXdzr3WtwtxUUzX/VNbcH3gRSD4x+sGYjgdbK0IFtfnPRldpzdjAzGA6pfKG5/g89q98RVhi8nPb7dcx62LF4FM7zDXm1hC4WMmaxPkyUjhPoRulvOFals5er431/sG93iVxbwvoLbg6oAtuD7w1mpi4mZRWBF84BR+tqv0ZqwgYM77vjU/az/EQdc99lUW8CYQk9MB4tDdhDofFH52Or1Zxq7jWqj3zcem3tk/VxcfZQGvzYom/3jEAlTuDYWfDZDe9GXaYyA9/d0XcI91f57rsUPAu0BMzPYB6dpQFoVs/WzGDyO39ATSXDaBgB6DuazwMQ9qjqk0wcbPZtyj279XqhhEoUbs64LsCGoTrn6XEH42k82Y5jwseBeuRRLkwpRpwIx4xMVgMqDcLtv4JAaKsnNjPvCZ6NbNXCdNgggEFsT0frKr6X0KiuurCtRPfDZeTq9X6PDey+YS3ctfjY7HSgUb+4Os0obn1xhdtYVIkLlA96eruUlqqiGs1muyHpN3vE7eFckmuFqvEAO4iaozU62+jBYEqGq36YOOXevoJHVduEO7im184qI9XIEU+OGuvdar3Jhsgg5tmJzZzmqKmjvr0uUC6fgklqnrsKBYr4AKf0OJBl3ESvCpJjih81a58eD5uM9BMe7ftj0c7hQaAtE528LXk70Rxdif/tH8box68HCquna71Hgd2/Zw06mG2Ir7tzf2Wa2MLhr6Sz7djlaKYjYvxdVXNU6zKOP+daauI844sLWjkq6U7uFREYHQuQx48PD9+q4OWs2vnY/0OrK3d75BOnWdcituEcH7Esua6DgEsoL2QVDjNDvWtTsf96/a6immrsOiIBlQxrStCSZNiK73WgYXCE5LVy6EevBwErt2u2zH/QOksNstCoxwN+pLICzdxa/jHpa+Bg/Sm5+1u4Wmg6/75ulx/z7bVvBz4e4NjV0XZUD3msPDKbfuFQhuQWyvaZqA6jhOaB9uFx5Y/Ix9Msh2NZhbbatSyz/LMudL1+X2cU8luEAaNbsx4Zsvrltk/bClt8Aekw+xy114+AAp1pHV/N41ln+WAZPRTz7WREcRg+iA0f49Mo6getjUfXMfaWHV4r9/c4d1QmJM+t3/uXLR6y0/n5ikzUeqYEFM10Srh41q5TC+B15q2riLvDqyMqiC26Rt1VIe1E3KCg5L3c/02i0/B0R4gdSZV0zTNYjdMp1L0ebtYgss2kO62jZaV8Btln8WiS1r12l/7cConyRINFtu56PWyQ6hHL3QS+p2qfjko7OnrNwutIfYdtqWNc6oxbT1WK/99b5czMIIJAt1hwMP5MsvtJIIJW/bCkV7CO5CHPc8TSQ0O1o3aL9n+Fx8HRqlEIiCokcqjU6/lIKiPWR22rYq4L3bYmA9+j3WmAojEFiFEY2vS8cnh7a/bj07aa5+KYW61YevsQHf97MrFyslDEXXBv2BfLCuIx4ta3CBYFVBw9Pzf93iOjNfXt3hoI5P8P26+88lbhdVnIH0dZXcqTRdbZuMrH3PN/pjoygILhCcCDqnfN4HPB2fUNxAVG31KFzaWCf8u3scV/RjB+9fp4HlxXvm+/0KLhDdALipwc5Voo5P8oqjqnHGbBB3HDIcCgjX2TfhBaJ5hZViDquKTxB0H3y101mP1FwMjY8mcUYV0rZZQBymd+phOUYCuKHBBaLbsoxgGY2NFP3/agWZix6pWlQ9zkiTRxyqSzoEEQhEf1oI/H/KCzKqbQUXq15bT78Y0md7CMQe++R51Ij+It0q06QG3sNQVjd8Fmtm3L1O/QAtJZ8P016jxRuPhfa4oksVnwB8qD7iDDx0yATFfj0XGT+0BZmCQyxk/1kUdZDxyTtaH7BqhXbRUJjehWHjdvlqD4GrgvpB7LsUbQqoEzN7XUIShUCwnk13tS/2F8Kfd0Xe0aO+2kOKMm7UtoCaTN48ezJ4QiMKgeCh0r1PDSHBzXI97gWjR7FHZD63S6VtfZx0GIi9Z9O2qMcAUQm4+3IcTZrRtJpgC66OFVFbcF1aEUW6baXWZSdfbehUcYbrIQdd0jXdv7VD2IJOBaTEYyAagZi4Wb6siAJCOXL2VNJugnH7EOlVafVcC4PqPokib7Cre6mNYpkNxDFwXW9luA+iEYjagqtzSqotuHkGX9sAofgw+y7ijNMyq5a3TcPHCmwcdtgdGVutKKpuXqRGW3bquREIonFPfaRkxTfqcaMUyQPXs7YmkotvPV7mXJkSlUBMrAjAFtwPez715mq5hOLeehqqni/Xq5aRFv/kQm+0fWnR3QcxsSLweTF3Cqa5qFDHGYDybkmTo7llRRlCEZ1ATK0IBsGhjd1mJUEoKO6TpHHR8+WiQl+ku/ZR3ig8ceVSsihGF1WMKopIXMQZru6WUA4WL2LTZpQCQQNjz9CXSaZKlyKIhHqtgeu7Jfg9KURc5G7maO+ko5KNgQkmQSJEUi99eogrpsCd6t56Gh93S2z6vPD+I8v4xUzHdFGJViB4g5HdwPJLkzSjGlBtO9eKiiLEGXNh6l4pUSD1jj/LcGMy6qkmOB1hDbC2wAQ11wptIKFcLuo2dN9ZH1gPXfcKLTeYdGizCThWoh/7A1cLblOegQsuxonOh4v1ab7ulqTpMoj/zl0dLO014kLMxcIDDquQxydW43rgekFsroQCi7FlZhA2FaHSoSbWw+eUwxAUZnCcmmiR9wFEsK/m7sJ/75NiuWHpEiD4xsBlJBMoLUbI0aPJRSwD6+FzymEICjV61FYkILFES6ZPepx8Y/fuiBF5UmPvBiaszBXYQwz4u82ystz07HMygF1J3oYRQ3UZtxQbDC6Jla0XbjaFm80LkTyamiIZsoAHHq/ZizPh66fXCzc87b6bNYYVB8i4xTzlMASFHF6NIQs4bSkmJdYCKVlf11kRZ5yQ/57QnazTd9zjnnIYgsJOd8dpCx/d5ZJMl8S04kDNqjI5FKpgPUCh1x/g4Tpy5iTpuB7XxDZ6NO+Uw6rMFC78fhCqcT0+iG2VWtGmHIagNAt0qLdMUfPJ+d5oBhGAPOIAIacchqBUG6aAmuKOCnpMQtmzcZsYvzcRRX8Y3FH8PqaJCAi8zFt2a7Fw+1u73xcl49HMOFNMPkFtA82OjR4GD2SBbb6o5osF2O99U4QAtZxdf9wsdv1hi3hy4UKjv4ukwr8u/bdyKxsWvHf8419EBUC9A9XuzWtaSS8B5QEuyjEZN/ksstl0FSPuwN3/Kq5tqIxAZgOx4L41eqgak4LhUq22erhISdVdPizSGFgVLGHlMI7HlVBgMdqbVyetIzYXn46cOVXZ1Q2VFUgtcLrW1y1KhKLE8jCpqk+7FbXiB7W30IbJmSHN16TrRRGjQPRohaGYqxXbIDfflC5ItwH+NV6TBn+Hqj8MQgO4U3Ht1s3kxJ6Q8ZNOQyWsX8vylUmPWPvzq8laY6ouDsAWhAiqJaG1mPzxfhIkp0msnAz8XfSJxTrlMARsQYhQxTMXIoEIfDRMgolkDnH4tQOxwAIhpOj9YahzHLvcV+ntu7N5QjCkqP6wIvUqwaXCLDJU+1kcj8MWxAFF6g8r0pTDELBAHKK26KLtxVUAnxdeTa0HC8QxqsaB/rAY2vJZGGZwmtczquXFp+uVrNq+Nd2bxsIwgy2IZ2BRBh5Mt+Zj6MN0b5ibARBYr43MFCaPcPCdDxZIQFA1PzFTKU8mpshK+IszPWJNDc8ZjVxFi8rV2zeTyjvut7OloIEFEglq/+Fg6lLV4pk2fSWU9JJMfK3qE4vhjklZYYFEDNyix+bd3haMZ7hQyDAZsEAYJgMWCMNkwAJhmAxYIAyTAQuEYTJggTBMBiwQhsmABcIwGbBAGCYDFgjDZMACYZgMWCAMkwELhGEyYIEwTAYsEIbJgAXCMBmwQBgmAxYIw2TAAmGYDFggDJPB/wFdmpLa3s9ZJQAAAABJRU5ErkJggg==",
+ },
+];
diff --git a/apps/console/src/components/common/Avatar.tsx b/apps/console/src/components/common/Avatar.tsx
new file mode 100644
index 000000000..f4f4e657c
--- /dev/null
+++ b/apps/console/src/components/common/Avatar.tsx
@@ -0,0 +1,27 @@
+import { ExtendedUser } from "~/@generated/graphql/graphql";
+import { useMemo } from "react";
+import { Avatar as ShadcnAvatar, AvatarFallback, AvatarImage } from "@pezzo/ui";
+import { cn } from "@pezzo/ui/utils";
+
+interface Props {
+ user: Partial;
+ className?: string;
+}
+
+const buildInitials = (name: string) => {
+ const splitName = name.split(" ");
+ if (splitName.length === 1) return splitName[0][0];
+ const [firstName, lastName] = splitName;
+ return `${firstName[0]}${lastName[0]}`;
+};
+
+export const Avatar = ({ user, className = "" }: Props) => {
+ const photoUrl = useMemo(() => user.photoUrl || undefined, [user.photoUrl]);
+
+ return (
+
+
+ {buildInitials(user.name || "")}
+
+ );
+};
diff --git a/apps/console/src/components/common/BreakpointDebugger.tsx b/apps/console/src/components/common/BreakpointDebugger.tsx
new file mode 100644
index 000000000..bcb6be8fb
--- /dev/null
+++ b/apps/console/src/components/common/BreakpointDebugger.tsx
@@ -0,0 +1,16 @@
+import { DEBUG_MODE } from "~/env";
+
+export const BreakpointDebugger = () => {
+ if (!DEBUG_MODE) {
+ return null;
+ }
+
+ return (
+
+ );
+};
diff --git a/apps/console/src/components/common/Drawer.tsx b/apps/console/src/components/common/Drawer.tsx
new file mode 100644
index 000000000..f57093bc0
--- /dev/null
+++ b/apps/console/src/components/common/Drawer.tsx
@@ -0,0 +1,55 @@
+import { cn } from "@pezzo/ui/utils";
+import { motion } from "framer-motion";
+import { useState } from "react";
+
+type Props = {
+ children: React.ReactNode;
+ className?: string;
+ onClose: () => void;
+ open: boolean;
+};
+
+export const Drawer = ({ children, className, onClose, open }: Props) => {
+ const [shouldHide, setShouldHide] = useState(false);
+
+ return (
+
+
{
+ if (e.key === "Enter") {
+ onClose();
+ }
+ }}
+ role="button"
+ aria-label="Close"
+ >
+
open && setShouldHide(false)}
+ onAnimationComplete={() => !open && setShouldHide(true)}
+ >
+ {children}
+
+
+ );
+};
diff --git a/apps/console/src/components/common/FullScreenLoader.tsx b/apps/console/src/components/common/FullScreenLoader.tsx
new file mode 100644
index 000000000..93147b5ac
--- /dev/null
+++ b/apps/console/src/components/common/FullScreenLoader.tsx
@@ -0,0 +1,9 @@
+import { Loader } from "./Loader";
+
+export const FullScreenLoader = () => {
+ return (
+
+
+
+ );
+};
diff --git a/apps/console/src/components/common/GenericDestructiveConfirmationModal.tsx b/apps/console/src/components/common/GenericDestructiveConfirmationModal.tsx
new file mode 100644
index 000000000..bfd4bbbff
--- /dev/null
+++ b/apps/console/src/components/common/GenericDestructiveConfirmationModal.tsx
@@ -0,0 +1,63 @@
+import { GraphQLErrorResponse } from "~/graphql/types";
+import {
+ Alert,
+ AlertDialog,
+ AlertDialogContent,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+ AlertDialogCancel,
+ AlertDialogDescription,
+ Button,
+ AlertTitle,
+ AlertDescription,
+} from "@pezzo/ui";
+import { AlertCircle } from "lucide-react";
+
+type Props = {
+ open: boolean;
+ title?: string;
+ description?: string | React.ReactNode;
+ error?: GraphQLErrorResponse;
+ confirmText?: string;
+ onConfirm: () => void;
+ onCancel: () => void;
+};
+
+export const GenericDestructiveConfirmationModal = ({
+ open,
+ error,
+ title = "Are you sure?",
+ description,
+ confirmText = "Confirm",
+ onConfirm,
+ onCancel,
+}: Props) => {
+ return (
+
+
+
+ {title}
+
+
+ {error && (
+
+
+ Oops!
+
+ {error.response.errors[0].message}
+
+
+ )}
+ {description}
+
+
+ Cancel
+
+ {confirmText}
+
+
+
+
+ );
+};
diff --git a/apps/console/src/components/common/InlineCodeSnippet.tsx b/apps/console/src/components/common/InlineCodeSnippet.tsx
new file mode 100644
index 000000000..39646ba5c
--- /dev/null
+++ b/apps/console/src/components/common/InlineCodeSnippet.tsx
@@ -0,0 +1,7 @@
+export const InlineCodeSnippet = ({ children }) => {
+ return (
+
+ {children}
+
+ );
+};
diff --git a/apps/console/src/components/common/Loader.tsx b/apps/console/src/components/common/Loader.tsx
new file mode 100644
index 000000000..9dac88b72
--- /dev/null
+++ b/apps/console/src/components/common/Loader.tsx
@@ -0,0 +1,8 @@
+import Lottie from "lottie-react";
+import LogoLoader from "~/assets/logo-loader.json";
+
+export const Loader = () => {
+ return (
+
+ );
+};
diff --git a/apps/console/src/components/common/Pagination.tsx b/apps/console/src/components/common/Pagination.tsx
new file mode 100644
index 000000000..92666127a
--- /dev/null
+++ b/apps/console/src/components/common/Pagination.tsx
@@ -0,0 +1,115 @@
+import { Button } from "@pezzo/ui";
+import { cn } from "@pezzo/ui/utils";
+import { ChevronLeft, ChevronRight } from "lucide-react";
+
+type Props = {
+ offset: number;
+ limit: number;
+ totalResults: number;
+ onChange: (page: number, limit: number) => void;
+};
+
+function generatePagination(
+ currentPage: number,
+ totalPages: number
+): (number | string)[] {
+ const pages: (number | string)[] = [];
+
+ // Lower limit for pagination
+ const lowerLimit = Math.max(1, currentPage - 3);
+
+ // Upper limit for pagination
+ const upperLimit = Math.min(totalPages, currentPage + 1);
+
+ // If there are more pages before the lower limit, add "..."
+ if (lowerLimit > 1) {
+ pages.push(1);
+ pages.push("...");
+ }
+
+ // Add the page numbers
+ for (let i = lowerLimit; i <= upperLimit; i++) {
+ pages.push(i);
+ }
+
+ // If there are more pages after the upper limit, add "..."
+ if (upperLimit < totalPages) {
+ pages.push("...");
+ pages.push(totalPages);
+ }
+
+ return pages;
+}
+
+export const Pagination = ({
+ offset,
+ limit,
+ totalResults,
+ onChange,
+}: Props) => {
+ // calculate currentPage, bsaed on offset and totalResults and limit (size of page)
+ const currentPage = Math.ceil((offset + 1) / limit);
+ const totalPages = Math.ceil(totalResults / limit);
+ const pagination = generatePagination(currentPage + 1, totalPages);
+
+ const handlePageClick = (_page: string) => {
+ const page = parseInt(_page);
+ onChange(page, limit);
+ };
+
+ const pagesToRender = pagination.map((page, idx) => {
+ if (page === "...") {
+ return (
+
+
+ {page}
+
+
+ );
+ }
+
+ const selected = page === currentPage;
+
+ return (
+
+ handlePageClick(`${page}`)}
+ >
+ {page}
+
+
+ );
+ });
+
+ return (
+
+
+ handlePageClick(`${currentPage - 1}`)}
+ disabled={currentPage <= 1}
+ size="icon"
+ variant="ghost"
+ >
+
+
+
+
+ {pagesToRender.map((Page) => Page)}
+
+
+ handlePageClick(`${currentPage + 1}`)}
+ disabled={currentPage >= totalPages}
+ size="icon"
+ variant="ghost"
+ >
+
+
+
+
+ );
+};
diff --git a/apps/console/src/components/common/Tag.tsx b/apps/console/src/components/common/Tag.tsx
new file mode 100644
index 000000000..98a658db4
--- /dev/null
+++ b/apps/console/src/components/common/Tag.tsx
@@ -0,0 +1,31 @@
+import { cn } from "@pezzo/ui/utils";
+import colors from "tailwindcss/colors";
+
+type Props = {
+ children: React.ReactNode;
+ color?: string;
+ className?: string;
+};
+
+export const Tag = ({ children, color = "stone", className = "" }: Props) => {
+ const baseCn = cn(
+ "rounded-sm border p-1 text-xs inline-flex gap-1 items-center h-6",
+ className
+ );
+
+ return (
+
+ {children}
+
+ );
+};
diff --git a/apps/console/src/components/environments/CreateEnvironmentModal.tsx b/apps/console/src/components/environments/CreateEnvironmentModal.tsx
new file mode 100644
index 000000000..37c0f565b
--- /dev/null
+++ b/apps/console/src/components/environments/CreateEnvironmentModal.tsx
@@ -0,0 +1,134 @@
+import { useMutation } from "@tanstack/react-query";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ Button,
+ Form,
+ FormItem,
+ FormControl,
+ Input,
+ FormField,
+ FormMessage,
+ FormLabel,
+ Alert,
+ AlertTitle,
+ AlertDescription,
+ useToast,
+} from "@pezzo/ui";
+import { gqlClient, queryClient } from "~/lib/graphql";
+import { CREATE_ENVIRONMENT } from "~/graphql/definitions/mutations/environments";
+import { CreateEnvironmentMutation } from "~/@generated/graphql/graphql";
+import { GraphQLErrorResponse } from "~/graphql/types";
+import { useCurrentProject } from "~/lib/hooks/useCurrentProject";
+import { trackEvent } from "~/lib/utils/analytics";
+import z from "zod";
+import { zodResolver } from "@hookform/resolvers/zod";
+import { useForm } from "react-hook-form";
+import { AlertCircle } from "lucide-react";
+
+interface Props {
+ open: boolean;
+ onClose: () => void;
+ onCreated: (id: string) => void;
+}
+
+const formSchema = z.object({
+ environmentName: z
+ .string()
+ .min(1, "Name must be at least 1 character long")
+ .max(64, "Name can't be longer than 64 characters")
+ .regex(/^[a-zA-Z]+$/, "Name can only contain letters, e.g. Production"),
+});
+
+export const CreateEnvironmentModal = ({ open, onClose, onCreated }: Props) => {
+ const { projectId } = useCurrentProject();
+ const { toast } = useToast();
+ const form = useForm>({
+ resolver: zodResolver(formSchema),
+ defaultValues: {
+ environmentName: "",
+ },
+ });
+
+ const { mutate, error } = useMutation<
+ CreateEnvironmentMutation,
+ GraphQLErrorResponse,
+ z.infer
+ >({
+ mutationFn: (data) =>
+ gqlClient.request(CREATE_ENVIRONMENT, {
+ data: {
+ name: data.environmentName,
+ projectId,
+ },
+ }),
+ onSuccess: (data) => {
+ onCreated(data.createEnvironment.name);
+ queryClient.invalidateQueries({ queryKey: ["environments"] });
+ toast({
+ title: "Environment created!",
+ description: `Your new environment was created successfully.`,
+ });
+ },
+ });
+
+ const onSubmit = (values: z.infer) => {
+ mutate(values);
+ form.reset();
+ trackEvent("environment_form_submitted");
+ };
+
+ const onCancel = () => {
+ onClose();
+ form.reset();
+ trackEvent("environment_form_cancelled");
+ };
+
+ return (
+
+
+
+
+
+
+ );
+};
diff --git a/apps/console/src/components/getting-started-wizard/HighlightCode.tsx b/apps/console/src/components/getting-started-wizard/HighlightCode.tsx
new file mode 100644
index 000000000..12b47ef1d
--- /dev/null
+++ b/apps/console/src/components/getting-started-wizard/HighlightCode.tsx
@@ -0,0 +1,28 @@
+import { Highlight, themes } from "prism-react-renderer";
+import { StyledPre } from "./StyledPre";
+
+type Props = {
+ code: string;
+ language: string;
+};
+
+export const HighlightCode = ({ code, language = "ts" }: Props) => (
+
+ {({ style, tokens, getLineProps, getTokenProps }) => (
+
+ {tokens.map((line, i) => (
+
+ {line.map((token, key) => (
+
+ ))}
+
+ ))}
+
+ )}
+
+);
diff --git a/apps/console/src/components/getting-started-wizard/PythonOpenAIIntegrationTutorial.tsx b/apps/console/src/components/getting-started-wizard/PythonOpenAIIntegrationTutorial.tsx
new file mode 100644
index 000000000..820dbaa7b
--- /dev/null
+++ b/apps/console/src/components/getting-started-wizard/PythonOpenAIIntegrationTutorial.tsx
@@ -0,0 +1,118 @@
+import { HighlightCode } from "./HighlightCode";
+import { useCurrentProject } from "~/lib/hooks/useCurrentProject";
+import { useCurrentPrompt } from "~/lib/providers/CurrentPromptContext";
+import { useEditorContext } from "~/lib/providers/EditorContext";
+import { Alert, AlertDescription, AlertTitle } from "@pezzo/ui";
+import { Link } from "react-router-dom";
+import { InfoIcon } from "lucide-react";
+import { usePezzoApiKeys } from "~/graphql/hooks/queries";
+import { StyledPre } from "./StyledPre";
+
+const getVariablesString = (variables: string[]) => {
+ if (!variables.length) return "";
+ const varStrings = variables.map((v) => ` "${v}": "value"`).join(",\n");
+ return `
+ "variables": {
+${varStrings}
+ }`;
+};
+
+export const PythonOpenAIIntegrationTutorial = () => {
+ const { variables } = useEditorContext();
+ const { prompt } = useCurrentPrompt();
+ const { pezzoApiKeys } = usePezzoApiKeys();
+ const API_KEY = pezzoApiKeys && pezzoApiKeys[0].id;
+
+ const { project } = useCurrentProject();
+ const codeSetupClients = `from pezzo.client import pezzo
+from pezzo.openai import openai
+
+/*
+ * The Pezzo client automatically searches for the following environment variables and uses them to initialize the cilent:
+ * - PEZZO_API_KEY: Your Pezzo API key
+ * - PEZZO_PROJECT_ID: The ID of the project you want to use
+ * - PEZZO_ENVIRONMENT: The environment you want to use. By default, Pezzo creates a "Production" environment for you.
+ */
+`;
+
+ const variablesString = getVariablesString(variables);
+
+ const codeWithPromptManagement = `${codeSetupClients}
+// Fetch the prompt from Pezzo
+prompt = pezzo.get_prompt("${prompt.name}")
+
+// Use the OpenAI API as you normally would
+response = await openai.ChatCompletion.create(
+ pezzo_prompt=prompt,
+ pezzo_options={${variablesString}
+ }
+)
+`;
+
+ return (
+
+
+
+ Need some more help?
+
+ Check out the{" "}
+
+ Using OpenAI With Pezzo
+ {" "}
+ page on our documentation to learn more.
+
+
+
+
Installation
+
+ Pezzo provides a package for intergration with Python projects. Here's
+ how to install it:
+
+
+
+ # via pip
+
+ pip install pezzo
+
+
+ # via poetry
+
+ poetry add pezzo
+
+
+
+
+
Usage
+
+ Managing your prompts with Pezzo is easy. You will fetch the prompt
+ payload from Pezzo, and then pass it to the OpenAI client as an
+ argument. As soon as you interact with OpenAI, your requests will be
+ available in the Requests view.
+
+
+
+
+
+
+
+
+ In addition to vriables, you can also provide{" "}
+ custom properties and caching when
+ executing prompts. These properties will be available in the Requests
+ view.{" "}
+
+ Read more in the Pezzo Documentation
+
+ .
+
+
+ );
+};
diff --git a/apps/console/src/components/getting-started-wizard/StyledPre.tsx b/apps/console/src/components/getting-started-wizard/StyledPre.tsx
new file mode 100644
index 000000000..7a4aeef94
--- /dev/null
+++ b/apps/console/src/components/getting-started-wizard/StyledPre.tsx
@@ -0,0 +1,10 @@
+export const StyledPre = ({ children, style = {} }) => {
+ return (
+
+ {children}
+
+ );
+};
diff --git a/apps/console/src/components/getting-started-wizard/TypeScriptOpenAIIntegrationTutorial.tsx b/apps/console/src/components/getting-started-wizard/TypeScriptOpenAIIntegrationTutorial.tsx
new file mode 100644
index 000000000..e356a61b3
--- /dev/null
+++ b/apps/console/src/components/getting-started-wizard/TypeScriptOpenAIIntegrationTutorial.tsx
@@ -0,0 +1,107 @@
+import { HighlightCode } from "./HighlightCode";
+import { useCurrentProject } from "~/lib/hooks/useCurrentProject";
+import { useCurrentPrompt } from "~/lib/providers/CurrentPromptContext";
+import { usePezzoApiKeys } from "~/graphql/hooks/queries";
+import { useEditorContext } from "~/lib/providers/EditorContext";
+import { Alert, AlertDescription, AlertTitle } from "@pezzo/ui";
+import { Link } from "react-router-dom";
+import { InfoIcon } from "lucide-react";
+import { StyledPre } from "./StyledPre";
+
+const getVariablesString = (variables: string[]) => {
+ if (!variables.length) return "";
+ const varStrings = variables.map((v) => ` ${v}: "value"`).join(",\n");
+ return `, {
+ variables: {
+${varStrings}
+ }
+}`;
+};
+
+export const TypeScriptOpenAIIntegrationTutorial = () => {
+ const { variables } = useEditorContext();
+ const { prompt } = useCurrentPrompt();
+ const { pezzoApiKeys } = usePezzoApiKeys();
+ const API_KEY = pezzoApiKeys && pezzoApiKeys[0].id;
+
+ const { project } = useCurrentProject();
+ const codeSetupClients = `import { Pezzo, PezzoOpenAI } from "@pezzo/client";
+
+// Initialize the Pezzo client
+export const pezzo = new Pezzo({
+ apiKey: "${API_KEY}",
+ projectId: "${project.id}",
+ environment: "Production", // Your desired environment
+});
+
+// Initialize the PezzoOpenAI client
+const openai = new PezzoOpenAI(pezzo);
+`;
+
+ const variablesString = getVariablesString(variables);
+
+ const codeWithPromptManagement = `${codeSetupClients}
+// Fetch the prompt from Pezzo
+const prompt = await pezzo.getPrompt("${prompt.name}");
+
+// Use the OpenAI API as you normally would
+const response = await openai.chat.completions.create(prompt${variablesString});
+`;
+
+ return (
+
+
+
+ Need some more help?
+
+ Check out the{" "}
+
+ Using OpenAI With Pezzo
+ {" "}
+ page on our documentation to learn more.
+
+
+
+
+
Installation
+
+ Pezzo provides a fully-typed NPM package for integration with
+ TypeScript projects.
+
+
+ {"npm install @pezzo/client openai"}
+
+
+
+
+
Usage
+
+ Managing your prompts with Pezzo is easy. You will fetch the prompt
+ payload from Pezzo, and then pass it to the OpenAI client as an
+ argument. As soon as you interact with OpenAI, your requests will be
+ available in the Requests view.
+
+
+
+
+
+ In addition to vriables, you can also provide{" "}
+ custom properties and caching when
+ executing prompts. These properties will be available in the Requests
+ view.{" "}
+
+ Read more in the Pezzo Documentation
+
+ .
+
+
+ );
+};
diff --git a/apps/console/src/components/layout/Header.tsx b/apps/console/src/components/layout/Header.tsx
new file mode 100644
index 000000000..13dedc1aa
--- /dev/null
+++ b/apps/console/src/components/layout/Header.tsx
@@ -0,0 +1,74 @@
+import { cn } from "@pezzo/ui/utils";
+import { BriefcaseIcon, ChevronsUpDown } from "lucide-react";
+import LogoSquare from "~/assets/logo-square.svg";
+import { useCurrentOrganization } from "~/lib/hooks/useCurrentOrganization";
+import { useCurrentProject } from "~/lib/hooks/useCurrentProject";
+import { Popover, PopoverContent, PopoverTrigger } from "@pezzo/ui";
+import { Link } from "react-router-dom";
+import { UserMenu } from "./UserMenu";
+import { useAuthContext } from "~/lib/providers/AuthProvider";
+import { ProjectCopy } from "../projects/ProjectCopy";
+import { OrgSelector } from "./OrgSelector";
+
+export const Header = () => {
+ const { organization } = useCurrentOrganization();
+ const { project } = useCurrentProject();
+ const { currentUser } = useAuthContext();
+
+ return (
+
+
+
+
+
+ {organization && (
+
+ {"/"}
+
+
+
+ {organization.name}
+
+
+
+
+
+
+
+
+
+ )}
+ {project && (
+
+ {"/"}
+
+
+ {project.name}
+
+
+
+
+
+
+
+
+
+ )}
+
+
+ {project && (
+
+ )}
+
+ {currentUser && }
+
+ );
+};
diff --git a/apps/console/src/components/layout/LayoutWrapper.tsx b/apps/console/src/components/layout/LayoutWrapper.tsx
new file mode 100644
index 000000000..132accc54
--- /dev/null
+++ b/apps/console/src/components/layout/LayoutWrapper.tsx
@@ -0,0 +1,43 @@
+import { SideNavigation } from "./SideNavigation";
+import { Header } from "./Header";
+import { OrgSubHeader } from "./OrgSubHeader";
+import { cn } from "@pezzo/ui/utils";
+
+interface Props {
+ children: React.ReactNode;
+ withSideNav?: boolean;
+ withOrgSubHeader?: boolean;
+}
+
+export const LayoutWrapper = ({
+ children,
+ withSideNav = false,
+ withOrgSubHeader = false,
+}: Props) => {
+ return (
+
+
+
+ {/* Top */}
+
+
+ {withOrgSubHeader && }
+
+
+ {/* Bottom */}
+
+ {withSideNav && (
+
+
+
+ )}
+
+
+ {children}
+
+
+
+
+
+ );
+};
diff --git a/apps/console/src/components/layout/OrgSelector.tsx b/apps/console/src/components/layout/OrgSelector.tsx
new file mode 100644
index 000000000..2e0bab361
--- /dev/null
+++ b/apps/console/src/components/layout/OrgSelector.tsx
@@ -0,0 +1,57 @@
+import { CheckIcon } from "lucide-react";
+import { useOrganizations } from "~/lib/hooks/useOrganizations";
+import { useGetProjects } from "~/graphql/hooks/queries";
+import { useCurrentOrganization } from "~/lib/hooks/useCurrentOrganization";
+import { useCurrentProject } from "~/lib/hooks/useCurrentProject";
+import { Link } from "react-router-dom";
+
+export const OrgSelector = () => {
+ const { organizations } = useOrganizations();
+ const { organization: currentOrganization } = useCurrentOrganization();
+ const { projects } = useGetProjects();
+ const { project: currentProject } = useCurrentProject();
+
+ return (
+
+
+
Organizations
+
+ {organizations &&
+ currentOrganization &&
+ organizations.map((org) => (
+
+
+ {org.name}
+ {org.id === currentOrganization.id && (
+
+ )}
+
+
+ ))}
+
+
+
+
Projects
+
+ {projects &&
+ projects.map((project) => (
+
+
+ {project.name}
+ {project.id === currentProject?.id && (
+
+ )}
+
+
+ ))}
+
+
+
+ );
+};
diff --git a/apps/console/src/components/layout/OrgSubHeader.tsx b/apps/console/src/components/layout/OrgSubHeader.tsx
new file mode 100644
index 000000000..2d008fc3f
--- /dev/null
+++ b/apps/console/src/components/layout/OrgSubHeader.tsx
@@ -0,0 +1,58 @@
+import { cn } from "@pezzo/ui/utils";
+import { useNavigate } from "react-router-dom";
+import { useCurrentOrganization } from "~/lib/hooks/useCurrentOrganization";
+
+const isActive = (href: string) => {
+ return window.location.pathname === href;
+};
+
+export const OrgSubHeader = () => {
+ const navigate = useNavigate();
+ const { organization, waitlisted } = useCurrentOrganization();
+
+ if (!organization || waitlisted) {
+ return;
+ }
+
+ const baseClassName = cn(
+ "cursor-pointer py-3 px-3 text-sm font-medium border-b-2 border-b-transparent hover:border-b-primary transition-all"
+ );
+
+ const getClassName = (href: string) => {
+ return cn(baseClassName, {
+ "border-b-2 text-primary border-b-primary": isActive(href),
+ });
+ };
+
+ const orgNavigation = [
+ { name: "Projects", href: `/orgs/${organization.id}` },
+ { name: "API Keys", href: `/orgs/${organization.id}/api-keys` },
+ { name: "Members", href: `/orgs/${organization.id}/members` },
+ { name: "Settings", href: `/orgs/${organization.id}/settings` },
+ ];
+
+ return (
+ organization && (
+
+
+ {orgNavigation.map((nav) => (
+ navigate(nav.href)}
+ className={getClassName(nav.href)}
+ onKeyDown={(e) => {
+ if (e.key === "Enter") {
+ navigate(nav.href);
+ }
+ }}
+ role="button"
+ aria-label={nav.name}
+ >
+ {nav.name}
+
+ ))}
+
+
+ )
+ );
+};
diff --git a/apps/console/src/components/layout/SideNavigation.tsx b/apps/console/src/components/layout/SideNavigation.tsx
new file mode 100644
index 000000000..f38d2387f
--- /dev/null
+++ b/apps/console/src/components/layout/SideNavigation.tsx
@@ -0,0 +1,140 @@
+import {
+ BarChart2,
+ BoxIcon,
+ GraduationCapIcon,
+ HardDriveIcon,
+ KeyRoundIcon,
+ RadioIcon,
+} from "lucide-react";
+import { cn } from "@pezzo/ui/utils";
+import { useCurrentProject } from "~/lib/hooks/useCurrentProject";
+import { Link } from "react-router-dom";
+import { useState } from "react";
+import { motion } from "framer-motion";
+import { useCurrentOrganization } from "~/lib/hooks/useCurrentOrganization";
+
+export const SideNavigation = () => {
+ const [collapsed, setCollapsed] = useState(true);
+ const { projectId } = useCurrentProject();
+ const { organizationId } = useCurrentOrganization();
+
+ const projectNavigation = [
+ {
+ name: "Dashboard",
+ href: `/projects/${projectId}`,
+ icon: BarChart2,
+ isActive: (href: string) => window.location.pathname === href,
+ },
+ {
+ name: "Requests",
+ href: `/projects/${projectId}/requests`,
+ icon: RadioIcon,
+ isActive: (href: string) => window.location.pathname.startsWith(href),
+ },
+ {
+ name: "Prompts",
+ href: `/projects/${projectId}/prompts`,
+ icon: BoxIcon,
+ isActive: (href: string) => window.location.pathname.startsWith(href),
+ },
+ {
+ name: "Environments",
+ href: `/projects/${projectId}/environments`,
+ icon: HardDriveIcon,
+ isActive: (href: string) => window.location.pathname.startsWith(href),
+ },
+ ];
+
+ return (
+ setCollapsed(false)}
+ onMouseLeave={() => setCollapsed(true)}
+ onFocus={() => setCollapsed(false)}
+ className={cn(
+ "z-50 flex h-full grow flex-col gap-y-4 overflow-y-auto overflow-x-hidden border-r border-border bg-stone-900 px-3 pt-2"
+ )}
+ >
+
+
+
+
+ {projectId &&
+ projectNavigation.map((item) => (
+
+
+
+
+ {item.name}
+
+
+
+ ))}
+
+
+
+
+
+
+
+ API Keys
+
+
+
+
+
+
+ Documenation
+
+
+
+
+
+
+ );
+};
diff --git a/apps/console/src/components/layout/UserMenu.tsx b/apps/console/src/components/layout/UserMenu.tsx
new file mode 100644
index 000000000..f36034bca
--- /dev/null
+++ b/apps/console/src/components/layout/UserMenu.tsx
@@ -0,0 +1,52 @@
+import { GraduationCap, LogOutIcon } from "lucide-react";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuLabel,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from "@pezzo/ui";
+import { useAuthContext } from "~/lib/providers/AuthProvider";
+import { Link } from "react-router-dom";
+import { Avatar } from "../common/Avatar";
+
+export const UserMenu = () => {
+ const { currentUser } = useAuthContext();
+
+ return (
+
+
+
+
+ {currentUser?.name}
+
+
+
+
+
+
+ {currentUser.name}
+
+
+ {currentUser.email}
+
+
+
+
+
+
+
+ Documentation
+
+
+
+
+
+ Sign out
+
+
+
+
+ );
+};
diff --git a/apps/console/src/components/metrics/CustomDateTooltip.tsx b/apps/console/src/components/metrics/CustomDateTooltip.tsx
new file mode 100644
index 000000000..3725c0a4e
--- /dev/null
+++ b/apps/console/src/components/metrics/CustomDateTooltip.tsx
@@ -0,0 +1,47 @@
+import { DatePicker } from "antd";
+import { Button } from "@pezzo/ui";
+import dayjs from "dayjs";
+import { useState } from "react";
+
+interface Props {
+ startDate: string;
+ endDate: string;
+ onApply: (dates: { startDate: string; endDate: string }) => void;
+}
+
+export const CustomDateTooltip = ({ startDate, endDate, onApply }: Props) => {
+ const [tempStartDate, setTempStartDate] = useState(startDate);
+ const [tempEndDate, setTempEndDate] = useState(endDate);
+
+ const handleApply = () => {
+ onApply({ startDate: tempStartDate, endDate: tempEndDate });
+ };
+
+ return (
+
+
+
Start date
+
setTempStartDate(v?.toISOString())}
+ placeholder="Start date"
+ showTime={{ format: "HH:mm" }}
+ />
+
+
+
End date
+
setTempEndDate(v?.toString())}
+ placeholder="End date"
+ showTime={{ format: "HH:mm" }}
+ />
+
+
+
+ Apply
+
+
+
+ );
+};
diff --git a/apps/console/src/components/metrics/StatisticBox.tsx b/apps/console/src/components/metrics/StatisticBox.tsx
new file mode 100644
index 000000000..1a4719935
--- /dev/null
+++ b/apps/console/src/components/metrics/StatisticBox.tsx
@@ -0,0 +1,116 @@
+import CountUp from "react-countup";
+import { ArrowDownIcon, ArrowUpIcon } from "lucide-react";
+import clsx from "clsx";
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipProvider,
+ TooltipTrigger,
+} from "@pezzo/ui";
+
+interface Props {
+ title: string;
+ currentValue: number;
+ previousValue: number;
+ suffix?: React.ReactNode;
+ prefix?: React.ReactNode;
+ precision?: number;
+ reverseColors?: boolean;
+ valueStyle?: React.CSSProperties;
+ numberPrefix?: string;
+ numberSuffix?: string;
+ numberSeparator?: string;
+ loading: boolean;
+}
+
+const getProperties = (
+ currentValue: number,
+ previousValue: number,
+ reverseColors: boolean
+) => {
+ const diff = currentValue - previousValue;
+ // Handle case where previousValue is 0
+ const calculatedPreviousValue = previousValue === 0 ? 1 : previousValue;
+
+ const percentage = Math.abs((diff / calculatedPreviousValue) * 100);
+
+ const percentageToRender =
+ percentage < 1 ? percentage.toFixed(3) : percentage;
+
+ const isIncrease = diff > 0;
+ const isDecrease = diff < 0;
+
+ const className = clsx("h-7 w-7", {
+ "text-destructive": isDecrease || (reverseColors && isIncrease),
+ "text-green-500": isIncrease || (reverseColors && isDecrease),
+ "text-muted": !isIncrease && !isDecrease,
+ });
+
+ if (isIncrease) {
+ return {
+ suffix: ,
+ percentage,
+ tooltipTitle: `${percentageToRender}% increase (previously ${previousValue})`,
+ };
+ }
+
+ if (isDecrease) {
+ return {
+ suffix: ,
+ percentage,
+ tooltipTitle: `${percentageToRender}% decrease (previously ${previousValue})`,
+ };
+ }
+
+ return { suffix: null, percentage: 0, tooltipTitle: "" };
+};
+
+export const StatisticBox = ({
+ title,
+ currentValue,
+ previousValue,
+ prefix,
+ precision = 0,
+ reverseColors = false,
+ numberPrefix,
+ numberSuffix,
+ numberSeparator,
+ loading = false,
+}: Props) => {
+ const calculatedFormatter = (value: number) => (
+
+ );
+
+ const { suffix: calculatedSuffix, tooltipTitle } = getProperties(
+ currentValue,
+ previousValue,
+ reverseColors
+ );
+
+ return (
+
+
{title}
+
+ {calculatedFormatter(currentValue)}
+ {calculatedSuffix && (
+
+
+
+ {prefix !== undefined ? prefix : calculatedSuffix}
+
+ {tooltipTitle}
+
+
+ )}
+
+
+ );
+};
diff --git a/apps/console/src/components/metrics/TimeframeSelector.tsx b/apps/console/src/components/metrics/TimeframeSelector.tsx
new file mode 100644
index 000000000..43803f9d9
--- /dev/null
+++ b/apps/console/src/components/metrics/TimeframeSelector.tsx
@@ -0,0 +1,98 @@
+import { CustomDateTooltip } from "./CustomDateTooltip";
+import { Button, PopoverContent, PopoverTrigger, Popover } from "@pezzo/ui";
+import clsx from "clsx";
+import {
+ Timeframe,
+ useTimeframeSelector,
+} from "~/lib/providers/TimeframeSelectorContext";
+import { trackEvent } from "~/lib/utils/analytics";
+import { useCurrentProject } from "~/lib/hooks/useCurrentProject";
+import { CalendarDaysIcon } from "lucide-react";
+import { useState } from "react";
+
+export const TimeframeSelector = () => {
+ const { projectId } = useCurrentProject();
+ const {
+ startDate,
+ endDate,
+ setStartDate,
+ setEndDate,
+ timeframe,
+ setTimeframe,
+ } = useTimeframeSelector();
+ const [isCustomTimeframePopoverOpen, setIsCustomTimeframePopoverOpen] =
+ useState(false);
+
+ const handleCustomDateApply = (dates: {
+ startDate: string;
+ endDate: string;
+ }) => {
+ setTimeframe(Timeframe.Custom);
+ setStartDate(dates.startDate);
+ setEndDate(dates.endDate);
+ trackEvent("project_dashboard_custom_date_applied", { projectId });
+ setIsCustomTimeframePopoverOpen(false);
+ };
+
+ const handlePopoverOpenChange = (isOpen: boolean) => {
+ if (isOpen === true) {
+ trackEvent("project_dashboard_custom_date_popover_opened", { projectId });
+ }
+
+ setIsCustomTimeframePopoverOpen(isOpen);
+ };
+
+ const handleSetTimeframe = (tf: Timeframe) => {
+ setTimeframe(tf);
+ trackEvent("project_dashboard_timeframe_changed", {
+ projectId,
+ timeframe: tf,
+ });
+ };
+
+ return (
+
+
+
+
+
+ Custom
+
+
+
+
+
+
+ {Object.values(Timeframe)
+ .filter((tf) => tf !== Timeframe.Custom)
+ .map((tf) => (
+ handleSetTimeframe(tf)}
+ className={clsx(
+ "relative -ml-px inline-flex items-center rounded-none border px-3 py-2 text-sm first:rounded-l-md last:rounded-r-md focus:z-10",
+ {
+ "bg-primary text-primary-foreground hover:bg-primary/90 hover:text-primary-foreground":
+ tf === timeframe,
+ }
+ )}
+ >
+ {tf}
+
+ ))}
+
+ );
+};
diff --git a/apps/console/src/components/organizations/InviteOrgMemberModal.tsx b/apps/console/src/components/organizations/InviteOrgMemberModal.tsx
new file mode 100644
index 000000000..01d7909fb
--- /dev/null
+++ b/apps/console/src/components/organizations/InviteOrgMemberModal.tsx
@@ -0,0 +1,119 @@
+import { useCreateOrgInvitationMutation } from "~/graphql/hooks/mutations";
+import { useCurrentOrganization } from "~/lib/hooks/useCurrentOrganization";
+import { useEffect } from "react";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ Button,
+ Form,
+ FormItem,
+ FormControl,
+ Input,
+ FormField,
+ FormMessage,
+ FormLabel,
+ Alert,
+ AlertTitle,
+ AlertDescription,
+ toast,
+} from "@pezzo/ui";
+import { AlertCircle } from "lucide-react";
+import z from "zod";
+import { zodResolver } from "@hookform/resolvers/zod";
+import { useForm } from "react-hook-form";
+
+interface Props {
+ open: boolean;
+ onClose: () => void;
+}
+
+const formSchema = z.object({
+ inviteeEmail: z
+ .string()
+ .email("Must be a valid email")
+ .max(100, "Email can't be longer than 100 characters"),
+});
+
+export const InviteOrgMemberModal = ({ open, onClose }: Props) => {
+ const { organization } = useCurrentOrganization({
+ includeMembers: true,
+ includeInvitations: false,
+ });
+ const form = useForm>({
+ resolver: zodResolver(formSchema),
+ defaultValues: {
+ inviteeEmail: "",
+ },
+ });
+ const { mutateAsync: createInvitation, error } =
+ useCreateOrgInvitationMutation();
+
+ useEffect(() => {
+ form.reset();
+ }, [open, form]);
+
+ const onSubmit = (values: z.infer) => {
+ const { inviteeEmail } = values;
+
+ createInvitation(
+ { email: inviteeEmail, organizationId: organization.id },
+ {
+ onSuccess: () => {
+ onClose();
+ toast({
+ title: "Invitation sent",
+ description: `An invitation has been sent to ${inviteeEmail}`,
+ });
+ },
+ }
+ );
+ };
+
+ return (
+
+
+
+
+
+
+ );
+};
diff --git a/apps/console/src/components/organizations/OrgInvitationsList.tsx b/apps/console/src/components/organizations/OrgInvitationsList.tsx
new file mode 100644
index 000000000..f47d62f92
--- /dev/null
+++ b/apps/console/src/components/organizations/OrgInvitationsList.tsx
@@ -0,0 +1,113 @@
+import { Button, Card, toast } from "@pezzo/ui";
+import { GetOrgQuery, OrgRole } from "~/@generated/graphql/graphql";
+import { OrgRoleSelector } from "./OrgRoleSelector";
+import { useCopyToClipboard } from "usehooks-ts";
+import { useState } from "react";
+import {
+ useDeleteOrgInvitationMutation,
+ useUpdateOrgInvitationMutation,
+} from "~/graphql/hooks/mutations";
+import { useCurrentOrgMembership } from "~/lib/hooks/useCurrentOrgMembership";
+import { CheckIcon, CopyIcon, TrashIcon } from "lucide-react";
+import { GenericDestructiveConfirmationModal } from "../common/GenericDestructiveConfirmationModal";
+
+type Invitation = GetOrgQuery["organization"]["invitations"][0];
+
+interface Props {
+ invitations: Invitation[];
+}
+
+const CopyInvitationButton = ({ invitationId }: { invitationId: string }) => {
+ const [copiedValue, copy] = useCopyToClipboard();
+
+ const url = new URL(window.location.origin);
+ url.pathname = `/invitations/${invitationId}/accept`;
+
+ return (
+ copy(url.toString())}>
+ {copiedValue ? (
+ <>
+
+ Copied!
+ >
+ ) : (
+ <>
+
+ Copy Link
+ >
+ )}
+
+ );
+};
+
+export const OrgInvitationsList = ({ invitations }: Props) => {
+ const { isOrgAdmin } = useCurrentOrgMembership();
+ const { mutate: deleteOrgInvitation, error: updateOrgInvitationError } =
+ useDeleteOrgInvitationMutation();
+ const { mutate: updateOrgInvitation } = useUpdateOrgInvitationMutation();
+ const [deletingInvitation, setDeletingInvitation] =
+ useState(null);
+
+ const handleDeleteInvitation = async (invitation: Invitation) => {
+ deleteOrgInvitation(
+ { id: invitation.id },
+ {
+ onSuccess: () => {
+ setDeletingInvitation(null);
+ toast({
+ title: "Invitation deleted",
+ description: `The invitation for ${invitation.email} has been deleted.`,
+ });
+ },
+ }
+ );
+ };
+
+ const handleRoleChange = (invitation: Invitation, role: OrgRole) => {
+ updateOrgInvitation({ invitationId: invitation.id, role });
+ };
+
+ return (
+ <>
+ handleDeleteInvitation(deletingInvitation)}
+ onCancel={() => setDeletingInvitation(null)}
+ />
+
+ {invitations
+ .sort((a, b) => a.email.localeCompare(b.email))
+ .map((invitation) => (
+
+
+
+
+
+ handleRoleChange(invitation, newRole)}
+ showArrow={isOrgAdmin}
+ />
+
+ setDeletingInvitation(invitation)}
+ size="icon"
+ variant="destructiveOutline"
+ >
+
+
+
+ ))}
+ >
+ );
+};
diff --git a/apps/console/src/components/organizations/OrgMembersList.tsx b/apps/console/src/components/organizations/OrgMembersList.tsx
new file mode 100644
index 000000000..b99392f90
--- /dev/null
+++ b/apps/console/src/components/organizations/OrgMembersList.tsx
@@ -0,0 +1,92 @@
+import { GetOrgQuery, OrgRole } from "~/@generated/graphql/graphql";
+import { Avatar } from "../common/Avatar";
+import { OrgRoleSelector } from "./OrgRoleSelector";
+import { useState } from "react";
+import {
+ useDeleteOrgMemberMutation,
+ useUpdateOrgMemberRoleMutation,
+} from "~/graphql/hooks/mutations";
+import { useAuthContext } from "~/lib/providers/AuthProvider";
+import { useCurrentOrgMembership } from "~/lib/hooks/useCurrentOrgMembership";
+import { Button, Card, toast } from "@pezzo/ui";
+import { TrashIcon } from "lucide-react";
+import { GenericDestructiveConfirmationModal } from "../common/GenericDestructiveConfirmationModal";
+
+type Member = GetOrgQuery["organization"]["members"][0];
+
+interface Props {
+ members: Member[];
+}
+
+export const OrgMembersList = ({ members }: Props) => {
+ const { isOrgAdmin } = useCurrentOrgMembership();
+ const { currentUser } = useAuthContext();
+ const { mutate: deleteOrgMember, error: deleteOrgMemberError } =
+ useDeleteOrgMemberMutation();
+ const { mutate: updateOrgMemberRole } = useUpdateOrgMemberRoleMutation();
+ const [deletingMember, setDeletingMember] = useState(null);
+
+ const handleDeleteMember = async (member: Member) => {
+ deleteOrgMember(
+ { id: member.id },
+ {
+ onSuccess: () => {
+ setDeletingMember(null);
+ toast({
+ title: "Member removed",
+ description: `${member.user.name} has been removed from your organization.`,
+ });
+ },
+ }
+ );
+ };
+
+ const handleRoleChange = async (member: Member, role: OrgRole) => {
+ updateOrgMemberRole({ id: member.id, role: role });
+ };
+
+ return (
+ <>
+ handleDeleteMember(deletingMember)}
+ onCancel={() => setDeletingMember(null)}
+ />
+
+ {members.map((member) => (
+
+
+
+
+ {member.user.name} {member.user.id === currentUser.id && " (You)"}
+
+
{member.user.email}
+
+
+ handleRoleChange(member, newRole)}
+ showArrow={isOrgAdmin}
+ />
+
+ setDeletingMember(member)}
+ size="icon"
+ variant="destructiveOutline"
+ disabled={member.user.id === currentUser.id}
+ >
+
+
+
+ ))}
+ >
+ );
+};
diff --git a/apps/console/src/components/organizations/OrgRoleSelector.tsx b/apps/console/src/components/organizations/OrgRoleSelector.tsx
new file mode 100644
index 000000000..a58575e0f
--- /dev/null
+++ b/apps/console/src/components/organizations/OrgRoleSelector.tsx
@@ -0,0 +1,33 @@
+import { OrgRole } from "~/@generated/graphql/graphql";
+import {
+ Select,
+ SelectTrigger,
+ SelectContent,
+ SelectValue,
+ SelectItem,
+} from "@pezzo/ui";
+
+interface Props {
+ value?: OrgRole;
+ onChange: (role: OrgRole) => void;
+ disabled?: boolean;
+ showArrow?: boolean;
+}
+
+export const OrgRoleSelector = ({
+ value = OrgRole.Member,
+ onChange,
+ disabled,
+}: Props) => {
+ return (
+
+
+
+
+
+ Member
+ Admin
+
+
+ );
+};
diff --git a/apps/console/src/components/projects/CreateNewProjectModal.tsx b/apps/console/src/components/projects/CreateNewProjectModal.tsx
new file mode 100644
index 000000000..676a7c07e
--- /dev/null
+++ b/apps/console/src/components/projects/CreateNewProjectModal.tsx
@@ -0,0 +1,126 @@
+import { useCreateProjectMutation } from "~/graphql/hooks/mutations";
+import { useCurrentOrganization } from "~/lib/hooks/useCurrentOrganization";
+import { trackEvent } from "~/lib/utils/analytics";
+import { useNavigate } from "react-router-dom";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ Button,
+ Form,
+ FormItem,
+ FormControl,
+ Input,
+ FormField,
+ FormMessage,
+ FormLabel,
+ Alert,
+ AlertTitle,
+ AlertDescription,
+} from "@pezzo/ui";
+import { AlertCircle } from "lucide-react";
+import z from "zod";
+import { zodResolver } from "@hookform/resolvers/zod";
+import { useForm } from "react-hook-form";
+
+const formSchema = z.object({
+ projectName: z
+ .string()
+ .min(1, "Name must be at least 1 character long")
+ .max(100, "Name can't be longer than 100 characters")
+ .regex(
+ /^[a-zA-Z]+(?:[ ]+[a-zA-Z]+)*$/,
+ "Name can only contain letters and spaces, e.g. My Project"
+ ),
+});
+
+interface Props {
+ open: boolean;
+ onClose: () => void;
+}
+
+export const CreateNewProjectModal = ({ open, onClose }: Props) => {
+ const form = useForm>({
+ resolver: zodResolver(formSchema),
+ defaultValues: {
+ projectName: "",
+ },
+ });
+ const { organizationId } = useCurrentOrganization();
+ const navigate = useNavigate();
+ const { mutateAsync: createProject, error } = useCreateProjectMutation({
+ onSuccess: () => {
+ form.reset();
+ onClose();
+ },
+ });
+
+ const onSubmit = (values: z.infer) => {
+ createProject(
+ {
+ name: values.projectName,
+ organizationId,
+ },
+ {
+ onSuccess: (data) => {
+ onClose();
+ navigate(`/projects/${data.createProject.id}`);
+ },
+ }
+ );
+
+ trackEvent("project_form_submitted");
+ };
+
+ const onCancel = () => {
+ onClose();
+ trackEvent("project_form_cancelled");
+ };
+
+ return (
+
+
+
+
+
+
+ );
+};
diff --git a/apps/console/src/components/projects/ProjectCopy.tsx b/apps/console/src/components/projects/ProjectCopy.tsx
new file mode 100644
index 000000000..eaddbfb7c
--- /dev/null
+++ b/apps/console/src/components/projects/ProjectCopy.tsx
@@ -0,0 +1,41 @@
+import { Button } from "@pezzo/ui";
+import { useCurrentProject } from "~/lib/hooks/useCurrentProject";
+import { copyToClipboard } from "~/lib/utils/browser-utils";
+import { useState } from "react";
+import { trackEvent } from "~/lib/utils/analytics";
+import { CheckIcon, CopyIcon } from "lucide-react";
+
+export const ProjectCopy = () => {
+ const { project, isLoading } = useCurrentProject();
+ const [clicked, setClicked] = useState(false);
+
+ if (isLoading) return null;
+
+ return (
+ {
+ copyToClipboard(project.id);
+ setClicked(true);
+ trackEvent("project_id_copied", { projectId: project.id });
+
+ setTimeout(() => {
+ setClicked(false);
+ }, 3000);
+ }}
+ >
+ {clicked ? (
+ <>
+
+ Copied!
+ >
+ ) : (
+ <>
+
+ Copy Project ID
+ >
+ )}
+
+ );
+};
diff --git a/apps/console/src/components/projects/RenameProjectModal.tsx b/apps/console/src/components/projects/RenameProjectModal.tsx
new file mode 100644
index 000000000..b28ce00ba
--- /dev/null
+++ b/apps/console/src/components/projects/RenameProjectModal.tsx
@@ -0,0 +1,122 @@
+import { GetProjectsQuery } from "~/@generated/graphql/graphql";
+import { useUpdateProjectSettingsMutation } from "~/graphql/hooks/mutations";
+import { trackEvent } from "~/lib/utils/analytics";
+import { useEffect } from "react";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ Button,
+ Form,
+ FormItem,
+ FormControl,
+ Input,
+ FormField,
+ FormMessage,
+ FormLabel,
+ Alert,
+ AlertTitle,
+ AlertDescription,
+} from "@pezzo/ui";
+import { AlertCircle } from "lucide-react";
+import z from "zod";
+import { zodResolver } from "@hookform/resolvers/zod";
+import { useForm } from "react-hook-form";
+
+const formSchema = z.object({
+ projectName: z
+ .string()
+ .min(1, "Name must be at least 1 character long")
+ .max(100, "Name can't be longer than 100 characters")
+ .regex(
+ /^[a-zA-Z]+(?:[ ]+[a-zA-Z]+)*$/,
+ "Name can only contain letters and spaces, e.g. My Project"
+ ),
+});
+
+interface Props {
+ projectToRename: GetProjectsQuery["projects"][0] | null;
+ onClose: () => void;
+}
+
+export const RenameProjectModal = ({ projectToRename, onClose }: Props) => {
+ const form = useForm>({
+ resolver: zodResolver(formSchema),
+ defaultValues: {
+ projectName: "",
+ },
+ });
+ const { mutate: updateProjectSettings, error } =
+ useUpdateProjectSettingsMutation();
+
+ useEffect(() => {
+ form.reset();
+ }, [projectToRename, form]);
+
+ const onSubmit = (values: z.infer) => {
+ updateProjectSettings(
+ { projectId: projectToRename.id, name: values.projectName },
+ {
+ onSuccess: () => {
+ trackEvent("project_rename_submitted");
+ onClose();
+ },
+ }
+ );
+ };
+
+ const onCancel = () => {
+ trackEvent("project_rename_cancelled");
+ onClose();
+ };
+
+ return (
+
+
+
+
+
+
+ );
+};
diff --git a/apps/console/src/components/prompts/CommitPromptModal.tsx b/apps/console/src/components/prompts/CommitPromptModal.tsx
new file mode 100644
index 000000000..b8bc49cbc
--- /dev/null
+++ b/apps/console/src/components/prompts/CommitPromptModal.tsx
@@ -0,0 +1,136 @@
+import { useCurrentPrompt } from "~/lib/providers/CurrentPromptContext";
+import { useCreatePromptVersion } from "~/graphql/hooks/mutations";
+import { trackEvent } from "~/lib/utils/analytics";
+import { useEditorContext } from "~/lib/providers/EditorContext";
+import {
+ Alert,
+ AlertDescription,
+ AlertTitle,
+ Button,
+ Dialog,
+ DialogContent,
+ DialogFooter,
+ DialogHeader,
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+ Input,
+ useToast,
+} from "@pezzo/ui";
+import { AlertCircle } from "lucide-react";
+import { z } from "zod";
+import { useForm } from "react-hook-form";
+import { zodResolver } from "@hookform/resolvers/zod";
+
+interface Props {
+ open: boolean;
+ onClose: () => void;
+ onCommitted: () => void;
+}
+
+const formSchema = z.object({
+ message: z
+ .string()
+ .min(1, "Message must be at least 1 character long")
+ .max(120, "Message can't be longer than 64 characters"),
+});
+
+export const CommitPromptModal = ({ open, onClose, onCommitted }: Props) => {
+ const form = useForm>({
+ resolver: zodResolver(formSchema),
+ defaultValues: {
+ message: "",
+ },
+ });
+ const { prompt } = useCurrentPrompt();
+ const { getForm: getEditorForm } = useEditorContext();
+ const editorForm = getEditorForm();
+ const { toast } = useToast();
+
+ const [settings, content, service, type] = editorForm.watch([
+ "settings",
+ "content",
+ "service",
+ "type",
+ ]);
+
+ const {
+ mutate: createPromptVersion,
+ error,
+ isLoading,
+ } = useCreatePromptVersion();
+
+ const handleFormFinish = async (values: z.infer) => {
+ const data = {
+ type,
+ message: values.message,
+ service: service,
+ content,
+ settings: settings || {},
+ promptId: prompt.id,
+ };
+
+ createPromptVersion(data, {
+ onSuccess: () => {
+ form.reset();
+ onCommitted();
+ trackEvent("prompt_commit_submitted");
+ toast({
+ title: "Changes committed!",
+ description: `Your commit has been created successfully.`,
+ });
+ },
+ });
+ };
+
+ const handleCancel = () => {
+ form.reset();
+ onClose();
+ trackEvent("prompt_commit_cancelled");
+ };
+
+ return (
+
+
+
+
+
+
+ );
+};
diff --git a/apps/console/src/components/prompts/ConsumePromptModal.tsx b/apps/console/src/components/prompts/ConsumePromptModal.tsx
new file mode 100644
index 000000000..d6c4cd6eb
--- /dev/null
+++ b/apps/console/src/components/prompts/ConsumePromptModal.tsx
@@ -0,0 +1,59 @@
+import { TypeScriptOpenAIIntegrationTutorial } from "../getting-started-wizard/TypeScriptOpenAIIntegrationTutorial";
+import { useState } from "react";
+import { PythonOpenAIIntegrationTutorial } from "../getting-started-wizard/PythonOpenAIIntegrationTutorial";
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@pezzo/ui";
+
+interface Props {
+ open: boolean;
+ onClose: () => void;
+}
+
+export const ConsumePromptModal = ({ open, onClose }: Props) => {
+ const [integration, setIntegration] = useState("typescript");
+
+ const renderInsructions = () => {
+ switch (integration) {
+ case "typescript":
+ return ;
+ case "python":
+ return ;
+ }
+ };
+
+ return (
+
+
+
+
+
+
How to consume
+
+
+
+
+
+
+
+ TypeScript
+ Python
+
+
+
+
+
+
+ {renderInsructions()}
+
+
+ );
+};
diff --git a/apps/console/src/components/prompts/CreatePromptModal.tsx b/apps/console/src/components/prompts/CreatePromptModal.tsx
new file mode 100644
index 000000000..0a99ffa42
--- /dev/null
+++ b/apps/console/src/components/prompts/CreatePromptModal.tsx
@@ -0,0 +1,137 @@
+import { useMutation } from "@tanstack/react-query";
+import { CREATE_PROMPT } from "~/graphql/definitions/mutations/prompts";
+import { gqlClient, queryClient } from "~/lib/graphql";
+import { CreatePromptMutation } from "~/@generated/graphql/graphql";
+import { GraphQLErrorResponse } from "~/graphql/types";
+import { useCurrentProject } from "~/lib/hooks/useCurrentProject";
+import { trackEvent } from "~/lib/utils/analytics";
+import z from "zod";
+import { zodResolver } from "@hookform/resolvers/zod";
+import { useForm } from "react-hook-form";
+import { AlertCircle } from "lucide-react";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ Button,
+ Form,
+ FormItem,
+ FormControl,
+ Input,
+ FormField,
+ FormMessage,
+ FormLabel,
+ Alert,
+ AlertTitle,
+ AlertDescription,
+} from "@pezzo/ui";
+import { useNavigate } from "react-router-dom";
+
+interface Props {
+ open: boolean;
+ onClose: () => void;
+ onCreated: (id: string) => void;
+}
+
+const formSchema = z.object({
+ promptName: z
+ .string()
+ .min(1, "Name must be at least 1 character long")
+ .max(100, "Name can't be longer than 64 characters")
+ .regex(
+ /^[a-zA-Z0-9]+$/,
+ "Name can only contain letters and numbers, e.g. SentimentAnalysis"
+ ),
+});
+
+export const CreatePromptModal = ({ open, onClose, onCreated }: Props) => {
+ const { project } = useCurrentProject();
+ const navigate = useNavigate();
+ const form = useForm>({
+ resolver: zodResolver(formSchema),
+ defaultValues: {
+ promptName: "",
+ },
+ });
+
+ const { mutate, error } = useMutation<
+ CreatePromptMutation,
+ GraphQLErrorResponse,
+ z.infer
+ >({
+ mutationFn: (data) =>
+ gqlClient.request(CREATE_PROMPT, {
+ data: {
+ name: data.promptName,
+ projectId: project.id,
+ },
+ }),
+ onSuccess: (data) => {
+ onCreated(data.createPrompt.id);
+ queryClient.invalidateQueries({ queryKey: ["prompts"] });
+ trackEvent("prompt_created", {
+ promptId: data.createPrompt.id,
+ });
+ navigate(`/projects/${project.id}/prompts/${data.createPrompt.id}`);
+ },
+ });
+
+ const onSubmit = (data: z.infer) => {
+ mutate(data);
+ form.reset();
+ trackEvent("prompt_form_submitted");
+ };
+
+ const onCancel = () => {
+ trackEvent("prompt_form_cancelled");
+ onClose();
+ };
+
+ return (
+
+
+
+
+
+
+ );
+};
diff --git a/apps/console/src/components/prompts/PromptSettingsSlider.tsx b/apps/console/src/components/prompts/PromptSettingsSlider.tsx
new file mode 100644
index 000000000..0307c960f
--- /dev/null
+++ b/apps/console/src/components/prompts/PromptSettingsSlider.tsx
@@ -0,0 +1,43 @@
+import { Input, Slider } from "@pezzo/ui";
+import { ControllerRenderProps, FieldValues } from "react-hook-form";
+
+interface Props {
+ min: number;
+ max: number;
+ step: number;
+ value?: number;
+ onChange?: (value: number) => void;
+ field: ControllerRenderProps;
+}
+
+export const PromptSettingsSlider = ({
+ min,
+ max,
+ step,
+ value,
+ onChange,
+ field,
+}: Props) => {
+ return (
+
+ field.onChange(value[0])}
+ value={[field.value]}
+ min={min}
+ max={max}
+ step={step}
+ />
+ field.onChange(e.target.valueAsNumber)}
+ size="sm"
+ className="w-20"
+ />
+
+ );
+};
diff --git a/apps/console/src/components/prompts/PromptVersionSelector.tsx b/apps/console/src/components/prompts/PromptVersionSelector.tsx
new file mode 100644
index 000000000..484637852
--- /dev/null
+++ b/apps/console/src/components/prompts/PromptVersionSelector.tsx
@@ -0,0 +1,80 @@
+import {
+ Select,
+ SelectTrigger,
+ SelectContent,
+ SelectItem,
+ SelectValue,
+} from "@pezzo/ui";
+import { useCurrentPrompt } from "~/lib/providers/CurrentPromptContext";
+import { usePromptVersions } from "~/lib/hooks/usePromptVersions";
+import { trackEvent } from "~/lib/utils/analytics";
+import { useEditorContext } from "~/lib/providers/EditorContext";
+
+export const PromptVersionSelector = () => {
+ const { prompt } = useCurrentPrompt();
+ const latestVersion = prompt.latestVersion;
+ const { promptVersions } = usePromptVersions(prompt.id);
+ const { currentVersionSha, setCurrentVersionSha } = useEditorContext();
+
+ const itemsFromVersionsList =
+ (promptVersions &&
+ promptVersions
+ .filter((version) => version.sha !== latestVersion.sha)
+ .map((version) => ({
+ key: version.sha,
+ label: `${version.sha.slice(0, 7)} - ${version.message} (by ${
+ version.createdBy.name
+ })`,
+ onClick: () => {
+ alert(version.sha);
+ setCurrentVersionSha(version.sha);
+ trackEvent("prompt_version_selected");
+ },
+ }))) ||
+ [];
+
+ const menuItems = [
+ {
+ key: latestVersion.sha,
+ label: `Latest (${latestVersion.sha.slice(0, 7)}) - ${
+ latestVersion.message
+ } (by ${latestVersion.createdBy.name}))`,
+ sha: latestVersion.sha,
+ },
+ ...itemsFromVersionsList,
+ ];
+
+ const handleVersionChange = (versionSha: string) => {
+ setCurrentVersionSha(versionSha);
+ trackEvent("prompt_version_selected", {
+ version: versionSha,
+ });
+ };
+
+ const isLatest = currentVersionSha === latestVersion.sha;
+
+ const selectedVersionLabel = isLatest
+ ? `Latest (${latestVersion.sha.slice(0, 7)})`
+ : `${currentVersionSha.slice(0, 7)}`;
+
+ return (
+
+
+
+ {selectedVersionLabel}
+
+
+ {menuItems.map((item) => (
+
+ {item.label}
+
+ ))}
+
+
+
+ );
+};
diff --git a/apps/console/src/components/prompts/PublishPromptModal.tsx b/apps/console/src/components/prompts/PublishPromptModal.tsx
new file mode 100644
index 000000000..2a6091d61
--- /dev/null
+++ b/apps/console/src/components/prompts/PublishPromptModal.tsx
@@ -0,0 +1,138 @@
+import { useEnvironments } from "~/lib/hooks/useEnvironments";
+import { useCurrentPrompt } from "~/lib/providers/CurrentPromptContext";
+import { useEffect, useState } from "react";
+import { useMutation } from "@tanstack/react-query";
+import { gqlClient, queryClient } from "~/lib/graphql";
+import { PUBLISH_PROMPT } from "~/graphql/definitions/mutations/prompt-environments";
+import {
+ PublishPromptInput,
+ PublishPromptMutation,
+} from "~/@generated/graphql/graphql";
+import { trackEvent } from "~/lib/utils/analytics";
+import { useEditorContext } from "~/lib/providers/EditorContext";
+import {
+ Alert,
+ AlertDescription,
+ AlertTitle,
+ Button,
+ Card,
+ Dialog,
+ DialogContent,
+ DialogFooter,
+ DialogHeader,
+ useToast,
+} from "@pezzo/ui";
+import { AlertCircle, CheckSquare, Square } from "lucide-react";
+import { GraphQLErrorResponse } from "~/graphql/types";
+
+interface Props {
+ open: boolean;
+ onClose: () => void;
+}
+
+export const PublishPromptModal = ({ open, onClose }: Props) => {
+ const { currentVersionSha } = useEditorContext();
+ const { prompt } = useCurrentPrompt();
+ const { environments } = useEnvironments();
+ const [selectedEnvironmentId, setSelectedEnvironmentId] =
+ useState(undefined);
+ const { toast } = useToast();
+
+ const {
+ mutate: publishPrompt,
+ error,
+ isLoading,
+ } = useMutation<
+ PublishPromptMutation,
+ GraphQLErrorResponse,
+ PublishPromptInput
+ >({
+ mutationFn: (data: PublishPromptInput) =>
+ gqlClient.request(PUBLISH_PROMPT, { data }),
+ mutationKey: ["publishPrompt", prompt.id, currentVersionSha],
+ onSuccess: () => {
+ queryClient.invalidateQueries(["promptEnvironments"]);
+ },
+ });
+
+ const handlePublish = async () => {
+ publishPrompt(
+ {
+ promptId: prompt.id,
+ environmentId: selectedEnvironmentId,
+ promptVersionSha: currentVersionSha,
+ },
+ {
+ onSuccess: () => {
+ toast({
+ title: "Prompt published!",
+ description: `Your prompt has been published successfully.`,
+ });
+ onClose();
+ },
+ }
+ );
+ trackEvent("prompt_publish_clicked");
+ };
+
+ useEffect(() => {
+ setSelectedEnvironmentId(undefined);
+ }, [open]);
+
+ const handleEnvironmentClick = (environmentId: string) => {
+ setSelectedEnvironmentId(environmentId);
+ };
+
+ return (
+ environments && (
+
+ onClose()}>
+ Publish Prompt - {prompt.name}
+
+ {error && (
+
+
+ Oops!
+
+ {error.response.errors[0].message}
+
+
+ )}
+
+
+ Select the environment to publish this version to.
+
+
+
+
+ {environments.map((environment) => (
+
handleEnvironmentClick(environment.id)}
+ className="flex cursor-pointer items-center justify-between border border-card p-4 hover:border-primary"
+ >
+ {environment.name}
+
+ {selectedEnvironmentId === environment.id && (
+
+ )}
+ {selectedEnvironmentId !== environment.id && (
+
+ )}
+
+
+ ))}
+
+
+
+ Publish
+
+
+
+
+ )
+ );
+};
diff --git a/apps/console/src/components/prompts/editor/ProviderSelector/ProviderSelector.tsx b/apps/console/src/components/prompts/editor/ProviderSelector/ProviderSelector.tsx
new file mode 100644
index 000000000..21246c40c
--- /dev/null
+++ b/apps/console/src/components/prompts/editor/ProviderSelector/ProviderSelector.tsx
@@ -0,0 +1,62 @@
+import { sortRenderedProviders } from "./providers";
+import { ProviderProps } from "./types";
+import { PromptService } from "~/@generated/graphql/graphql";
+import { useEditorContext } from "~/lib/providers/EditorContext";
+import {
+ FormControl,
+ SelectContent,
+ FormField,
+ FormItem,
+ Select,
+ SelectTrigger,
+ SelectValue,
+ SelectItem,
+} from "@pezzo/ui";
+
+export const ProviderSelector = () => {
+ const { getForm } = useEditorContext();
+ const form = getForm();
+
+ const settings = form.watch("settings");
+ const providers = sortRenderedProviders(
+ Object.keys(settings) as PromptService[]
+ );
+
+ const renderProvider = (provider: ProviderProps) => {
+ const isAvailable = provider.value === PromptService.OpenAiChatCompletion;
+
+ return (
+
+
+
{provider.image}
+
{provider.label}
+
+
+ );
+ };
+
+ return (
+ (
+
+
+
+
+
+
+
+ {providers.map((provider) => renderProvider(provider))}
+
+
+
+
+ )}
+ />
+ );
+};
diff --git a/apps/console/src/components/prompts/editor/ProviderSelector/providers.tsx b/apps/console/src/components/prompts/editor/ProviderSelector/providers.tsx
new file mode 100644
index 000000000..439564330
--- /dev/null
+++ b/apps/console/src/components/prompts/editor/ProviderSelector/providers.tsx
@@ -0,0 +1,55 @@
+import { ProviderProps } from "./types";
+import { promptProvidersMapping } from "@pezzo/types";
+import { PromptService } from "~/@generated/graphql/graphql";
+
+// Logos
+import OpenAILogo from "~/assets/providers/openai-logo.png";
+import AzureOpenAILogo from "~/assets/providers/azure-logo.png";
+import AnthropicLogo from "~/assets/providers/anthropic-logo.png";
+
+export const providersList: ProviderProps[] = [
+ {
+ image: ,
+ value: PromptService.OpenAiChatCompletion,
+ label: promptProvidersMapping[PromptService.OpenAiChatCompletion].name,
+ },
+ {
+ image: (
+
+ ),
+ value: PromptService.AzureOpenAiChatCompletion,
+ label: promptProvidersMapping[PromptService.AzureOpenAiChatCompletion].name,
+ },
+ {
+ image: (
+
+ ),
+ value: PromptService.AnthropicCompletion,
+ label: promptProvidersMapping[PromptService.AnthropicCompletion].name,
+ },
+];
+
+/**
+ * This function sorts the providers list for correct rendering in the UI.
+ * It divides them into two groups - managed and unmanaged.
+ * Within those groups, they will be provided in the original order as displayed in the "providersList" array.
+ *
+ * @param providersKeys Array of provider keys that are managed
+ * @returns
+ */
+export const sortRenderedProviders = (providersKeys: PromptService[]) => {
+ const managed = providersList.filter((provider) =>
+ providersKeys.includes(provider.value)
+ );
+ const unmanaged = providersList.filter(
+ (provider) => !providersKeys.includes(provider.value)
+ );
+ const sort = (a, b) => providersList.indexOf(a) - providersList.indexOf(b);
+ const sortedManaged = managed.sort(sort);
+ const sortedUnmanaged = unmanaged.sort(sort);
+ return [...sortedManaged, ...sortedUnmanaged];
+};
diff --git a/apps/console/src/components/prompts/editor/ProviderSelector/types.ts b/apps/console/src/components/prompts/editor/ProviderSelector/types.ts
new file mode 100644
index 000000000..cfb14b91c
--- /dev/null
+++ b/apps/console/src/components/prompts/editor/ProviderSelector/types.ts
@@ -0,0 +1,7 @@
+import { PromptService } from "~/@generated/graphql/graphql";
+
+export interface ProviderProps {
+ image: React.ReactNode;
+ value: PromptService;
+ label: string;
+}
diff --git a/apps/console/src/components/prompts/editor/ProviderSettings/ProviderSettingsSchemaRenderer.tsx b/apps/console/src/components/prompts/editor/ProviderSettings/ProviderSettingsSchemaRenderer.tsx
new file mode 100644
index 000000000..9c14ce6ab
--- /dev/null
+++ b/apps/console/src/components/prompts/editor/ProviderSettings/ProviderSettingsSchemaRenderer.tsx
@@ -0,0 +1,89 @@
+import {
+ FormField,
+ FormControl,
+ FormItem,
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+ FormLabel,
+} from "@pezzo/ui";
+import { PromptSettingsSlider } from "../../PromptSettingsSlider";
+import { useEditorContext } from "~/lib/providers/EditorContext";
+import { generateFormSchema } from "./providers/openai-chat-completion";
+import { SelectFormField, SliderFormField } from "./types";
+import { ControllerRenderProps, FieldValues } from "react-hook-form";
+
+interface Props {
+ schema: ReturnType;
+}
+
+export const ProviderSettingsSchemaRenderer = ({ schema }: Props) => {
+ const { getForm } = useEditorContext();
+ const form = getForm();
+
+ const renderField = (
+ renderSchema: any,
+ field: ControllerRenderProps
+ ) => {
+ switch (renderSchema.type) {
+ case "select":
+ return renderSelectField(renderSchema, field);
+ case "slider":
+ return renderSliderField(renderSchema, field);
+ }
+ };
+
+ const renderSelectField = (
+ renderSchema: SelectFormField,
+ field: ControllerRenderProps
+ ) => {
+ return (
+
+
+
+
+
+ {renderSchema.options.map((option) => (
+
+ {option.label}
+
+ ))}
+
+
+ );
+ };
+
+ const renderSliderField = (
+ renderSchema: SliderFormField,
+ field: ControllerRenderProps
+ ) => {
+ return (
+
+ );
+ };
+
+ return (
+ <>
+ {schema.map((renderSchema, index) => (
+ (
+
+ {renderSchema.label}
+ {renderField(renderSchema, field)}
+
+ )}
+ />
+ ))}
+ >
+ );
+};
diff --git a/apps/console/src/components/prompts/editor/ProviderSettings/providers/azure-openai-chat-completion.ts b/apps/console/src/components/prompts/editor/ProviderSettings/providers/azure-openai-chat-completion.ts
new file mode 100644
index 000000000..3846866ad
--- /dev/null
+++ b/apps/console/src/components/prompts/editor/ProviderSettings/providers/azure-openai-chat-completion.ts
@@ -0,0 +1,85 @@
+import OpenAI from "openai";
+import { FormSchema, ProviderSettingsDefinition } from "../types";
+
+type OpenAIProviderSettings = Omit<
+ OpenAI.Chat.Completions.CompletionCreateParams,
+ "messages"
+>;
+
+const defaultSettings: OpenAIProviderSettings = {
+ model: "gpt-35-turbo",
+ temperature: 0.7,
+ max_tokens: 256,
+ top_p: 1,
+ frequency_penalty: 0,
+ presence_penalty: 0,
+};
+
+const generateFormSchema = (settings: OpenAIProviderSettings): FormSchema => {
+ const getMaxResponseTokensMaxValue = () => {
+ switch (settings.model) {
+ case "gpt-35-turbo":
+ return 8192;
+ case "gpt-4":
+ return 4096;
+ }
+ };
+
+ return [
+ {
+ label: "Model",
+ name: "model",
+ type: "select",
+ options: [
+ { value: "gpt-35-turbo", label: "gpt-35-turbo" },
+ { value: "gpt-4", label: "gpt-4" },
+ ],
+ },
+ {
+ label: "Temperature",
+ name: "temperature",
+ type: "slider",
+ min: 0,
+ max: 1,
+ step: 0.1,
+ },
+ {
+ label: "Max Response Length",
+ name: "max_tokens",
+ type: "slider",
+ min: 1,
+ max: getMaxResponseTokensMaxValue(),
+ step: 1,
+ },
+ {
+ label: "Top P",
+ name: "top_p",
+ type: "slider",
+ min: 0,
+ max: 1,
+ step: 0.1,
+ },
+ {
+ label: "Frequency Penalty",
+ name: "frequency_penalty",
+ type: "slider",
+ min: 0,
+ max: 1,
+ step: 0.1,
+ },
+ {
+ label: "Presence Penalty",
+ name: "presence_penalty",
+ type: "slider",
+ min: 0,
+ max: 1,
+ step: 0.1,
+ },
+ ];
+};
+
+export const azureOpenAIChatCompletionSettingsDefinition: ProviderSettingsDefinition =
+ {
+ defaultSettings,
+ generateFormSchema,
+ };
diff --git a/apps/console/src/components/prompts/editor/ProviderSettings/providers/index.ts b/apps/console/src/components/prompts/editor/ProviderSettings/providers/index.ts
new file mode 100644
index 000000000..fd763857f
--- /dev/null
+++ b/apps/console/src/components/prompts/editor/ProviderSettings/providers/index.ts
@@ -0,0 +1,14 @@
+import { PromptService } from "~/@generated/graphql/graphql";
+import { azureOpenAIChatCompletionSettingsDefinition } from "./azure-openai-chat-completion";
+import { openAIChatCompletionSettingsDefinition } from "./openai-chat-completion";
+
+const defaultSettingsMap = {
+ [PromptService.OpenAiChatCompletion]:
+ openAIChatCompletionSettingsDefinition.defaultSettings,
+ [PromptService.AzureOpenAiChatCompletion]:
+ azureOpenAIChatCompletionSettingsDefinition.defaultSettings,
+};
+
+export const getServiceDefaultSettings = (service: PromptService) => {
+ return defaultSettingsMap[service];
+};
diff --git a/apps/console/src/components/prompts/editor/ProviderSettings/providers/openai-chat-completion.ts b/apps/console/src/components/prompts/editor/ProviderSettings/providers/openai-chat-completion.ts
new file mode 100644
index 000000000..ef36eb4a3
--- /dev/null
+++ b/apps/console/src/components/prompts/editor/ProviderSettings/providers/openai-chat-completion.ts
@@ -0,0 +1,85 @@
+import OpenAI from "openai";
+import { FormSchema, ProviderSettingsDefinition } from "../types";
+import { OpenAIToolkit } from "@pezzo/llm-toolkit";
+
+const { gptModels } = OpenAIToolkit;
+
+type OpenAIProviderSettings = Omit<
+ OpenAI.Chat.Completions.CompletionCreateParams,
+ "messages"
+>;
+
+const defaultSettings: OpenAIProviderSettings = {
+ model: "gpt-3.5-turbo",
+ temperature: 0.7,
+ max_tokens: 256,
+ top_p: 1,
+ frequency_penalty: 0,
+ presence_penalty: 0,
+};
+
+export const generateFormSchema = (
+ settings: OpenAIProviderSettings
+): FormSchema => {
+ const options = Object.keys(gptModels).map((model) => ({
+ value: model,
+ label: model,
+ }));
+
+ const maxResponseTokensValue = gptModels[settings.model].maxTokens;
+
+ return [
+ {
+ label: "Model",
+ name: "model",
+ type: "select",
+ options,
+ },
+ {
+ label: "Temperature",
+ name: "temperature",
+ type: "slider",
+ min: 0,
+ max: 1,
+ step: 0.1,
+ },
+ {
+ label: "Max Response Length",
+ name: "max_tokens",
+ type: "slider",
+ min: 1,
+ max: maxResponseTokensValue,
+ step: 1,
+ },
+ {
+ label: "Top P",
+ name: "top_p",
+ type: "slider",
+ min: 0,
+ max: 1,
+ step: 0.1,
+ },
+ {
+ label: "Frequency Penalty",
+ name: "frequency_penalty",
+ type: "slider",
+ min: 0,
+ max: 1,
+ step: 0.1,
+ },
+ {
+ label: "Presence Penalty",
+ name: "presence_penalty",
+ type: "slider",
+ min: 0,
+ max: 1,
+ step: 0.1,
+ },
+ ];
+};
+
+export const openAIChatCompletionSettingsDefinition: ProviderSettingsDefinition =
+ {
+ defaultSettings,
+ generateFormSchema,
+ };
diff --git a/apps/console/src/components/prompts/editor/ProviderSettings/types.ts b/apps/console/src/components/prompts/editor/ProviderSettings/types.ts
new file mode 100644
index 000000000..f7082ca14
--- /dev/null
+++ b/apps/console/src/components/prompts/editor/ProviderSettings/types.ts
@@ -0,0 +1,28 @@
+interface BaseFormField {
+ label: string;
+ name: string;
+}
+
+export interface SelectFormField extends BaseFormField {
+ type: "select";
+ options: {
+ value: string;
+ label: string;
+ }[];
+}
+
+export interface SliderFormField extends BaseFormField {
+ type: "slider";
+ min: number;
+ max: number;
+ step: number;
+}
+
+export type FormField = SelectFormField | SliderFormField;
+
+export type FormSchema = FormField[];
+
+export interface ProviderSettingsDefinition {
+ defaultSettings: TSettings;
+ generateFormSchema: (settings: TSettings) => FormSchema;
+}
diff --git a/apps/console/src/components/prompts/editor/chat/ChatEditMode.tsx b/apps/console/src/components/prompts/editor/chat/ChatEditMode.tsx
new file mode 100644
index 000000000..c34f493cc
--- /dev/null
+++ b/apps/console/src/components/prompts/editor/chat/ChatEditMode.tsx
@@ -0,0 +1,82 @@
+import { ChatMessage } from "./ChatMessage";
+import { trackEvent } from "~/lib/utils/analytics";
+import { useCurrentPrompt } from "~/lib/providers/CurrentPromptContext";
+import { useEditorContext } from "~/lib/providers/EditorContext";
+import { Button } from "@pezzo/ui";
+import { PlusIcon } from "lucide-react";
+import {
+ DragDropContext,
+ Droppable,
+ Draggable,
+ DropResult,
+} from "react-beautiful-dnd";
+
+export const ChatEditMode = () => {
+ const { promptId } = useCurrentPrompt();
+ const { messagesArray } = useEditorContext();
+ const { fields, append, remove, move } = messagesArray;
+
+ const handleAdd = () => {
+ append({
+ role: "user",
+ content: "",
+ });
+
+ trackEvent("prompt_chat_completion_message_created", {
+ promptId,
+ });
+ };
+
+ const onDragEnd = (result: DropResult) => {
+ if (!result.destination) {
+ return;
+ }
+
+ move(result.source.index, result.destination.index);
+ };
+
+ return (
+
+
+
+ {(provided) => (
+
+ {fields.map((field, index) => (
+
+ {(provided) => (
+
+ {
+ remove(index);
+ trackEvent("prompt_chat_completion_message_deleted", {
+ promptId,
+ });
+ }}
+ />
+
+ )}
+
+ ))}
+ {provided.placeholder}
+
+ )}
+
+
+
+
+
+ );
+};
diff --git a/apps/console/src/components/prompts/editor/chat/ChatMessage.tsx b/apps/console/src/components/prompts/editor/chat/ChatMessage.tsx
new file mode 100644
index 000000000..759630132
--- /dev/null
+++ b/apps/console/src/components/prompts/editor/chat/ChatMessage.tsx
@@ -0,0 +1,129 @@
+import { useCurrentPrompt } from "~/lib/providers/CurrentPromptContext";
+import { trackEvent } from "~/lib/utils/analytics";
+import {
+ Card,
+ CardContent,
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+ FormControl,
+ FormField,
+ FormItem,
+ FormMessage,
+ Textarea,
+} from "@pezzo/ui";
+import {
+ ArrowDownUpIcon,
+ BotIcon,
+ GripVertical,
+ LucideIcon,
+ TrashIcon,
+ UserIcon,
+ WrenchIcon,
+} from "lucide-react";
+import { useEditorContext } from "~/lib/providers/EditorContext";
+import { useMemo } from "react";
+import { useWatch } from "react-hook-form";
+
+interface Props {
+ index: number;
+ canDelete?: boolean;
+ onDelete: () => void;
+}
+
+type role = "system" | "user" | "assistant";
+
+const roleOptions: {
+ label: string;
+ value: role;
+ icon: LucideIcon;
+}[] = [
+ { label: "System", value: "system", icon: WrenchIcon },
+ { label: "User", value: "user", icon: UserIcon },
+ { label: "Assistant", value: "assistant", icon: BotIcon },
+];
+
+export const ChatMessage = ({ index, canDelete = true, onDelete }: Props) => {
+ const { promptId } = useCurrentPrompt();
+ const { getForm } = useEditorContext();
+ const form = getForm();
+ const message = useWatch({
+ control: form.control,
+ name: `content.messages.${index}`,
+ });
+
+ const currentRole = useMemo(
+ () => roleOptions.find((roleOption) => message?.role === roleOption.value),
+ [message?.role]
+ );
+
+ if (!message) {
+ return null;
+ }
+
+ const handleRoleChange = (role: "system" | "user" | "assistant") => {
+ form.setValue(`content.messages.${index}.role`, role, {
+ shouldDirty: true,
+ });
+ trackEvent("prompt_chat_completion_message_role_changed", {
+ promptId,
+ role,
+ });
+ };
+
+ return (
+
+
+
+
+
+
+
+
{currentRole.label}
+
+
+
+ {roleOptions.map((role) => (
+ handleRoleChange(role.value)}
+ >
+
+ {role.label}
+
+ ))}
+
+
+
+ {canDelete && (
+
+ )}
+
+
+ (
+
+
+
+
+
+
+ )}
+ />
+
+
+ );
+};
diff --git a/apps/console/src/components/prompts/form.css b/apps/console/src/components/prompts/form.css
new file mode 100644
index 000000000..766f6e4af
--- /dev/null
+++ b/apps/console/src/components/prompts/form.css
@@ -0,0 +1,48 @@
+.MuiGrid-root,
+.MuiFormControl-root,
+.css-g1nj7g-MuiGrid-root,
+.css-t0knny-MuiPaper-root-MuiCard-root,
+.css-wb57ya-MuiFormControl-root-MuiTextField-root,
+.css-z6glxt-MuiPaper-root-MuiCard-root,
+.css-u4tvz2-MuiFormLabel-root,
+.css-1n4twyu-MuiInputBase-input-MuiOutlinedInput-input,
+.css-1d3z3hw-MuiOutlinedInput-notchedOutline,
+.css-10wpov9-MuiTypography-root {
+ font-family: "Inter", sans-serif !important;
+ color: rgba(255, 255, 255, 0.85) !important;
+ font-size: 14px !important;
+}
+
+.css-fy4l7m,
+.css-z6glxt-MuiPaper-root-MuiCard-root {
+ border-color: rgb(48 48 48) !important;
+}
+
+.css-1d3z3hw-MuiOutlinedInput-notchedOutline {
+ border-color: rgb(48 48 48) !important;
+}
+
+.css-6hp17o-MuiList-root-MuiMenu-list {
+ background-color: rgb(48 48 48) !important;
+ color: rgba(255, 255, 255, 0.85) !important;
+}
+
+.MuiGrid-root svg {
+ color: rgba(255, 255, 255, 0.85) !important;
+}
+
+.css-jedpe8-MuiSelect-select-MuiInputBase-input-MuiOutlinedInput-input.css-jedpe8-MuiSelect-select-MuiInputBase-input-MuiOutlinedInput-input.css-jedpe8-MuiSelect-select-MuiInputBase-input-MuiOutlinedInput-input {
+ color: rgba(255, 255, 255, 0.85) !important;
+}
+
+.MuiGrid-root .css-sghohy-MuiButtonBase-root-MuiButton-root {
+ background-color: #11a071;
+}
+
+.MuiGrid-root .css-sghohy-MuiButtonBase-root-MuiButton-root:hover {
+ background-color: #2fb584;
+}
+
+.MuiGrid-root button:hover {
+ background-color: #11a0704a;
+}
diff --git a/apps/console/src/components/prompts/metrics/SimpleChart.tsx b/apps/console/src/components/prompts/metrics/SimpleChart.tsx
new file mode 100644
index 000000000..1524d8537
--- /dev/null
+++ b/apps/console/src/components/prompts/metrics/SimpleChart.tsx
@@ -0,0 +1,45 @@
+import {
+ CartesianGrid,
+ Tooltip,
+ Line,
+ LineChart,
+ ResponsiveContainer,
+ XAxis,
+ YAxis,
+} from "recharts";
+import { useMetric } from "~/lib/providers/MetricContext";
+import { colors } from "~/lib/theme/colors";
+
+interface Props {
+ tooltipFormatter?: (value: string) => string;
+ lineLabel?: string;
+}
+
+export const SimpleChart = ({ tooltipFormatter, lineLabel }: Props) => {
+ const { data: metricData, formatTimestamp } = useMetric();
+
+ const data = metricData.map((metric) => ({
+ timestamp: formatTimestamp(metric.time),
+ value: metric.value,
+ }));
+
+ return (
+
+
+
+
+
+ v)} />
+
+
+
+ );
+};
diff --git a/apps/console/src/components/prompts/prompt-tester/PromptTesterModal.tsx b/apps/console/src/components/prompts/prompt-tester/PromptTesterModal.tsx
new file mode 100644
index 000000000..75c711836
--- /dev/null
+++ b/apps/console/src/components/prompts/prompt-tester/PromptTesterModal.tsx
@@ -0,0 +1,57 @@
+import { usePromptTester } from "~/lib/providers/PromptTesterContext";
+import { VariablesStep } from "./VariablesStep";
+import { RequestDetails } from "../../requests";
+import { trackEvent } from "~/lib/utils/analytics";
+import {
+ Alert,
+ AlertDescription,
+ AlertTitle,
+ Dialog,
+ DialogContent,
+} from "@pezzo/ui";
+import { AlertCircle } from "lucide-react";
+import { cn } from "@pezzo/ui/utils";
+
+export const PromptTesterModal = () => {
+ const { isOpen, closeTestModal, runTest, testResult, testError } =
+ usePromptTester();
+
+ const handleSubmitVariables = async () => {
+ runTest();
+ trackEvent("prompt_test_submitted");
+ };
+
+ const handleCancel = () => {
+ closeTestModal();
+ trackEvent("prompt_test_cancelled");
+ };
+
+ return (
+
+
+
+ {testError && (
+
+
+ Oops!
+
+ {testError.response.errors[0].message}
+
+
+ )}
+ {!testResult &&
}
+ {testResult && (
+
+
+
+ )}
+
+
+
+ );
+};
diff --git a/apps/console/src/components/prompts/prompt-tester/PromptVariable.tsx b/apps/console/src/components/prompts/prompt-tester/PromptVariable.tsx
new file mode 100644
index 000000000..ffd4220a9
--- /dev/null
+++ b/apps/console/src/components/prompts/prompt-tester/PromptVariable.tsx
@@ -0,0 +1,43 @@
+import {
+ Input,
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+ Textarea,
+} from "@pezzo/ui";
+import { Maximize2Icon, VariableIcon } from "lucide-react";
+import { ControllerRenderProps } from "react-hook-form";
+import { PromptTesterVariablesInputs } from "~/lib/providers/PromptTesterContext";
+
+interface Props {
+ name: string;
+ value: string;
+ field: ControllerRenderProps;
+}
+
+export const PromptVariable = ({ name, value, field }: Props) => {
+ return (
+
+ );
+};
diff --git a/apps/console/src/components/prompts/prompt-tester/PromptVariables.tsx b/apps/console/src/components/prompts/prompt-tester/PromptVariables.tsx
new file mode 100644
index 000000000..c83f31109
--- /dev/null
+++ b/apps/console/src/components/prompts/prompt-tester/PromptVariables.tsx
@@ -0,0 +1,49 @@
+import { PromptTesterVariablesInputs } from "~/lib/providers/PromptTesterContext";
+import { PromptVariable } from "./PromptVariable";
+import { isJson } from "~/lib/utils/is-json";
+import { UseFormReturn } from "react-hook-form";
+import { FormField, FormItem, FormMessage } from "@pezzo/ui";
+
+interface Props {
+ variables: Record;
+ form: UseFormReturn;
+}
+
+export const PromptVariables = ({ variables, form }: Props) => {
+ if (Object.keys(variables).length === 0) {
+ return No variables found
;
+ }
+
+ return (
+ <>
+ Provide values for your variables below.
+
+ {Object.keys(variables).map((variableName) => (
+
(
+
+
+
+
+ )}
+ />
+ ))}
+
+ >
+ );
+};
diff --git a/apps/console/src/components/prompts/prompt-tester/VariablesStep.tsx b/apps/console/src/components/prompts/prompt-tester/VariablesStep.tsx
new file mode 100644
index 000000000..9380e3b38
--- /dev/null
+++ b/apps/console/src/components/prompts/prompt-tester/VariablesStep.tsx
@@ -0,0 +1,37 @@
+import {
+ PromptTesterVariablesInputs,
+ usePromptTester,
+} from "~/lib/providers/PromptTesterContext";
+import { PromptVariables } from "./PromptVariables";
+import { Button, DialogFooter, Form } from "@pezzo/ui";
+import { useWatch } from "react-hook-form";
+
+interface Props {
+ onSubmit: () => void;
+}
+
+export const VariablesStep = ({ onSubmit }: Props) => {
+ const { testVariablesForm: form, isTestLoading } = usePromptTester();
+ const testVariablesFormValues = useWatch({
+ control: form.control,
+ });
+
+ const handleSubmit = (values: PromptTesterVariablesInputs) => {
+ onSubmit();
+ };
+
+ return (
+
+ );
+};
diff --git a/apps/console/src/components/prompts/views/PromptVersionsView.tsx b/apps/console/src/components/prompts/views/PromptVersionsView.tsx
new file mode 100644
index 000000000..fcac76eeb
--- /dev/null
+++ b/apps/console/src/components/prompts/views/PromptVersionsView.tsx
@@ -0,0 +1,163 @@
+import { usePromptVersions } from "~/lib/hooks/usePromptVersions";
+import { useCurrentPrompt } from "~/lib/providers/CurrentPromptContext";
+import { trackEvent } from "~/lib/utils/analytics";
+import React, { useMemo } from "react";
+import {
+ ColumnDef,
+ flexRender,
+ getCoreRowModel,
+ useReactTable,
+} from "@tanstack/react-table";
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@pezzo/ui";
+import { InlineCodeSnippet } from "~/components/common/InlineCodeSnippet";
+
+type PromptVersion = ReturnType["promptVersions"][0];
+
+const getTableColumns = (): ColumnDef[] => {
+ return [
+ {
+ accessorKey: "sha",
+ id: "sha",
+ header: "SHA",
+ cell: ({ row }) => (
+
+ {row.original.sha.slice(0, 7)}
+
+ ),
+ },
+ {
+ accessorKey: "author",
+ id: "author",
+ header: "Author",
+ cell: ({ row }) => {row.original.createdBy.email}
,
+ },
+ {
+ accessorKey: "message",
+ id: "message",
+ header: "Message",
+ cell: ({ row }) => {
+ const msg = row.original.message;
+
+ if (!msg) {
+ return No message
;
+ } else {
+ return {row.original.message}
;
+ }
+ },
+ },
+ {
+ accessorKey: "time",
+ id: "time",
+ header: "Time",
+ cell: ({ row }) => (
+ {new Date(row.original.createdAt).toLocaleString()}
+ ),
+ },
+ ];
+};
+
+export const PromptVersionsView = () => {
+ const { prompt } = useCurrentPrompt();
+ const { promptVersions } = usePromptVersions(prompt.id);
+
+ React.useEffect(() => {
+ trackEvent("prompt_versions_viewed");
+ }, [prompt.id]);
+
+ const data = useMemo(() => promptVersions, [promptVersions]);
+ const columns: ColumnDef[] = useMemo(
+ () => getTableColumns(),
+ []
+ );
+
+ const table = useReactTable({
+ data,
+ columns,
+ getCoreRowModel: getCoreRowModel(),
+ defaultColumn: {
+ minSize: 0,
+ size: Number.MAX_SAFE_INTEGER,
+ maxSize: Number.MAX_SAFE_INTEGER,
+ },
+ });
+
+ return (
+ promptVersions && (
+
+
+
+
+ {table.getHeaderGroups().map((headerGroup) => (
+
+ {headerGroup.headers.map((header) => {
+ return (
+
+ {header.isPlaceholder
+ ? null
+ : flexRender(
+ header.column.columnDef.header,
+ header.getContext()
+ )}
+
+ );
+ })}
+
+ ))}
+
+
+ {table.getRowModel().rows?.length ? (
+ table.getRowModel().rows.map((row) => (
+
+ {row.getVisibleCells().map((cell) => (
+
+ {flexRender(
+ cell.column.columnDef.cell,
+ cell.getContext()
+ )}
+
+ ))}
+
+ ))
+ ) : (
+
+
+ No results.
+
+
+ )}
+
+
+
+
+ )
+ );
+};
diff --git a/apps/console/src/components/requests/RequestDetails.tsx b/apps/console/src/components/requests/RequestDetails.tsx
new file mode 100644
index 000000000..6707ebb6c
--- /dev/null
+++ b/apps/console/src/components/requests/RequestDetails.tsx
@@ -0,0 +1,281 @@
+import {
+ ObservabilityReportMetadata,
+ ObservabilityRequest,
+ ObservabilityResponse,
+ Provider,
+ ObservabilityReportProperties,
+} from "@pezzo/types";
+import {
+ Alert,
+ AlertDescription,
+ Tooltip,
+ TooltipProvider,
+ TooltipTrigger,
+ TooltipContent,
+ Tabs,
+ TabsList,
+ TabsTrigger,
+ Button,
+} from "@pezzo/ui";
+import ms from "ms";
+import { useState } from "react";
+import { RequestResponseViewJsonView } from "./RequestResponseViewJsonView";
+import { trackEvent } from "~/lib/utils/analytics";
+import {
+ BracesIcon,
+ CheckIcon,
+ CircleSlash,
+ CoinsIcon,
+ InfoIcon,
+ Link,
+ MessageSquare,
+} from "lucide-react";
+import { Tag } from "../common/Tag";
+import { cn } from "@pezzo/ui/utils";
+import { normalizeOpenAIChatResponse } from "~/features/chat/normalizers/openai-normalizer";
+import { ChatView } from "~/features/chat/ChatView";
+import { useCopyToClipboard } from "usehooks-ts";
+import { ModelDetails } from "~/pages/requests/ModelDetails";
+import { useReport } from "~/graphql/hooks/queries";
+import { useCurrentProject } from "~/lib/hooks/useCurrentProject";
+import OpenAI from "openai";
+
+type Mode = "chat" | "json";
+
+interface Props {
+ disableCopy?: boolean;
+ id: string;
+}
+
+const getClientDisplayName = (client: string) => {
+ switch (client) {
+ case "pezzo-ts":
+ return "TypeScript (Official)";
+ case "pezzo-python":
+ return "Python (Official)";
+ default:
+ return client;
+ }
+};
+
+export const RequestDetails = (props: Props) => {
+ const { projectId } = useCurrentProject();
+ const disableCopy = props.disableCopy || false;
+ const { report } = useReport(
+ { projectId, reportId: props.id },
+ { enabled: !!projectId && !!props.id }
+ );
+
+ const isSuccess = report.isError === false;
+ const isError = report.isError === true;
+
+ const [selectedMode, setSelectedMode] = useState(
+ isSuccess ? "chat" : "json"
+ );
+
+ const [copied, copy] = useCopyToClipboard();
+
+ const handleDisplayModeChange = (mode: Mode) => {
+ setSelectedMode(mode);
+ trackEvent("prompt_test_display_mode_changed", { mode });
+ };
+
+ const clientString =
+ report.client && report.clientVersion
+ ? `${getClientDisplayName(report.client)} - v${report.clientVersion}`
+ : "Unknown";
+
+ const listData = [
+ {
+ title: "Request ID",
+ description: report.id,
+ },
+ {
+ title: "Cache",
+ description: (
+
+ {report.cacheEnabled ? "enabled" : "disabled"}
+
+ {report.cacheEnabled && {report.cacheHit ? "hit" : "miss"} }
+
+ ),
+ },
+ {
+ title: "Client",
+ description: clientString,
+ },
+ {
+ title: "Provider",
+ description: {report.provider}
,
+ },
+ {
+ title: "Model",
+ description: (
+
+ ),
+ },
+ {
+ title: "Tokens",
+ description: report.isError ? (
+ "0"
+ ) : (
+
+
{report.totalTokens}
+
+
+
+
+
+
+
+
+ Prompt tokens:
+ {report.promptTokens}
+
+
+ Completion tokens: {" "}
+ {report.completionTokens}
+
+
+
+
+
+
+ ),
+ },
+ {
+ title: "Cost",
+ description: `$${report?.totalCost?.toFixed(5) ?? 0}`,
+ },
+ {
+ title: "Status",
+ description: (
+
+ {isError ? (
+ <>
+
+ {report.responseStatusCode} Error
+ >
+ ) : (
+ <>
+
+ Success
+ >
+ )}
+
+ ),
+ },
+ {
+ title: "Environment",
+ description: report.environment,
+ },
+ {
+ title: "Duration",
+ description: ms(report.duration),
+ },
+ ];
+
+ const renderResponse = () => {
+ if (selectedMode === "json") {
+ return (
+
+ );
+ }
+
+ const chat = normalizeOpenAIChatResponse(
+ report.requestBody as OpenAI.ChatCompletionCreateParams,
+ report.responseBody as OpenAI.ChatCompletion
+ );
+ return ;
+ };
+
+ return (
+
+
+
Request Details
+
+ {!disableCopy && (
+
+ {
+ copy(window.location.href);
+ }}
+ >
+ {!copied && (
+ <>
+ Copy Link
+ >
+ )}
+
+ {copied && (
+ <>
+ Copied
+ >
+ )}
+
+
+ )}
+
+
+
+ {report.environment === "PLAYGROUND" && (
+
+
+
+ This is a test request from the Pezzo Console
+
+
+ )}
+
+
+ {listData.map((item) => (
+
+
{item.title}
+
{item.description}
+
+ ))}
+
+
+
+ handleDisplayModeChange(value)}
+ >
+
+
+ Chat
+
+
+ JSON
+
+
+
+
+
+ {renderResponse()}
+
+
+
+
+ );
+};
diff --git a/apps/console/src/components/requests/RequestFilters.tsx b/apps/console/src/components/requests/RequestFilters.tsx
new file mode 100644
index 000000000..08b8a1629
--- /dev/null
+++ b/apps/console/src/components/requests/RequestFilters.tsx
@@ -0,0 +1,34 @@
+import { AddFilterItem, FilterItem } from "./filters/FilterItem";
+import { useFiltersAndSortParams } from "~/lib/hooks/useFiltersAndSortParams";
+import {
+ NUMBER_FILTER_OPERATORS,
+ STRING_FILTER_OPERATORS,
+} from "~/lib/constants/filters";
+
+export const RequestFilters = () => {
+ const { filters, removeFilter, addFilter } = useFiltersAndSortParams();
+
+ return (
+
+
+
+ {filters.length > 0 && (
+
+ {filters.map((filter) => (
+ op.value === filter.operator
+ )?.label
+ }
+ value={filter.value}
+ onRemoveFilter={() => removeFilter(filter)}
+ />
+ ))}
+
+ )}
+
+ );
+};
diff --git a/apps/console/src/components/requests/RequestResponseViewJsonView.tsx b/apps/console/src/components/requests/RequestResponseViewJsonView.tsx
new file mode 100644
index 000000000..43bbef7bb
--- /dev/null
+++ b/apps/console/src/components/requests/RequestResponseViewJsonView.tsx
@@ -0,0 +1,33 @@
+import { Card } from "@pezzo/ui";
+import OpenAI from "openai";
+
+interface Props {
+ requestBody: OpenAI.ChatCompletionCreateParams;
+ responseBody: OpenAI.ChatCompletion;
+}
+
+export const RequestResponseViewJsonView = ({
+ requestBody,
+ responseBody,
+}: Props) => {
+ return (
+
+
+
Request
+
+
+ {JSON.stringify(requestBody, null, 2)}
+
+
+
+
+
Response
+
+
+ {JSON.stringify(responseBody, null, 2)}
+
+
+
+
+ );
+};
diff --git a/apps/console/src/components/requests/filters/FilterItem.tsx b/apps/console/src/components/requests/filters/FilterItem.tsx
new file mode 100644
index 000000000..f0fddbe71
--- /dev/null
+++ b/apps/console/src/components/requests/filters/FilterItem.tsx
@@ -0,0 +1,232 @@
+import {
+ Button,
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ Select,
+ SelectTrigger,
+ SelectContent,
+ SelectValue,
+ SelectItem,
+ Input,
+} from "@pezzo/ui";
+import { useCallback, useMemo, useState } from "react";
+import { FilterInput, FilterOperator } from "~/@generated/graphql/graphql";
+import {
+ FILTER_FIELDS_LIST,
+ NUMBER_FILTER_OPERATORS,
+ STRING_FILTER_OPERATORS,
+} from "~/lib/constants/filters";
+import { useForm } from "react-hook-form";
+import z from "zod";
+import { zodResolver } from "@hookform/resolvers/zod";
+import { CheckIcon, PlusIcon, XIcon } from "lucide-react";
+
+interface Props {
+ onRemoveFilter: () => void;
+ field: string;
+ operator: string;
+ value: string;
+ property?: string;
+}
+
+export const FilterItem = ({
+ field,
+ operator,
+ value,
+ onRemoveFilter,
+}: Props) => {
+ const translatedField = useMemo(() => {
+ const found = FILTER_FIELDS_LIST.find((f) => f.value === field);
+ return found?.label.toLocaleLowerCase();
+ }, [field]);
+
+ return (
+
+
{translatedField}
+
{operator}
+
{value}
+
+
+
+
+ );
+};
+
+const formSchema = z.object({
+ field: z.string().min(1).max(100),
+ operator: z.nativeEnum(FilterOperator),
+ value: z.string().min(1).max(100),
+ // property: z.string().min(1).max(100).optional(),
+});
+
+export const AddFilterForm = ({
+ onAdd,
+ onCancel,
+}: {
+ onAdd: (input: FilterInput) => void;
+ onCancel: () => void;
+}) => {
+ const form = useForm>({
+ resolver: zodResolver(formSchema),
+ defaultValues: {
+ field: "",
+ operator: FilterOperator.Eq,
+ value: "",
+ // property: undefined,
+ },
+ });
+
+ const onSubmit = (values: z.infer) => {
+ const filterValue: FilterInput = {
+ field: values.field,
+ operator: values.operator,
+ value: values.value,
+ };
+
+ onAdd(filterValue);
+ form.reset();
+ };
+
+ const formValues = form.watch();
+ const { field } = formValues;
+ const selectedFilterField = FILTER_FIELDS_LIST.find(
+ (fieldInList) => fieldInList.value === field
+ );
+
+ const isFormValid = form.formState.isValid;
+
+ return (
+
+
+ );
+};
+
+export const AddFilterItem = ({
+ onAdd,
+}: {
+ onAdd: (input: FilterInput) => void;
+}) => {
+ const [addFormOpen, setAddFormOpen] = useState(false);
+
+ const handleAdd = useCallback(
+ (filter: FilterInput) => {
+ onAdd(filter);
+ setAddFormOpen(false);
+ },
+ [onAdd]
+ );
+
+ if (addFormOpen) {
+ return (
+ setAddFormOpen(false)} />
+ );
+ }
+ return (
+ setAddFormOpen(true)}>
+ Add Filter
+
+ );
+};
diff --git a/apps/console/src/components/requests/index.ts b/apps/console/src/components/requests/index.ts
new file mode 100644
index 000000000..c2151bd20
--- /dev/null
+++ b/apps/console/src/components/requests/index.ts
@@ -0,0 +1 @@
+export * from "./RequestDetails";
diff --git a/apps/console/src/env.ts b/apps/console/src/env.ts
index 4dba6f0a4..779930805 100644
--- a/apps/console/src/env.ts
+++ b/apps/console/src/env.ts
@@ -1,5 +1,20 @@
-function get(name: string): string | undefined {
+export function getEnvVariable(name: string): string | undefined {
return window[name] || process.env[name];
}
-export const BASE_API_URL = get("NX_BASE_API_URL");
+export const BASE_API_URL = getEnvVariable("NX_BASE_API_URL");
+export const AUTH_GITHUB_ENABLED = getEnvVariable("NX_AUTH_GITHUB_ENABLED");
+export const AUTH_GOOGLE_ENABLED = getEnvVariable("NX_AUTH_GOOGLE_ENABLED");
+export const INTERCOM_APP_ID = getEnvVariable("NX_INTERCOM_APP_ID");
+export const HOTJAR_SITE_ID = getEnvVariable("NX_HOTJAR_SITE_ID");
+export const HOTJAR_VERSION = getEnvVariable("NX_HOTJAR_VERSION");
+export const SUPERTOKENS_API_DOMAIN = getEnvVariable(
+ "NX_SUPERTOKENS_API_DOMAIN"
+);
+export const SUPERTOKENS_WEBSITE_DOMAIN = getEnvVariable(
+ "NX_SUPERTOKENS_WEBSITE_DOMAIN"
+);
+export const SENTRY_DSN_URL = getEnvVariable("NX_SENTRY_DSN_URL");
+export const SEGMENT_WRITE_KEY = getEnvVariable("NX_SEGMENT_WRITE_KEY");
+export const GTM_TAG_ID = getEnvVariable("NX_GTM_TAG_ID");
+export const DEBUG_MODE = getEnvVariable("NX_DEBUG_MODE");
diff --git a/apps/console/src/environments/environment.prod.ts b/apps/console/src/environments/environment.prod.ts
deleted file mode 100644
index c9669790b..000000000
--- a/apps/console/src/environments/environment.prod.ts
+++ /dev/null
@@ -1,3 +0,0 @@
-export const environment = {
- production: true,
-};
diff --git a/apps/console/src/environments/environment.ts b/apps/console/src/environments/environment.ts
deleted file mode 100644
index 7ed83767f..000000000
--- a/apps/console/src/environments/environment.ts
+++ /dev/null
@@ -1,6 +0,0 @@
-// This file can be replaced during build by using the `fileReplacements` array.
-// When building for production, this file is replaced with `environment.prod.ts`.
-
-export const environment = {
- production: false,
-};
diff --git a/apps/console/src/features/chat/ChatView.tsx b/apps/console/src/features/chat/ChatView.tsx
new file mode 100644
index 000000000..5c2f2a0e1
--- /dev/null
+++ b/apps/console/src/features/chat/ChatView.tsx
@@ -0,0 +1,66 @@
+import { Chat, SubChatMessage } from "./types";
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipProvider,
+ TooltipTrigger,
+} from "@pezzo/ui";
+
+type Props = {
+ chat: Chat;
+};
+
+export const ChatView = ({ chat }: Props) => {
+ const renderSubMessages = (subMessages: SubChatMessage[]) => {
+ return subMessages.map((subMessage, index) => {
+ return (
+
+
+
+
+ {subMessage.icon}
+
+ {subMessage.label}
+
+
+
+
+
{subMessage.content}
+
+ );
+ });
+ };
+
+ return (
+
+
+ {chat.messages.map((message, index) => (
+
+
+
+
+ {message.icon}
+
+ {message.role}
+
+
+
+
+
+ {message.subMessages ? (
+
+ {renderSubMessages(message.subMessages)}
+
+ ) : (
+ message.content
+ )}
+
+
+ ))}
+
+
+ );
+};
diff --git a/apps/console/src/features/chat/normalizers/openai-normalizer.tsx b/apps/console/src/features/chat/normalizers/openai-normalizer.tsx
new file mode 100644
index 000000000..813e8d3e8
--- /dev/null
+++ b/apps/console/src/features/chat/normalizers/openai-normalizer.tsx
@@ -0,0 +1,141 @@
+import OpenAI from "openai";
+import { Chat, ChatMessage, SubChatMessage } from "../types";
+import { Provider } from "@pezzo/types";
+import { cn } from "@pezzo/ui/utils";
+import {
+ BotIcon,
+ ImageIcon,
+ TypeIcon,
+ UserIcon,
+ WrenchIcon,
+} from "lucide-react";
+import { InlineCodeSnippet } from "~/components/common/InlineCodeSnippet";
+
+const baseIconCn = cn(
+ "w-10 h-10 flex items-center justify-center rounded-sm border"
+);
+
+export const getIcon = (role: OpenAI.ChatCompletionRole) => {
+ switch (role) {
+ case "user":
+ return (
+
+
+
+ );
+ case "assistant":
+ return (
+
+
+
+ );
+ case "system":
+ return (
+
+
+
+ );
+ }
+};
+
+const renderFunctionCall = (message: OpenAI.Chat.ChatCompletionMessage) => {
+ const function_call = {
+ ...message.function_call,
+ arguments: JSON.parse(message.function_call.arguments),
+ };
+
+ return (
+
+
Function call:
+
+ {JSON.stringify(function_call, null, 2)}
+
+
+ );
+};
+
+export const normalizeOpenAIChatResponse = (
+ request: OpenAI.Chat.CompletionCreateParams,
+ response: OpenAI.Chat.ChatCompletion
+): Chat => {
+ const messages: ChatMessage[] = [];
+
+ // First, populate messages from the request
+ request.messages.forEach((message) => {
+ if (Array.isArray(message.content)) {
+ const subMessages: SubChatMessage[] = [];
+ const contentParts = message.content;
+
+ contentParts.forEach((contentPart) => {
+ const { type } = contentPart;
+
+ if (type === "text") {
+ subMessages.push({
+ icon: (
+
+
+
+ ),
+ label: "text",
+ content: contentPart.text,
+ });
+ } else if (type === "image_url") {
+ subMessages.push({
+ icon: (
+
+
+
+ ),
+ label: "image",
+ content: (
+
+
+ Detail:
+
+ {contentPart.image_url.detail}
+
+
+
+
+ ),
+ });
+ }
+ });
+
+ messages.push({
+ icon: getIcon(message.role),
+ role: message.role,
+ subMessages,
+ content: "",
+ });
+ } else {
+ messages.push({
+ icon: getIcon(message.role),
+ role: message.role,
+ content: message.content as string, // TODO: support vision model
+ });
+ }
+ });
+
+ // Then, populate response messages
+ response.choices.forEach((choice) => {
+ if (choice.message.function_call) {
+ messages.push({
+ icon: getIcon(choice.message.role),
+ role: choice.message.role,
+ content: renderFunctionCall(choice.message),
+ });
+ } else {
+ messages.push({
+ icon: getIcon(choice.message.role),
+ role: choice.message.role,
+ content: choice.message.content,
+ });
+ }
+ });
+
+ return {
+ provider: Provider.OpenAI,
+ messages,
+ };
+};
diff --git a/apps/console/src/features/chat/types.ts b/apps/console/src/features/chat/types.ts
new file mode 100644
index 000000000..b6c39ccfe
--- /dev/null
+++ b/apps/console/src/features/chat/types.ts
@@ -0,0 +1,20 @@
+import { Provider } from "@pezzo/types";
+import OpenAI from "openai";
+
+export type ChatMessage = {
+ icon: string | React.ReactNode;
+ role: OpenAI.ChatCompletionRole;
+ content: string | React.ReactNode;
+ subMessages?: SubChatMessage[];
+};
+
+export type SubChatMessage = {
+ icon: string | React.ReactNode;
+ label: "text" | "image";
+ content: string | React.ReactNode;
+};
+
+export type Chat = {
+ provider: Provider;
+ messages: ChatMessage[];
+};
diff --git a/apps/console/src/features/editor/CommitButton.tsx b/apps/console/src/features/editor/CommitButton.tsx
new file mode 100644
index 000000000..401fd5f63
--- /dev/null
+++ b/apps/console/src/features/editor/CommitButton.tsx
@@ -0,0 +1,24 @@
+import { Button } from "@pezzo/ui";
+import { SaveIcon } from "lucide-react";
+import { useEditorContext } from "~/lib/providers/EditorContext";
+
+interface Props {
+ onClick: () => void;
+}
+
+export const CommitButton = ({ onClick }: Props) => {
+ const { hasChangesToCommit, getForm } = useEditorContext();
+ const form = getForm();
+ const isValid = form.formState.isValid;
+
+ return (
+
+
+ Commit
+
+ );
+};
diff --git a/apps/console/src/features/editor/PromptEditMode.tsx b/apps/console/src/features/editor/PromptEditMode.tsx
new file mode 100644
index 000000000..eac0bc3e8
--- /dev/null
+++ b/apps/console/src/features/editor/PromptEditMode.tsx
@@ -0,0 +1,38 @@
+import {
+ Card,
+ FormControl,
+ FormField,
+ FormItem,
+ FormMessage,
+ Textarea,
+} from "@pezzo/ui";
+import { useEditorContext } from "~/lib/providers/EditorContext";
+
+export const PromptEditMode = () => {
+ const { getForm } = useEditorContext();
+ const form = getForm();
+
+ return (
+
+ (
+
+
+
+
+
+
+ )}
+ >
+
+ );
+};
diff --git a/apps/console/src/features/editor/PromptEditView.tsx b/apps/console/src/features/editor/PromptEditView.tsx
new file mode 100644
index 000000000..c8791ce89
--- /dev/null
+++ b/apps/console/src/features/editor/PromptEditView.tsx
@@ -0,0 +1,215 @@
+import { useEditorContext } from "~/lib/providers/EditorContext";
+import { usePromptTester } from "~/lib/providers/PromptTesterContext";
+import {
+ Button,
+ Card,
+ CardContent,
+ CardHeader,
+ Form,
+ FormField,
+ Tabs,
+ TabsList,
+ TabsTrigger,
+ Tooltip,
+ TooltipContent,
+ TooltipProvider,
+ TooltipTrigger,
+} from "@pezzo/ui";
+import { PromptEditMode } from "./PromptEditMode";
+import { PromptType } from "~/@generated/graphql/graphql";
+import { useState } from "react";
+import { ProviderSettingsCard } from "./ProviderSettingsCard";
+import {
+ BugPlayIcon,
+ InfoIcon,
+ SendHorizonalIcon,
+ TerminalIcon,
+} from "lucide-react";
+import { Variables } from "./Variables";
+import { Tag } from "~/components/common/Tag";
+import { PromptVersionSelector } from "~/components/prompts/PromptVersionSelector";
+import { CommitButton } from "./CommitButton";
+import { trackEvent } from "~/lib/utils/analytics";
+import { useProviderApiKeys } from "~/graphql/hooks/queries";
+import { useRequiredProviderApiKeyModal } from "~/lib/providers/RequiredProviderApiKeyModalProvider";
+import { PromptTesterModal } from "~/components/prompts/prompt-tester/PromptTesterModal";
+import { ConsumePromptModal } from "~/components/prompts/ConsumePromptModal";
+import { CommitPromptModal } from "~/components/prompts/CommitPromptModal";
+import { PublishPromptModal } from "~/components/prompts/PublishPromptModal";
+import { ChatEditMode } from "~/components/prompts/editor/chat/ChatEditMode";
+import { useWatch } from "react-hook-form";
+
+export const PromptEditView = () => {
+ const {
+ getForm,
+ isDraft,
+ handleTypeChange,
+ isPublishEnabled,
+ currentVersionSha,
+ } = useEditorContext();
+ const { providerApiKeys } = useProviderApiKeys();
+ const { openRequiredProviderApiKeyModal } = useRequiredProviderApiKeyModal();
+
+ const form = getForm();
+ const [type] = useWatch({
+ control: form.control,
+ name: ["type"],
+ });
+
+ const { openTestModal } = usePromptTester();
+
+ const [isCommitModalOpen, setIsCommitModalOpen] = useState(false);
+ const [isConsumePromptModalOpen, setIsConsumePromptModalOpen] =
+ useState(false);
+ const [isPublishModalOpen, setIsPublishModalOpen] = useState(false);
+
+ const handleRunTest = () => {
+ const provider = "OpenAI";
+ const hasProviderApiKey = !!providerApiKeys.find(
+ (key) => key.provider === provider
+ );
+
+ trackEvent("prompt_run_test_clicked");
+ const values = form.getValues();
+
+ if (!hasProviderApiKey) {
+ openRequiredProviderApiKeyModal({
+ callback: () => {
+ openTestModal(values);
+ },
+ provider,
+ reason: "prompt_tester",
+ });
+ return;
+ }
+
+ openTestModal(values);
+ };
+
+ const handleHowToConsumeClick = () => {
+ setIsConsumePromptModalOpen(true);
+ trackEvent("prompt_how_to_consume_modal_opened");
+ };
+
+ const handlePublishClick = () => {
+ setIsPublishModalOpen(true);
+ trackEvent("prompt_publish_modal_opened");
+ };
+
+ const handleCommitClick = () => {
+ setIsCommitModalOpen(true);
+ trackEvent("prompt_commit_modal_opened");
+ };
+
+ return (
+ <>
+
+
+
+
+ setIsConsumePromptModalOpen(false)}
+ />
+
+ setIsCommitModalOpen(false)}
+ onCommitted={() => {
+ setIsCommitModalOpen(false);
+ }}
+ />
+ {currentVersionSha && (
+ setIsPublishModalOpen(false)}
+ open={isPublishModalOpen}
+ />
+ )}
+ >
+ );
+};
diff --git a/apps/console/src/features/editor/ProviderSettingsCard.tsx b/apps/console/src/features/editor/ProviderSettingsCard.tsx
new file mode 100644
index 000000000..ac6e851c3
--- /dev/null
+++ b/apps/console/src/features/editor/ProviderSettingsCard.tsx
@@ -0,0 +1,38 @@
+import { ProviderSelector } from "../../components/prompts/editor/ProviderSelector/ProviderSelector";
+import { PromptService } from "@pezzo/types";
+import { ProviderSettingsSchemaRenderer } from "../../components/prompts/editor/ProviderSettings/ProviderSettingsSchemaRenderer";
+import { openAIChatCompletionSettingsDefinition } from "../../components/prompts/editor/ProviderSettings/providers/openai-chat-completion";
+import { azureOpenAIChatCompletionSettingsDefinition } from "../../components/prompts/editor/ProviderSettings/providers/azure-openai-chat-completion";
+import { useEditorContext } from "~/lib/providers/EditorContext";
+
+const providerSettings = {
+ [PromptService.OpenAIChatCompletion]: openAIChatCompletionSettingsDefinition,
+ [PromptService.AzureOpenAIChatCompletion]:
+ azureOpenAIChatCompletionSettingsDefinition,
+};
+
+export const ProviderSettingsCard = () => {
+ const { getForm } = useEditorContext();
+
+ const form = getForm();
+ const settings = form.watch("settings");
+ const service = form.watch("service");
+
+ if (!settings) {
+ return null;
+ }
+
+ return (
+
+
+
+
+
+ {service && (
+
+ )}
+
+ );
+};
diff --git a/apps/console/src/features/editor/Variables.tsx b/apps/console/src/features/editor/Variables.tsx
new file mode 100644
index 000000000..d4be25646
--- /dev/null
+++ b/apps/console/src/features/editor/Variables.tsx
@@ -0,0 +1,18 @@
+import { Tag } from "~/components/common/Tag";
+import { useEditorContext } from "~/lib/providers/EditorContext";
+
+export const Variables = () => {
+ const { variables } = useEditorContext();
+
+ return (
+ <>
+ {variables.length === 0 && (
+ No variables found.
+ )}
+
+ {variables.map((key) => (
+ {key}
+ ))}
+ >
+ );
+};
diff --git a/apps/console/src/fonts.css b/apps/console/src/fonts.css
new file mode 100644
index 000000000..6a2fa9766
--- /dev/null
+++ b/apps/console/src/fonts.css
@@ -0,0 +1,34 @@
+@import url("https://fonts.googleapis.com/css2?family=Roboto+Mono:wght@200;300;400;500;600;700&display=swap");
+@import url("https://fonts.googleapis.com/css2?family=Inter:wght@200;300;400;500;600;700&display=swap");
+
+@font-face {
+ font-family: "Brockmann";
+ src: url("https://cdn.pezzo.ai/fonts/brockmann/brockmann-regular-webfont.ttf")
+ format("truetype");
+ font-weight: 400;
+ font-style: "normal";
+}
+
+@font-face {
+ font-family: "Brockmann";
+ src: url("https://cdn.pezzo.ai/fonts/brockmann/brockmann-medium-webfont.ttf")
+ format("truetype");
+ font-weight: 500;
+ font-style: "normal";
+}
+
+@font-face {
+ font-family: "Brockmann";
+ src: url("https://cdn.pezzo.ai/fonts/brockmann/brockmann-semibold-webfont.ttf")
+ format("truetype");
+ font-weight: 600;
+ font-style: "normal";
+}
+
+@font-face {
+ font-family: "Brockmann";
+ src: url("https://cdn.pezzo.ai/fonts/brockmann/brockmann-bold-webfont.ttf")
+ format("truetype");
+ font-weight: 700;
+ font-style: "normal";
+}
diff --git a/apps/console/src/graphql/definitions/mutations/api-keys.tsx b/apps/console/src/graphql/definitions/mutations/api-keys.tsx
new file mode 100644
index 000000000..b831b968b
--- /dev/null
+++ b/apps/console/src/graphql/definitions/mutations/api-keys.tsx
@@ -0,0 +1,17 @@
+import { graphql } from "~/@generated/graphql";
+
+export const UPDATE_PROVIDER_API_KEY = graphql(/* GraphQL */ `
+ mutation UpdateProviderAPIKey($data: CreateProviderApiKeyInput!) {
+ updateProviderApiKey(data: $data) {
+ provider
+ }
+ }
+`);
+
+export const DELETE_PROVIDER_API_KEY = graphql(/* GraphQL */ `
+ mutation DeleteProviderAPIKey($data: DeleteProviderApiKeyInput!) {
+ deleteProviderApiKey(data: $data) {
+ provider
+ }
+ }
+`);
diff --git a/apps/console/src/graphql/definitions/mutations/environments.tsx b/apps/console/src/graphql/definitions/mutations/environments.tsx
new file mode 100644
index 000000000..41172aa6e
--- /dev/null
+++ b/apps/console/src/graphql/definitions/mutations/environments.tsx
@@ -0,0 +1,17 @@
+import { graphql } from "~/@generated/graphql";
+
+export const CREATE_ENVIRONMENT = graphql(/* GraphQL */ `
+ mutation CreateEnvironment($data: CreateEnvironmentInput!) {
+ createEnvironment(data: $data) {
+ name
+ }
+ }
+`);
+
+export const DELETE_ENVIRONMENT = graphql(/* GraphQL */ `
+ mutation DeleteEnvironment($data: EnvironmentWhereUniqueInput!) {
+ deleteEnvironment(data: $data) {
+ id
+ }
+ }
+`);
diff --git a/apps/console/src/graphql/definitions/mutations/organizations.tsx b/apps/console/src/graphql/definitions/mutations/organizations.tsx
new file mode 100644
index 000000000..a0c794345
--- /dev/null
+++ b/apps/console/src/graphql/definitions/mutations/organizations.tsx
@@ -0,0 +1,59 @@
+import { graphql } from "~/@generated/graphql";
+
+export const DELETE_INVITATION = graphql(/* GraphQL */ `
+ mutation DeleteInvitation($data: InvitationWhereUniqueInput!) {
+ deleteOrgInvitation(data: $data) {
+ id
+ }
+ }
+`);
+
+export const ACCEPT_ORG_INVITATION = graphql(/* GraphQL */ `
+ mutation AcceptInvitation($data: InvitationWhereUniqueInput!) {
+ acceptOrgInvitation(data: $data) {
+ id
+ name
+ }
+ }
+`);
+
+export const CREATE_ORG_INVITATION = graphql(/* GraphQL */ `
+ mutation CreateOrgInvitation($data: CreateOrgInvitationInput!) {
+ createOrgInvitation(data: $data) {
+ id
+ }
+ }
+`);
+
+export const UPDATE_ORG_INVITATION = graphql(/* GraphQL */ `
+ mutation UpdateOrgInvitation($data: UpdateOrgInvitationInput!) {
+ updateOrgInvitation(data: $data) {
+ id
+ role
+ }
+ }
+`);
+
+export const DELETE_ORG_MEMBER = graphql(/* GraphQL */ `
+ mutation DeleteOrgMember($data: OrganizationMemberWhereUniqueInput!) {
+ deleteOrgMember(data: $data) {
+ id
+ }
+ }
+`);
+
+export const UPDATE_ORG_MEMBER_ROLE = graphql(/* GraphQL */ `
+ mutation UpdateOrgMemberRole($data: UpdateOrgMemberRoleInput!) {
+ updateOrgMemberRole(data: $data) {
+ role
+ }
+ }
+`);
+
+export const UPDATE_ORG_SETTINGS = graphql(/* GraphQL */ `
+ mutation UpdateOrgSettings($data: UpdateOrgSettingsInput!) {
+ updateOrgSettings(data: $data) {
+ name
+ }
+ }
+`);
diff --git a/apps/console/src/graphql/definitions/mutations/projects.tsx b/apps/console/src/graphql/definitions/mutations/projects.tsx
new file mode 100644
index 000000000..2bdfcd6b9
--- /dev/null
+++ b/apps/console/src/graphql/definitions/mutations/projects.tsx
@@ -0,0 +1,27 @@
+import { graphql } from "~/@generated/graphql";
+
+export const CREATE_PROJECT = graphql(/* GraphQL */ `
+ mutation createProject($data: CreateProjectInput!) {
+ createProject(data: $data) {
+ id
+ organizationId
+ name
+ }
+ }
+`);
+
+export const DELETE_PROJECT = graphql(/* GraphQL */ `
+ mutation deleteProject($data: ProjectWhereUniqueInput!) {
+ deleteProject(data: $data) {
+ id
+ }
+ }
+`);
+
+export const UPDATE_PROJECT_SETTINGS = graphql(/* GraphQL */ `
+ mutation updateProjectSettings($data: UpdateProjectSettingsInput!) {
+ updateProjectSettings(data: $data) {
+ id
+ }
+ }
+`);
diff --git a/apps/console/src/app/graphql/mutations/prompt-environments.tsx b/apps/console/src/graphql/definitions/mutations/prompt-environments.tsx
similarity index 72%
rename from apps/console/src/app/graphql/mutations/prompt-environments.tsx
rename to apps/console/src/graphql/definitions/mutations/prompt-environments.tsx
index c5aba619c..95229c96a 100644
--- a/apps/console/src/app/graphql/mutations/prompt-environments.tsx
+++ b/apps/console/src/graphql/definitions/mutations/prompt-environments.tsx
@@ -1,10 +1,9 @@
-import { graphql } from "@pezzo/graphql";
+import { graphql } from "~/@generated/graphql";
export const PUBLISH_PROMPT = graphql(/* GraphQL */ `
mutation PublishPrompt($data: PublishPromptInput!) {
publishPrompt(data: $data) {
promptId
- environmentSlug
}
}
`);
diff --git a/apps/console/src/graphql/definitions/mutations/prompts.tsx b/apps/console/src/graphql/definitions/mutations/prompts.tsx
new file mode 100644
index 000000000..55bfbf2d3
--- /dev/null
+++ b/apps/console/src/graphql/definitions/mutations/prompts.tsx
@@ -0,0 +1,25 @@
+import { graphql } from "~/@generated/graphql";
+
+export const CREATE_PROMPT = graphql(/* GraphQL */ `
+ mutation createPrompt($data: CreatePromptInput!) {
+ createPrompt(data: $data) {
+ id
+ }
+ }
+`);
+
+export const CREATE_PROMPT_VERSION = graphql(/* GraphQL */ `
+ mutation createPromptVersion($data: CreatePromptVersionInput!) {
+ createPromptVersion(data: $data) {
+ sha
+ }
+ }
+`);
+
+export const DELETE_PROMPT = graphql(/* GraphQL */ `
+ mutation deletePrompt($data: PromptWhereUniqueInput!) {
+ deletePrompt(data: $data) {
+ id
+ }
+ }
+`);
diff --git a/apps/console/src/graphql/definitions/queries/api-keys.tsx b/apps/console/src/graphql/definitions/queries/api-keys.tsx
new file mode 100644
index 000000000..301a7ef02
--- /dev/null
+++ b/apps/console/src/graphql/definitions/queries/api-keys.tsx
@@ -0,0 +1,19 @@
+import { graphql } from "~/@generated/graphql";
+
+export const GET_ALL_PROVIDER_API_KEYS = graphql(/* GraphQL */ `
+ query ProviderApiKeys($data: GetProviderApiKeysInput!) {
+ providerApiKeys(data: $data) {
+ id
+ provider
+ censoredValue
+ }
+ }
+`);
+
+export const GET_ALL_API_KEYS = graphql(/* GraphQL */ `
+ query ApiKeys($data: GetApiKeysInput!) {
+ apiKeys(data: $data) {
+ id
+ }
+ }
+`);
diff --git a/apps/console/src/graphql/definitions/queries/environments.tsx b/apps/console/src/graphql/definitions/queries/environments.tsx
new file mode 100644
index 000000000..180e796ed
--- /dev/null
+++ b/apps/console/src/graphql/definitions/queries/environments.tsx
@@ -0,0 +1,10 @@
+import { graphql } from "~/@generated/graphql";
+
+export const GET_ALL_ENVIRONMENTS = graphql(/* GraphQL */ `
+ query Environments($data: GetEnvironmentsInput!) {
+ environments(data: $data) {
+ id
+ name
+ }
+ }
+`);
diff --git a/apps/console/src/graphql/definitions/queries/metrics.tsx b/apps/console/src/graphql/definitions/queries/metrics.tsx
new file mode 100644
index 000000000..fbb580a21
--- /dev/null
+++ b/apps/console/src/graphql/definitions/queries/metrics.tsx
@@ -0,0 +1,20 @@
+import { graphql } from "~/@generated/graphql";
+
+export const GET_GENERIC_PROJECT_METRIC_HISTOGRAM = graphql(/* GraphQL */ `
+ query getGenericProjectMetricHistogram(
+ $data: GetProjectGenericHistogramInput!
+ ) {
+ genericProjectMetricHistogram(data: $data) {
+ data
+ }
+ }
+`);
+
+export const GET_PROJECT_METRIC_DELTA = graphql(/* GraphQL */ `
+ query getProjectMetricDelta($data: GetProjectMetricDeltaInput!) {
+ projectMetricDelta(data: $data) {
+ currentValue
+ previousValue
+ }
+ }
+`);
diff --git a/apps/console/src/graphql/definitions/queries/organizations.tsx b/apps/console/src/graphql/definitions/queries/organizations.tsx
new file mode 100644
index 000000000..520d645eb
--- /dev/null
+++ b/apps/console/src/graphql/definitions/queries/organizations.tsx
@@ -0,0 +1,51 @@
+import { graphql } from "~/@generated/graphql";
+
+export const GET_ORGANIZATIONS = graphql(/* GraphQL */ `
+ query GetMyOrganizations {
+ organizations {
+ id
+ name
+ }
+ }
+`);
+
+export const GET_ORGANIZATION = graphql(/* GraphQL */ `
+ query GetOrg(
+ $data: OrganizationWhereUniqueInput!
+ $includeInvitations: Boolean = false
+ $includeMembers: Boolean = true
+ ) {
+ organization(data: $data) {
+ id
+ name
+ waitlisted
+ members @include(if: $includeMembers) {
+ id
+ role
+ user {
+ id
+ name
+ email
+ }
+ }
+ invitations @include(if: $includeInvitations) {
+ id
+ email
+ role
+ invitedBy {
+ photoUrl
+ }
+ }
+ }
+ }
+`);
+
+export const GET_USER_ORG_MEMBERSHIP = graphql(/* GraphQL */ `
+ query GetOrgMembership($data: GetUserOrgMembershipInput!) {
+ userOrgMembership(data: $data) {
+ userId
+ role
+ organizationId
+ }
+ }
+`);
diff --git a/apps/console/src/graphql/definitions/queries/projects.tsx b/apps/console/src/graphql/definitions/queries/projects.tsx
new file mode 100644
index 000000000..94f97716f
--- /dev/null
+++ b/apps/console/src/graphql/definitions/queries/projects.tsx
@@ -0,0 +1,26 @@
+import { graphql } from "~/@generated/graphql";
+
+export const GET_PROJECT = graphql(/* GraphQL */ `
+ query getProject($data: ProjectWhereUniqueInput!) {
+ project(data: $data) {
+ id
+ slug
+ name
+ organization {
+ id
+ name
+ }
+ }
+ }
+`);
+
+export const GET_ALL_PROJECTS = graphql(/* GraphQL */ `
+ query getProjects($data: GetProjectsInput!) {
+ projects(data: $data) {
+ id
+ slug
+ name
+ organizationId
+ }
+ }
+`);
diff --git a/apps/console/src/graphql/definitions/queries/prompt-executions.tsx b/apps/console/src/graphql/definitions/queries/prompt-executions.tsx
new file mode 100644
index 000000000..82a59c077
--- /dev/null
+++ b/apps/console/src/graphql/definitions/queries/prompt-executions.tsx
@@ -0,0 +1,7 @@
+import { graphql } from "~/@generated/graphql";
+
+export const TEST_PROMPT = graphql(/* GraphQL */ `
+ mutation testPrompt($data: TestPromptInput!) {
+ testPrompt(data: $data)
+ }
+`);
diff --git a/apps/console/src/graphql/definitions/queries/prompts.tsx b/apps/console/src/graphql/definitions/queries/prompts.tsx
new file mode 100644
index 000000000..e65a41fe7
--- /dev/null
+++ b/apps/console/src/graphql/definitions/queries/prompts.tsx
@@ -0,0 +1,61 @@
+import { graphql } from "~/@generated/graphql";
+
+export const GET_ALL_PROMPTS = graphql(/* GraphQL */ `
+ query getAllPrompts($data: GetProjectPromptsInput!) {
+ prompts(data: $data) {
+ id
+ name
+ isDraft
+ }
+ }
+`);
+
+export const GET_PROMPT = graphql(/* GraphQL */ `
+ query getPrompt($data: GetPromptInput!) {
+ prompt(data: $data) {
+ id
+ name
+ isDraft
+ latestVersion {
+ sha
+ message
+ createdBy {
+ name
+ photoUrl
+ }
+ }
+ }
+ }
+`);
+
+export const GET_PROMPT_VERSION = graphql(/* GraphQL */ `
+ query getPromptVersion($data: PromptVersionWhereUniqueInput!) {
+ promptVersion(data: $data) {
+ sha
+ type
+ service
+ content
+ settings
+ message
+ }
+ }
+`);
+
+export const GET_PROMPT_VERSIONS = graphql(/* GraphQL */ `
+ query GetPromptVersionsWithTags($data: GetPromptInput!) {
+ prompt(data: $data) {
+ versions {
+ type
+ sha
+ service
+ message
+ createdAt
+ createdBy {
+ name
+ photoUrl
+ email
+ }
+ }
+ }
+ }
+`);
diff --git a/apps/console/src/graphql/definitions/queries/requests.ts b/apps/console/src/graphql/definitions/queries/requests.ts
new file mode 100644
index 000000000..d7734274f
--- /dev/null
+++ b/apps/console/src/graphql/definitions/queries/requests.ts
@@ -0,0 +1,20 @@
+import { graphql } from "~/@generated/graphql";
+
+export const GET_ALL_REQUESTS = graphql(/* GraphQL */ `
+ query PaginatedRequests($data: GetRequestsInput!) {
+ paginatedRequests(data: $data) {
+ data
+ pagination {
+ offset
+ limit
+ total
+ }
+ }
+ }
+`);
+
+export const GET_REPORT = graphql(/* GraphQL */ `
+ query GetReport($data: GetReportInput!) {
+ report(data: $data)
+ }
+`);
diff --git a/apps/console/src/graphql/definitions/queries/users.ts b/apps/console/src/graphql/definitions/queries/users.ts
new file mode 100644
index 000000000..fe67a251f
--- /dev/null
+++ b/apps/console/src/graphql/definitions/queries/users.ts
@@ -0,0 +1,21 @@
+import { graphql } from "~/@generated/graphql";
+
+export const GET_ME = graphql(/* GraphQL */ `
+ query GetMe {
+ me {
+ id
+ email
+ photoUrl
+ name
+ organizationIds
+ }
+ }
+`);
+
+export const UPDATE_PROFILE = graphql(/* GraphQL */ `
+ mutation UpdateProfile($data: UpdateProfileInput!) {
+ updateProfile(data: $data) {
+ name
+ }
+ }
+`);
diff --git a/apps/console/src/graphql/hooks/mutations.ts b/apps/console/src/graphql/hooks/mutations.ts
new file mode 100644
index 000000000..62ef3b424
--- /dev/null
+++ b/apps/console/src/graphql/hooks/mutations.ts
@@ -0,0 +1,340 @@
+import {
+ AcceptInvitationMutation,
+ CreateOrgInvitationInput,
+ CreateOrgInvitationMutation,
+ CreateProjectInput,
+ CreateProjectMutation,
+ CreatePromptVersionInput,
+ CreatePromptVersionMutation,
+ DeleteEnvironmentMutation,
+ DeleteProviderApiKeyMutation,
+ DeleteProjectMutation,
+ UpdateProjectSettingsMutation,
+ DeletePromptMutation,
+ EnvironmentWhereUniqueInput,
+ InvitationWhereUniqueInput,
+ ProjectWhereUniqueInput,
+ TestPromptInput,
+ TestPromptMutation,
+ UpdateOrgInvitationInput,
+ UpdateOrgInvitationMutation,
+ UpdateOrgMemberRoleInput,
+ UpdateOrgMemberRoleMutation,
+ UpdateOrgSettingsInput,
+ UpdateOrgSettingsMutation,
+ UpdateProfileInput,
+ UpdateProjectSettingsInput,
+ DeleteProviderApiKeyInput,
+ DeleteOrgMemberMutation,
+ OrganizationMemberWhereUniqueInput,
+ DeleteInvitationMutation,
+} from "~/@generated/graphql/graphql";
+import { useMutation, useQueryClient } from "@tanstack/react-query";
+import { gqlClient } from "~/lib/graphql";
+import { UPDATE_PROFILE } from "../definitions/queries/users";
+import {
+ CREATE_PROJECT,
+ DELETE_PROJECT,
+ UPDATE_PROJECT_SETTINGS,
+} from "../definitions/mutations/projects";
+import {
+ ACCEPT_ORG_INVITATION,
+ CREATE_ORG_INVITATION,
+ DELETE_INVITATION,
+ DELETE_ORG_MEMBER,
+ UPDATE_ORG_INVITATION,
+ UPDATE_ORG_MEMBER_ROLE,
+ UPDATE_ORG_SETTINGS,
+} from "../definitions/mutations/organizations";
+import { GraphQLErrorResponse } from "../types";
+import {
+ CREATE_PROMPT_VERSION,
+ DELETE_PROMPT,
+} from "../definitions/mutations/prompts";
+import { DELETE_ENVIRONMENT } from "../definitions/mutations/environments";
+import { DELETE_PROVIDER_API_KEY } from "../definitions/mutations/api-keys";
+import { useCurrentPrompt } from "~/lib/providers/CurrentPromptContext";
+import { TEST_PROMPT } from "../definitions/queries/prompt-executions";
+import { useEditorContext } from "~/lib/providers/EditorContext";
+
+export const useUpdateCurrentUserMutation = () =>
+ useMutation({
+ mutationFn: ({ name }: UpdateProfileInput) =>
+ gqlClient.request(UPDATE_PROFILE, { data: { name } }),
+ });
+
+export const useCreateProjectMutation = (props?: {
+ onSuccess?: () => void;
+}) => {
+ const queryClient = useQueryClient();
+
+ return useMutation<
+ CreateProjectMutation,
+ GraphQLErrorResponse,
+ CreateProjectInput
+ >({
+ mutationFn: (data: CreateProjectInput) =>
+ gqlClient.request(CREATE_PROJECT, { data }),
+ onSuccess: () => {
+ queryClient.invalidateQueries({
+ queryKey: ["projects"],
+ });
+ props?.onSuccess?.();
+ },
+ });
+};
+
+export const useDeleteProjectMutation = () => {
+ const queryClient = useQueryClient();
+
+ return useMutation<
+ DeleteProjectMutation,
+ GraphQLErrorResponse,
+ ProjectWhereUniqueInput
+ >({
+ mutationFn: (data: ProjectWhereUniqueInput) =>
+ gqlClient.request(DELETE_PROJECT, { data }),
+ onSuccess: () => {
+ queryClient.invalidateQueries({
+ queryKey: ["projects"],
+ });
+ },
+ });
+};
+
+export const useUpdateProjectSettingsMutation = () => {
+ const queryClient = useQueryClient();
+
+ return useMutation<
+ UpdateProjectSettingsMutation,
+ GraphQLErrorResponse,
+ UpdateProjectSettingsInput
+ >({
+ mutationFn: (data: UpdateProjectSettingsInput) =>
+ gqlClient.request(UPDATE_PROJECT_SETTINGS, {
+ data,
+ }),
+ onSuccess: () => {
+ queryClient.invalidateQueries({
+ queryKey: ["projects"],
+ });
+ },
+ });
+};
+
+export const useDeleteOrgInvitationMutation = () => {
+ const queryClient = useQueryClient();
+
+ return useMutation<
+ DeleteInvitationMutation,
+ GraphQLErrorResponse,
+ InvitationWhereUniqueInput
+ >({
+ mutationFn: (data) => gqlClient.request(DELETE_INVITATION, { data }),
+ onSuccess: () => {
+ queryClient.invalidateQueries({
+ queryKey: ["currentOrganization"],
+ });
+ },
+ });
+};
+
+export const useAcceptOrgInvitationMutation = () => {
+ const queryClient = useQueryClient();
+
+ return useMutation<
+ AcceptInvitationMutation,
+ GraphQLErrorResponse,
+ InvitationWhereUniqueInput
+ >({
+ mutationFn: (data) => gqlClient.request(ACCEPT_ORG_INVITATION, { data }),
+ onSuccess: () => {
+ queryClient.invalidateQueries({
+ queryKey: ["organizations"],
+ });
+ },
+ });
+};
+
+export const useDeleteOrgMemberMutation = () => {
+ const queryClient = useQueryClient();
+
+ return useMutation<
+ DeleteOrgMemberMutation,
+ GraphQLErrorResponse,
+ OrganizationMemberWhereUniqueInput
+ >({
+ mutationFn: (data) => gqlClient.request(DELETE_ORG_MEMBER, { data }),
+ onSuccess: () => {
+ queryClient.invalidateQueries({
+ queryKey: ["currentOrganization"],
+ });
+ },
+ });
+};
+
+export const useCreateOrgInvitationMutation = () => {
+ const queryClient = useQueryClient();
+
+ return useMutation<
+ CreateOrgInvitationMutation,
+ GraphQLErrorResponse,
+ CreateOrgInvitationInput
+ >({
+ mutationFn: (data: CreateOrgInvitationInput) =>
+ gqlClient.request(CREATE_ORG_INVITATION, { data }),
+ onSuccess: () => {
+ queryClient.invalidateQueries({
+ queryKey: ["currentOrganization"],
+ });
+ },
+ });
+};
+
+export const useUpdateOrgInvitationMutation = () => {
+ const queryClient = useQueryClient();
+
+ return useMutation<
+ UpdateOrgInvitationMutation,
+ GraphQLErrorResponse,
+ UpdateOrgInvitationInput
+ >({
+ mutationFn: (data: UpdateOrgInvitationInput) =>
+ gqlClient.request(UPDATE_ORG_INVITATION, { data }),
+ onSuccess: () => {
+ queryClient.invalidateQueries({
+ queryKey: ["currentOrganization"],
+ });
+ },
+ });
+};
+
+export const useDeletePromptMutation = () => {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: (id: string) =>
+ gqlClient.request(DELETE_PROMPT, {
+ data: {
+ id,
+ },
+ }),
+ onSuccess: (data) => {
+ queryClient.invalidateQueries({ queryKey: ["prompts"] });
+ },
+ });
+};
+
+export const useUpdateOrgMemberRoleMutation = () => {
+ const queryClient = useQueryClient();
+
+ return useMutation<
+ UpdateOrgMemberRoleMutation,
+ GraphQLErrorResponse,
+ UpdateOrgMemberRoleInput
+ >({
+ mutationFn: (data: UpdateOrgMemberRoleInput) =>
+ gqlClient.request(UPDATE_ORG_MEMBER_ROLE, { data }),
+ onSuccess: () => {
+ queryClient.invalidateQueries({
+ queryKey: ["currentOrganization"],
+ });
+ },
+ });
+};
+
+export const useUpdateOrgSettingsMutation = () => {
+ const queryClient = useQueryClient();
+
+ return useMutation<
+ UpdateOrgSettingsMutation,
+ GraphQLErrorResponse,
+ UpdateOrgSettingsInput
+ >({
+ mutationFn: (data: UpdateOrgSettingsInput) =>
+ gqlClient.request(UPDATE_ORG_SETTINGS, { data }),
+ onSuccess: () => {
+ queryClient.invalidateQueries({
+ queryKey: ["currentOrganization"],
+ });
+ },
+ });
+};
+
+export const useDeleteEnvironmentMutation = () => {
+ const queryClient = useQueryClient();
+
+ return useMutation<
+ DeleteEnvironmentMutation,
+ GraphQLErrorResponse,
+ EnvironmentWhereUniqueInput
+ >({
+ mutationFn: (data: EnvironmentWhereUniqueInput) =>
+ gqlClient.request(DELETE_ENVIRONMENT, { data }),
+ onSuccess: () => {
+ queryClient.invalidateQueries({
+ queryKey: ["environments"],
+ });
+ },
+ });
+};
+
+export const useDeleteProviderApiKeyMutation = () => {
+ const queryClient = useQueryClient();
+
+ return useMutation<
+ DeleteProviderApiKeyMutation,
+ GraphQLErrorResponse,
+ DeleteProviderApiKeyInput
+ >({
+ mutationFn: (data: DeleteProviderApiKeyInput) =>
+ gqlClient.request(DELETE_PROVIDER_API_KEY, { data }),
+ onSuccess: () => {
+ queryClient.invalidateQueries({
+ queryKey: ["providerApiKeys"],
+ });
+ },
+ });
+};
+
+export const useCreatePromptVersion = () => {
+ const { prompt } = useCurrentPrompt();
+ const { setCurrentVersionSha } = useEditorContext();
+ const queryClient = useQueryClient();
+
+ return useMutation<
+ CreatePromptVersionMutation,
+ GraphQLErrorResponse,
+ CreatePromptVersionInput
+ >({
+ mutationFn: (data: CreatePromptVersionInput) => {
+ return gqlClient.request(CREATE_PROMPT_VERSION, {
+ data: {
+ type: data.type,
+ service: data.service,
+ message: data.message,
+ content: data.content,
+ settings: data.settings,
+ promptId: data.promptId,
+ },
+ });
+ },
+ onSuccess: (data) => {
+ const { sha } = data.createPromptVersion;
+ queryClient.invalidateQueries(["prompt", prompt.id]);
+ setCurrentVersionSha(sha);
+ },
+ });
+};
+
+export const useTestPrompt = () => {
+ return useMutation(
+ {
+ mutationFn: (data: TestPromptInput) => {
+ return gqlClient.request(TEST_PROMPT, {
+ data,
+ });
+ },
+ }
+ );
+};
diff --git a/apps/console/src/graphql/hooks/queries.ts b/apps/console/src/graphql/hooks/queries.ts
new file mode 100644
index 000000000..3bb2897e5
--- /dev/null
+++ b/apps/console/src/graphql/hooks/queries.ts
@@ -0,0 +1,197 @@
+import { UseQueryOptions, useQuery } from "@tanstack/react-query";
+import { gqlClient } from "~/lib/graphql";
+import {
+ GET_ALL_API_KEYS,
+ GET_ALL_PROVIDER_API_KEYS,
+} from "../definitions/queries/api-keys";
+import { GET_ME } from "../definitions/queries/users";
+import { GET_ALL_PROJECTS } from "../definitions/queries/projects";
+import { useCurrentOrganization } from "~/lib/hooks/useCurrentOrganization";
+import { useCurrentProject } from "~/lib/hooks/useCurrentProject";
+import { GET_ALL_REQUESTS, GET_REPORT } from "../definitions/queries/requests";
+import {
+ GetPromptQuery,
+ GetPromptVersionQuery,
+ PaginatedRequestsQuery,
+ GetGenericProjectMetricHistogramQueryVariables,
+ GetGenericProjectMetricHistogramQuery,
+ GetProjectMetricDeltaQueryVariables,
+ GetProjectMetricDeltaQuery,
+ GetReportQuery,
+ GetReportQueryVariables,
+} from "~/@generated/graphql/graphql";
+import { GraphQLErrorResponse } from "../types";
+import { GET_PROMPT, GET_PROMPT_VERSION } from "../definitions/queries/prompts";
+import { useFiltersAndSortParams } from "~/lib/hooks/useFiltersAndSortParams";
+import {
+ GET_GENERIC_PROJECT_METRIC_HISTOGRAM,
+ GET_PROJECT_METRIC_DELTA,
+} from "../definitions/queries/metrics";
+import { SerializedReport } from "@pezzo/types";
+
+export const useProviderApiKeys = () => {
+ const { organization } = useCurrentOrganization();
+
+ const result = useQuery({
+ queryKey: ["providerApiKeys", organization?.id],
+ queryFn: () =>
+ gqlClient.request(GET_ALL_PROVIDER_API_KEYS, {
+ data: { organizationId: organization?.id },
+ }),
+ });
+
+ return { ...result, providerApiKeys: result.data?.providerApiKeys ?? [] };
+};
+
+export const usePezzoApiKeys = () => {
+ const { organization } = useCurrentOrganization();
+
+ const { data, isLoading } = useQuery({
+ queryKey: ["pezzoApiKeys", organization?.id],
+ queryFn: () =>
+ gqlClient.request(GET_ALL_API_KEYS, {
+ data: { organizationId: organization?.id },
+ }),
+ });
+
+ return {
+ pezzoApiKeys: data?.apiKeys,
+ isLoading,
+ };
+};
+
+export const useGetCurrentUser = () =>
+ useQuery({ queryKey: ["me"], queryFn: () => gqlClient.request(GET_ME) });
+
+export const useGetProjects = () => {
+ const { organization } = useCurrentOrganization();
+
+ const { data, isLoading } = useQuery({
+ queryKey: ["projects", organization?.id],
+ queryFn: () =>
+ gqlClient.request(GET_ALL_PROJECTS, {
+ data: { organizationId: organization?.id },
+ }),
+ enabled: !!organization,
+ });
+
+ return {
+ projects: data?.projects,
+ isLoading,
+ };
+};
+
+export const useGetPrompt = (
+ promptId: string,
+ options: UseQueryOptions = {}
+) => {
+ const result = useQuery({
+ queryKey: ["prompt", promptId],
+ queryFn: () =>
+ gqlClient.request(GET_PROMPT, {
+ data: { promptId },
+ }),
+ ...options,
+ });
+
+ return { ...result, prompt: result.data?.prompt };
+};
+
+export const useGetPromptVersion = (
+ {
+ promptId,
+ promptVersionSha,
+ }: { promptId: string; promptVersionSha: string },
+ options: UseQueryOptions = {}
+) => {
+ const result = useQuery({
+ queryKey: ["prompt", promptId, "version", promptVersionSha],
+ queryFn: () =>
+ gqlClient.request(GET_PROMPT_VERSION, {
+ data: { sha: promptVersionSha },
+ }),
+ ...options,
+ });
+
+ return { ...result, promptVersion: result.data?.promptVersion };
+};
+
+export const useGetRequestReports = (
+ {
+ offset,
+ limit,
+ }: {
+ offset: number;
+ limit: number;
+ },
+ { enabled = true }: UseQueryOptions
+) => {
+ const { projectId } = useCurrentProject();
+ const { filters, sort } = useFiltersAndSortParams();
+
+ return useQuery({
+ queryFn: () =>
+ gqlClient.request(GET_ALL_REQUESTS, {
+ data: { projectId, offset, limit, filters, sort },
+ }),
+ queryKey: ["requests", projectId, offset, limit, filters, sort],
+ enabled,
+ });
+};
+
+export const useReport = (
+ data: GetReportQueryVariables["data"],
+ options: UseQueryOptions = {}
+) => {
+ const result = useQuery({
+ queryKey: ["report", data.reportId],
+ queryFn: () =>
+ gqlClient.request(GET_REPORT, {
+ data,
+ }),
+ ...options,
+ });
+
+ return { ...result, report: result.data?.report as SerializedReport };
+};
+
+export const useGenericProjectMetricHistogram = (
+ data: GetGenericProjectMetricHistogramQueryVariables["data"],
+ options: UseQueryOptions<
+ GetGenericProjectMetricHistogramQuery,
+ GraphQLErrorResponse
+ > = {}
+) => {
+ const result = useQuery({
+ queryKey: ["genericProjectMetricHistogram", ...Object.values(data)],
+ queryFn: () =>
+ gqlClient.request(GET_GENERIC_PROJECT_METRIC_HISTOGRAM, {
+ data,
+ }),
+ ...options,
+ });
+
+ return {
+ ...result,
+ histogram: result.data?.genericProjectMetricHistogram as { data: T },
+ };
+};
+
+export const useProjctMetricDelta = (
+ data: GetProjectMetricDeltaQueryVariables["data"],
+ options: UseQueryOptions<
+ GetProjectMetricDeltaQuery,
+ GraphQLErrorResponse
+ > = {}
+) => {
+ const result = useQuery({
+ queryKey: ["projectMetricDelta", ...Object.values(data)],
+ queryFn: () =>
+ gqlClient.request(GET_PROJECT_METRIC_DELTA, {
+ data,
+ }),
+ ...options,
+ });
+
+ return { ...result, data: result.data?.projectMetricDelta };
+};
diff --git a/apps/console/src/graphql/types.ts b/apps/console/src/graphql/types.ts
new file mode 100644
index 000000000..240e8cdc8
--- /dev/null
+++ b/apps/console/src/graphql/types.ts
@@ -0,0 +1,14 @@
+import { Provider } from "@pezzo/types";
+import { GraphQLError } from "graphql-request/build/esm/types";
+
+export interface GraphQLErrorResponse {
+ response:
+ | {
+ errors: GraphQLError[];
+ }
+ | undefined;
+}
+
+export type ReportRequestResponse<
+ TProviderType extends Provider | unknown = unknown
+> = Record;
diff --git a/apps/console/src/index.html b/apps/console/src/index.html
index 219221745..bf8748b15 100644
--- a/apps/console/src/index.html
+++ b/apps/console/src/index.html
@@ -25,7 +25,7 @@
href="./assets/favicon/favicon-16x16.png"
/>
-
+
diff --git a/apps/console/src/lib/auth/supertokens.ts b/apps/console/src/lib/auth/supertokens.ts
new file mode 100644
index 000000000..8c1498dbb
--- /dev/null
+++ b/apps/console/src/lib/auth/supertokens.ts
@@ -0,0 +1,38 @@
+import SuperTokens from "supertokens-auth-react";
+import ThirdPartyEmailPassword, {
+ Google,
+} from "supertokens-auth-react/recipe/thirdpartyemailpassword";
+import Session from "supertokens-auth-react/recipe/session";
+import {
+ AUTH_GOOGLE_ENABLED,
+ SUPERTOKENS_API_DOMAIN,
+ SUPERTOKENS_WEBSITE_DOMAIN,
+} from "~/env";
+
+export const googleEnabled = AUTH_GOOGLE_ENABLED === "true";
+
+export const initSuperTokens = () => {
+ const providers = [];
+
+ if (googleEnabled) {
+ providers.push(Google.init());
+ }
+
+ SuperTokens.init({
+ appInfo: {
+ appName: "Pezzo",
+ apiDomain: SUPERTOKENS_API_DOMAIN,
+ websiteDomain: SUPERTOKENS_WEBSITE_DOMAIN,
+ apiBasePath: "/api/auth",
+ websiteBasePath: "/login",
+ },
+ recipeList: [
+ ThirdPartyEmailPassword.init({
+ signInAndUpFeature: {
+ providers,
+ },
+ }),
+ Session.init(),
+ ],
+ });
+};
diff --git a/apps/console/src/lib/constants/filters.ts b/apps/console/src/lib/constants/filters.ts
new file mode 100644
index 000000000..fb8c151cc
--- /dev/null
+++ b/apps/console/src/lib/constants/filters.ts
@@ -0,0 +1,81 @@
+export type FilterDefinition = {
+ value: string;
+ type: "number" | "string" | "date";
+ label: string;
+ formatter?: (value: string) => string;
+};
+
+export const FILTER_FIELDS_LIST: FilterDefinition[] = [
+ {
+ value: "duration",
+ type: "number",
+ label: "Duration (ms)",
+ },
+ {
+ value: "environment",
+ type: "string",
+ label: "Environment",
+ },
+ {
+ value: "responseStatusCode",
+ type: "number",
+ label: "Status",
+ },
+ {
+ value: "timestamp",
+ type: "date",
+ label: "Timestamp",
+ },
+ {
+ value: "totalCost",
+ type: "number",
+ label: "Total Cost",
+ },
+ {
+ value: "totalTokens",
+ type: "number",
+ label: "Total Tokens",
+ },
+];
+
+export const NUMBER_FILTER_OPERATORS: { value: string; label: string }[] = [
+ {
+ value: "eq",
+ label: "=",
+ },
+ {
+ value: "neq",
+ label: "!=",
+ },
+ {
+ value: "gt",
+ label: ">",
+ },
+ {
+ value: "gte",
+ label: ">=",
+ },
+ {
+ value: "lt",
+ label: "<",
+ },
+ {
+ value: "lte",
+ label: "<=",
+ },
+];
+
+export const STRING_FILTER_OPERATORS: { value: string; label: string }[] = [
+ {
+ value: "eq",
+ label: "=",
+ },
+ {
+ value: "neq",
+ label: "!=",
+ },
+ {
+ value: "like",
+ label: "LIKE",
+ },
+];
diff --git a/apps/console/src/lib/constants/pagination.ts b/apps/console/src/lib/constants/pagination.ts
new file mode 100644
index 000000000..16c3b7f39
--- /dev/null
+++ b/apps/console/src/lib/constants/pagination.ts
@@ -0,0 +1,2 @@
+export const PAGE_SIZE_OPTIONS = ["10", "25", "50", "100"];
+export const DEFAULT_PAGE_SIZE = 10;
diff --git a/apps/console/src/lib/graphql.ts b/apps/console/src/lib/graphql.ts
new file mode 100644
index 000000000..3aa5946ab
--- /dev/null
+++ b/apps/console/src/lib/graphql.ts
@@ -0,0 +1,40 @@
+import { QueryClient } from "@tanstack/react-query";
+import { GraphQLClient } from "graphql-request";
+import { BASE_API_URL } from "~/env";
+import { attemptRefreshingSession } from "supertokens-auth-react/recipe/session";
+import { signOut } from "./utils/sign-out";
+
+export const gqlClient = new GraphQLClient(`${BASE_API_URL}/graphql`, {
+ credentials: "include",
+ fetch: async (url, options) => {
+ const res = await fetch(url, options);
+ const json = await res.clone().json();
+
+ if (json.errors && json.errors.length > 0) {
+ // Check if auth error
+ if (json.errors[0].extensions?.code === "UNAUTHORIZED") {
+ // Attempt to refresh the session
+ const isSuccessful = await attemptRefreshingSession();
+
+ if (!isSuccessful) {
+ await signOut();
+ return;
+ }
+
+ // Retry request
+ return fetch(url, options);
+ }
+ }
+
+ return res;
+ },
+});
+
+export const queryClient = new QueryClient({
+ defaultOptions: {
+ queries: {
+ staleTime: 5 * 1000,
+ suspense: true,
+ },
+ },
+});
diff --git a/apps/console/src/lib/hooks/useCurrentOrgMembership.tsx b/apps/console/src/lib/hooks/useCurrentOrgMembership.tsx
new file mode 100644
index 000000000..2fdd92701
--- /dev/null
+++ b/apps/console/src/lib/hooks/useCurrentOrgMembership.tsx
@@ -0,0 +1,27 @@
+import { useQuery } from "@tanstack/react-query";
+import { gqlClient } from "../graphql";
+import { GET_USER_ORG_MEMBERSHIP } from "~/graphql/definitions/queries/organizations";
+import { useCurrentOrganization } from "./useCurrentOrganization";
+import { useAuthContext } from "../providers/AuthProvider";
+import { OrgRole } from "~/@generated/graphql/graphql";
+
+export const useCurrentOrgMembership = () => {
+ const { organization } = useCurrentOrganization();
+ const { currentUser } = useAuthContext();
+
+ const { data, isLoading } = useQuery({
+ queryKey: ["userOrgMembership", organization?.id, currentUser.id],
+ queryFn: async () =>
+ gqlClient.request(GET_USER_ORG_MEMBERSHIP, {
+ data: { organizationId: organization?.id, userId: currentUser.id },
+ }),
+ enabled: !!organization && !!currentUser,
+ });
+
+ return {
+ membership: data?.userOrgMembership,
+ role: data?.userOrgMembership.role,
+ isOrgAdmin: data?.userOrgMembership.role === OrgRole.Admin,
+ isLoading,
+ };
+};
diff --git a/apps/console/src/lib/hooks/useCurrentOrganization.tsx b/apps/console/src/lib/hooks/useCurrentOrganization.tsx
new file mode 100644
index 000000000..8d03f965d
--- /dev/null
+++ b/apps/console/src/lib/hooks/useCurrentOrganization.tsx
@@ -0,0 +1,64 @@
+import { useQuery } from "@tanstack/react-query";
+import { gqlClient } from "../graphql";
+import { GET_ORGANIZATION } from "~/graphql/definitions/queries/organizations";
+import { useLocalStorage } from "usehooks-ts";
+import { useEffect } from "react";
+import { useOrganizations } from "./useOrganizations";
+import { useParams } from "react-router-dom";
+
+const defaultProps = {
+ includeMembers: false,
+ includeInvitations: false,
+};
+
+export const useCurrentOrganization = ({
+ includeMembers,
+ includeInvitations,
+} = defaultProps) => {
+ const { organizations } = useOrganizations();
+ const { orgId } = useParams<{ orgId: string }>();
+
+ // TODO: currentOrgId in local storage might be different than the actual org if customer has multiple orgs for multiple users
+ const [currentOrgId, setCurrentOrgId] = useLocalStorage(
+ "currentOrgId",
+ orgId
+ );
+
+ useEffect(() => {
+ if (organizations && !currentOrgId) {
+ setCurrentOrgId(organizations[0].id);
+ }
+ }, [currentOrgId, organizations, setCurrentOrgId]);
+
+ const { data, isLoading, isSuccess, error, isError } = useQuery({
+ queryKey: [
+ "currentOrganization",
+ currentOrgId,
+ includeMembers,
+ includeInvitations,
+ ],
+ queryFn: async () =>
+ gqlClient.request(GET_ORGANIZATION, {
+ data: { id: currentOrgId },
+ includeMembers,
+ includeInvitations,
+ }),
+ enabled: !!currentOrgId,
+ });
+
+ const selectOrg = (orgId: string) => {
+ setCurrentOrgId(orgId);
+ };
+
+ return {
+ organization: data?.organization,
+ organizationId: data?.organization?.id,
+ isLoading,
+ isSuccess,
+ isError,
+ error,
+ currentOrgId,
+ selectOrg,
+ waitlisted: data?.organization?.waitlisted,
+ };
+};
diff --git a/apps/console/src/lib/hooks/useCurrentProject.ts b/apps/console/src/lib/hooks/useCurrentProject.ts
new file mode 100644
index 000000000..43724fd92
--- /dev/null
+++ b/apps/console/src/lib/hooks/useCurrentProject.ts
@@ -0,0 +1,15 @@
+import { useMemo } from "react";
+import { useParams } from "react-router-dom";
+import { useGetProjects } from "~/graphql/hooks/queries";
+
+export const useCurrentProject = () => {
+ const { projectId } = useParams();
+ const { projects, isLoading } = useGetProjects();
+
+ const project = useMemo(
+ () => projects?.find((project) => project.id === projectId),
+ [projects, projectId]
+ );
+
+ return { project, projectId: project?.id, isLoading };
+};
diff --git a/apps/console/src/lib/hooks/useEnvironments.ts b/apps/console/src/lib/hooks/useEnvironments.ts
new file mode 100644
index 000000000..f4019b862
--- /dev/null
+++ b/apps/console/src/lib/hooks/useEnvironments.ts
@@ -0,0 +1,22 @@
+import { useQuery } from "@tanstack/react-query";
+import { gqlClient } from "../graphql";
+import { GET_ALL_ENVIRONMENTS } from "~/graphql/definitions/queries/environments";
+import { useCurrentProject } from "./useCurrentProject";
+
+export const useEnvironments = () => {
+ const { project } = useCurrentProject();
+
+ const { data, isLoading } = useQuery({
+ queryKey: ["environments"],
+ queryFn: () =>
+ gqlClient.request(GET_ALL_ENVIRONMENTS, {
+ data: { projectId: project.id },
+ }),
+ enabled: !!project,
+ });
+
+ return {
+ environments: data?.environments,
+ isLoading,
+ };
+};
diff --git a/apps/console/src/lib/hooks/useFiltersAndSortParams.ts b/apps/console/src/lib/hooks/useFiltersAndSortParams.ts
new file mode 100644
index 000000000..1b718b7a3
--- /dev/null
+++ b/apps/console/src/lib/hooks/useFiltersAndSortParams.ts
@@ -0,0 +1,66 @@
+import { useSearchParams } from "react-router-dom";
+import { useCallback, useEffect, useMemo } from "react";
+import { extractSortAndFiltersFromSearchParams } from "../utils/filters-utils";
+import { FilterInput } from "~/@generated/graphql/graphql";
+import { trackEvent } from "../utils/analytics";
+
+export const useFiltersAndSortParams = () => {
+ const [searchParams, setSearchParams] = useSearchParams();
+
+ const { filters, sort } = useMemo(
+ () => extractSortAndFiltersFromSearchParams(searchParams),
+ [searchParams]
+ );
+
+ useEffect(() => {
+ if (!sort) setSearchParams({ sort: "timestamp:desc" });
+ }, [setSearchParams, sort]);
+
+ const addFilter = useCallback(
+ ({ field, operator, value }: FilterInput) => {
+ searchParams.append("f", `${field}:${operator}:${value}`);
+ setSearchParams(searchParams);
+ trackEvent("request_details_filter_added", {
+ field,
+ operator,
+ value,
+ });
+ },
+ [searchParams, setSearchParams]
+ );
+
+ const removeFilter = useCallback(
+ (filterToRemove: FilterInput) => {
+ // Get all instances of the parameter
+ const allParams = searchParams.getAll("f");
+
+ // Remove the specified instance
+ const newParams = allParams.filter(
+ (filter) =>
+ filter !==
+ `${filterToRemove.field}:${filterToRemove.operator}:${filterToRemove.value}`
+ );
+
+ trackEvent("request_details_filter_removed");
+
+ // Delete all instances of the parameter from searchParams
+ searchParams.delete("f");
+
+ // Append the remaining instances to searchParams
+ for (const newValue of newParams) {
+ searchParams.append("f", newValue);
+ }
+
+ // Update the search parameters in the URL
+ setSearchParams(searchParams);
+ },
+ [searchParams, setSearchParams]
+ );
+
+ return {
+ filters,
+ sort,
+ removeFilter,
+ addFilter,
+ };
+};
diff --git a/apps/console/src/lib/hooks/useOrganizations.tsx b/apps/console/src/lib/hooks/useOrganizations.tsx
new file mode 100644
index 000000000..aee6f8fa6
--- /dev/null
+++ b/apps/console/src/lib/hooks/useOrganizations.tsx
@@ -0,0 +1,18 @@
+import { useQuery } from "@tanstack/react-query";
+import { gqlClient } from "../graphql";
+import { GET_ORGANIZATIONS } from "~/graphql/definitions/queries/organizations";
+
+export const useOrganizations = () => {
+ const { data, isLoading, isSuccess, isError, error } = useQuery({
+ queryKey: ["organizations"],
+ queryFn: async () => gqlClient.request(GET_ORGANIZATIONS),
+ });
+
+ return {
+ organizations: data?.organizations,
+ isLoading,
+ isSuccess,
+ isError,
+ error,
+ };
+};
diff --git a/apps/console/src/lib/hooks/usePageTitle.tsx b/apps/console/src/lib/hooks/usePageTitle.tsx
new file mode 100644
index 000000000..ae7081420
--- /dev/null
+++ b/apps/console/src/lib/hooks/usePageTitle.tsx
@@ -0,0 +1,10 @@
+import { useDocumentTitle } from "usehooks-ts";
+
+/**
+ * Custom hook to set the page title in the format: "{feature} - Pezzo"
+ * @param feature - The name of the feature or page.
+ */
+export const usePageTitle = (feature: string = null) => {
+ const title = feature ? `${feature} - Pezzo` : "Pezzo";
+ useDocumentTitle(title);
+};
diff --git a/apps/console/src/lib/hooks/useProject.ts b/apps/console/src/lib/hooks/useProject.ts
new file mode 100644
index 000000000..875c43afd
--- /dev/null
+++ b/apps/console/src/lib/hooks/useProject.ts
@@ -0,0 +1,18 @@
+import { useQuery } from "@tanstack/react-query";
+import { gqlClient } from "../graphql";
+import { GET_PROJECT } from "~/graphql/definitions/queries/projects";
+
+export const useProject = (projectId: string) => {
+ const { data, isLoading } = useQuery({
+ queryKey: ["project", projectId],
+ queryFn: () =>
+ gqlClient.request(GET_PROJECT, {
+ data: { id: projectId },
+ }),
+ });
+
+ return {
+ project: data?.project,
+ isLoading,
+ };
+};
diff --git a/apps/console/src/lib/hooks/usePromptVersions.ts b/apps/console/src/lib/hooks/usePromptVersions.ts
new file mode 100644
index 000000000..9842dffc9
--- /dev/null
+++ b/apps/console/src/lib/hooks/usePromptVersions.ts
@@ -0,0 +1,18 @@
+import { useQuery } from "@tanstack/react-query";
+import { gqlClient } from "~/lib/graphql";
+import { GET_PROMPT_VERSIONS } from "~/graphql/definitions/queries/prompts";
+
+export const usePromptVersions = (promptId: string) => {
+ const { data, isLoading } = useQuery({
+ queryKey: ["promptVersions", promptId],
+ queryFn: () =>
+ gqlClient.request(GET_PROMPT_VERSIONS, {
+ data: { promptId },
+ }),
+ });
+
+ return {
+ promptVersions: data?.prompt?.versions,
+ isLoading,
+ };
+};
diff --git a/apps/console/src/lib/hooks/usePrompts.ts b/apps/console/src/lib/hooks/usePrompts.ts
new file mode 100644
index 000000000..9d5c95b8e
--- /dev/null
+++ b/apps/console/src/lib/hooks/usePrompts.ts
@@ -0,0 +1,22 @@
+import { useQuery } from "@tanstack/react-query";
+import { gqlClient } from "../graphql";
+import { useCurrentProject } from "./useCurrentProject";
+import { GET_ALL_PROMPTS } from "~/graphql/definitions/queries/prompts";
+
+export const usePrompts = () => {
+ const { project } = useCurrentProject();
+
+ const { data, isLoading } = useQuery({
+ queryKey: ["prompts"],
+ queryFn: () =>
+ gqlClient.request(GET_ALL_PROMPTS, {
+ data: { projectId: project.id },
+ }),
+ enabled: !!project,
+ });
+
+ return {
+ prompts: data?.prompts,
+ isLoading,
+ };
+};
diff --git a/apps/console/src/lib/hooks/useQueryState.tsx b/apps/console/src/lib/hooks/useQueryState.tsx
new file mode 100644
index 000000000..ccc8f6c72
--- /dev/null
+++ b/apps/console/src/lib/hooks/useQueryState.tsx
@@ -0,0 +1,47 @@
+import { useCallback, useEffect, useState } from "react";
+import { useSearchParams } from "react-router-dom";
+
+export function useQueryState(key, defaultValue = undefined) {
+ const [searchParams, setSearchParams] = useSearchParams();
+ const [value, setValue] = useState(() => {
+ const paramValue = searchParams.get(key);
+ // If there's a value in the URL, use it; otherwise, use defaultValue if provided.
+ return paramValue !== null ? paramValue : defaultValue;
+ });
+
+ // Set the default value in the URL if it's provided and doesn't exist.
+ useEffect(() => {
+ if (defaultValue !== undefined && !searchParams.has(key)) {
+ const newSearchParams = new URLSearchParams(searchParams);
+ newSearchParams.set(key, defaultValue);
+ setSearchParams(newSearchParams, { replace: true });
+ }
+ }, [key, defaultValue, searchParams, setSearchParams]);
+
+ // Update the state when the URL changes.
+ useEffect(() => {
+ const paramValue = searchParams.get(key);
+ if (paramValue !== value) {
+ // Only set the value if it's not null, which indicates that it's present in the URL.
+ setValue(paramValue !== null ? paramValue : defaultValue);
+ }
+ }, [key, defaultValue, value, searchParams]);
+
+ // Function to update both the state and the URL search params.
+ const setQueryState = useCallback(
+ (newValue) => {
+ setValue(newValue);
+ const newSearchParams = new URLSearchParams(searchParams);
+ // If newValue is undefined, delete the search param; otherwise, set the new value.
+ if (newValue === undefined) {
+ newSearchParams.delete(key);
+ } else {
+ newSearchParams.set(key, newValue);
+ }
+ setSearchParams(newSearchParams, { replace: true });
+ },
+ [key, searchParams, setSearchParams]
+ );
+
+ return [value, setQueryState];
+}
diff --git a/apps/console/src/lib/hooks/useWaitlist.ts b/apps/console/src/lib/hooks/useWaitlist.ts
new file mode 100644
index 000000000..adccc66eb
--- /dev/null
+++ b/apps/console/src/lib/hooks/useWaitlist.ts
@@ -0,0 +1,14 @@
+import { useMemo } from "react";
+import { useCurrentOrganization } from "./useCurrentOrganization";
+
+export const useWaitlist = () => {
+ const { isSuccess, currentOrgId, waitlisted } = useCurrentOrganization();
+
+ const shouldRenderWaitlistNotice = useMemo(() => {
+ return isSuccess && currentOrgId && waitlisted;
+ }, [isSuccess, currentOrgId, waitlisted]);
+
+ return {
+ shouldRenderWaitlistNotice,
+ };
+};
diff --git a/apps/console/src/lib/providers/AuthProvider.tsx b/apps/console/src/lib/providers/AuthProvider.tsx
new file mode 100644
index 000000000..190cbfec0
--- /dev/null
+++ b/apps/console/src/lib/providers/AuthProvider.tsx
@@ -0,0 +1,44 @@
+import { hotjar } from "react-hotjar";
+import { createContext, useContext, useEffect, useMemo } from "react";
+import { useGetCurrentUser } from "~/graphql/hooks/queries";
+import { GetMeQuery } from "~/@generated/graphql/graphql";
+import { useIdentify } from "~/lib/utils/analytics";
+
+const AuthProviderContext = createContext<{
+ currentUser: GetMeQuery["me"];
+ isLoading: boolean;
+}>({
+ currentUser: undefined,
+ isLoading: false,
+});
+
+export const useAuthContext = () => useContext(AuthProviderContext);
+
+export const AuthProvider = ({ children }) => {
+ const { data, isLoading } = useGetCurrentUser();
+
+ const value = useMemo(
+ () => ({
+ currentUser: data?.me,
+ isLoading,
+ }),
+ [data, isLoading]
+ );
+
+ useEffect(() => {
+ if (hotjar.initialized() && value.currentUser) {
+ hotjar.identify(value.currentUser.id, {
+ name: value.currentUser?.name,
+ email: value.currentUser?.email,
+ });
+ }
+ }, [value.currentUser]);
+
+ useIdentify(value.currentUser);
+
+ return (
+
+ {children}
+
+ );
+};
diff --git a/apps/console/src/lib/providers/CurrentPromptContext.tsx b/apps/console/src/lib/providers/CurrentPromptContext.tsx
new file mode 100644
index 000000000..07ecd293c
--- /dev/null
+++ b/apps/console/src/lib/providers/CurrentPromptContext.tsx
@@ -0,0 +1,43 @@
+import { createContext, useContext } from "react";
+import { GetPromptQuery } from "~/@generated/graphql/graphql";
+import { Navigate, useParams } from "react-router-dom";
+import { useGetPrompt } from "~/graphql/hooks/queries";
+
+interface CurrentPromptContextValue {
+ prompt: GetPromptQuery["prompt"];
+ promptId: string;
+ isLoading: boolean;
+}
+
+const CurrentPromptContext = createContext({
+ prompt: undefined,
+ promptId: undefined,
+ isLoading: false,
+});
+
+export const useCurrentPrompt = () => {
+ return useContext(CurrentPromptContext);
+};
+
+export const CurrentPromptProvider = ({ children }) => {
+ const { promptId } = useParams();
+ const { prompt, isError, isLoading } = useGetPrompt(promptId, {
+ enabled: !!promptId,
+ });
+
+ if (isError) {
+ return ;
+ }
+
+ const value = {
+ prompt,
+ promptId,
+ isLoading,
+ };
+
+ return (
+
+ {children}
+
+ );
+};
diff --git a/apps/console/src/lib/providers/EditorContext.tsx b/apps/console/src/lib/providers/EditorContext.tsx
new file mode 100644
index 000000000..eaea18e81
--- /dev/null
+++ b/apps/console/src/lib/providers/EditorContext.tsx
@@ -0,0 +1,199 @@
+import z from "zod";
+import { zodResolver } from "@hookform/resolvers/zod";
+import {
+ UseFieldArrayReturn,
+ UseFormReturn,
+ useFieldArray,
+ useForm,
+ useWatch,
+} from "react-hook-form";
+import { PromptService, PromptType } from "~/@generated/graphql/graphql";
+import {
+ createContext,
+ useContext,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+} from "react";
+import { getServiceDefaultSettings } from "~/components/prompts/editor/ProviderSettings/providers";
+import { useCurrentPrompt } from "./CurrentPromptContext";
+import { useGetPromptVersion } from "~/graphql/hooks/queries";
+import { findVariables } from "../utils/find-variables";
+
+const getDefaultContent = (type: PromptType) => {
+ switch (type) {
+ case PromptType.Prompt:
+ return { prompt: "" };
+ case PromptType.Chat:
+ return {
+ messages: [
+ {
+ role: "user",
+ content: "",
+ },
+ ],
+ };
+ }
+};
+
+const formSchema = z.object({
+ type: z.nativeEnum(PromptType).default(PromptType.Chat),
+ service: z.nativeEnum(PromptService),
+ settings: z.any(),
+ content: z.union([
+ z.object({
+ prompt: z.string().min(1, "Prompt content is required"),
+ }),
+ z.object({
+ messages: z.array(
+ z.object({
+ content: z.string().min(1, "Message content is required"),
+ role: z.enum(["user", "assistant", "system"]),
+ })
+ ),
+ }),
+ ]),
+});
+
+export type EditorFormInputs = z.infer;
+
+interface EditorContext {
+ getForm: () => UseFormReturn;
+ messagesArray: UseFieldArrayReturn;
+ variables: string[];
+ isDraft: boolean;
+ currentVersionSha: string;
+ setCurrentVersionSha: (sha: string) => void;
+ isPublishEnabled: boolean;
+ hasChangesToCommit: boolean;
+ handleTypeChange: (type: PromptType) => void;
+}
+
+const EditorContext = createContext(null);
+
+export const useEditorContext = () => {
+ return useContext(EditorContext);
+};
+
+export const EditorProvider = ({ children }) => {
+ const { prompt } = useCurrentPrompt();
+ const initialValues = useRef(undefined);
+ const [currentVersionSha, setCurrentVersionSha] = useState(
+ prompt?.latestVersion?.sha
+ );
+ const { promptVersion: currentVersion, isFetched } = useGetPromptVersion(
+ { promptId: prompt?.id, promptVersionSha: currentVersionSha },
+ {
+ enabled: !!prompt && !!currentVersionSha,
+ }
+ );
+
+ const isDraft = useMemo(
+ () => !prompt?.latestVersion,
+ [prompt?.latestVersion]
+ );
+
+ const form = useForm({
+ resolver: zodResolver(formSchema),
+ });
+
+ const messagesArray = useFieldArray({
+ control: form.control,
+ name: "content.messages",
+ });
+
+ const [type, promptContent, chatContent] = useWatch({
+ control: form.control,
+ name: ["type", "content.prompt", "content.messages"],
+ });
+
+ const handleTypeChange = (type: PromptType) => {
+ form.setValue("type", type, { shouldDirty: true, shouldTouch: true });
+ let content: EditorFormInputs["content"];
+
+ if (type === PromptType.Chat) {
+ const promptContent = form.getValues("content.prompt");
+ content = {
+ messages: [
+ {
+ role: "user",
+ content: promptContent ?? "",
+ },
+ ],
+ };
+
+ form.setValue("content.messages", content.messages);
+ } else if (type === PromptType.Prompt) {
+ const firstMessageContent = form.getValues("content.messages.0.content");
+ content = {
+ prompt: firstMessageContent ?? "",
+ };
+
+ form.setValue("content.prompt", content.prompt);
+ }
+ };
+
+ const variables =
+ useMemo(() => {
+ if (type === PromptType.Prompt && promptContent) {
+ const variables = findVariables(promptContent);
+ return [...new Set(variables)];
+ } else if (type === PromptType.Chat && chatContent) {
+ let variables = [];
+ chatContent
+ .filter((message) => !!message)
+ .forEach((message) => {
+ const foundVariables = findVariables(message?.content);
+ if (message?.content) variables = [...variables, ...foundVariables];
+ });
+
+ return [...new Set(variables)];
+ }
+ }, [promptContent, chatContent, type]) ?? [];
+
+ useEffect(() => {
+ if (isDraft) {
+ const service = PromptService.OpenAiChatCompletion;
+ const settings = getServiceDefaultSettings(service);
+
+ form.reset({
+ service,
+ settings,
+ content: getDefaultContent(PromptType.Chat),
+ type: PromptType.Chat,
+ });
+ }
+
+ if (isFetched && currentVersion) {
+ form.reset({
+ service: currentVersion.service,
+ settings: currentVersion.settings,
+ content: currentVersion.content,
+ type: currentVersion.type,
+ });
+ }
+
+ form.reset(initialValues.current);
+ }, [currentVersion, isFetched, prompt, isDraft]);
+
+ const getForm = () => form;
+
+ return (
+
+ {children}
+
+ );
+};
diff --git a/apps/console/src/lib/providers/GettingStartedWizardProvider.tsx b/apps/console/src/lib/providers/GettingStartedWizardProvider.tsx
new file mode 100644
index 000000000..97dc5bc7e
--- /dev/null
+++ b/apps/console/src/lib/providers/GettingStartedWizardProvider.tsx
@@ -0,0 +1,83 @@
+import { Provider } from "@pezzo/types";
+import { createContext, useContext, useMemo, useState } from "react";
+
+export enum Language {
+ TypeScript = "TypeScript",
+}
+
+export enum Usage {
+ Observability = "Observability",
+ ObservabilityAndPromptManagement = "ObservabilityAndPromptManagement",
+}
+
+interface GettingStartedWizardContextValue {
+ currentStep: number;
+ provider: Provider;
+ setProvider: (provider: Provider) => void;
+ language: Language;
+ setLanguage: (language: Language) => void;
+ usage: Usage;
+ setUsage: (usage: Usage) => void;
+}
+
+export const GetingStartedWizardContext = createContext<
+ GettingStartedWizardContextValue | undefined
+>(undefined);
+export const useGettingStartedWizard = () =>
+ useContext(GetingStartedWizardContext);
+
+interface Props {
+ children: React.ReactNode;
+}
+
+export const GettingStartedWizardProvider = ({ children }: Props) => {
+ const [provider, setProvider] = useState();
+ const [language, setLanguage] = useState();
+ const [usage, setUsage] = useState();
+
+ const currentStep = useMemo(() => {
+ if (usage) {
+ return 3;
+ }
+
+ if (language) {
+ return 2;
+ }
+
+ if (provider) {
+ return 1;
+ }
+
+ return 0;
+ }, [usage, language, provider]);
+
+ const handleProviderChange = (provider: Provider) => {
+ setLanguage(undefined);
+ setUsage(undefined);
+ setProvider(provider);
+ };
+
+ const handleLanguageChange = (language: Language) => {
+ setLanguage(language);
+ };
+
+ const handleUsageChange = (usage: Usage) => {
+ setUsage(usage);
+ };
+
+ return (
+
+ {children}
+
+ );
+};
diff --git a/apps/console/src/lib/providers/OptionalIntercomProvider.tsx b/apps/console/src/lib/providers/OptionalIntercomProvider.tsx
new file mode 100644
index 000000000..d7aecb1ac
--- /dev/null
+++ b/apps/console/src/lib/providers/OptionalIntercomProvider.tsx
@@ -0,0 +1,29 @@
+import { INTERCOM_APP_ID } from "~/env";
+import React from "react";
+import { IntercomProvider } from "react-use-intercom";
+import { useAuthContext } from "./AuthProvider";
+
+export const OptionalIntercomProvider = ({
+ children,
+}: {
+ children: React.ReactNode | string;
+}): JSX.Element => {
+ const { currentUser } = useAuthContext();
+ if (INTERCOM_APP_ID) {
+ return (
+
+ {children}
+
+ );
+ }
+ // eslint-disable-next-line react/jsx-no-useless-fragment
+ return <>{children}>;
+};
diff --git a/apps/console/src/lib/providers/PromptTesterContext.tsx b/apps/console/src/lib/providers/PromptTesterContext.tsx
new file mode 100644
index 000000000..ded616298
--- /dev/null
+++ b/apps/console/src/lib/providers/PromptTesterContext.tsx
@@ -0,0 +1,118 @@
+import { createContext, useContext, useEffect, useState } from "react";
+import { useTestPrompt } from "~/graphql/hooks/mutations";
+import { useCurrentProject } from "../hooks/useCurrentProject";
+import { useCurrentPrompt } from "./CurrentPromptContext";
+import { EditorFormInputs, useEditorContext } from "./EditorContext";
+import { UseFormReturn, useForm } from "react-hook-form";
+import { z } from "zod";
+import { zodResolver } from "@hookform/resolvers/zod";
+import { SerializedReport } from "@pezzo/types";
+
+const formSchema = z.record(
+ z.string(),
+ z
+ .string()
+ .min(1)
+ .regex(/^[^\s].*[^\s]$/, "Must be a valid value")
+);
+
+export type PromptTesterVariablesInputs = z.infer;
+
+interface PromptTesterContextValue {
+ isOpen: boolean;
+ openTestModal: (value: EditorFormInputs) => void;
+ closeTestModal: () => void;
+ testVariablesForm: UseFormReturn>;
+ runTest: () => void;
+ isTestLoading: boolean;
+ testError: any;
+ testResult: SerializedReport;
+}
+
+const PromptTesterContext = createContext({
+ isOpen: undefined,
+ openTestModal: () => void 0,
+ closeTestModal: () => void 0,
+ testVariablesForm: undefined,
+ runTest: () => void 0,
+ isTestLoading: undefined,
+ testError: undefined,
+ testResult: undefined,
+});
+
+export const usePromptTester = () => {
+ return useContext(PromptTesterContext);
+};
+
+export const PromptTesterProvider = ({ children }) => {
+ const { prompt } = useCurrentPrompt();
+ const { project } = useCurrentProject();
+ const {
+ mutate: testPrompt,
+ isLoading: isTestLoading,
+ error: testError,
+ data,
+ reset,
+ } = useTestPrompt();
+ const { getForm, variables } = useEditorContext();
+ const editorForm = getForm();
+ const [isOpen, setIsOpen] = useState(false);
+
+ const testVariablesForm = useForm({
+ resolver: zodResolver(formSchema),
+ defaultValues: {},
+ });
+
+ useEffect(() => {
+ const currentValues = testVariablesForm.getValues();
+ const variablesRecord: z.infer = {};
+
+ for (const variableName of variables) {
+ variablesRecord[variableName] = currentValues[variableName] || "";
+ }
+
+ testVariablesForm.reset({
+ ...variablesRecord,
+ });
+ }, [prompt, variables, testVariablesForm]);
+
+ const handleOpenTestModal = () => {
+ setIsOpen(true);
+ };
+
+ const handleCloseTestModal = () => {
+ setIsOpen(false);
+ reset();
+ };
+
+ const runTest = async () => {
+ const variables = testVariablesForm.getValues();
+ const { type, content, settings } = editorForm.getValues();
+
+ testPrompt({
+ type,
+ content,
+ settings,
+ variables,
+ projectId: project.id,
+ promptId: prompt.id,
+ });
+ };
+
+ const value = {
+ isOpen,
+ openTestModal: handleOpenTestModal,
+ closeTestModal: handleCloseTestModal,
+ testVariablesForm,
+ runTest,
+ isTestLoading,
+ testError: testError?.response.errors[0].message,
+ testResult: data?.testPrompt,
+ };
+
+ return (
+
+ {children}
+
+ );
+};
diff --git a/apps/console/src/lib/providers/RequiredProviderApiKeyModalProvider.tsx b/apps/console/src/lib/providers/RequiredProviderApiKeyModalProvider.tsx
new file mode 100644
index 000000000..99fb0bb4b
--- /dev/null
+++ b/apps/console/src/lib/providers/RequiredProviderApiKeyModalProvider.tsx
@@ -0,0 +1,180 @@
+import { createContext, useContext, useRef, useState } from "react";
+import { useProviderApiKeys } from "~/graphql/hooks/queries";
+import { ProviderApiKeyListItem } from "~/components/api-keys/ProviderApiKeyListItem";
+import { trackEvent } from "../utils/analytics";
+import {
+ Accordion,
+ AccordionContent,
+ AccordionItem,
+ AccordionTrigger,
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+} from "@pezzo/ui";
+
+enum Reason {
+ prompt_tester,
+}
+
+interface OpenRequiredProviderApiKeyModalOptions {
+ callback: () => void;
+ provider: "OpenAI";
+ reason: keyof typeof Reason;
+}
+
+interface RequiredProviderApiKeyModalContextValue {
+ openRequiredProviderApiKeyModal: (
+ options?: OpenRequiredProviderApiKeyModalOptions
+ ) => void;
+ closeRequiredProviderApiKeyModal: () => void;
+}
+
+const RequiredProviderApiKeyModalContext =
+ createContext({
+ openRequiredProviderApiKeyModal: () => void 0,
+ closeRequiredProviderApiKeyModal: () => void 0,
+ });
+
+export const useRequiredProviderApiKeyModal = () =>
+ useContext(RequiredProviderApiKeyModalContext);
+
+export const RequiredProviderApiKeyModalProvider = ({ children }) => {
+ const reasonRef = useRef(null);
+ const callbackRef = useRef(null);
+
+ const { providerApiKeys } = useProviderApiKeys();
+ const [open, setOpen] = useState(false);
+
+ const openRequiredProviderApiKeyModal = (
+ options: OpenRequiredProviderApiKeyModalOptions
+ ) => {
+ setOpen(true);
+ trackEvent("provider_api_keys_modal_opened", {
+ provider: options.provider,
+ reason: options.reason,
+ });
+
+ callbackRef.current = options.callback;
+ reasonRef.current = options.reason;
+ };
+
+ const closeRequiredProviderApiKeyModal = (cancel = false) => {
+ setOpen(false);
+
+ if (cancel) {
+ trackEvent("provider_api_keys_modal_canceled", {
+ reason: reasonRef.current,
+ });
+ } else {
+ callbackRef.current();
+ }
+
+ callbackRef.current = null;
+ reasonRef.current = null;
+ };
+
+ const value = {
+ openRequiredProviderApiKeyModal,
+ closeRequiredProviderApiKeyModal,
+ };
+
+ const key =
+ providerApiKeys?.find((key) => key.provider === "OpenAI") ?? undefined;
+
+ return (
+
+
+ setOpen(false)}
+ className="text-sm"
+ >
+
+ API Key Required
+
+
+ In order to test your prompts within the Pezzo Console, you must
+ provide an OpenAI API key.
+
+
+
+
+
+
+
+
+ How do I get an OpenAI API key?
+
+
+
+
+
+
+
+ How does Pezzo securely store my API keys?
+
+
+
+
+ All API keys stored on Pezzo are encrypted using AES-256
+ with a unique data key, generated per API key, using a
+ master key stored and rotated regularly on AWS KMS. This
+ technique is also called{" "}
+
+ Key Encapsulation
+
+ .
+
+
+ Even in the event of a data breach, your API keys are safe.
+
+
+
+
+
+
+ Can I later delete my API key?
+
+
+
+
+ Yes. You can view and manage all API keys for your
+ organization by navigating to your Organization page, and
+ then to the API Keys view.
+
+
+
+
+
+
+
+ {children}
+
+ );
+};
diff --git a/apps/console/src/lib/providers/TimeframeSelectorContext.tsx b/apps/console/src/lib/providers/TimeframeSelectorContext.tsx
new file mode 100644
index 000000000..0803929b8
--- /dev/null
+++ b/apps/console/src/lib/providers/TimeframeSelectorContext.tsx
@@ -0,0 +1,72 @@
+import moment from "moment";
+import { createContext, useContext, useEffect, useState } from "react";
+
+export enum Timeframe {
+ Custom = "Custom",
+ PastHour = "1H",
+ PastDay = "24H",
+ PastWeek = "7D",
+ PastMonth = "1M",
+ PastYear = "1Y",
+}
+
+interface TimeframeSelectorContextValue {
+ startDate: string;
+ endDate: string;
+ setStartDate: (startDate: string) => void;
+ setEndDate: (endDate: string) => void;
+ timeframe: Timeframe;
+ setTimeframe: (timeframe: Timeframe) => void;
+}
+
+export const TimeframeSelectorContext =
+ createContext({
+ startDate: undefined,
+ endDate: undefined,
+ setStartDate: () => void 0,
+ setEndDate: () => void 0,
+ timeframe: Timeframe.PastDay,
+ setTimeframe: () => void 0,
+ });
+
+export const useTimeframeSelector = () => useContext(TimeframeSelectorContext);
+
+const timeframeStartdateFn = {
+ [Timeframe.PastHour]: () => moment().subtract(1, "hour"),
+ [Timeframe.PastDay]: () => moment().subtract(1, "day"),
+ [Timeframe.PastWeek]: () => moment().subtract(6, "days"),
+ [Timeframe.PastMonth]: () => moment().subtract(1, "month"),
+ [Timeframe.PastYear]: () => moment().subtract(1, "year"),
+};
+
+export const TimeframeSelectorProvider = ({
+ children,
+}: {
+ children: React.ReactNode;
+}) => {
+ const [startDate, setStartDate] = useState(undefined);
+ const [endDate, setEndDate] = useState(undefined);
+ const [timeframe, setTimeframe] = useState(Timeframe.PastDay);
+
+ useEffect(() => {
+ if (timeframe !== Timeframe.Custom) {
+ setEndDate(moment().toISOString());
+ setStartDate(timeframeStartdateFn[timeframe]().toISOString());
+ }
+ }, [timeframe, setStartDate, setEndDate]);
+
+ const value = {
+ startDate,
+ endDate,
+ setStartDate,
+ setEndDate,
+ timeframe,
+ setTimeframe,
+ };
+
+ return (
+
+ {children}
+
+ );
+};
diff --git a/apps/console/src/app/lib/theme/colors.ts b/apps/console/src/lib/theme/colors.ts
similarity index 100%
rename from apps/console/src/app/lib/theme/colors.ts
rename to apps/console/src/lib/theme/colors.ts
diff --git a/apps/console/src/lib/utils/analytics.ts b/apps/console/src/lib/utils/analytics.ts
new file mode 100644
index 000000000..b0342d5c9
--- /dev/null
+++ b/apps/console/src/lib/utils/analytics.ts
@@ -0,0 +1,104 @@
+import React from "react";
+import Analytics from "analytics";
+import googleTagManager from "@analytics/google-tag-manager";
+import segment from "@analytics/segment";
+import { GetMeQuery } from "~/@generated/graphql/graphql";
+import { SEGMENT_WRITE_KEY, GTM_TAG_ID } from "~/env";
+import { AnalyticsEvent } from "./events.types";
+import { useCurrentProject } from "../hooks/useCurrentProject";
+import { useCurrentOrganization } from "../hooks/useCurrentOrganization";
+
+const shouldTrack = !!SEGMENT_WRITE_KEY;
+
+const getAnalyicsPlugins = () => {
+ const plugins = [];
+
+ // Segment
+ if (SEGMENT_WRITE_KEY) {
+ plugins.push(
+ segment({
+ writeKey: SEGMENT_WRITE_KEY,
+ })
+ );
+ }
+
+ // GTM
+ if (GTM_TAG_ID) {
+ plugins.push(
+ googleTagManager({
+ containerId: GTM_TAG_ID,
+ })
+ );
+ }
+
+ return plugins;
+};
+
+const analytics = Analytics({
+ app: "pezzo-console",
+ plugins: getAnalyicsPlugins(),
+});
+
+// Can be handled on backend
+export const useIdentify = (user: GetMeQuery["me"]) => {
+ const { projectId } = useCurrentProject();
+ const { organizationId } = useCurrentOrganization();
+
+ React.useEffect(() => {
+ if (!user) return;
+ const segmentUserId = JSON.parse(localStorage.getItem("ajs_user_id"));
+ if (segmentUserId === user.id) return;
+
+ const identifyRequest = {
+ name: user.name,
+ email: user.email,
+ avatar: user.photoUrl,
+ groupId: organizationId,
+ organizationId,
+ projectId,
+ };
+
+ analytics.identify(user.id, identifyRequest);
+ // unsafe support for segment group in analytics lib
+ (analytics.plugins as any).segment?.group(organizationId, {});
+
+ const window = (global as any).window;
+
+ // GTM data layer
+ if (window.dataLayer) {
+ window.dataLayer.push({ ...identifyRequest });
+ }
+ }, [user, organizationId, projectId]);
+};
+
+export interface ContextProps {
+ organizationId?: string;
+ projectId?: string;
+ promptId?: string;
+}
+
+const getContextPropsFromPathIfExists = () => {
+ const contextProps: ContextProps = {};
+ const path = window.location.pathname;
+ const [, projectsPath, projectId, promptsPath, promptId] = path.split("/");
+ if (projectsPath === "projects" && projectId) {
+ contextProps.projectId = projectId;
+ }
+ if (promptsPath === "prompts" && promptId) {
+ contextProps.promptId = promptId;
+ }
+ return contextProps;
+};
+
+export const trackEvent = (
+ event: keyof typeof AnalyticsEvent,
+ properties?: Record & ContextProps
+) => {
+ const groupId = JSON.parse(localStorage.getItem("currentOrgId"));
+ const context = { groupId };
+ const contextProps = {
+ ...getContextPropsFromPathIfExists(),
+ organizationId: groupId,
+ };
+ analytics.track(event, { ...contextProps, ...properties }, context);
+};
diff --git a/apps/console/src/lib/utils/browser-utils.ts b/apps/console/src/lib/utils/browser-utils.ts
new file mode 100644
index 000000000..8982a0cc2
--- /dev/null
+++ b/apps/console/src/lib/utils/browser-utils.ts
@@ -0,0 +1,8 @@
+export const copyToClipboard = (text: string) => {
+ const textarea = document.createElement("textarea");
+ textarea.value = text;
+ document.body.appendChild(textarea);
+ textarea.select();
+ document.execCommand("copy");
+ document.body.removeChild(textarea);
+};
diff --git a/apps/console/src/lib/utils/cn.ts b/apps/console/src/lib/utils/cn.ts
new file mode 100644
index 000000000..a5ef19350
--- /dev/null
+++ b/apps/console/src/lib/utils/cn.ts
@@ -0,0 +1,6 @@
+import { clsx, type ClassValue } from "clsx";
+import { twMerge } from "tailwind-merge";
+
+export function cn(...inputs: ClassValue[]) {
+ return twMerge(clsx(inputs));
+}
diff --git a/apps/console/src/lib/utils/events.types.ts b/apps/console/src/lib/utils/events.types.ts
new file mode 100644
index 000000000..9488c39da
--- /dev/null
+++ b/apps/console/src/lib/utils/events.types.ts
@@ -0,0 +1,70 @@
+// Pattern snake_case, Context => Object => Action
+
+export enum AnalyticsEvent {
+ user_login,
+ user_logout,
+ user_signup,
+ prompt_create_modal_opened,
+ prompt_created,
+ prompt_nav_clicked,
+ prompt_run_test_clicked,
+ prompt_how_to_consume_modal_opened,
+ prompt_how_to_consume_tab_changed,
+ prompt_publish_modal_opened,
+ prompt_commit_modal_opened,
+ prompt_functions_modal_opened,
+ prompt_provider_selector_opened,
+ prompt_publish_clicked,
+ prompt_environment_selected,
+ prompt_version_selected,
+ prompt_functions_edited,
+ prompt_delete_modal_opened,
+ prompt_delete_confirmed,
+ prompt_delete_cancelled,
+ prompt_form_submitted,
+ prompt_form_cancelled,
+ prompt_commit_submitted,
+ prompt_commit_cancelled,
+ prompt_versions_viewed,
+ prompt_settings_viewed,
+ prompt_metrics_viewed,
+ prompt_metric_view_changed,
+ prompt_test_submitted,
+ prompt_test_cancelled,
+ prompt_test_display_mode_changed,
+ prompt_chat_completion_message_created,
+ prompt_chat_completion_message_deleted,
+ prompt_chat_completion_message_role_changed,
+ provider_api_keys_modal_opened,
+ provider_api_key_set,
+ provider_api_key_deleted,
+ provider_api_keys_modal_canceled,
+ request_details_viewed,
+ request_details_pagination_change,
+ request_details_filter_added,
+ request_details_filter_removed,
+ environment_create_modal_opened,
+ environment_delete_modal_opened,
+ environment_form_submitted,
+ environment_form_cancelled,
+ environment_delete_confirmed,
+ environment_delete_cancelled,
+ project_create_modal_opened,
+ project_form_submitted,
+ project_form_cancelled,
+ project_nav_clicked,
+ project_id_copied,
+ project_dashboard_timeframe_changed,
+ project_dashboard_custom_date_popover_opened,
+ project_dashboard_custom_date_applied,
+ project_delete_modal_opened,
+ project_delete_confirmed,
+ project_delete_cancelled,
+ project_rename_modal_opened,
+ project_rename_submitted,
+ project_rename_cancelled,
+ organization_tab_changed,
+ organization_member_invite_modal_opened,
+ organization_api_key_copied,
+ organization_settings_form_submitted,
+}
diff --git a/apps/console/src/lib/utils/filters-utils.ts b/apps/console/src/lib/utils/filters-utils.ts
new file mode 100644
index 000000000..8bdc9c3b9
--- /dev/null
+++ b/apps/console/src/lib/utils/filters-utils.ts
@@ -0,0 +1,28 @@
+import { FilterOperator, SortOrder } from "~/@generated/graphql/graphql";
+
+export const extractSortAndFiltersFromSearchParams = (
+ searchParams: URLSearchParams
+) => {
+ const filterParams: (string | null)[] | null = searchParams.getAll("f");
+ const filters = filterParams?.map((filterParam) => {
+ const [field, operator, value] = (filterParam ?? ":").split(":");
+ return {
+ field,
+ operator: operator as FilterOperator,
+ value,
+ };
+ });
+
+ const sortParam = searchParams.get("sort");
+ // eslint-disable-next-line no-unsafe-optional-chaining
+ const [sortField, sortOrder] = sortParam?.split(":") ?? [];
+
+ return {
+ filters,
+ sort: sortField &&
+ sortOrder && {
+ field: sortField,
+ order: (sortOrder as SortOrder) ?? SortOrder.Desc,
+ },
+ };
+};
diff --git a/apps/console/src/lib/utils/find-variables.ts b/apps/console/src/lib/utils/find-variables.ts
new file mode 100644
index 000000000..3d783c59c
--- /dev/null
+++ b/apps/console/src/lib/utils/find-variables.ts
@@ -0,0 +1,9 @@
+const regex = /\{([\w\s]+)\}/g;
+
+export function findVariables(text: string): string[] {
+ const matches = text.match(regex);
+ const foundVariables = matches
+ ? matches.map((match) => match.replace(/[{}]/g, ""))
+ : [];
+ return Array.from(new Set(foundVariables));
+}
diff --git a/apps/console/src/app/lib/utils/is-json.ts b/apps/console/src/lib/utils/is-json.ts
similarity index 100%
rename from apps/console/src/app/lib/utils/is-json.ts
rename to apps/console/src/lib/utils/is-json.ts
diff --git a/apps/console/src/lib/utils/sign-out.ts b/apps/console/src/lib/utils/sign-out.ts
new file mode 100644
index 000000000..9703344c2
--- /dev/null
+++ b/apps/console/src/lib/utils/sign-out.ts
@@ -0,0 +1,8 @@
+import { signOut as supertokensSignOut } from "supertokens-auth-react/recipe/session";
+import { trackEvent } from "./analytics";
+
+export async function signOut() {
+ trackEvent("user_logout");
+ await supertokensSignOut();
+ window.location.href = "/login";
+}
diff --git a/apps/console/src/app/lib/utils/string-utils.ts b/apps/console/src/lib/utils/string-utils.ts
similarity index 100%
rename from apps/console/src/app/lib/utils/string-utils.ts
rename to apps/console/src/lib/utils/string-utils.ts
diff --git a/apps/console/src/main.tsx b/apps/console/src/main.tsx
index fce41d571..b246c7b60 100644
--- a/apps/console/src/main.tsx
+++ b/apps/console/src/main.tsx
@@ -1,11 +1,13 @@
+import "reflect-metadata";
import * as ReactDOM from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
-import App from "./app/app";
+import App from "./app";
const root = ReactDOM.createRoot(
document.getElementById("root") as HTMLElement
);
+
root.render(
diff --git a/apps/console/src/pages/WaitlistWrapper.tsx b/apps/console/src/pages/WaitlistWrapper.tsx
new file mode 100644
index 000000000..6cd9e91cb
--- /dev/null
+++ b/apps/console/src/pages/WaitlistWrapper.tsx
@@ -0,0 +1,36 @@
+import { useGetCurrentUser } from "~/graphql/hooks/queries";
+import { useWaitlist } from "~/lib/hooks/useWaitlist";
+
+export const WaitlistWrapper = ({ children }) => {
+ const { shouldRenderWaitlistNotice } = useWaitlist();
+ const { data: currentUserData } = useGetCurrentUser();
+
+ if (shouldRenderWaitlistNotice) {
+ return (
+
+
+ You're on the waitlist!
+
+
+
Thank you for signing up for Pezzo Cloud.
+
+ You will receive an invitation at{" "}
+ {currentUserData.me.email} {" "}
+ soon.
+
+
+ Need access sooner? Email us at{" "}
+
+ hello@pezzo.ai
+
+
+
+
+ );
+ } else {
+ return children;
+ }
+};
diff --git a/apps/console/src/pages/auth/AuthCallbackPage.tsx b/apps/console/src/pages/auth/AuthCallbackPage.tsx
new file mode 100644
index 000000000..49c8a80e1
--- /dev/null
+++ b/apps/console/src/pages/auth/AuthCallbackPage.tsx
@@ -0,0 +1,40 @@
+import { useEffect } from "react";
+import ThirdPartyEmailPassword from "supertokens-auth-react/recipe/thirdpartyemailpassword";
+import { Loader } from "~/components/common/Loader";
+import { trackEvent } from "~/lib/utils/analytics";
+
+export const AuthCallbackPage = () => {
+ const handleAuthCallback = async () => {
+ try {
+ const response = await ThirdPartyEmailPassword.thirdPartySignInAndUp();
+
+ if (response.status !== "OK") {
+ return window.location.assign("/login?error=signin");
+ }
+
+ if (response.createdNewUser) {
+ trackEvent("user_signup", { method: "third_party" });
+ } else {
+ trackEvent("user_login", { method: "third_party" });
+ }
+
+ window.location.assign("/");
+ } catch (_) {
+ return window.location.assign("/login?error=signin");
+ }
+ };
+
+ useEffect(() => {
+ handleAuthCallback();
+ }, []);
+
+ return (
+
+ );
+};
diff --git a/apps/console/src/pages/auth/LoginPage.tsx b/apps/console/src/pages/auth/LoginPage.tsx
new file mode 100644
index 000000000..115443cb9
--- /dev/null
+++ b/apps/console/src/pages/auth/LoginPage.tsx
@@ -0,0 +1,420 @@
+import BlurryBlurb from "~/assets/blurry-blurb.svg";
+import Spline from "@splinetool/react-spline";
+import { useEffect, useState } from "react";
+import { usePageTitle } from "~/lib/hooks/usePageTitle";
+import {
+ Alert,
+ AlertDescription,
+ AlertTitle,
+ Button,
+ FormItem,
+ FormMessage,
+ Input,
+} from "@pezzo/ui";
+import GoogleIcon from "~/assets/icons/google.svg";
+import ThirdPartyEmailPassword from "supertokens-auth-react/recipe/thirdpartyemailpassword";
+import { Form, FormField } from "@pezzo/ui";
+import { useForm } from "react-hook-form";
+import { zodResolver } from "@hookform/resolvers/zod";
+import * as z from "zod";
+import { motion } from "framer-motion";
+import { useSearchParams } from "react-router-dom";
+import { trackEvent } from "~/lib/utils/analytics";
+import clsx from "clsx";
+import { googleEnabled } from "~/lib/auth/supertokens";
+
+const GENERIC_ERROR = "Something went wrong. Please try again later.";
+
+export const LoginPage = () => {
+ const [searchParams] = useSearchParams();
+ const [mode, setMode] = useState<"signin" | "signup" | "forgot_password">(
+ "signin"
+ );
+ const [isEmail, setIsEmail] = useState(false);
+ const [error, setError] = useState(undefined);
+ const [emailPasswordLoading, setEmailPasswordLoading] =
+ useState(false);
+ const [thirdPartyLoading, setThirdPartyLoading] = useState(false);
+
+ const verb = mode === "signin" ? "Sign in" : "Sign up";
+ usePageTitle(verb);
+ const signInSchema = z.object({
+ email: z.string().email({ message: "Invalid email address" }),
+ password: z.string().min(1, "Password is required"),
+ confirm_password: mode === "signup" ? z.string() : z.string().optional(),
+ });
+
+ const signUpSchema = z
+ .object({
+ email: z.string().email({ message: "Invalid email address" }),
+ password: z
+ .string()
+ .regex(
+ /^(?=.*[A-Z])(?=.*[a-z])(?=.*\d)(?=.*[!@#$%^&*()_+{}[\]:;<>,.?~\\-]).{8,}$/,
+ "Password must contain at least 8 characters, one uppercase, one lowercase, one number, and one special symbol"
+ ),
+ confirm_password: z.string().min(1, "Confirm password is required"),
+ name: z.string().min(1, "Display name is required"),
+ })
+ .refine((data) => data.password === data.confirm_password, {
+ message: "Passwords do not match",
+ path: ["confirm_password"],
+ });
+
+ const formSchema = mode === "signin" ? signInSchema : signUpSchema;
+
+ useEffect(() => {
+ const error = searchParams.get("error");
+
+ if (error) {
+ setError(GENERIC_ERROR);
+ window.history.replaceState(null, "", window.location.pathname);
+ }
+ }, [searchParams]);
+
+ const emailPasswordForm = useForm>({
+ resolver: zodResolver(formSchema),
+ defaultValues: {
+ email: "",
+ password: "",
+ },
+ });
+
+ const handleSetMode = (mode: "signin" | "signup" | "forgot_password") => {
+ setMode(mode);
+ setError(undefined);
+ emailPasswordForm.clearErrors();
+ };
+
+ const onEmailPasswordSubmit = async (formValues) => {
+ setEmailPasswordLoading(true);
+
+ if (mode === "signup") {
+ const values: z.infer = formValues;
+ await emailPasswordSignUp(values.email, values.password, values.name);
+ } else {
+ const values: z.infer = formValues;
+ await emailPasswordSignIn(values.email, values.password);
+ }
+
+ setEmailPasswordLoading(false);
+ };
+
+ const handleThirdPartySignIn = async (providerId: "google") => {
+ setError(null);
+ setThirdPartyLoading(true);
+
+ try {
+ const url =
+ await ThirdPartyEmailPassword.getAuthorisationURLWithQueryParamsAndSetState(
+ {
+ providerId,
+ authorisationURL: `${window.location.origin}/login/callback/${providerId}`,
+ }
+ );
+
+ window.location.href = url;
+ } catch (error) {
+ setError(GENERIC_ERROR);
+ }
+ };
+
+ const emailPasswordSignIn = async (email: string, password: string) => {
+ const response = await ThirdPartyEmailPassword.emailPasswordSignIn({
+ formFields: [
+ {
+ id: "email",
+ value: email,
+ },
+ {
+ id: "password",
+ value: password,
+ },
+ ],
+ });
+
+ if (response.status === "WRONG_CREDENTIALS_ERROR") {
+ // the input email / password combination did not match,
+ // so we show an appropriate error message to the user
+ setError("Invalid email or password. Please try again.");
+ return;
+ }
+ if (response.status === "FIELD_ERROR") {
+ response.formFields.forEach((item) => {
+ if (item.id === "email") {
+ // this means that something was wrong with the entered email.
+ // probably that it's not a valid email (from a syntax point of view)
+ setError(item.error);
+ } else if (item.id === "password") {
+ setError(item.error);
+ }
+ });
+
+ return;
+ }
+
+ trackEvent("user_login", { method: "email_password" });
+ window.location.assign("/");
+ };
+
+ const emailPasswordSignUp = async (
+ email: string,
+ password: string,
+ name: string
+ ) => {
+ const response = await ThirdPartyEmailPassword.emailPasswordSignUp({
+ formFields: [
+ {
+ id: "email",
+ value: email,
+ },
+ {
+ id: "password",
+ value: password,
+ },
+ {
+ id: "name",
+ value: name,
+ },
+ ],
+ });
+
+ if (response.status === "FIELD_ERROR") {
+ let error = "";
+ response.formFields.forEach((item) => {
+ error += item.error + "\n";
+ });
+
+ setError(error);
+ return;
+ }
+
+ trackEvent("user_signup", { method: "email_password" });
+ window.location.assign("/");
+ };
+
+ return (
+
+
+
+
+
+
+
+
+ {verb} to Pezzo{" "}
+
+
+
+
+ {error && (
+
+ Oops!
+ {error}
+
+ )}
+
+
+
+ {googleEnabled && (
+
handleThirdPartySignIn("google")}
+ loading={thirdPartyLoading}
+ >
+
+ {verb} with Google
+
+ )}
+
+
+ {isEmail && (
+ <>
+
+
+
+
+ >
+ )}
+
+
+ {!isEmail && (
+
setIsEmail(true)}
+ >
+ {verb} with Email
+
+ )}
+
+
+
+ {mode === "signin" ? (
+
+ Don't have an account?{" "}
+ handleSetMode("signup")}
+ className="px-0"
+ >
+ Sign up
+
+ .
+
+ ) : (
+
+ Already have an account?{" "}
+ {
+ handleSetMode("signin");
+ }}
+ className="px-0"
+ >
+ Sign in
+
+ .
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/apps/console/src/pages/auth/LogoutPage.tsx b/apps/console/src/pages/auth/LogoutPage.tsx
new file mode 100644
index 000000000..1b220e669
--- /dev/null
+++ b/apps/console/src/pages/auth/LogoutPage.tsx
@@ -0,0 +1,15 @@
+import { useEffect } from "react";
+import { FullScreenLoader } from "~/components/common/FullScreenLoader";
+import { signOut } from "~/lib/utils/sign-out";
+
+export const LogoutPage = () => {
+ useEffect(() => {
+ const _signOut = async () => {
+ await signOut();
+ window.location.href = "/login";
+ };
+ _signOut();
+ }, []);
+
+ return ;
+};
diff --git a/apps/console/src/pages/environments/EnvironmentsPage.tsx b/apps/console/src/pages/environments/EnvironmentsPage.tsx
new file mode 100644
index 000000000..43f69f60c
--- /dev/null
+++ b/apps/console/src/pages/environments/EnvironmentsPage.tsx
@@ -0,0 +1,102 @@
+import { Button, Card, toast } from "@pezzo/ui";
+import { CreateEnvironmentModal } from "~/components/environments/CreateEnvironmentModal";
+import { useState } from "react";
+import { useEnvironments } from "~/lib/hooks/useEnvironments";
+import { EnvironmentsQuery } from "~/@generated/graphql/graphql";
+import { trackEvent } from "~/lib/utils/analytics";
+import { usePageTitle } from "~/lib/hooks/usePageTitle";
+import { HardDriveIcon, PlusIcon, TrashIcon } from "lucide-react";
+import { GenericDestructiveConfirmationModal } from "~/components/common/GenericDestructiveConfirmationModal";
+import { useDeleteEnvironmentMutation } from "~/graphql/hooks/mutations";
+
+type Environment = EnvironmentsQuery["environments"][0];
+
+export const EnvironmentsPage = () => {
+ usePageTitle("Environments");
+ const { environments, isLoading } = useEnvironments();
+ const [isCreateEnvironmentModalOpen, setIsCreateEnvironmentModalOpen] =
+ useState(false);
+ const [environmentToDelete, setEnvironmentToDelete] =
+ useState(null);
+ const { mutate: deleteEnvironment, error: deleteEnvironmentError } =
+ useDeleteEnvironmentMutation();
+
+ const handleCreateEnvironmentClick = () => {
+ setIsCreateEnvironmentModalOpen(true);
+ trackEvent("environment_create_modal_opened");
+ };
+
+ const handleDeleteEnvironmentClick = (environment: Environment) => {
+ setEnvironmentToDelete(environment);
+ trackEvent("environment_delete_modal_opened", { name: environment.name });
+ };
+
+ const handleDeleteEnvironmentConfirm = (environmentId: string) => {
+ deleteEnvironment(
+ { id: environmentId },
+ {
+ onSuccess: () => {
+ trackEvent("environment_delete_confirmed", {
+ name: environmentToDelete?.name,
+ });
+ setEnvironmentToDelete(null);
+ toast({
+ title: "Environment deleted",
+ description: "The environment has been deleted.",
+ });
+ },
+ }
+ );
+ };
+
+ return (
+ <>
+ handleDeleteEnvironmentConfirm(environmentToDelete.id)}
+ onCancel={() => setEnvironmentToDelete(null)}
+ error={deleteEnvironmentError}
+ />
+
+ setIsCreateEnvironmentModalOpen(false)}
+ onCreated={() => setIsCreateEnvironmentModalOpen(false)}
+ />
+
+
+
+
Environments
+
+
+ New Environment
+
+
+
+
+
+
+ {environments &&
+ environments.map((environment) => (
+
+
+ {environment.name}
+ handleDeleteEnvironmentClick(environment)}
+ size="icon"
+ variant="destructiveOutline"
+ >
+
+
+
+ ))}
+
+
+ >
+ );
+};
diff --git a/apps/console/src/pages/invitations/AcceptInvitationPage.tsx b/apps/console/src/pages/invitations/AcceptInvitationPage.tsx
new file mode 100644
index 000000000..9a98179dd
--- /dev/null
+++ b/apps/console/src/pages/invitations/AcceptInvitationPage.tsx
@@ -0,0 +1,60 @@
+import { useNavigate, useParams } from "react-router-dom";
+import { useAcceptOrgInvitationMutation } from "~/graphql/hooks/mutations";
+import { useEffect, useState } from "react";
+import { GraphQLErrorResponse } from "~/graphql/types";
+import { usePageTitle } from "~/lib/hooks/usePageTitle";
+import { CheckIcon, XCircleIcon } from "lucide-react";
+
+export const AcceptInvitationPage = () => {
+ usePageTitle("Accept Invitation");
+ const params = useParams();
+ const { mutateAsync } = useAcceptOrgInvitationMutation();
+ const [orgName, setOrgName] = useState(null);
+ const [error, setError] = useState(null);
+ const navigate = useNavigate();
+
+ useEffect(() => {
+ if (!params.token) {
+ setError("Invalid token");
+ }
+
+ const acceptInvitation = async (token: string) => {
+ mutateAsync({ id: token })
+ .then((result) => {
+ setOrgName(result.acceptOrgInvitation.name);
+ setTimeout(() => {
+ navigate(`/orgs/${result.acceptOrgInvitation.id}`);
+ }, 3000);
+ })
+ .catch((error: GraphQLErrorResponse) => {
+ if (error.response) {
+ setError(error.response.errors[0].message);
+ }
+ });
+ };
+
+ acceptInvitation(params.token as string);
+ }, [params, setError, mutateAsync, navigate]);
+
+ return (
+
+ {error && (
+ <>
+
+
Could not accept invitation
+
{error}
+ >
+ )}
+
+ {orgName && (
+ <>
+
+
You're in!
+
+ You have successfully joined ${orgName}! Redirecting...
+
+ >
+ )}
+
+ );
+};
diff --git a/apps/console/src/pages/organizations/OrgApiKeysPage.tsx b/apps/console/src/pages/organizations/OrgApiKeysPage.tsx
new file mode 100644
index 000000000..1359c426f
--- /dev/null
+++ b/apps/console/src/pages/organizations/OrgApiKeysPage.tsx
@@ -0,0 +1,51 @@
+import { usePezzoApiKeys } from "~/graphql/hooks/queries";
+import { PezzoApiKeyListItem } from "~/components/api-keys/PezzoApiKeyListItem";
+import { ProviderApiKeysList } from "~/components/api-keys/ProviderApiKeysList";
+import { Card } from "@pezzo/ui";
+
+export const OrgApiKeysPage = () => {
+ const { pezzoApiKeys } = usePezzoApiKeys();
+
+ return (
+ <>
+
+
+
+
+
+ Pezzo API Keys
+
+ Below you can find your Pezzo API key. This API key is provided to
+ the Pezzo Client when executing prompts.
+
+
+
+ {pezzoApiKeys?.map((item, index) => (
+
+ ))}
+
+
+
+
+
+
+ Provider API Keys
+
+ In order to be able to test your prompts within the Pezzo Console,
+ you must provide an API key for each provider you wish to test.
+ This is optional.
+
+
+
+
+
+
+ >
+ );
+};
diff --git a/apps/console/src/pages/organizations/OrgMembersPage.tsx b/apps/console/src/pages/organizations/OrgMembersPage.tsx
new file mode 100644
index 000000000..18bddf0e5
--- /dev/null
+++ b/apps/console/src/pages/organizations/OrgMembersPage.tsx
@@ -0,0 +1,75 @@
+import { useCurrentOrganization } from "~/lib/hooks/useCurrentOrganization";
+import { OrgMembersList } from "~/components/organizations/OrgMembersList";
+import { OrgInvitationsList } from "~/components/organizations/OrgInvitationsList";
+import { InviteOrgMemberModal } from "~/components/organizations/InviteOrgMemberModal";
+import { useState } from "react";
+import { useCurrentOrgMembership } from "~/lib/hooks/useCurrentOrgMembership";
+import { trackEvent } from "~/lib/utils/analytics";
+import { Button, Card } from "@pezzo/ui";
+import { Plus } from "lucide-react";
+
+export const OrgMembersPage = () => {
+ const { organization } = useCurrentOrganization({
+ includeMembers: true,
+ includeInvitations: true,
+ });
+ const { isOrgAdmin } = useCurrentOrgMembership();
+ const [isInviteModalOpen, setIsInviteModalOpen] = useState(false);
+
+ if (!organization) {
+ return null;
+ }
+
+ const { members, invitations } = organization;
+
+ const onOpenInviteModal = () => {
+ setIsInviteModalOpen(true);
+ trackEvent("organization_member_invite_modal_opened");
+ };
+
+ return (
+ <>
+ setIsInviteModalOpen(false)}
+ />
+
+
+
+
+
+
+
+
Members
+
+ {isOrgAdmin && (
+
+
+ Invite Member
+
+ )}
+
+
+
+
+
+
+
+ {isOrgAdmin && invitations.length > 0 && (
+
+
+
+ )}
+
+ >
+ );
+};
diff --git a/apps/console/src/pages/organizations/OrgSettingsPage.tsx b/apps/console/src/pages/organizations/OrgSettingsPage.tsx
new file mode 100644
index 000000000..406f49330
--- /dev/null
+++ b/apps/console/src/pages/organizations/OrgSettingsPage.tsx
@@ -0,0 +1,128 @@
+import { useCurrentOrganization } from "~/lib/hooks/useCurrentOrganization";
+import { useUpdateOrgSettingsMutation } from "~/graphql/hooks/mutations";
+import { trackEvent } from "~/lib/utils/analytics";
+import z from "zod";
+import { zodResolver } from "@hookform/resolvers/zod";
+import { useForm } from "react-hook-form";
+import { AlertCircle, SaveIcon } from "lucide-react";
+import {
+ Form,
+ Alert,
+ AlertTitle,
+ AlertDescription,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormControl,
+ Input,
+ FormMessage,
+ Button,
+ Card,
+ toast,
+} from "@pezzo/ui";
+
+const formSchema = z.strictObject({
+ orgName: z
+ .string()
+ .min(1, "Name must be at least 1 character long")
+ .max(64, "Name can't be longer than 64 characters"),
+});
+
+export const OrgSettingsPage = () => {
+ const { organization } = useCurrentOrganization();
+ const form = useForm>({
+ resolver: zodResolver(formSchema),
+ defaultValues: {
+ orgName: organization?.name,
+ },
+ });
+
+ const {
+ mutate: updateSettings,
+ error,
+ isLoading,
+ } = useUpdateOrgSettingsMutation();
+
+ const onSubmit = async (values: z.infer) => {
+ if (!organization) return;
+
+ updateSettings(
+ { organizationId: organization?.id, name: values.orgName },
+ {
+ onSuccess: () => {
+ form.reset({ orgName: values.orgName });
+ toast({
+ title: "Settings updated",
+ description: "Your organization settings have been updated.",
+ });
+ },
+ }
+ );
+
+ trackEvent("organization_settings_form_submitted");
+ };
+
+ if (!organization) return null;
+
+ return (
+ <>
+
+
+
+ >
+ );
+};
diff --git a/apps/console/src/pages/organizations/onboarding/OnboardingPage.tsx b/apps/console/src/pages/organizations/onboarding/OnboardingPage.tsx
new file mode 100644
index 000000000..09d52b5f6
--- /dev/null
+++ b/apps/console/src/pages/organizations/onboarding/OnboardingPage.tsx
@@ -0,0 +1,126 @@
+import { useNavigate } from "react-router-dom";
+import { useGetProjects } from "~/graphql/hooks/queries";
+import {
+ useCreateProjectMutation,
+ useUpdateCurrentUserMutation,
+} from "~/graphql/hooks/mutations";
+import { useCallback, useEffect } from "react";
+import z from "zod";
+import { zodResolver } from "@hookform/resolvers/zod";
+import { useForm } from "react-hook-form";
+import {
+ CreateProjectMutation,
+ UpdateProfileMutation,
+} from "~/@generated/graphql/graphql";
+import { useAuthContext } from "~/lib/providers/AuthProvider";
+import { useCurrentOrganization } from "~/lib/hooks/useCurrentOrganization";
+import { usePageTitle } from "~/lib/hooks/usePageTitle";
+import {
+ Button,
+ Card,
+ CardContent,
+ CardFooter,
+ CardHeader,
+ Form,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+ Input,
+} from "@pezzo/ui";
+
+const formSchema = z.object({
+ projectName: z
+ .string()
+ .min(1, "Please enter a valid project name")
+ .max(100, "Project name must be less than 100 characters"),
+});
+
+export const OnboardingPage = () => {
+ const form = useForm>({
+ resolver: zodResolver(formSchema),
+ defaultValues: {
+ projectName: "",
+ },
+ });
+
+ const { organization } = useCurrentOrganization();
+ const { mutateAsync: updateCurrentUser, isLoading: isUpdatingUserLoading } =
+ useUpdateCurrentUserMutation();
+ const { mutateAsync: createProject, isLoading: isProjectCreationLoading } =
+ useCreateProjectMutation();
+ usePageTitle("Onboarding");
+ const { projects, isLoading: isProjectsLoading } = useGetProjects();
+
+ const { currentUser } = useAuthContext();
+
+ const navigate = useNavigate();
+
+ const isCreatingProject = isProjectCreationLoading || isUpdatingUserLoading;
+ const hasName = currentUser.name !== null;
+
+ const handleCreateProject = useCallback(
+ async (values: z.infer) => {
+ const actions: [
+ Promise,
+ Promise
+ ] = [
+ createProject({
+ name: values.projectName,
+ organizationId: organization?.id,
+ }),
+ null,
+ ];
+
+ await Promise.all(actions.filter(Boolean));
+ return navigate("/");
+ },
+ [createProject, organization?.id, navigate]
+ );
+
+ useEffect(() => {
+ if (projects && projects.length > 0) {
+ navigate("/", { replace: true });
+ }
+ }, [projects, navigate]);
+
+ return (
+
+ );
+};
diff --git a/apps/console/src/pages/organizations/onboarding/index.ts b/apps/console/src/pages/organizations/onboarding/index.ts
new file mode 100644
index 000000000..abf6671d0
--- /dev/null
+++ b/apps/console/src/pages/organizations/onboarding/index.ts
@@ -0,0 +1 @@
+export * from "./OnboardingPage";
diff --git a/apps/console/src/pages/projects/OrgPage.tsx b/apps/console/src/pages/projects/OrgPage.tsx
new file mode 100644
index 000000000..a300c7443
--- /dev/null
+++ b/apps/console/src/pages/projects/OrgPage.tsx
@@ -0,0 +1,16 @@
+import { ProjectsPage } from "./ProjectsPage";
+import { useParams } from "react-router-dom";
+import { useEffect } from "react";
+import { useLocalStorage } from "usehooks-ts";
+
+export const OrgPage = () => {
+ const { orgId } = useParams<{ orgId: string }>();
+
+ const [, setCurrentOrgId] = useLocalStorage("currentOrgId", orgId);
+
+ useEffect(() => {
+ setCurrentOrgId(orgId);
+ }, [orgId, setCurrentOrgId]);
+
+ return ;
+};
diff --git a/apps/console/src/pages/projects/ProjectsPage.tsx b/apps/console/src/pages/projects/ProjectsPage.tsx
new file mode 100644
index 000000000..080b2c92c
--- /dev/null
+++ b/apps/console/src/pages/projects/ProjectsPage.tsx
@@ -0,0 +1,174 @@
+import { useNavigate } from "react-router-dom";
+import { useGetProjects } from "~/graphql/hooks/queries";
+import { useEffect, useState } from "react";
+import {
+ Button,
+ Card,
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+ toast,
+} from "@pezzo/ui";
+import { trackEvent } from "~/lib/utils/analytics";
+import { usePageTitle } from "~/lib/hooks/usePageTitle";
+import {
+ FolderRootIcon,
+ MoreVertical,
+ PencilIcon,
+ PlusIcon,
+ TrashIcon,
+} from "lucide-react";
+import { CreateNewProjectModal } from "~/components/projects/CreateNewProjectModal";
+import { RenameProjectModal } from "~/components/projects/RenameProjectModal";
+import { GetProjectsQuery } from "~/@generated/graphql/graphql";
+import clsx from "clsx";
+import { useDeleteProjectMutation } from "~/graphql/hooks/mutations";
+import { GenericDestructiveConfirmationModal } from "~/components/common/GenericDestructiveConfirmationModal";
+
+type Project = GetProjectsQuery["projects"][0];
+
+export const ProjectsPage = () => {
+ const { projects, isLoading } = useGetProjects();
+ const [isCreateNewProjectModalOpen, setIsCreateNewProjectModalOpen] =
+ useState(false);
+ const [projectToDelete, setProjectToDelete] = useState(null);
+ const [projectToRename, setProjectToRename] = useState(null);
+ const navigate = useNavigate();
+ usePageTitle("Projects");
+
+ const { mutate: deleteProject } = useDeleteProjectMutation();
+
+ const handleDeleteProject = (projectId: string) => {
+ deleteProject(
+ { id: projectToDelete.id },
+ {
+ onSuccess: () => {
+ setProjectToDelete(null);
+ toast({
+ title: "Project deleted",
+ description: "The project has been deleted.",
+ });
+ },
+ }
+ );
+ trackEvent("project_delete_confirmed", {
+ projectId: projectToDelete?.id,
+ });
+ };
+
+ useEffect(() => {
+ if (isLoading) return;
+ if (!projects?.length) navigate("/onboarding");
+ }, [projects, isLoading, navigate]);
+
+ const handleCreateNewProjectClick = () => {
+ setIsCreateNewProjectModalOpen(true);
+ trackEvent("project_create_modal_opened");
+ };
+
+ const handleProjectClick = (projectId: string) => {
+ navigate(`/projects/${projectId}`);
+ trackEvent("project_nav_clicked", { projectId });
+ };
+
+ const handleRenameClick = (e: React.MouseEvent, project: Project) => {
+ e.stopPropagation();
+ trackEvent("project_rename_modal_opened", { projectId: project.id });
+ setProjectToRename(project);
+ };
+
+ const handleDeleteClick = (e: React.MouseEvent, project: Project) => {
+ e.stopPropagation();
+ trackEvent("project_delete_modal_opened", { projectId: project.id });
+ setProjectToDelete(project);
+ };
+
+ const baseCardClassName = "col-span-3 cursor-pointer p-6 transition-all";
+
+ const renderProject = (project: Project) => (
+ handleProjectClick(project.id)}
+ >
+
+
+
+
+
+
+
+
+ handleRenameClick(e, project)}>
+
+ Rename
+
+ handleDeleteClick(e, project)}
+ >
+
+ Delete
+
+
+
+
+
+ );
+
+ return (
+ <>
+ setProjectToDelete(null)}
+ onConfirm={() => handleDeleteProject(projectToDelete?.id)}
+ confirmText="Delete"
+ />
+
+ setProjectToRename(null)}
+ />
+
+ setIsCreateNewProjectModalOpen(false)}
+ />
+
+
+
+
Projects
+
+
+ New Project
+
+
+
+
+
+
+
+ {projects?.map((project) => renderProject(project))}
+
+
+ New Project
+
+
+
+ >
+ );
+};
diff --git a/apps/console/src/pages/projects/overview/DashboardPage.tsx b/apps/console/src/pages/projects/overview/DashboardPage.tsx
new file mode 100644
index 000000000..8cb4c364e
--- /dev/null
+++ b/apps/console/src/pages/projects/overview/DashboardPage.tsx
@@ -0,0 +1,79 @@
+import { Card } from "@pezzo/ui";
+import { SuccessErrorRateChart } from "./charts/SuccessErrorRateChart";
+import { ProjectMetricControlsProvider } from "./charts/ProjectMetricContext";
+import { TimeframeSelector } from "~/components/metrics/TimeframeSelector";
+import { TimeframeSelectorProvider } from "~/lib/providers/TimeframeSelectorContext";
+import { StatisticsSection } from "./StatisticsSection";
+import { ExecutionTimeChart } from "./charts/ExecutionTimeChart";
+import { usePageTitle } from "~/lib/hooks/usePageTitle";
+import { RequestFilters } from "~/components/requests/RequestFilters";
+import { useFiltersAndSortParams } from "~/lib/hooks/useFiltersAndSortParams";
+import { Popover, PopoverContent, PopoverTrigger, Button } from "@pezzo/ui";
+import { FilterIcon } from "lucide-react";
+
+export const DashboardPage = () => {
+ usePageTitle("Dashboard");
+ const { filters } = useFiltersAndSortParams();
+
+ return (
+
+
+
+
Dashboard
+
+
+
+
+
+ Filters {filters.length ? `(${filters.length})` : ""}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Success/Error (Total)
+
+
+
+
+
+ Request Duration (Average)
+
+
+
+
+
+ {/*
+
+
+ Model Usage
+
+
+
+
*/}
+
+
+ );
+};
diff --git a/apps/console/src/pages/projects/overview/StatisticsSection.tsx b/apps/console/src/pages/projects/overview/StatisticsSection.tsx
new file mode 100644
index 000000000..d98f21313
--- /dev/null
+++ b/apps/console/src/pages/projects/overview/StatisticsSection.tsx
@@ -0,0 +1,53 @@
+import { Card } from "@pezzo/ui";
+import { StatisticBox } from "~/components/metrics/StatisticBox";
+import { useProjectOverviewMetrics } from "./useProjectOverviewMetrics";
+
+export const StatisticsSection = () => {
+ const metrics = useProjectOverviewMetrics();
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/apps/console/src/pages/projects/overview/charts/ExecutionTimeChart.tsx b/apps/console/src/pages/projects/overview/charts/ExecutionTimeChart.tsx
new file mode 100644
index 000000000..01f954656
--- /dev/null
+++ b/apps/console/src/pages/projects/overview/charts/ExecutionTimeChart.tsx
@@ -0,0 +1,112 @@
+import { useTimeframeSelector } from "~/lib/providers/TimeframeSelectorContext";
+import {
+ LineChart,
+ Line,
+ XAxis,
+ YAxis,
+ CartesianGrid,
+ Tooltip,
+ Legend,
+ ResponsiveContainer,
+} from "recharts";
+import colors from "tailwindcss/colors";
+import { useProjectMetricControls } from "./ProjectMetricContext";
+import { TooltipWithTimestamp } from "./TooltipWithTimestamp";
+import { useCurrentProject } from "~/lib/hooks/useCurrentProject";
+import { useGenericProjectMetricHistogram } from "~/graphql/hooks/queries";
+import { HistogramIdType } from "~/@generated/graphql/graphql";
+import { useFiltersAndSortParams } from "~/lib/hooks/useFiltersAndSortParams";
+import { Loader2Icon } from "lucide-react";
+import { MetricsTypes } from "@pezzo/common";
+
+export const ExecutionTimeChart = () => {
+ const { project } = useCurrentProject();
+ const { startDate, endDate } = useTimeframeSelector();
+ const controls = useProjectMetricControls();
+ const { filters } = useFiltersAndSortParams();
+
+ const durationHistogram =
+ useGenericProjectMetricHistogram(
+ {
+ projectId: project?.id,
+ histogramId: HistogramIdType.RequestDuration,
+ bucketSize: controls.bucketSize,
+ startDate: startDate,
+ endDate: endDate,
+ filters,
+ },
+ {
+ enabled: !!project && !!startDate && !!endDate,
+ }
+ );
+
+ if (durationHistogram.isLoading) {
+ return (
+
+ );
+ }
+
+ const data = durationHistogram.histogram.data.map((d) => ({
+ timestamp: d.timestamp,
+ value: d.value,
+ }));
+
+ return (
+
+
+
+ controls.formatTimestamp(v)}
+ />
+ `${(value / 1000).toFixed(2)}s`}
+ />
+
+ key !== "value"
+ ? value
+ : `${((value as number) / 1000).toFixed(2)}s`
+ }
+ />
+
+
+
+
+ );
+};
diff --git a/apps/console/src/pages/projects/overview/charts/ModelUsageChart.tsx b/apps/console/src/pages/projects/overview/charts/ModelUsageChart.tsx
new file mode 100644
index 000000000..1ae2b005d
--- /dev/null
+++ b/apps/console/src/pages/projects/overview/charts/ModelUsageChart.tsx
@@ -0,0 +1,172 @@
+import {
+ XAxis,
+ YAxis,
+ CartesianGrid,
+ Tooltip,
+ Legend,
+ ResponsiveContainer,
+ Bar,
+ BarChart,
+} from "recharts";
+import colors from "tailwindcss/colors";
+import {
+ useGenericProjectMetricHistogram,
+ useProjectModelUsageHistogram,
+} from "~/graphql/hooks/queries";
+import {
+ HistogramIdType,
+ ModelUsageHistogramBucket,
+} from "~/@generated/graphql/graphql";
+import { useCurrentProject } from "~/lib/hooks/useCurrentProject";
+import { useTimeframeSelector } from "~/lib/providers/TimeframeSelectorContext";
+import { useProjectMetricControls } from "./ProjectMetricContext";
+import { TooltipWithTimestamp } from "./TooltipWithTimestamp";
+import { useFiltersAndSortParams } from "~/lib/hooks/useFiltersAndSortParams";
+import { Loader2Icon } from "lucide-react";
+import { modelAuthorDetails } from "~/pages/requests/model-display-details";
+import { ModelDetails } from "~/pages/requests/ModelDetails";
+import { MetricsTypes } from "@pezzo/common";
+
+interface ModelBar {
+ name: string;
+ color: string;
+}
+
+interface ModelLegendItem {
+ id: string;
+ value: string;
+ color: string;
+ LegendItem: React.ReactNode;
+}
+
+const histogramToChartData = (
+ buckets: MetricsTypes.ModelUsageResultDataType
+): {
+ data: any[];
+ bars: Map;
+ legendItems: Map;
+} => {
+ const bars = new Map();
+ const legendItems = new Map();
+
+ const timestamps = {};
+
+ const data = buckets.map((bucket) => {
+ const { model, modelAuthor, value, timestamp } = bucket;
+
+ if (!timestamps[timestamp]) {
+ timestamps[timestamp] = {};
+ }
+
+ if (!timestamps[timestamp][model]) {
+ timestamps[timestamp][model] = 0;
+ const { color } = modelAuthorDetails[modelAuthor];
+
+ bars.set(model, {
+ name: model,
+ color,
+ });
+
+ legendItems.set(model, {
+ id: model,
+ value: model,
+ color,
+ LegendItem: ,
+ });
+ }
+
+ timestamps[timestamp][model] += value;
+ });
+
+ return { data, bars, legendItems };
+};
+
+export const ModelUsageChart = () => {
+ const { startDate, endDate } = useTimeframeSelector();
+ const controls = useProjectMetricControls();
+ const { filters } = useFiltersAndSortParams();
+
+ const { project } = useCurrentProject();
+ const histogram =
+ useGenericProjectMetricHistogram(
+ {
+ projectId: project?.id,
+ histogramId: HistogramIdType.ModelUsage,
+ bucketSize: controls.bucketSize,
+ startDate: startDate,
+ endDate: endDate,
+ filters,
+ },
+ {
+ enabled: !!project && !!startDate && !!endDate,
+ }
+ );
+
+ if (histogram.isLoading) {
+ return (
+
+ );
+ }
+
+ const { data, bars, legendItems } = histogramToChartData(
+ histogram.histogram.data
+ );
+
+ return (
+
+
+
+ controls.formatTimestamp(v)}
+ />
+ `$${Number(v).toFixed(5)}`} />
+ {
+ return key === "Timestamp" ? value : `$${Number(value).toFixed(5)}`;
+ }}
+ />
+ {/* */}
+ {/*
+
+ {Array.from(legendItems.values()).sort((a, b) => a.id.localeCompare(b.id)).map(
+ ({ LegendItem, color }) => {LegendItem}
+ )}
+
+ }
+ /> */}
+
+ {/* {Array.from(bars).sort().map(([key, bar]) => (
+
+ ))} */}
+
+
+ );
+};
diff --git a/apps/console/src/pages/projects/overview/charts/ProjectMetricContext.tsx b/apps/console/src/pages/projects/overview/charts/ProjectMetricContext.tsx
new file mode 100644
index 000000000..68803f391
--- /dev/null
+++ b/apps/console/src/pages/projects/overview/charts/ProjectMetricContext.tsx
@@ -0,0 +1,65 @@
+import { createContext, useContext, useMemo } from "react";
+import { ProjectMetricHistogramBucketSize } from "~/@generated/graphql/graphql";
+import moment from "moment";
+import {
+ Timeframe,
+ useTimeframeSelector,
+} from "~/lib/providers/TimeframeSelectorContext";
+
+interface ProjectMetricControlsContextValue {
+ bucketSize: ProjectMetricHistogramBucketSize;
+ formatTimestamp: (timestamp: string) => string;
+}
+
+export const ProjectMetricControlsContext =
+ createContext(undefined);
+
+export const useProjectMetricControls = () =>
+ useContext(ProjectMetricControlsContext);
+
+interface Props {
+ children: React.ReactNode;
+}
+
+const timeframeToTimestampFormatterMapping = {
+ [Timeframe.PastHour]: "yyyy-MM-DD HH:mm",
+ [Timeframe.PastDay]: "yyyy-MM-DD HH:mm",
+ [Timeframe.PastWeek]: "DD MMM",
+ [Timeframe.PastMonth]: "DD MMM",
+ [Timeframe.PastYear]: "MMM yyyy",
+ [Timeframe.Custom]: "DD MMM",
+};
+
+const timeframeToBucketSizeMapping = {
+ [Timeframe.PastHour]: ProjectMetricHistogramBucketSize.Minutely,
+ [Timeframe.PastDay]: ProjectMetricHistogramBucketSize.Hourly,
+ [Timeframe.PastWeek]: ProjectMetricHistogramBucketSize.Daily,
+ [Timeframe.PastMonth]: ProjectMetricHistogramBucketSize.Daily,
+ [Timeframe.PastYear]: ProjectMetricHistogramBucketSize.Monthly,
+ [Timeframe.Custom]: ProjectMetricHistogramBucketSize.Daily,
+};
+
+export const ProjectMetricControlsProvider = ({ children }: Props) => {
+ const { timeframe } = useTimeframeSelector();
+
+ const formatTimestamp = (timestamp: string) => {
+ const date = moment(timestamp);
+ return date.format(timeframeToTimestampFormatterMapping[timeframe]);
+ };
+
+ const bucketSize = useMemo(
+ () => timeframeToBucketSizeMapping[timeframe],
+ [timeframe]
+ );
+
+ const value = {
+ bucketSize,
+ formatTimestamp,
+ };
+
+ return (
+
+ {children}
+
+ );
+};
diff --git a/apps/console/src/pages/projects/overview/charts/SuccessErrorRateChart.tsx b/apps/console/src/pages/projects/overview/charts/SuccessErrorRateChart.tsx
new file mode 100644
index 000000000..686b51530
--- /dev/null
+++ b/apps/console/src/pages/projects/overview/charts/SuccessErrorRateChart.tsx
@@ -0,0 +1,111 @@
+import {
+ XAxis,
+ YAxis,
+ CartesianGrid,
+ Tooltip,
+ Legend,
+ ResponsiveContainer,
+ Bar,
+ BarChart,
+} from "recharts";
+import colors from "tailwindcss/colors";
+import { HistogramIdType } from "~/@generated/graphql/graphql";
+import { useCurrentProject } from "~/lib/hooks/useCurrentProject";
+import { useTimeframeSelector } from "~/lib/providers/TimeframeSelectorContext";
+import { useProjectMetricControls } from "./ProjectMetricContext";
+import { TooltipWithTimestamp } from "./TooltipWithTimestamp";
+import { useFiltersAndSortParams } from "~/lib/hooks/useFiltersAndSortParams";
+import { Loader2Icon } from "lucide-react";
+import { useGenericProjectMetricHistogram } from "~/graphql/hooks/queries";
+import { MetricsTypes } from "@pezzo/common";
+
+export const SuccessErrorRateChart = () => {
+ const { startDate, endDate } = useTimeframeSelector();
+ const controls = useProjectMetricControls();
+ const { filters } = useFiltersAndSortParams();
+
+ const { project } = useCurrentProject();
+ const histogram =
+ useGenericProjectMetricHistogram(
+ {
+ projectId: project?.id,
+ histogramId: HistogramIdType.SuccessErrorRate,
+ bucketSize: controls.bucketSize,
+ startDate: startDate,
+ endDate: endDate,
+ filters,
+ },
+ {
+ enabled: !!project && !!startDate && !!endDate,
+ }
+ );
+
+ if (histogram.isLoading) {
+ return (
+
+ );
+ }
+
+ const data = histogram.histogram.data;
+
+ return (
+
+
+
+ controls.formatTimestamp(v)}
+ />
+
+
+
+
+
+
+
+ );
+};
diff --git a/apps/console/src/pages/projects/overview/charts/TooltipWithTimestamp.tsx b/apps/console/src/pages/projects/overview/charts/TooltipWithTimestamp.tsx
new file mode 100644
index 000000000..e2f55b29a
--- /dev/null
+++ b/apps/console/src/pages/projects/overview/charts/TooltipWithTimestamp.tsx
@@ -0,0 +1,27 @@
+import moment from "moment";
+import { DefaultTooltipContent } from "recharts";
+
+export const TooltipWithTimestamp = (props) => {
+ // payload[0] doesn't exist when tooltip isn't visible
+ if (props.payload && props.payload[0] != null) {
+ // mutating props directly is against react's conventions
+ // so we create a new payload with the name and value fields set to what we want
+ const newPayload = [
+ {
+ name: "Timestamp",
+ // all your data which created the tooltip is located in the .payload property
+ value: moment(props.payload[0].payload.timestamp).format(
+ "YYYY-MM-DD HH:mm"
+ ),
+ // you can also add "unit" here if you need it
+ },
+ ...props.payload,
+ ];
+
+ // we render the default, but with our overridden payload
+ return ;
+ }
+
+ // we just render the default
+ return ;
+};
diff --git a/apps/console/src/pages/projects/overview/charts/TotalCostChart.tsx b/apps/console/src/pages/projects/overview/charts/TotalCostChart.tsx
new file mode 100644
index 000000000..b99e3730a
--- /dev/null
+++ b/apps/console/src/pages/projects/overview/charts/TotalCostChart.tsx
@@ -0,0 +1,244 @@
+import {
+ XAxis,
+ YAxis,
+ CartesianGrid,
+ Tooltip,
+ Legend,
+ ResponsiveContainer,
+ BarChart,
+ Bar,
+} from "recharts";
+import colors from "tailwindcss/colors";
+
+const data = [
+ {
+ name: "7-18",
+ openai: 94,
+ anthropic: 17,
+ azureai: 35,
+ },
+ {
+ name: "7-19",
+ openai: 91,
+ anthropic: 20,
+ azureai: 37,
+ },
+ {
+ name: "7-20",
+ openai: 95,
+ anthropic: 19,
+ azureai: 35,
+ },
+ {
+ name: "7-21",
+ openai: 98,
+ anthropic: 17,
+ azureai: 36,
+ },
+ {
+ name: "7-22",
+ openai: 99,
+ anthropic: 19,
+ azureai: 38,
+ },
+ {
+ name: "7-23",
+ openai: 98,
+ anthropic: 18,
+ azureai: 20,
+ },
+ {
+ name: "7-24",
+ openai: 93,
+ anthropic: 17,
+ azureai: 38,
+ },
+ {
+ name: "7-25",
+ openai: 91,
+ anthropic: 18,
+ azureai: 36,
+ },
+ {
+ name: "7-26",
+ openai: 92,
+ anthropic: 18,
+ azureai: 39,
+ },
+ {
+ name: "7-27",
+ openai: 95,
+ anthropic: 19,
+ azureai: 37,
+ },
+ {
+ name: "7-28",
+ openai: 95,
+ anthropic: 19,
+ azureai: 40,
+ },
+ {
+ name: "7-29",
+ openai: 89,
+ anthropic: 18,
+ azureai: 36,
+ },
+ {
+ name: "7-30",
+ openai: 88,
+ anthropic: 18,
+ azureai: 35,
+ },
+ {
+ name: "7-31",
+ openai: 94,
+ anthropic: 18,
+ azureai: 38,
+ },
+ {
+ name: "8-1",
+ openai: 89,
+ anthropic: 19,
+ azureai: 36,
+ },
+ {
+ name: "8-2",
+ openai: 96,
+ anthropic: 20,
+ azureai: 38,
+ },
+ {
+ name: "8-3",
+ openai: 99,
+ anthropic: 19,
+ azureai: 37,
+ },
+ {
+ name: "8-4",
+ openai: 98,
+ anthropic: 18,
+ azureai: 35,
+ },
+ {
+ name: "8-5",
+ openai: 89,
+ anthropic: 18,
+ azureai: 35,
+ },
+ {
+ name: "8-6",
+ openai: 90,
+ anthropic: 19,
+ azureai: 37,
+ },
+ {
+ name: "8-7",
+ openai: 140,
+ anthropic: 17,
+ azureai: 36,
+ },
+ {
+ name: "8-8",
+ openai: 87,
+ anthropic: 19,
+ azureai: 35,
+ },
+ {
+ name: "8-9",
+ openai: 96,
+ anthropic: 19,
+ azureai: 34,
+ },
+ {
+ name: "8-10",
+ openai: 88,
+ anthropic: 20,
+ azureai: 37,
+ },
+ {
+ name: "8-11",
+ openai: 100,
+ anthropic: 20,
+ azureai: 34,
+ },
+ {
+ name: "8-12",
+ openai: 85,
+ anthropic: 17,
+ azureai: 39,
+ },
+ {
+ name: "8-13",
+ openai: 94,
+ anthropic: 18,
+ azureai: 36,
+ },
+ {
+ name: "8-14",
+ openai: 95,
+ anthropic: 17,
+ azureai: 35,
+ },
+ {
+ name: "8-15",
+ openai: 90,
+ anthropic: 17,
+ azureai: 37,
+ },
+ {
+ name: "8-16",
+ openai: 89,
+ anthropic: 18,
+ azureai: 37,
+ },
+];
+
+export const TotalCostChart = () => {
+ return (
+
+
+
+
+ `$${value}`} />
+
+
+
+
+
+
+
+ );
+};
diff --git a/apps/console/src/pages/projects/overview/useProjectOverviewMetrics.tsx b/apps/console/src/pages/projects/overview/useProjectOverviewMetrics.tsx
new file mode 100644
index 000000000..55fd8b892
--- /dev/null
+++ b/apps/console/src/pages/projects/overview/useProjectOverviewMetrics.tsx
@@ -0,0 +1,36 @@
+import { DeltaMetricType } from "~/@generated/graphql/graphql";
+import { useProjctMetricDelta } from "~/graphql/hooks/queries";
+import { useCurrentProject } from "~/lib/hooks/useCurrentProject";
+import { useTimeframeSelector } from "~/lib/providers/TimeframeSelectorContext";
+
+export const useProjectOverviewMetrics = () => {
+ const { project } = useCurrentProject();
+ const { startDate, endDate } = useTimeframeSelector();
+
+ const useMetric = (metric: DeltaMetricType) =>
+ useProjctMetricDelta(
+ {
+ projectId: project?.id,
+ metric,
+ startDate,
+ endDate,
+ },
+ {
+ enabled: !!project && !!startDate && !!endDate,
+ }
+ );
+
+ const requests = useMetric(DeltaMetricType.TotalRequests);
+ const cost = useMetric(DeltaMetricType.TotalCost);
+ const avgExecutionDuration = useMetric(
+ DeltaMetricType.AverageRequestDuration
+ );
+ const successRate = useMetric(DeltaMetricType.SuccessResponses);
+
+ return {
+ requests,
+ cost,
+ avgExecutionDuration,
+ successRate,
+ };
+};
diff --git a/apps/console/src/pages/prompts/PromptNavigation.tsx b/apps/console/src/pages/prompts/PromptNavigation.tsx
new file mode 100644
index 000000000..46cd6e418
--- /dev/null
+++ b/apps/console/src/pages/prompts/PromptNavigation.tsx
@@ -0,0 +1,74 @@
+import { cn } from "@pezzo/ui/utils";
+import { BoxIcon, GitCommitIcon, PencilIcon } from "lucide-react";
+import { useNavigate } from "react-router-dom";
+import { useCurrentOrganization } from "~/lib/hooks/useCurrentOrganization";
+import { useCurrentProject } from "~/lib/hooks/useCurrentProject";
+import { useCurrentPrompt } from "~/lib/providers/CurrentPromptContext";
+
+export const PromptNavigation = () => {
+ const { projectId } = useCurrentProject();
+ const { prompt } = useCurrentPrompt();
+ const navigate = useNavigate();
+ const { organization } = useCurrentOrganization();
+
+ if (!organization) {
+ return;
+ }
+
+ const baseClassName = cn(
+ "cursor-pointer h-12 flex gap-2 items-center py-3 px-4 text-sm font-medium border-b-2 border-b-transparent hover:border-b-primary transition-all"
+ );
+
+ const getClassName = (item) => {
+ return cn(baseClassName, {
+ "border-b-2 text-primary border-b-primary": item.isActive(item.href),
+ });
+ };
+
+ const basePath = `/projects/${projectId}/prompts/${prompt?.id}`;
+
+ const orgNavigation = [
+ {
+ name: "Editor",
+ icon: PencilIcon,
+ href: `${basePath}/edit`,
+ isActive: (href) => window.location.pathname === href,
+ },
+ {
+ name: "Versions",
+ icon: GitCommitIcon,
+ href: `${basePath}/versions`,
+ isActive: (href) => window.location.pathname === href,
+ },
+ ];
+
+ return (
+ organization && (
+
+
+
+
+ {prompt?.name}
+
+ {orgNavigation.map((nav) => (
+ navigate(nav.href)}
+ className={getClassName(nav)}
+ onKeyDown={(e) => {
+ if (e.key === "Enter") {
+ navigate(nav.href);
+ }
+ }}
+ role="button"
+ aria-label={nav.name}
+ >
+
+ {nav.name}
+
+ ))}
+
+
+ )
+ );
+};
diff --git a/apps/console/src/pages/prompts/PromptPage.tsx b/apps/console/src/pages/prompts/PromptPage.tsx
new file mode 100644
index 000000000..3dad2e00b
--- /dev/null
+++ b/apps/console/src/pages/prompts/PromptPage.tsx
@@ -0,0 +1,16 @@
+import { PromptNavigation } from "./PromptNavigation";
+import { Outlet } from "react-router-dom";
+import { Suspense } from "react";
+import { FullScreenLoader } from "~/components/common/FullScreenLoader";
+
+export const PromptPage = () => {
+ return (
+ <>
+
+
+ }>
+
+
+ >
+ );
+};
diff --git a/apps/console/src/pages/prompts/PromptsPage.tsx b/apps/console/src/pages/prompts/PromptsPage.tsx
new file mode 100644
index 000000000..eb8c98f60
--- /dev/null
+++ b/apps/console/src/pages/prompts/PromptsPage.tsx
@@ -0,0 +1,119 @@
+import { Button, Card, toast } from "@pezzo/ui";
+import { CreatePromptModal } from "~/components/prompts/CreatePromptModal";
+import { useState } from "react";
+import { usePrompts } from "~/lib/hooks/usePrompts";
+import { trackEvent } from "~/lib/utils/analytics";
+import { usePageTitle } from "~/lib/hooks/usePageTitle";
+import { MoveRightIcon, PlusIcon, TrashIcon } from "lucide-react";
+import { GetAllPromptsQuery } from "~/@generated/graphql/graphql";
+import { BoxIcon } from "lucide-react";
+import { useNavigate } from "react-router-dom";
+import { useCurrentProject } from "~/lib/hooks/useCurrentProject";
+import { GenericDestructiveConfirmationModal } from "~/components/common/GenericDestructiveConfirmationModal";
+import { useDeletePromptMutation } from "~/graphql/hooks/mutations";
+
+type Prompt = GetAllPromptsQuery["prompts"][0];
+
+export const PromptsPage = () => {
+ usePageTitle("Prompts");
+ const { projectId } = useCurrentProject();
+ const { prompts } = usePrompts();
+ const navigate = useNavigate();
+ const [isCreatePromptModalOpen, setIsCreatePromptModalOpen] = useState(false);
+ const [promptToDelete, setPromptToDelete] = useState(null);
+ const { mutate: deletePrompt, error: deletePromptError } =
+ useDeletePromptMutation();
+
+ const handleCreatePrompt = () => {
+ setIsCreatePromptModalOpen(true);
+ trackEvent("prompt_create_modal_opened");
+ };
+
+ const handleClickPrompt = (e: React.MouseEvent, promptId: string) => {
+ navigate(`/projects/${projectId}/prompts/${promptId}`);
+ trackEvent("prompt_nav_clicked", { promptId });
+ };
+
+ const handleDeletePromptClick = (e: React.MouseEvent, prompt: Prompt) => {
+ e.stopPropagation();
+ setPromptToDelete(prompt);
+ trackEvent("prompt_delete_modal_opened", { name: prompt.name });
+ };
+
+ const handleDeletePromptConfirm = (promptId: string) => {
+ deletePrompt(promptToDelete.id, {
+ onSuccess: () => {
+ setPromptToDelete(null);
+ toast({
+ title: "Prompt deleted",
+ description: "The prompt has been deleted.",
+ });
+ },
+ });
+ trackEvent("prompt_delete_confirmed");
+ };
+
+ return (
+ <>
+ setIsCreatePromptModalOpen(false)}
+ onCreated={() => setIsCreatePromptModalOpen(false)}
+ />
+
+ setPromptToDelete(null)}
+ onConfirm={() => handleDeletePromptConfirm(promptToDelete.id)}
+ error={deletePromptError}
+ />
+
+
+
+
Prompts
+
+
+ New Prompt
+
+
+
+
+
+
+ {prompts &&
+ prompts.map((prompt) => (
+
handleClickPrompt(e, prompt.id)}
+ key={prompt.id}
+ >
+
+ {prompt.name}
+ handleDeletePromptClick(e, prompt)}
+ size="icon"
+ variant="destructiveOutline"
+ >
+
+
+
+
+ ))}
+
+ {prompts && prompts.length === 0 && (
+
+
+ Create your first prompt
+
+ )}
+
+
+ >
+ );
+};
diff --git a/apps/console/src/pages/requests/ModelDetails.tsx b/apps/console/src/pages/requests/ModelDetails.tsx
new file mode 100644
index 000000000..92d7d2adf
--- /dev/null
+++ b/apps/console/src/pages/requests/ModelDetails.tsx
@@ -0,0 +1,20 @@
+import { getModelDisplayDetails } from "./model-display-details";
+
+interface Props {
+ model: string;
+ modelAuthor: string;
+}
+
+export const ModelDetails = ({ model, modelAuthor }: Props) => {
+ const displayDetails = getModelDisplayDetails(modelAuthor);
+ return (
+
+
+
{model}
+
+ );
+};
diff --git a/apps/console/src/pages/requests/RequestTags.tsx b/apps/console/src/pages/requests/RequestTags.tsx
new file mode 100644
index 000000000..59ac0cd72
--- /dev/null
+++ b/apps/console/src/pages/requests/RequestTags.tsx
@@ -0,0 +1,81 @@
+import { cn } from "@pezzo/ui/utils";
+import {
+ Tooltip as ShadcnTooltip,
+ TooltipContent,
+ TooltipProvider,
+ TooltipTrigger,
+} from "@pezzo/ui";
+import { RequestReportItem } from "./types";
+import { AlertTriangleIcon, BugPlayIcon, ZapIcon } from "lucide-react";
+
+export const Tooltip = ({
+ text,
+ children,
+}: {
+ text: string;
+ children: React.ReactNode;
+}) => (
+
+
+ {children}
+ {text}
+
+
+);
+
+export const RequestItemTags = ({
+ request,
+}: {
+ request: RequestReportItem;
+}) => {
+ const { isTestPrompt, promptId, cacheHit } = request;
+ const tags = [];
+
+ const baseCn = "rounded-sm border p-1 text-xs flex gap-1 items-center h-6";
+
+ if (!promptId) {
+ tags.push(
+
+
+
+ );
+ }
+
+ if (isTestPrompt) {
+ tags.push(
+
+
+
+ Test
+
+
+ );
+ }
+
+ if (cacheHit) {
+ tags.push(
+
+
+
+ Cache
+
+
+ );
+ }
+
+ return (
+
+
+ {tags.map((tag, i) => (
+
{tag}
+ ))}
+
+
+ );
+};
diff --git a/apps/console/src/pages/requests/RequestsPage.tsx b/apps/console/src/pages/requests/RequestsPage.tsx
new file mode 100644
index 000000000..18cc75389
--- /dev/null
+++ b/apps/console/src/pages/requests/RequestsPage.tsx
@@ -0,0 +1,231 @@
+import { useGetRequestReports } from "~/graphql/hooks/queries";
+import { Suspense, useMemo } from "react";
+import { DEFAULT_PAGE_SIZE } from "~/lib/constants/pagination";
+import { RequestReportItem } from "./types";
+import { usePageTitle } from "~/lib/hooks/usePageTitle";
+import { RequestsTable } from "./RequestsTable";
+import { ColumnDef } from "@tanstack/react-table";
+import { cn } from "@pezzo/ui/utils";
+import { CheckIcon, CircleSlash, MoveRightIcon } from "lucide-react";
+import { RequestItemTags } from "./RequestTags";
+import { RequestDetails } from "~/components/requests";
+import { Drawer } from "~/components/common/Drawer";
+import { useCurrentProject } from "~/lib/hooks/useCurrentProject";
+import { useQueryState } from "~/lib/hooks/useQueryState";
+import { RequestFilters } from "~/components/requests/RequestFilters";
+import {
+ Accordion,
+ AccordionContent,
+ AccordionItem,
+ AccordionTrigger,
+ Card,
+} from "@pezzo/ui";
+import { ModelDetails } from "./ModelDetails";
+import { SerializedPaginatedReport } from "@pezzo/types";
+
+const getTableColumns = (): ColumnDef[] => {
+ const columns: ColumnDef[] = [
+ {
+ accessorKey: "timestamp",
+ id: "timestamp",
+ header: "Timestamp",
+ cell: ({ row }) => {row.original.timestamp}
,
+ enableSorting: true,
+ },
+ {
+ accessorKey: "status",
+ id: "status",
+ header: "Status",
+ cell: ({ row }) => {
+ const isError = row.original.status >= 400;
+ return (
+
+ {isError ? (
+ <>
+
+ Error
+ >
+ ) : (
+ <>
+
+ Success
+ >
+ )}
+
+ );
+ },
+ enableSorting: true,
+ },
+ {
+ accessorKey: "model",
+ id: "model",
+ header: "Model",
+ cell: ({ row }) => {
+ const { model, modelAuthor } = row.original;
+ return ;
+ },
+ enableSorting: true,
+ },
+ {
+ accessorKey: "provider",
+ id: "provider",
+ header: "Provider",
+ cell: ({ row }) => (
+ {row.original.provider}
+ ),
+ enableSorting: true,
+ },
+ {
+ accessorKey: "duration",
+ id: "duration",
+ header: "Duration",
+ cell: ({ row }) => (
+ {`${(row.original.duration / 1000).toFixed(2)}s`}
+ ),
+ },
+ {
+ accessorKey: "totalTokens",
+ id: "totalTokens",
+ header: "Total Tokens",
+ cell: ({ row }) => {row.original.totalTokens}
,
+ },
+ {
+ accessorKey: "cost",
+ id: "cost",
+ header: "Cost",
+ cell: ({ row }) => ${row.original.cost.toFixed(5)}
,
+ },
+ {
+ id: "tags",
+ cell: ({ row }) => ,
+ },
+ {
+ id: "inspect",
+ size: 50,
+ cell: ({ row }) => (
+
+
+
+ ),
+ },
+ ];
+
+ return columns;
+};
+
+export const RequestsPage = () => {
+ usePageTitle("Requests");
+ const { projectId } = useCurrentProject();
+ const [inspectedRequestId, setInspectedRequestId] =
+ useQueryState("inspectedRequestId");
+ const [offset, setOffset] = useQueryState("offset", 0);
+ const [limit, setLimit] = useQueryState("limit", DEFAULT_PAGE_SIZE);
+
+ const { data: requests, isSuccess } = useGetRequestReports(
+ {
+ offset: Number(offset),
+ limit: Number(limit),
+ },
+ {
+ enabled:
+ inspectedRequestId &&
+ projectId &&
+ offset !== undefined &&
+ limit !== undefined,
+ }
+ );
+
+ const columns = useMemo(() => getTableColumns(), []);
+
+ const pagination = useMemo(
+ () => requests?.paginatedRequests?.pagination,
+ [requests]
+ );
+ const paginatedResults = useMemo(
+ () => requests?.paginatedRequests?.data ?? [],
+ [requests]
+ );
+
+ const data: RequestReportItem[] = useMemo(
+ () =>
+ paginatedResults.map((report: SerializedPaginatedReport) => {
+ return {
+ reportId: report.id,
+ timestamp: report.timestamp,
+ status: report.responseStatusCode,
+ duration: report.duration ?? 0,
+ totalTokens: report.totalTokens ?? 0,
+ cost: report.totalCost ?? 0,
+ isTestPrompt: report.environment === "PLAYGROUND" || false,
+ promptId: null,
+ cacheEnabled: report.cacheEnabled,
+ cacheHit: report.cacheHit,
+ model: report.model,
+ modelAuthor: report.modelAuthor,
+ provider: report.provider,
+ };
+ }),
+ [paginatedResults]
+ );
+
+ return (
+ <>
+ setInspectedRequestId(undefined)}
+ open={!!inspectedRequestId}
+ >
+ {inspectedRequestId && (
+
+
+
+ )}
+
+
+
+
+
+
+
+
+ Filters
+
+
+
+
+
+
+
+ {isSuccess && (
+
{
+ setOffset(offset);
+ }}
+ onLimitChange={(limit) => {
+ setLimit(limit);
+ }}
+ onClickReport={(reportId) => {
+ setInspectedRequestId(reportId);
+ }}
+ />
+ )}
+
+ >
+ );
+};
diff --git a/apps/console/src/pages/requests/RequestsTable.tsx b/apps/console/src/pages/requests/RequestsTable.tsx
new file mode 100644
index 000000000..af46213db
--- /dev/null
+++ b/apps/console/src/pages/requests/RequestsTable.tsx
@@ -0,0 +1,166 @@
+import {
+ ColumnDef,
+ flexRender,
+ getCoreRowModel,
+ getPaginationRowModel,
+ useReactTable,
+} from "@tanstack/react-table";
+
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@pezzo/ui";
+import { RequestReportItem } from "./types";
+import { Pagination } from "~/components/common/Pagination";
+import { useEffect } from "react";
+import { PAGE_SIZE_OPTIONS } from "~/lib/constants/pagination";
+
+interface DataTableProps {
+ data: RequestReportItem[];
+ columns: ColumnDef[];
+ totalResults: number;
+ limit: number;
+ offset: number;
+ onOffsetChange: (offset: number) => void;
+ onLimitChange: (limit: number) => void;
+ onClickReport: (reportId: string) => void;
+}
+
+export function RequestsTable({
+ columns,
+ data,
+ totalResults,
+ limit,
+ offset,
+ onOffsetChange,
+ onLimitChange,
+ onClickReport,
+}: DataTableProps) {
+ const table = useReactTable({
+ data,
+ columns,
+ getCoreRowModel: getCoreRowModel(),
+ getPaginationRowModel: getPaginationRowModel(),
+ defaultColumn: {
+ minSize: 0,
+ size: Number.MAX_SAFE_INTEGER,
+ maxSize: Number.MAX_SAFE_INTEGER,
+ },
+ });
+
+ useEffect(() => {
+ table.setPageCount(Math.ceil(totalResults / limit));
+ table.setPageSize(limit);
+ }, [table, limit, totalResults]);
+
+ return (
+ <>
+
+
+
+ {table.getHeaderGroups().map((headerGroup) => (
+
+ {headerGroup.headers.map((header) => {
+ return (
+
+ {header.isPlaceholder
+ ? null
+ : flexRender(
+ header.column.columnDef.header,
+ header.getContext()
+ )}
+
+ );
+ })}
+
+ ))}
+
+
+ {table.getRowModel().rows?.length ? (
+ table.getRowModel().rows.map((row) => (
+ onClickReport(row.original.reportId)}
+ key={row.id}
+ data-state={row.getIsSelected() && "selected"}
+ >
+ {row.getVisibleCells().map((cell) => (
+
+ {flexRender(
+ cell.column.columnDef.cell,
+ cell.getContext()
+ )}
+
+ ))}
+
+ ))
+ ) : (
+
+
+ No results.
+
+
+ )}
+
+
+
+
+
{
+ const offset = (page - 1) * limit;
+ onOffsetChange(offset);
+ }}
+ />
+
+ Results per page
+ onLimitChange(parseInt(value))}
+ >
+
+
+
+
+ {PAGE_SIZE_OPTIONS.map((size) => (
+
+ {size}
+
+ ))}
+
+
+
+
+ >
+ );
+}
diff --git a/apps/console/src/pages/requests/model-display-details.tsx b/apps/console/src/pages/requests/model-display-details.tsx
new file mode 100644
index 000000000..7759c0c31
--- /dev/null
+++ b/apps/console/src/pages/requests/model-display-details.tsx
@@ -0,0 +1,34 @@
+import OpenAILogo from "~/assets/providers/openai-logo.png";
+import MistralLogo from "~/assets/providers/mistral-logo.png";
+import MetaLogo from "~/assets/providers/meta-logo.png";
+
+export const modelAuthorDetails = {
+ openai: {
+ image: OpenAILogo,
+ name: "OpenAI",
+ color: "#3B976B",
+ },
+ mistral: {
+ image: MistralLogo,
+ name: "Mistral",
+ color: "#cf651f",
+ },
+ meta: {
+ image: MetaLogo,
+ name: "Meta",
+ color: "#579BE0",
+ },
+};
+
+export const getModelDisplayDetails = (modelAuthor: string) => {
+ const authorDetails = modelAuthorDetails[modelAuthor];
+
+ if (!authorDetails) {
+ return {
+ image: ,
+ name: "Unknown",
+ };
+ }
+
+ return authorDetails;
+};
diff --git a/apps/console/src/pages/requests/types.ts b/apps/console/src/pages/requests/types.ts
new file mode 100644
index 000000000..be49f76f5
--- /dev/null
+++ b/apps/console/src/pages/requests/types.ts
@@ -0,0 +1,17 @@
+import { AllPrimitiveTypes } from "@pezzo/types";
+
+export interface RequestReportItem {
+ reportId: string;
+ timestamp: string;
+ status: number;
+ duration: number;
+ totalTokens: number;
+ cost: number;
+ promptId: AllPrimitiveTypes | null;
+ isTestPrompt: boolean;
+ cacheEnabled: boolean;
+ cacheHit: boolean;
+ model: string;
+ modelAuthor: string;
+ provider: string;
+}
diff --git a/apps/console/src/styles.css b/apps/console/src/styles.css
new file mode 100644
index 000000000..0af09d57e
--- /dev/null
+++ b/apps/console/src/styles.css
@@ -0,0 +1,91 @@
+@import "./fonts.css";
+
+@tailwind components;
+@tailwind base;
+@tailwind utilities;
+
+html,
+body,
+#root {
+ @apply h-full;
+}
+
+@layer base {
+ h1 {
+ @apply font-heading text-3xl;
+ }
+ h2 {
+ @apply font-heading text-2xl;
+ }
+ h3 {
+ @apply font-heading text-xl;
+ }
+}
+
+@layer base {
+ :root {
+ --font-sans: "Inter", sans-serif;
+ --font-heading: "Brockmann";
+
+ --background: 0 0% 100%;
+ --foreground: 222.2 47.4% 11.2%;
+
+ --muted: 210 40% 96.1%;
+ --muted-foreground: 215.4 16.3% 46.9%;
+
+ --popover: 0 0% 100%;
+ --popover-foreground: 222.2 47.4% 11.2%;
+
+ --border: 214.3 31.8% 91.4%;
+ --input: 214.3 31.8% 91.4%;
+
+ --card: 0 0% 100%;
+ --card-foreground: 222.2 47.4% 11.2%;
+
+ --primary: 222.2 47.4% 11.2%;
+ --primary-foreground: 210 40% 98%;
+
+ --secondary: 210 40% 96.1%;
+ --secondary-foreground: 222.2 47.4% 11.2%;
+
+ --accent: 210 40% 96.1%;
+ --accent-foreground: 222.2 47.4% 11.2%;
+
+ --destructive: 0 68% 51%;
+ --destructive-foreground: 210 40% 98%;
+
+ --ring: 215 20.2% 65.1%;
+
+ --radius: 0.5rem;
+ }
+
+ .dark {
+ --background: 30 4% 9%;
+ --foreground: 0 1% 85%;
+
+ --muted: 240 4% 24%;
+ --muted-foreground: 0 0% 60%;
+
+ --accent: 0 0% 21%;
+ --accent-foreground: 0 1% 85%;
+
+ --popover: 30 3% 11%;
+ --popover-foreground: 0 1% 85%;
+ --border: 30 4% 18%;
+ --input: 0 0% 28%;
+
+ --card: 30 3% 11%;
+ --card-foreground: 360 100% 100%;
+ --primary: 159.13 51% 44%;
+ --primary-foreground: 0 0% 100%;
+
+ --secondary: 0 0% 45%;
+ --secondary-foreground: 210 4% 9%;
+ --destructive: 0 65% 57%;
+ --destructive-foreground: 210 40% 98%;
+
+ --ring: 0, 0% 21%;
+
+ --radius: 0.5rem;
+ }
+}
diff --git a/apps/console/tailwind.config.js b/apps/console/tailwind.config.js
index 7d5d9e11d..bc850449f 100644
--- a/apps/console/tailwind.config.js
+++ b/apps/console/tailwind.config.js
@@ -1,8 +1,93 @@
+const { fontFamily } = require("tailwindcss/defaultTheme");
+const { createGlobPatternsForDependencies } = require("@nx/react/tailwind");
+const { join } = require("path");
+
/** @type {import('tailwindcss').Config} */
module.exports = {
- content: [],
+ darkMode: ["class"],
+ content: [
+ join(
+ __dirname,
+ "{src,pages,components,app}/**/*!(*.stories|*.spec).{ts,tsx,html}"
+ ),
+ ...createGlobPatternsForDependencies(__dirname),
+ ],
theme: {
- extend: {},
+ container: {
+ center: true,
+ padding: "2rem",
+ },
+ extend: {
+ colors: {
+ border: "hsl(var(--border) / )",
+ input: "hsl(var(--input) / )",
+ ring: "hsl(var(--ring) / )",
+ background: "hsl(var(--background) / )",
+ foreground: "hsl(var(--foreground) / )",
+ primary: {
+ DEFAULT: "hsl(var(--primary) / )",
+ foreground: "hsl(var(--primary-foreground) / )",
+ },
+ secondary: {
+ DEFAULT: "hsl(var(--secondary) / )",
+ foreground: "hsl(var(--secondary-foreground) / )",
+ },
+ destructive: {
+ DEFAULT: "hsl(var(--destructive) / )",
+ foreground: "hsl(var(--destructive-foreground) / )",
+ },
+ muted: {
+ DEFAULT: "hsl(var(--muted) / )",
+ foreground: "hsl(var(--muted-foreground) / )",
+ },
+ accent: {
+ DEFAULT: "hsl(var(--accent) / )",
+ foreground: "hsl(var(--accent-foreground) / )",
+ },
+ popover: {
+ DEFAULT: "hsl(var(--popover) / )",
+ foreground: "hsl(var(--popover-foreground) / )",
+ },
+ card: {
+ DEFAULT: "hsl(var(--card) / )",
+ foreground: "var(--card-foreground)",
+ },
+ },
+ container: {
+ screens: {
+ sm: "640px",
+ md: "768px",
+ lg: "1024px",
+ xl: "1280px",
+ },
+ },
+ borderColor: {
+ DEFAULT: "hsl(var(--border) / )",
+ },
+ borderRadius: {
+ lg: `var(--radius)`,
+ md: `calc(var(--radius) - 2px)`,
+ sm: "calc(var(--radius) - 4px)",
+ },
+ fontFamily: {
+ sans: ["var(--font-sans)", ...fontFamily.sans],
+ heading: ["var(--font-heading)", ...fontFamily.sans],
+ },
+ keyframes: {
+ "accordion-down": {
+ from: { height: 0 },
+ to: { height: "var(--radix-accordion-content-height)" },
+ },
+ "accordion-up": {
+ from: { height: "var(--radix-accordion-content-height)" },
+ to: { height: 0 },
+ },
+ },
+ animation: {
+ "accordion-down": "accordion-down 0.2s ease-out",
+ "accordion-up": "accordion-up 0.2s ease-out",
+ },
+ },
},
- plugins: [],
+ plugins: [require("tailwindcss-animate")],
};
diff --git a/apps/console/tsconfig.app.json b/apps/console/tsconfig.app.json
index ce8082f13..8932816f7 100644
--- a/apps/console/tsconfig.app.json
+++ b/apps/console/tsconfig.app.json
@@ -5,8 +5,8 @@
"types": ["node"]
},
"files": [
- "../../node_modules/@nrwl/react/typings/cssmodule.d.ts",
- "../../node_modules/@nrwl/react/typings/image.d.ts"
+ "../../node_modules/@nx/react/typings/cssmodule.d.ts",
+ "../../node_modules/@nx/react/typings/image.d.ts"
],
"exclude": [
"jest.config.ts",
@@ -17,7 +17,11 @@
"src/**/*.spec.js",
"src/**/*.test.js",
"src/**/*.spec.jsx",
- "src/**/*.test.jsx"
+ "src/**/*.test.jsx",
+ "**/*.stories.ts",
+ "**/*.stories.js",
+ "**/*.stories.jsx",
+ "**/*.stories.tsx"
],
- "include": ["src/**/*.js", "src/**/*.jsx", "src/**/*.ts", "src/**/*.tsx"]
+ "include": ["src/main.tsx"]
}
diff --git a/apps/console/tsconfig.json b/apps/console/tsconfig.json
index 27d8ca657..18ff725b2 100644
--- a/apps/console/tsconfig.json
+++ b/apps/console/tsconfig.json
@@ -5,8 +5,9 @@
"esModuleInterop": false,
"allowSyntheticDefaultImports": true,
"strict": false,
- "jsxImportSource": "@emotion/react",
- "resolveJsonModule": true
+ "resolveJsonModule": true,
+ "sourceMap": true,
+ "strictNullChecks": false
},
"files": [],
"include": [],
@@ -16,6 +17,9 @@
},
{
"path": "./tsconfig.spec.json"
+ },
+ {
+ "path": "./tsconfig.storybook.json"
}
],
"extends": "../../tsconfig.base.json"
diff --git a/apps/console/tsconfig.spec.json b/apps/console/tsconfig.spec.json
index 310059903..989ff64de 100644
--- a/apps/console/tsconfig.spec.json
+++ b/apps/console/tsconfig.spec.json
@@ -18,7 +18,7 @@
"src/**/*.d.ts"
],
"files": [
- "../../node_modules/@nrwl/react/typings/cssmodule.d.ts",
- "../../node_modules/@nrwl/react/typings/image.d.ts"
+ "../../node_modules/@nx/react/typings/cssmodule.d.ts",
+ "../../node_modules/@nx/react/typings/image.d.ts"
]
}
diff --git a/apps/console/tsconfig.storybook.json b/apps/console/tsconfig.storybook.json
new file mode 100644
index 000000000..2da3caee1
--- /dev/null
+++ b/apps/console/tsconfig.storybook.json
@@ -0,0 +1,31 @@
+{
+ "extends": "./tsconfig.json",
+ "compilerOptions": {
+ "emitDecoratorMetadata": true,
+ "outDir": ""
+ },
+ "files": [
+ "../../node_modules/@nx/react/typings/styled-jsx.d.ts",
+ "../../node_modules/@nx/react/typings/cssmodule.d.ts",
+ "../../node_modules/@nx/react/typings/image.d.ts"
+ ],
+ "exclude": [
+ "src/**/*.spec.ts",
+ "src/**/*.test.ts",
+ "src/**/*.spec.js",
+ "src/**/*.test.js",
+ "src/**/*.spec.tsx",
+ "src/**/*.test.tsx",
+ "src/**/*.spec.jsx",
+ "src/**/*.test.js"
+ ],
+ "include": [
+ "src/**/*.stories.ts",
+ "src/**/*.stories.js",
+ "src/**/*.stories.jsx",
+ "src/**/*.stories.tsx",
+ "src/**/*.stories.mdx",
+ ".storybook/*.js",
+ ".storybook/*.ts"
+ ]
+}
diff --git a/apps/console/webpack.config.js b/apps/console/webpack.config.js
index 03da29705..ace442fee 100644
--- a/apps/console/webpack.config.js
+++ b/apps/console/webpack.config.js
@@ -1,9 +1,14 @@
-const { composePlugins, withNx } = require("@nrwl/webpack");
-const { withReact } = require("@nrwl/react");
+const { composePlugins, withNx } = require("@nx/webpack");
+const { withReact } = require("@nx/react");
// Nx plugins for webpack.
module.exports = composePlugins(withNx(), withReact(), (config) => {
// Update the webpack config as needed here.
// e.g. `config.plugins.push(new MyPlugin())`
+
+ config = {
+ ...config,
+ };
+
return config;
});
diff --git a/libs/graphql/.eslintrc.json b/apps/proxy/.eslintrc.json
similarity index 100%
rename from libs/graphql/.eslintrc.json
rename to apps/proxy/.eslintrc.json
diff --git a/apps/proxy/Dockerfile b/apps/proxy/Dockerfile
new file mode 100644
index 000000000..4413e1176
--- /dev/null
+++ b/apps/proxy/Dockerfile
@@ -0,0 +1,18 @@
+FROM node:20-alpine
+LABEL org.opencontainers.image.source https://github.com/pezzolabs/pezzo/proxy
+
+RUN apk update
+
+WORKDIR /app
+
+COPY ./dist/apps/proxy/package*.json ./
+
+RUN npm i --omit=dev
+
+COPY ./dist/apps/proxy .
+
+ENV HOST=0.0.0.0
+ENV PORT=3000
+EXPOSE $PORT
+
+ENTRYPOINT ["node", "main.js"]
\ No newline at end of file
diff --git a/libs/integrations/jest.config.ts b/apps/proxy/jest.config.ts
similarity index 68%
rename from libs/integrations/jest.config.ts
rename to apps/proxy/jest.config.ts
index c0333ebf5..5b7d6ed4a 100644
--- a/libs/integrations/jest.config.ts
+++ b/apps/proxy/jest.config.ts
@@ -1,10 +1,11 @@
/* eslint-disable */
export default {
- displayName: "integrations",
+ displayName: "proxy",
preset: "../../jest.preset.js",
+ testEnvironment: "node",
transform: {
"^.+\\.[tj]s$": ["ts-jest", { tsconfig: "/tsconfig.spec.json" }],
},
moduleFileExtensions: ["ts", "js", "html"],
- coverageDirectory: "../../coverage/libs/integrations",
+ coverageDirectory: "../../coverage/apps/proxy",
};
diff --git a/apps/proxy/project.json b/apps/proxy/project.json
new file mode 100644
index 000000000..3a0cce2b5
--- /dev/null
+++ b/apps/proxy/project.json
@@ -0,0 +1,91 @@
+{
+ "name": "proxy",
+ "$schema": "../../node_modules/nx/schemas/project-schema.json",
+ "sourceRoot": "apps/proxy/src",
+ "projectType": "application",
+ "targets": {
+ "build": {
+ "dependsOn": ["^build"],
+ "executor": "@nx/esbuild:esbuild",
+ "outputs": ["{options.outputPath}"],
+ "defaultConfiguration": "production",
+ "options": {
+ "platform": "node",
+ "outputPath": "dist/apps/proxy",
+ "format": ["cjs"],
+ "bundle": false,
+ "main": "apps/proxy/src/main.ts",
+ "tsConfig": "apps/proxy/tsconfig.app.json",
+ "assets": ["apps/proxy/src/assets"],
+ "generatePackageJson": true,
+ "esbuildOptions": {
+ "sourcemap": true,
+ "outExtension": {
+ ".js": ".js"
+ }
+ }
+ },
+ "configurations": {
+ "development": {},
+ "production": {
+ "esbuildOptions": {
+ "sourcemap": false,
+ "outExtension": {
+ ".js": ".js"
+ }
+ }
+ }
+ }
+ },
+ "serve": {
+ "executor": "@nx/js:node",
+ "defaultConfiguration": "development",
+ "options": {
+ "buildTarget": "proxy:build"
+ },
+ "configurations": {
+ "development": {
+ "buildTarget": "proxy:build:development"
+ },
+ "production": {
+ "buildTarget": "proxy:build:production"
+ }
+ }
+ },
+ "lint": {
+ "executor": "@nx/linter:eslint",
+ "outputs": ["{options.outputFile}"],
+ "options": {
+ "lintFilePatterns": ["apps/proxy/**/*.ts"]
+ }
+ },
+ "test": {
+ "executor": "@nx/jest:jest",
+ "outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
+ "options": {
+ "jestConfig": "apps/proxy/jest.config.ts",
+ "passWithNoTests": true
+ },
+ "configurations": {
+ "ci": {
+ "ci": true,
+ "codeCoverage": true
+ }
+ }
+ },
+ "docker:build": {
+ "dependsOn": ["build"],
+ "executor": "@nx-tools/nx-container:build",
+ "inputs": ["{projectRoot}/../../dist/apps/proxy"],
+ "defaultConfiguration": "local",
+ "options": {},
+ "configurations": {
+ "local": {
+ "tags": ["ghcr.io/pezzolabs/pezzo/proxy"],
+ "push": false
+ }
+ }
+ }
+ },
+ "tags": []
+}
diff --git a/tools/generators/.gitkeep b/apps/proxy/src/assets/.gitkeep
similarity index 100%
rename from tools/generators/.gitkeep
rename to apps/proxy/src/assets/.gitkeep
diff --git a/apps/proxy/src/lib/OpenAIHandler.ts b/apps/proxy/src/lib/OpenAIHandler.ts
new file mode 100644
index 000000000..917338a3e
--- /dev/null
+++ b/apps/proxy/src/lib/OpenAIHandler.ts
@@ -0,0 +1,163 @@
+import { PromptExecutionType, Provider } from "@pezzo/types";
+import { RequestWithPezzoClient } from "../types/common.types";
+import { Response } from "express";
+import axios from "axios";
+
+export class OpenAIV1Handler {
+ constructor(private req: RequestWithPezzoClient, private res: Response) {}
+
+ async handleRequest() {
+ const method = this.req.method;
+ const { headers, originalUrl } = this.req;
+ const url = originalUrl.replace("/openai/v1", "");
+ console.log(`[openai] ${method} ${url}`);
+
+ const execFn = async () => {
+ try {
+ const result = await axios({
+ method,
+ url: `https://api.openai.com/v1/${url}`,
+ data: this.req.body,
+ headers: {
+ Authorization: headers.authorization,
+ },
+ });
+
+ const status = result.status;
+ const data = result.data;
+ this.res.status(result.status).send(result.data);
+ return { status, data };
+ } catch (err) {
+ this.res.status(err.response.status).send(err.response.data);
+ return { status: err.response.status, data: err.response.data };
+ }
+ };
+
+ if (url.startsWith("/chat/completions")) {
+ await this.handleCreateChatCompletion(this.req, this.res, execFn);
+ } else {
+ await execFn();
+ }
+ }
+
+ async handleCreateChatCompletion(
+ originalRequest: RequestWithPezzoClient,
+ originalResponse: Response,
+ execFn: any
+ ) {
+ const pezzo = originalRequest.pezzo;
+
+ let properties = {};
+ const isCacheEnabled =
+ originalRequest.headers["x-pezzo-cache-enabled"] === "true";
+ const hasProperties =
+ originalRequest.headers["x-pezzo-properties"] !== undefined;
+
+ if (hasProperties) {
+ properties = JSON.parse(
+ originalRequest.headers["x-pezzo-properties"] as string
+ );
+ }
+
+ const baseMetadata: any = {
+ environment: pezzo.options.environment,
+ provider: Provider.OpenAI,
+ type: PromptExecutionType.ChatCompletion,
+ client: "pezzo-proxy",
+ clientVersion: "0.0.1",
+ };
+
+ const requestTimestamp = new Date().toISOString();
+
+ // Report Execution
+ const baseReport = {
+ cacheEnabled: isCacheEnabled,
+ cacheHit: false,
+ metadata: baseMetadata,
+ properties,
+ request: {
+ timestamp: requestTimestamp,
+ body: originalRequest.body,
+ },
+ };
+
+ let response;
+ let reportPayload;
+
+ if (isCacheEnabled) {
+ const cachedRequest = await pezzo.fetchCachedRequest(
+ originalRequest.body
+ );
+
+ if (cachedRequest.hit === true) {
+ baseReport.cacheHit = true;
+
+ console.log("cachedRequest", cachedRequest);
+
+ response = {
+ ...cachedRequest.data,
+ usage: {
+ prompt_tokens: 0,
+ completion_tokens: 0,
+ total_tokens: 0,
+ },
+ };
+
+ reportPayload = {
+ ...baseReport,
+ response: {
+ timestamp: requestTimestamp,
+ body: response,
+ status: 200,
+ },
+ };
+
+ originalResponse.status(200).json(response);
+ } else {
+ baseReport.cacheHit = false;
+ }
+ }
+
+ if (!isCacheEnabled || (isCacheEnabled && !baseReport.cacheHit)) {
+ const { status, data } = await execFn();
+
+ if (status === 200) {
+ reportPayload = {
+ ...baseReport,
+ response: {
+ timestamp: new Date().toISOString(),
+ body: data,
+ status: 200,
+ },
+ };
+ } else {
+ reportPayload = {
+ ...baseReport,
+ response: {
+ timestamp: new Date().toISOString(),
+ body: data,
+ status: status,
+ },
+ };
+ }
+ }
+
+ const shouldWriteToCache =
+ isCacheEnabled &&
+ reportPayload.cacheHit === false &&
+ reportPayload.response.status === 200;
+
+ try {
+ if (shouldWriteToCache) {
+ await Promise.all([
+ pezzo.reportPromptExecution(reportPayload),
+ pezzo.cacheRequest(originalRequest.body, reportPayload.response.body),
+ ]);
+ } else {
+ await pezzo.reportPromptExecution(reportPayload);
+ }
+ } catch (error) {
+ console.error("Error reporting prompt execution", error);
+ }
+ }
+}
diff --git a/apps/proxy/src/lib/middleware/create-openai-client-from-request.ts b/apps/proxy/src/lib/middleware/create-openai-client-from-request.ts
new file mode 100644
index 000000000..7e6fc7e6a
--- /dev/null
+++ b/apps/proxy/src/lib/middleware/create-openai-client-from-request.ts
@@ -0,0 +1,39 @@
+import { NextFunction, Response } from "express";
+import { RequestWithPezzoClient } from "../../types/common.types";
+import { Pezzo } from "@pezzo/client";
+
+export function createPezzoClientFromRequest(
+ req: RequestWithPezzoClient,
+ res: Response,
+ next: NextFunction
+) {
+ if (!req.headers["x-pezzo-api-key"]) {
+ return res.status(400).send("Missing x-pezzo-api-key header");
+ }
+
+ if (!req.headers["x-pezzo-project-id"]) {
+ return res.status(400).send("Missing x-pezzo-project-id header");
+ }
+
+ if (!req.headers["x-pezzo-environment"]) {
+ return res.status(400).send("Missing x-pezzo-environment header");
+ }
+
+ const options: {
+ apiKey: string;
+ projectId: string;
+ environment: string;
+ serverUrl?: string;
+ } = {
+ apiKey: req.headers["x-pezzo-api-key"] as string,
+ projectId: req.headers["x-pezzo-project-id"] as string,
+ environment: req.headers["x-pezzo-environment"] as string,
+ };
+
+ if (req.headers["x-pezzo-server-url"]) {
+ options.serverUrl = req.headers["x-pezzo-server-url"] as string;
+ }
+
+ req.pezzo = new Pezzo(options);
+ next();
+}
diff --git a/apps/proxy/src/main.ts b/apps/proxy/src/main.ts
new file mode 100644
index 000000000..d3bb895fe
--- /dev/null
+++ b/apps/proxy/src/main.ts
@@ -0,0 +1,21 @@
+import "reflect-metadata";
+import express from "express";
+import { openaiRouter } from "./routers/openai.router";
+
+const host = process.env.HOST ?? "localhost";
+const port = process.env.PORT ? Number(process.env.PORT) : 3001;
+
+const app = express();
+
+app.use(express.json());
+app.use(express.urlencoded({ extended: true }));
+
+app.use("/openai/v1", openaiRouter);
+
+app.get("/healthz", (_, res) => {
+ res.status(200).send("OK");
+});
+
+app.listen(port, host, () => {
+ console.log(`[ ready ] http://${host}:${port}`);
+});
diff --git a/apps/proxy/src/routers/openai.router.ts b/apps/proxy/src/routers/openai.router.ts
new file mode 100644
index 000000000..ae021918c
--- /dev/null
+++ b/apps/proxy/src/routers/openai.router.ts
@@ -0,0 +1,14 @@
+import express from "express";
+import { OpenAIV1Handler } from "../lib/OpenAIHandler";
+import { RequestWithPezzoClient } from "../types/common.types";
+import { createPezzoClientFromRequest } from "../lib/middleware/create-openai-client-from-request";
+
+export const openaiRouter = express.Router();
+
+openaiRouter.use(createPezzoClientFromRequest);
+openaiRouter.use(async (req: RequestWithPezzoClient, res, next) => {
+ const handler = new OpenAIV1Handler(req, res);
+ await handler.handleRequest();
+
+ next();
+});
diff --git a/apps/proxy/src/types/common.types.ts b/apps/proxy/src/types/common.types.ts
new file mode 100644
index 000000000..f6160f364
--- /dev/null
+++ b/apps/proxy/src/types/common.types.ts
@@ -0,0 +1,6 @@
+import { Pezzo } from "@pezzo/client";
+import { Request } from "express";
+
+export interface RequestWithPezzoClient extends Request {
+ pezzo: Pezzo;
+}
diff --git a/apps/proxy/tsconfig.app.json b/apps/proxy/tsconfig.app.json
new file mode 100644
index 000000000..f5e2e0859
--- /dev/null
+++ b/apps/proxy/tsconfig.app.json
@@ -0,0 +1,10 @@
+{
+ "extends": "./tsconfig.json",
+ "compilerOptions": {
+ "outDir": "../../dist/out-tsc",
+ "module": "commonjs",
+ "types": ["node"]
+ },
+ "exclude": ["jest.config.ts", "src/**/*.spec.ts", "src/**/*.test.ts"],
+ "include": ["src/**/*.ts"]
+}
diff --git a/apps/proxy/tsconfig.json b/apps/proxy/tsconfig.json
new file mode 100644
index 000000000..c1e2dd4e8
--- /dev/null
+++ b/apps/proxy/tsconfig.json
@@ -0,0 +1,16 @@
+{
+ "extends": "../../tsconfig.base.json",
+ "files": [],
+ "include": [],
+ "references": [
+ {
+ "path": "./tsconfig.app.json"
+ },
+ {
+ "path": "./tsconfig.spec.json"
+ }
+ ],
+ "compilerOptions": {
+ "esModuleInterop": true
+ }
+}
diff --git a/libs/graphql/tsconfig.spec.json b/apps/proxy/tsconfig.spec.json
similarity index 100%
rename from libs/graphql/tsconfig.spec.json
rename to apps/proxy/tsconfig.spec.json
diff --git a/apps/server/.dockerignore b/apps/server/.dockerignore
index bbe63a07d..8f6915074 100644
--- a/apps/server/.dockerignore
+++ b/apps/server/.dockerignore
@@ -1,3 +1,4 @@
.env
.env*
-project.json
\ No newline at end of file
+project.json
+volumes
\ No newline at end of file
diff --git a/apps/server/.env b/apps/server/.env
deleted file mode 100644
index f32f6b5a5..000000000
--- a/apps/server/.env
+++ /dev/null
@@ -1 +0,0 @@
-DATABASE_URL=postgresql://postgres:postgres@localhost:5432/pezzo
\ No newline at end of file
diff --git a/apps/server/.env.example b/apps/server/.env.example
new file mode 100644
index 000000000..0111f8d03
--- /dev/null
+++ b/apps/server/.env.example
@@ -0,0 +1,7 @@
+PINO_PRETTIFY="true"
+DATABASE_URL=postgresql://postgres:postgres@localhost:5433/pezzo
+SUPERTOKENS_CONNECTION_URI="http://localhost:3567"
+CONSOLE_HOST="http://localhost:4200"
+KAFKA_BROKERS="localhost:9092"
+OPENSEARCH_URL="http://localhost:9200"
+REDIS_URL="redis://localhost:6379"
\ No newline at end of file
diff --git a/apps/server/.eslintrc.json b/apps/server/.eslintrc.json
index 9d9c0db55..e9a9025b0 100644
--- a/apps/server/.eslintrc.json
+++ b/apps/server/.eslintrc.json
@@ -4,7 +4,9 @@
"overrides": [
{
"files": ["*.ts", "*.tsx", "*.js", "*.jsx"],
- "rules": {}
+ "rules": {
+ "no-case-declarations": "off"
+ }
},
{
"files": ["*.ts", "*.tsx"],
diff --git a/apps/server/Dockerfile b/apps/server/Dockerfile
index c4ef201f8..698d134fc 100644
--- a/apps/server/Dockerfile
+++ b/apps/server/Dockerfile
@@ -1,4 +1,4 @@
-FROM node:18.12.1-slim AS base
+FROM node:18.12-slim AS base
LABEL org.opencontainers.image.source https://github.com/pezzolabs/pezzo
RUN apt-get update
@@ -11,11 +11,14 @@ COPY ./dist/apps/server/package*.json ./
RUN npm i --omit=dev
COPY ./dist/apps/server .
-RUN chmod +x ./entrypoint.sh
RUN npx prisma generate
+COPY ./clickhouse ./clickhouse
+
+RUN cd clickhouse && npm i
+
ENV PORT=3000
EXPOSE $PORT
-ENTRYPOINT ["./entrypoint.sh"]
\ No newline at end of file
+ENTRYPOINT ["node", "main.js"]
\ No newline at end of file
diff --git a/apps/server/entrypoint.sh b/apps/server/entrypoint.sh
deleted file mode 100755
index 4df32217a..000000000
--- a/apps/server/entrypoint.sh
+++ /dev/null
@@ -1,7 +0,0 @@
-#!/bin/sh
-
-# Deploy prisma migration
-npx prisma migrate deploy
-
-# Start server
-node main.js
\ No newline at end of file
diff --git a/apps/server/prisma/migrations/20230513234620_orgs_and_api_keys/migration.sql b/apps/server/prisma/migrations/20230513234620_orgs_and_api_keys/migration.sql
new file mode 100644
index 000000000..6fed56df4
--- /dev/null
+++ b/apps/server/prisma/migrations/20230513234620_orgs_and_api_keys/migration.sql
@@ -0,0 +1,107 @@
+/*
+ Warnings:
+
+ - The primary key for the `Environment` table will be changed. If it partially fails, the table could be left without primary key constraint.
+ - You are about to drop the column `environmentSlug` on the `PromptEnvironment` table. All the data in the column will be lost.
+ - You are about to drop the `ProviderAPIKey` table. If the table is not empty, all the data it contains will be lost.
+ - The required column `id` was added to the `Environment` table with a prisma-level default value. This is not possible if the table is not empty. Please add this column as optional, then populate it before making it required.
+ - Added the required column `organizationId` to the `Environment` table without a default value. This is not possible if the table is not empty.
+ - Added the required column `updatedAt` to the `Environment` table without a default value. This is not possible if the table is not empty.
+ - Added the required column `organizationId` to the `Prompt` table without a default value. This is not possible if the table is not empty.
+ - Added the required column `environmentId` to the `PromptEnvironment` table without a default value. This is not possible if the table is not empty.
+
+*/
+-- CreateEnum
+CREATE TYPE "OrgRole" AS ENUM ('Admin');
+
+-- DropForeignKey
+ALTER TABLE "PromptEnvironment" DROP CONSTRAINT "PromptEnvironment_environmentSlug_fkey";
+
+-- AlterTable
+ALTER TABLE "Environment" DROP CONSTRAINT "Environment_pkey",
+ADD COLUMN "id" TEXT NOT NULL,
+ADD COLUMN "organizationId" TEXT NOT NULL,
+ADD COLUMN "updatedAt" TIMESTAMP(3) NOT NULL,
+ADD CONSTRAINT "Environment_pkey" PRIMARY KEY ("id");
+
+-- AlterTable
+ALTER TABLE "Prompt" ADD COLUMN "organizationId" TEXT NOT NULL;
+
+-- AlterTable
+ALTER TABLE "PromptEnvironment" DROP COLUMN "environmentSlug",
+ADD COLUMN "environmentId" TEXT NOT NULL;
+
+-- DropTable
+DROP TABLE "ProviderAPIKey";
+
+-- CreateTable
+CREATE TABLE "User" (
+ "id" TEXT NOT NULL,
+ "email" TEXT NOT NULL,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updatedAt" TIMESTAMP(3) NOT NULL,
+
+ CONSTRAINT "User_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "Organization" (
+ "id" TEXT NOT NULL,
+ "name" TEXT NOT NULL,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updatedAt" TIMESTAMP(3) NOT NULL,
+
+ CONSTRAINT "Organization_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "OrganizationMember" (
+ "id" TEXT NOT NULL,
+ "organizationId" TEXT NOT NULL,
+ "userId" TEXT NOT NULL,
+ "role" "OrgRole" NOT NULL DEFAULT E'Admin',
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updatedAt" TIMESTAMP(3) NOT NULL,
+
+ CONSTRAINT "OrganizationMember_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "ApiKey" (
+ "id" TEXT NOT NULL,
+ "name" TEXT NOT NULL DEFAULT E'Default',
+ "organizationId" TEXT NOT NULL,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+
+ CONSTRAINT "ApiKey_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "ProviderApiKey" (
+ "id" TEXT NOT NULL,
+ "provider" TEXT NOT NULL,
+ "value" TEXT NOT NULL,
+ "organizationId" TEXT NOT NULL,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updatedAt" TIMESTAMP(3) NOT NULL,
+
+ CONSTRAINT "ProviderApiKey_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateIndex
+CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
+
+-- AddForeignKey
+ALTER TABLE "OrganizationMember" ADD CONSTRAINT "OrganizationMember_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "OrganizationMember" ADD CONSTRAINT "OrganizationMember_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "PromptEnvironment" ADD CONSTRAINT "PromptEnvironment_environmentId_fkey" FOREIGN KEY ("environmentId") REFERENCES "Environment"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "ApiKey" ADD CONSTRAINT "ApiKey_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "ProviderApiKey" ADD CONSTRAINT "ProviderApiKey_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
diff --git a/apps/server/prisma/migrations/20230519223302_add_projects/migration.sql b/apps/server/prisma/migrations/20230519223302_add_projects/migration.sql
new file mode 100644
index 000000000..790327519
--- /dev/null
+++ b/apps/server/prisma/migrations/20230519223302_add_projects/migration.sql
@@ -0,0 +1,72 @@
+/*
+ Warnings:
+
+ - You are about to drop the column `organizationId` on the `ApiKey` table. All the data in the column will be lost.
+ - You are about to drop the column `organizationId` on the `Environment` table. All the data in the column will be lost.
+ - You are about to drop the column `organizationId` on the `Prompt` table. All the data in the column will be lost.
+ - You are about to drop the column `organizationId` on the `ProviderApiKey` table. All the data in the column will be lost.
+ - Added the required column `projectId` to the `ApiKey` table without a default value. This is not possible if the table is not empty.
+ - Added the required column `projectId` to the `Environment` table without a default value. This is not possible if the table is not empty.
+ - Added the required column `projectId` to the `Prompt` table without a default value. This is not possible if the table is not empty.
+ - Added the required column `projectId` to the `ProviderApiKey` table without a default value. This is not possible if the table is not empty.
+
+*/
+-- DropForeignKey
+ALTER TABLE "ApiKey" DROP CONSTRAINT "ApiKey_organizationId_fkey";
+
+-- DropForeignKey
+ALTER TABLE "ProviderApiKey" DROP CONSTRAINT "ProviderApiKey_organizationId_fkey";
+
+-- AlterTable
+ALTER TABLE "ApiKey" DROP COLUMN "organizationId",
+ADD COLUMN "projectId" TEXT NOT NULL;
+
+-- AlterTable
+ALTER TABLE "Environment" DROP COLUMN "organizationId",
+ADD COLUMN "projectId" TEXT NOT NULL;
+
+-- AlterTable
+ALTER TABLE "Prompt" DROP COLUMN "organizationId",
+ADD COLUMN "projectId" TEXT NOT NULL;
+
+-- AlterTable
+ALTER TABLE "ProviderApiKey" DROP COLUMN "organizationId",
+ADD COLUMN "projectId" TEXT NOT NULL;
+
+-- CreateTable
+CREATE TABLE "Project" (
+ "id" TEXT NOT NULL,
+ "slug" TEXT NOT NULL,
+ "name" TEXT NOT NULL,
+ "organizationId" TEXT NOT NULL,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updatedAt" TIMESTAMP(3) NOT NULL,
+
+ CONSTRAINT "Project_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "ProjectMember" (
+ "id" TEXT NOT NULL,
+ "projectId" TEXT NOT NULL,
+ "userId" TEXT NOT NULL,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updatedAt" TIMESTAMP(3) NOT NULL,
+
+ CONSTRAINT "ProjectMember_pkey" PRIMARY KEY ("id")
+);
+
+-- AddForeignKey
+ALTER TABLE "Project" ADD CONSTRAINT "Project_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "ProjectMember" ADD CONSTRAINT "ProjectMember_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "ProjectMember" ADD CONSTRAINT "ProjectMember_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "ApiKey" ADD CONSTRAINT "ApiKey_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "ProviderApiKey" ADD CONSTRAINT "ProviderApiKey_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
diff --git a/apps/server/prisma/migrations/20230524115604_add_created_by_to_prompt_version_and_published_by_to_prompt_environmnet/migration.sql b/apps/server/prisma/migrations/20230524115604_add_created_by_to_prompt_version_and_published_by_to_prompt_environmnet/migration.sql
new file mode 100644
index 000000000..e9094c865
--- /dev/null
+++ b/apps/server/prisma/migrations/20230524115604_add_created_by_to_prompt_version_and_published_by_to_prompt_environmnet/migration.sql
@@ -0,0 +1,18 @@
+/*
+ Warnings:
+
+ - Added the required column `publishedById` to the `PromptEnvironment` table without a default value. This is not possible if the table is not empty.
+ - Added the required column `createdById` to the `PromptVersion` table without a default value. This is not possible if the table is not empty.
+
+*/
+-- AlterTable
+ALTER TABLE "PromptEnvironment" ADD COLUMN "publishedById" TEXT NOT NULL;
+
+-- AlterTable
+ALTER TABLE "PromptVersion" ADD COLUMN "createdById" TEXT NOT NULL;
+
+-- AddForeignKey
+ALTER TABLE "PromptVersion" ADD CONSTRAINT "PromptVersion_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "PromptEnvironment" ADD CONSTRAINT "PromptEnvironment_publishedById_fkey" FOREIGN KEY ("publishedById") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
diff --git a/apps/server/prisma/migrations/20230525224945_refactor_to_point_api_key_at_environment/migration.sql b/apps/server/prisma/migrations/20230525224945_refactor_to_point_api_key_at_environment/migration.sql
new file mode 100644
index 000000000..6fe118b45
--- /dev/null
+++ b/apps/server/prisma/migrations/20230525224945_refactor_to_point_api_key_at_environment/migration.sql
@@ -0,0 +1,16 @@
+/*
+ Warnings:
+
+ - You are about to drop the column `projectId` on the `ApiKey` table. All the data in the column will be lost.
+ - Added the required column `environmentId` to the `ApiKey` table without a default value. This is not possible if the table is not empty.
+
+*/
+-- DropForeignKey
+ALTER TABLE "ApiKey" DROP CONSTRAINT "ApiKey_projectId_fkey";
+
+-- AlterTable
+ALTER TABLE "ApiKey" DROP COLUMN "projectId",
+ADD COLUMN "environmentId" TEXT NOT NULL;
+
+-- AddForeignKey
+ALTER TABLE "ApiKey" ADD CONSTRAINT "ApiKey_environmentId_fkey" FOREIGN KEY ("environmentId") REFERENCES "Environment"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
diff --git a/apps/server/prisma/migrations/20230525230100_remove_environmnet_slug/migration.sql b/apps/server/prisma/migrations/20230525230100_remove_environmnet_slug/migration.sql
new file mode 100644
index 000000000..78b14d206
--- /dev/null
+++ b/apps/server/prisma/migrations/20230525230100_remove_environmnet_slug/migration.sql
@@ -0,0 +1,8 @@
+/*
+ Warnings:
+
+ - You are about to drop the column `slug` on the `Environment` table. All the data in the column will be lost.
+
+*/
+-- AlterTable
+ALTER TABLE "Environment" DROP COLUMN "slug";
diff --git a/apps/server/prisma/migrations/20230526001135_add_environment_id_to_prompt_execution/migration.sql b/apps/server/prisma/migrations/20230526001135_add_environment_id_to_prompt_execution/migration.sql
new file mode 100644
index 000000000..9b351669a
--- /dev/null
+++ b/apps/server/prisma/migrations/20230526001135_add_environment_id_to_prompt_execution/migration.sql
@@ -0,0 +1,8 @@
+/*
+ Warnings:
+
+ - Added the required column `environmentId` to the `PromptExecution` table without a default value. This is not possible if the table is not empty.
+
+*/
+-- AlterTable
+ALTER TABLE "PromptExecution" ADD COLUMN "environmentId" TEXT NOT NULL;
diff --git a/apps/server/prisma/migrations/20230608182604_add_org_id_to_api_keys/migration.sql b/apps/server/prisma/migrations/20230608182604_add_org_id_to_api_keys/migration.sql
new file mode 100644
index 000000000..fa9f09fb7
--- /dev/null
+++ b/apps/server/prisma/migrations/20230608182604_add_org_id_to_api_keys/migration.sql
@@ -0,0 +1,29 @@
+-- DropForeignKey
+ALTER TABLE "ApiKey" DROP CONSTRAINT "ApiKey_environmentId_fkey";
+
+-- DropForeignKey
+ALTER TABLE "ProviderApiKey" DROP CONSTRAINT "ProviderApiKey_projectId_fkey";
+
+-- AlterTable
+ALTER TABLE "ApiKey" ADD COLUMN "organizationId" TEXT,
+ALTER COLUMN "environmentId" DROP NOT NULL;
+
+-- AlterTable
+ALTER TABLE "ProviderApiKey" ADD COLUMN "organizationId" TEXT,
+ALTER COLUMN "projectId" DROP NOT NULL;
+
+-- AlterTable
+ALTER TABLE "User" ALTER COLUMN "createdAt" DROP NOT NULL,
+ALTER COLUMN "updatedAt" DROP NOT NULL;
+
+-- AddForeignKey
+ALTER TABLE "ApiKey" ADD CONSTRAINT "ApiKey_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE SET NULL ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "ApiKey" ADD CONSTRAINT "ApiKey_environmentId_fkey" FOREIGN KEY ("environmentId") REFERENCES "Environment"("id") ON DELETE SET NULL ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "ProviderApiKey" ADD CONSTRAINT "ProviderApiKey_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE SET NULL ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "ProviderApiKey" ADD CONSTRAINT "ProviderApiKey_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE SET NULL ON UPDATE CASCADE;
diff --git a/apps/server/prisma/migrations/20230609142933_remove_old_fields_and_optional_values_post_migration/migration.sql b/apps/server/prisma/migrations/20230609142933_remove_old_fields_and_optional_values_post_migration/migration.sql
new file mode 100644
index 000000000..e4ee9de62
--- /dev/null
+++ b/apps/server/prisma/migrations/20230609142933_remove_old_fields_and_optional_values_post_migration/migration.sql
@@ -0,0 +1,34 @@
+/*
+ Warnings:
+
+ - You are about to drop the column `environmentId` on the `ApiKey` table. All the data in the column will be lost.
+ - You are about to drop the column `projectId` on the `ProviderApiKey` table. All the data in the column will be lost.
+ - Made the column `organizationId` on table `ApiKey` required. This step will fail if there are existing NULL values in that column.
+ - Made the column `organizationId` on table `ProviderApiKey` required. This step will fail if there are existing NULL values in that column.
+
+*/
+-- DropForeignKey
+ALTER TABLE "ApiKey" DROP CONSTRAINT "ApiKey_environmentId_fkey";
+
+-- DropForeignKey
+ALTER TABLE "ApiKey" DROP CONSTRAINT "ApiKey_organizationId_fkey";
+
+-- DropForeignKey
+ALTER TABLE "ProviderApiKey" DROP CONSTRAINT "ProviderApiKey_organizationId_fkey";
+
+-- DropForeignKey
+ALTER TABLE "ProviderApiKey" DROP CONSTRAINT "ProviderApiKey_projectId_fkey";
+
+-- AlterTable
+ALTER TABLE "ApiKey" DROP COLUMN "environmentId",
+ALTER COLUMN "organizationId" SET NOT NULL;
+
+-- AlterTable
+ALTER TABLE "ProviderApiKey" DROP COLUMN "projectId",
+ALTER COLUMN "organizationId" SET NOT NULL;
+
+-- AddForeignKey
+ALTER TABLE "ApiKey" ADD CONSTRAINT "ApiKey_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "ProviderApiKey" ADD CONSTRAINT "ProviderApiKey_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
diff --git a/apps/server/prisma/migrations/20230610210435_add_org_invitations/migration.sql b/apps/server/prisma/migrations/20230610210435_add_org_invitations/migration.sql
new file mode 100644
index 000000000..fb1d43c16
--- /dev/null
+++ b/apps/server/prisma/migrations/20230610210435_add_org_invitations/migration.sql
@@ -0,0 +1,24 @@
+-- CreateEnum
+CREATE TYPE "InvitationStatus" AS ENUM ('Pending', 'Accepted');
+
+-- AlterEnum
+ALTER TYPE "OrgRole" ADD VALUE 'Member';
+
+-- CreateTable
+CREATE TABLE "Invitation" (
+ "id" TEXT NOT NULL,
+ "email" TEXT NOT NULL,
+ "role" "OrgRole" NOT NULL,
+ "status" "InvitationStatus" NOT NULL,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "organizationId" TEXT NOT NULL,
+ "invitedById" TEXT NOT NULL,
+
+ CONSTRAINT "Invitation_pkey" PRIMARY KEY ("id")
+);
+
+-- AddForeignKey
+ALTER TABLE "Invitation" ADD CONSTRAINT "Invitation_invitedById_fkey" FOREIGN KEY ("invitedById") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "Invitation" ADD CONSTRAINT "Invitation_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
diff --git a/apps/server/prisma/migrations/20230612230210_remove_project_memberships/migration.sql b/apps/server/prisma/migrations/20230612230210_remove_project_memberships/migration.sql
new file mode 100644
index 000000000..e710a0445
--- /dev/null
+++ b/apps/server/prisma/migrations/20230612230210_remove_project_memberships/migration.sql
@@ -0,0 +1,14 @@
+/*
+ Warnings:
+
+ - You are about to drop the `ProjectMember` table. If the table is not empty, all the data it contains will be lost.
+
+*/
+-- DropForeignKey
+ALTER TABLE "ProjectMember" DROP CONSTRAINT "ProjectMember_projectId_fkey";
+
+-- DropForeignKey
+ALTER TABLE "ProjectMember" DROP CONSTRAINT "ProjectMember_userId_fkey";
+
+-- DropTable
+DROP TABLE "ProjectMember";
diff --git a/apps/server/prisma/migrations/20230706185133_remove_integration_id_from_prompt/migration.sql b/apps/server/prisma/migrations/20230706185133_remove_integration_id_from_prompt/migration.sql
new file mode 100644
index 000000000..a9f799131
--- /dev/null
+++ b/apps/server/prisma/migrations/20230706185133_remove_integration_id_from_prompt/migration.sql
@@ -0,0 +1,26 @@
+/*
+ Warnings:
+
+ - You are about to drop the column `integrationId` on the `Prompt` table. All the data in the column will be lost.
+
+*/
+-- DropForeignKey
+ALTER TABLE "PromptEnvironment" DROP CONSTRAINT "PromptEnvironment_promptId_fkey";
+
+-- DropForeignKey
+ALTER TABLE "PromptExecution" DROP CONSTRAINT "PromptExecution_promptId_fkey";
+
+-- DropForeignKey
+ALTER TABLE "PromptVersion" DROP CONSTRAINT "PromptVersion_promptId_fkey";
+
+-- AlterTable
+ALTER TABLE "Prompt" DROP COLUMN "integrationId";
+
+-- AddForeignKey
+ALTER TABLE "PromptVersion" ADD CONSTRAINT "PromptVersion_promptId_fkey" FOREIGN KEY ("promptId") REFERENCES "Prompt"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "PromptEnvironment" ADD CONSTRAINT "PromptEnvironment_promptId_fkey" FOREIGN KEY ("promptId") REFERENCES "Prompt"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "PromptExecution" ADD CONSTRAINT "PromptExecution_promptId_fkey" FOREIGN KEY ("promptId") REFERENCES "Prompt"("id") ON DELETE CASCADE ON UPDATE CASCADE;
diff --git a/apps/server/prisma/migrations/20230707155033_prompt_version_content_to_json/migration.sql b/apps/server/prisma/migrations/20230707155033_prompt_version_content_to_json/migration.sql
new file mode 100644
index 000000000..9c85e6268
--- /dev/null
+++ b/apps/server/prisma/migrations/20230707155033_prompt_version_content_to_json/migration.sql
@@ -0,0 +1,54 @@
+/*
+ Warnings:
+
+ - Changed the type of `content` on the `PromptVersion` table. No cast exists, the column would be dropped and recreated, which cannot be done if there is data, since the column is required.
+
+*/
+BEGIN;
+
+-- CreateEnum
+CREATE TYPE "PromptType" AS ENUM ('Prompt', 'Chat');
+
+-- Step 1: Add the tempContent column to the PromptVersion table
+ALTER TABLE "PromptVersion" ADD COLUMN "tempContent" TEXT;
+
+-- Step 2: Copy the content from the content column to the tempContent column, replacing nulls with an empty string
+UPDATE "PromptVersion" SET "tempContent" = COALESCE("content", '');
+
+-- AlterTable
+ALTER TABLE "PromptVersion"
+DROP COLUMN "content",
+ADD COLUMN "content" JSONB;
+
+ALTER TABLE "PromptVersion" ALTER COLUMN "settings" SET DEFAULT '{}';
+
+-- Step 4: Format the tempContent into a JSON structure and copy it to the content column
+UPDATE "PromptVersion" SET "content" = jsonb_build_object('prompt', "tempContent");
+
+-- Step 5: Drop the tempContent column
+ALTER TABLE "PromptVersion" ALTER COLUMN "content" SET NOT NULL;
+ALTER TABLE "PromptVersion" DROP COLUMN "tempContent";
+
+-- Step 6: Flatten the settings column
+UPDATE "PromptVersion"
+SET "settings" = jsonb_build_object(
+ 'model', "settings" -> 'model',
+ 'top_p', "settings" -> 'modelSettings' -> 'top_p',
+ 'max_tokens', "settings" -> 'modelSettings' -> 'max_tokens',
+ 'temperature', "settings" -> 'modelSettings' -> 'temperature',
+ 'presence_penalty', "settings" -> 'modelSettings' -> 'presence_penalty',
+ 'frequency_penalty', "settings" -> 'modelSettings' -> 'frequency_penalty'
+);
+
+-- Step 7: Add the new type column with a temporary default value
+ALTER TABLE "Prompt" ADD COLUMN "type" "PromptType" NOT NULL DEFAULT E'Prompt';
+
+-- Remove the default constraint
+ALTER TABLE "Prompt" ALTER COLUMN "type" DROP DEFAULT;
+
+-- Step 8: Add the new "service" column to PromptVersion
+ALTER TABLE "PromptVersion" ADD COLUMN "service" text;
+UPDATE "PromptVersion" SET service='OpenAIChatCompletion';
+ALTER TABLE "PromptVersion" ALTER COLUMN "service" SET NOT NULL;
+
+COMMIT;
\ No newline at end of file
diff --git a/apps/server/prisma/migrations/20230711154645_/migration.sql b/apps/server/prisma/migrations/20230711154645_/migration.sql
new file mode 100644
index 000000000..99067b5bb
--- /dev/null
+++ b/apps/server/prisma/migrations/20230711154645_/migration.sql
@@ -0,0 +1,5 @@
+-- DropForeignKey
+ALTER TABLE "PromptEnvironment" DROP CONSTRAINT "PromptEnvironment_environmentId_fkey";
+
+-- AddForeignKey
+ALTER TABLE "PromptEnvironment" ADD CONSTRAINT "PromptEnvironment_environmentId_fkey" FOREIGN KEY ("environmentId") REFERENCES "Environment"("id") ON DELETE CASCADE ON UPDATE CASCADE;
diff --git a/apps/server/prisma/migrations/20230917214320_encrypted_provider_api_keys/migration.sql b/apps/server/prisma/migrations/20230917214320_encrypted_provider_api_keys/migration.sql
new file mode 100644
index 000000000..f7b857a86
--- /dev/null
+++ b/apps/server/prisma/migrations/20230917214320_encrypted_provider_api_keys/migration.sql
@@ -0,0 +1,26 @@
+/*
+ Warnings:
+
+ - You are about to drop the column `value` on the `ProviderApiKey` table. All the data in the column will be lost.
+ - Added the required column `censoredValue` to the `ProviderApiKey` table without a default value. This is not possible if the table is not empty.
+ - Added the required column `encryptedData` to the `ProviderApiKey` table without a default value. This is not possible if the table is not empty.
+ - Added the required column `encryptedDataKey` to the `ProviderApiKey` table without a default value. This is not possible if the table is not empty.
+
+*/
+
+-- DropTable
+DROP TABLE IF EXISTS "ProviderApiKey";
+
+-- CreateTable
+CREATE TABLE "ProviderApiKey" (
+ "id" TEXT NOT NULL,
+ "provider" TEXT NOT NULL,
+ "censoredValue" TEXT NOT NULL,
+ "encryptedData" TEXT NOT NULL,
+ "encryptedDataKey" TEXT NOT NULL,
+ "organizationId" TEXT NOT NULL,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updatedAt" TIMESTAMP(3) NOT NULL,
+
+ CONSTRAINT "ProviderApiKey_pkey" PRIMARY KEY ("id")
+);
\ No newline at end of file
diff --git a/apps/server/prisma/migrations/20230918141726_support_chat_completion_prompts_with_multiple_messages/migration.sql b/apps/server/prisma/migrations/20230918141726_support_chat_completion_prompts_with_multiple_messages/migration.sql
new file mode 100644
index 000000000..2828d4de3
--- /dev/null
+++ b/apps/server/prisma/migrations/20230918141726_support_chat_completion_prompts_with_multiple_messages/migration.sql
@@ -0,0 +1,14 @@
+/*
+ Warnings:
+
+ - You are about to drop the column `type` on the `Prompt` table. All the data in the column will be lost.
+
+*/
+-- AlterTable
+ALTER TABLE "Prompt" DROP COLUMN "type";
+
+-- AlterTable
+ALTER TABLE "PromptVersion" ADD COLUMN "type" "PromptType" NOT NULL DEFAULT 'Prompt';
+
+-- AddForeignKey
+ALTER TABLE "ProviderApiKey" ADD CONSTRAINT "ProviderApiKey_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
diff --git a/apps/server/prisma/migrations/20231108052034_remove_prompt_executions/migration.sql b/apps/server/prisma/migrations/20231108052034_remove_prompt_executions/migration.sql
new file mode 100644
index 000000000..c10121592
--- /dev/null
+++ b/apps/server/prisma/migrations/20231108052034_remove_prompt_executions/migration.sql
@@ -0,0 +1,14 @@
+/*
+ Warnings:
+
+ - You are about to drop the `PromptExecution` table. If the table is not empty, all the data it contains will be lost.
+
+*/
+-- DropForeignKey
+ALTER TABLE "PromptExecution" DROP CONSTRAINT "PromptExecution_promptId_fkey";
+
+-- DropTable
+DROP TABLE "PromptExecution";
+
+-- DropEnum
+DROP TYPE "PromptExecutionStatus";
diff --git a/apps/server/prisma/migrations/20240105073154_add_organization_waitlisted_and_default_to_false/migration.sql b/apps/server/prisma/migrations/20240105073154_add_organization_waitlisted_and_default_to_false/migration.sql
new file mode 100644
index 000000000..92c6c8e9f
--- /dev/null
+++ b/apps/server/prisma/migrations/20240105073154_add_organization_waitlisted_and_default_to_false/migration.sql
@@ -0,0 +1,2 @@
+-- AlterTable
+ALTER TABLE "Organization" ADD COLUMN "waitlisted" BOOLEAN NOT NULL DEFAULT false;
diff --git a/apps/server/prisma/migrations/20240515123857_add/migration.sql b/apps/server/prisma/migrations/20240515123857_add/migration.sql
new file mode 100644
index 000000000..c0353e069
--- /dev/null
+++ b/apps/server/prisma/migrations/20240515123857_add/migration.sql
@@ -0,0 +1,2 @@
+-- AlterTable
+ALTER TABLE "ProviderApiKey" ADD COLUMN "encryptionTag" TEXT;
diff --git a/apps/server/prisma/migrations/20240515125809_make/migration.sql b/apps/server/prisma/migrations/20240515125809_make/migration.sql
new file mode 100644
index 000000000..6c796ecbb
--- /dev/null
+++ b/apps/server/prisma/migrations/20240515125809_make/migration.sql
@@ -0,0 +1,8 @@
+/*
+ Warnings:
+
+ - Made the column `encryptionTag` on table `ProviderApiKey` required. This step will fail if there are existing NULL values in that column.
+
+*/
+-- AlterTable
+ALTER TABLE "ProviderApiKey" ALTER COLUMN "encryptionTag" SET NOT NULL;
diff --git a/apps/server/prisma/schema.prisma b/apps/server/prisma/schema.prisma
index e38e2bf0d..fee46f053 100644
--- a/apps/server/prisma/schema.prisma
+++ b/apps/server/prisma/schema.prisma
@@ -8,9 +8,24 @@ generator client {
}
generator nestgraphql {
- provider = "node node_modules/prisma-nestjs-graphql"
- output = "../src/@generated"
+ provider = "node node_modules/prisma-nestjs-graphql"
+ output = "../src/@generated"
binaryTargets = ["native", "debian-openssl-1.1.x", "linux-arm64-openssl-1.1.x"]
+ purgeOutput = true
+
+ // PromptService
+ fields_PromptService_from = "../../app/prompts/models/prompt-version-service.enum"
+ fields_PromptService_input = true
+ fields_PromptService_models = true
+ fields_PromptService_output = true
+ fields_PromptService_namedImport = true
+
+ // ExtendedUser
+ fields_ExtendedUser_from = "../../app/identity/models/extended-user.model"
+ fields_ExtendedUser_input = false
+ fields_ExtendedUser_models = true
+ fields_ExtendedUser_output = true
+ fields_ExtendedUser_namedImport = true
}
datasource db {
@@ -18,77 +33,153 @@ datasource db {
url = env("DATABASE_URL")
}
-model Environment {
- slug String @id
- name String
+model PromptVersion {
+ sha String @id
+ type PromptType @default(Prompt)
+ /// @FieldType('PromptService')
+ /// @PropertyType('PromptService')
+ service String
+ settings Json @default("{}")
+ content Json
+ promptId String
+ prompt Prompt @relation(fields: [promptId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
promptEnvironments PromptEnvironment[]
+ message String?
+ createdById String
+ /// @FieldType('ExtendedUser')
+ /// @PropertyType('ExtendedUser')
+ createdBy User @relation(fields: [createdById], references: [id])
}
-model Prompt {
+model User {
+ id String @id
+ email String @unique
+ createdAt DateTime? @default(now())
+ updatedAt DateTime? @updatedAt
+ orgMemberships OrganizationMember[]
+ publishedPrompts PromptEnvironment[]
+ createdPromptVersions PromptVersion[]
+ sentInvitations Invitation[]
+}
+
+model Project {
+ id String @id @default(cuid())
+ slug String
+ name String
+ organization Organization @relation(fields: [organizationId], references: [id])
+ organizationId String
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+}
+
+model Organization {
+ id String @id @default(cuid())
+ name String
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+ members OrganizationMember[]
+ projects Project[]
+ apiKeys ApiKey[]
+ providerApiKeys ProviderApiKey[]
+ invitations Invitation[]
+ waitlisted Boolean @default(false)
+}
+
+model OrganizationMember {
+ id String @id @default(cuid())
+ organizationId String
+ userId String
+ role OrgRole @default(Admin)
+ organization Organization @relation(fields: [organizationId], references: [id])
+ /// @FieldType('ExtendedUser')
+ /// @PropertyType('ExtendedUser')
+ user User @relation(fields: [userId], references: [id])
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+}
+
+model Invitation {
+ id String @id @default(cuid())
+ email String
+ role OrgRole
+ status InvitationStatus
+ createdAt DateTime @default(now())
+ organizationId String
+ organization Organization @relation(fields: [organizationId], references: [id])
+ invitedById String
+ /// @FieldType('ExtendedUser')
+ /// @PropertyType('ExtendedUser')
+ invitedBy User @relation(fields: [invitedById], references: [id])
+}
+
+enum InvitationStatus {
+ Pending
+ Accepted
+}
+
+enum OrgRole {
+ Admin
+ Member
+}
+
+model Environment {
id String @id @default(cuid())
- integrationId String
name String
+ projectId String
+ promptEnvironments PromptEnvironment[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
- executions PromptExecution[]
- promptEnvironments PromptEnvironment[]
- versions PromptVersion[]
}
-model PromptVersion {
- sha String @id
- content String
- settings Json
- promptId String
- prompt Prompt @relation(fields: [promptId], references: [id])
- createdAt DateTime @default(now())
+model ApiKey {
+ id String @id
+ name String @default("Default")
+ organizationId String
+ organization Organization @relation(fields: [organizationId], references: [id])
+ createdAt DateTime @default(now())
+}
+
+enum PromptType {
+ Prompt
+ Chat
+}
+
+model Prompt {
+ id String @id @default(cuid())
+ projectId String
+ name String
promptEnvironments PromptEnvironment[]
- message String?
+ versions PromptVersion[]
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
}
model PromptEnvironment {
id String @id @default(cuid())
promptId String
- environmentSlug String
+ environmentId String
createdAt DateTime @default(now())
- prompt Prompt @relation(fields: [promptId], references: [id])
+ prompt Prompt @relation(fields: [promptId], references: [id], onDelete: Cascade)
promptVersionSha String
promptVersion PromptVersion? @relation(fields: [promptVersionSha], references: [sha])
- environment Environment @relation(fields: [environmentSlug], references: [slug])
-}
-
-model PromptExecution {
- id String @id @default(cuid())
- prompt Prompt @relation(fields: [promptId], references: [id])
- promptId String
- promptVersionSha String
- timestamp DateTime @default(now())
- status PromptExecutionStatus
- content String
- interpolatedContent String
- settings Json
- result String?
- duration Int
- promptTokens Int
- completionTokens Int
- totalTokens Int
- promptCost Float
- completionCost Float
- totalCost Float
- error String?
- variables Json @default("{}")
-}
-
-enum PromptExecutionStatus {
- Success
- Error
-}
-
-model ProviderAPIKey {
- id String @id @default(cuid())
- provider String
- value String
- createdAt DateTime @default(now())
- updatedAt DateTime @updatedAt
-}
\ No newline at end of file
+ environment Environment @relation(fields: [environmentId], references: [id], onDelete: Cascade)
+ publishedById String
+ publishedBy User @relation(fields: [publishedById], references: [id])
+}
+
+model ProviderApiKey {
+ id String @id @default(cuid())
+ /// @HideField({ input: true, output: true })
+ encryptedData String
+ /// @HideField({ input: true, output: true })
+ encryptedDataKey String
+ /// @HideField({ input: true, output: true })
+ encryptionTag String
+ censoredValue String
+ provider String
+ organizationId String
+ organization Organization @relation(fields: [organizationId], references: [id])
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+}
diff --git a/apps/server/project.json b/apps/server/project.json
index 67ce58fda..f4bc16e89 100644
--- a/apps/server/project.json
+++ b/apps/server/project.json
@@ -3,11 +3,10 @@
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "apps/server/src",
"projectType": "application",
- "implicitDependencies": ["graphql"],
"targets": {
"build": {
- "dependsOn": ["prisma:generate", "^graphql:codegen:offline"],
- "executor": "@nrwl/webpack:webpack",
+ "dependsOn": ["prisma:generate", "^prebuild"],
+ "executor": "@nx/webpack:webpack",
"outputs": ["{options.outputPath}"],
"defaultConfiguration": "production",
"options": {
@@ -18,18 +17,14 @@
"tsConfig": "apps/server/tsconfig.app.json",
"assets": [
"apps/server/src/assets",
- {
- "glob": "entrypoint.sh",
- "input": "apps/server",
- "output": "."
- },
{
"glob": "prisma",
"input": "apps/server",
"output": "prisma"
}
],
- "isolatedConfig": true,
+ "isolatedConfig": false,
+ "sourceMap": true,
"webpackConfig": "apps/server/webpack.config.js"
},
"configurations": {
@@ -38,7 +33,7 @@
}
},
"serve": {
- "executor": "@nrwl/js:node",
+ "executor": "@nx/js:node",
"defaultConfiguration": "development",
"dependsOn": ["prisma:generate"],
"options": {
@@ -54,14 +49,14 @@
}
},
"lint": {
- "executor": "@nrwl/linter:eslint",
+ "executor": "@nx/linter:eslint",
"outputs": ["{options.outputFile}"],
"options": {
"lintFilePatterns": ["apps/server/**/*.ts"]
}
},
"test": {
- "executor": "@nrwl/jest:jest",
+ "executor": "@nx/jest:jest",
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
"options": {
"jestConfig": "apps/server/jest.config.ts",
@@ -74,36 +69,44 @@
}
}
},
+ "prebuild": {
+ "dependsOn": ["prisma:generate", "graphql:generate"],
+ "executor": "nx:noop"
+ },
"prisma:generate": {
- "executor": "nx:run-commands",
- "outputs": ["apps/server/prisma/schema.prisma"],
- "options": {
- "command": "npx prisma generate --schema apps/server/prisma/schema.prisma"
- }
+ "command": "npx prisma generate --schema apps/server/prisma/schema.prisma",
+ "inputs": ["{projectRoot}/prisma/schema.prisma"],
+ "outputs": ["{projectRoot}/src/@generated"]
+ },
+ "graphql:generate": {
+ "dependsOn": ["graphql:schema-generate", "graphql:codegen"],
+ "executor": "nx:noop"
},
"graphql:schema-generate": {
"dependsOn": ["prisma:generate"],
- "executor": "nx:run-commands",
- "options": {
- "command": "GITHUB_ACTIONS=true npx ts-node -P apps/server/tsconfig.app.json -r tsconfig-paths/register apps/server/scripts/generate-graphql-schema.ts"
- }
+ "command": "GITHUB_ACTIONS=true npx ts-node -P apps/server/tsconfig.app.json -r tsconfig-paths/register apps/server/scripts/generate-graphql-schema.ts",
+ "outputs": ["{projectRoot}/schema.graphql"]
+ },
+ "generate-open-api-spec": {
+ "command": "npx ts-node -P apps/server/tsconfig.app.json -r tsconfig-paths/register apps/server/scripts/generate-openapi-spec-json.ts"
+ },
+ "graphql:codegen": {
+ "dependsOn": ["graphql:schema-generate"],
+ "command": "OFFLINE=true npx graphql-codegen"
+ },
+ "graphql:codegen:watch": {
+ "command": "npx graphql-codegen --watch"
},
"docker:build": {
- "executor": "@nx-tools/nx-container:build",
"dependsOn": ["build"],
+ "executor": "@nx-tools/nx-container:build",
+ "inputs": ["{projectRoot}/../../dist/apps/server"],
"defaultConfiguration": "local",
"options": {},
"configurations": {
"local": {
"tags": ["ghcr.io/pezzolabs/pezzo/server"],
"push": false
- },
- "ci": {
- "push": true,
- "metadata": {
- "images": ["ghcr.io/pezzolabs/pezzo/server"],
- "platforms": ["linux/amd64", "linux/arm64"]
- }
}
}
}
diff --git a/apps/server/scripts/generate-graphql-schema.ts b/apps/server/scripts/generate-graphql-schema.ts
index 1e7ccd17a..471f7a0e6 100644
--- a/apps/server/scripts/generate-graphql-schema.ts
+++ b/apps/server/scripts/generate-graphql-schema.ts
@@ -2,18 +2,37 @@ process.env.SKIP_CONFIG_VALIDATION = "true";
import { NestFactory } from "@nestjs/core";
import { PrismaClient } from "@prisma/client";
+import supertokens from "supertokens-node";
import { AppModule } from "../src/app/app.module";
+import { KafkaConsumerService, KafkaProducerService } from "@pezzo/kafka";
+import { ClickHouseService } from "../src/app/clickhouse/clickhouse.service";
+import { RedisService } from "../src/app/redis/redis.service";
// This script only runs in GitHub Actions
if (process.env.GITHUB_ACTIONS !== "true") {
process.exit(0);
}
+/**
+ * When generating the GraphQL schema in offline mode (CI), we don't want to connect to all
+ * external services, and we also don't want to serve the server. Therefore, we need to generate
+ * a static `schenma.gql` file to be used by `graphql-codegen`. To achieve this, we need to mock
+ * all external-facing services to prevent them from trying to establish connections.
+ */
export default async function generateGraphQLSchema(): Promise {
- // Override PrismaClient $connect to prevent connections to the database
- PrismaClient.prototype.$connect = async function () {
- return;
- };
+ // eslint-disable-next-line @typescript-eslint/no-empty-function
+ PrismaClient.prototype.$connect = async () => {};
+ // eslint-disable-next-line @typescript-eslint/no-empty-function
+ supertokens.init = async () => {};
+ // eslint-disable-next-line @typescript-eslint/no-empty-function
+ KafkaConsumerService.prototype.connect = async () => {};
+ // eslint-disable-next-line @typescript-eslint/no-empty-function
+ KafkaProducerService.prototype.connect = async () => {};
+ // eslint-disable-next-line @typescript-eslint/no-empty-function
+ ClickHouseService.prototype.onModuleInit = async () => {};
+ // eslint-disable-next-line @typescript-eslint/no-empty-function
+ RedisService.prototype.onModuleInit = async () => {};
+
// Use the side effect of initializing the nest application for generating
// the Nest.js schema
const app = await NestFactory.create(AppModule);
@@ -23,7 +42,6 @@ export default async function generateGraphQLSchema(): Promise {
if (require.main === module) {
generateGraphQLSchema()
.then(() => {
- console.log("Schema generated");
process.exit(0);
})
.catch((error) => {
diff --git a/apps/server/scripts/generate-openapi-spec-json.ts b/apps/server/scripts/generate-openapi-spec-json.ts
new file mode 100644
index 000000000..ce73fb4b7
--- /dev/null
+++ b/apps/server/scripts/generate-openapi-spec-json.ts
@@ -0,0 +1,65 @@
+import { DocumentBuilder, SwaggerModule } from "@nestjs/swagger";
+import { AppModule } from "../src/app/app.module";
+import { NestFactory } from "@nestjs/core";
+import path from "path";
+import fs from "fs";
+import { execSync } from "child_process";
+import { stderr } from "process";
+
+export default async function generateOpenAPISpecJSON() {
+ const app = await NestFactory.create(AppModule);
+
+ // Swagger setup
+ const config = new DocumentBuilder()
+ .setTitle("Pezzo API")
+ .setDescription(
+ "Specification of the Pezzo REST API, used by various clients."
+ )
+ .setVersion("1.0")
+ .build();
+ const document = SwaggerModule.createDocument(app, config);
+ const docsPath = path.join(__dirname, "../../../docs/");
+ const outputFilePath = path.join(docsPath, "openapi.json");
+ fs.writeFileSync(outputFilePath, JSON.stringify(document));
+ console.log("Generated OpenAPI spec");
+
+ let scrapedOutput = "";
+
+ console.log("Scraping the generated OpenAPI JSON");
+
+ scrapedOutput = execSync(
+ `cd ${docsPath} && npx --yes @mintlify/scraping@3.0.46 openapi-file openapi.json -o api-reference`,
+ { encoding: "utf-8" }
+ );
+
+ console.log(
+ "Scraped the OpenAPI JSON file and generated the MDX files for it"
+ );
+
+ const mintJSON = fs.readFileSync(path.join(docsPath, "mint.json"), {
+ encoding: "utf-8",
+ });
+
+ const mint = JSON.parse(mintJSON);
+
+ mint.navigation = mint.navigation.filter((navigationItem) => {
+ const pages: string[] = navigationItem.pages;
+ if (pages.length < 1) return true;
+ for (let i = 0; i < pages.length; i++) {
+ const path = pages[i];
+ if (path.startsWith("api-reference/")) return false;
+ }
+ return true;
+ });
+
+ scrapedOutput = scrapedOutput.slice(scrapedOutput.indexOf("["));
+ mint.navigation.push(...JSON.parse(scrapedOutput));
+ fs.writeFileSync(
+ path.join(docsPath, "mint.json"),
+ JSON.stringify(mint, null, 2)
+ );
+ console.log("Updated mint.json file");
+ process.exit(0);
+}
+
+generateOpenAPISpecJSON();
diff --git a/apps/server/src/app/analytics/analytics.module.ts b/apps/server/src/app/analytics/analytics.module.ts
new file mode 100644
index 000000000..a19e7ecbf
--- /dev/null
+++ b/apps/server/src/app/analytics/analytics.module.ts
@@ -0,0 +1,10 @@
+import { Global, Module } from "@nestjs/common";
+import { AnalyticsService } from "./analytics.service";
+
+@Global()
+@Module({
+ imports: [],
+ providers: [AnalyticsService],
+ exports: [AnalyticsService],
+})
+export class AnalyticsModule {}
diff --git a/apps/server/src/app/analytics/analytics.service.ts b/apps/server/src/app/analytics/analytics.service.ts
new file mode 100644
index 000000000..fcd0d6639
--- /dev/null
+++ b/apps/server/src/app/analytics/analytics.service.ts
@@ -0,0 +1,76 @@
+import { Inject, Injectable, Scope } from "@nestjs/common";
+import { ConfigService } from "@nestjs/config";
+import { Analytics } from "@segment/analytics-node";
+import { AnalyticsEvent } from "./events.types";
+import { CONTEXT } from "@nestjs/graphql";
+import { getRequestContext } from "../cls.utils";
+
+export interface EventContextProps {
+ userId?: string;
+ organizationId?: string;
+ projectId?: string;
+ promptId?: string;
+}
+
+const getId = (context: EventContextProps) => {
+ if (context.userId) return context.userId;
+ if (context.organizationId) return `org:${context.organizationId}`;
+ if (context.projectId) return `project:${context.projectId}`;
+ return "anonymous";
+};
+
+@Injectable()
+export class AnalyticsService {
+ analytics: Analytics;
+ segmentEnabled: boolean;
+
+ constructor(
+ private configService: ConfigService,
+ private config: ConfigService,
+ @Inject(CONTEXT)
+ private readonly context: { eventContext: EventContextProps } = {
+ eventContext: null,
+ }
+ ) {
+ const segmentApiKey = this.config.get("SEGMENT_KEY");
+
+ if (!segmentApiKey) {
+ console.log("Segment analytics disabled");
+ this.segmentEnabled = false;
+ } else {
+ this.analytics = new Analytics({
+ writeKey: segmentApiKey,
+ });
+ this.segmentEnabled = true;
+ }
+ }
+
+ trackEvent = (
+ event: keyof typeof AnalyticsEvent,
+ properties?: Record & EventContextProps
+ ) => {
+ if (!this.segmentEnabled) return;
+
+ const eventContext = {
+ ...this.context.eventContext,
+ ...getRequestContext(),
+ };
+
+ const { organizationId, projectId, promptId } = eventContext;
+ const eventPayload = {
+ event,
+ userId: getId(eventContext),
+ properties: {
+ organizationId,
+ projectId,
+ promptId,
+ ...properties,
+ },
+ context: {
+ groupId: organizationId,
+ },
+ };
+
+ this.analytics.track(eventPayload);
+ };
+}
diff --git a/apps/server/src/app/analytics/events.types.ts b/apps/server/src/app/analytics/events.types.ts
new file mode 100644
index 000000000..de7596161
--- /dev/null
+++ b/apps/server/src/app/analytics/events.types.ts
@@ -0,0 +1,17 @@
+// Pattern snake_case, Context => Object => Action
+
+export enum AnalyticsEvent {
+ prompt_created,
+ prompt_deleted,
+ prompt_published,
+ prompt_tested,
+ prompt_find_with_api_key,
+ prompt_version_created,
+ prompt_execution_reported,
+ provider_api_key_created,
+ environment_created,
+ project_created,
+ project_deleted,
+ project_settings_updated,
+ request_reported,
+}
diff --git a/apps/server/src/app/app.module.ts b/apps/server/src/app/app.module.ts
index ee997acdb..66df74e41 100644
--- a/apps/server/src/app/app.module.ts
+++ b/apps/server/src/app/app.module.ts
@@ -3,50 +3,81 @@ import { Module } from "@nestjs/common";
import { GraphQLModule } from "@nestjs/graphql";
import { ApolloDriver, ApolloDriverConfig } from "@nestjs/apollo";
import { ConfigModule } from "@nestjs/config";
-import Joi from "joi";
-
+import { randomUUID } from "crypto";
import { ApolloServerPluginLandingPageLocalDefault } from "@apollo/server/plugin/landingPage/default";
+import { EventEmitterModule } from "@nestjs/event-emitter";
+
import { PromptsModule } from "./prompts/prompts.module";
import { HealthController } from "./health.controller";
-import { EnvironmentsModule } from "./environments/environments.module";
import { formatError } from "../lib/gql-format-error";
import { PromptEnvironmentsModule } from "./prompt-environments/prompt-environments.module";
import { CredentialsModule } from "./credentials/credentials.module";
+import { AuthModule } from "./auth/auth.module";
+import { IdentityModule } from "./identity/identity.module";
+import { MetricsModule } from "./metrics/metrics.module";
+import { LoggerModule } from "./logger/logger.module";
+import { AnalyticsModule } from "./analytics/analytics.module";
+import { ReportingModule } from "./reporting/reporting.module";
+import { NotificationsModule } from "./notifications/notifications.module";
+import { getConfigSchema } from "./config/common-config-schema";
+import { PromptTesterModule } from "./prompt-tester/prompt-tester.module";
+import { ClsModule } from "./cls.module";
+import { CacheModule } from "./cache/cache.module";
+import { RedisModule } from "./redis/redis.module";
+import { EncryptionModule } from "./encryption/encryption.module";
+import { ClickhHouseModule } from "./clickhouse/clickhouse.module";
-const GQL_SCHEMA_PATH = join(process.cwd(), "apps/server/src/schema.graphql");
+const isCloud = process.env.PEZZO_CLOUD === "true";
@Module({
imports: [
+ LoggerModule,
ConfigModule.forRoot({
isGlobal: true,
envFilePath: ".env",
- validationSchema: Joi.object({
- DATABASE_URL: Joi.string().required(),
- PORT: Joi.number().default(3000),
- }),
+ validationSchema: getConfigSchema(),
// In CI, we need to skip validation because we don't have a .env file
// This is consumed by the graphql:schema-generate Nx target
validate:
process.env.SKIP_CONFIG_VALIDATION === "true" ? () => ({}) : undefined,
}),
+ ClickhHouseModule,
+ RedisModule,
+ EncryptionModule,
+ EventEmitterModule.forRoot(),
GraphQLModule.forRoot({
driver: ApolloDriver,
playground: false,
- autoSchemaFile: GQL_SCHEMA_PATH,
+ autoSchemaFile: join(process.cwd(), "apps/server/schema.graphql"),
sortSchema: true,
plugins: [ApolloServerPluginLandingPageLocalDefault()],
+ context: (ctx) => {
+ ctx.requestId = ctx.req.headers["x-request-id"] || randomUUID();
+ return ctx;
+ },
include: [
PromptsModule,
- EnvironmentsModule,
+ ReportingModule,
PromptEnvironmentsModule,
CredentialsModule,
+ IdentityModule,
+ MetricsModule,
+ PromptTesterModule,
],
formatError,
}),
+ AuthModule.forRoot(),
+ AnalyticsModule,
PromptsModule,
- EnvironmentsModule,
+ PromptTesterModule,
PromptEnvironmentsModule,
CredentialsModule,
+ IdentityModule,
+ MetricsModule,
+ ReportingModule,
+ ...(isCloud ? [NotificationsModule] : []),
+ ClsModule,
+ CacheModule,
],
controllers: [HealthController],
})
diff --git a/apps/server/src/app/auth/api-key-auth.guard.ts b/apps/server/src/app/auth/api-key-auth.guard.ts
new file mode 100644
index 000000000..ba7313f85
--- /dev/null
+++ b/apps/server/src/app/auth/api-key-auth.guard.ts
@@ -0,0 +1,54 @@
+import {
+ CanActivate,
+ ExecutionContext,
+ Injectable,
+ Scope,
+ UnauthorizedException,
+} from "@nestjs/common";
+import { ApiKeysService } from "../identity/api-keys.service";
+import { PinoLogger } from "../logger/pino-logger";
+import { updateRequestContext } from "../cls.utils";
+
+export enum AuthMethod {
+ ApiKey = "ApiKey",
+ BearerToken = "BearerToken",
+}
+
+@Injectable({ scope: Scope.REQUEST })
+export class ApiKeyAuthGuard implements CanActivate {
+ constructor(
+ private readonly apiKeysService: ApiKeysService,
+ private readonly logger: PinoLogger
+ ) {}
+
+ async canActivate(context: ExecutionContext): Promise {
+ const req = context.switchToHttp().getRequest();
+
+ if (!req.headers["x-pezzo-api-key"]) {
+ throw new UnauthorizedException("Pezzo API Key Not Provided");
+ }
+
+ return this.authorizeApiKey(req);
+ }
+
+ private async authorizeApiKey(req) {
+ this.logger.assign({ method: AuthMethod.ApiKey });
+ const keyValue = req.headers["x-pezzo-api-key"];
+ const apiKey = await this.apiKeysService.getApiKey(keyValue);
+
+ if (!apiKey) {
+ throw new UnauthorizedException("Invalid Pezzo API Key");
+ }
+
+ const organization = apiKey.organization;
+
+ req.organizationId = organization.id;
+ req.authMethod = AuthMethod.ApiKey;
+ this.logger.assign({
+ organizationId: organization.id,
+ });
+ updateRequestContext({ organizationId: organization.id });
+
+ return true;
+ }
+}
diff --git a/apps/server/src/app/auth/auth.filter.ts b/apps/server/src/app/auth/auth.filter.ts
new file mode 100644
index 000000000..d4450b594
--- /dev/null
+++ b/apps/server/src/app/auth/auth.filter.ts
@@ -0,0 +1,30 @@
+import { ExceptionFilter, Catch, ArgumentsHost } from "@nestjs/common";
+import { Request, Response, NextFunction, ErrorRequestHandler } from "express";
+
+import { errorHandler } from "supertokens-node/framework/express";
+import { Error as STError } from "supertokens-node";
+
+@Catch(STError)
+export class SupertokensExceptionFilter implements ExceptionFilter {
+ handler: ErrorRequestHandler;
+
+ constructor() {
+ this.handler = errorHandler();
+ }
+
+ catch(exception: Error, host: ArgumentsHost) {
+ const ctx = host.switchToHttp();
+
+ const resp = ctx.getResponse();
+ if (resp.headersSent) {
+ return;
+ }
+
+ this.handler(
+ exception,
+ ctx.getRequest(),
+ resp,
+ ctx.getNext()
+ );
+ }
+}
diff --git a/apps/server/src/app/auth/auth.guard.ts b/apps/server/src/app/auth/auth.guard.ts
new file mode 100644
index 000000000..7e59014c6
--- /dev/null
+++ b/apps/server/src/app/auth/auth.guard.ts
@@ -0,0 +1,105 @@
+import {
+ CanActivate,
+ ExecutionContext,
+ Inject,
+ Injectable,
+ InternalServerErrorException,
+ Scope,
+ UnauthorizedException,
+} from "@nestjs/common";
+import { CONTEXT, GqlExecutionContext } from "@nestjs/graphql";
+import ThirdPartyEmailPassword from "supertokens-node/recipe/thirdpartyemailpassword";
+import { UsersService } from "../identity/users.service";
+import { RequestUser } from "../identity/users.types";
+import Session, { SessionContainer } from "supertokens-node/recipe/session";
+import { PinoLogger } from "../logger/pino-logger";
+import { updateRequestContext } from "../cls.utils";
+
+export enum AuthMethod {
+ ApiKey = "ApiKey",
+ BearerToken = "BearerToken",
+}
+
+@Injectable({ scope: Scope.REQUEST })
+export class AuthGuard implements CanActivate {
+ constructor(
+ private readonly usersService: UsersService,
+ private readonly logger: PinoLogger,
+ @Inject(CONTEXT)
+ private readonly context = { eventContext: null }
+ ) {}
+
+ async canActivate(context: ExecutionContext): Promise {
+ return this.authorizeBearerToken(context);
+ }
+
+ private async authorizeBearerToken(context: ExecutionContext) {
+ const gqlCtx = GqlExecutionContext.create(context);
+ const ctx = gqlCtx.getContext();
+ const req = ctx.req;
+ const res = ctx.res;
+
+ let session: SessionContainer;
+
+ try {
+ session = await Session.getSession(req, res, {
+ sessionRequired: false,
+ antiCsrfCheck: process.env.NODE_ENV === "development" ? false : true,
+ });
+ } catch (error) {
+ throw new UnauthorizedException();
+ }
+
+ if (!session) {
+ throw new UnauthorizedException();
+ }
+
+ const supertokensUser = await ThirdPartyEmailPassword.getUserById(
+ session.getUserId()
+ );
+
+ req["supertokensUser"] = supertokensUser;
+
+ const user = await this.usersService.getUser(supertokensUser.email);
+
+ if (!user) {
+ throw new UnauthorizedException("User not found");
+ }
+
+ try {
+ const memberships = await this.usersService.getUserOrgMemberships(
+ supertokensUser.email
+ );
+
+ const reqUser: RequestUser = {
+ id: user.id,
+ supertokensUserId: supertokensUser.id,
+ email: user.email,
+ orgMemberships: memberships.map((m) => ({
+ organizationId: m.organizationId,
+ memberSince: m.createdAt,
+ role: m.role,
+ })),
+ };
+ const eventContext = {
+ userId: reqUser.id,
+ organizationId: reqUser.orgMemberships[0].organizationId,
+ };
+ this.context.eventContext = eventContext;
+ updateRequestContext(eventContext);
+
+ this.context.eventContext = {
+ userId: reqUser.id,
+ organizationId: reqUser.orgMemberships[0].organizationId,
+ };
+
+ req["user"] = reqUser;
+ this.logger.assign({ userId: reqUser.id });
+ } catch (error) {
+ throw new InternalServerErrorException();
+ }
+
+ req.authMethod = AuthMethod.BearerToken;
+ return true;
+ }
+}
diff --git a/apps/server/src/app/auth/auth.middleware.ts b/apps/server/src/app/auth/auth.middleware.ts
new file mode 100644
index 000000000..1cd8e9450
--- /dev/null
+++ b/apps/server/src/app/auth/auth.middleware.ts
@@ -0,0 +1,15 @@
+import { Injectable, NestMiddleware } from "@nestjs/common";
+import { middleware } from "supertokens-node/framework/express";
+
+@Injectable()
+export class AuthMiddleware implements NestMiddleware {
+ supertokensMiddleware: any;
+
+ constructor() {
+ this.supertokensMiddleware = middleware();
+ }
+
+ use(req: Request, res: any, next: () => void) {
+ return this.supertokensMiddleware(req, res, next);
+ }
+}
diff --git a/apps/server/src/app/auth/auth.module.ts b/apps/server/src/app/auth/auth.module.ts
new file mode 100644
index 000000000..f874a32b3
--- /dev/null
+++ b/apps/server/src/app/auth/auth.module.ts
@@ -0,0 +1,31 @@
+import {
+ MiddlewareConsumer,
+ Module,
+ NestModule,
+ DynamicModule,
+} from "@nestjs/common";
+
+import { AuthMiddleware } from "./auth.middleware";
+import { SupertokensService } from "./supertokens.service";
+import { IdentityModule } from "../identity/identity.module";
+import { AnalyticsModule } from "../analytics/analytics.module";
+
+@Module({
+ providers: [],
+ exports: [],
+ controllers: [],
+})
+export class AuthModule implements NestModule {
+ configure(consumer: MiddlewareConsumer) {
+ consumer.apply(AuthMiddleware).forRoutes("*");
+ }
+
+ static forRoot(): DynamicModule {
+ return {
+ providers: [SupertokensService],
+ exports: [],
+ imports: [IdentityModule, AnalyticsModule],
+ module: AuthModule,
+ };
+ }
+}
diff --git a/apps/server/src/app/auth/auth.types.ts b/apps/server/src/app/auth/auth.types.ts
new file mode 100644
index 000000000..d817258b4
--- /dev/null
+++ b/apps/server/src/app/auth/auth.types.ts
@@ -0,0 +1,5 @@
+export type SupertokensMetadata = {
+ metadata:
+ | { profile: { name: string | null; photoUrl: string | null } }
+ | undefined;
+};
diff --git a/apps/server/src/app/auth/current-auth-method.decorator.ts b/apps/server/src/app/auth/current-auth-method.decorator.ts
new file mode 100644
index 000000000..d7983af20
--- /dev/null
+++ b/apps/server/src/app/auth/current-auth-method.decorator.ts
@@ -0,0 +1,11 @@
+import { createParamDecorator, ExecutionContext } from "@nestjs/common";
+import { GqlExecutionContext } from "@nestjs/graphql";
+import { AuthMethod as _AuthMethod } from "./auth.guard";
+
+export const CurrentAuthMethod = createParamDecorator(
+ (_: unknown, context: ExecutionContext): _AuthMethod => {
+ const gqlCtx = GqlExecutionContext.create(context);
+ const ctx = gqlCtx.getContext();
+ return ctx.req.authMethod;
+ }
+);
diff --git a/apps/server/src/app/auth/project-id-auth.guard.ts b/apps/server/src/app/auth/project-id-auth.guard.ts
new file mode 100644
index 000000000..47a2eb413
--- /dev/null
+++ b/apps/server/src/app/auth/project-id-auth.guard.ts
@@ -0,0 +1,46 @@
+import {
+ CanActivate,
+ ExecutionContext,
+ Injectable,
+ Scope,
+ UnauthorizedException,
+} from "@nestjs/common";
+import { PinoLogger } from "../logger/pino-logger";
+import { ProjectsService } from "../identity/projects.service";
+import { updateRequestContext } from "../cls.utils";
+
+@Injectable({ scope: Scope.REQUEST })
+export class ProjectIdAuthGuard implements CanActivate {
+ constructor(
+ private readonly projectsService: ProjectsService,
+ private readonly logger: PinoLogger
+ ) {}
+
+ async canActivate(context: ExecutionContext): Promise {
+ const req = context.switchToHttp().getRequest();
+
+ if (!req.headers["x-pezzo-project-id"]) {
+ throw new UnauthorizedException("Invalid Pezzo Project ID");
+ }
+
+ return this.authorizeProjectId(req);
+ }
+
+ private async authorizeProjectId(req) {
+ const projectId = req.headers["x-pezzo-project-id"];
+ const project = await this.projectsService.getProjectById(projectId);
+
+ if (!project) {
+ throw new UnauthorizedException("Invalid Pezzo Project ID");
+ }
+
+ req.projectId = projectId;
+ updateRequestContext({ projectId });
+
+ this.logger.assign({
+ projectId,
+ });
+
+ return true;
+ }
+}
diff --git a/apps/server/src/app/auth/session.decorator.ts b/apps/server/src/app/auth/session.decorator.ts
new file mode 100644
index 000000000..f94fcd748
--- /dev/null
+++ b/apps/server/src/app/auth/session.decorator.ts
@@ -0,0 +1,8 @@
+import { createParamDecorator, ExecutionContext } from "@nestjs/common";
+
+export const Session = createParamDecorator(
+ (data: unknown, ctx: ExecutionContext) => {
+ const request = ctx.switchToHttp().getRequest();
+ return request.session;
+ }
+);
diff --git a/apps/server/src/app/auth/supertokens.service.ts b/apps/server/src/app/auth/supertokens.service.ts
new file mode 100644
index 000000000..973609322
--- /dev/null
+++ b/apps/server/src/app/auth/supertokens.service.ts
@@ -0,0 +1,215 @@
+import { Injectable } from "@nestjs/common";
+import supertokens from "supertokens-node";
+import Session from "supertokens-node/recipe/session";
+import UserMetadata from "supertokens-node/recipe/usermetadata";
+import ThirdPartyEmailPassword, {
+ TypeProvider,
+} from "supertokens-node/recipe/thirdpartyemailpassword";
+import Dashboard from "supertokens-node/recipe/dashboard";
+import ThirdParty from "supertokens-node/recipe/thirdparty";
+import { ConfigService } from "@nestjs/config";
+import { google } from "googleapis";
+import { UsersService } from "../identity/users.service";
+import { UserCreateRequest } from "../identity/users.types";
+import { PinoLogger } from "../logger/pino-logger";
+
+@Injectable()
+export class SupertokensService {
+ private logger: PinoLogger = new PinoLogger();
+
+ constructor(
+ private readonly config: ConfigService,
+ private readonly usersService: UsersService
+ ) {
+ supertokens.init({
+ appInfo: {
+ appName: "Pezzo",
+ apiDomain: config.get("SUPERTOKENS_API_DOMAIN"),
+ websiteDomain: config.get("SUPERTOKENS_WEBSITE_DOMAIN"),
+ apiBasePath: "/api/auth",
+ websiteBasePath: "/login",
+ },
+ supertokens: {
+ connectionURI: config.get("SUPERTOKENS_CONNECTION_URI"),
+ apiKey: config.get("SUPERTOKENS_API_KEY"),
+ },
+ recipeList: [
+ Dashboard.init(),
+ Session.init(),
+ UserMetadata.init(),
+ ThirdPartyEmailPassword.init({
+ providers: this.getActiveProviders(),
+ signUpFeature: {
+ formFields: [{ id: "name" }],
+ },
+ override: {
+ functions: (originalImplementation) => {
+ return {
+ ...originalImplementation,
+ emailPasswordSignUp: async function (input) {
+ const existingUsers =
+ await ThirdPartyEmailPassword.getUsersByEmail(input.email);
+ if (existingUsers.length === 0) {
+ // this means this email is new so we allow sign up
+ return originalImplementation.emailPasswordSignUp(input);
+ }
+ return {
+ status: "EMAIL_ALREADY_EXISTS_ERROR",
+ };
+ },
+ thirdPartySignInUp: async function (input) {
+ const existingUsers =
+ await ThirdPartyEmailPassword.getUsersByEmail(input.email);
+ if (existingUsers.length === 0) {
+ // this means this email is new so we allow sign up
+ return originalImplementation.thirdPartySignInUp(input);
+ }
+ if (
+ existingUsers.find(
+ (i) =>
+ i.thirdParty !== undefined &&
+ i.thirdParty.id === input.thirdPartyId &&
+ i.thirdParty.userId === input.thirdPartyUserId
+ )
+ ) {
+ // this means we are trying to sign in with the same social login. So we allow it
+ return originalImplementation.thirdPartySignInUp(input);
+ }
+ // this means that the email already exists with another social or email password login method, so we throw an error.
+ throw new Error("Cannot sign up as email already exists");
+ },
+ };
+ },
+ apis: (originalImplementation) => {
+ return {
+ ...originalImplementation,
+ emailPasswordSignUpPOST: async (input) => {
+ const res =
+ await originalImplementation.emailPasswordSignUpPOST(input);
+
+ if (res?.status === "OK") {
+ const userCreateRequest: UserCreateRequest = {
+ email: res.user.email,
+ id: res.user.id,
+ name: input.formFields.find(
+ (field) => field.id === "name"
+ ).value,
+ };
+
+ this.logger.assign({ userId: res.user.id });
+ await this.usersService.createUser(userCreateRequest);
+
+ const fullName = input.formFields.find(
+ (field) => field.id === "name"
+ )?.value;
+
+ if (fullName) {
+ try {
+ await UserMetadata.updateUserMetadata(res.user.id, {
+ profile: {
+ name: fullName,
+ },
+ });
+ } catch (error) {
+ this.logger.error(
+ { error },
+ "Failed to update user metadata fields"
+ );
+ }
+ }
+ }
+ return res;
+ },
+ thirdPartySignInUpPOST: async (input) => {
+ try {
+ const res =
+ await originalImplementation.thirdPartySignInUpPOST(
+ input
+ );
+
+ if (res.status === "OK") {
+ const { access_token } = res.authCodeResponse;
+ const client = new google.auth.OAuth2(
+ config.get("GOOGLE_OAUTH_CLIENT_ID"),
+ config.get("GOOGLE_OAUTH_CLIENT_SECRET")
+ );
+
+ client.setCredentials({ access_token });
+
+ // get user info from google since supertokens doesn't return it
+ const { data } = await google.oauth2("v2").userinfo.get({
+ auth: client,
+ fields: "email,given_name,family_name,picture",
+ });
+
+ this.logger.assign({ userId: res.user.id });
+
+ const metadataFields = {
+ name: `${data.given_name} ${data.family_name}`,
+ photoUrl: data.picture,
+ };
+
+ const userCreateRequest: UserCreateRequest = {
+ email: data.email,
+ id: res.user.id,
+ name: metadataFields.name,
+ };
+
+ const user = await this.usersService.getUser(data.email);
+
+ if (!user) {
+ await this.usersService.createUser(userCreateRequest);
+ }
+
+ await UserMetadata.updateUserMetadata(res.user.id, {
+ profile: metadataFields,
+ }).catch((error) => {
+ this.logger.error(
+ { error },
+ "Failed to update user metadata fields"
+ );
+ });
+ }
+ return res;
+ } catch (e) {
+ if (
+ e.message === "Cannot sign up as email already exists"
+ ) {
+ // this error was thrown from our function override above.
+ // so we send a useful message to the user
+ return {
+ status: "GENERAL_ERROR",
+ message:
+ "Seems like you already have an account with Google. Please use that instead.",
+ };
+ }
+ throw e;
+ }
+ },
+ };
+ },
+ },
+ }),
+ ],
+ });
+ }
+
+ private getActiveProviders(): TypeProvider[] {
+ const providers: TypeProvider[] = [];
+
+ if (
+ this.config.get("GOOGLE_OAUTH_CLIENT_ID") &&
+ this.config.get("GOOGLE_OAUTH_CLIENT_SECRET")
+ ) {
+ providers.push(
+ ThirdParty.Google({
+ clientId: this.config.get("GOOGLE_OAUTH_CLIENT_ID"),
+ clientSecret: this.config.get("GOOGLE_OAUTH_CLIENT_SECRET"),
+ scope: ["email", "profile"],
+ })
+ );
+ }
+
+ return providers;
+ }
+}
diff --git a/apps/server/src/app/cache/cache.controller.ts b/apps/server/src/app/cache/cache.controller.ts
new file mode 100644
index 000000000..da5a6951e
--- /dev/null
+++ b/apps/server/src/app/cache/cache.controller.ts
@@ -0,0 +1,92 @@
+import {
+ Body,
+ Controller,
+ Post,
+ UnauthorizedException,
+ UseGuards,
+} from "@nestjs/common";
+import { PinoLogger } from "../logger/pino-logger";
+import { CacheService } from "./cache.service";
+import { ApiKeyOrgId } from "../identity/api-key-org-id.decoator";
+import { ApiKeyAuthGuard } from "../auth/api-key-auth.guard";
+import { CacheRequestDto } from "./dto/cache-request.dto";
+import { ProjectIdAuthGuard } from "../auth/project-id-auth.guard";
+import { ProjectId } from "../identity/project-id.decorator";
+import { CacheRequestResult, FetchCachedRequestResult } from "@pezzo/client";
+import { RetrieveCacheRequestDto } from "./dto/retrieve-cache-request.dto";
+import { ApiOperation, ApiResponse, ApiTags } from "@nestjs/swagger";
+
+@UseGuards(ApiKeyAuthGuard)
+@UseGuards(ProjectIdAuthGuard)
+@ApiTags("Cache")
+@Controller("/cache/v1")
+export class CacheController {
+ constructor(
+ private readonly logger: PinoLogger,
+ private readonly cacheService: CacheService
+ ) {}
+
+ @Post("/request/retrieve")
+ @ApiOperation({ summary: "Retrieve cached request" })
+ @ApiResponse({
+ status: 200,
+ description: "Returns the cached request data.",
+ })
+ @ApiResponse({ status: 404, description: "Cached request not found." })
+ async retrieveCachedRequest(
+ @ApiKeyOrgId() organizationId: string,
+ @ProjectId() projectId: string,
+ @Body() dto: RetrieveCacheRequestDto
+ ): Promise {
+ this.logger
+ .assign({
+ organizationId,
+ projectId,
+ })
+ .info("fetchCachedRequest");
+
+ const cachedRequest = await this.cacheService.fetchRequest(dto.request);
+
+ const hit = !!cachedRequest;
+
+ if (hit && cachedRequest.metadata.organizationId !== organizationId) {
+ throw new UnauthorizedException();
+ }
+
+ return {
+ hit,
+ data: cachedRequest?.response ?? null,
+ };
+ }
+
+ @Post("/request/save")
+ @ApiOperation({ summary: "Save request to cache" })
+ @ApiResponse({
+ status: 200,
+ description: "Returns the cache request result.",
+ })
+ async saveRequestToCache(
+ @ApiKeyOrgId() organizationId: string,
+ @ProjectId() projectId: string,
+ @Body() dto: CacheRequestDto
+ ): Promise {
+ this.logger
+ .assign({
+ organizationId,
+ projectId,
+ })
+ .info("cacheRequest");
+
+ const { hash, exp } = await this.cacheService.cacheRequest({
+ request: dto.request,
+ response: dto.response,
+ organizationId,
+ projectId,
+ });
+
+ return {
+ hash,
+ exp,
+ };
+ }
+}
diff --git a/apps/server/src/app/cache/cache.module.ts b/apps/server/src/app/cache/cache.module.ts
new file mode 100644
index 000000000..b41ae7ab8
--- /dev/null
+++ b/apps/server/src/app/cache/cache.module.ts
@@ -0,0 +1,13 @@
+import { Module } from "@nestjs/common";
+import { CacheController } from "./cache.controller";
+import { CacheService } from "./cache.service";
+import { IdentityModule } from "../identity/identity.module";
+import { AuthModule } from "../auth/auth.module";
+import { RedisModule } from "../redis/redis.module";
+
+@Module({
+ imports: [IdentityModule, AuthModule, RedisModule],
+ controllers: [CacheController],
+ providers: [CacheService],
+})
+export class CacheModule {}
diff --git a/apps/server/src/app/cache/cache.service.ts b/apps/server/src/app/cache/cache.service.ts
new file mode 100644
index 000000000..734aca6ea
--- /dev/null
+++ b/apps/server/src/app/cache/cache.service.ts
@@ -0,0 +1,85 @@
+import { Injectable } from "@nestjs/common";
+import {
+ CacheRequestDto,
+ CacheRequestResult,
+ CachedRequest,
+} from "@pezzo/client";
+import { createHash } from "crypto";
+import stableStringify from "json-stable-stringify";
+import { RedisService } from "../redis/redis.service";
+
+// 3 days
+const REQUEST_CACHE_TTL = 60 * 60 * 24 * 3;
+
+@Injectable()
+export class CacheService {
+ constructor(private redisService: RedisService) {}
+
+ private generateHash(request: Record): string {
+ const stringified = stableStringify(request);
+ const hash = createHash("sha256").update(stringified).digest("hex");
+ return hash;
+ }
+
+ async fetchRequest(
+ request: Record
+ ): Promise {
+ const hash = this.generateHash(request);
+ const result = await this.redisService.client.json.get(`request:${hash}`, {
+ path: ".",
+ });
+
+ if (!result) {
+ return null;
+ }
+
+ this.redisService.client
+ .multi()
+ .expire(`request:${hash}`, REQUEST_CACHE_TTL)
+ .expire(`meta:request:${hash}`, REQUEST_CACHE_TTL)
+ .exec();
+
+ const metadata = await this.redisService.client.hGetAll(
+ `meta:request:${hash}`
+ );
+
+ return {
+ response: (result as unknown as Record).response,
+ metadata: {
+ projectId: metadata.projectId,
+ organizationId: metadata.organizationId,
+ },
+ };
+ }
+
+ async cacheRequest(dto: CacheRequestDto): Promise {
+ const { request, response } = dto;
+ const hash = this.generateHash(request);
+
+ await Promise.all([
+ this.redisService.client
+ .multi()
+ .json.set(`request:${hash}`, ".", {
+ response: { ...response },
+ })
+ .expire(`request:${hash}`, REQUEST_CACHE_TTL)
+ .exec(),
+ this.redisService.client
+ .multi()
+ .hSet(`meta:request:${hash}`, "projectId", dto.projectId)
+ .hSet(`meta:request:${hash}`, "organizationId", dto.organizationId)
+ .expire(`meta:request:${hash}`, REQUEST_CACHE_TTL)
+ .exec(),
+ this.redisService.client
+ .multi()
+ .sAdd(`index:organizationId:${dto.organizationId}`, hash)
+ .sAdd(`index:projectId:${dto.projectId}`, hash)
+ .exec(),
+ ]);
+
+ return {
+ hash,
+ exp: new Date(Date.now() + REQUEST_CACHE_TTL * 1000).toISOString(),
+ };
+ }
+}
diff --git a/apps/server/src/app/cache/dto/cache-request.dto.ts b/apps/server/src/app/cache/dto/cache-request.dto.ts
new file mode 100644
index 000000000..6b896cfb0
--- /dev/null
+++ b/apps/server/src/app/cache/dto/cache-request.dto.ts
@@ -0,0 +1,22 @@
+import { IsObject } from "class-validator";
+import { ApiProperty } from "@nestjs/swagger";
+
+export class CacheRequestDto {
+ @ApiProperty({
+ description: "The request object to cache",
+ type: Object,
+ additionalProperties: true,
+ example: { key1: "value1", key2: "value2" },
+ })
+ @IsObject()
+ request: Record;
+
+ @ApiProperty({
+ description: "The response object to cache",
+ type: Object,
+ additionalProperties: true,
+ example: { key1: "value1", key2: "value2" },
+ })
+ @IsObject()
+ response: Record;
+}
diff --git a/apps/server/src/app/cache/dto/retrieve-cache-request.dto.ts b/apps/server/src/app/cache/dto/retrieve-cache-request.dto.ts
new file mode 100644
index 000000000..100ee8eb2
--- /dev/null
+++ b/apps/server/src/app/cache/dto/retrieve-cache-request.dto.ts
@@ -0,0 +1,13 @@
+import { IsObject } from "class-validator";
+import { ApiProperty } from "@nestjs/swagger";
+
+export class RetrieveCacheRequestDto {
+ @ApiProperty({
+ description: "The request object to retrieve from cache",
+ type: Object,
+ additionalProperties: true,
+ example: { key1: "value1", key2: "value2" },
+ })
+ @IsObject()
+ request: Record;
+}
diff --git a/apps/server/src/app/clickhouse/clickhouse.module.ts b/apps/server/src/app/clickhouse/clickhouse.module.ts
new file mode 100644
index 000000000..3c75c2fee
--- /dev/null
+++ b/apps/server/src/app/clickhouse/clickhouse.module.ts
@@ -0,0 +1,10 @@
+import { Module } from "@nestjs/common";
+import { LoggerModule } from "../logger/logger.module";
+import { ClickHouseService } from "./clickhouse.service";
+
+@Module({
+ imports: [LoggerModule],
+ providers: [ClickHouseService],
+ exports: [ClickHouseService],
+})
+export class ClickhHouseModule {}
diff --git a/apps/server/src/app/clickhouse/clickhouse.service.ts b/apps/server/src/app/clickhouse/clickhouse.service.ts
new file mode 100644
index 000000000..cfe015db9
--- /dev/null
+++ b/apps/server/src/app/clickhouse/clickhouse.service.ts
@@ -0,0 +1,69 @@
+import { ConfigService } from "@nestjs/config";
+import { Injectable, OnModuleInit } from "@nestjs/common";
+import { createLogger } from "../logger/create-logger";
+import { pino } from "pino";
+import { createClient, ClickHouseClient } from "@clickhouse/client"; // or '@clickhouse/client-web'
+import { knex, Knex } from "knex";
+import clickhouesDialect from "@pezzo/knex-clickhouse-dialect";
+
+@Injectable()
+export class ClickHouseService implements OnModuleInit {
+ public client: ClickHouseClient;
+ public knex: Knex;
+ private logger: pino.Logger;
+ public requestsIndexAlias: string;
+
+ constructor(private config: ConfigService) {
+ this.logger = createLogger({
+ scope: "ClickHouseService",
+ });
+ }
+
+ async onModuleInit() {
+ const host = this.config.get("CLICKHOUSE_HOST");
+ const port = this.config.get("CLICKHOUSE_PORT");
+ const username = this.config.get("CLICKHOUSE_USER");
+ const password = this.config.get("CLICKHOUSE_PASSWORD");
+ const protocol = this.config.get("CLICKHOUSE_PROTOCOL");
+ const database = "default";
+
+ this.logger.info("Creating ClickHouse client");
+
+ this.client = createClient({
+ host: `${protocol}://${host}:${port}`,
+ username,
+ password,
+ database,
+ });
+
+ this.logger.info("Creating Knex instance");
+ const connectionStr =
+ `${protocol}://${username}:${password}@${host}:${port}/${database}` as any;
+
+ this.knex = knex({
+ client: clickhouesDialect as any,
+ connection: () => connectionStr,
+ });
+
+ await this.healthCheck();
+ }
+
+ async healthCheck() {
+ try {
+ await this.client.query({
+ query: "SELECT 1",
+ format: "JSONEachRow",
+ });
+ } catch (error) {
+ this.logger.error({ error }, "ClickHouse client healthcheck failed");
+ throw error;
+ }
+
+ try {
+ await this.knex.raw("SELECT 1");
+ } catch (error) {
+ this.logger.error({ error }, "Knex healthcheck failed");
+ throw error;
+ }
+ }
+}
diff --git a/apps/server/src/app/cls.middleware.ts b/apps/server/src/app/cls.middleware.ts
new file mode 100644
index 000000000..bf070c36b
--- /dev/null
+++ b/apps/server/src/app/cls.middleware.ts
@@ -0,0 +1,17 @@
+import { Injectable, NestMiddleware } from "@nestjs/common";
+import { Request, Response, NextFunction } from "express";
+import { namespace, initRequestWithContext } from "./cls.utils";
+
+@Injectable()
+export class ClsMiddleware implements NestMiddleware {
+ use(req: Request, res: Response, next: NextFunction) {
+ try {
+ namespace.bindEmitter(req);
+ namespace.bindEmitter(res);
+ const nextWithContext = initRequestWithContext(next);
+ nextWithContext();
+ } catch (err) {
+ res.status(500).send({});
+ }
+ }
+}
diff --git a/apps/server/src/app/cls.module.ts b/apps/server/src/app/cls.module.ts
new file mode 100644
index 000000000..f1d5868cf
--- /dev/null
+++ b/apps/server/src/app/cls.module.ts
@@ -0,0 +1,11 @@
+import { Module, MiddlewareConsumer, RequestMethod } from "@nestjs/common";
+import { ClsMiddleware } from "./cls.middleware";
+
+@Module({})
+export class ClsModule {
+ configure(consumer: MiddlewareConsumer) {
+ consumer
+ .apply(ClsMiddleware)
+ .forRoutes({ path: "*", method: RequestMethod.ALL });
+ }
+}
diff --git a/apps/server/src/app/cls.utils.ts b/apps/server/src/app/cls.utils.ts
new file mode 100644
index 000000000..765bbdafb
--- /dev/null
+++ b/apps/server/src/app/cls.utils.ts
@@ -0,0 +1,63 @@
+import cls, { createNamespace } from "cls-hooked";
+
+export const CLS_NAMESPACE = "_PEZZO_CONTEXT";
+export const CLS_CONTEXT_KEY = "_PEZZO_KEY";
+
+interface RequestContext {
+ traceId: string;
+ appName: string;
+ methodName?: string;
+ userId?: string;
+ organizationId?: string;
+ projectId?: string;
+ promptId?: string;
+}
+
+const createTraceId = (): string => new Date().getTime().toString();
+
+createNamespace(CLS_NAMESPACE);
+
+export const namespace = cls.getNamespace(CLS_NAMESPACE) as cls.Namespace;
+
+// eslint-disable-next-line @typescript-eslint/ban-types
+export const initRequestWithContext = (
+ func: Func,
+ additionalContext?: Partial
+): Func => {
+ const traceId = createTraceId();
+ const requestContext = { traceId, appName: "main", ...additionalContext };
+ return namespace.bind(func, { [CLS_CONTEXT_KEY]: requestContext });
+};
+
+export const getRequestContext = (): RequestContext => {
+ return namespace.get(CLS_CONTEXT_KEY) || {};
+};
+
+export const setRequestContext = (context: RequestContext): void => {
+ if (namespace.active) {
+ namespace.set(CLS_CONTEXT_KEY, context);
+ }
+};
+
+// eslint-disable-next-line @typescript-eslint/ban-types
+export const bindRequestContext = (
+ requestContext: Partial,
+ func: Func
+): Func => {
+ return namespace.bind(func, { [CLS_CONTEXT_KEY]: requestContext });
+};
+
+export const updateRequestContext = (
+ context: Partial
+): void => {
+ const currentContext = getRequestContext();
+ setRequestContext({ ...currentContext, ...context });
+};
+
+export const runWithRequestContext = (
+ requestContext: Partial,
+ func: () => Promise
+): Promise => {
+ updateRequestContext(requestContext);
+ return func();
+};
diff --git a/apps/server/src/app/common/filters/filter.input.ts b/apps/server/src/app/common/filters/filter.input.ts
new file mode 100644
index 000000000..780b573f2
--- /dev/null
+++ b/apps/server/src/app/common/filters/filter.input.ts
@@ -0,0 +1,56 @@
+import { Field, InputType, registerEnumType } from "@nestjs/graphql";
+import { RequestReportFilterFields } from "./shared";
+
+export enum FilterOperator {
+ eq = "eq",
+ neq = "neq",
+ in = "in",
+ nin = "nin",
+ like = "like",
+ gt = "gt",
+ gte = "gte",
+ lt = "lt",
+ lte = "lte",
+}
+registerEnumType(FilterOperator, {
+ name: "FilterOperator",
+});
+
+export const getSQLOperatorByFilterOperator = (
+ operator: FilterOperator
+): string => {
+ switch (operator) {
+ case FilterOperator.eq:
+ return "=";
+ case FilterOperator.neq:
+ return "!=";
+ case FilterOperator.in:
+ return "IN";
+ case FilterOperator.nin:
+ return "NOT IN";
+ case FilterOperator.like:
+ return "LIKE";
+ case FilterOperator.gt:
+ return ">";
+ case FilterOperator.gte:
+ return ">=";
+ case FilterOperator.lt:
+ return "<";
+ case FilterOperator.lte:
+ return "<=";
+ default:
+ throw new Error(`Unknown filter operator: ${operator}`);
+ }
+};
+
+@InputType()
+export class FilterInput {
+ @Field(() => String, { nullable: false })
+ field: RequestReportFilterFields;
+
+ @Field(() => FilterOperator, { nullable: false })
+ operator: FilterOperator;
+
+ @Field(() => String, { nullable: false })
+ value: string | string[];
+}
diff --git a/apps/server/src/app/common/filters/shared.ts b/apps/server/src/app/common/filters/shared.ts
new file mode 100644
index 000000000..9ef27e554
--- /dev/null
+++ b/apps/server/src/app/common/filters/shared.ts
@@ -0,0 +1,10 @@
+import { ReportSchema } from "@pezzo/types";
+
+export type FilterFields =
+ ReportSchema[TMainKey] extends Record
+ ? keyof ReportSchema[TMainKey] extends string
+ ? `${TMainKey}.${keyof ReportSchema[TMainKey]}`
+ : `${TMainKey}`
+ : `${TMainKey}`;
+
+export type RequestReportFilterFields = FilterFields;
diff --git a/apps/server/src/app/common/filters/sort.input.ts b/apps/server/src/app/common/filters/sort.input.ts
new file mode 100644
index 000000000..73525a887
--- /dev/null
+++ b/apps/server/src/app/common/filters/sort.input.ts
@@ -0,0 +1,20 @@
+import { Field, InputType, registerEnumType } from "@nestjs/graphql";
+import { RequestReportFilterFields } from "./shared";
+
+// Enum keys are read by GQL, which means they need to be lowercase
+export enum SortOrder {
+ asc = "asc",
+ desc = "desc",
+}
+registerEnumType(SortOrder, {
+ name: "SortOrder",
+});
+
+@InputType()
+export class SortInput {
+ @Field(() => String, { nullable: false })
+ field: RequestReportFilterFields;
+
+ @Field(() => SortOrder, { nullable: false })
+ order: SortOrder;
+}
diff --git a/apps/server/src/app/config/common-config-schema.ts b/apps/server/src/app/config/common-config-schema.ts
new file mode 100644
index 000000000..3f695f952
--- /dev/null
+++ b/apps/server/src/app/config/common-config-schema.ts
@@ -0,0 +1,41 @@
+import Joi from "joi";
+
+const commonConfigSchema = {
+ PORT: Joi.number().default(3000),
+ PEZZO_CLOUD: Joi.boolean().default(false),
+ PINO_PRETTIFY: Joi.boolean().default(false),
+ DATABASE_URL: Joi.string().required(),
+ SUPERTOKENS_CONNECTION_URI: Joi.string().required(),
+ SUPERTOKENS_API_KEY: Joi.string().optional(),
+ SUPERTOKENS_API_DOMAIN: Joi.string().default("http://localhost:3000"),
+ SUPERTOKENS_WEBSITE_DOMAIN: Joi.string().default("http://localhost:4200"),
+ CLICKHOUSE_HOST: Joi.string().default("localhost"),
+ CLICKHOUSE_PORT: Joi.string().default("8123"),
+ CLICKHOUSE_USER: Joi.string().default("default"),
+ CLICKHOUSE_PASSWORD: Joi.string().default("default"),
+ CLICKHOUSE_PROTOCOL: Joi.string().default("http"),
+ REDIS_URL: Joi.string().required(),
+ REDIS_TLS_ENABLED: Joi.boolean().default(false),
+ KMS_REGION: Joi.string().default("us-east-1"),
+ KMS_LOCAL: Joi.boolean().default(true),
+ KMS_LOCAL_ENDPOINT: Joi.string().default("http://localhost:9981"),
+ KMS_KEY_ARN: Joi.string().default(
+ "arn:aws:kms:us-east-1:111122223333:key/demo-master-key"
+ ),
+ WAITLIST_ENABLED: Joi.boolean().default(false),
+};
+
+const cloudConfigSchema = {
+ SENDGRID_API_KEY: Joi.string().required(),
+ SEGMENT_KEY: Joi.string().required(),
+ GOOGLE_OAUTH_CLIENT_ID: Joi.string().required(),
+ GOOGLE_OAUTH_CLIENT_SECRET: Joi.string().required(),
+};
+
+const isCloud = process.env.PEZZO_CLOUD === "true";
+
+export const getConfigSchema = () =>
+ Joi.object({
+ ...commonConfigSchema,
+ ...(isCloud ? cloudConfigSchema : {}),
+ });
diff --git a/apps/server/src/app/credentials/credentials.module.ts b/apps/server/src/app/credentials/credentials.module.ts
index 20cbbf3da..a95cd3ef8 100644
--- a/apps/server/src/app/credentials/credentials.module.ts
+++ b/apps/server/src/app/credentials/credentials.module.ts
@@ -1,10 +1,13 @@
import { Module } from "@nestjs/common";
-import { ProviderAPIKeysResolver } from "./provider-api-keys.resolver";
+import { ProviderApiKeysResolver } from "./provider-api-keys.resolver";
import { PrismaService } from "../prisma.service";
-import { ProviderAPIKeysService } from "./provider-api-keys.service";
+import { ProviderApiKeysService } from "./provider-api-keys.service";
+import { IdentityModule } from "../identity/identity.module";
+import { EncryptionModule } from "../encryption/encryption.module";
@Module({
- providers: [PrismaService, ProviderAPIKeysResolver, ProviderAPIKeysService],
- exports: [ProviderAPIKeysService],
+ imports: [IdentityModule, EncryptionModule],
+ providers: [PrismaService, ProviderApiKeysResolver, ProviderApiKeysService],
+ exports: [ProviderApiKeysService],
})
export class CredentialsModule {}
diff --git a/apps/server/src/app/credentials/inputs/create-provider-api-key.input.ts b/apps/server/src/app/credentials/inputs/create-provider-api-key.input.ts
index 24b8ed381..f59ab8540 100644
--- a/apps/server/src/app/credentials/inputs/create-provider-api-key.input.ts
+++ b/apps/server/src/app/credentials/inputs/create-provider-api-key.input.ts
@@ -1,10 +1,16 @@
import { Field, InputType } from "@nestjs/graphql";
+import { IsNotEmpty, IsString } from "class-validator";
@InputType()
-export class CreateProviderAPIKeyInput {
+export class CreateProviderApiKeyInput {
@Field(() => String, { nullable: false })
provider: string;
@Field(() => String, { nullable: false })
+ @IsString()
+ @IsNotEmpty()
value: string;
+
+ @Field(() => String, { nullable: false })
+ organizationId: string;
}
diff --git a/apps/server/src/app/prompts/inputs/get-deployed-prompt-version.input.ts b/apps/server/src/app/credentials/inputs/delete-provider-api-key.input.ts
similarity index 63%
rename from apps/server/src/app/prompts/inputs/get-deployed-prompt-version.input.ts
rename to apps/server/src/app/credentials/inputs/delete-provider-api-key.input.ts
index ce9024d90..5b851da3b 100644
--- a/apps/server/src/app/prompts/inputs/get-deployed-prompt-version.input.ts
+++ b/apps/server/src/app/credentials/inputs/delete-provider-api-key.input.ts
@@ -1,10 +1,10 @@
import { Field, InputType } from "@nestjs/graphql";
@InputType()
-export class GetDeployedPromptVersionInput {
+export class DeleteProviderApiKeyInput {
@Field(() => String, { nullable: false })
- environmentSlug: string;
+ provider: string;
@Field(() => String, { nullable: false })
- promptId: string;
+ organizationId: string;
}
diff --git a/apps/server/src/app/credentials/inputs/get-provider-api-keys.input.ts b/apps/server/src/app/credentials/inputs/get-provider-api-keys.input.ts
new file mode 100644
index 000000000..8057cac36
--- /dev/null
+++ b/apps/server/src/app/credentials/inputs/get-provider-api-keys.input.ts
@@ -0,0 +1,7 @@
+import { Field, InputType } from "@nestjs/graphql";
+
+@InputType()
+export class GetProviderApiKeysInput {
+ @Field(() => String, { nullable: false })
+ organizationId: string;
+}
diff --git a/apps/server/src/app/credentials/provider-api-keys.resolver.ts b/apps/server/src/app/credentials/provider-api-keys.resolver.ts
index 85d6f6565..280c4bdfb 100644
--- a/apps/server/src/app/credentials/provider-api-keys.resolver.ts
+++ b/apps/server/src/app/credentials/provider-api-keys.resolver.ts
@@ -1,40 +1,106 @@
import { Args, Mutation, Query, Resolver } from "@nestjs/graphql";
-import { ProviderAPIKey } from "../../@generated/provider-api-key/provider-api-key.model";
-import { CreateProviderAPIKeyInput } from "./inputs/create-provider-api-key.input";
-import { ProviderAPIKeysService } from "./provider-api-keys.service";
-import { ProviderAPIKeyWhereUniqueInput } from "../../@generated/provider-api-key/provider-api-key-where-unique.input";
-
-@Resolver(() => ProviderAPIKey)
-export class ProviderAPIKeysResolver {
- constructor(private providerAPIKeysService: ProviderAPIKeysService) {}
-
- @Query(() => [ProviderAPIKey])
- async providerAPIKeys() {
- const keys = await this.providerAPIKeysService.getAllProviderAPIKeys();
- return keys.map((key) => ({ ...key, value: this.censorAPIKey(key.value) }));
- }
+import { ProviderApiKey } from "../../@generated/provider-api-key/provider-api-key.model";
+import { CreateProviderApiKeyInput } from "./inputs/create-provider-api-key.input";
+import { DeleteProviderApiKeyInput } from "./inputs/delete-provider-api-key.input";
+import { ProviderApiKeysService } from "./provider-api-keys.service";
+import { CurrentUser } from "../identity/current-user.decorator";
+import { RequestUser } from "../identity/users.types";
+import {
+ InternalServerErrorException,
+ UseGuards,
+ NotFoundException,
+} from "@nestjs/common";
+import { AuthGuard } from "../auth/auth.guard";
+import { GetProviderApiKeysInput } from "./inputs/get-provider-api-keys.input";
+import {
+ isOrgMemberOrThrow,
+ isOrgAdminOrThrow,
+} from "../identity/identity.utils";
+import { PinoLogger } from "../logger/pino-logger";
+import { AnalyticsService } from "../analytics/analytics.service";
+import { ProviderApiKeyWhereUniqueInput } from "../../@generated/provider-api-key/provider-api-key-where-unique.input";
- @Mutation(() => ProviderAPIKey)
- async updateProviderAPIKey(@Args("data") data: CreateProviderAPIKeyInput) {
- const key = await this.providerAPIKeysService.upsertProviderAPIKey(
- data.provider,
- data.value
- );
+@UseGuards(AuthGuard)
+@Resolver(() => ProviderApiKey)
+export class ProviderApiKeysResolver {
+ constructor(
+ private providerAPIKeysService: ProviderApiKeysService,
+ private logger: PinoLogger,
+ private analytics: AnalyticsService
+ ) {}
- return {
- ...key,
- value: this.censorAPIKey(key.value),
- };
+ @Query(() => [ProviderApiKey])
+ async providerApiKeys(
+ @Args("data") data: GetProviderApiKeysInput,
+ @CurrentUser() user: RequestUser
+ ): Promise {
+ const { organizationId } = data;
+ isOrgMemberOrThrow(user, organizationId);
+ try {
+ this.logger.assign({ organizationId }).info("Getting provider API keys");
+ const keys = await this.providerAPIKeysService.getAllProviderApiKeys(
+ organizationId
+ );
+ return keys;
+ } catch (error) {
+ this.logger.error({ error }, "Error getting provider API keys");
+ }
}
- private censorAPIKey(value: string) {
- return value.substring(0, 3) + "..." + value.substring(value.length - 3);
+ @Mutation(() => ProviderApiKey)
+ async updateProviderApiKey(
+ @Args("data") data: CreateProviderApiKeyInput,
+ @CurrentUser() user: RequestUser
+ ): Promise {
+ const { provider, organizationId, value } = data;
+ isOrgMemberOrThrow(user, organizationId);
+
+ try {
+ this.logger
+ .assign({ provider, organizationId })
+ .info("Updating provider API key");
+ const key = await this.providerAPIKeysService.upsertProviderApiKey(
+ provider,
+ value,
+ organizationId
+ );
+
+ this.analytics.trackEvent("provider_api_key_created", {
+ organizationId,
+ provider,
+ });
+
+ return key;
+ } catch (error) {
+ this.logger.error({ error }, "Error updating provider API key");
+ throw new InternalServerErrorException();
+ }
}
- @Mutation(() => Boolean)
- async deleteProviderAPIKey(
- @Args("data") data: ProviderAPIKeyWhereUniqueInput
+ @Mutation(() => ProviderApiKey)
+ async deleteProviderApiKey(
+ @Args("data") data: DeleteProviderApiKeyInput,
+ @CurrentUser() user: RequestUser
) {
- return this.providerAPIKeysService.deleteProviderAPIKey(data.id);
+ const { provider, organizationId } = data;
+ // this.logger.assign({ apiKeyId: id });
+ this.logger.info("Deleting provider api key");
+
+ const providerApiKey = await this.providerAPIKeysService.getProviderApiKey(
+ provider,
+ organizationId
+ );
+
+ if (!providerApiKey) {
+ throw new NotFoundException(
+ `${providerApiKey.provider} api key not found`
+ );
+ }
+
+ isOrgAdminOrThrow(user, providerApiKey.organizationId);
+
+ await this.providerAPIKeysService.deleteProviderApiKey(providerApiKey.id);
+
+ return providerApiKey;
}
}
diff --git a/apps/server/src/app/credentials/provider-api-keys.service.ts b/apps/server/src/app/credentials/provider-api-keys.service.ts
index 04bdb1260..fb2af421e 100644
--- a/apps/server/src/app/credentials/provider-api-keys.service.ts
+++ b/apps/server/src/app/credentials/provider-api-keys.service.ts
@@ -1,63 +1,84 @@
import { Injectable } from "@nestjs/common";
import { PrismaService } from "../prisma.service";
+import { EncryptionService } from "../encryption/encryption.service";
+import { ProviderApiKey } from "@prisma/client";
@Injectable()
-export class ProviderAPIKeysService {
- constructor(private readonly prisma: PrismaService) {}
+export class ProviderApiKeysService {
+ constructor(
+ private readonly prisma: PrismaService,
+ private readonly encryptionService: EncryptionService
+ ) {}
- async getByProvider(provider: string) {
- const keys = await this.prisma.providerAPIKey.findFirst({
- where: { provider },
+ async getByProvider(provider: string, organizationId: string) {
+ const key = await this.prisma.providerApiKey.findFirst({
+ where: { provider, organizationId },
});
- return keys;
+ return key;
}
- async getAllProviderAPIKeys() {
- const keys = await this.prisma.providerAPIKey.findMany();
- return keys;
+ async decryptProviderApiKey(key: ProviderApiKey): Promise {
+ const decrypted = await this.encryptionService.decrypt(
+ key.encryptedData,
+ key.encryptedDataKey,
+ key.encryptionTag
+ );
+ return decrypted;
}
- async createProviderAPIKey(provider: string, value: string) {
- const key = await this.prisma.providerAPIKey.create({
- data: {
- provider,
- value,
- },
+ async getAllProviderApiKeys(organizationId: string) {
+ const keys = await this.prisma.providerApiKey.findMany({
+ where: { organizationId },
});
-
- return key;
+ return keys;
}
- async upsertProviderAPIKey(provider: string, value: string) {
- const exists = await this.prisma.providerAPIKey.findFirst({
- where: { provider },
+ async upsertProviderApiKey(
+ provider: string,
+ value: string,
+ organizationId: string
+ ) {
+ const exists = await this.prisma.providerApiKey.findFirst({
+ where: { provider, organizationId },
});
+ const { encryptedData, encryptedDataKey, encryptionTag } =
+ await this.encryptionService.encrypt(value);
+
+ const censoredValue = this.censorApiKey(value);
+
if (exists) {
- const key = await this.prisma.providerAPIKey.update({
+ const key = await this.prisma.providerApiKey.update({
where: {
id: exists.id,
},
data: {
- value,
+ encryptedData,
+ encryptedDataKey,
+ encryptionTag,
+ censoredValue,
},
});
return key;
}
- const key = await this.prisma.providerAPIKey.create({
+ const key = await this.prisma.providerApiKey.create({
data: {
provider,
- value,
+ encryptedData,
+ encryptedDataKey,
+ encryptionTag,
+ censoredValue,
+ organizationId,
},
});
return key;
}
- async deleteProviderAPIKey(id: string) {
- await this.prisma.providerAPIKey.delete({
+ async deleteProviderApiKey(id: string) {
+ await this.prisma.providerApiKey.delete({
where: {
id,
},
@@ -65,4 +86,19 @@ export class ProviderAPIKeysService {
return true;
}
+
+ private censorApiKey(value: string) {
+ return value.substring(value.length - 4);
+ }
+
+ async getProviderApiKey(provider: string, organizationId: string) {
+ const providerApiKey = await this.prisma.providerApiKey.findFirst({
+ where: { provider, organizationId },
+ include: {
+ organization: true,
+ },
+ });
+
+ return providerApiKey;
+ }
}
diff --git a/apps/server/src/app/encryption/encryption.module.ts b/apps/server/src/app/encryption/encryption.module.ts
new file mode 100644
index 000000000..010fbf878
--- /dev/null
+++ b/apps/server/src/app/encryption/encryption.module.ts
@@ -0,0 +1,10 @@
+import { Module } from "@nestjs/common";
+import { EncryptionService } from "./encryption.service";
+import { ConfigModule } from "@nestjs/config";
+
+@Module({
+ imports: [ConfigModule],
+ providers: [EncryptionService],
+ exports: [EncryptionService],
+})
+export class EncryptionModule {}
diff --git a/apps/server/src/app/encryption/encryption.service.ts b/apps/server/src/app/encryption/encryption.service.ts
new file mode 100644
index 000000000..2070279fc
--- /dev/null
+++ b/apps/server/src/app/encryption/encryption.service.ts
@@ -0,0 +1,87 @@
+import { Global, Injectable } from "@nestjs/common";
+import { KMS } from "@aws-sdk/client-kms";
+import { ConfigService } from "@nestjs/config";
+import crypto from "crypto";
+import { PinoLogger } from "../logger/pino-logger";
+
+@Global()
+@Injectable()
+export class EncryptionService {
+ private kms: KMS;
+
+ constructor(private config: ConfigService, private logger: PinoLogger) {
+ const isLocalKMS = this.config.get("KMS_LOCAL");
+ const region = this.config.get("KMS_REGION");
+
+ const endpoint = isLocalKMS
+ ? this.config.get("KMS_LOCAL_ENDPOINT")
+ : undefined;
+
+ const credentials = isLocalKMS
+ ? { accessKeyId: "", secretAccessKey: "" }
+ : undefined;
+
+ this.kms = new KMS({
+ region,
+ endpoint,
+ credentials,
+ });
+ }
+
+ private async generateDataKey(): Promise<{
+ plaintext: Uint8Array;
+ ciphertext: Uint8Array;
+ }> {
+ this.logger.info("Generating data key");
+
+ const result = await this.kms.generateDataKey({
+ KeyId: this.config.get("KMS_KEY_ARN"),
+ KeySpec: "AES_256",
+ });
+
+ return {
+ plaintext: result.Plaintext,
+ ciphertext: result.CiphertextBlob,
+ };
+ }
+
+ async encrypt(data: string): Promise<{
+ encryptedData: string;
+ encryptedDataKey: string;
+ encryptionTag: string;
+ }> {
+ this.logger.info("Encrypting data");
+
+ const dataKey = await this.generateDataKey();
+ const iv = crypto.randomBytes(12); // 12 bytes is recommended for GCM
+ const cipher = crypto.createCipheriv("aes-256-gcm", dataKey.plaintext, iv);
+ let encrypted = cipher.update(data, "utf8");
+ encrypted = Buffer.concat([encrypted, cipher.final()]);
+
+ return {
+ encryptedData: Buffer.concat([iv, encrypted]).toString("hex"),
+ encryptedDataKey: Buffer.from(dataKey.ciphertext).toString("base64"),
+ encryptionTag: cipher.getAuthTag().toString("hex"), // Store the tag for verification during decryption
+ };
+ }
+
+ async decrypt(
+ encryptedData: string,
+ dataKeyBase64: string,
+ tagHex: string
+ ): Promise {
+ this.logger.info("Decrypting data");
+
+ const encryptedDataBuffer = Buffer.from(encryptedData, "hex");
+ const { Plaintext: dataKey } = await this.kms.decrypt({
+ CiphertextBlob: Buffer.from(dataKeyBase64, "base64"),
+ });
+
+ const iv = encryptedDataBuffer.slice(0, 12);
+ const data = encryptedDataBuffer.slice(12);
+ const decipher = crypto.createDecipheriv("aes-256-gcm", dataKey, iv);
+ decipher.setAuthTag(Buffer.from(tagHex, "hex")); // Set the authentication tag for verification
+
+ return decipher.update(data) + decipher.final("utf8");
+ }
+}
diff --git a/apps/server/src/app/environments/environments.module.ts b/apps/server/src/app/environments/environments.module.ts
deleted file mode 100644
index e74d531bf..000000000
--- a/apps/server/src/app/environments/environments.module.ts
+++ /dev/null
@@ -1,8 +0,0 @@
-import { Module } from "@nestjs/common";
-import { PrismaService } from "../prisma.service";
-import { EnvironmentsResolver } from "./environments.resolver";
-
-@Module({
- providers: [PrismaService, EnvironmentsResolver],
-})
-export class EnvironmentsModule {}
diff --git a/apps/server/src/app/environments/environments.resolver.ts b/apps/server/src/app/environments/environments.resolver.ts
deleted file mode 100644
index d1991273c..000000000
--- a/apps/server/src/app/environments/environments.resolver.ts
+++ /dev/null
@@ -1,44 +0,0 @@
-import { Args, Mutation, Query, Resolver } from "@nestjs/graphql";
-import { Environment } from "../../@generated/environment/environment.model";
-import { PrismaService } from "../prisma.service";
-import { EnvironmentWhereUniqueInput } from "../../@generated/environment/environment-where-unique.input";
-import { EnvironmentCreateInput } from "../../@generated/environment/environment-create.input";
-import { ConflictException } from "@nestjs/common";
-
-@Resolver(() => Environment)
-export class EnvironmentsResolver {
- constructor(private prisma: PrismaService) {}
-
- @Query(() => [Environment])
- async environments() {
- const environments = await this.prisma.environment.findMany();
- return environments;
- }
-
- @Query(() => Environment)
- async environment(@Args("data") data: EnvironmentWhereUniqueInput) {
- const environment = await this.prisma.environment.findUnique({
- where: data,
- });
- return environment;
- }
-
- @Mutation(() => Environment)
- async createEnvironment(@Args("data") data: EnvironmentCreateInput) {
- const exists = await this.prisma.environment.findUnique({
- where: { slug: data.slug },
- });
-
- if (exists) {
- throw new ConflictException(
- `Environment with slug "${data.slug}" already exists`
- );
- }
-
- const environment = await this.prisma.environment.create({
- data,
- });
-
- return environment;
- }
-}
diff --git a/apps/server/src/app/health.controller.ts b/apps/server/src/app/health.controller.ts
index eb792a022..0a4b4aebb 100644
--- a/apps/server/src/app/health.controller.ts
+++ b/apps/server/src/app/health.controller.ts
@@ -1,9 +1,16 @@
import { Get, Controller } from "@nestjs/common";
+import { ApiOperation, ApiResponse, ApiTags } from "@nestjs/swagger";
import { version } from "@pezzo/common";
+@ApiTags("Health")
@Controller("healthz")
export class HealthController {
@Get()
+ @ApiOperation({ summary: "Performs a health check" })
+ @ApiResponse({
+ status: 200,
+ description: "Returns the health status and current version",
+ })
healthz() {
return {
status: "OK",
diff --git a/apps/server/src/app/identity/api-key-org-id.decoator.ts b/apps/server/src/app/identity/api-key-org-id.decoator.ts
new file mode 100644
index 000000000..1a0876d92
--- /dev/null
+++ b/apps/server/src/app/identity/api-key-org-id.decoator.ts
@@ -0,0 +1,10 @@
+import { createParamDecorator, ExecutionContext } from "@nestjs/common";
+import { GqlExecutionContext } from "@nestjs/graphql";
+
+export const ApiKeyOrgId = createParamDecorator(
+ (_: unknown, context: ExecutionContext): string => {
+ const gqlCtx = GqlExecutionContext.create(context);
+ const ctx = gqlCtx.getContext();
+ return ctx.req.organizationId;
+ }
+);
diff --git a/apps/server/src/app/identity/api-keys.resolver.ts b/apps/server/src/app/identity/api-keys.resolver.ts
new file mode 100644
index 000000000..93f1afeef
--- /dev/null
+++ b/apps/server/src/app/identity/api-keys.resolver.ts
@@ -0,0 +1,33 @@
+import { Args, Query, Resolver } from "@nestjs/graphql";
+import { ApiKey } from "../../@generated/api-key/api-key.model";
+import { ApiKeysService } from "./api-keys.service";
+import { UseGuards } from "@nestjs/common";
+import { AuthGuard } from "../auth/auth.guard";
+import { PinoLogger } from "../logger/pino-logger";
+import { GetApiKeysInput } from "./inputs/get-api-keys.input";
+import { isOrgMemberOrThrow } from "./identity.utils";
+import { CurrentUser } from "./current-user.decorator";
+import { RequestUser } from "./users.types";
+
+@UseGuards(AuthGuard)
+@Resolver(() => ApiKey)
+export class ApiKeysResolver {
+ constructor(
+ private apiKeysService: ApiKeysService,
+ private logger: PinoLogger
+ ) {}
+
+ @Query(() => [ApiKey])
+ async apiKeys(
+ @Args("data") data: GetApiKeysInput,
+ @CurrentUser() user: RequestUser
+ ) {
+ const { organizationId } = data;
+ isOrgMemberOrThrow(user, organizationId);
+
+ const apiKeys = await this.apiKeysService.getApiKeysByOrganizationId(
+ organizationId
+ );
+ return apiKeys;
+ }
+}
diff --git a/apps/server/src/app/identity/api-keys.service.ts b/apps/server/src/app/identity/api-keys.service.ts
new file mode 100644
index 000000000..86131b458
--- /dev/null
+++ b/apps/server/src/app/identity/api-keys.service.ts
@@ -0,0 +1,31 @@
+import { Injectable } from "@nestjs/common";
+import { PrismaService } from "../prisma.service";
+import { ApiKey } from "@prisma/client";
+
+@Injectable()
+export class ApiKeysService {
+ constructor(private readonly prisma: PrismaService) {}
+
+ async getApiKeysByOrganizationId(organizationId: string): Promise {
+ const apiKeys = await this.prisma.apiKey.findMany({
+ where: {
+ organizationId,
+ },
+ });
+
+ return apiKeys;
+ }
+
+ async getApiKey(value: string) {
+ const apiKey = await this.prisma.apiKey.findFirst({
+ where: {
+ id: value,
+ },
+ include: {
+ organization: true,
+ },
+ });
+
+ return apiKey;
+ }
+}
diff --git a/apps/server/src/app/identity/current-user.decorator.ts b/apps/server/src/app/identity/current-user.decorator.ts
new file mode 100644
index 000000000..6bbfb1a43
--- /dev/null
+++ b/apps/server/src/app/identity/current-user.decorator.ts
@@ -0,0 +1,11 @@
+import { createParamDecorator, ExecutionContext } from "@nestjs/common";
+import { GqlExecutionContext } from "@nestjs/graphql";
+import { RequestUser } from "./users.types";
+
+export const CurrentUser = createParamDecorator(
+ (_: unknown, context: ExecutionContext): RequestUser => {
+ const gqlCtx = GqlExecutionContext.create(context);
+ const ctx = gqlCtx.getContext();
+ return ctx.req.user;
+ }
+);
diff --git a/apps/server/src/app/identity/environments.resolver.ts b/apps/server/src/app/identity/environments.resolver.ts
new file mode 100644
index 000000000..a487de705
--- /dev/null
+++ b/apps/server/src/app/identity/environments.resolver.ts
@@ -0,0 +1,131 @@
+import { Args, Mutation, Query, Resolver } from "@nestjs/graphql";
+import { Environment } from "../../@generated/environment/environment.model";
+import {
+ ConflictException,
+ InternalServerErrorException,
+ NotFoundException,
+ UseGuards,
+} from "@nestjs/common";
+import { CreateEnvironmentInput } from "./inputs/create-environment.input";
+import { CurrentUser } from "../identity/current-user.decorator";
+import { RequestUser } from "../identity/users.types";
+import { AuthGuard } from "../auth/auth.guard";
+import { EnvironmentsService } from "./environments.service";
+import { GetEnvironmentsInput } from "./inputs/get-environments.input";
+import {
+ isOrgAdminOrThrow,
+ isOrgMemberOrThrow,
+} from "../identity/identity.utils";
+import { PinoLogger } from "../logger/pino-logger";
+import { AnalyticsService } from "../analytics/analytics.service";
+import { PrismaService } from "../prisma.service";
+import { EnvironmentWhereUniqueInput } from "../../@generated/environment/environment-where-unique.input";
+import { ProjectsService } from "./projects.service";
+
+@UseGuards(AuthGuard)
+@Resolver(() => Environment)
+export class EnvironmentsResolver {
+ constructor(
+ private environmentsService: EnvironmentsService,
+ private projectsService: ProjectsService,
+ private logger: PinoLogger,
+ private analytics: AnalyticsService,
+ private prisma: PrismaService
+ ) {}
+
+ @Query(() => [Environment])
+ async environments(
+ @Args("data") data: GetEnvironmentsInput,
+ @CurrentUser() user: RequestUser
+ ) {
+ const { projectId } = data;
+
+ const project = await this.prisma.project.findUnique({
+ where: {
+ id: projectId,
+ },
+ });
+
+ isOrgMemberOrThrow(user, project.organizationId);
+
+ try {
+ this.logger.assign({ projectId }).info("Getting environments");
+ return this.environmentsService.getAll(projectId);
+ } catch (error) {
+ this.logger.error({ error }, "Error getting environments");
+ throw new InternalServerErrorException();
+ }
+ }
+
+ @Mutation(() => Environment)
+ async createEnvironment(
+ @Args("data") data: CreateEnvironmentInput,
+ @CurrentUser() user: RequestUser
+ ) {
+ const { projectId, name } = data;
+ this.logger.assign({ projectId, name });
+
+ const project = await this.prisma.project.findUnique({
+ where: {
+ id: projectId,
+ },
+ });
+
+ isOrgMemberOrThrow(user, project.organizationId);
+
+ let exists: Environment;
+
+ try {
+ exists = await this.environmentsService.getByName(name, projectId);
+ } catch (error) {
+ this.logger.error({ error }, "Error checking for existing environment");
+ throw new InternalServerErrorException();
+ }
+
+ if (exists) {
+ throw new ConflictException(`Environment "${name}" already exists`);
+ }
+
+ try {
+ this.logger.info("Creating environment");
+ const environment = await this.environmentsService.createEnvironment(
+ name,
+ projectId
+ );
+ this.analytics.trackEvent("environment_created", {
+ projectId,
+ name,
+ environmentId: environment.id,
+ });
+ return environment;
+ } catch (error) {
+ this.logger.error({ error }, "Error creating environment");
+ throw new InternalServerErrorException();
+ }
+ }
+
+ @Mutation(() => Environment)
+ async deleteEnvironment(
+ @Args("data") data: EnvironmentWhereUniqueInput,
+ @CurrentUser() user: RequestUser
+ ) {
+ const { id } = data;
+ this.logger.assign({ environmentId: id });
+ this.logger.info("Deleting environment");
+
+ const environment = await this.environmentsService.getById(id);
+
+ if (!environment) {
+ throw new NotFoundException(`Environment with id "${id}" not found`);
+ }
+
+ const project = await this.projectsService.getProjectById(
+ environment.projectId
+ );
+
+ isOrgAdminOrThrow(user, project.organizationId);
+
+ await this.environmentsService.deleteEnvironment(id);
+ return environment;
+ }
+}
diff --git a/apps/server/src/app/identity/environments.service.ts b/apps/server/src/app/identity/environments.service.ts
new file mode 100644
index 000000000..4bfaf3606
--- /dev/null
+++ b/apps/server/src/app/identity/environments.service.ts
@@ -0,0 +1,51 @@
+import { Injectable } from "@nestjs/common";
+import { PrismaService } from "../prisma.service";
+
+@Injectable()
+export class EnvironmentsService {
+ constructor(private readonly prisma: PrismaService) {}
+
+ async createEnvironment(name: string, projectId: string) {
+ const environment = await this.prisma.environment.create({
+ data: {
+ name,
+ projectId,
+ },
+ });
+
+ return environment;
+ }
+
+ async getById(id: string) {
+ const environment = await this.prisma.environment.findUnique({
+ where: { id },
+ });
+
+ return environment;
+ }
+
+ async getAll(projectId: string) {
+ const environments = await this.prisma.environment.findMany({
+ where: {
+ projectId,
+ },
+ });
+
+ return environments;
+ }
+
+ async getByName(name: string, projectId: string) {
+ const environment = await this.prisma.environment.findFirst({
+ where: { name, projectId },
+ });
+ return environment;
+ }
+
+ async deleteEnvironment(id: string) {
+ const environment = await this.prisma.environment.delete({
+ where: { id },
+ });
+
+ return environment;
+ }
+}
diff --git a/apps/server/src/app/identity/identity.module.ts b/apps/server/src/app/identity/identity.module.ts
new file mode 100644
index 000000000..14e614152
--- /dev/null
+++ b/apps/server/src/app/identity/identity.module.ts
@@ -0,0 +1,43 @@
+import { Module } from "@nestjs/common";
+import { UsersService } from "./users.service";
+import { PrismaService } from "../prisma.service";
+import { OrganizationsService } from "./organizations.service";
+import { ProjectsResolver } from "./projects.resolver";
+import { ProjectsService } from "./projects.service";
+import { UsersResolver } from "./users.resolver";
+import { EnvironmentsResolver } from "./environments.resolver";
+import { EnvironmentsService } from "./environments.service";
+import { ApiKeysResolver } from "./api-keys.resolver";
+import { ApiKeysService } from "./api-keys.service";
+import { OrganizationsResolver } from "./organizations.resolver";
+import { OrgInvitationsResolver } from "./org-invitations.resolver";
+import { OrganizationMembersResolver } from "./org-members.resolver";
+import { InvitationsService } from "./invitations.service";
+
+@Module({
+ providers: [
+ OrganizationsService,
+ ProjectsResolver,
+ UsersResolver,
+ PrismaService,
+ UsersService,
+ ProjectsService,
+ InvitationsService,
+ EnvironmentsResolver,
+ EnvironmentsService,
+ ApiKeysService,
+ ApiKeysResolver,
+ OrganizationsResolver,
+ OrgInvitationsResolver,
+ OrganizationMembersResolver,
+ ],
+ exports: [
+ UsersService,
+ OrganizationsService,
+ ProjectsService,
+ ApiKeysService,
+ ApiKeysResolver,
+ EnvironmentsService,
+ ],
+})
+export class IdentityModule {}
diff --git a/apps/server/src/app/identity/identity.utils.ts b/apps/server/src/app/identity/identity.utils.ts
new file mode 100644
index 000000000..d3b81e2bb
--- /dev/null
+++ b/apps/server/src/app/identity/identity.utils.ts
@@ -0,0 +1,30 @@
+import { ForbiddenException } from "@nestjs/common";
+import { RequestUser } from "./users.types";
+import { OrgRole } from "@prisma/client";
+
+export function isOrgMember(user: RequestUser, organizationId: string) {
+ return !!user.orgMemberships.find((m) => m.organizationId === organizationId);
+}
+
+export function isOrgMemberOrThrow(user: RequestUser, organizationId: string) {
+ if (!user.orgMemberships.find((m) => m.organizationId === organizationId)) {
+ throw new ForbiddenException();
+ }
+}
+
+export function isOrgAdmin(user: RequestUser, organizationId: string) {
+ const membership = user.orgMemberships.find(
+ (m) => m.organizationId === organizationId
+ );
+ return !!membership && membership.role === OrgRole.Admin;
+}
+
+export function isOrgAdminOrThrow(user: RequestUser, organizationId: string) {
+ const membership = user.orgMemberships.find(
+ (m) => m.organizationId === organizationId
+ );
+
+ if (!membership || membership.role !== OrgRole.Admin) {
+ throw new ForbiddenException();
+ }
+}
diff --git a/apps/server/src/app/identity/inputs/create-environment.input.ts b/apps/server/src/app/identity/inputs/create-environment.input.ts
new file mode 100644
index 000000000..3d943edff
--- /dev/null
+++ b/apps/server/src/app/identity/inputs/create-environment.input.ts
@@ -0,0 +1,10 @@
+import { Field, InputType } from "@nestjs/graphql";
+
+@InputType()
+export class CreateEnvironmentInput {
+ @Field(() => String, { nullable: false })
+ name: string;
+
+ @Field(() => String, { nullable: false })
+ projectId: string;
+}
diff --git a/apps/server/src/app/identity/inputs/create-org-invitation.input.ts b/apps/server/src/app/identity/inputs/create-org-invitation.input.ts
new file mode 100644
index 000000000..1dd21de83
--- /dev/null
+++ b/apps/server/src/app/identity/inputs/create-org-invitation.input.ts
@@ -0,0 +1,12 @@
+import { Field, InputType } from "@nestjs/graphql";
+import { IsEmail } from "class-validator";
+
+@InputType()
+export class CreateOrgInvitationInput {
+ @Field(() => String, { nullable: false })
+ organizationId: string;
+
+ @Field(() => String, { nullable: false })
+ @IsEmail()
+ email: string;
+}
diff --git a/apps/server/src/app/identity/inputs/create-organization.input.ts b/apps/server/src/app/identity/inputs/create-organization.input.ts
new file mode 100644
index 000000000..4deff07f9
--- /dev/null
+++ b/apps/server/src/app/identity/inputs/create-organization.input.ts
@@ -0,0 +1,7 @@
+import { Field, InputType } from "@nestjs/graphql";
+
+@InputType()
+export class CreateOrganizationInput {
+ @Field(() => String, { nullable: false })
+ name: string;
+}
diff --git a/apps/server/src/app/identity/inputs/create-project.input.ts b/apps/server/src/app/identity/inputs/create-project.input.ts
new file mode 100644
index 000000000..481601f47
--- /dev/null
+++ b/apps/server/src/app/identity/inputs/create-project.input.ts
@@ -0,0 +1,10 @@
+import { Field, InputType } from "@nestjs/graphql";
+
+@InputType()
+export class CreateProjectInput {
+ @Field(() => String, { nullable: false })
+ name: string;
+
+ @Field(() => String, { nullable: false })
+ organizationId: string;
+}
diff --git a/apps/server/src/app/identity/inputs/get-api-keys.input.ts b/apps/server/src/app/identity/inputs/get-api-keys.input.ts
new file mode 100644
index 000000000..3afc1b357
--- /dev/null
+++ b/apps/server/src/app/identity/inputs/get-api-keys.input.ts
@@ -0,0 +1,7 @@
+import { Field, InputType } from "@nestjs/graphql";
+
+@InputType()
+export class GetApiKeysInput {
+ @Field(() => String, { nullable: false })
+ organizationId: string;
+}
diff --git a/apps/server/src/app/identity/inputs/get-environments.input.ts b/apps/server/src/app/identity/inputs/get-environments.input.ts
new file mode 100644
index 000000000..2ef41fda4
--- /dev/null
+++ b/apps/server/src/app/identity/inputs/get-environments.input.ts
@@ -0,0 +1,7 @@
+import { Field, InputType } from "@nestjs/graphql";
+
+@InputType()
+export class GetEnvironmentsInput {
+ @Field(() => String, { nullable: false })
+ projectId: string;
+}
diff --git a/apps/server/src/app/identity/inputs/get-org-invitations.input.ts b/apps/server/src/app/identity/inputs/get-org-invitations.input.ts
new file mode 100644
index 000000000..c4e79e5e5
--- /dev/null
+++ b/apps/server/src/app/identity/inputs/get-org-invitations.input.ts
@@ -0,0 +1,7 @@
+import { Field, InputType } from "@nestjs/graphql";
+
+@InputType()
+export class GetOrgInvitationsInput {
+ @Field(() => String, { nullable: false })
+ organizationId: string;
+}
diff --git a/apps/server/src/app/identity/inputs/get-projects.input.ts b/apps/server/src/app/identity/inputs/get-projects.input.ts
new file mode 100644
index 000000000..6a2c180a0
--- /dev/null
+++ b/apps/server/src/app/identity/inputs/get-projects.input.ts
@@ -0,0 +1,7 @@
+import { Field, InputType } from "@nestjs/graphql";
+
+@InputType()
+export class GetProjectsInput {
+ @Field(() => String, { nullable: false })
+ organizationId: string;
+}
diff --git a/apps/server/src/app/identity/inputs/get-user-org-membership.input.ts b/apps/server/src/app/identity/inputs/get-user-org-membership.input.ts
new file mode 100644
index 000000000..9ca585b83
--- /dev/null
+++ b/apps/server/src/app/identity/inputs/get-user-org-membership.input.ts
@@ -0,0 +1,10 @@
+import { Field, InputType } from "@nestjs/graphql";
+
+@InputType()
+export class GetUserOrgMembershipInput {
+ @Field(() => String, { nullable: false })
+ organizationId: string;
+
+ @Field(() => String, { nullable: false })
+ userId: string;
+}
diff --git a/apps/server/src/app/identity/inputs/organization-include.input.ts b/apps/server/src/app/identity/inputs/organization-include.input.ts
new file mode 100644
index 000000000..961372d07
--- /dev/null
+++ b/apps/server/src/app/identity/inputs/organization-include.input.ts
@@ -0,0 +1,15 @@
+import { Field, InputType } from "@nestjs/graphql";
+import { IsBoolean, IsOptional } from "class-validator";
+
+@InputType()
+export class OrganizationIncludeInput {
+ @Field(() => Boolean, { nullable: true })
+ @IsBoolean()
+ @IsOptional()
+ members = false;
+
+ @Field(() => Boolean, { nullable: true })
+ @IsBoolean()
+ @IsOptional()
+ invitations = false;
+}
diff --git a/apps/server/src/app/identity/inputs/update-org-invitation.input.ts b/apps/server/src/app/identity/inputs/update-org-invitation.input.ts
new file mode 100644
index 000000000..e303802d2
--- /dev/null
+++ b/apps/server/src/app/identity/inputs/update-org-invitation.input.ts
@@ -0,0 +1,11 @@
+import { Field, InputType } from "@nestjs/graphql";
+import { OrgRole } from "../../../@generated/prisma/org-role.enum";
+
+@InputType()
+export class UpdateOrgInvitationInput {
+ @Field(() => String, { nullable: false })
+ invitationId: string;
+
+ @Field(() => OrgRole, { nullable: false })
+ role: OrgRole;
+}
diff --git a/apps/server/src/app/identity/inputs/update-org-member-role.input.ts b/apps/server/src/app/identity/inputs/update-org-member-role.input.ts
new file mode 100644
index 000000000..804ff7e0d
--- /dev/null
+++ b/apps/server/src/app/identity/inputs/update-org-member-role.input.ts
@@ -0,0 +1,11 @@
+import { Field, InputType } from "@nestjs/graphql";
+import { OrgRole } from "../../../@generated/prisma/org-role.enum";
+
+@InputType()
+export class UpdateOrgMemberRoleInput {
+ @Field(() => String, { nullable: false })
+ id: string;
+
+ @Field(() => OrgRole, { nullable: false })
+ role: OrgRole;
+}
diff --git a/apps/server/src/app/identity/inputs/update-org-settings.input.ts b/apps/server/src/app/identity/inputs/update-org-settings.input.ts
new file mode 100644
index 000000000..2736ef430
--- /dev/null
+++ b/apps/server/src/app/identity/inputs/update-org-settings.input.ts
@@ -0,0 +1,12 @@
+import { Field, InputType } from "@nestjs/graphql";
+import { IsString } from "class-validator";
+
+@InputType()
+export class UpdateOrgSettingsInput {
+ @Field(() => String, { nullable: false })
+ @IsString()
+ organizationId: string;
+
+ @Field(() => String, { nullable: false })
+ name: string;
+}
diff --git a/apps/server/src/app/identity/inputs/update-profile.input.ts b/apps/server/src/app/identity/inputs/update-profile.input.ts
new file mode 100644
index 000000000..322f400c7
--- /dev/null
+++ b/apps/server/src/app/identity/inputs/update-profile.input.ts
@@ -0,0 +1,7 @@
+import { Field, InputType } from "@nestjs/graphql";
+
+@InputType()
+export class UpdateProfileInput {
+ @Field(() => String, { nullable: false })
+ name: string;
+}
diff --git a/apps/server/src/app/identity/inputs/update-project-settings.input.ts b/apps/server/src/app/identity/inputs/update-project-settings.input.ts
new file mode 100644
index 000000000..6594c5984
--- /dev/null
+++ b/apps/server/src/app/identity/inputs/update-project-settings.input.ts
@@ -0,0 +1,12 @@
+import { Field, InputType } from "@nestjs/graphql";
+import { IsString } from "class-validator";
+
+@InputType()
+export class UpdateProjectSettingsInput {
+ @Field(() => String, { nullable: false })
+ @IsString()
+ projectId: string;
+
+ @Field(() => String, { nullable: false })
+ name: string;
+}
diff --git a/apps/server/src/app/identity/invitations.resolver.ts b/apps/server/src/app/identity/invitations.resolver.ts
new file mode 100644
index 000000000..34ed70ea6
--- /dev/null
+++ b/apps/server/src/app/identity/invitations.resolver.ts
@@ -0,0 +1,39 @@
+import { Args, Mutation, Resolver } from "@nestjs/graphql";
+import { Invitation } from "../../@generated/invitation/invitation.model";
+import { PrismaService } from "../prisma.service";
+import { InvitationWhereUniqueInput } from "../../@generated/invitation/invitation-where-unique.input";
+import { CurrentUser } from "./current-user.decorator";
+import { RequestUser } from "./users.types";
+import { UseGuards } from "@nestjs/common";
+import { AuthGuard } from "../auth/auth.guard";
+import { isOrgAdminOrThrow } from "./identity.utils";
+
+@UseGuards(AuthGuard)
+@Resolver(() => Invitation)
+export class InvitationsResolver {
+ constructor(private prisma: PrismaService) {}
+
+ @Mutation(() => Invitation)
+ async deleteInvitation(
+ @Args("data") data: InvitationWhereUniqueInput,
+ @CurrentUser() user: RequestUser
+ ) {
+ const invitation = await this.prisma.invitation.findUnique({
+ where: {
+ id: data.id,
+ },
+ });
+
+ if (!invitation) {
+ throw new Error("Invitation not found");
+ }
+
+ isOrgAdminOrThrow(user, invitation.organizationId);
+
+ return this.prisma.invitation.delete({
+ where: {
+ id: data.id,
+ },
+ });
+ }
+}
diff --git a/apps/server/src/app/identity/invitations.service.ts b/apps/server/src/app/identity/invitations.service.ts
new file mode 100644
index 000000000..2e7205356
--- /dev/null
+++ b/apps/server/src/app/identity/invitations.service.ts
@@ -0,0 +1,54 @@
+import { Injectable } from "@nestjs/common";
+import { PrismaService } from "../prisma.service";
+import { InvitationStatus, OrgRole } from "@prisma/client";
+
+@Injectable()
+export class InvitationsService {
+ constructor(private readonly prisma: PrismaService) {}
+
+ async createInvitation(
+ email: string,
+ organizationId: string,
+ inviterId: string
+ ) {
+ return await this.prisma.invitation.create({
+ data: {
+ email,
+ organizationId,
+ role: OrgRole.Member,
+ status: InvitationStatus.Pending,
+ invitedById: inviterId,
+ },
+ });
+ }
+
+ async getInvitationByEmail(email: string, organizationId: string) {
+ return await this.prisma.invitation.findFirst({
+ where: {
+ organizationId,
+ email,
+ },
+ });
+ }
+
+ async getInvitationById(id: string) {
+ return await this.prisma.invitation.findUnique({ where: { id } });
+ }
+
+ async getAllByOrgId(organizationId: string) {
+ return await this.prisma.invitation.findMany({
+ where: { organizationId },
+ });
+ }
+
+ async upsertRoleById(id: string, role: OrgRole) {
+ return await this.prisma.invitation.update({
+ where: { id },
+ data: { role },
+ });
+ }
+
+ async deleteInvitationById(id: string) {
+ return await this.prisma.invitation.delete({ where: { id } });
+ }
+}
diff --git a/apps/server/src/app/identity/models/extended-user.model.ts b/apps/server/src/app/identity/models/extended-user.model.ts
new file mode 100644
index 000000000..176effc9f
--- /dev/null
+++ b/apps/server/src/app/identity/models/extended-user.model.ts
@@ -0,0 +1,14 @@
+import { Field, ObjectType } from "@nestjs/graphql";
+import { User } from "../../../@generated/user/user.model";
+
+@ObjectType()
+export class ExtendedUser extends User {
+ @Field(() => String, { nullable: true })
+ name: string;
+
+ @Field(() => String, { nullable: true })
+ photoUrl: string;
+
+ @Field(() => [String])
+ organizationIds: string[];
+}
diff --git a/apps/server/src/app/identity/org-invitations.resolver.ts b/apps/server/src/app/identity/org-invitations.resolver.ts
new file mode 100644
index 000000000..5ec03f548
--- /dev/null
+++ b/apps/server/src/app/identity/org-invitations.resolver.ts
@@ -0,0 +1,322 @@
+import {
+ Args,
+ Mutation,
+ Parent,
+ Query,
+ ResolveField,
+ Resolver,
+} from "@nestjs/graphql";
+import { Invitation } from "../../@generated/invitation/invitation.model";
+import { GetOrgInvitationsInput } from "./inputs/get-org-invitations.input";
+import { CurrentUser } from "./current-user.decorator";
+import { RequestUser } from "./users.types";
+import { isOrgAdminOrThrow, isOrgMember } from "./identity.utils";
+import {
+ ConflictException,
+ InternalServerErrorException,
+ NotFoundException,
+ UseGuards,
+} from "@nestjs/common";
+import { AuthGuard } from "../auth/auth.guard";
+import { CreateOrgInvitationInput } from "./inputs/create-org-invitation.input";
+import { UsersService } from "./users.service";
+import { ExtendedUser } from "./models/extended-user.model";
+import { InvitationWhereUniqueInput } from "../../@generated/invitation/invitation-where-unique.input";
+import { Organization } from "../../@generated/organization/organization.model";
+import { UpdateOrgInvitationInput } from "./inputs/update-org-invitation.input";
+import { ConfigService } from "@nestjs/config";
+import { PinoLogger } from "../logger/pino-logger";
+import { OrganizationsService } from "./organizations.service";
+import { InvitationsService } from "./invitations.service";
+import { EventEmitter2 } from "@nestjs/event-emitter";
+import { KafkaSchemas } from "@pezzo/kafka";
+
+@UseGuards(AuthGuard)
+@Resolver(() => Invitation)
+export class OrgInvitationsResolver {
+ constructor(
+ private eventEmitter: EventEmitter2,
+ private usersService: UsersService,
+ private organizationService: OrganizationsService,
+ private invitationsService: InvitationsService,
+ private logger: PinoLogger,
+ private config: ConfigService
+ ) {}
+
+ @Mutation(() => Invitation)
+ async createOrgInvitation(
+ @Args("data") data: CreateOrgInvitationInput,
+ @CurrentUser() user: RequestUser
+ ): Promise {
+ this.logger.assign({ userId: user.id }).info("Creating org invitation");
+
+ const { organizationId, email } = data;
+ isOrgAdminOrThrow(user, organizationId);
+
+ let organization: Organization;
+ try {
+ this.logger.info("Getting org");
+ organization = await this.organizationService.getById(organizationId);
+ } catch (error) {
+ this.logger.error({ error }, "Failed to get organization");
+ throw new InternalServerErrorException();
+ }
+
+ if (!organization) {
+ throw new NotFoundException("Organization not found");
+ }
+
+ const isMemberAlready = organization.members?.some(
+ (member) => member.user?.email === email
+ );
+
+ if (isMemberAlready) {
+ throw new ConflictException(
+ "User is already a member of this organization"
+ );
+ }
+
+ let exists: boolean;
+ try {
+ this.logger.info("Checking if invitation exists");
+ exists = !!(await this.invitationsService.getInvitationByEmail(
+ email,
+ organizationId
+ ));
+ } catch (error) {
+ this.logger.error({ error }, "Failed to get invitation");
+ throw new InternalServerErrorException();
+ }
+
+ if (exists) {
+ throw new ConflictException("Invitation to this email already exists");
+ }
+
+ let invitation: Invitation;
+ try {
+ this.logger.info("Creating invitation");
+ invitation = await this.invitationsService.createInvitation(
+ email,
+ organizationId,
+ user.id
+ );
+ } catch (error) {
+ this.logger.error({ error }, "Error getting invitation");
+ throw new InternalServerErrorException();
+ }
+
+ const consoleHost = this.config.get("CONSOLE_HOST");
+ const invitationUrl = new URL(consoleHost);
+ invitationUrl.pathname = `/invitations/${invitation.id}/accept`;
+
+ const topic = "org-invitation-created";
+
+ this.logger
+ .assign({ topic })
+ .info("Sending kafka invitation created event");
+
+ const payload: KafkaSchemas["org-invitation-created"] = {
+ key: invitation.id,
+ invitationUrl: invitationUrl.toString(),
+ invitationId: invitation.id,
+ organizationId,
+ organizationName: organization.name,
+ email,
+ role: invitation.role,
+ };
+
+ this.eventEmitter.emit("org-invitation-created", payload);
+
+ return invitation;
+ }
+
+ @Mutation(() => Invitation)
+ async updateOrgInvitation(
+ @Args("data") data: UpdateOrgInvitationInput,
+ @CurrentUser() user: RequestUser
+ ): Promise {
+ const { invitationId, role } = data;
+
+ this.logger
+ .assign({ userId: user.id, invitationId: data.invitationId })
+ .info("Updating org invitation");
+ let invitation: Invitation;
+ try {
+ invitation = await this.invitationsService.getInvitationById(
+ invitationId
+ );
+ } catch (error) {
+ this.logger.error({ error }, "Error getting invitation");
+ throw new InternalServerErrorException();
+ }
+ if (!invitation) {
+ throw new NotFoundException("Invitation not found");
+ }
+
+ isOrgAdminOrThrow(user, invitation.organizationId);
+
+ let updatedInvitation: Invitation;
+ try {
+ updatedInvitation = await this.invitationsService.upsertRoleById(
+ invitationId,
+ role
+ );
+ } catch (error) {
+ this.logger.error({ error }, "Error updating invitation");
+ throw new InternalServerErrorException();
+ }
+
+ return updatedInvitation;
+ }
+
+ @Query(() => [Invitation])
+ async orgInvitations(
+ @Args("data") data: GetOrgInvitationsInput,
+ @CurrentUser() user: RequestUser
+ ): Promise {
+ const { organizationId } = data;
+ isOrgAdminOrThrow(user, organizationId);
+
+ let organization: Organization;
+ try {
+ this.logger
+ .assign({ userId: user.id, organizationId: organization.id })
+ .info("Getting org");
+ organization = await this.organizationService.getById(organizationId);
+ } catch (error) {
+ this.logger.error({ error }, "Error getting org");
+ throw new InternalServerErrorException();
+ }
+
+ if (!organization) {
+ throw new NotFoundException("Organization not found");
+ }
+
+ let invitations: Invitation[];
+ try {
+ this.logger.info("Getting invitations for organization");
+ invitations = await this.invitationsService.getAllByOrgId(organizationId);
+ } catch (error) {
+ this.logger.error({ error }, "Error getting invitations");
+ throw new InternalServerErrorException();
+ }
+
+ return invitations;
+ }
+
+ @Mutation(() => Invitation)
+ async deleteOrgInvitation(
+ @Args("data") data: InvitationWhereUniqueInput,
+ @CurrentUser() user: RequestUser
+ ): Promise {
+ const { id } = data;
+
+ this.logger.assign({ userId: user.id, invitationId: id });
+
+ let invitation: Invitation;
+ try {
+ this.logger.info("Getting invitation");
+ invitation = await this.invitationsService.getInvitationById(id);
+ } catch (error) {
+ this.logger.error({ error }, "Error getting invitation");
+ throw new InternalServerErrorException();
+ }
+
+ if (!invitation) {
+ throw new NotFoundException("Invitation not found");
+ }
+
+ isOrgAdminOrThrow(user, invitation.organizationId);
+
+ try {
+ this.logger.info("Deleting invitation");
+ await this.invitationsService.deleteInvitationById(id);
+ } catch (error) {
+ this.logger.error({ error }, "Error deleting invitation");
+ throw new InternalServerErrorException();
+ }
+
+ return invitation;
+ }
+
+ @Mutation(() => Organization)
+ async acceptOrgInvitation(
+ @Args("data") data: InvitationWhereUniqueInput,
+ @CurrentUser() user: RequestUser
+ ): Promise {
+ const { id } = data;
+
+ let invitation: Invitation;
+ try {
+ invitation = await this.invitationsService.getInvitationById(id);
+ } catch (error) {
+ this.logger.error({ error }, "Error getting invitation");
+ throw new InternalServerErrorException();
+ }
+
+ if (!invitation) {
+ throw new NotFoundException("Invitation not found");
+ }
+
+ const isMemberAlready = isOrgMember(user, invitation.organizationId);
+
+ if (isMemberAlready) {
+ throw new ConflictException(
+ "You are already a member of this organization"
+ );
+ }
+
+ let organization: Organization;
+ try {
+ organization = await this.organizationService.getById(
+ invitation.organizationId
+ );
+ } catch (error) {
+ this.logger.error({ error }, "Error getting organization");
+ throw new InternalServerErrorException();
+ }
+
+ if (!organization) {
+ throw new NotFoundException("Organization not found");
+ }
+
+ try {
+ await this.organizationService.addMember(
+ invitation.organizationId,
+ user.id,
+ invitation.role
+ );
+ } catch (error) {
+ this.logger.error({ error }, "Error adding member to organization");
+ throw new InternalServerErrorException();
+ }
+
+ try {
+ await this.invitationsService.deleteInvitationById(id);
+ } catch (error) {
+ this.logger.error({ error }, "Error deleting invitation");
+ throw new InternalServerErrorException();
+ }
+
+ return organization;
+ }
+
+ @ResolveField(() => ExtendedUser)
+ async invitedBy(@Parent() invitation: Invitation): Promise {
+ try {
+ this.logger
+ .assign({ invitedById: invitation.invitedById })
+ .info("Getting invited by user");
+ const user = await this.usersService.getById(invitation.invitedById);
+
+ if (!user) {
+ throw new NotFoundException("User not found");
+ }
+
+ return this.usersService.serializeExtendedUser(user);
+ } catch (error) {
+ this.logger.error({ error }, "Error getting invited by user");
+ throw new InternalServerErrorException();
+ }
+ }
+}
diff --git a/apps/server/src/app/identity/org-members.resolver.ts b/apps/server/src/app/identity/org-members.resolver.ts
new file mode 100644
index 000000000..29a824c37
--- /dev/null
+++ b/apps/server/src/app/identity/org-members.resolver.ts
@@ -0,0 +1,128 @@
+import { Args, Mutation, Query, Resolver } from "@nestjs/graphql";
+import { OrganizationMember } from "../../@generated/organization-member/organization-member.model";
+import { OrganizationMemberWhereUniqueInput } from "../../@generated/organization-member/organization-member-where-unique.input";
+import { CurrentUser } from "./current-user.decorator";
+import { RequestUser } from "./users.types";
+import { isOrgAdminOrThrow, isOrgMemberOrThrow } from "./identity.utils";
+import {
+ ForbiddenException,
+ NotFoundException,
+ UseGuards,
+} from "@nestjs/common";
+import { AuthGuard } from "../auth/auth.guard";
+import { GetUserOrgMembershipInput } from "./inputs/get-user-org-membership.input";
+import { UpdateOrgMemberRoleInput } from "./inputs/update-org-member-role.input";
+import { OrganizationsService } from "./organizations.service";
+import { PinoLogger } from "../logger/pino-logger";
+
+@UseGuards(AuthGuard)
+@Resolver(OrganizationMember)
+export class OrganizationMembersResolver {
+ constructor(
+ private logger: PinoLogger,
+ private readonly organizationsService: OrganizationsService
+ ) {}
+
+ @Query(() => OrganizationMember)
+ async userOrgMembership(
+ @Args("data") data: GetUserOrgMembershipInput,
+ @CurrentUser() user: RequestUser
+ ) {
+ this.logger.assign({ userId: user.id });
+ let member: OrganizationMember;
+
+ try {
+ this.logger.info({ data }, "Getting user org membership");
+ member = await this.organizationsService.getOrgMemberByOrgId(
+ data.organizationId,
+ user.id
+ );
+ } catch (error) {
+ this.logger.error({ error }, "Failed to get user org membership");
+ }
+
+ if (!member) {
+ throw new NotFoundException("Member not found");
+ }
+
+ isOrgMemberOrThrow(user, member.organizationId);
+ return member;
+ }
+
+ @Mutation(() => OrganizationMember)
+ async deleteOrgMember(
+ @Args("data") data: OrganizationMemberWhereUniqueInput,
+ @CurrentUser() user: RequestUser
+ ) {
+ this.logger.assign({ userId: user.id, orgMemberId: data.id });
+ let member: OrganizationMember;
+
+ try {
+ this.logger.info("Getting user org membership");
+ member = await this.organizationsService.getOrganizationMemberById(
+ data.id
+ );
+ } catch (error) {
+ this.logger.error({ error }, "Failed to get user org membership");
+ }
+
+ if (!member) {
+ throw new NotFoundException("Member not found");
+ }
+
+ isOrgAdminOrThrow(user, member.organizationId);
+
+ if (member.userId === user.id) {
+ throw new ForbiddenException(
+ "You cannot remove yourself from the organization"
+ );
+ }
+
+ try {
+ this.logger.info("Deleting org member");
+ return await this.organizationsService.deleteOrgMember(data.id);
+ } catch (error) {
+ this.logger.error({ error }, "Failed to delete org member");
+ }
+ }
+
+ @Mutation(() => OrganizationMember)
+ async updateOrgMemberRole(
+ @Args("data") data: UpdateOrgMemberRoleInput,
+ @CurrentUser() user: RequestUser
+ ) {
+ this.logger.assign({
+ userId: user.id,
+ orgMemberId: data.id,
+ });
+
+ let member: OrganizationMember;
+
+ try {
+ member = await this.organizationsService.getOrganizationMemberById(
+ data.id
+ );
+ } catch (error) {
+ this.logger.error({ error }, "Failed to get org member");
+ }
+
+ if (!member) {
+ throw new NotFoundException("Member not found");
+ }
+
+ isOrgAdminOrThrow(user, member.organizationId);
+
+ let updatedMember: OrganizationMember;
+ try {
+ this.logger.info("Updating org member role");
+ updatedMember = await this.organizationsService.updateMember(
+ data.id,
+ data.role
+ );
+ } catch (error) {
+ this.logger.error({ error }, "Failed to update org member role");
+ }
+
+ return updatedMember;
+ }
+}
diff --git a/apps/server/src/app/identity/organizations.resolver.ts b/apps/server/src/app/identity/organizations.resolver.ts
new file mode 100644
index 000000000..bc7cab6ec
--- /dev/null
+++ b/apps/server/src/app/identity/organizations.resolver.ts
@@ -0,0 +1,177 @@
+import {
+ Args,
+ Mutation,
+ Parent,
+ Query,
+ ResolveField,
+ Resolver,
+} from "@nestjs/graphql";
+import { Organization } from "../../@generated/organization/organization.model";
+import { PrismaService } from "../prisma.service";
+import {
+ ConflictException,
+ NotFoundException,
+ UseGuards,
+} from "@nestjs/common";
+import { AuthGuard } from "../auth/auth.guard";
+import { CurrentUser } from "./current-user.decorator";
+import { RequestUser } from "./users.types";
+import { CreateOrganizationInput } from "./inputs/create-organization.input";
+import { OrganizationWhereUniqueInput } from "../../@generated/organization/organization-where-unique.input";
+import { isOrgAdminOrThrow, isOrgMemberOrThrow } from "./identity.utils";
+import { OrganizationMember } from "../../@generated/organization-member/organization-member.model";
+import { UsersService } from "./users.service";
+import { Invitation } from "../../@generated/invitation/invitation.model";
+import { UpdateOrgSettingsInput } from "./inputs/update-org-settings.input";
+import { PinoLogger } from "../logger/pino-logger";
+import { OrganizationsService } from "./organizations.service";
+
+@UseGuards(AuthGuard)
+@Resolver(() => Organization)
+export class OrganizationsResolver {
+ constructor(
+ private readonly prisma: PrismaService,
+ private readonly logger: PinoLogger,
+ private readonly organizationsService: OrganizationsService,
+ private readonly usersService: UsersService
+ ) {}
+
+ @Query(() => [Organization])
+ async organizations(@CurrentUser() user: RequestUser) {
+ this.logger.assign({ userId: user.id });
+ try {
+ this.logger.info("Getting all orgs");
+ const orgs = await this.organizationsService.getAllByUserId(user.id);
+ return orgs;
+ } catch (error) {
+ this.logger.error({ error }, "Failed to get orgs");
+ }
+ }
+
+ @Query(() => Organization)
+ async organization(
+ @CurrentUser() user: RequestUser,
+ @Args("data") data: OrganizationWhereUniqueInput
+ ) {
+ this.logger.assign({ userId: user.id, organizationId: data.id });
+
+ let org: Organization;
+ try {
+ this.logger.info("Getting org");
+ org = await this.organizationsService.getById(data.id);
+ } catch (error) {
+ this.logger.error({ error }, "Failed to get org");
+ }
+
+ if (!org) {
+ throw new NotFoundException();
+ }
+
+ isOrgMemberOrThrow(user, org.id);
+
+ return org;
+ }
+
+ @Mutation(() => Organization)
+ async createOrganization(
+ @Args("data") data: CreateOrganizationInput,
+ @CurrentUser() user: RequestUser
+ ) {
+ const { name } = data;
+ this.logger.assign({
+ userId: user.id,
+ organizationName: name,
+ });
+
+ let exists: boolean;
+ try {
+ this.logger.info("Checking if org available");
+ exists = await this.organizationsService.isOrgExists(name, user.id);
+ } catch (error) {
+ this.logger.error({ error }, "Failed to check if org available");
+ }
+ if (exists) {
+ throw new ConflictException(
+ "You already have an organization with this name"
+ );
+ }
+
+ let org: Organization;
+
+ try {
+ this.logger.info("Creating org");
+ org = await this.organizationsService.createOrg(name, user.id);
+ } catch (error) {
+ this.logger.error({ error }, "Failed to create org");
+ }
+
+ return org;
+ }
+
+ @ResolveField(() => [OrganizationMember])
+ async members(@Parent() organization: Organization) {
+ try {
+ this.logger.assign({ organizationId: organization.id });
+ this.logger.info("Getting all org members");
+ const members = await this.organizationsService.getOrgMembers(
+ organization.id
+ );
+
+ return members.map((member) => ({
+ ...member,
+ user: this.usersService.serializeExtendedUser(member.user),
+ }));
+ } catch (error) {
+ this.logger.error({ error }, "Failed to get org members");
+ }
+ }
+
+ @ResolveField(() => [Invitation])
+ async invitations(@Parent() organization: Organization) {
+ const invitations = await this.prisma.invitation.findMany({
+ where: {
+ organizationId: organization.id,
+ },
+ });
+
+ return invitations.map((invitation) => ({
+ ...invitation,
+ }));
+ }
+
+ @Mutation(() => Organization)
+ async updateOrgSettings(
+ @Args("data") data: UpdateOrgSettingsInput,
+ @CurrentUser() user: RequestUser
+ ) {
+ const { organizationId, name } = data;
+ this.logger.assign({
+ organizationId: data.organizationId,
+ userId: user.id,
+ });
+ let org: Organization;
+
+ try {
+ this.logger.info("Getting org");
+ org = await this.organizationsService.getById(data.organizationId);
+ } catch (error) {
+ this.logger.error({ error }, "Failed to get org");
+ }
+ if (!org) {
+ throw new ConflictException("Organization not found");
+ }
+
+ isOrgAdminOrThrow(user, organizationId);
+
+ try {
+ this.logger.info("Updating org");
+ const updatedOrganization = await this.organizationsService.updateOrg(
+ name,
+ organizationId
+ );
+ return updatedOrganization;
+ } catch (error) {
+ this.logger.error({ error }, "Failed to update organization");
+ }
+ }
+}
diff --git a/apps/server/src/app/identity/organizations.service.ts b/apps/server/src/app/identity/organizations.service.ts
new file mode 100644
index 000000000..4edb874c4
--- /dev/null
+++ b/apps/server/src/app/identity/organizations.service.ts
@@ -0,0 +1,141 @@
+import { Injectable } from "@nestjs/common";
+import { PrismaService } from "../prisma.service";
+import { OrgRole } from "@prisma/client";
+import { ConfigService } from "@nestjs/config";
+
+@Injectable()
+export class OrganizationsService {
+ constructor(
+ private readonly prisma: PrismaService,
+ private config: ConfigService
+ ) {}
+
+ async getById(id: string) {
+ return await this.prisma.organization.findUnique({ where: { id } });
+ }
+
+ async getAllByUserId(userId: string) {
+ return this.prisma.organization.findMany({
+ where: {
+ members: {
+ some: {
+ userId,
+ },
+ },
+ },
+ });
+ }
+
+ async getOrgByProjectId(projectId: string) {
+ return this.prisma.organization.findFirst({
+ where: {
+ projects: {
+ some: {
+ id: projectId,
+ },
+ },
+ },
+ });
+ }
+
+ async getOrgMemberByOrgId(id: string, userId: string) {
+ return await this.prisma.organizationMember.findFirst({
+ where: {
+ organizationId: id,
+ userId,
+ },
+ });
+ }
+
+ async getOrganizationMemberById(id: string) {
+ return await this.prisma.organizationMember.findUnique({ where: { id } });
+ }
+
+ async getOrgMembers(id: string) {
+ return await this.prisma.organizationMember.findMany({
+ where: {
+ organizationId: id,
+ },
+ include: {
+ user: {
+ include: {
+ orgMemberships: true,
+ },
+ },
+ },
+ });
+ }
+
+ async deleteOrgMember(id: string) {
+ return await this.prisma.organizationMember.delete({ where: { id } });
+ }
+
+ async addMember(organizationId: string, userId: string, role: OrgRole) {
+ const member = await this.prisma.organizationMember.create({
+ data: {
+ organizationId,
+ userId,
+ role,
+ },
+ });
+
+ return member;
+ }
+
+ async updateMember(id: string, role: OrgRole) {
+ const member = await this.prisma.organizationMember.update({
+ where: { id },
+ data: {
+ role,
+ },
+ });
+
+ return member;
+ }
+
+ async isOrgExists(name: string, userId: string) {
+ const exists = await this.prisma.organization.findFirst({
+ where: {
+ members: {
+ some: {
+ userId,
+ },
+ },
+ name: {
+ equals: name,
+ mode: "insensitive",
+ },
+ },
+ });
+
+ return !!exists;
+ }
+
+ async createOrg(name: string, creatorUserId: string) {
+ const waitlisted = this.config.get("WAITLIST_ENABLED");
+
+ return await this.prisma.organization.create({
+ data: {
+ name,
+ waitlisted,
+ members: {
+ create: {
+ userId: creatorUserId,
+ role: OrgRole.Admin,
+ },
+ },
+ },
+ });
+ }
+
+ async updateOrg(name: string, id: string) {
+ return await this.prisma.organization.update({
+ where: {
+ id,
+ },
+ data: {
+ name,
+ },
+ });
+ }
+}
diff --git a/apps/server/src/app/identity/project-id.decorator.ts b/apps/server/src/app/identity/project-id.decorator.ts
new file mode 100644
index 000000000..ee44b4a73
--- /dev/null
+++ b/apps/server/src/app/identity/project-id.decorator.ts
@@ -0,0 +1,10 @@
+import { createParamDecorator, ExecutionContext } from "@nestjs/common";
+import { GqlExecutionContext } from "@nestjs/graphql";
+
+export const ProjectId = createParamDecorator(
+ (_: unknown, context: ExecutionContext): string => {
+ const gqlCtx = GqlExecutionContext.create(context);
+ const ctx = gqlCtx.getContext();
+ return ctx.req.projectId;
+ }
+);
diff --git a/apps/server/src/app/identity/projects.resolver.ts b/apps/server/src/app/identity/projects.resolver.ts
new file mode 100644
index 000000000..424546c45
--- /dev/null
+++ b/apps/server/src/app/identity/projects.resolver.ts
@@ -0,0 +1,206 @@
+import {
+ Args,
+ Mutation,
+ Parent,
+ Query,
+ ResolveField,
+ Resolver,
+} from "@nestjs/graphql";
+import { Project } from "../../@generated/project/project.model";
+import { CreateProjectInput } from "./inputs/create-project.input";
+import { ProjectsService } from "./projects.service";
+import { AuthGuard } from "../auth/auth.guard";
+import {
+ ConflictException,
+ InternalServerErrorException,
+ NotFoundException,
+ UseGuards,
+} from "@nestjs/common";
+import { isOrgAdminOrThrow, isOrgMemberOrThrow } from "./identity.utils";
+import { CurrentUser } from "./current-user.decorator";
+import { RequestUser } from "./users.types";
+import { slugify } from "@pezzo/common";
+import { ProjectWhereUniqueInput } from "../../@generated/project/project-where-unique.input";
+import { PinoLogger } from "../logger/pino-logger";
+import { AnalyticsService } from "../analytics/analytics.service";
+import { Organization } from "../../@generated/organization/organization.model";
+import { GetProjectsInput } from "./inputs/get-projects.input";
+import { UpdateProjectSettingsInput } from "./inputs/update-project-settings.input";
+
+@UseGuards(AuthGuard)
+@Resolver(() => Project)
+export class ProjectsResolver {
+ constructor(
+ private projectsService: ProjectsService,
+ private logger: PinoLogger,
+ private analytics: AnalyticsService
+ ) {}
+
+ @Query(() => Project)
+ async project(
+ @Args("data") data: ProjectWhereUniqueInput,
+ @CurrentUser() user: RequestUser
+ ) {
+ const { id: projectId } = data;
+ let project: Project;
+ this.logger.assign({ projectId }).info("Getting project");
+
+ try {
+ project = await this.projectsService.getProjectById(projectId);
+ } catch (error) {
+ this.logger.error({ error }, "Error getting project");
+ throw new InternalServerErrorException();
+ }
+
+ if (!project) {
+ throw new NotFoundException();
+ }
+
+ isOrgMemberOrThrow(user, project.organizationId);
+ return project;
+ }
+
+ @Query(() => [Project])
+ async projects(
+ @Args("data") data: GetProjectsInput,
+ @CurrentUser() user: RequestUser
+ ) {
+ const { organizationId } = data;
+ isOrgMemberOrThrow(user, organizationId);
+ this.logger.assign({ organizationId }).info("Getting projects");
+
+ try {
+ return await this.projectsService.getProjectsByOrgId(organizationId);
+ } catch (error) {
+ this.logger.error({ error }, "Error getting projects");
+ throw new InternalServerErrorException();
+ }
+ }
+
+ @Mutation(() => Project)
+ async createProject(@Args("data") data: CreateProjectInput) {
+ const { organizationId, name } = data;
+
+ this.logger.assign({ organizationId, name }).info("Creating project");
+
+ const slug = slugify(data.name);
+ let exists: Project;
+
+ try {
+ exists = await this.projectsService.getProjectBySlug(
+ slug,
+ data.organizationId
+ );
+ } catch (error) {
+ this.logger.error({ error }, "Error checking for existing project");
+ throw new InternalServerErrorException();
+ }
+
+ if (exists) {
+ throw new ConflictException(`Project with slug "${slug}" already exists`);
+ }
+
+ try {
+ const project = await this.projectsService.createProject(
+ data.name,
+ slug,
+ data.organizationId
+ );
+
+ this.analytics.trackEvent("project_created", {
+ projectId: project.id,
+ name,
+ });
+
+ return project;
+ } catch (error) {
+ this.logger.error({ error }, "Error creating project");
+ throw new InternalServerErrorException();
+ }
+ }
+
+ @Mutation(() => Project)
+ async updateProjectSettings(
+ @Args("data") data: UpdateProjectSettingsInput,
+ @CurrentUser() user: RequestUser
+ ) {
+ this.logger.assign(data);
+
+ let project: Project;
+
+ try {
+ project = await this.projectsService.getProjectById(data.projectId);
+ } catch (error) {
+ this.logger.error({ error }, "Error checking for existing project");
+ throw new InternalServerErrorException();
+ }
+
+ if (!project) {
+ throw new NotFoundException(`Project does not exist`);
+ }
+
+ isOrgAdminOrThrow(user, project.organizationId);
+
+ this.logger.info("Updating project settings");
+
+ try {
+ const project = await this.projectsService.updateProjectSettings(data);
+ this.logger.info("Project settings updated");
+
+ this.analytics.trackEvent("project_settings_updated", {
+ projectId: project.id,
+ name: project.name,
+ });
+
+ return project;
+ } catch (error) {
+ this.logger.error({ error }, "Error updating project settings");
+ throw new InternalServerErrorException();
+ }
+ }
+
+ @Mutation(() => Project)
+ async deleteProject(
+ @Args("data") data: ProjectWhereUniqueInput,
+ @CurrentUser() user: RequestUser
+ ) {
+ const { id } = data;
+ this.logger.assign({ id });
+
+ let project: Project;
+
+ try {
+ project = await this.projectsService.getProjectById(id);
+ } catch (error) {
+ this.logger.error({ error }, "Error checking for existing project");
+ throw new InternalServerErrorException();
+ }
+
+ if (!project) {
+ throw new NotFoundException(`Project does not exist`);
+ }
+
+ isOrgAdminOrThrow(user, project.organizationId);
+
+ this.logger.info("Deleting project");
+
+ try {
+ const project = await this.projectsService.deleteProject(id);
+ this.logger.info("Project deleted");
+
+ this.analytics.trackEvent("project_deleted", {
+ projectId: project.id,
+ });
+
+ return project;
+ } catch (error) {
+ this.logger.error({ error }, "Error deleting project");
+ throw new InternalServerErrorException();
+ }
+ }
+
+ @ResolveField(() => Organization)
+ async oganization(@Parent() project: Project) {
+ return project.organization;
+ }
+}
diff --git a/apps/server/src/app/identity/projects.service.ts b/apps/server/src/app/identity/projects.service.ts
new file mode 100644
index 000000000..0ea0ed446
--- /dev/null
+++ b/apps/server/src/app/identity/projects.service.ts
@@ -0,0 +1,82 @@
+import { Injectable } from "@nestjs/common";
+import { PrismaService } from "../prisma.service";
+import { PinoLogger } from "../logger/pino-logger";
+import { EnvironmentsService } from "./environments.service";
+import { UpdateProjectSettingsInput } from "./inputs/update-project-settings.input";
+
+@Injectable()
+export class ProjectsService {
+ constructor(
+ private prisma: PrismaService,
+ private environmentsService: EnvironmentsService,
+ private logger: PinoLogger
+ ) {}
+
+ async createProject(name: string, slug: string, organizationId: string) {
+ this.logger.info("Creating project in database");
+ const project = await this.prisma.project.create({
+ data: {
+ name,
+ slug,
+ organizationId,
+ },
+ });
+
+ // Create default environment
+ await this.environmentsService.createEnvironment("Production", project.id);
+
+ this.logger
+ .assign({ projectId: project.id })
+ .info("Creating API key for project");
+
+ return project;
+ }
+
+ async getProjectById(id: string) {
+ return this.prisma.project.findUnique({
+ where: {
+ id,
+ },
+ include: {
+ organization: true,
+ },
+ });
+ }
+
+ async getProjectsByOrgId(organizationId: string) {
+ return this.prisma.project.findMany({
+ where: {
+ organizationId,
+ },
+ });
+ }
+
+ async getProjectBySlug(slug: string, organizationId: string) {
+ return this.prisma.project.findFirst({
+ where: {
+ slug,
+ organizationId,
+ },
+ });
+ }
+
+ async deleteProject(id: string) {
+ return this.prisma.project.delete({
+ where: {
+ id,
+ },
+ });
+ }
+
+ async updateProjectSettings(data: UpdateProjectSettingsInput) {
+ const { projectId, name } = data;
+ return this.prisma.project.update({
+ where: {
+ id: projectId,
+ },
+ data: {
+ name,
+ },
+ });
+ }
+}
diff --git a/apps/server/src/app/identity/users.resolver.ts b/apps/server/src/app/identity/users.resolver.ts
new file mode 100644
index 000000000..aa650d5e9
--- /dev/null
+++ b/apps/server/src/app/identity/users.resolver.ts
@@ -0,0 +1,96 @@
+import { Args, Mutation, Query, Resolver } from "@nestjs/graphql";
+import UserMetadata from "supertokens-node/recipe/usermetadata";
+import { AuthGuard } from "../auth/auth.guard";
+import { NotFoundException, UseGuards } from "@nestjs/common";
+import { CurrentUser } from "./current-user.decorator";
+import { RequestUser } from "./users.types";
+import { UsersService } from "./users.service";
+import { UpdateProfileInput } from "./inputs/update-profile.input";
+import { PinoLogger } from "../logger/pino-logger";
+import { ExtendedUser } from "./models/extended-user.model";
+
+type SupertokensMetadata = {
+ metadata:
+ | { profile: { name: string | null; photoUrl: string | null } }
+ | undefined;
+};
+
+@UseGuards(AuthGuard)
+@Resolver(() => ExtendedUser)
+export class UsersResolver {
+ constructor(private usersService: UsersService, private logger: PinoLogger) {}
+
+ @Query(() => ExtendedUser)
+ async me(@CurrentUser() userInfo: RequestUser) {
+ this.logger.info(
+ {
+ userId: userInfo.id,
+ email: userInfo.email,
+ supertokensUserId: userInfo.supertokensUserId,
+ },
+ "Getting user"
+ );
+
+ const user = await this.usersService.getUser(userInfo.email);
+
+ if (!user) {
+ throw new NotFoundException();
+ }
+
+ const organizationIds = userInfo.orgMemberships.map(
+ (m) => m.organizationId
+ );
+
+ const { metadata } = (await UserMetadata.getUserMetadata(
+ userInfo.supertokensUserId
+ )) as SupertokensMetadata;
+
+ if (metadata) {
+ return {
+ ...user,
+ ...metadata.profile,
+ organizationIds,
+ };
+ }
+
+ return {
+ ...user,
+ organizationIds,
+ };
+ }
+
+ @Mutation(() => ExtendedUser)
+ async updateProfile(
+ @CurrentUser() userInfo: RequestUser,
+ @Args("data") { name }: UpdateProfileInput
+ ) {
+ this.logger.info({ name }, "Updating profile");
+ const user = await this.usersService.getUser(userInfo.email);
+
+ if (!user) {
+ throw new NotFoundException();
+ }
+
+ const { metadata } = (await UserMetadata.getUserMetadata(
+ user.id
+ )) as SupertokensMetadata;
+
+ const profileMetadata = metadata?.profile ?? {
+ name,
+ photoUrl: null,
+ };
+
+ await UserMetadata.updateUserMetadata(user.id, {
+ ...metadata,
+ profile: {
+ ...profileMetadata,
+ name,
+ },
+ });
+
+ return {
+ ...user,
+ name,
+ };
+ }
+}
diff --git a/apps/server/src/app/identity/users.service.ts b/apps/server/src/app/identity/users.service.ts
new file mode 100644
index 000000000..f4e759dc2
--- /dev/null
+++ b/apps/server/src/app/identity/users.service.ts
@@ -0,0 +1,104 @@
+import { Injectable } from "@nestjs/common";
+import { PrismaService } from "../prisma.service";
+import { OrganizationMember, User } from "@prisma/client";
+import { UserCreateRequest } from "./users.types";
+import { ExtendedUser } from "./models/extended-user.model";
+import UserMetadata from "supertokens-node/recipe/usermetadata";
+import { SupertokensMetadata } from "../auth/auth.types";
+import { randomBytes } from "crypto";
+import { ConfigService } from "@nestjs/config";
+
+export type UserWithOrgMemberships = User & {
+ orgMemberships: OrganizationMember[];
+};
+
+@Injectable()
+export class UsersService {
+ constructor(
+ private readonly prisma: PrismaService,
+ private config: ConfigService
+ ) {}
+
+ async createUser(
+ userCreateRequest: UserCreateRequest
+ ): Promise {
+ const user = await this.prisma.user.create({
+ data: {
+ id: userCreateRequest.id,
+ email: userCreateRequest.email.toLowerCase(),
+ },
+ include: {
+ orgMemberships: true,
+ },
+ });
+
+ const waitlisted = this.config.get("WAITLIST_ENABLED");
+
+ const organization = await this.prisma.organization.create({
+ data: {
+ name: `${userCreateRequest.name}'s Organization`,
+ waitlisted,
+ members: {
+ create: {
+ userId: userCreateRequest.id,
+ },
+ },
+ },
+ });
+
+ const apiKeyValue = `pez_${randomBytes(16).toString("hex")}`;
+ await this.prisma.apiKey.create({
+ data: {
+ id: apiKeyValue,
+ organizationId: organization.id,
+ },
+ });
+
+ return user;
+ }
+
+ async getUser(email: string): Promise {
+ const user = await this.prisma.user.findUnique({
+ where: { email },
+ include: { orgMemberships: true },
+ });
+
+ return user;
+ }
+
+ async getById(id: string): Promise {
+ const user = await this.prisma.user.findUnique({
+ where: { id },
+ include: { orgMemberships: true },
+ });
+
+ return user;
+ }
+
+ async getUserOrgMemberships(email: string): Promise {
+ const memberships = await this.prisma.organizationMember.findMany({
+ where: {
+ user: {
+ email,
+ },
+ },
+ });
+ return memberships;
+ }
+
+ async serializeExtendedUser(
+ user: UserWithOrgMemberships
+ ): Promise {
+ const organizationIds = user.orgMemberships.map((m) => m.organizationId);
+
+ const { metadata } = (await UserMetadata.getUserMetadata(
+ user.id
+ )) as SupertokensMetadata;
+
+ return {
+ ...user,
+ ...metadata.profile,
+ organizationIds,
+ };
+ }
+}
diff --git a/apps/server/src/app/identity/users.types.ts b/apps/server/src/app/identity/users.types.ts
new file mode 100644
index 000000000..5a8a09afb
--- /dev/null
+++ b/apps/server/src/app/identity/users.types.ts
@@ -0,0 +1,17 @@
+import { OrgRole } from "@prisma/client";
+export interface RequestUser {
+ id: string;
+ supertokensUserId: string;
+ email: string;
+ orgMemberships: {
+ organizationId: string;
+ role: OrgRole;
+ memberSince: Date;
+ }[];
+}
+
+export interface UserCreateRequest {
+ id: string;
+ email: string;
+ name: string;
+}
diff --git a/apps/server/src/app/logger/create-logger.ts b/apps/server/src/app/logger/create-logger.ts
new file mode 100644
index 000000000..741b0cc2a
--- /dev/null
+++ b/apps/server/src/app/logger/create-logger.ts
@@ -0,0 +1,19 @@
+import { pino } from "pino";
+import pretty from "pino-pretty";
+
+export function createLogger(context = {}) {
+ let logger: pino.Logger;
+
+ if (process.env.PINO_PRETTIFY === "true") {
+ const prettyStream = pretty({
+ levelFirst: true,
+ colorize: true,
+ });
+ logger = pino({ redact: ["pid", "hostname", "res"] }, prettyStream);
+ } else {
+ logger = pino({ redact: ["pid", "hostname", "res"] });
+ }
+
+ const child = logger.child(context);
+ return child;
+}
diff --git a/apps/server/src/app/logger/logger.module.ts b/apps/server/src/app/logger/logger.module.ts
new file mode 100644
index 000000000..87f4c6bee
--- /dev/null
+++ b/apps/server/src/app/logger/logger.module.ts
@@ -0,0 +1,9 @@
+import { Global, Module } from "@nestjs/common";
+import { PinoLogger } from "./pino-logger";
+
+@Global()
+@Module({
+ providers: [PinoLogger],
+ exports: [PinoLogger],
+})
+export class LoggerModule {}
diff --git a/apps/server/src/app/logger/pino-logger.ts b/apps/server/src/app/logger/pino-logger.ts
new file mode 100644
index 000000000..73ffc91f9
--- /dev/null
+++ b/apps/server/src/app/logger/pino-logger.ts
@@ -0,0 +1,86 @@
+import { Inject, Injectable, Scope } from "@nestjs/common";
+import { CONTEXT } from "@nestjs/graphql";
+import { pino } from "pino";
+import { createLogger } from "./create-logger";
+
+@Injectable({ scope: Scope.REQUEST })
+export class PinoLogger {
+ private logger: pino.Logger;
+
+ constructor(
+ @Inject(CONTEXT)
+ private readonly context = { requestId: null, logger: null }
+ ) {
+ const logger = createLogger({ requestId: this.context.requestId });
+ this.setLogger(logger);
+ }
+
+ private setLogger(logger: pino.Logger) {
+ this.logger = logger;
+ this.context.logger = logger;
+ return logger;
+ }
+
+ assign(obj: object) {
+ const child = this.logger.child(obj);
+ return this.setLogger(child);
+ }
+
+ info(obj: T, msg?: string): void;
+ info(msg: string): void;
+ info(obj: any, msg?: any): void {
+ if (typeof obj === "string") {
+ this.logger.info(obj);
+ } else {
+ this.logger.info(obj, msg);
+ }
+ }
+
+ error(obj: T, msg?: string): void;
+ error(msg: string): void;
+ error(obj: any, msg?: any): void {
+ if (typeof obj === "string") {
+ this.logger.error(obj);
+ } else {
+ if (obj.error && obj.error instanceof Error) {
+ obj = {
+ ...obj,
+ message: obj.error.message,
+ stack: obj.error.stack,
+ };
+ }
+
+ this.logger.error(obj, msg);
+ }
+ }
+
+ warn(obj: T, msg?: string): void;
+ warn(msg: string): void;
+ warn(obj: any, msg?: any): void {
+ if (typeof obj === "string") {
+ this.logger.warn(obj);
+ } else {
+ this.logger.warn(obj, msg);
+ }
+ }
+
+ debug(obj: T, msg?: string): void;
+ debug(msg: string): void;
+ debug(obj: any, msg?: any): void {
+ if (typeof obj === "string") {
+ this.logger.debug(obj);
+ } else {
+ this.logger.debug(obj, msg);
+ }
+ }
+
+ trace(obj: T, msg?: string): void;
+ trace(msg: string): void;
+ trace(obj: any, msg?: any): void {
+ if (typeof obj === "string") {
+ this.logger.trace(obj);
+ } else {
+ this.logger.trace(obj, msg);
+ }
+ }
+}
diff --git a/apps/server/src/app/metrics/inputs/get-project-metrics.input.ts b/apps/server/src/app/metrics/inputs/get-project-metrics.input.ts
new file mode 100644
index 000000000..dba0aeaf6
--- /dev/null
+++ b/apps/server/src/app/metrics/inputs/get-project-metrics.input.ts
@@ -0,0 +1,129 @@
+import { Field, InputType, registerEnumType } from "@nestjs/graphql";
+import { FilterInput } from "../../common/filters/filter.input";
+
+export enum ProjectMetricType {
+ requests = "requests",
+ cost = "cost",
+ duration = "duration",
+ successfulRequests = "successfulRequests",
+ erroneousRequests = "erroneousRequests",
+ model = "model",
+}
+
+registerEnumType(ProjectMetricType, {
+ name: "ProjectMetricType",
+});
+
+export enum HistogramIdType {
+ requestDuration = "requestDuration",
+ successErrorRate = "successErrorRate",
+ modelUsage = "modelUsage",
+}
+
+registerEnumType(HistogramIdType, {
+ name: "HistogramIdType",
+});
+
+export enum DeltaAggregation {
+ sum = "sum",
+ avg = "avg",
+ min = "min",
+ max = "max",
+ count = "count",
+}
+
+registerEnumType(DeltaAggregation, {
+ name: "DeltaAggregation",
+});
+
+export enum DeltaMetricType {
+ TotalCost = "TotalCost",
+ TotalTokens = "TotalTokens",
+ TotalRequests = "TotalRequests",
+ AverageRequestDuration = "AverageRequestDuration",
+ SuccessResponses = "SuccessfulResponses",
+ ErrorResponses = "ErroneousResponses",
+}
+
+registerEnumType(DeltaMetricType, {
+ name: "DeltaMetricType",
+});
+
+@InputType()
+export class GetProjectMetricInput {
+ @Field(() => String, { nullable: false })
+ projectId: string;
+
+ @Field(() => ProjectMetricType, { nullable: false })
+ metric: ProjectMetricType;
+
+ @Field(() => Date, { nullable: false })
+ startDate: Date;
+
+ @Field(() => Date, { nullable: false })
+ endDate: Date;
+
+ @Field(() => [FilterInput], { nullable: true })
+ filters?: FilterInput[];
+}
+
+export enum ProjectMetricHistogramBucketSize {
+ minutely = "1m",
+ hourly = "1h",
+ daily = "1d",
+ weekly = "1w",
+ monthly = "30d",
+ yearly = "1y",
+}
+
+registerEnumType(ProjectMetricHistogramBucketSize, {
+ name: "ProjectMetricHistogramBucketSize",
+});
+
+@InputType()
+export class BaseProjectMetricInput {
+ @Field(() => String, { nullable: false })
+ projectId: string;
+
+ @Field(() => Date, { nullable: false })
+ startDate: Date;
+
+ @Field(() => Date, { nullable: false })
+ endDate: Date;
+
+ @Field(() => ProjectMetricHistogramBucketSize, { nullable: true })
+ bucketSize?: ProjectMetricHistogramBucketSize; // The size of each histogram bucket, e.g., "1d", "1w", "1h"
+
+ @Field(() => [FilterInput], { nullable: true })
+ filters?: FilterInput[];
+}
+
+@InputType()
+export class GetProjectMetricHistogramInput extends BaseProjectMetricInput {
+ @Field(() => ProjectMetricType, { nullable: false })
+ metric: ProjectMetricType;
+}
+
+@InputType()
+export class GetProjectGenericHistogramInput extends BaseProjectMetricInput {
+ @Field(() => HistogramIdType, { nullable: false })
+ histogramId: HistogramIdType;
+}
+
+@InputType()
+export class GetProjectModelUsageHistogramInput extends BaseProjectMetricInput {}
+
+@InputType()
+export class GetProjectMetricDeltaInput {
+ @Field(() => String, { nullable: false })
+ projectId: string;
+
+ @Field(() => Date, { nullable: false })
+ startDate: Date;
+
+ @Field(() => Date, { nullable: false })
+ endDate: Date;
+
+ @Field(() => DeltaMetricType, { nullable: true })
+ metric: DeltaMetricType;
+}
diff --git a/apps/server/src/app/metrics/metrics.module.ts b/apps/server/src/app/metrics/metrics.module.ts
new file mode 100644
index 000000000..9c8903310
--- /dev/null
+++ b/apps/server/src/app/metrics/metrics.module.ts
@@ -0,0 +1,13 @@
+import { Module } from "@nestjs/common";
+import { IdentityModule } from "../identity/identity.module";
+import { PromptsModule } from "../prompts/prompts.module";
+import { PrismaService } from "../prisma.service";
+import { ProjectMetricsResolver } from "./project-metrics.resolver";
+import { ProjectMetricsService } from "./project-metrics.service";
+import { ClickhHouseModule } from "../clickhouse/clickhouse.module";
+
+@Module({
+ imports: [IdentityModule, PromptsModule, ClickhHouseModule],
+ providers: [ProjectMetricsResolver, PrismaService, ProjectMetricsService],
+})
+export class MetricsModule {}
diff --git a/apps/server/src/app/metrics/metrics.utils.ts b/apps/server/src/app/metrics/metrics.utils.ts
new file mode 100644
index 000000000..df6896f4c
--- /dev/null
+++ b/apps/server/src/app/metrics/metrics.utils.ts
@@ -0,0 +1,56 @@
+import bodybuilder from "bodybuilder";
+
+export function getPercentageChange(
+ currentValue: number,
+ previousValue: number
+) {
+ if (previousValue === 0) {
+ return 0;
+ }
+
+ return Math.ceil(((currentValue - previousValue) / previousValue) * 100);
+}
+
+export function buildBaseProjectMetricQuery(
+ body: bodybuilder.Bodybuilder,
+ projectId: string,
+ startDate: string,
+ endDate: string
+): bodybuilder.Bodybuilder {
+ return body
+ .filter("term", "ownership.projectId", projectId)
+ .filter("range", "timestamp", { gte: startDate, lte: endDate })
+ .size(0);
+}
+
+type IntervalDates = {
+ current: {
+ startDate: string;
+ endDate: string;
+ };
+ previous: {
+ startDate: string;
+ endDate: string;
+ };
+};
+
+export function getStartAndEndDates(
+ startDate: Date,
+ endDate: Date
+): IntervalDates {
+ const diff = endDate.getTime() - startDate.getTime();
+
+ const previousStartDate = new Date(startDate.getTime() - diff);
+ const previousEndDate = new Date(endDate.getTime() - diff);
+
+ return {
+ current: {
+ startDate: startDate.toISOString(),
+ endDate: endDate.toISOString(),
+ },
+ previous: {
+ startDate: previousStartDate.toISOString(),
+ endDate: previousEndDate.toISOString(),
+ },
+ };
+}
diff --git a/apps/server/src/app/metrics/models/project-metric.model.ts b/apps/server/src/app/metrics/models/project-metric.model.ts
new file mode 100644
index 000000000..8fb86d5ae
--- /dev/null
+++ b/apps/server/src/app/metrics/models/project-metric.model.ts
@@ -0,0 +1,57 @@
+import { Field } from "@nestjs/graphql";
+import { ObjectType } from "@nestjs/graphql";
+import GraphQLJSON from "graphql-type-json";
+
+@ObjectType()
+export class ProjectMetric {
+ @Field(() => Number, { nullable: false })
+ currentValue: number;
+
+ @Field(() => Number, { nullable: false })
+ previousValue: number;
+}
+
+@ObjectType()
+export class HistogramMetric {
+ @Field(() => String, { nullable: false })
+ timestamp: string; // ISO date string for the bucket
+
+ @Field(() => Number, { nullable: false })
+ value: number; // Value for the metric at that date
+}
+
+@ObjectType()
+export class ModelUsageHistogramValue {
+ @Field(() => String, { nullable: false })
+ model: string;
+
+ @Field(() => String, { nullable: false })
+ modelAuthor: string;
+
+ @Field(() => Number, { nullable: false })
+ value: number;
+}
+
+@ObjectType()
+export class ModelUsageHistogramBucket {
+ @Field(() => String, { nullable: false })
+ timestamp: string;
+
+ @Field(() => [ModelUsageHistogramValue], { nullable: false })
+ values: ModelUsageHistogramValue[]; // Value for the metric at that date
+}
+
+@ObjectType()
+export class GenericProjectHistogramResult {
+ @Field(() => [GraphQLJSON], { nullable: false })
+ data: any;
+}
+
+@ObjectType()
+export class ProjectMetricDeltaResult {
+ @Field(() => Number, { nullable: false })
+ currentValue: number;
+
+ @Field(() => Number, { nullable: false })
+ previousValue: number;
+}
diff --git a/apps/server/src/app/metrics/project-metrics.resolver.ts b/apps/server/src/app/metrics/project-metrics.resolver.ts
new file mode 100644
index 000000000..d85e1d541
--- /dev/null
+++ b/apps/server/src/app/metrics/project-metrics.resolver.ts
@@ -0,0 +1,84 @@
+import { Args, Query, Resolver } from "@nestjs/graphql";
+import { AuthGuard } from "../auth/auth.guard";
+import { UseGuards } from "@nestjs/common";
+import { PinoLogger } from "../logger/pino-logger";
+import { CurrentUser } from "../identity/current-user.decorator";
+import { RequestUser } from "../identity/users.types";
+import { isOrgMemberOrThrow } from "../identity/identity.utils";
+import { PrismaService } from "../prisma.service";
+import {
+ GenericProjectHistogramResult,
+ ProjectMetric,
+ ProjectMetricDeltaResult,
+} from "./models/project-metric.model";
+import {
+ GetProjectGenericHistogramInput,
+ GetProjectMetricDeltaInput,
+} from "./inputs/get-project-metrics.input";
+import { ProjectMetricsService } from "./project-metrics.service";
+
+@UseGuards(AuthGuard)
+@Resolver(() => ProjectMetric)
+export class ProjectMetricsResolver {
+ constructor(
+ private prismaService: PrismaService,
+ private projectMetricsService: ProjectMetricsService,
+ private readonly logger: PinoLogger
+ ) {}
+
+ @Query(() => GenericProjectHistogramResult)
+ async genericProjectMetricHistogram(
+ @Args("data") data: GetProjectGenericHistogramInput,
+ @CurrentUser() user: RequestUser
+ ): Promise {
+ this.logger.assign({ data });
+ this.logger.info("Getting project metric histogram");
+
+ const { projectId, histogramId, startDate, endDate, bucketSize, filters } =
+ data;
+
+ const project = await this.prismaService.project.findUnique({
+ where: {
+ id: projectId,
+ },
+ });
+
+ isOrgMemberOrThrow(user, project.organizationId);
+ return this.projectMetricsService.getGenericHistogram(
+ projectId,
+ histogramId,
+ startDate,
+ endDate,
+ bucketSize,
+ filters
+ );
+ }
+
+ @Query(() => ProjectMetricDeltaResult)
+ async projectMetricDelta(
+ @Args("data") data: GetProjectMetricDeltaInput,
+ @CurrentUser() user: RequestUser
+ ): Promise {
+ this.logger.assign({ data });
+ this.logger.info("Getting project metric with deltas");
+
+ const { projectId, startDate, endDate, metric } = data;
+
+ const project = await this.prismaService.project.findUnique({
+ where: {
+ id: projectId,
+ },
+ });
+
+ isOrgMemberOrThrow(user, project.organizationId);
+
+ const result = await this.projectMetricsService.getProjectMetricDelta(
+ projectId,
+ startDate,
+ endDate,
+ metric
+ );
+
+ return result;
+ }
+}
diff --git a/apps/server/src/app/metrics/project-metrics.service.ts b/apps/server/src/app/metrics/project-metrics.service.ts
new file mode 100644
index 000000000..d34d04e9a
--- /dev/null
+++ b/apps/server/src/app/metrics/project-metrics.service.ts
@@ -0,0 +1,133 @@
+import {
+ BadRequestException,
+ Injectable,
+ InternalServerErrorException,
+} from "@nestjs/common";
+import {
+ DeltaMetricType,
+ HistogramIdType,
+ ProjectMetricHistogramBucketSize,
+} from "./inputs/get-project-metrics.input";
+import {
+ GenericProjectHistogramResult,
+ ProjectMetricDeltaResult,
+} from "./models/project-metric.model";
+import { FilterInput } from "../common/filters/filter.input";
+import { ClickHouseService } from "../clickhouse/clickhouse.service";
+import { averageRequestDurationQuery } from "./queries/average-request-duration-query";
+import { Knex } from "knex";
+import { successErrorRateQuery } from "./queries/success-error-rate-query";
+
+@Injectable()
+export class ProjectMetricsService {
+ constructor(private clickHouseService: ClickHouseService) {}
+
+ async getGenericHistogram(
+ projectId: string,
+ histogramId: HistogramIdType,
+ startDate: Date,
+ endDate: Date,
+ bucketSize: ProjectMetricHistogramBucketSize,
+ filters: FilterInput[]
+ ): Promise {
+ let queryBuilder: Knex.QueryBuilder;
+
+ switch (histogramId) {
+ case HistogramIdType.requestDuration:
+ queryBuilder = averageRequestDurationQuery(
+ this.clickHouseService.knex,
+ { projectId, bucketSize, startDate, endDate }
+ );
+ break;
+ case HistogramIdType.successErrorRate:
+ queryBuilder = successErrorRateQuery(this.clickHouseService.knex, {
+ projectId,
+ bucketSize,
+ startDate,
+ endDate,
+ });
+ break;
+ default:
+ throw new BadRequestException(
+ `HistogramId ${histogramId} is not supported`
+ );
+ }
+
+ const result = await queryBuilder;
+ return { data: result };
+ }
+
+ async getProjectMetricDelta(
+ projectId: string,
+ startDate: Date,
+ endDate: Date,
+ metric: DeltaMetricType
+ ): Promise {
+ let selectStatement: string;
+
+ switch (metric) {
+ case DeltaMetricType.AverageRequestDuration:
+ const currentValue = /*sql*/ `avgIf(r.duration, r.isError = false AND r.requestTimestamp >= currentStartDate AND r.responseTimestamp <= currentEndDate)`;
+ const previousValue = /*sql*/ `avgIf(r.duration, r.isError = false AND r.requestTimestamp >= previousStartDate AND r.responseTimestamp <= previousEndDate)`;
+
+ selectStatement = /*sql*/ `
+ if(isNaN(${currentValue}), 0, ${currentValue}) AS currentValue,
+ if(isNaN(${previousValue}), 0, ${previousValue}) AS previousValue
+ `;
+ break;
+ case DeltaMetricType.TotalRequests:
+ selectStatement = /*sql*/ `
+ countIf(r.requestTimestamp >= currentStartDate AND r.responseTimestamp <= currentEndDate) AS currentValue,
+ countIf(r.requestTimestamp >= previousStartDate AND r.responseTimestamp <= previousEndDate) AS previousValue
+ `;
+ break;
+ case DeltaMetricType.TotalCost:
+ selectStatement = /*sql*/ `
+ sumIf(r.totalCost, r.requestTimestamp >= currentStartDate AND r.responseTimestamp <= currentEndDate) AS currentValue,
+ sumIf(r.totalCost, r.requestTimestamp >= previousStartDate AND r.responseTimestamp <= previousEndDate) AS previousValue
+ `;
+ break;
+ case DeltaMetricType.SuccessResponses:
+ selectStatement = /*sql*/ `
+ countIf(r.isError = false AND r.requestTimestamp >= currentStartDate AND r.responseTimestamp <= currentEndDate) AS currentSuccess,
+ countIf(r.isError = false AND r.requestTimestamp >= previousStartDate AND r.responseTimestamp <= previousEndDate) AS previousSuccess,
+ countIf(r.requestTimestamp >= currentStartDate AND r.responseTimestamp <= currentEndDate) AS currentTotal,
+ countIf(r.requestTimestamp >= previousStartDate AND r.responseTimestamp <= previousEndDate) AS previousTotal,
+ if(currentTotal = 0, 0, (currentSuccess / currentTotal)) as currentValue,
+ if(previousTotal = 0, 0, (previousSuccess / previousTotal)) as previousValue
+ `;
+ break;
+ default:
+ throw new BadRequestException(`Metric ${metric} is not supported`);
+ }
+
+ const query = /*sql*/ `
+ WITH (
+ parseDateTimeBestEffort('${startDate.toISOString()}') AS currentStartDate,
+ parseDateTimeBestEffort('${endDate.toISOString()}') AS currentEndDate,
+ datediff(second, currentStartDate, currentEndDate) AS diff,
+ subtractSeconds(currentStartDate, diff) AS previousStartDate,
+ subtractSeconds(currentEndDate, diff) AS previousEndDate
+ )
+ SELECT
+ ${selectStatement}
+ FROM
+ reports r
+ WHERE
+ projectId = '${projectId}'
+ `;
+
+ try {
+ const result = await this.clickHouseService.knex.raw(query);
+ const data = result[0][0];
+
+ return {
+ currentValue: data.currentValue,
+ previousValue: data.previousValue,
+ };
+ } catch (error) {
+ console.error(error);
+ throw new InternalServerErrorException(`Failed to get metric delta`);
+ }
+ }
+}
diff --git a/apps/server/src/app/metrics/queries/average-request-duration-query.ts b/apps/server/src/app/metrics/queries/average-request-duration-query.ts
new file mode 100644
index 000000000..87b61541c
--- /dev/null
+++ b/apps/server/src/app/metrics/queries/average-request-duration-query.ts
@@ -0,0 +1,44 @@
+import { ProjectMetricHistogramBucketSize } from "../inputs/get-project-metrics.input";
+import {
+ generateBucketsSubquery,
+ timePropertiesFromBucketSize,
+} from "./queries.utils";
+import { Knex } from "knex";
+
+export const averageRequestDurationQuery = (
+ knex: Knex,
+ params: {
+ projectId: string;
+ bucketSize: ProjectMetricHistogramBucketSize;
+ startDate: Date;
+ endDate: Date;
+ }
+): Knex.QueryBuilder => {
+ const { projectId, bucketSize, startDate, endDate } = params;
+
+ const timeProps = timePropertiesFromBucketSize(bucketSize);
+ const bucketsSubquery = generateBucketsSubquery(knex, {
+ startDate,
+ endDate,
+ timeProps,
+ });
+
+ const mainquery = knex
+ .with("buckets", bucketsSubquery)
+ .select({
+ timestamp: "b.timestamp",
+ value: knex.raw(`COALESCE(AVG(r.duration), 0)`),
+ })
+ .from("buckets as b")
+ .leftJoin("reports as r", (join) =>
+ join.on(
+ knex.raw(
+ `${timeProps.roundFn}(r.requestTimestamp) = b.timestamp AND r."projectId" = '${projectId}'`
+ )
+ )
+ )
+ .groupBy("b.timestamp")
+ .orderBy("b.timestamp");
+
+ return mainquery;
+};
diff --git a/apps/server/src/app/metrics/queries/model-usage-query.ts b/apps/server/src/app/metrics/queries/model-usage-query.ts
new file mode 100644
index 000000000..61dcc7d75
--- /dev/null
+++ b/apps/server/src/app/metrics/queries/model-usage-query.ts
@@ -0,0 +1,50 @@
+import { ProjectMetricHistogramBucketSize } from "../inputs/get-project-metrics.input";
+import {
+ generateBucketsSubquery,
+ timePropertiesFromBucketSize,
+} from "./queries.utils";
+import { Knex } from "knex";
+
+export const modelUsageQuery = (
+ knex: Knex,
+ params: {
+ projectId: string;
+ bucketSize: ProjectMetricHistogramBucketSize;
+ column: string;
+ startDate: Date;
+ endDate: Date;
+ }
+): Knex.QueryBuilder => {
+ const { projectId, bucketSize, column, startDate, endDate } = params;
+
+ const timeProps = timePropertiesFromBucketSize(bucketSize);
+ const bucketsSubquery = generateBucketsSubquery(knex, {
+ startDate,
+ endDate,
+ timeProps,
+ });
+
+ const mainquery = knex
+ .select({
+ timestamp: "s.timeframe",
+ values: knex.raw(`
+ if(
+ arrayReduce('sumMap', arrayMap((model, duration) -> map(model, duration), groupArray(r.model), groupArray(r.duration))) = map('', 0),
+ NULL,
+ toJSONString(arrayReduce('sumMap', arrayMap((model, duration) -> map(model, duration), groupArray(r.model), groupArray(r.duration))))
+ )
+ `),
+ })
+ .from(bucketsSubquery)
+ .leftJoin("reports as r", (join) =>
+ join.on(
+ knex.raw(
+ `${timeProps.roundFn}(r."request.timestamp") = s.timeframe AND r."projectId" = '${projectId}'`
+ )
+ )
+ )
+ .groupBy(["s.timeframe"])
+ .orderBy(["s.timeframe"]);
+
+ return mainquery;
+};
diff --git a/apps/server/src/app/metrics/queries/queries.utils.ts b/apps/server/src/app/metrics/queries/queries.utils.ts
new file mode 100644
index 000000000..169a5f39b
--- /dev/null
+++ b/apps/server/src/app/metrics/queries/queries.utils.ts
@@ -0,0 +1,51 @@
+import { Knex } from "knex";
+import { ProjectMetricHistogramBucketSize } from "../inputs/get-project-metrics.input";
+
+interface TimeProps {
+ roundFn: string;
+ interval: string;
+}
+
+export const timePropertiesFromBucketSize = (
+ bucketSize: ProjectMetricHistogramBucketSize
+): TimeProps => {
+ switch (bucketSize) {
+ case ProjectMetricHistogramBucketSize.minutely:
+ return { roundFn: "toStartOfMinute", interval: "minute" };
+ case ProjectMetricHistogramBucketSize.hourly:
+ return { roundFn: "toStartOfHour", interval: "hour" };
+ case ProjectMetricHistogramBucketSize.daily:
+ return { roundFn: "toStartOfDay", interval: "day" };
+ case ProjectMetricHistogramBucketSize.weekly:
+ return { roundFn: "toStartOfWeek", interval: "day" };
+ case ProjectMetricHistogramBucketSize.monthly:
+ return { roundFn: "toStartOfMonth", interval: "month" };
+ }
+};
+
+export const generateBucketsSubquery = (
+ knex: Knex,
+ params: {
+ startDate: Date;
+ endDate: Date;
+ timeProps: TimeProps;
+ }
+): Knex.QueryBuilder => {
+ const { startDate, endDate, timeProps } = params;
+
+ const subquery = knex
+ .select(
+ knex.raw(
+ `${timeProps.roundFn}(parseDateTimeBestEffort(?) + INTERVAL number ${timeProps.interval}) AS timestamp`,
+ [startDate]
+ )
+ )
+ .from(
+ knex.raw(
+ `numbers(dateDiff('${timeProps.interval}', parseDateTimeBestEffort(?), parseDateTimeBestEffort(?)) + 1)`,
+ [startDate, endDate]
+ )
+ );
+
+ return subquery;
+};
diff --git a/apps/server/src/app/metrics/queries/success-error-rate-query.ts b/apps/server/src/app/metrics/queries/success-error-rate-query.ts
new file mode 100644
index 000000000..9b766584c
--- /dev/null
+++ b/apps/server/src/app/metrics/queries/success-error-rate-query.ts
@@ -0,0 +1,52 @@
+import { ProjectMetricHistogramBucketSize } from "../inputs/get-project-metrics.input";
+import {
+ generateBucketsSubquery,
+ timePropertiesFromBucketSize,
+} from "./queries.utils";
+import { Knex } from "knex";
+
+export const successErrorRateQuery = (
+ knex: Knex,
+ params: {
+ projectId: string;
+ bucketSize: ProjectMetricHistogramBucketSize;
+ startDate: Date;
+ endDate: Date;
+ }
+): Knex.QueryBuilder => {
+ const { projectId, bucketSize, startDate, endDate } = params;
+
+ const timeProps = timePropertiesFromBucketSize(bucketSize);
+ const bucketsSubquery = generateBucketsSubquery(knex, {
+ startDate,
+ endDate,
+ timeProps,
+ });
+
+ const mainquery = knex
+ .with("buckets", bucketsSubquery)
+ .select({
+ timestamp: "b.timestamp",
+ requests: knex.raw(
+ `countIf(${timeProps.roundFn}(r.requestTimestamp) = timestamp)`
+ ),
+ error: knex.raw(
+ `countIf(r.isError = true AND ${timeProps.roundFn}(r.requestTimestamp) = timestamp)`
+ ),
+ success: knex.raw(
+ `countIf(r.isError = false AND ${timeProps.roundFn}(r.requestTimestamp) = timestamp)`
+ ),
+ })
+ .from("buckets as b")
+ .leftJoin("reports as r", (join) =>
+ join.on(
+ knex.raw(
+ `${timeProps.roundFn}(r.requestTimestamp) = b.timestamp AND r."projectId" = '${projectId}'`
+ )
+ )
+ )
+ .groupBy("timestamp")
+ .orderBy("timestamp");
+
+ return mainquery;
+};
diff --git a/apps/server/src/app/notifications/notifications.module.ts b/apps/server/src/app/notifications/notifications.module.ts
new file mode 100644
index 000000000..5213ffad3
--- /dev/null
+++ b/apps/server/src/app/notifications/notifications.module.ts
@@ -0,0 +1,7 @@
+import { Module } from "@nestjs/common";
+import { NotificationsService } from "./notifications.service";
+
+@Module({
+ providers: [NotificationsService],
+})
+export class NotificationsModule {}
diff --git a/apps/server/src/app/notifications/notifications.service.ts b/apps/server/src/app/notifications/notifications.service.ts
new file mode 100644
index 000000000..cdf377910
--- /dev/null
+++ b/apps/server/src/app/notifications/notifications.service.ts
@@ -0,0 +1,39 @@
+import { Injectable } from "@nestjs/common";
+import { ConfigService } from "@nestjs/config";
+import { OnEvent } from "@nestjs/event-emitter";
+import { KafkaSchemas } from "@pezzo/kafka";
+import sgMail, { MailDataRequired } from "@sendgrid/mail";
+import { PinoLogger } from "../logger/pino-logger";
+
+export const emailTemplates: Record = {
+ "org-invitation-created": "d-a36b6b8076b040ba89aff0dd5bf11936",
+};
+
+@Injectable()
+export class NotificationsService {
+ constructor(private config: ConfigService, private logger: PinoLogger) {
+ sgMail.setApiKey(this.config.get("SENDGRID_API_KEY"));
+ }
+
+ @OnEvent("org-invitation-created")
+ async sendOrgInvitationEmail(data: KafkaSchemas["org-invitation-created"]) {
+ const templateId = emailTemplates["org-invitation-created"];
+
+ this.logger.info({ templateId, data }, "Sending org invitation email");
+
+ const mailData: MailDataRequired = {
+ to: data.email,
+ from: "Pezzo ",
+ templateId,
+ dynamicTemplateData: {
+ ...data,
+ },
+ };
+
+ try {
+ await sgMail.send(mailData);
+ } catch (error) {
+ this.logger.error(error);
+ }
+ }
+}
diff --git a/apps/server/src/app/prompt-environments/inputs/create-prompt-environment.input.ts b/apps/server/src/app/prompt-environments/inputs/create-prompt-environment.input.ts
index bbc7890a5..3314d1338 100644
--- a/apps/server/src/app/prompt-environments/inputs/create-prompt-environment.input.ts
+++ b/apps/server/src/app/prompt-environments/inputs/create-prompt-environment.input.ts
@@ -6,7 +6,7 @@ export class PublishPromptInput {
promptId: string;
@Field(() => String, { nullable: false })
- environmentSlug: string;
+ environmentId: string;
@Field(() => String, { nullable: false })
promptVersionSha: string;
diff --git a/apps/server/src/app/prompt-environments/prompt-environments.module.ts b/apps/server/src/app/prompt-environments/prompt-environments.module.ts
index 7bc2cce9f..615fdda6f 100644
--- a/apps/server/src/app/prompt-environments/prompt-environments.module.ts
+++ b/apps/server/src/app/prompt-environments/prompt-environments.module.ts
@@ -2,8 +2,10 @@ import { Module } from "@nestjs/common";
import { PrismaService } from "../prisma.service";
import { PromptEnvironmentsResolver } from "./prompt-environments.resolver";
import { PromptEnvironmentsService } from "./prompt-environments.service";
+import { IdentityModule } from "../identity/identity.module";
@Module({
+ imports: [IdentityModule],
providers: [
PrismaService,
PromptEnvironmentsResolver,
diff --git a/apps/server/src/app/prompt-environments/prompt-environments.resolver.ts b/apps/server/src/app/prompt-environments/prompt-environments.resolver.ts
index 0dc0344ae..eed81a678 100644
--- a/apps/server/src/app/prompt-environments/prompt-environments.resolver.ts
+++ b/apps/server/src/app/prompt-environments/prompt-environments.resolver.ts
@@ -2,39 +2,105 @@ import { Args, Mutation, Resolver } from "@nestjs/graphql";
import { PromptEnvironment } from "../../@generated/prompt-environment/prompt-environment.model";
import { PrismaService } from "../prisma.service";
import { PublishPromptInput } from "./inputs/create-prompt-environment.input";
-import { ConflictException } from "@nestjs/common";
+import {
+ ConflictException,
+ InternalServerErrorException,
+ NotFoundException,
+ UseGuards,
+} from "@nestjs/common";
import { PromptEnvironmentsService } from "./prompt-environments.service";
+import { EnvironmentsService } from "../identity/environments.service";
+import { AuthGuard } from "../auth/auth.guard";
+import { CurrentUser } from "../identity/current-user.decorator";
+import { RequestUser } from "../identity/users.types";
+import { isOrgMemberOrThrow } from "../identity/identity.utils";
+import { PinoLogger } from "../logger/pino-logger";
+import { Environment } from "@prisma/client";
+import { AnalyticsService } from "../analytics/analytics.service";
+@UseGuards(AuthGuard)
@Resolver()
export class PromptEnvironmentsResolver {
constructor(
private promptEnvironmentsService: PromptEnvironmentsService,
- private prisma: PrismaService
+ private environmentsService: EnvironmentsService,
+ private prisma: PrismaService,
+ private logger: PinoLogger,
+ private analytics: AnalyticsService
) {}
@Mutation(() => PromptEnvironment)
- async publishPrompt(@Args("data") data: PublishPromptInput) {
- const versionAlreadyPublished =
- await this.prisma.promptEnvironment.findFirst({
+ async publishPrompt(
+ @Args("data") data: PublishPromptInput,
+ @CurrentUser() user: RequestUser
+ ) {
+ this.logger.assign({ ...data });
+ this.logger.info("Publishing prompt to environment");
+
+ let environment: Environment;
+
+ try {
+ environment = await this.environmentsService.getById(data.environmentId);
+ } catch (error) {
+ this.logger.error({ error }, "Error getting environment");
+ throw new InternalServerErrorException();
+ }
+
+ const project = await this.prisma.project.findUnique({
+ where: {
+ id: environment.projectId,
+ },
+ });
+
+ isOrgMemberOrThrow(user, project.organizationId);
+
+ if (!environment) {
+ throw new NotFoundException(`Environment not found`);
+ }
+
+ let versionAlreadyPublished: PromptEnvironment;
+
+ try {
+ versionAlreadyPublished = await this.prisma.promptEnvironment.findFirst({
where: {
promptId: data.promptId,
- environmentSlug: data.environmentSlug,
+ environmentId: environment.id,
promptVersionSha: data.promptVersionSha,
},
});
+ } catch (error) {
+ this.logger.error({ error }, "Error checking prompt already published");
+ throw new InternalServerErrorException();
+ }
if (versionAlreadyPublished) {
throw new ConflictException(
- `Prompt version already published to environment "${data.environmentSlug}"`
+ `Prompt version is already published to this environment`
);
}
- const promptEnvironment =
- await this.promptEnvironmentsService.createPromptEnvironment(
- data.promptId,
- data.environmentSlug,
- data.promptVersionSha
- );
+ let promptEnvironment: PromptEnvironment;
+
+ try {
+ promptEnvironment =
+ await this.promptEnvironmentsService.createPromptEnvironment(
+ data.promptId,
+ environment.id,
+ data.promptVersionSha,
+ user.id
+ );
+ this.logger.info("Prompt published to environment");
+ } catch (error) {
+ this.logger.error({ error }, "Error creating prompt environment");
+ throw new InternalServerErrorException();
+ }
+
+ this.analytics.trackEvent("prompt_published", {
+ projectId: environment.projectId,
+ promptId: data.promptId,
+ environmentId: environment.id,
+ });
+
return promptEnvironment;
}
}
diff --git a/apps/server/src/app/prompt-environments/prompt-environments.service.ts b/apps/server/src/app/prompt-environments/prompt-environments.service.ts
index 5a721d0b2..f2ff32c06 100644
--- a/apps/server/src/app/prompt-environments/prompt-environments.service.ts
+++ b/apps/server/src/app/prompt-environments/prompt-environments.service.ts
@@ -7,36 +7,34 @@ export class PromptEnvironmentsService {
async createPromptEnvironment(
promptId: string,
- environmentSlug: string,
- promptVersionSha: string
+ environmentId: string,
+ promptVersionSha: string,
+ publishedByUserId: string
) {
const promptEnvironment = await this.prisma.promptEnvironment.upsert({
create: {
- id: `${environmentSlug}_${promptId}`,
+ id: `${environmentId}_${promptId}`,
promptId,
- environmentSlug,
+ environmentId,
promptVersionSha,
+ publishedById: publishedByUserId,
},
update: {
- id: `${environmentSlug}_${promptId}`,
promptId,
- environmentSlug,
+ environmentId,
promptVersionSha,
},
where: {
- id: `${environmentSlug}_${promptId}`,
+ id: `${environmentId}_${promptId}`,
},
});
return promptEnvironment;
}
- async getPromptEnvironment(promptId: string, environmentSlug: string) {
- const promptEnvironment = await this.prisma.promptEnvironment.findFirst({
- where: {
- promptId,
- environmentSlug,
- },
+ async getPromptEnvironment(promptId: string, environmentId: string) {
+ const promptEnvironment = await this.prisma.promptEnvironment.findUnique({
+ where: { id: `${environmentId}_${promptId}` },
});
return promptEnvironment;
diff --git a/apps/server/src/app/prompt-tester/prompt-tester.module.ts b/apps/server/src/app/prompt-tester/prompt-tester.module.ts
new file mode 100644
index 000000000..4a61261a0
--- /dev/null
+++ b/apps/server/src/app/prompt-tester/prompt-tester.module.ts
@@ -0,0 +1,14 @@
+import { Module } from "@nestjs/common";
+import { PromptsModule } from "../prompts/prompts.module";
+import { PromptTesterResolver } from "./prompt-tester.resolver";
+import { PromptTesterService } from "./prompt-tester.service";
+import { PrismaService } from "../prisma.service";
+import { IdentityModule } from "../identity/identity.module";
+import { ReportingModule } from "../reporting/reporting.module";
+import { CredentialsModule } from "../credentials/credentials.module";
+
+@Module({
+ imports: [ReportingModule, PromptsModule, IdentityModule, CredentialsModule],
+ providers: [PrismaService, PromptTesterResolver, PromptTesterService],
+})
+export class PromptTesterModule {}
diff --git a/apps/server/src/app/prompt-tester/prompt-tester.resolver.ts b/apps/server/src/app/prompt-tester/prompt-tester.resolver.ts
new file mode 100644
index 000000000..881f234d5
--- /dev/null
+++ b/apps/server/src/app/prompt-tester/prompt-tester.resolver.ts
@@ -0,0 +1,51 @@
+import { Args, Mutation, Resolver } from "@nestjs/graphql";
+import { UseGuards } from "@nestjs/common";
+import { AuthGuard } from "../auth/auth.guard";
+import { PrismaService } from "../prisma.service";
+import { PromptTesterService } from "./prompt-tester.service";
+import { TestPromptInput } from "../prompts/inputs/test-prompt.input";
+import { CurrentUser } from "../identity/current-user.decorator";
+import { RequestUser } from "../identity/users.types";
+import { PinoLogger } from "../logger/pino-logger";
+import { isOrgMemberOrThrow } from "../identity/identity.utils";
+import { RequestReport } from "../reporting/object-types/request-report.model";
+import { SerializedReport } from "@pezzo/types";
+import GraphQLJSON from "graphql-type-json";
+
+@UseGuards(AuthGuard)
+@Resolver(() => RequestReport)
+export class PromptTesterResolver {
+ constructor(
+ private prisma: PrismaService,
+ private promptTesterService: PromptTesterService,
+ private logger: PinoLogger
+ ) {}
+
+ @Mutation(() => GraphQLJSON)
+ async testPrompt(
+ @Args("data") data: TestPromptInput,
+ @CurrentUser() user: RequestUser
+ ): Promise {
+ this.logger
+ .assign({
+ projectId: data.projectId,
+ settings: data.settings,
+ type: data.type,
+ })
+ .info("Testing prompt");
+
+ const project = await this.prisma.project.findUnique({
+ where: { id: data.projectId },
+ });
+
+ isOrgMemberOrThrow(user, project.organizationId);
+
+ const result = await this.promptTesterService.runTest(
+ data,
+ project.id,
+ project.organizationId
+ );
+
+ return result;
+ }
+}
diff --git a/apps/server/src/app/prompt-tester/prompt-tester.service.ts b/apps/server/src/app/prompt-tester/prompt-tester.service.ts
new file mode 100644
index 000000000..384de765f
--- /dev/null
+++ b/apps/server/src/app/prompt-tester/prompt-tester.service.ts
@@ -0,0 +1,75 @@
+import { Injectable } from "@nestjs/common";
+import { TestPromptInput } from "../prompts/inputs/test-prompt.input";
+import { Pezzo, PezzoOpenAI } from "@pezzo/client";
+import { ReportingService } from "../reporting/reporting.service";
+import { ProviderApiKeysService } from "../credentials/provider-api-keys.service";
+import { SerializedReport } from "@pezzo/types";
+
+@Injectable()
+export class PromptTesterService {
+ constructor(
+ private reportingService: ReportingService,
+ private providerApiKeysService: ProviderApiKeysService
+ ) {}
+
+ async runTest(
+ testData: TestPromptInput,
+ projectId: string,
+ organizationId: string
+ ): Promise {
+ const provider = "OpenAI";
+ const providerApiKey = await this.providerApiKeysService.getByProvider(
+ provider,
+ organizationId
+ );
+
+ const testerApiKey =
+ await this.providerApiKeysService.decryptProviderApiKey(providerApiKey);
+
+ let promptExecutionData;
+
+ const mockPezzo = {
+ options: {
+ environment: "PEZZO_TESTER",
+ },
+ reportPromptExecution: (data) => (promptExecutionData = data),
+ };
+
+ const pezzoOpenAI = new PezzoOpenAI(mockPezzo as unknown as Pezzo, {
+ apiKey: testerApiKey,
+ });
+
+ const mockRequest: any = {
+ pezzo: {
+ metadata: {
+ promptId: testData.promptId,
+ promptVersionSha: "test-prompt",
+ type: "Prompt" as any,
+ isTestPrompt: true,
+ },
+ settings: testData.settings,
+ content: testData.content,
+ type: testData.type,
+ },
+ };
+
+ try {
+ await pezzoOpenAI.chat.completions.create(mockRequest, {
+ variables: testData.variables,
+ });
+ } catch (err) {
+ //
+ }
+
+ const report = await this.reportingService.saveReport(
+ promptExecutionData,
+ {
+ organizationId,
+ projectId,
+ },
+ true
+ );
+
+ return report;
+ }
+}
diff --git a/apps/server/src/app/prompts/dto/get-prompt-deployment.dto.ts b/apps/server/src/app/prompts/dto/get-prompt-deployment.dto.ts
new file mode 100644
index 000000000..5870a65ef
--- /dev/null
+++ b/apps/server/src/app/prompts/dto/get-prompt-deployment.dto.ts
@@ -0,0 +1,20 @@
+import { IsString } from "class-validator";
+import { ApiProperty } from "@nestjs/swagger";
+
+export class GetPromptDeploymentDto {
+ @ApiProperty({
+ description: "The name of the prompt (case sensitive)",
+ type: String,
+ example: "PromptName",
+ })
+ @IsString()
+ name: string;
+
+ @ApiProperty({
+ description: "The name of the environment (case sensitive)",
+ type: String,
+ example: "Production",
+ })
+ @IsString()
+ environmentName: string;
+}
diff --git a/apps/server/src/app/prompts/inputs/create-prompt-version.input.ts b/apps/server/src/app/prompts/inputs/create-prompt-version.input.ts
index 5e459cea3..baf27c3a0 100644
--- a/apps/server/src/app/prompts/inputs/create-prompt-version.input.ts
+++ b/apps/server/src/app/prompts/inputs/create-prompt-version.input.ts
@@ -1,18 +1,25 @@
import { Field, InputType } from "@nestjs/graphql";
-import { OpenAIChatSettings } from "@pezzo/common";
import GraphQLJSON from "graphql-type-json";
+import { PromptService } from "../models/prompt-version-service.enum";
+import { PromptType } from "../../../@generated/prisma/prompt-type.enum";
@InputType()
export class CreatePromptVersionInput {
@Field(() => String, { nullable: false })
- message: string;
+ promptId: string;
- @Field(() => String, { nullable: false })
- content: string;
+ @Field(() => PromptType, { nullable: false })
+ type: PromptType;
+
+ @Field(() => PromptService, { nullable: false })
+ service: PromptService;
@Field(() => String, { nullable: false })
- promptId: string;
+ message: string;
+
+ @Field(() => GraphQLJSON, { nullable: false })
+ content: any;
@Field(() => GraphQLJSON, { nullable: false })
- settings: OpenAIChatSettings;
+ settings: any;
}
diff --git a/apps/server/src/app/prompts/inputs/create-prompt.input.ts b/apps/server/src/app/prompts/inputs/create-prompt.input.ts
index c23142b74..30ec5bed4 100644
--- a/apps/server/src/app/prompts/inputs/create-prompt.input.ts
+++ b/apps/server/src/app/prompts/inputs/create-prompt.input.ts
@@ -6,5 +6,5 @@ export class CreatePromptInput {
name: string;
@Field(() => String, { nullable: false })
- integrationId: string;
+ projectId: string;
}
diff --git a/apps/server/src/app/prompts/inputs/find-prompt-by-name.input.ts b/apps/server/src/app/prompts/inputs/find-prompt-by-name.input.ts
new file mode 100644
index 000000000..415748d7c
--- /dev/null
+++ b/apps/server/src/app/prompts/inputs/find-prompt-by-name.input.ts
@@ -0,0 +1,7 @@
+import { Field, InputType } from "@nestjs/graphql";
+
+@InputType()
+export class FindPromptByNameInput {
+ @Field(() => String, { nullable: false })
+ name: string;
+}
diff --git a/apps/server/src/app/prompts/inputs/get-project-prompts.input.ts b/apps/server/src/app/prompts/inputs/get-project-prompts.input.ts
new file mode 100644
index 000000000..3e095abff
--- /dev/null
+++ b/apps/server/src/app/prompts/inputs/get-project-prompts.input.ts
@@ -0,0 +1,7 @@
+import { Field, InputType } from "@nestjs/graphql";
+
+@InputType()
+export class GetProjectPromptsInput {
+ @Field(() => String, { nullable: false })
+ projectId: string;
+}
diff --git a/apps/server/src/app/prompts/inputs/get-prompt.input.ts b/apps/server/src/app/prompts/inputs/get-prompt.input.ts
index 818671388..7fb4a24c4 100644
--- a/apps/server/src/app/prompts/inputs/get-prompt.input.ts
+++ b/apps/server/src/app/prompts/inputs/get-prompt.input.ts
@@ -4,7 +4,4 @@ import { Field, InputType } from "@nestjs/graphql";
export class GetPromptInput {
@Field(() => String, { nullable: false })
promptId: string;
-
- @Field(() => String, { nullable: true })
- version = "latest";
}
diff --git a/apps/server/src/app/prompts/inputs/resolve-deployed-version.input.ts b/apps/server/src/app/prompts/inputs/resolve-deployed-version.input.ts
new file mode 100644
index 000000000..d0350bfce
--- /dev/null
+++ b/apps/server/src/app/prompts/inputs/resolve-deployed-version.input.ts
@@ -0,0 +1,7 @@
+import { Field, InputType } from "@nestjs/graphql";
+
+@InputType()
+export class ResolveDeployedVersionInput {
+ @Field(() => String, { nullable: false })
+ apiKey: string;
+}
diff --git a/apps/server/src/app/prompts/inputs/test-prompt.input.ts b/apps/server/src/app/prompts/inputs/test-prompt.input.ts
index 2201fa9e1..be9b3c686 100644
--- a/apps/server/src/app/prompts/inputs/test-prompt.input.ts
+++ b/apps/server/src/app/prompts/inputs/test-prompt.input.ts
@@ -1,17 +1,24 @@
import { Field, InputType } from "@nestjs/graphql";
+import { PromptType } from "../../../@generated/prisma/prompt-type.enum";
import GraphQLJSON from "graphql-type-json";
@InputType()
export class TestPromptInput {
@Field(() => String, { nullable: false })
- integrationId: string;
+ promptId: string;
@Field(() => String, { nullable: false })
- content: string;
+ projectId: string;
+
+ @Field(() => GraphQLJSON, { nullable: false })
+ content: any;
+
+ @Field(() => PromptType, { nullable: false })
+ type: PromptType;
@Field(() => GraphQLJSON, { nullable: false })
settings: { model: string; modelSettings: unknown };
@Field(() => GraphQLJSON, { nullable: true })
- variables?: Record;
+ variables: Record;
}
diff --git a/apps/server/src/app/prompts/models/prompt-version-service.enum.ts b/apps/server/src/app/prompts/models/prompt-version-service.enum.ts
new file mode 100644
index 000000000..877d39e3c
--- /dev/null
+++ b/apps/server/src/app/prompts/models/prompt-version-service.enum.ts
@@ -0,0 +1,9 @@
+import { PromptService } from "@pezzo/types";
+import { registerEnumType } from "@nestjs/graphql";
+
+registerEnumType(PromptService, {
+ name: "PromptService",
+ description: undefined,
+});
+
+export { PromptService };
diff --git a/apps/server/src/app/prompts/prompt-executions.resolver.ts b/apps/server/src/app/prompts/prompt-executions.resolver.ts
deleted file mode 100644
index 1ebe39f24..000000000
--- a/apps/server/src/app/prompts/prompt-executions.resolver.ts
+++ /dev/null
@@ -1,76 +0,0 @@
-import { Args, Mutation, Query, Resolver } from "@nestjs/graphql";
-import { Prompt } from "../../@generated/prompt/prompt.model";
-import { PrismaService } from "../prisma.service";
-import { PromptExecution } from "../../@generated/prompt-execution/prompt-execution.model";
-import { PromptExecutionCreateInput } from "../../@generated/prompt-execution/prompt-execution-create.input";
-import { PromptExecutionWhereInput } from "../../@generated/prompt-execution/prompt-execution-where.input";
-import { PromptExecutionWhereUniqueInput } from "../../@generated/prompt-execution/prompt-execution-where-unique.input";
-import { PromptExecutionStatus } from "../../@generated/prisma/prompt-execution-status.enum";
-import { TestPromptInput } from "./inputs/test-prompt.input";
-import { PromptsService } from "./prompts.service";
-import { PromptTesterService } from "./prompt-tester.service";
-
-@Resolver(() => Prompt)
-export class PromptExecutionsResolver {
- constructor(
- private prisma: PrismaService,
- private readonly promptsService: PromptsService,
- private readonly promptTesterService: PromptTesterService
- ) {}
-
- @Query(() => PromptExecution)
- async promptExecution(@Args("data") data: PromptExecutionWhereUniqueInput) {
- const prompt = await this.prisma.promptExecution.findUnique({
- where: data,
- });
- return prompt;
- }
-
- @Query(() => [PromptExecution])
- async promptExecutions(@Args("data") data: PromptExecutionWhereInput) {
- const executions = await this.prisma.promptExecution.findMany({
- where: data,
- orderBy: {
- timestamp: "desc",
- },
- });
- return executions;
- }
-
- @Mutation(() => PromptExecution)
- async reportPromptExecution(@Args("data") data: PromptExecutionCreateInput) {
- const execution = await this.prisma.promptExecution.create({
- data,
- });
- return execution;
- }
-
- @Mutation(() => PromptExecution)
- async testPrompt(@Args("data") data: TestPromptInput) {
- const result = await this.promptTesterService.testPrompt(data);
-
- const execution = new PromptExecution();
- execution.id = "test";
- execution.prompt = null;
- execution.promptId = "test";
- execution.timestamp = new Date();
- execution.status = result.success
- ? PromptExecutionStatus.Success
- : PromptExecutionStatus.Error;
- execution.content = result.content;
- execution.interpolatedContent = result.interpolatedContent;
- execution.settings = result.settings;
- execution.result = result.result;
- execution.duration = result.duration;
- execution.promptTokens = result.promptTokens;
- execution.completionTokens = result.completionTokens;
- execution.totalTokens = result.totalTokens;
- execution.promptCost = result.promptCost;
- execution.completionCost = result.completionCost;
- execution.totalCost = result.totalCost;
- execution.error = result.error;
- execution.variables = result.variables;
-
- return execution;
- }
-}
diff --git a/apps/server/src/app/prompts/prompt-tester.service.ts b/apps/server/src/app/prompts/prompt-tester.service.ts
deleted file mode 100644
index 0aa9d4e80..000000000
--- a/apps/server/src/app/prompts/prompt-tester.service.ts
+++ /dev/null
@@ -1,89 +0,0 @@
-import { Injectable } from "@nestjs/common";
-import { TestPromptInput } from "./inputs/test-prompt.input";
-import { getIntegration } from "@pezzo/integrations";
-import { Executor as OpenAIExecutor } from "@pezzo/integrations/lib/integrations/openai/Executor";
-import { Executor as AI21Executor } from "@pezzo/integrations/lib/integrations/ai21/Executor";
-import { Pezzo, TestPromptResult } from "@pezzo/client";
-import { interpolateVariables } from "@pezzo/common";
-import { ExecuteResult } from "@pezzo/integrations/lib/integrations/BaseExecutor";
-import { ProviderAPIKeysService } from "../credentials/provider-api-keys.service";
-
-const AI21_API_KEY = process.env.AI21_API_KEY;
-const OPENAI_API_KEY = process.env.OPENAI_API_KEY;
-
-@Injectable()
-export class PromptTesterService {
- constructor(private providerAPIKeysService: ProviderAPIKeysService) {}
-
- private async getExecutor(integrationId: string) {
- let executor;
-
- const { provider } = getIntegration(integrationId);
- const apiKey = await this.providerAPIKeysService.getByProvider(provider);
-
- if (integrationId === "openai") {
- executor = new OpenAIExecutor({} as Pezzo, { apiKey: apiKey.value });
- }
- if (integrationId === "ai21") {
- executor = new AI21Executor({} as Pezzo, { apiKey: apiKey.value });
- }
-
- return executor;
- }
-
- async testPrompt(input: TestPromptInput): Promise {
- const { integrationId, content, variables } = input;
- const interpolatedContent = interpolateVariables(content, variables);
-
- const executor = await this.getExecutor(integrationId);
- const settings = input.settings;
- let start: number, end: number;
- let result: ExecuteResult