Compare commits

..

5 Commits

Author SHA1 Message Date
David Corbitt
4e176088e9 Auto-resize project menu width 2023-08-10 22:46:01 -07:00
David Corbitt
3cec1f7786 Merge branch 'main' into logged-calls 2023-08-10 22:19:16 -07:00
David Corbitt
b3d8f96fa8 Merge branch 'main' into logged-calls 2023-08-10 21:49:31 -07:00
David Corbitt
54d97ddfa8 Add getUsage function 2023-08-10 19:51:36 -07:00
David Corbitt
1f8e3b820f Rename prompt and completion tokens to input and output tokens 2023-08-10 19:49:18 -07:00
239 changed files with 5414 additions and 11525 deletions

View File

@@ -1,5 +0,0 @@
**/node_modules/
.git
**/.venv/
**/.env*
**/.next/

6
.gitignore vendored
View File

@@ -1,6 +0,0 @@
.env
.venv/
*.pyc
node_modules/
*.tsbuildinfo
dist/

View File

@@ -1,2 +0,0 @@
*.schema.json
app/pnpm-lock.yaml

View File

@@ -1,8 +1,10 @@
<!-- <img src="https://github.com/openpipe/openpipe/assets/41524992/ca59596e-eb80-40f9-921f-6d67f6e6d8fa" width="72px" /> -->
# OpenPipe # OpenPipe
OpenPipe is a flexible playground for comparing and optimizing LLM prompts. It lets you quickly generate, test and compare candidate prompts, and can automatically [translate](#-translate-between-model-apis) those prompts between models. OpenPipe is a flexible playground for comparing and optimizing LLM prompts. It lets you quickly generate, test and compare candidate prompts, and can automatically [translate](#-translate-between-model-apis) those prompts between models.
<img src="https://github.com/openpipe/openpipe/assets/41524992/66bb1843-cb72-4130-a369-eec2df3b8201" alt="demo"> <img src="https://github.com/openpipe/openpipe/assets/41524992/219a844e-3f4e-4f6b-8066-41348b42977b" alt="demo">
You can use our hosted version of OpenPipe at https://openpipe.ai. You can also clone this repository and [run it locally](#running-locally). You can use our hosted version of OpenPipe at https://openpipe.ai. You can also clone this repository and [run it locally](#running-locally).
@@ -35,19 +37,25 @@ OpenPipe lets you _template_ a prompt. Use the templating feature to run the pro
Write your prompt in one format and automatically convert it to work with any other model. Write your prompt in one format and automatically convert it to work with any other model.
<!-- <img width="480" alt="Screenshot 2023-08-01 at 11 55 38 PM" src="https://github.com/OpenPipe/OpenPipe/assets/41524992/1e19ccf2-96b6-4e93-a3a5-1449710d1b5b" alt="translate between models"> --> <img width="480" alt="Screenshot 2023-08-01 at 11 55 38 PM" src="https://github.com/OpenPipe/OpenPipe/assets/41524992/1e19ccf2-96b6-4e93-a3a5-1449710d1b5b" alt="translate between models">
<br><br>
### 🛠️ Refine Your Prompts Automatically ### 🛠️ Refine Your Prompts Automatically
Use a growing database of best-practice refinements to improve your prompts automatically. Use a growing database of best-practice refinements to improve your prompts automatically.
<!-- <img width="480" alt="Screenshot 2023-08-01 at 11 55 38 PM" src="https://github.com/OpenPipe/OpenPipe/assets/41524992/87a27fe7-daef-445c-a5e2-1c82b23f9f99" alt="add function call"> --> <img width="480" alt="Screenshot 2023-08-01 at 11 55 38 PM" src="https://github.com/OpenPipe/OpenPipe/assets/41524992/87a27fe7-daef-445c-a5e2-1c82b23f9f99" alt="add function call">
<br><br>
### 🪄 Auto-generate Test Scenarios ### 🪄 Auto-generate Test Scenarios
OpenPipe includes a tool to generate new test scenarios based on your existing prompts and scenarios. Just click "Autogenerate Scenario" to try it out! OpenPipe includes a tool to generate new test scenarios based on your existing prompts and scenarios. Just click "Autogenerate Scenario" to try it out!
<!-- <img width="600" src="https://github.com/openpipe/openpipe/assets/41524992/219a844e-3f4e-4f6b-8066-41348b42977b" alt="auto-generate"> --> <img width="600" src="https://github.com/openpipe/openpipe/assets/41524992/219a844e-3f4e-4f6b-8066-41348b42977b" alt="auto-generate">
<br><br>
## Running Locally ## Running Locally

View File

@@ -32,11 +32,5 @@ NEXT_PUBLIC_HOST="http://localhost:3000"
GITHUB_CLIENT_ID="your_client_id" GITHUB_CLIENT_ID="your_client_id"
GITHUB_CLIENT_SECRET="your_secret" GITHUB_CLIENT_SECRET="your_secret"
OPENPIPE_BASE_URL="http://localhost:3000/api/v1" OPENPIPE_BASE_URL="http://localhost:3000/api"
OPENPIPE_API_KEY="your_key" OPENPIPE_API_KEY="your_key"
SENDER_EMAIL="placeholder"
SMTP_HOST="placeholder"
SMTP_PORT="placeholder"
SMTP_LOGIN="placeholder"
SMTP_PASSWORD="placeholder"

View File

@@ -6,7 +6,7 @@ const config = {
overrides: [ overrides: [
{ {
extends: ["plugin:@typescript-eslint/recommended-requiring-type-checking"], extends: ["plugin:@typescript-eslint/recommended-requiring-type-checking"],
files: ["*.mts", "*.ts", "*.tsx"], files: ["*.ts", "*.tsx"],
parserOptions: { parserOptions: {
project: path.join(__dirname, "tsconfig.json"), project: path.join(__dirname, "tsconfig.json"),
}, },

3
app/.gitignore vendored
View File

@@ -44,6 +44,3 @@ yarn-error.log*
# Sentry Auth Token # Sentry Auth Token
.sentryclirc .sentryclirc
# custom openai intialization
src/server/utils/openaiCustomConfig.json

2
app/.prettierignore Normal file
View File

@@ -0,0 +1,2 @@
*.schema.json
pnpm-lock.yaml

View File

@@ -12,20 +12,19 @@ declare module "nextjs-routes" {
export type Route = export type Route =
| StaticRoute<"/account/signin"> | StaticRoute<"/account/signin">
| StaticRoute<"/admin/jobs"> | DynamicRoute<"/api/[...trpc]", { "trpc": string[] }>
| DynamicRoute<"/api/auth/[...nextauth]", { "nextauth": string[] }> | DynamicRoute<"/api/auth/[...nextauth]", { "nextauth": string[] }>
| StaticRoute<"/api/experiments/og-image"> | StaticRoute<"/api/experiments/og-image">
| StaticRoute<"/api/openapi">
| StaticRoute<"/api/sentry-example-api">
| DynamicRoute<"/api/trpc/[trpc]", { "trpc": string }> | DynamicRoute<"/api/trpc/[trpc]", { "trpc": string }>
| DynamicRoute<"/api/v1/[...trpc]", { "trpc": string[] }> | DynamicRoute<"/data/[id]", { "id": string }>
| StaticRoute<"/api/v1/openapi"> | StaticRoute<"/data">
| StaticRoute<"/dashboard"> | DynamicRoute<"/experiments/[id]", { "id": string }>
| DynamicRoute<"/experiments/[experimentSlug]", { "experimentSlug": string }>
| StaticRoute<"/experiments"> | StaticRoute<"/experiments">
| StaticRoute<"/fine-tunes">
| StaticRoute<"/"> | StaticRoute<"/">
| DynamicRoute<"/invitations/[invitationToken]", { "invitationToken": string }> | StaticRoute<"/logged-calls">
| StaticRoute<"/project/settings"> | StaticRoute<"/project/settings">
| StaticRoute<"/request-logs">
| StaticRoute<"/sentry-example-page"> | StaticRoute<"/sentry-example-page">
| StaticRoute<"/world-champs"> | StaticRoute<"/world-champs">
| StaticRoute<"/world-champs/signup">; | StaticRoute<"/world-champs/signup">;

View File

@@ -6,13 +6,13 @@ RUN yarn global add pnpm
# DEPS # DEPS
FROM base as deps FROM base as deps
WORKDIR /code WORKDIR /app
COPY app/prisma app/package.json ./app/ COPY prisma ./
COPY client-libs/typescript/package.json ./client-libs/typescript/
COPY pnpm-lock.yaml pnpm-workspace.yaml ./
RUN cd app && pnpm install --frozen-lockfile COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile
# BUILDER # BUILDER
FROM base as builder FROM base as builder
@@ -23,26 +23,24 @@ ARG NEXT_PUBLIC_SOCKET_URL
ARG NEXT_PUBLIC_HOST ARG NEXT_PUBLIC_HOST
ARG NEXT_PUBLIC_SENTRY_DSN ARG NEXT_PUBLIC_SENTRY_DSN
ARG SENTRY_AUTH_TOKEN ARG SENTRY_AUTH_TOKEN
ARG NEXT_PUBLIC_FF_SHOW_BETA_FEATURES ARG NEXT_PUBLIC_FF_SHOW_LOGGED_CALLS
WORKDIR /code WORKDIR /app
COPY --from=deps /code/node_modules ./node_modules COPY --from=deps /app/node_modules ./node_modules
COPY --from=deps /code/app/node_modules ./app/node_modules
COPY --from=deps /code/client-libs/typescript/node_modules ./client-libs/typescript/node_modules
COPY . . COPY . .
RUN cd app && SKIP_ENV_VALIDATION=1 pnpm build RUN SKIP_ENV_VALIDATION=1 pnpm build
# RUNNER # RUNNER
FROM base as runner FROM base as runner
WORKDIR /code/app WORKDIR /app
ENV NODE_ENV production ENV NODE_ENV production
ENV NEXT_TELEMETRY_DISABLED 1 ENV NEXT_TELEMETRY_DISABLED 1
COPY --from=builder /code/ /code/ COPY --from=builder /app/ ./
EXPOSE 3000 EXPOSE 3000
ENV PORT 3000 ENV PORT 3000
# Run the "run-prod.sh" script # Run the "run-prod.sh" script
CMD /code/app/scripts/run-prod.sh CMD /app/run-prod.sh

View File

@@ -36,8 +36,6 @@ let config = {
}); });
return config; return config;
}, },
transpilePackages: ["openpipe"],
}; };
config = nextRoutes()(config); config = nextRoutes()(config);

View File

@@ -1,6 +1,5 @@
{ {
"name": "openpipe-app", "name": "openpipe",
"private": true,
"type": "module", "type": "module",
"version": "0.1.0", "version": "0.1.0",
"license": "Apache-2.0", "license": "Apache-2.0",
@@ -10,15 +9,14 @@
}, },
"scripts": { "scripts": {
"build": "next build", "build": "next build",
"dev:next": "TZ=UTC next dev", "dev:next": "next dev",
"dev:wss": "pnpm tsx --watch src/wss-server.ts", "dev:wss": "pnpm tsx --watch src/wss-server.ts",
"worker": "NODE_ENV='development' pnpm tsx --watch src/server/tasks/worker.ts", "dev:worker": "NODE_ENV='development' pnpm tsx --watch src/server/tasks/worker.ts",
"dev": "concurrently --kill-others 'pnpm dev:next' 'pnpm dev:wss' 'pnpm worker --watch'", "dev": "concurrently --kill-others 'pnpm dev:next' 'pnpm dev:wss' 'pnpm dev:worker'",
"postinstall": "prisma generate", "postinstall": "prisma generate",
"lint": "next lint", "lint": "next lint",
"start": "TZ=UTC next start", "start": "next start",
"codegen:clients": "tsx src/server/scripts/client-codegen.ts", "codegen": "tsx src/server/scripts/client-codegen.ts",
"codegen:db": "prisma generate && kysely-codegen --dialect postgres --out-file src/server/db.types.ts",
"seed": "tsx prisma/seed.ts", "seed": "tsx prisma/seed.ts",
"check": "concurrently 'pnpm lint' 'pnpm tsc' 'pnpm prettier . --check'", "check": "concurrently 'pnpm lint' 'pnpm tsc' 'pnpm prettier . --check'",
"test": "pnpm vitest" "test": "pnpm vitest"
@@ -26,6 +24,7 @@
"dependencies": { "dependencies": {
"@anthropic-ai/sdk": "^0.5.8", "@anthropic-ai/sdk": "^0.5.8",
"@apidevtools/json-schema-ref-parser": "^10.1.0", "@apidevtools/json-schema-ref-parser": "^10.1.0",
"@babel/preset-typescript": "^7.22.5",
"@babel/standalone": "^7.22.9", "@babel/standalone": "^7.22.9",
"@chakra-ui/anatomy": "^2.2.0", "@chakra-ui/anatomy": "^2.2.0",
"@chakra-ui/next-js": "^2.1.4", "@chakra-ui/next-js": "^2.1.4",
@@ -38,7 +37,6 @@
"@monaco-editor/loader": "^1.3.3", "@monaco-editor/loader": "^1.3.3",
"@next-auth/prisma-adapter": "^1.0.5", "@next-auth/prisma-adapter": "^1.0.5",
"@prisma/client": "^4.14.0", "@prisma/client": "^4.14.0",
"@sendinblue/client": "^3.3.1",
"@sentry/nextjs": "^7.61.0", "@sentry/nextjs": "^7.61.0",
"@t3-oss/env-nextjs": "^0.3.1", "@t3-oss/env-nextjs": "^0.3.1",
"@tabler/icons-react": "^2.22.0", "@tabler/icons-react": "^2.22.0",
@@ -60,25 +58,20 @@
"framer-motion": "^10.12.17", "framer-motion": "^10.12.17",
"gpt-tokens": "^1.0.10", "gpt-tokens": "^1.0.10",
"graphile-worker": "^0.13.0", "graphile-worker": "^0.13.0",
"human-id": "^4.0.0",
"immer": "^10.0.2", "immer": "^10.0.2",
"isolated-vm": "^4.5.0", "isolated-vm": "^4.5.0",
"json-schema-to-typescript": "^13.0.2", "json-schema-to-typescript": "^13.0.2",
"json-stringify-pretty-compact": "^4.0.0", "json-stringify-pretty-compact": "^4.0.0",
"jsonschema": "^1.4.1", "jsonschema": "^1.4.1",
"kysely": "^0.26.1", "kysely": "^0.26.1",
"kysely-codegen": "^0.10.1",
"lodash-es": "^4.17.21", "lodash-es": "^4.17.21",
"lucide-react": "^0.265.0", "lucide-react": "^0.265.0",
"marked": "^7.0.3",
"next": "^13.4.2", "next": "^13.4.2",
"next-auth": "^4.22.1", "next-auth": "^4.22.1",
"next-query-params": "^4.2.3", "next-query-params": "^4.2.3",
"nextjs-cors": "^2.1.2", "nextjs-cors": "^2.1.2",
"nextjs-routes": "^2.0.1", "nextjs-routes": "^2.0.1",
"nodemailer": "^6.9.4",
"openai": "4.0.0-beta.7", "openai": "4.0.0-beta.7",
"openpipe": "workspace:*",
"pg": "^8.11.2", "pg": "^8.11.2",
"pluralize": "^8.0.0", "pluralize": "^8.0.0",
"posthog-js": "^1.75.3", "posthog-js": "^1.75.3",
@@ -120,7 +113,6 @@
"@types/json-schema": "^7.0.12", "@types/json-schema": "^7.0.12",
"@types/lodash-es": "^4.17.8", "@types/lodash-es": "^4.17.8",
"@types/node": "^18.16.0", "@types/node": "^18.16.0",
"@types/nodemailer": "^6.4.9",
"@types/pg": "^8.10.2", "@types/pg": "^8.10.2",
"@types/pluralize": "^0.0.30", "@types/pluralize": "^0.0.30",
"@types/prismjs": "^1.26.0", "@types/prismjs": "^1.26.0",
@@ -136,7 +128,6 @@
"eslint-plugin-unused-imports": "^2.0.0", "eslint-plugin-unused-imports": "^2.0.0",
"monaco-editor": "^0.40.0", "monaco-editor": "^0.40.0",
"openapi-typescript": "^6.3.4", "openapi-typescript": "^6.3.4",
"openapi-typescript-codegen": "^0.25.0",
"prisma": "^4.14.0", "prisma": "^4.14.0",
"raw-loader": "^4.0.2", "raw-loader": "^4.0.2",
"typescript": "^5.0.4", "typescript": "^5.0.4",

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,15 @@
/*
Warnings:
- You are about to rename the column `completionTokens` to `outputTokens` on the `ModelResponse` table.
- You are about to rename the column `promptTokens` to `inputTokens` on the `ModelResponse` table.
*/
-- Rename completionTokens to outputTokens
ALTER TABLE "ModelResponse"
RENAME COLUMN "completionTokens" TO "outputTokens";
-- Rename promptTokens to inputTokens
ALTER TABLE "ModelResponse"
RENAME COLUMN "promptTokens" TO "inputTokens";

View File

@@ -1,66 +0,0 @@
/*
Warnings:
- You are about to rename the column `completionTokens` to `outputTokens` on the `ModelResponse` table.
- You are about to rename the column `promptTokens` to `inputTokens` on the `ModelResponse` table.
- You are about to rename the column `startTime` on the `LoggedCall` table to `requestedAt`. Ensure compatibility with application logic.
- You are about to rename the column `startTime` on the `LoggedCallModelResponse` table to `requestedAt`. Ensure compatibility with application logic.
- You are about to rename the column `endTime` on the `LoggedCallModelResponse` table to `receivedAt`. Ensure compatibility with application logic.
- You are about to rename the column `error` on the `LoggedCallModelResponse` table to `errorMessage`. Ensure compatibility with application logic.
- You are about to rename the column `respStatus` on the `LoggedCallModelResponse` table to `statusCode`. Ensure compatibility with application logic.
- You are about to rename the column `totalCost` on the `LoggedCallModelResponse` table to `cost`. Ensure compatibility with application logic.
- You are about to rename the column `inputHash` on the `ModelResponse` table to `cacheKey`. Ensure compatibility with application logic.
- You are about to rename the column `output` on the `ModelResponse` table to `respPayload`. Ensure compatibility with application logic.
*/
-- DropIndex
DROP INDEX "LoggedCall_startTime_idx";
-- DropIndex
DROP INDEX "ModelResponse_inputHash_idx";
-- Rename completionTokens to outputTokens
ALTER TABLE "ModelResponse"
RENAME COLUMN "completionTokens" TO "outputTokens";
-- Rename promptTokens to inputTokens
ALTER TABLE "ModelResponse"
RENAME COLUMN "promptTokens" TO "inputTokens";
-- AlterTable
ALTER TABLE "LoggedCall"
RENAME COLUMN "startTime" TO "requestedAt";
-- AlterTable
ALTER TABLE "LoggedCallModelResponse"
RENAME COLUMN "startTime" TO "requestedAt";
-- AlterTable
ALTER TABLE "LoggedCallModelResponse"
RENAME COLUMN "endTime" TO "receivedAt";
-- AlterTable
ALTER TABLE "LoggedCallModelResponse"
RENAME COLUMN "error" TO "errorMessage";
-- AlterTable
ALTER TABLE "LoggedCallModelResponse"
RENAME COLUMN "respStatus" TO "statusCode";
-- AlterTable
ALTER TABLE "LoggedCallModelResponse"
RENAME COLUMN "totalCost" TO "cost";
-- AlterTable
ALTER TABLE "ModelResponse"
RENAME COLUMN "inputHash" TO "cacheKey";
-- AlterTable
ALTER TABLE "ModelResponse"
RENAME COLUMN "output" TO "respPayload";
-- CreateIndex
CREATE INDEX "LoggedCall_requestedAt_idx" ON "LoggedCall"("requestedAt");
-- CreateIndex
CREATE INDEX "ModelResponse_cacheKey_idx" ON "ModelResponse"("cacheKey");

View File

@@ -1,2 +0,0 @@
-- AlterTable
ALTER TABLE "LoggedCall" ADD COLUMN "model" TEXT;

View File

@@ -1,22 +0,0 @@
-- DropIndex
DROP INDEX "LoggedCallTag_name_idx";
DROP INDEX "LoggedCallTag_name_value_idx";
-- AlterTable: Add projectId column without NOT NULL constraint for now
ALTER TABLE "LoggedCallTag" ADD COLUMN "projectId" UUID;
-- Set the default value
UPDATE "LoggedCallTag" lct
SET "projectId" = lc."projectId"
FROM "LoggedCall" lc
WHERE lct."loggedCallId" = lc.id;
-- Now set the NOT NULL constraint
ALTER TABLE "LoggedCallTag" ALTER COLUMN "projectId" SET NOT NULL;
-- CreateIndex
CREATE INDEX "LoggedCallTag_projectId_name_idx" ON "LoggedCallTag"("projectId", "name");
CREATE INDEX "LoggedCallTag_projectId_name_value_idx" ON "LoggedCallTag"("projectId", "name", "value");
-- CreateIndex
CREATE UNIQUE INDEX "LoggedCallTag_loggedCallId_name_key" ON "LoggedCallTag"("loggedCallId", "name");

View File

@@ -1,25 +0,0 @@
-- CreateTable
CREATE TABLE "UserInvitation" (
"id" UUID NOT NULL,
"projectId" UUID NOT NULL,
"email" TEXT NOT NULL,
"role" "ProjectUserRole" NOT NULL,
"invitationToken" TEXT NOT NULL,
"senderId" UUID NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "UserInvitation_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "UserInvitation_invitationToken_key" ON "UserInvitation"("invitationToken");
-- CreateIndex
CREATE UNIQUE INDEX "UserInvitation_projectId_email_key" ON "UserInvitation"("projectId", "email");
-- AddForeignKey
ALTER TABLE "UserInvitation" ADD CONSTRAINT "UserInvitation_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "UserInvitation" ADD CONSTRAINT "UserInvitation_senderId_fkey" FOREIGN KEY ("senderId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -1,88 +0,0 @@
/*
* Copyright 2023 Viascom Ltd liab. Co
*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
CREATE EXTENSION IF NOT EXISTS pgcrypto;
CREATE OR REPLACE FUNCTION nanoid(
size int DEFAULT 21,
alphabet text DEFAULT '_-0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'
)
RETURNS text
LANGUAGE plpgsql
volatile
AS
$$
DECLARE
idBuilder text := '';
counter int := 0;
bytes bytea;
alphabetIndex int;
alphabetArray text[];
alphabetLength int;
mask int;
step int;
BEGIN
alphabetArray := regexp_split_to_array(alphabet, '');
alphabetLength := array_length(alphabetArray, 1);
mask := (2 << cast(floor(log(alphabetLength - 1) / log(2)) as int)) - 1;
step := cast(ceil(1.6 * mask * size / alphabetLength) AS int);
while true
loop
bytes := gen_random_bytes(step);
while counter < step
loop
alphabetIndex := (get_byte(bytes, counter) & mask) + 1;
if alphabetIndex <= alphabetLength then
idBuilder := idBuilder || alphabetArray[alphabetIndex];
if length(idBuilder) = size then
return idBuilder;
end if;
end if;
counter := counter + 1;
end loop;
counter := 0;
end loop;
END
$$;
-- Make a short_nanoid function that uses the default alphabet and length of 15
CREATE OR REPLACE FUNCTION short_nanoid()
RETURNS text
LANGUAGE plpgsql
volatile
AS
$$
BEGIN
RETURN nanoid(15, '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ');
END
$$;
-- AlterTable
ALTER TABLE "Experiment" ADD COLUMN "slug" TEXT NOT NULL DEFAULT short_nanoid();
-- For existing experiments, keep the existing id as the slug for backwards compatibility
UPDATE "Experiment" SET "slug" = "id";
-- CreateIndex
CREATE UNIQUE INDEX "Experiment_slug_key" ON "Experiment"("slug");

View File

@@ -1,48 +0,0 @@
/*
Warnings:
- You are about to drop the column `input` on the `DatasetEntry` table. All the data in the column will be lost.
- You are about to drop the column `output` on the `DatasetEntry` table. All the data in the column will be lost.
- Added the required column `loggedCallId` to the `DatasetEntry` table without a default value. This is not possible if the table is not empty.
*/
-- AlterTable
ALTER TABLE "DatasetEntry" DROP COLUMN "input",
DROP COLUMN "output",
ADD COLUMN "loggedCallId" UUID NOT NULL;
-- AddForeignKey
ALTER TABLE "DatasetEntry" ADD CONSTRAINT "DatasetEntry_loggedCallId_fkey" FOREIGN KEY ("loggedCallId") REFERENCES "LoggedCall"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AlterTable
ALTER TABLE "LoggedCallModelResponse" ALTER COLUMN "cost" SET DATA TYPE DOUBLE PRECISION;
-- CreateEnum
CREATE TYPE "FineTuneStatus" AS ENUM ('PENDING', 'TRAINING', 'AWAITING_DEPLOYMENT', 'DEPLOYING', 'DEPLOYED', 'ERROR');
-- CreateTable
CREATE TABLE "FineTune" (
"id" UUID NOT NULL,
"slug" TEXT NOT NULL,
"baseModel" TEXT NOT NULL,
"status" "FineTuneStatus" NOT NULL DEFAULT 'PENDING',
"trainingStartedAt" TIMESTAMP(3),
"trainingFinishedAt" TIMESTAMP(3),
"deploymentStartedAt" TIMESTAMP(3),
"deploymentFinishedAt" TIMESTAMP(3),
"datasetId" UUID NOT NULL,
"projectId" UUID NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "FineTune_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "FineTune_slug_key" ON "FineTune"("slug");
-- AddForeignKey
ALTER TABLE "FineTune" ADD CONSTRAINT "FineTune_datasetId_fkey" FOREIGN KEY ("datasetId") REFERENCES "Dataset"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "FineTune" ADD CONSTRAINT "FineTune_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -11,9 +11,7 @@ datasource db {
} }
model Experiment { model Experiment {
id String @id @default(uuid()) @db.Uuid id String @id @default(uuid()) @db.Uuid
slug String @unique @default(dbgenerated("short_nanoid()"))
label String label String
sortIndex Int @default(0) sortIndex Int @default(0)
@@ -114,17 +112,17 @@ model ScenarioVariantCell {
model ModelResponse { model ModelResponse {
id String @id @default(uuid()) @db.Uuid id String @id @default(uuid()) @db.Uuid
cacheKey String inputHash String
requestedAt DateTime? requestedAt DateTime?
receivedAt DateTime? receivedAt DateTime?
respPayload Json? output Json?
cost Float? cost Float?
inputTokens Int? inputTokens Int?
outputTokens Int? outputTokens Int?
statusCode Int? statusCode Int?
errorMessage String? errorMessage String?
retryTime DateTime? retryTime DateTime?
outdated Boolean @default(false) outdated Boolean @default(false)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@ -133,7 +131,7 @@ model ModelResponse {
scenarioVariantCell ScenarioVariantCell @relation(fields: [scenarioVariantCellId], references: [id], onDelete: Cascade) scenarioVariantCell ScenarioVariantCell @relation(fields: [scenarioVariantCellId], references: [id], onDelete: Cascade)
outputEvaluations OutputEvaluation[] outputEvaluations OutputEvaluation[]
@@index([cacheKey]) @@index([inputHash])
} }
enum EvalType { enum EvalType {
@@ -181,7 +179,6 @@ model Dataset {
name String name String
datasetEntries DatasetEntry[] datasetEntries DatasetEntry[]
fineTunes FineTune[]
projectId String @db.Uuid projectId String @db.Uuid
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
@@ -193,8 +190,8 @@ model Dataset {
model DatasetEntry { model DatasetEntry {
id String @id @default(uuid()) @db.Uuid id String @id @default(uuid()) @db.Uuid
loggedCallId String @db.Uuid input String
loggedCall LoggedCall @relation(fields: [loggedCallId], references: [id], onDelete: Cascade) output String?
datasetId String @db.Uuid datasetId String @db.Uuid
dataset Dataset? @relation(fields: [datasetId], references: [id], onDelete: Cascade) dataset Dataset? @relation(fields: [datasetId], references: [id], onDelete: Cascade)
@@ -210,15 +207,13 @@ model Project {
personalProjectUserId String? @unique @db.Uuid personalProjectUserId String? @unique @db.Uuid
personalProjectUser User? @relation(fields: [personalProjectUserId], references: [id], onDelete: Cascade) personalProjectUser User? @relation(fields: [personalProjectUserId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
projectUsers ProjectUser[] projectUsers ProjectUser[]
projectUserInvitations UserInvitation[] experiments Experiment[]
experiments Experiment[] datasets Dataset[]
datasets Dataset[] loggedCalls LoggedCall[]
loggedCalls LoggedCall[] apiKeys ApiKey[]
fineTunes FineTune[]
apiKeys ApiKey[]
} }
enum ProjectUserRole { enum ProjectUserRole {
@@ -261,7 +256,7 @@ model WorldChampEntrant {
model LoggedCall { model LoggedCall {
id String @id @default(uuid()) @db.Uuid id String @id @default(uuid()) @db.Uuid
requestedAt DateTime startTime DateTime
// True if this call was served from the cache, false otherwise // True if this call was served from the cache, false otherwise
cacheHit Boolean cacheHit Boolean
@@ -278,14 +273,12 @@ model LoggedCall {
projectId String @db.Uuid projectId String @db.Uuid
project Project? @relation(fields: [projectId], references: [id], onDelete: Cascade) project Project? @relation(fields: [projectId], references: [id], onDelete: Cascade)
model String? tags LoggedCallTag[]
tags LoggedCallTag[]
datasetEntries DatasetEntry[]
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@index([requestedAt]) @@index([startTime])
} }
model LoggedCallModelResponse { model LoggedCallModelResponse {
@@ -294,14 +287,14 @@ model LoggedCallModelResponse {
reqPayload Json reqPayload Json
// The HTTP status returned by the model provider // The HTTP status returned by the model provider
statusCode Int? respStatus Int?
respPayload Json? respPayload Json?
// Should be null if the request was successful, and some string if the request failed. // Should be null if the request was successful, and some string if the request failed.
errorMessage String? error String?
requestedAt DateTime startTime DateTime
receivedAt DateTime endTime DateTime
// Note: the function to calculate the cacheKey should include the project // Note: the function to calculate the cacheKey should include the project
// ID so we don't share cached responses between projects, which could be an // ID so we don't share cached responses between projects, which could be an
@@ -315,7 +308,7 @@ model LoggedCallModelResponse {
outputTokens Int? outputTokens Int?
finishReason String? finishReason String?
completionId String? completionId String?
cost Float? totalCost Decimal? @db.Decimal(18, 12)
// The LoggedCall that created this LoggedCallModelResponse // The LoggedCall that created this LoggedCallModelResponse
originalLoggedCallId String @unique @db.Uuid originalLoggedCallId String @unique @db.Uuid
@@ -329,17 +322,15 @@ model LoggedCallModelResponse {
} }
model LoggedCallTag { model LoggedCallTag {
id String @id @default(uuid()) @db.Uuid id String @id @default(uuid()) @db.Uuid
name String name String
value String? value String?
projectId String @db.Uuid
loggedCallId String @db.Uuid loggedCallId String @db.Uuid
loggedCall LoggedCall @relation(fields: [loggedCallId], references: [id], onDelete: Cascade) loggedCall LoggedCall @relation(fields: [loggedCallId], references: [id], onDelete: Cascade)
@@unique([loggedCallId, name]) @@index([name])
@@index([projectId, name]) @@index([name, value])
@@index([projectId, name, value])
} }
model ApiKey { model ApiKey {
@@ -348,8 +339,8 @@ model ApiKey {
name String name String
apiKey String @unique apiKey String @unique
projectId String @db.Uuid projectId String @db.Uuid
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) project Project? @relation(fields: [projectId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@ -396,33 +387,16 @@ model User {
role UserRole @default(USER) role UserRole @default(USER)
accounts Account[] accounts Account[]
sessions Session[] sessions Session[]
projectUsers ProjectUser[] projectUsers ProjectUser[]
projects Project[] projects Project[]
worldChampEntrant WorldChampEntrant? worldChampEntrant WorldChampEntrant?
sentUserInvitations UserInvitation[]
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt updatedAt DateTime @default(now()) @updatedAt
} }
model UserInvitation {
id String @id @default(uuid()) @db.Uuid
projectId String @db.Uuid
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
email String
role ProjectUserRole
invitationToken String @unique
senderId String @db.Uuid
sender User @relation(fields: [senderId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([projectId, email])
}
model VerificationToken { model VerificationToken {
identifier String identifier String
token String @unique token String @unique
@@ -430,33 +404,3 @@ model VerificationToken {
@@unique([identifier, token]) @@unique([identifier, token])
} }
enum FineTuneStatus {
PENDING
TRAINING
AWAITING_DEPLOYMENT
DEPLOYING
DEPLOYED
ERROR
}
model FineTune {
id String @id @default(uuid()) @db.Uuid
slug String @unique
baseModel String
status FineTuneStatus @default(PENDING)
trainingStartedAt DateTime?
trainingFinishedAt DateTime?
deploymentStartedAt DateTime?
deploymentFinishedAt DateTime?
datasetId String @db.Uuid
dataset Dataset @relation(fields: [datasetId], references: [id], onDelete: Cascade)
projectId String @db.Uuid
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}

View File

@@ -2,7 +2,6 @@ import { prisma } from "~/server/db";
import dedent from "dedent"; import dedent from "dedent";
import { generateNewCell } from "~/server/utils/generateNewCell"; import { generateNewCell } from "~/server/utils/generateNewCell";
import { promptConstructorVersion } from "~/promptConstructor/version"; import { promptConstructorVersion } from "~/promptConstructor/version";
import { env } from "~/env.mjs";
const defaultId = "11111111-1111-1111-1111-111111111111"; const defaultId = "11111111-1111-1111-1111-111111111111";
@@ -10,14 +9,6 @@ await prisma.project.deleteMany({
where: { id: defaultId }, where: { id: defaultId },
}); });
// Mark all users as admins
await prisma.user.updateMany({
where: {},
data: {
role: "ADMIN",
},
});
// If there's an existing project, just seed into it // If there's an existing project, just seed into it
const project = const project =
(await prisma.project.findFirst({})) ?? (await prisma.project.findFirst({})) ??
@@ -25,20 +16,6 @@ const project =
data: { id: defaultId }, data: { id: defaultId },
})); }));
if (env.OPENPIPE_API_KEY) {
await prisma.apiKey.upsert({
where: {
apiKey: env.OPENPIPE_API_KEY,
},
create: {
projectId: project.id,
name: "Default API Key",
apiKey: env.OPENPIPE_API_KEY,
},
update: {},
});
}
await prisma.experiment.deleteMany({ await prisma.experiment.deleteMany({
where: { where: {
id: defaultId, id: defaultId,

View File

@@ -13,7 +13,6 @@ const MODEL_RESPONSE_TEMPLATES: {
inputTokens: number; inputTokens: number;
outputTokens: number; outputTokens: number;
finishReason: string; finishReason: string;
tags: { name: string; value: string }[];
}[] = [ }[] = [
{ {
reqPayload: { reqPayload: {
@@ -108,7 +107,6 @@ const MODEL_RESPONSE_TEMPLATES: {
inputTokens: 236, inputTokens: 236,
outputTokens: 5, outputTokens: 5,
finishReason: "stop", finishReason: "stop",
tags: [],
}, },
{ {
reqPayload: { reqPayload: {
@@ -195,7 +193,6 @@ const MODEL_RESPONSE_TEMPLATES: {
inputTokens: 222, inputTokens: 222,
outputTokens: 5, outputTokens: 5,
finishReason: "stop", finishReason: "stop",
tags: [],
}, },
{ {
reqPayload: { reqPayload: {
@@ -234,7 +231,6 @@ const MODEL_RESPONSE_TEMPLATES: {
inputTokens: 14, inputTokens: 14,
outputTokens: 7, outputTokens: 7,
finishReason: "stop", finishReason: "stop",
tags: [{ name: "prompt_id", value: "id2" }],
}, },
{ {
reqPayload: { reqPayload: {
@@ -310,10 +306,6 @@ const MODEL_RESPONSE_TEMPLATES: {
inputTokens: 2802, inputTokens: 2802,
outputTokens: 108, outputTokens: 108,
finishReason: "stop", finishReason: "stop",
tags: [
{ name: "prompt_id", value: "chatcmpl-7lQS3MktOT8BTgNEytl9dkyssCQqL" },
{ name: "some_other_tag", value: "some_other_value" },
],
}, },
]; ];
@@ -347,18 +339,17 @@ for (let i = 0; i < 1437; i++) {
MODEL_RESPONSE_TEMPLATES[Math.floor(Math.random() * MODEL_RESPONSE_TEMPLATES.length)]!; MODEL_RESPONSE_TEMPLATES[Math.floor(Math.random() * MODEL_RESPONSE_TEMPLATES.length)]!;
const model = template.reqPayload.model; const model = template.reqPayload.model;
// choose random time in the last two weeks, with a bias towards the last few days // choose random time in the last two weeks, with a bias towards the last few days
const requestedAt = new Date(Date.now() - Math.pow(Math.random(), 2) * 1000 * 60 * 60 * 24 * 14); const startTime = new Date(Date.now() - Math.pow(Math.random(), 2) * 1000 * 60 * 60 * 24 * 14);
// choose random delay anywhere from 2 to 10 seconds later for gpt-4, or 1 to 5 seconds for gpt-3.5 // choose random delay anywhere from 2 to 10 seconds later for gpt-4, or 1 to 5 seconds for gpt-3.5
const delay = const delay =
model === "gpt-4" ? 1000 * 2 + Math.random() * 1000 * 8 : 1000 + Math.random() * 1000 * 4; model === "gpt-4" ? 1000 * 2 + Math.random() * 1000 * 8 : 1000 + Math.random() * 1000 * 4;
const receivedAt = new Date(requestedAt.getTime() + delay); const endTime = new Date(startTime.getTime() + delay);
loggedCallsToCreate.push({ loggedCallsToCreate.push({
id: loggedCallId, id: loggedCallId,
cacheHit: false, cacheHit: false,
requestedAt, startTime,
projectId: project.id, projectId: project.id,
model: template.reqPayload.model, createdAt: startTime,
createdAt: requestedAt,
}); });
const { promptTokenPrice, completionTokenPrice } = const { promptTokenPrice, completionTokenPrice } =
@@ -374,20 +365,21 @@ for (let i = 0; i < 1437; i++) {
loggedCallModelResponsesToCreate.push({ loggedCallModelResponsesToCreate.push({
id: loggedCallModelResponseId, id: loggedCallModelResponseId,
requestedAt, startTime,
receivedAt, endTime,
originalLoggedCallId: loggedCallId, originalLoggedCallId: loggedCallId,
reqPayload: template.reqPayload, reqPayload: template.reqPayload,
respPayload: template.respPayload, respPayload: template.respPayload,
statusCode: template.respStatus, respStatus: template.respStatus,
errorMessage: template.error, error: template.error,
createdAt: requestedAt, createdAt: startTime,
cacheKey: hashRequest(project.id, template.reqPayload as JsonValue), cacheKey: hashRequest(project.id, template.reqPayload as JsonValue),
durationMs: receivedAt.getTime() - requestedAt.getTime(), durationMs: endTime.getTime() - startTime.getTime(),
inputTokens: template.inputTokens, inputTokens: template.inputTokens,
outputTokens: template.outputTokens, outputTokens: template.outputTokens,
finishReason: template.finishReason, finishReason: template.finishReason,
cost: template.inputTokens * promptTokenPrice + template.outputTokens * completionTokenPrice, totalCost:
template.inputTokens * promptTokenPrice + template.outputTokens * completionTokenPrice,
}); });
loggedCallsToUpdate.push({ loggedCallsToUpdate.push({
where: { where: {
@@ -397,14 +389,11 @@ for (let i = 0; i < 1437; i++) {
modelResponseId: loggedCallModelResponseId, modelResponseId: loggedCallModelResponseId,
}, },
}); });
for (const tag of template.tags) { loggedCallTagsToCreate.push({
loggedCallTagsToCreate.push({ loggedCallId,
projectId: project.id, name: "$model",
loggedCallId, value: template.reqPayload.model,
name: tag.name, });
value: tag.value,
});
}
} }
await prisma.$transaction([ await prisma.$transaction([

View File

@@ -10,4 +10,6 @@ pnpm tsx src/promptConstructor/migrate.ts
echo "Starting the server" echo "Starting the server"
pnpm start pnpm concurrently --kill-others \
"pnpm start" \
"pnpm tsx src/server/tasks/worker.ts"

View File

@@ -1,6 +0,0 @@
#! /bin/bash
set -e
cd "$(dirname "$0")/.."
apt-get update
apt-get install -y htop psql

View File

@@ -1,10 +0,0 @@
#! /bin/bash
set -e
echo "Migrating the database"
pnpm prisma migrate deploy
echo "Starting 4 workers"
pnpm concurrently "pnpm worker" "pnpm worker" "pnpm worker" "pnpm worker"

View File

@@ -1,13 +0,0 @@
#! /bin/bash
set -e
cd "$(dirname "$0")/../.."
echo "Env is"
echo $ENVIRONMENT
docker build . --file app/Dockerfile --tag "openpipe-prod"
# Run the image
docker run --env-file app/.env -it --entrypoint "/bin/bash" "openpipe-prod"

View File

@@ -3,7 +3,6 @@
// https://docs.sentry.io/platforms/javascript/guides/nextjs/ // https://docs.sentry.io/platforms/javascript/guides/nextjs/
import * as Sentry from "@sentry/nextjs"; import * as Sentry from "@sentry/nextjs";
import { isError } from "lodash-es";
import { env } from "~/env.mjs"; import { env } from "~/env.mjs";
if (env.NEXT_PUBLIC_SENTRY_DSN) { if (env.NEXT_PUBLIC_SENTRY_DSN) {
@@ -16,10 +15,4 @@ if (env.NEXT_PUBLIC_SENTRY_DSN) {
// Setting this option to true will print useful information to the console while you're setting up Sentry. // Setting this option to true will print useful information to the console while you're setting up Sentry.
debug: false, debug: false,
}); });
} else {
// Install local debug exception handler for rejected promises
process.on("unhandledRejection", (reason) => {
const reasonDetails = isError(reason) ? reason?.stack : reason;
console.log("Unhandled Rejection at:", reasonDetails);
});
} }

View File

@@ -1,13 +1,13 @@
import { Textarea, type TextareaProps } from "@chakra-ui/react"; import { Textarea, type TextareaProps } from "@chakra-ui/react";
import ResizeTextarea from "react-textarea-autosize"; import ResizeTextarea from "react-textarea-autosize";
import React, { useEffect, useState } from "react"; import React, { useLayoutEffect, useState } from "react";
export const AutoResizeTextarea: React.ForwardRefRenderFunction< export const AutoResizeTextarea: React.ForwardRefRenderFunction<
HTMLTextAreaElement, HTMLTextAreaElement,
TextareaProps & { minRows?: number } TextareaProps & { minRows?: number }
> = ({ minRows = 1, overflowY = "hidden", ...props }, ref) => { > = ({ minRows = 1, overflowY = "hidden", ...props }, ref) => {
const [isRerendered, setIsRerendered] = useState(false); const [isRerendered, setIsRerendered] = useState(false);
useEffect(() => setIsRerendered(true), []); useLayoutEffect(() => setIsRerendered(true), []);
return ( return (
<Textarea <Textarea

View File

@@ -87,7 +87,7 @@ export const ModelStatsCard = ({
label="Price" label="Price"
info={ info={
<Text> <Text>
${model.pricePerSecond.toFixed(4)} ${model.pricePerSecond.toFixed(3)}
<Text color="gray.500"> / second</Text> <Text color="gray.500"> / second</Text>
</Text> </Text>
} }

View File

@@ -1,16 +1,15 @@
import { HStack, Icon, IconButton, Tooltip, Text, type StackProps } from "@chakra-ui/react"; import { HStack, Icon, IconButton, Tooltip, Text } from "@chakra-ui/react";
import { useState } from "react"; import { useState } from "react";
import { MdContentCopy } from "react-icons/md"; import { MdContentCopy } from "react-icons/md";
import { useHandledAsyncCallback } from "~/utils/hooks"; import { useHandledAsyncCallback } from "~/utils/hooks";
const CopiableCode = ({ code, ...rest }: { code: string } & StackProps) => { const CopiableCode = ({ code }: { code: string }) => {
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
const [copyToClipboard] = useHandledAsyncCallback(async () => { const [copyToClipboard] = useHandledAsyncCallback(async () => {
await navigator.clipboard.writeText(code); await navigator.clipboard.writeText(code);
setCopied(true); setCopied(true);
}, [code]); }, [code]);
return ( return (
<HStack <HStack
backgroundColor="blackAlpha.800" backgroundColor="blackAlpha.800"
@@ -19,19 +18,9 @@ const CopiableCode = ({ code, ...rest }: { code: string } & StackProps) => {
padding={3} padding={3}
w="full" w="full"
justifyContent="space-between" justifyContent="space-between"
alignItems="flex-start"
{...rest}
> >
<Text <Text fontFamily="inconsolata" fontWeight="bold" letterSpacing={0.5} overflowX="auto">
fontFamily="inconsolata"
fontWeight="bold"
letterSpacing={0.5}
overflowX="auto"
whiteSpace="pre-wrap"
>
{code} {code}
{/* Necessary for trailing newline to actually be displayed */}
{code.endsWith("\n") ? "\n" : ""}
</Text> </Text>
<Tooltip closeOnClick={false} label={copied ? "Copied!" : "Copy to clipboard"}> <Tooltip closeOnClick={false} label={copied ? "Copied!" : "Copy to clipboard"}>
<IconButton <IconButton

View File

@@ -1,91 +0,0 @@
import {
Input,
InputGroup,
InputRightElement,
Icon,
Popover,
PopoverTrigger,
PopoverContent,
VStack,
HStack,
Button,
Text,
useDisclosure,
type InputGroupProps,
} from "@chakra-ui/react";
import { FiChevronDown } from "react-icons/fi";
import { BiCheck } from "react-icons/bi";
type InputDropdownProps<T> = {
options: ReadonlyArray<T>;
selectedOption: T;
onSelect: (option: T) => void;
inputGroupProps?: InputGroupProps;
};
const InputDropdown = <T,>({
options,
selectedOption,
onSelect,
inputGroupProps,
}: InputDropdownProps<T>) => {
const popover = useDisclosure();
return (
<Popover placement="bottom-start" {...popover}>
<PopoverTrigger>
<InputGroup
cursor="pointer"
w={(selectedOption as string).length * 14 + 180}
{...inputGroupProps}
>
<Input
value={selectedOption as string}
// eslint-disable-next-line @typescript-eslint/no-empty-function -- controlled input requires onChange
onChange={() => {}}
cursor="pointer"
borderColor={popover.isOpen ? "blue.500" : undefined}
_hover={popover.isOpen ? { borderColor: "blue.500" } : undefined}
contentEditable={false}
// disable focus
onFocus={(e) => {
e.target.blur();
}}
/>
<InputRightElement>
<Icon as={FiChevronDown} />
</InputRightElement>
</InputGroup>
</PopoverTrigger>
<PopoverContent boxShadow="0 0 40px 4px rgba(0, 0, 0, 0.1);" minW={0} w="auto">
<VStack spacing={0}>
{options?.map((option, index) => (
<HStack
key={index}
as={Button}
onClick={() => {
onSelect(option);
popover.onClose();
}}
w="full"
variant="ghost"
justifyContent="space-between"
fontWeight="semibold"
borderRadius={0}
colorScheme="blue"
color="black"
fontSize="sm"
borderBottomWidth={1}
>
<Text mr={16}>{option as string}</Text>
{option === selectedOption && <Icon as={BiCheck} color="blue.500" boxSize={5} />}
</HStack>
))}
</VStack>
</PopoverContent>
</Popover>
);
};
export default InputDropdown;

View File

@@ -8,7 +8,7 @@ import {
useHandledAsyncCallback, useHandledAsyncCallback,
useVisibleScenarioIds, useVisibleScenarioIds,
} from "~/utils/hooks"; } from "~/utils/hooks";
import { cellPadding } from "./constants"; import { cellPadding } from "../constants";
import { ActionButton } from "./ScenariosHeader"; import { ActionButton } from "./ScenariosHeader";
export default function AddVariantButton() { export default function AddVariantButton() {
@@ -33,11 +33,25 @@ export default function AddVariantButton() {
<Flex w="100%" justifyContent="flex-end"> <Flex w="100%" justifyContent="flex-end">
<ActionButton <ActionButton
onClick={onClick} onClick={onClick}
py={7} py={5}
leftIcon={<Icon as={loading ? Spinner : BsPlus} boxSize={6} mr={loading ? 1 : 0} />} leftIcon={<Icon as={loading ? Spinner : BsPlus} boxSize={6} mr={loading ? 1 : 0} />}
> >
<Text display={{ base: "none", md: "flex" }}>Add Variant</Text> <Text display={{ base: "none", md: "flex" }}>Add Variant</Text>
</ActionButton> </ActionButton>
{/* <Button
alignItems="center"
justifyContent="center"
fontWeight="normal"
bgColor="transparent"
_hover={{ bgColor: "gray.100" }}
px={cellPadding.x}
onClick={onClick}
height="unset"
minH={headerMinHeight}
>
<Icon as={loading ? Spinner : BsPlus} boxSize={6} mr={loading ? 1 : 0} />
<Text display={{ base: "none", md: "flex" }}>Add Variant</Text>
</Button> */}
</Flex> </Flex>
); );
} }

View File

@@ -4,7 +4,7 @@ import { useEffect, useState } from "react";
import { BsPencil, BsX } from "react-icons/bs"; import { BsPencil, BsX } from "react-icons/bs";
import { api } from "~/utils/api"; import { api } from "~/utils/api";
import { useExperiment, useHandledAsyncCallback, useScenarioVars } from "~/utils/hooks"; import { useExperiment, useHandledAsyncCallback, useScenarioVars } from "~/utils/hooks";
import { maybeReportError } from "~/utils/errorHandling/maybeReportError"; import { maybeReportError } from "~/utils/standardResponses";
import { FloatingLabelInput } from "./FloatingLabelInput"; import { FloatingLabelInput } from "./FloatingLabelInput";
export const ScenarioVar = ({ export const ScenarioVar = ({

View File

@@ -1,29 +0,0 @@
import { type StackProps, VStack } from "@chakra-ui/react";
import { type RouterOutputs } from "~/utils/api";
import { type Scenario } from "../types";
import { CellOptions } from "./CellOptions";
import { OutputStats } from "./OutputStats";
const CellWrapper: React.FC<
StackProps & {
cell: RouterOutputs["scenarioVariantCells"]["get"] | undefined;
hardRefetching: boolean;
hardRefetch: () => void;
mostRecentResponse:
| NonNullable<RouterOutputs["scenarioVariantCells"]["get"]>["modelResponses"][0]
| undefined;
scenario: Scenario;
}
> = ({ children, cell, hardRefetching, hardRefetch, mostRecentResponse, scenario, ...props }) => (
<VStack w="full" alignItems="flex-start" {...props} px={2} py={2} h="100%">
{cell && (
<CellOptions refetchingOutput={hardRefetching} refetchOutput={hardRefetch} cell={cell} />
)}
<VStack w="full" alignItems="flex-start" maxH={500} overflowY="auto" flex={1}>
{children}
</VStack>
{mostRecentResponse && <OutputStats modelResponse={mostRecentResponse} scenario={scenario} />}
</VStack>
);
export default CellWrapper;

View File

@@ -1,16 +1,17 @@
import { Text } from "@chakra-ui/react"; import { api } from "~/utils/api";
import stringify from "json-stringify-pretty-compact"; import { type PromptVariant, type Scenario } from "../types";
import { Fragment, useEffect, useState, type ReactElement } from "react"; import { type StackProps, Text, VStack } from "@chakra-ui/react";
import { useScenarioVars, useHandledAsyncCallback } from "~/utils/hooks";
import SyntaxHighlighter from "react-syntax-highlighter"; import SyntaxHighlighter from "react-syntax-highlighter";
import { docco } from "react-syntax-highlighter/dist/cjs/styles/hljs"; import { docco } from "react-syntax-highlighter/dist/cjs/styles/hljs";
import frontendModelProviders from "~/modelProviders/frontendModelProviders"; import stringify from "json-stringify-pretty-compact";
import { api } from "~/utils/api"; import { type ReactElement, useState, useEffect, Fragment, useCallback } from "react";
import { useHandledAsyncCallback, useScenarioVars } from "~/utils/hooks";
import useSocket from "~/utils/useSocket"; import useSocket from "~/utils/useSocket";
import { type PromptVariant, type Scenario } from "../types"; import { OutputStats } from "./OutputStats";
import CellWrapper from "./CellWrapper";
import { ResponseLog } from "./ResponseLog";
import { RetryCountdown } from "./RetryCountdown"; import { RetryCountdown } from "./RetryCountdown";
import frontendModelProviders from "~/modelProviders/frontendModelProviders";
import { ResponseLog } from "./ResponseLog";
import { CellOptions } from "./TopActions";
const WAITING_MESSAGE_INTERVAL = 20000; const WAITING_MESSAGE_INTERVAL = 20000;
@@ -32,7 +33,7 @@ export default function OutputCell({
if (!templateHasVariables) disabledReason = "Add a value to the scenario variables to see output"; if (!templateHasVariables) disabledReason = "Add a value to the scenario variables to see output";
const [refetchInterval, setRefetchInterval] = useState<number | false>(false); const [refetchInterval, setRefetchInterval] = useState(0);
const { data: cell, isLoading: queryLoading } = api.scenarioVariantCells.get.useQuery( const { data: cell, isLoading: queryLoading } = api.scenarioVariantCells.get.useQuery(
{ scenarioId: scenario.id, variantId: variant.id }, { scenarioId: scenario.id, variantId: variant.id },
{ refetchInterval }, { refetchInterval },
@@ -43,7 +44,7 @@ export default function OutputCell({
type OutputSchema = Parameters<typeof provider.normalizeOutput>[0]; type OutputSchema = Parameters<typeof provider.normalizeOutput>[0];
const { mutateAsync: hardRefetchMutate } = api.scenarioVariantCells.hardRefetch.useMutation(); const { mutateAsync: hardRefetchMutate } = api.scenarioVariantCells.forceRefetch.useMutation();
const [hardRefetch, hardRefetching] = useHandledAsyncCallback(async () => { const [hardRefetch, hardRefetching] = useHandledAsyncCallback(async () => {
await hardRefetchMutate({ scenarioId: scenario.id, variantId: variant.id }); await hardRefetchMutate({ scenarioId: scenario.id, variantId: variant.id });
await utils.scenarioVariantCells.get.invalidate({ await utils.scenarioVariantCells.get.invalidate({
@@ -63,34 +64,42 @@ export default function OutputCell({
cell.retrievalStatus === "PENDING" || cell.retrievalStatus === "PENDING" ||
cell.retrievalStatus === "IN_PROGRESS" || cell.retrievalStatus === "IN_PROGRESS" ||
hardRefetching; hardRefetching;
useEffect(() => setRefetchInterval(awaitingOutput ? 1000 : 0), [awaitingOutput]);
useEffect(() => setRefetchInterval(awaitingOutput ? 1000 : false), [awaitingOutput]);
// TODO: disconnect from socket if we're not streaming anymore // TODO: disconnect from socket if we're not streaming anymore
const streamedMessage = useSocket<OutputSchema>(cell?.id); const streamedMessage = useSocket<OutputSchema>(cell?.id);
const mostRecentResponse = cell?.modelResponses[cell.modelResponses.length - 1]; const mostRecentResponse = cell?.modelResponses[cell.modelResponses.length - 1];
const wrapperProps: Parameters<typeof CellWrapper>[0] = { const CellWrapper = useCallback(
cell, ({ children, ...props }: StackProps) => (
hardRefetching, <VStack w="full" alignItems="flex-start" {...props} px={2} py={2} h="100%">
hardRefetch, {cell && (
mostRecentResponse, <CellOptions refetchingOutput={hardRefetching} refetchOutput={hardRefetch} cell={cell} />
scenario, )}
}; <VStack w="full" alignItems="flex-start" maxH={500} overflowY="auto" flex={1}>
{children}
</VStack>
{mostRecentResponse && (
<OutputStats modelResponse={mostRecentResponse} scenario={scenario} />
)}
</VStack>
),
[hardRefetching, hardRefetch, mostRecentResponse, scenario, cell],
);
if (!vars) return null; if (!vars) return null;
if (!cell && !fetchingOutput) if (!cell && !fetchingOutput)
return ( return (
<CellWrapper {...wrapperProps}> <CellWrapper>
<Text color="gray.500">Error retrieving output</Text> <Text color="gray.500">Error retrieving output</Text>
</CellWrapper> </CellWrapper>
); );
if (cell && cell.errorMessage) { if (cell && cell.errorMessage) {
return ( return (
<CellWrapper {...wrapperProps}> <CellWrapper>
<Text color="red.500">{cell.errorMessage}</Text> <Text color="red.500">{cell.errorMessage}</Text>
</CellWrapper> </CellWrapper>
); );
@@ -98,16 +107,11 @@ export default function OutputCell({
if (disabledReason) return <Text color="gray.500">{disabledReason}</Text>; if (disabledReason) return <Text color="gray.500">{disabledReason}</Text>;
const showLogs = !streamedMessage && !mostRecentResponse?.respPayload; const showLogs = !streamedMessage && !mostRecentResponse?.output;
if (showLogs) if (showLogs)
return ( return (
<CellWrapper <CellWrapper alignItems="flex-start" fontFamily="inconsolata, monospace" spacing={0}>
{...wrapperProps}
alignItems="flex-start"
fontFamily="inconsolata, monospace"
spacing={0}
>
{cell?.jobQueuedAt && <ResponseLog time={cell.jobQueuedAt} title="Job queued" />} {cell?.jobQueuedAt && <ResponseLog time={cell.jobQueuedAt} title="Job queued" />}
{cell?.jobStartedAt && <ResponseLog time={cell.jobStartedAt} title="Job started" />} {cell?.jobStartedAt && <ResponseLog time={cell.jobStartedAt} title="Job started" />}
{cell?.modelResponses?.map((response) => { {cell?.modelResponses?.map((response) => {
@@ -116,13 +120,8 @@ export default function OutputCell({
? response.receivedAt.getTime() ? response.receivedAt.getTime()
: Date.now(); : Date.now();
if (response.requestedAt) { if (response.requestedAt) {
numWaitingMessages = Math.min( numWaitingMessages = Math.floor(
Math.floor( (relativeWaitingTime - response.requestedAt.getTime()) / WAITING_MESSAGE_INTERVAL,
(relativeWaitingTime - response.requestedAt.getTime()) / WAITING_MESSAGE_INTERVAL,
),
// Don't try to render more than 15, it'll use too much CPU and
// break the page
15,
); );
} }
return ( return (
@@ -161,15 +160,15 @@ export default function OutputCell({
</CellWrapper> </CellWrapper>
); );
const normalizedOutput = mostRecentResponse?.respPayload const normalizedOutput = mostRecentResponse?.output
? provider.normalizeOutput(mostRecentResponse?.respPayload) ? provider.normalizeOutput(mostRecentResponse?.output)
: streamedMessage : streamedMessage
? provider.normalizeOutput(streamedMessage) ? provider.normalizeOutput(streamedMessage)
: null; : null;
if (mostRecentResponse?.respPayload && normalizedOutput?.type === "json") { if (mostRecentResponse?.output && normalizedOutput?.type === "json") {
return ( return (
<CellWrapper {...wrapperProps}> <CellWrapper>
<SyntaxHighlighter <SyntaxHighlighter
customStyle={{ overflowX: "unset", width: "100%", flex: 1 }} customStyle={{ overflowX: "unset", width: "100%", flex: 1 }}
language="json" language="json"
@@ -188,7 +187,7 @@ export default function OutputCell({
const contentToDisplay = (normalizedOutput?.type === "text" && normalizedOutput.value) || ""; const contentToDisplay = (normalizedOutput?.type === "text" && normalizedOutput.value) || "";
return ( return (
<CellWrapper {...wrapperProps}> <CellWrapper>
<Text whiteSpace="pre-wrap">{contentToDisplay}</Text> <Text whiteSpace="pre-wrap">{contentToDisplay}</Text>
</CellWrapper> </CellWrapper>
); );

View File

@@ -5,103 +5,30 @@ import {
ModalContent, ModalContent,
ModalHeader, ModalHeader,
ModalOverlay, ModalOverlay,
VStack,
Text,
Box,
type UseDisclosureReturn, type UseDisclosureReturn,
Link,
} from "@chakra-ui/react"; } from "@chakra-ui/react";
import { api, type RouterOutputs } from "~/utils/api"; import { type RouterOutputs } from "~/utils/api";
import { JSONTree } from "react-json-tree"; import { JSONTree } from "react-json-tree";
import CopiableCode from "~/components/CopiableCode";
const theme = { export default function ExpandedModal(props: {
scheme: "chalk",
author: "chris kempson (http://chriskempson.com)",
base00: "transparent",
base01: "#202020",
base02: "#303030",
base03: "#505050",
base04: "#b0b0b0",
base05: "#d0d0d0",
base06: "#e0e0e0",
base07: "#f5f5f5",
base08: "#fb9fb1",
base09: "#eda987",
base0A: "#ddb26f",
base0B: "#acc267",
base0C: "#12cfc0",
base0D: "#6fc2ef",
base0E: "#e1a3ee",
base0F: "#deaf8f",
};
export default function PromptModal(props: {
cell: NonNullable<RouterOutputs["scenarioVariantCells"]["get"]>; cell: NonNullable<RouterOutputs["scenarioVariantCells"]["get"]>;
disclosure: UseDisclosureReturn; disclosure: UseDisclosureReturn;
}) { }) {
const { data } = api.scenarioVariantCells.getTemplatedPromptMessage.useQuery(
{
cellId: props.cell.id,
},
{
enabled: props.disclosure.isOpen,
},
);
return ( return (
<Modal isOpen={props.disclosure.isOpen} onClose={props.disclosure.onClose} size="xl"> <Modal isOpen={props.disclosure.isOpen} onClose={props.disclosure.onClose} size="2xl">
<ModalOverlay /> <ModalOverlay />
<ModalContent> <ModalContent>
<ModalHeader>Prompt Details</ModalHeader> <ModalHeader>Prompt</ModalHeader>
<ModalCloseButton /> <ModalCloseButton />
<ModalBody> <ModalBody>
<VStack py={4} w=""> <JSONTree
<VStack w="full" alignItems="flex-start"> data={props.cell.prompt}
<Text fontWeight="bold">Full Prompt</Text> invertTheme={true}
<Box theme="chalk"
w="full" shouldExpandNodeInitially={() => true}
p={4} getItemString={() => ""}
alignItems="flex-start" hideRoot
backgroundColor="blackAlpha.800" />
borderRadius={4}
>
<JSONTree
data={props.cell.prompt}
theme={theme}
shouldExpandNodeInitially={() => true}
getItemString={() => ""}
hideRoot
/>
</Box>
</VStack>
{data?.templatedPrompt && (
<VStack w="full" mt={4} alignItems="flex-start">
<Text fontWeight="bold">Templated prompt message:</Text>
<CopiableCode
w="full"
// bgColor="gray.100"
p={4}
borderWidth={1}
whiteSpace="pre-wrap"
code={data.templatedPrompt}
/>
</VStack>
)}
{data?.learnMoreUrl && (
<Link
href={data.learnMoreUrl}
isExternal
color="blue.500"
fontWeight="bold"
fontSize="sm"
mt={4}
alignSelf="flex-end"
>
Learn More
</Link>
)}
</VStack>
</ModalBody> </ModalBody>
</ModalContent> </ModalContent>
</Modal> </Modal>

View File

@@ -1,7 +1,7 @@
import { HStack, Icon, IconButton, Spinner, Tooltip, useDisclosure } from "@chakra-ui/react"; import { HStack, Icon, IconButton, Spinner, Tooltip, useDisclosure } from "@chakra-ui/react";
import { BsArrowClockwise, BsInfoCircle } from "react-icons/bs"; import { BsArrowClockwise, BsInfoCircle } from "react-icons/bs";
import { useExperimentAccess } from "~/utils/hooks"; import { useExperimentAccess } from "~/utils/hooks";
import PromptModal from "./PromptModal"; import ExpandedModal from "./PromptModal";
import { type RouterOutputs } from "~/utils/api"; import { type RouterOutputs } from "~/utils/api";
export const CellOptions = ({ export const CellOptions = ({
@@ -32,7 +32,7 @@ export const CellOptions = ({
variant="ghost" variant="ghost"
/> />
</Tooltip> </Tooltip>
<PromptModal cell={cell} disclosure={modalDisclosure} /> <ExpandedModal cell={cell} disclosure={modalDisclosure} />
</> </>
)} )}
{canModify && ( {canModify && (

View File

@@ -16,7 +16,7 @@ import {
VStack, VStack,
} from "@chakra-ui/react"; } from "@chakra-ui/react";
import { BsArrowsAngleExpand, BsX } from "react-icons/bs"; import { BsArrowsAngleExpand, BsX } from "react-icons/bs";
import { cellPadding } from "./constants"; import { cellPadding } from "../constants";
import { FloatingLabelInput } from "./FloatingLabelInput"; import { FloatingLabelInput } from "./FloatingLabelInput";
import { ScenarioEditorModal } from "./ScenarioEditorModal"; import { ScenarioEditorModal } from "./ScenarioEditorModal";
@@ -111,23 +111,25 @@ export default function ScenarioEditor({
onDrop={onReorder} onDrop={onReorder}
backgroundColor={isDragTarget ? "gray.100" : "transparent"} backgroundColor={isDragTarget ? "gray.100" : "transparent"}
> >
{ {variableLabels.length === 0 ? (
<Box color="gray.500">
{vars.data ? "No scenario variables configured" : "Loading..."}
</Box>
) : (
<VStack spacing={4} flex={1} py={2}> <VStack spacing={4} flex={1} py={2}>
<HStack justifyContent="space-between" w="100%" align="center" spacing={0}> <HStack justifyContent="space-between" w="100%" align="center" spacing={0}>
<Text flex={1}>Scenario</Text> <Text flex={1}>Scenario</Text>
{variableLabels.length && ( <Tooltip label="Expand" hasArrow>
<Tooltip label="Expand" hasArrow> <IconButton
<IconButton aria-label="Expand"
aria-label="Expand" icon={<Icon as={BsArrowsAngleExpand} boxSize={3} />}
icon={<Icon as={BsArrowsAngleExpand} boxSize={3} />} onClick={() => setScenarioEditorModalOpen(true)}
onClick={() => setScenarioEditorModalOpen(true)} size="xs"
size="xs" colorScheme="gray"
colorScheme="gray" color="gray.500"
color="gray.500" variant="ghost"
variant="ghost" />
/> </Tooltip>
</Tooltip>
)}
{canModify && props.canHide && ( {canModify && props.canHide && (
<Tooltip label="Delete" hasArrow> <Tooltip label="Delete" hasArrow>
<IconButton <IconButton
@@ -148,38 +150,31 @@ export default function ScenarioEditor({
</Tooltip> </Tooltip>
)} )}
</HStack> </HStack>
{variableLabels.map((key) => {
{variableLabels.length === 0 ? ( const value = values[key] ?? "";
<Box color="gray.500"> return (
{vars.data ? "No scenario variables configured" : "Loading..."} <FloatingLabelInput
</Box> key={key}
) : ( label={key}
variableLabels.map((key) => { isDisabled={!canModify}
const value = values[key] ?? ""; style={{ width: "100%" }}
return ( maxHeight={32}
<FloatingLabelInput value={value}
key={key} onChange={(e) => {
label={key} setValues((prev) => ({ ...prev, [key]: e.target.value }));
isDisabled={!canModify} }}
style={{ width: "100%" }} onKeyDown={(e) => {
maxHeight={32} if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
value={value} e.preventDefault();
onChange={(e) => { e.currentTarget.blur();
setValues((prev) => ({ ...prev, [key]: e.target.value })); onSave();
}} }
onKeyDown={(e) => { }}
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) { onMouseEnter={() => setVariableInputHovered(true)}
e.preventDefault(); onMouseLeave={() => setVariableInputHovered(false)}
e.currentTarget.blur(); />
onSave(); );
} })}
}}
onMouseEnter={() => setVariableInputHovered(true)}
onMouseLeave={() => setVariableInputHovered(false)}
/>
);
})
)}
{hasChanged && ( {hasChanged && (
<HStack justify="right"> <HStack justify="right">
<Button <Button
@@ -197,7 +192,7 @@ export default function ScenarioEditor({
</HStack> </HStack>
)} )}
</VStack> </VStack>
} )}
</HStack> </HStack>
{scenarioEditorModalOpen && ( {scenarioEditorModalOpen && (
<ScenarioEditorModal <ScenarioEditorModal

View File

@@ -65,11 +65,11 @@ export const ScenarioEditorModal = ({
<Modal <Modal
isOpen isOpen
onClose={onClose} onClose={onClose}
size={{ base: "xl", sm: "2xl", md: "3xl", lg: "4xl", xl: "5xl" }} size={{ base: "xl", sm: "2xl", md: "3xl", lg: "5xl", xl: "7xl" }}
> >
<ModalOverlay /> <ModalOverlay />
<ModalContent w={1200}> <ModalContent w={1200}>
<ModalHeader>Edit Scenario</ModalHeader> <ModalHeader />
<ModalCloseButton /> <ModalCloseButton />
<ModalBody maxW="unset"> <ModalBody maxW="unset">
<VStack spacing={8}> <VStack spacing={8}>

View File

@@ -1,16 +1,21 @@
import { type StackProps } from "@chakra-ui/react";
import { useScenarios } from "~/utils/hooks"; import { useScenarios } from "~/utils/hooks";
import Paginator from "../Paginator"; import Paginator from "../Paginator";
const ScenarioPaginator = (props: StackProps) => { const ScenarioPaginator = () => {
const { data } = useScenarios(); const { data } = useScenarios();
if (!data) return null; if (!data) return null;
const { count } = data; const { scenarios, startIndex, lastPage, count } = data;
return <Paginator count={count} condense {...props} />; return (
<Paginator
numItemsLoaded={scenarios.length}
startIndex={startIndex}
lastPage={lastPage}
count={count}
/>
);
}; };
export default ScenarioPaginator; export default ScenarioPaginator;

View File

@@ -10,8 +10,6 @@ const ScenarioRow = (props: {
variants: PromptVariant[]; variants: PromptVariant[];
canHide: boolean; canHide: boolean;
rowStart: number; rowStart: number;
isFirst: boolean;
isLast: boolean;
}) => { }) => {
const [isHovered, setIsHovered] = useState(false); const [isHovered, setIsHovered] = useState(false);
@@ -23,14 +21,10 @@ const ScenarioRow = (props: {
onMouseEnter={() => setIsHovered(true)} onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)} onMouseLeave={() => setIsHovered(false)}
sx={isHovered ? highlightStyle : undefined} sx={isHovered ? highlightStyle : undefined}
bgColor="white" borderLeftWidth={1}
{...borders}
rowStart={props.rowStart} rowStart={props.rowStart}
colStart={1} colStart={1}
borderLeftWidth={1}
borderTopWidth={props.isFirst ? 1 : 0}
borderTopLeftRadius={props.isFirst ? 8 : 0}
borderBottomLeftRadius={props.isLast ? 8 : 0}
{...borders}
> >
<ScenarioEditor scenario={props.scenario} hovered={isHovered} canHide={props.canHide} /> <ScenarioEditor scenario={props.scenario} hovered={isHovered} canHide={props.canHide} />
</GridItem> </GridItem>
@@ -40,12 +34,8 @@ const ScenarioRow = (props: {
onMouseEnter={() => setIsHovered(true)} onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)} onMouseLeave={() => setIsHovered(false)}
sx={isHovered ? highlightStyle : undefined} sx={isHovered ? highlightStyle : undefined}
bgColor="white"
rowStart={props.rowStart} rowStart={props.rowStart}
colStart={i + 2} colStart={i + 2}
borderTopWidth={props.isFirst ? 1 : 0}
borderTopRightRadius={props.isFirst && i === props.variants.length - 1 ? 8 : 0}
borderBottomRightRadius={props.isLast && i === props.variants.length - 1 ? 8 : 0}
{...borders} {...borders}
> >
<OutputCell key={variant.id} scenario={props.scenario} variant={variant} /> <OutputCell key={variant.id} scenario={props.scenario} variant={variant} />

View File

@@ -11,7 +11,7 @@ import {
IconButton, IconButton,
Spinner, Spinner,
} from "@chakra-ui/react"; } from "@chakra-ui/react";
import { cellPadding } from "./constants"; import { cellPadding } from "../constants";
import { import {
useExperiment, useExperiment,
useExperimentAccess, useExperimentAccess,
@@ -48,7 +48,7 @@ export const ScenariosHeader = () => {
); );
return ( return (
<HStack w="100%" py={cellPadding.y} px={cellPadding.x} align="center" spacing={0}> <HStack w="100%" pb={cellPadding.y} pt={0} align="center" spacing={0}>
<Text fontSize={16} fontWeight="bold"> <Text fontSize={16} fontWeight="bold">
Scenarios ({scenarios.data?.count}) Scenarios ({scenarios.data?.count})
</Text> </Text>
@@ -57,16 +57,11 @@ export const ScenariosHeader = () => {
<MenuButton <MenuButton
as={IconButton} as={IconButton}
mt={1} mt={1}
ml={2}
variant="ghost" variant="ghost"
aria-label="Edit Scenarios" aria-label="Edit Scenarios"
icon={<Icon as={loading ? Spinner : BsGear} />} icon={<Icon as={loading ? Spinner : BsGear} />}
maxW={8}
minW={8}
minH={8}
maxH={8}
/> />
<MenuList fontSize="md" zIndex="dropdown" mt={-1}> <MenuList fontSize="md" zIndex="dropdown" mt={-3}>
<MenuItem <MenuItem
icon={<Icon as={BsPlus} boxSize={6} mx="-5px" />} icon={<Icon as={BsPlus} boxSize={6} mx="-5px" />}
onClick={() => onAddScenario(false)} onClick={() => onAddScenario(false)}

View File

@@ -110,7 +110,7 @@ export default function VariantEditor(props: { variant: PromptVariant }) {
setIsChanged(false); setIsChanged(false);
await utils.promptVariants.list.invalidate(); await utils.promptVariants.list.invalidate();
}, [checkForChanges, replaceVariant.mutateAsync]); }, [checkForChanges]);
useEffect(() => { useEffect(() => {
if (monaco) { if (monaco) {

View File

@@ -1,6 +1,6 @@
import { HStack, Icon, Text, useToken } from "@chakra-ui/react"; import { HStack, Icon, Text, useToken } from "@chakra-ui/react";
import { type PromptVariant } from "./types"; import { type PromptVariant } from "./types";
import { cellPadding } from "./constants"; import { cellPadding } from "../constants";
import { api } from "~/utils/api"; import { api } from "~/utils/api";
import chroma from "chroma-js"; import chroma from "chroma-js";
import { BsCurrencyDollar } from "react-icons/bs"; import { BsCurrencyDollar } from "react-icons/bs";
@@ -21,18 +21,14 @@ export default function VariantStats(props: { variant: PromptVariant }) {
outputTokens: 0, outputTokens: 0,
scenarioCount: 0, scenarioCount: 0,
outputCount: 0, outputCount: 0,
awaitingCompletions: false,
awaitingEvals: false, awaitingEvals: false,
}, },
refetchInterval, refetchInterval,
}, },
); );
// Poll every five seconds while we are waiting for LLM retrievals to finish // Poll every two seconds while we are waiting for LLM retrievals to finish
useEffect( useEffect(() => setRefetchInterval(data.awaitingEvals ? 5000 : 0), [data.awaitingEvals]);
() => setRefetchInterval(data.awaitingCompletions || data.awaitingEvals ? 5000 : 0),
[data.awaitingCompletions, data.awaitingEvals],
);
const [passColor, neutralColor, failColor] = useToken("colors", [ const [passColor, neutralColor, failColor] = useToken("colors", [
"green.500", "green.500",

View File

@@ -3,14 +3,13 @@ import { api } from "~/utils/api";
import AddVariantButton from "./AddVariantButton"; import AddVariantButton from "./AddVariantButton";
import ScenarioRow from "./ScenarioRow"; import ScenarioRow from "./ScenarioRow";
import VariantEditor from "./VariantEditor"; import VariantEditor from "./VariantEditor";
import VariantHeader from "./VariantHeader/VariantHeader"; import VariantHeader from "../VariantHeader/VariantHeader";
import VariantStats from "./VariantStats"; import VariantStats from "./VariantStats";
import { ScenariosHeader } from "./ScenariosHeader"; import { ScenariosHeader } from "./ScenariosHeader";
import { borders } from "./styles"; import { borders } from "./styles";
import { useScenarios } from "~/utils/hooks"; import { useScenarios } from "~/utils/hooks";
import ScenarioPaginator from "./ScenarioPaginator"; import ScenarioPaginator from "./ScenarioPaginator";
import { Fragment } from "react"; import { Fragment } from "react";
import useScrolledPast from "./useHasScrolledPast";
export default function OutputsTable({ experimentId }: { experimentId: string | undefined }) { export default function OutputsTable({ experimentId }: { experimentId: string | undefined }) {
const variants = api.promptVariants.list.useQuery( const variants = api.promptVariants.list.useQuery(
@@ -19,7 +18,6 @@ export default function OutputsTable({ experimentId }: { experimentId: string |
); );
const scenarios = useScenarios(); const scenarios = useScenarios();
const shouldFlattenHeader = useScrolledPast(50);
if (!variants.data || !scenarios.data) return null; if (!variants.data || !scenarios.data) return null;
@@ -55,30 +53,20 @@ export default function OutputsTable({ experimentId }: { experimentId: string |
colStart: i + 2, colStart: i + 2,
borderLeftWidth: i === 0 ? 1 : 0, borderLeftWidth: i === 0 ? 1 : 0,
marginLeft: i === 0 ? "-1px" : 0, marginLeft: i === 0 ? "-1px" : 0,
backgroundColor: "white", backgroundColor: "gray.100",
}; };
const isFirst = i === 0;
const isLast = i === variants.data.length - 1;
return ( return (
<Fragment key={variant.uiId}> <Fragment key={variant.uiId}>
<VariantHeader <VariantHeader
variant={variant} variant={variant}
canHide={variants.data.length > 1} canHide={variants.data.length > 1}
rowStart={1} rowStart={1}
borderTopLeftRadius={isFirst && !shouldFlattenHeader ? 8 : 0}
borderTopRightRadius={isLast && !shouldFlattenHeader ? 8 : 0}
{...sharedProps} {...sharedProps}
/> />
<GridItem rowStart={2} {...sharedProps}> <GridItem rowStart={2} {...sharedProps}>
<VariantEditor variant={variant} /> <VariantEditor variant={variant} />
</GridItem> </GridItem>
<GridItem <GridItem rowStart={3} {...sharedProps}>
rowStart={3}
{...sharedProps}
borderBottomLeftRadius={isFirst ? 8 : 0}
borderBottomRightRadius={isLast ? 8 : 0}
boxShadow="5px 5px 15px 1px rgba(0, 0, 0, 0.1);"
>
<VariantStats variant={variant} /> <VariantStats variant={variant} />
</GridItem> </GridItem>
</Fragment> </Fragment>
@@ -89,6 +77,7 @@ export default function OutputsTable({ experimentId }: { experimentId: string |
colSpan={allCols - 1} colSpan={allCols - 1}
rowStart={variantHeaderRows + 1} rowStart={variantHeaderRows + 1}
colStart={1} colStart={1}
{...borders}
borderRightWidth={0} borderRightWidth={0}
> >
<ScenariosHeader /> <ScenariosHeader />
@@ -101,8 +90,6 @@ export default function OutputsTable({ experimentId }: { experimentId: string |
scenario={scenario} scenario={scenario}
variants={variants.data} variants={variants.data}
canHide={visibleScenariosCount > 1} canHide={visibleScenariosCount > 1}
isFirst={i === 0}
isLast={i === visibleScenariosCount - 1}
/> />
))} ))}
<GridItem <GridItem

View File

@@ -1,34 +0,0 @@
import { useState, useEffect } from "react";
const useScrolledPast = (scrollThreshold: number) => {
const [hasScrolledPast, setHasScrolledPast] = useState(true);
useEffect(() => {
const container = document.getElementById("output-container");
if (!container) {
console.warn('Element with id "outputs-container" not found.');
return;
}
const checkScroll = () => {
const { scrollTop } = container;
// Check if scrollTop is greater than or equal to scrollThreshold
setHasScrolledPast(scrollTop > scrollThreshold);
};
checkScroll();
container.addEventListener("scroll", checkScroll);
// Cleanup
return () => {
container.removeEventListener("scroll", checkScroll);
};
}, []);
return hasScrolledPast;
};
export default useScrolledPast;

View File

@@ -1,126 +1,77 @@
import { Box, HStack, IconButton } from "@chakra-ui/react";
import { import {
HStack, BsChevronDoubleLeft,
IconButton, BsChevronDoubleRight,
Text, BsChevronLeft,
Select, BsChevronRight,
type StackProps, } from "react-icons/bs";
Icon, import { usePage } from "~/utils/hooks";
useBreakpointValue,
} from "@chakra-ui/react";
import React, { useCallback } from "react";
import { FiChevronsLeft, FiChevronsRight, FiChevronLeft, FiChevronRight } from "react-icons/fi";
import { usePageParams } from "~/utils/hooks";
const pageSizeOptions = [10, 25, 50, 100]; const Paginator = ({
numItemsLoaded,
const Paginator = ({ count, ...props }: { count: number; condense?: boolean } & StackProps) => { startIndex,
const { page, pageSize, setPageParams } = usePageParams(); lastPage,
count,
const lastPage = Math.ceil(count / pageSize); }: {
numItemsLoaded: number;
const updatePageSize = useCallback( startIndex: number;
(newPageSize: number) => { lastPage: number;
const newPage = Math.floor(((page - 1) * pageSize) / newPageSize) + 1; count: number;
setPageParams({ page: newPage, pageSize: newPageSize }, "replace"); }) => {
}, const [page, setPage] = usePage();
[page, pageSize, setPageParams],
);
const nextPage = () => { const nextPage = () => {
if (page < lastPage) { if (page < lastPage) {
setPageParams({ page: page + 1 }, "replace"); setPage(page + 1, "replace");
} }
}; };
const prevPage = () => { const prevPage = () => {
if (page > 1) { if (page > 1) {
setPageParams({ page: page - 1 }, "replace"); setPage(page - 1, "replace");
} }
}; };
const goToLastPage = () => setPageParams({ page: lastPage }, "replace"); const goToLastPage = () => setPage(lastPage, "replace");
const goToFirstPage = () => setPageParams({ page: 1 }, "replace"); const goToFirstPage = () => setPage(1, "replace");
const isMobile = useBreakpointValue({ base: true, md: false });
const condense = isMobile || props.condense;
if (count === 0) return null;
return ( return (
<HStack <HStack pt={4}>
pt={4} <IconButton
spacing={8} variant="ghost"
justifyContent={condense ? "flex-start" : "space-between"} size="sm"
alignItems="center" onClick={goToFirstPage}
w="full" isDisabled={page === 1}
{...props} aria-label="Go to first page"
> icon={<BsChevronDoubleLeft />}
{!condense && ( />
<> <IconButton
<HStack> variant="ghost"
<Text>Rows</Text> size="sm"
<Select onClick={prevPage}
value={pageSize} isDisabled={page === 1}
onChange={(e) => updatePageSize(parseInt(e.target.value))} aria-label="Previous page"
w={20} icon={<BsChevronLeft />}
backgroundColor="white" />
> <Box>
{pageSizeOptions.map((option) => ( {startIndex}-{startIndex + numItemsLoaded - 1} / {count}
<option key={option} value={option}> </Box>
{option} <IconButton
</option> variant="ghost"
))} size="sm"
</Select> onClick={nextPage}
</HStack> isDisabled={page === lastPage}
<Text> aria-label="Next page"
Page {page} of {lastPage} icon={<BsChevronRight />}
</Text> />
</> <IconButton
)} variant="ghost"
size="sm"
<HStack> onClick={goToLastPage}
<IconButton isDisabled={page === lastPage}
variant="outline" aria-label="Go to last page"
size="sm" icon={<BsChevronDoubleRight />}
onClick={goToFirstPage} />
isDisabled={page === 1}
aria-label="Go to first page"
icon={<Icon as={FiChevronsLeft} boxSize={5} strokeWidth={1.5} />}
bgColor="white"
/>
<IconButton
variant="outline"
size="sm"
onClick={prevPage}
isDisabled={page === 1}
aria-label="Previous page"
icon={<Icon as={FiChevronLeft} boxSize={5} strokeWidth={1.5} />}
bgColor="white"
/>
{condense && (
<Text>
Page {page} of {lastPage}
</Text>
)}
<IconButton
variant="outline"
size="sm"
onClick={nextPage}
isDisabled={page === lastPage}
aria-label="Next page"
icon={<Icon as={FiChevronRight} boxSize={5} strokeWidth={1.5} />}
bgColor="white"
/>
<IconButton
variant="outline"
size="sm"
onClick={goToLastPage}
isDisabled={page === lastPage}
aria-label="Go to last page"
icon={<Icon as={FiChevronsRight} boxSize={5} strokeWidth={1.5} />}
bgColor="white"
/>
</HStack>
</HStack> </HStack>
); );
}; };

View File

@@ -1,11 +1,11 @@
import { useState, type DragEvent } from "react"; import { useState, type DragEvent } from "react";
import { type PromptVariant } from "../types"; import { type PromptVariant } from "../OutputsTable/types";
import { api } from "~/utils/api"; import { api } from "~/utils/api";
import { RiDraggable } from "react-icons/ri"; import { RiDraggable } from "react-icons/ri";
import { useExperimentAccess, useHandledAsyncCallback } from "~/utils/hooks"; import { useExperimentAccess, useHandledAsyncCallback } from "~/utils/hooks";
import { HStack, Icon, Text, GridItem, type GridItemProps } from "@chakra-ui/react"; // Changed here import { HStack, Icon, Text, GridItem, type GridItemProps } from "@chakra-ui/react"; // Changed here
import { cellPadding, headerMinHeight } from "../constants"; import { cellPadding, headerMinHeight } from "../constants";
import AutoResizeTextArea from "../../AutoResizeTextArea"; import AutoResizeTextArea from "../AutoResizeTextArea";
import VariantHeaderMenuButton from "./VariantHeaderMenuButton"; import VariantHeaderMenuButton from "./VariantHeaderMenuButton";
export default function VariantHeader( export default function VariantHeader(
@@ -84,7 +84,6 @@ export default function VariantHeader(
> >
<HStack <HStack
spacing={2} spacing={2}
py={2}
alignItems="flex-start" alignItems="flex-start"
minH={headerMinHeight} minH={headerMinHeight}
draggable={!isInputHovered} draggable={!isInputHovered}
@@ -103,9 +102,7 @@ export default function VariantHeader(
setIsDragTarget(false); setIsDragTarget(false);
}} }}
onDrop={onReorder} onDrop={onReorder}
backgroundColor={isDragTarget ? "gray.200" : "white"} backgroundColor={isDragTarget ? "gray.200" : "gray.100"}
borderTopLeftRadius={gridItemProps.borderTopLeftRadius}
borderTopRightRadius={gridItemProps.borderTopRightRadius}
h="full" h="full"
> >
<Icon <Icon

View File

@@ -1,4 +1,6 @@
import { useState } from "react"; import { type PromptVariant } from "../OutputsTable/types";
import { api } from "~/utils/api";
import { useHandledAsyncCallback, useVisibleScenarioIds } from "~/utils/hooks";
import { import {
Icon, Icon,
Menu, Menu,
@@ -12,13 +14,10 @@ import {
} from "@chakra-ui/react"; } from "@chakra-ui/react";
import { BsFillTrashFill, BsGear, BsStars } from "react-icons/bs"; import { BsFillTrashFill, BsGear, BsStars } from "react-icons/bs";
import { FaRegClone } from "react-icons/fa"; import { FaRegClone } from "react-icons/fa";
import { useState } from "react";
import { RefinePromptModal } from "../RefinePromptModal/RefinePromptModal";
import { RiExchangeFundsFill } from "react-icons/ri"; import { RiExchangeFundsFill } from "react-icons/ri";
import { ChangeModelModal } from "../ChangeModelModal/ChangeModelModal";
import { api } from "~/utils/api";
import { useHandledAsyncCallback, useVisibleScenarioIds } from "~/utils/hooks";
import { type PromptVariant } from "../types";
import { RefinePromptModal } from "../../RefinePromptModal/RefinePromptModal";
import { ChangeModelModal } from "../../ChangeModelModal/ChangeModelModal";
export default function VariantHeaderMenuButton({ export default function VariantHeaderMenuButton({
variant, variant,

View File

@@ -0,0 +1,201 @@
import {
Box,
Card,
CardHeader,
Heading,
Table,
Tbody,
Td,
Th,
Thead,
Tr,
Tooltip,
Collapse,
HStack,
VStack,
IconButton,
useToast,
Icon,
Button,
ButtonGroup,
} from "@chakra-ui/react";
import dayjs from "dayjs";
import relativeTime from "dayjs/plugin/relativeTime";
import { ChevronUpIcon, ChevronDownIcon, CopyIcon } from "lucide-react";
import { useMemo, useState } from "react";
import { type RouterOutputs, api } from "~/utils/api";
import SyntaxHighlighter from "react-syntax-highlighter";
import { atelierCaveLight } from "react-syntax-highlighter/dist/cjs/styles/hljs";
import stringify from "json-stringify-pretty-compact";
import Link from "next/link";
dayjs.extend(relativeTime);
type LoggedCall = RouterOutputs["dashboard"]["loggedCalls"][0];
const FormattedJson = ({ json }: { json: any }) => {
const jsonString = stringify(json, { maxLength: 40 });
const toast = useToast();
const copyToClipboard = async (text: string) => {
try {
await navigator.clipboard.writeText(text);
toast({
title: "Copied to clipboard",
status: "success",
duration: 2000,
});
} catch (err) {
toast({
title: "Failed to copy to clipboard",
status: "error",
duration: 2000,
});
}
};
return (
<Box position="relative" fontSize="sm" borderRadius="md" overflow="hidden">
<SyntaxHighlighter
customStyle={{ overflowX: "unset" }}
language="json"
style={atelierCaveLight}
lineProps={{
style: { wordBreak: "break-all", whiteSpace: "pre-wrap" },
}}
wrapLines
>
{jsonString}
</SyntaxHighlighter>
<IconButton
aria-label="Copy"
icon={<CopyIcon />}
position="absolute"
top={1}
right={1}
size="xs"
variant="ghost"
onClick={() => void copyToClipboard(jsonString)}
/>
</Box>
);
};
function TableRow({
loggedCall,
isExpanded,
onToggle,
}: {
loggedCall: LoggedCall;
isExpanded: boolean;
onToggle: () => void;
}) {
const isError = loggedCall.modelResponse?.respStatus !== 200;
const timeAgo = dayjs(loggedCall.startTime).fromNow();
const fullTime = dayjs(loggedCall.startTime).toString();
const model = useMemo(
() => loggedCall.tags.find((tag) => tag.name.startsWith("$model"))?.value,
[loggedCall.tags],
);
return (
<>
<Tr
onClick={onToggle}
key={loggedCall.id}
_hover={{ bgColor: "gray.100", cursor: "pointer" }}
sx={{
"> td": { borderBottom: "none" },
}}
>
<Td>
<Icon boxSize={6} as={isExpanded ? ChevronUpIcon : ChevronDownIcon} />
</Td>
<Td>
<Tooltip label={fullTime} placement="top">
<Box whiteSpace="nowrap" minW="120px">
{timeAgo}
</Box>
</Tooltip>
</Td>
<Td width="100%">{model}</Td>
<Td isNumeric>{((loggedCall.modelResponse?.durationMs ?? 0) / 1000).toFixed(2)}s</Td>
<Td isNumeric>{loggedCall.modelResponse?.inputTokens}</Td>
<Td isNumeric>{loggedCall.modelResponse?.outputTokens}</Td>
<Td sx={{ color: isError ? "red.500" : "green.500", fontWeight: "semibold" }} isNumeric>
{loggedCall.modelResponse?.respStatus ?? "No response"}
</Td>
</Tr>
<Tr>
<Td colSpan={8} p={0}>
<Collapse in={isExpanded} unmountOnExit={true}>
<VStack p={4} align="stretch">
<HStack align="stretch">
<VStack flex={1} align="stretch">
<Heading size="sm">Input</Heading>
<FormattedJson json={loggedCall.modelResponse?.reqPayload} />
</VStack>
<VStack flex={1} align="stretch">
<Heading size="sm">Output</Heading>
<FormattedJson json={loggedCall.modelResponse?.respPayload} />
</VStack>
</HStack>
<ButtonGroup alignSelf="flex-end">
<Button as={Link} colorScheme="blue" href={{ pathname: "/experiments" }}>
Experiments
</Button>
</ButtonGroup>
</VStack>
</Collapse>
</Td>
</Tr>
</>
);
}
export default function LoggedCallTable() {
const [expandedRow, setExpandedRow] = useState<string | null>(null);
const loggedCalls = api.dashboard.loggedCalls.useQuery({});
return (
<Card variant="outline" width="100%" overflow="hidden">
<CardHeader>
<Heading as="h3" size="sm">
Logged Calls
</Heading>
</CardHeader>
<Table>
<Thead>
<Tr>
<Th />
<Th>Time</Th>
<Th>Model</Th>
<Th isNumeric>Duration</Th>
<Th isNumeric>Input tokens</Th>
<Th isNumeric>Output tokens</Th>
<Th isNumeric>Status</Th>
</Tr>
</Thead>
<Tbody>
{loggedCalls.data?.map((loggedCall) => {
return (
<TableRow
key={loggedCall.id}
loggedCall={loggedCall}
isExpanded={loggedCall.id === expandedRow}
onToggle={() => {
if (loggedCall.id === expandedRow) {
setExpandedRow(null);
} else {
setExpandedRow(loggedCall.id);
}
}}
/>
);
})}
</Tbody>
</Table>
</Card>
);
}

View File

@@ -1,46 +0,0 @@
import { Card, CardHeader, Heading, Table, Tbody, HStack, Button, Text } from "@chakra-ui/react";
import { useState } from "react";
import Link from "next/link";
import { useLoggedCalls } from "~/utils/hooks";
import { TableHeader, TableRow } from "../requestLogs/TableRow";
export default function LoggedCallsTable() {
const [expandedRow, setExpandedRow] = useState<string | null>(null);
const { data: loggedCalls } = useLoggedCalls();
return (
<Card width="100%" overflow="hidden">
<CardHeader>
<HStack justifyContent="space-between">
<Heading as="h3" size="sm">
Request Logs
</Heading>
<Button as={Link} href="/request-logs" variant="ghost" colorScheme="blue">
<Text>View All</Text>
</Button>
</HStack>
</CardHeader>
<Table>
<TableHeader />
<Tbody>
{loggedCalls?.calls.map((loggedCall) => {
return (
<TableRow
key={loggedCall.id}
loggedCall={loggedCall}
isExpanded={loggedCall.id === expandedRow}
onToggle={() => {
if (loggedCall.id === expandedRow) {
setExpandedRow(null);
} else {
setExpandedRow(loggedCall.id);
}
}}
/>
);
})}
</Tbody>
</Table>
</Card>
);
}

View File

@@ -1,61 +0,0 @@
import {
ResponsiveContainer,
LineChart,
Line,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
Legend,
} from "recharts";
import { useMemo } from "react";
import { useSelectedProject } from "~/utils/hooks";
import dayjs from "~/utils/dayjs";
import { api } from "~/utils/api";
export default function UsageGraph() {
const { data: selectedProject } = useSelectedProject();
const stats = api.dashboard.stats.useQuery(
{ projectId: selectedProject?.id ?? "" },
{ enabled: !!selectedProject },
);
const data = useMemo(() => {
return (
stats.data?.periods.map(({ period, numQueries, cost }) => ({
period,
Requests: numQueries,
"Total Spent (USD)": parseFloat(cost.toString()),
})) || []
);
}, [stats.data]);
return (
<ResponsiveContainer width="100%" height={400}>
<LineChart data={data} margin={{ top: 5, right: 20, left: 10, bottom: 5 }}>
<XAxis dataKey="period" tickFormatter={(str: string) => dayjs(str).format("MMM D")} />
<YAxis yAxisId="left" dataKey="Requests" orientation="left" stroke="#8884d8" />
<YAxis
yAxisId="right"
dataKey="Total Spent (USD)"
orientation="right"
unit="$"
stroke="#82ca9d"
/>
<Tooltip />
<Legend />
<CartesianGrid stroke="#f5f5f5" />
<Line dataKey="Requests" stroke="#8884d8" yAxisId="left" dot={false} strokeWidth={2} />
<Line
dataKey="Total Spent (USD)"
stroke="#82ca9d"
yAxisId="right"
dot={false}
strokeWidth={2}
/>
</LineChart>
</ResponsiveContainer>
);
}

View File

@@ -0,0 +1,112 @@
import {
HStack,
Icon,
VStack,
Text,
Divider,
Spinner,
AspectRatio,
SkeletonText,
} from "@chakra-ui/react";
import { RiDatabase2Line } from "react-icons/ri";
import { formatTimePast } from "~/utils/dayjs";
import Link from "next/link";
import { useRouter } from "next/router";
import { BsPlusSquare } from "react-icons/bs";
import { api } from "~/utils/api";
import { useHandledAsyncCallback } from "~/utils/hooks";
import { useAppStore } from "~/state/store";
type DatasetData = {
name: string;
numEntries: number;
id: string;
createdAt: Date;
updatedAt: Date;
};
export const DatasetCard = ({ dataset }: { dataset: DatasetData }) => {
return (
<AspectRatio ratio={1.2} w="full">
<VStack
as={Link}
href={{ pathname: "/data/[id]", query: { id: dataset.id } }}
bg="gray.50"
_hover={{ bg: "gray.100" }}
transition="background 0.2s"
cursor="pointer"
borderColor="gray.200"
borderWidth={1}
p={4}
justify="space-between"
>
<HStack w="full" color="gray.700" justify="center">
<Icon as={RiDatabase2Line} boxSize={4} />
<Text fontWeight="bold">{dataset.name}</Text>
</HStack>
<HStack h="full" spacing={4} flex={1} align="center">
<CountLabel label="Rows" count={dataset.numEntries} />
</HStack>
<HStack w="full" color="gray.500" fontSize="xs" textAlign="center">
<Text flex={1}>Created {formatTimePast(dataset.createdAt)}</Text>
<Divider h={4} orientation="vertical" />
<Text flex={1}>Updated {formatTimePast(dataset.updatedAt)}</Text>
</HStack>
</VStack>
</AspectRatio>
);
};
const CountLabel = ({ label, count }: { label: string; count: number }) => {
return (
<VStack alignItems="center" flex={1}>
<Text color="gray.500" fontWeight="bold">
{label}
</Text>
<Text fontSize="sm" color="gray.500">
{count}
</Text>
</VStack>
);
};
export const NewDatasetCard = () => {
const router = useRouter();
const selectedProjectId = useAppStore((s) => s.selectedProjectId);
const createMutation = api.datasets.create.useMutation();
const [createDataset, isLoading] = useHandledAsyncCallback(async () => {
const newDataset = await createMutation.mutateAsync({ projectId: selectedProjectId ?? "" });
await router.push({ pathname: "/data/[id]", query: { id: newDataset.id } });
}, [createMutation, router, selectedProjectId]);
return (
<AspectRatio ratio={1.2} w="full">
<VStack
align="center"
justify="center"
_hover={{ cursor: "pointer", bg: "gray.50" }}
transition="background 0.2s"
cursor="pointer"
borderColor="gray.200"
borderWidth={1}
p={4}
onClick={createDataset}
>
<Icon as={isLoading ? Spinner : BsPlusSquare} boxSize={8} />
<Text display={{ base: "none", md: "block" }} ml={2}>
New Dataset
</Text>
</VStack>
</AspectRatio>
);
};
export const DatasetCardSkeleton = () => (
<AspectRatio ratio={1.2} w="full">
<VStack align="center" borderColor="gray.200" borderWidth={1} p={4} bg="gray.50">
<SkeletonText noOfLines={1} w="80%" />
<SkeletonText noOfLines={2} w="60%" />
<SkeletonText noOfLines={1} w="80%" />
</VStack>
</AspectRatio>
);

View File

@@ -0,0 +1,21 @@
import { useDatasetEntries } from "~/utils/hooks";
import Paginator from "../Paginator";
const DatasetEntriesPaginator = () => {
const { data } = useDatasetEntries();
if (!data) return null;
const { entries, startIndex, lastPage, count } = data;
return (
<Paginator
numItemsLoaded={entries.length}
startIndex={startIndex}
lastPage={lastPage}
count={count}
/>
);
};
export default DatasetEntriesPaginator;

View File

@@ -0,0 +1,31 @@
import { type StackProps, VStack, Table, Th, Tr, Thead, Tbody, Text } from "@chakra-ui/react";
import { useDatasetEntries } from "~/utils/hooks";
import TableRow from "./TableRow";
import DatasetEntriesPaginator from "./DatasetEntriesPaginator";
const DatasetEntriesTable = (props: StackProps) => {
const { data } = useDatasetEntries();
return (
<VStack justifyContent="space-between" {...props}>
<Table variant="simple" sx={{ "table-layout": "fixed", width: "full" }}>
<Thead>
<Tr>
<Th>Input</Th>
<Th>Output</Th>
</Tr>
</Thead>
<Tbody>{data?.entries.map((entry) => <TableRow key={entry.id} entry={entry} />)}</Tbody>
</Table>
{(!data || data.entries.length) === 0 ? (
<Text alignSelf="flex-start" pl={6} color="gray.500">
No entries found
</Text>
) : (
<DatasetEntriesPaginator />
)}
</VStack>
);
};
export default DatasetEntriesTable;

View File

@@ -0,0 +1,26 @@
import { Button, HStack, useDisclosure } from "@chakra-ui/react";
import { BiImport } from "react-icons/bi";
import { BsStars } from "react-icons/bs";
import { GenerateDataModal } from "./GenerateDataModal";
export const DatasetHeaderButtons = () => {
const generateModalDisclosure = useDisclosure();
return (
<>
<HStack>
<Button leftIcon={<BiImport />} colorScheme="blue" variant="ghost">
Import Data
</Button>
<Button leftIcon={<BsStars />} colorScheme="blue" onClick={generateModalDisclosure.onOpen}>
Generate Data
</Button>
</HStack>
<GenerateDataModal
isOpen={generateModalDisclosure.isOpen}
onClose={generateModalDisclosure.onClose}
/>
</>
);
};

View File

@@ -0,0 +1,128 @@
import {
Modal,
ModalBody,
ModalCloseButton,
ModalContent,
ModalHeader,
ModalOverlay,
ModalFooter,
Text,
HStack,
VStack,
Icon,
NumberInput,
NumberInputField,
NumberInputStepper,
NumberIncrementStepper,
NumberDecrementStepper,
Button,
} from "@chakra-ui/react";
import { BsStars } from "react-icons/bs";
import { useState } from "react";
import { useDataset, useHandledAsyncCallback } from "~/utils/hooks";
import { api } from "~/utils/api";
import AutoResizeTextArea from "~/components/AutoResizeTextArea";
export const GenerateDataModal = ({
isOpen,
onClose,
}: {
isOpen: boolean;
onClose: () => void;
}) => {
const utils = api.useContext();
const datasetId = useDataset().data?.id;
const [numToGenerate, setNumToGenerate] = useState<number>(20);
const [inputDescription, setInputDescription] = useState<string>(
"Each input should contain an email body. Half of the emails should contain event details, and the other half should not.",
);
const [outputDescription, setOutputDescription] = useState<string>(
`Each output should contain "true" or "false", where "true" indicates that the email contains event details.`,
);
const generateEntriesMutation = api.datasetEntries.autogenerateEntries.useMutation();
const [generateEntries, generateEntriesInProgress] = useHandledAsyncCallback(async () => {
if (!inputDescription || !outputDescription || !numToGenerate || !datasetId) return;
await generateEntriesMutation.mutateAsync({
datasetId,
inputDescription,
outputDescription,
numToGenerate,
});
await utils.datasetEntries.list.invalidate();
onClose();
}, [
generateEntriesMutation,
onClose,
inputDescription,
outputDescription,
numToGenerate,
datasetId,
]);
return (
<Modal isOpen={isOpen} onClose={onClose} size={{ base: "xl", sm: "2xl", md: "3xl" }}>
<ModalOverlay />
<ModalContent w={1200}>
<ModalHeader>
<HStack>
<Icon as={BsStars} />
<Text>Generate Data</Text>
</HStack>
</ModalHeader>
<ModalCloseButton />
<ModalBody maxW="unset">
<VStack w="full" spacing={8} padding={8} alignItems="flex-start">
<VStack alignItems="flex-start" spacing={2}>
<Text fontWeight="bold">Number of Rows:</Text>
<NumberInput
step={5}
defaultValue={15}
min={0}
max={100}
onChange={(valueString) => setNumToGenerate(parseInt(valueString) || 0)}
value={numToGenerate}
w="24"
>
<NumberInputField />
<NumberInputStepper>
<NumberIncrementStepper />
<NumberDecrementStepper />
</NumberInputStepper>
</NumberInput>
</VStack>
<VStack alignItems="flex-start" w="full" spacing={2}>
<Text fontWeight="bold">Input Description:</Text>
<AutoResizeTextArea
value={inputDescription}
onChange={(e) => setInputDescription(e.target.value)}
placeholder="Each input should contain..."
/>
</VStack>
<VStack alignItems="flex-start" w="full" spacing={2}>
<Text fontWeight="bold">Output Description (optional):</Text>
<AutoResizeTextArea
value={outputDescription}
onChange={(e) => setOutputDescription(e.target.value)}
placeholder="The output should contain..."
/>
</VStack>
</VStack>
</ModalBody>
<ModalFooter>
<Button
colorScheme="blue"
isLoading={generateEntriesInProgress}
isDisabled={!numToGenerate || !inputDescription || !outputDescription}
onClick={generateEntries}
>
Generate
</Button>
</ModalFooter>
</ModalContent>
</Modal>
);
};

View File

@@ -0,0 +1,13 @@
import { Td, Tr } from "@chakra-ui/react";
import { type DatasetEntry } from "@prisma/client";
const TableRow = ({ entry }: { entry: DatasetEntry }) => {
return (
<Tr key={entry.id}>
<Td>{entry.input}</Td>
<Td>{entry.output}</Td>
</Tr>
);
};
export default TableRow;

View File

@@ -7,35 +7,39 @@ import {
Spinner, Spinner,
AspectRatio, AspectRatio,
SkeletonText, SkeletonText,
Card,
} from "@chakra-ui/react"; } from "@chakra-ui/react";
import { RiFlaskLine } from "react-icons/ri"; import { RiFlaskLine } from "react-icons/ri";
import { formatTimePast } from "~/utils/dayjs"; import { formatTimePast } from "~/utils/dayjs";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { BsPlusSquare } from "react-icons/bs"; import { BsPlusSquare } from "react-icons/bs";
import { RouterOutputs, api } from "~/utils/api"; import { api } from "~/utils/api";
import { useHandledAsyncCallback } from "~/utils/hooks"; import { useHandledAsyncCallback } from "~/utils/hooks";
import { useAppStore } from "~/state/store"; import { useAppStore } from "~/state/store";
export const ExperimentCard = ({ exp }: { exp: RouterOutputs["experiments"]["list"][0] }) => { type ExperimentData = {
testScenarioCount: number;
promptVariantCount: number;
id: string;
label: string;
sortIndex: number;
createdAt: Date;
updatedAt: Date;
};
export const ExperimentCard = ({ exp }: { exp: ExperimentData }) => {
return ( return (
<Card <AspectRatio ratio={1.2} w="full">
w="full"
h="full"
cursor="pointer"
p={4}
bg="white"
borderRadius={4}
_hover={{ bg: "gray.100" }}
transition="background 0.2s"
aspectRatio={1.2}
>
<VStack <VStack
as={Link} as={Link}
w="full" href={{ pathname: "/experiments/[id]", query: { id: exp.id } }}
h="full" bg="gray.50"
href={{ pathname: "/experiments/[experimentSlug]", query: { experimentSlug: exp.slug } }} _hover={{ bg: "gray.100" }}
transition="background 0.2s"
cursor="pointer"
borderColor="gray.200"
borderWidth={1}
p={4}
justify="space-between" justify="space-between"
> >
<HStack w="full" color="gray.700" justify="center"> <HStack w="full" color="gray.700" justify="center">
@@ -53,7 +57,7 @@ export const ExperimentCard = ({ exp }: { exp: RouterOutputs["experiments"]["lis
<Text flex={1}>Updated {formatTimePast(exp.updatedAt)}</Text> <Text flex={1}>Updated {formatTimePast(exp.updatedAt)}</Text>
</HStack> </HStack>
</VStack> </VStack>
</Card> </AspectRatio>
); );
}; };
@@ -79,36 +83,36 @@ export const NewExperimentCard = () => {
projectId: selectedProjectId ?? "", projectId: selectedProjectId ?? "",
}); });
await router.push({ await router.push({
pathname: "/experiments/[experimentSlug]", pathname: "/experiments/[id]",
query: { experimentSlug: newExperiment.slug }, query: { id: newExperiment.id },
}); });
}, [createMutation, router, selectedProjectId]); }, [createMutation, router, selectedProjectId]);
return ( return (
<Card <AspectRatio ratio={1.2} w="full">
w="full" <VStack
h="full" align="center"
cursor="pointer" justify="center"
p={4} _hover={{ cursor: "pointer", bg: "gray.50" }}
bg="white" transition="background 0.2s"
borderRadius={4} cursor="pointer"
_hover={{ bg: "gray.100" }} borderColor="gray.200"
transition="background 0.2s" borderWidth={1}
aspectRatio={1.2} p={4}
> onClick={createExperiment}
<VStack align="center" justify="center" w="full" h="full" p={4} onClick={createExperiment}> >
<Icon as={isLoading ? Spinner : BsPlusSquare} boxSize={8} /> <Icon as={isLoading ? Spinner : BsPlusSquare} boxSize={8} />
<Text display={{ base: "none", md: "block" }} ml={2}> <Text display={{ base: "none", md: "block" }} ml={2}>
New Experiment New Experiment
</Text> </Text>
</VStack> </VStack>
</Card> </AspectRatio>
); );
}; };
export const ExperimentCardSkeleton = () => ( export const ExperimentCardSkeleton = () => (
<AspectRatio ratio={1.2} w="full"> <AspectRatio ratio={1.2} w="full">
<VStack align="center" borderColor="gray.200" borderWidth={1} p={4} bg="white"> <VStack align="center" borderColor="gray.200" borderWidth={1} p={4} bg="gray.50">
<SkeletonText noOfLines={1} w="80%" /> <SkeletonText noOfLines={1} w="80%" />
<SkeletonText noOfLines={2} w="60%" /> <SkeletonText noOfLines={2} w="60%" />
<SkeletonText noOfLines={1} w="80%" /> <SkeletonText noOfLines={1} w="80%" />

View File

@@ -16,14 +16,11 @@ export const useOnForkButtonPressed = () => {
const [onFork, isForking] = useHandledAsyncCallback(async () => { const [onFork, isForking] = useHandledAsyncCallback(async () => {
if (!experiment.data?.id || !selectedProjectId) return; if (!experiment.data?.id || !selectedProjectId) return;
const newExperiment = await forkMutation.mutateAsync({ const forkedExperimentId = await forkMutation.mutateAsync({
id: experiment.data.id, id: experiment.data.id,
projectId: selectedProjectId, projectId: selectedProjectId,
}); });
await router.push({ await router.push({ pathname: "/experiments/[id]", query: { id: forkedExperimentId } });
pathname: "/experiments/[experimentSlug]",
query: { experimentSlug: newExperiment.slug },
});
}, [forkMutation, experiment.data?.id, router]); }, [forkMutation, experiment.data?.id, router]);
const onForkButtonPressed = useCallback(() => { const onForkButtonPressed = useCallback(() => {

View File

@@ -1,65 +0,0 @@
import { Card, Table, Thead, Tr, Th, Tbody, Td, VStack, Icon, Text } from "@chakra-ui/react";
import { FaTable } from "react-icons/fa";
import { type FineTuneStatus } from "@prisma/client";
import dayjs from "~/utils/dayjs";
import { useFineTunes } from "~/utils/hooks";
const FineTunesTable = ({}) => {
const { data } = useFineTunes();
const fineTunes = data?.fineTunes || [];
return (
<Card width="100%" overflowX="auto">
{fineTunes.length ? (
<Table>
<Thead>
<Tr>
<Th>ID</Th>
<Th>Created At</Th>
<Th>Base Model</Th>
<Th>Dataset Size</Th>
<Th>Status</Th>
</Tr>
</Thead>
<Tbody>
{fineTunes.map((fineTune) => {
return (
<Tr key={fineTune.id}>
<Td>{fineTune.slug}</Td>
<Td>{dayjs(fineTune.createdAt).format("MMMM D h:mm A")}</Td>
<Td>{fineTune.baseModel}</Td>
<Td>{fineTune.dataset._count.datasetEntries}</Td>
<Td fontSize="sm" fontWeight="bold">
<Text color={getStatusColor(fineTune.status)}>{fineTune.status}</Text>
</Td>
</Tr>
);
})}
</Tbody>
</Table>
) : (
<VStack py={8}>
<Icon as={FaTable} boxSize={16} color="gray.300" />
<Text color="gray.400" fontSize="lg" fontWeight="bold">
No Fine Tunes Found
</Text>
</VStack>
)}
</Card>
);
};
export default FineTunesTable;
const getStatusColor = (status: FineTuneStatus) => {
switch (status) {
case "DEPLOYED":
return "green.500";
case "ERROR":
return "red.500";
default:
return "yellow.500";
}
};

View File

@@ -1,4 +1,4 @@
import { useState, useEffect, useRef } from "react"; import { useState, useEffect } from "react";
import { import {
Heading, Heading,
VStack, VStack,
@@ -9,36 +9,28 @@ import {
Box, Box,
Link as ChakraLink, Link as ChakraLink,
Flex, Flex,
useBreakpointValue,
} from "@chakra-ui/react"; } from "@chakra-ui/react";
import Head from "next/head"; import Head from "next/head";
import Link from "next/link"; import Link from "next/link";
import { BsGearFill, BsGithub, BsPersonCircle } from "react-icons/bs"; import { BsGearFill, BsGithub, BsPersonCircle } from "react-icons/bs";
import { IoStatsChartOutline } from "react-icons/io5"; import { IoStatsChartOutline } from "react-icons/io5";
import { RiHome3Line, RiFlaskLine } from "react-icons/ri"; import { RiDatabase2Line, RiFlaskLine } from "react-icons/ri";
import { FaRobot } from "react-icons/fa";
import { signIn, useSession } from "next-auth/react"; import { signIn, useSession } from "next-auth/react";
import UserMenu from "./UserMenu";
import { env } from "~/env.mjs"; import { env } from "~/env.mjs";
import ProjectMenu from "./ProjectMenu"; import ProjectMenu from "./ProjectMenu";
import NavSidebarOption from "./NavSidebarOption"; import NavSidebarOption from "./NavSidebarOption";
import IconLink from "./IconLink"; import IconLink from "./IconLink";
import { BetaModal } from "./BetaModal";
const Divider = () => <Box h="1px" bgColor="gray.300" w="full" />; const Divider = () => <Box h="1px" bgColor="gray.300" w="full" />;
const NavSidebar = () => { const NavSidebar = () => {
const user = useSession().data; const user = useSession().data;
// Hack to get around initial flash, see https://github.com/chakra-ui/chakra-ui/issues/6452
const isMobile = useBreakpointValue({ base: true, md: false, ssr: false });
const renderCount = useRef(0);
renderCount.current++;
const displayLogo = isMobile && renderCount.current > 1;
return ( return (
<VStack <VStack
align="stretch" align="stretch"
bgColor="gray.50"
py={2} py={2}
px={2} px={2}
pb={0} pb={0}
@@ -48,55 +40,39 @@ const NavSidebar = () => {
borderRightWidth={1} borderRightWidth={1}
borderColor="gray.300" borderColor="gray.300"
> >
{displayLogo && ( <HStack
<> as={Link}
<HStack href="/"
as={Link} _hover={{ textDecoration: "none" }}
href="/" spacing={{ base: 1, md: 0 }}
_hover={{ textDecoration: "none" }} mx={2}
spacing={{ base: 1, md: 0 }} py={{ base: 1, md: 2 }}
mx={2} >
py={{ base: 1, md: 2 }} <Image src="/logo.svg" alt="" boxSize={6} mr={4} ml={{ base: 0.5, md: 0 }} />
> <Heading size="md" fontFamily="inconsolata, monospace">
<Image src="/logo.svg" alt="" boxSize={6} mr={4} ml={{ base: 0.5, md: 0 }} /> OpenPipe
<Heading size="md" fontFamily="inconsolata, monospace"> </Heading>
OpenPipe </HStack>
</Heading> <Divider />
</HStack>
<Divider />
</>
)}
<VStack align="flex-start" overflowY="auto" overflowX="hidden" flex={1}> <VStack align="flex-start" overflowY="auto" overflowX="hidden" flex={1}>
{user != null && ( {user != null && (
<> <>
<ProjectMenu /> <ProjectMenu />
<Divider /> <Divider />
{env.NEXT_PUBLIC_FF_SHOW_LOGGED_CALLS && (
<IconLink icon={RiHome3Line} label="Dashboard" href="/dashboard" beta /> <IconLink icon={IoStatsChartOutline} label="Logged Calls" href="/logged-calls" beta />
<IconLink icon={IoStatsChartOutline} label="Request Logs" href="/request-logs" beta /> )}
<IconLink icon={FaRobot} label="Fine Tunes" href="/fine-tunes" beta />
<IconLink icon={RiFlaskLine} label="Experiments" href="/experiments" /> <IconLink icon={RiFlaskLine} label="Experiments" href="/experiments" />
<VStack w="full" alignItems="flex-start" spacing={0} pt={8}> {env.NEXT_PUBLIC_SHOW_DATA && (
<Text <IconLink icon={RiDatabase2Line} label="Data" href="/data" />
pl={2} )}
pb={2}
fontSize="xs"
fontWeight="bold"
color="gray.500"
display={{ base: "none", md: "flex" }}
>
CONFIGURATION
</Text>
<IconLink icon={BsGearFill} label="Project Settings" href="/project/settings" />
</VStack>
</> </>
)} )}
{user === null && ( {user === null && (
<NavSidebarOption> <NavSidebarOption>
<HStack <HStack
w="full" w="full"
p={{ base: 2, md: 4 }} p={4}
as={ChakraLink} as={ChakraLink}
justifyContent="start" justifyContent="start"
onClick={() => { onClick={() => {
@@ -111,7 +87,20 @@ const NavSidebar = () => {
</NavSidebarOption> </NavSidebarOption>
)} )}
</VStack> </VStack>
<VStack w="full" alignItems="flex-start" spacing={0}>
<Text
pl={2}
pb={2}
fontSize="xs"
fontWeight="bold"
color="gray.500"
display={{ base: "none", md: "flex" }}
>
CONFIGURATION
</Text>
<IconLink icon={BsGearFill} label="Project Settings" href="/project/settings" />
</VStack>
{user && <UserMenu user={user} borderColor={"gray.200"} />}
<Divider /> <Divider />
<VStack spacing={0} align="center"> <VStack spacing={0} align="center">
<ChakraLink <ChakraLink
@@ -132,12 +121,10 @@ export default function AppShell({
children, children,
title, title,
requireAuth, requireAuth,
requireBeta,
}: { }: {
children: React.ReactNode; children: React.ReactNode;
title?: string; title?: string;
requireAuth?: boolean; requireAuth?: boolean;
requireBeta?: boolean;
}) { }) {
const [vh, setVh] = useState("100vh"); // Default height to prevent flicker on initial render const [vh, setVh] = useState("100vh"); // Default height to prevent flicker on initial render
@@ -168,17 +155,14 @@ export default function AppShell({
}, [requireAuth, user, authLoading]); }, [requireAuth, user, authLoading]);
return ( return (
<> <Flex h={vh} w="100vw">
<Flex h={vh} w="100vw"> <Head>
<Head> <title>{title ? `${title} | OpenPipe` : "OpenPipe"}</title>
<title>{title ? `${title} | OpenPipe` : "OpenPipe"}</title> </Head>
</Head> <NavSidebar />
<NavSidebar /> <Box h="100%" flex={1} overflowY="auto">
<Box h="100%" flex={1} overflowY="auto" bgColor="gray.50"> {children}
{children} </Box>
</Box> </Flex>
</Flex>
{requireBeta && !env.NEXT_PUBLIC_FF_SHOW_BETA_FEATURES && <BetaModal />}
</>
); );
} }

View File

@@ -1,67 +0,0 @@
import {
Button,
Modal,
ModalBody,
ModalContent,
ModalFooter,
ModalHeader,
ModalOverlay,
VStack,
Text,
HStack,
Icon,
Link,
} from "@chakra-ui/react";
import { BsStars } from "react-icons/bs";
import { useRouter } from "next/router";
import { useSession } from "next-auth/react";
export const BetaModal = () => {
const router = useRouter();
const session = useSession();
const email = session.data?.user.email ?? "";
return (
<Modal
isOpen
onClose={router.back}
closeOnOverlayClick={false}
size={{ base: "xl", md: "2xl" }}
>
<ModalOverlay />
<ModalContent w={1200}>
<ModalHeader>
<HStack>
<Icon as={BsStars} />
<Text>Beta-Only Feature</Text>
</HStack>
</ModalHeader>
<ModalBody maxW="unset">
<VStack spacing={8} py={4} alignItems="flex-start">
<Text fontSize="md">
This feature is currently in beta. To receive early access to beta-only features, join
the waitlist. You'll receive an email at <b>{email}</b> when you're approved.
</Text>
</VStack>
</ModalBody>
<ModalFooter>
<HStack spacing={4}>
<Button
as={Link}
textDecoration="none !important"
colorScheme="orange"
target="_blank"
href={`https://ax3nafkw0jp.typeform.com/to/ZNpYqvAc#email=${email}`}
>
Join Waitlist
</Button>
<Button colorScheme="blue" onClick={router.back}>
Done
</Button>
</HStack>
</ModalFooter>
</ModalContent>
</Modal>
);
};

View File

@@ -6,18 +6,16 @@ import {
PopoverTrigger, PopoverTrigger,
PopoverContent, PopoverContent,
Flex, Flex,
IconButton,
Icon, Icon,
Divider, Divider,
Button, Button,
useDisclosure, useDisclosure,
Spinner, Spinner,
Link as ChakraLink,
Image,
Box,
} from "@chakra-ui/react"; } from "@chakra-ui/react";
import { useEffect } from "react"; import React, { useEffect, useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { BsPlus, BsPersonCircle } from "react-icons/bs"; import { BsChevronRight, BsGear, BsPlus } from "react-icons/bs";
import { type Project } from "@prisma/client"; import { type Project } from "@prisma/client";
import { useAppStore } from "~/state/store"; import { useAppStore } from "~/state/store";
@@ -25,14 +23,13 @@ import { api } from "~/utils/api";
import NavSidebarOption from "./NavSidebarOption"; import NavSidebarOption from "./NavSidebarOption";
import { useHandledAsyncCallback, useSelectedProject } from "~/utils/hooks"; import { useHandledAsyncCallback, useSelectedProject } from "~/utils/hooks";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { useSession, signOut } from "next-auth/react";
export default function ProjectMenu() { export default function ProjectMenu() {
const router = useRouter(); const router = useRouter();
const utils = api.useContext(); const utils = api.useContext();
const selectedProjectId = useAppStore((s) => s.selectedProjectId); const selectedProjectId = useAppStore((s) => s.selectedProjectId);
const setSelectedProjectId = useAppStore((s) => s.setSelectedProjectId); const setselectedProjectId = useAppStore((s) => s.setselectedProjectId);
const { data: projects } = api.projects.list.useQuery(); const { data: projects } = api.projects.list.useQuery();
@@ -42,9 +39,9 @@ export default function ProjectMenu() {
projects[0] && projects[0] &&
(!selectedProjectId || !projects.find((proj) => proj.id === selectedProjectId)) (!selectedProjectId || !projects.find((proj) => proj.id === selectedProjectId))
) { ) {
setSelectedProjectId(projects[0].id); setselectedProjectId(projects[0].id);
} }
}, [selectedProjectId, setSelectedProjectId, projects]); }, [selectedProjectId, setselectedProjectId, projects]);
const { data: selectedProject } = useSelectedProject(); const { data: selectedProject } = useSelectedProject();
@@ -52,38 +49,28 @@ export default function ProjectMenu() {
const createMutation = api.projects.create.useMutation(); const createMutation = api.projects.create.useMutation();
const [createProject, isLoading] = useHandledAsyncCallback(async () => { const [createProject, isLoading] = useHandledAsyncCallback(async () => {
const newProj = await createMutation.mutateAsync({ name: "Untitled Project" }); const newProj = await createMutation.mutateAsync({ name: "New Project" });
await utils.projects.list.invalidate(); await utils.projects.list.invalidate();
setSelectedProjectId(newProj.id); setselectedProjectId(newProj.id);
await router.push({ pathname: "/project/settings" }); await router.push({ pathname: "/project/settings" });
}, [createMutation, router]); }, [createMutation, router]);
const user = useSession().data;
const profileImage = user?.user.image ? (
<Image src={user.user.image} alt="profile picture" boxSize={6} borderRadius="50%" />
) : (
<Icon as={BsPersonCircle} boxSize={6} />
);
return ( return (
<VStack <VStack w="full" alignItems="flex-start" spacing={0}>
w="full" <Text
alignItems="flex-start" pl={2}
spacing={0} pb={2}
py={1} fontSize="xs"
zIndex={popover.isOpen ? "dropdown" : undefined} fontWeight="bold"
> color="gray.500"
<Popover display={{ base: "none", md: "flex" }}
placement="bottom"
isOpen={popover.isOpen}
onOpen={popover.onOpen}
onClose={popover.onClose}
closeOnBlur
> >
PROJECT
</Text>
<Popover placement="right-end" isOpen={popover.isOpen} onClose={popover.onClose} closeOnBlur>
<PopoverTrigger> <PopoverTrigger>
<NavSidebarOption> <NavSidebarOption>
<HStack w="full"> <HStack w="full" onClick={popover.onToggle}>
<Flex <Flex
p={1} p={1}
borderRadius={4} borderRadius={4}
@@ -96,35 +83,20 @@ export default function ProjectMenu() {
> >
<Text>{selectedProject?.name[0]?.toUpperCase()}</Text> <Text>{selectedProject?.name[0]?.toUpperCase()}</Text>
</Flex> </Flex>
<Text <Text fontSize="sm" display={{ base: "none", md: "block" }} py={1} flex={1}>
fontSize="sm"
display={{ base: "none", md: "block" }}
py={1}
flex={1}
fontWeight="bold"
>
{selectedProject?.name} {selectedProject?.name}
</Text> </Text>
<Box mr={2}>{profileImage}</Box> <Icon as={BsChevronRight} boxSize={4} color="gray.500" />
</HStack> </HStack>
</NavSidebarOption> </NavSidebarOption>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent <PopoverContent _focusVisible={{ outline: "unset" }} ml={-1} w="auto" minW={100} maxW={280}>
_focusVisible={{ outline: "unset" }} <VStack alignItems="flex-start" spacing={2} py={4} px={2}>
w={220} <Text color="gray.500" fontSize="xs" fontWeight="bold" pb={1}>
ml={{ base: 2, md: 0 }} PROJECTS
boxShadow="0 0 40px 4px rgba(0, 0, 0, 0.1);"
fontSize="sm"
>
<VStack alignItems="flex-start" spacing={1} py={1}>
<Text px={3} py={2}>
{user?.user.email}
</Text> </Text>
<Divider /> <Divider />
<Text alignSelf="flex-start" fontWeight="bold" px={3} pt={2}> <VStack spacing={0} w="full">
Your Projects
</Text>
<VStack spacing={0} w="full" px={1}>
{projects?.map((proj) => ( {projects?.map((proj) => (
<ProjectOption <ProjectOption
key={proj.id} key={proj.id}
@@ -133,38 +105,19 @@ export default function ProjectMenu() {
onClose={popover.onClose} onClose={popover.onClose}
/> />
))} ))}
<HStack
as={Button}
variant="ghost"
colorScheme="blue"
color="blue.400"
fontSize="sm"
justifyContent="flex-start"
onClick={createProject}
w="full"
borderRadius={4}
spacing={0}
>
<Text>Add project</Text>
<Icon as={isLoading ? Spinner : BsPlus} boxSize={4} strokeWidth={0.5} />
</HStack>
</VStack>
<Divider />
<VStack w="full" px={1}>
<ChakraLink
onClick={() => {
signOut().catch(console.error);
}}
_hover={{ bgColor: "gray.200", textDecoration: "none" }}
w="full"
py={2}
px={2}
borderRadius={4}
>
<Text>Sign out</Text>
</ChakraLink>
</VStack> </VStack>
<HStack
as={Button}
variant="ghost"
colorScheme="blue"
color="blue.400"
pr={8}
w="full"
onClick={createProject}
>
<Icon as={isLoading ? Spinner : BsPlus} boxSize={6} />
<Text>New project</Text>
</HStack>
</VStack> </VStack>
</PopoverContent> </PopoverContent>
</Popover> </Popover>
@@ -181,26 +134,38 @@ const ProjectOption = ({
isActive: boolean; isActive: boolean;
onClose: () => void; onClose: () => void;
}) => { }) => {
const setSelectedProjectId = useAppStore((s) => s.setSelectedProjectId); const setselectedProjectId = useAppStore((s) => s.setselectedProjectId);
const [gearHovered, setGearHovered] = useState(false);
return ( return (
<HStack <HStack
as={Link} as={Link}
href="/experiments" href="/experiments"
onClick={() => { onClick={() => {
setSelectedProjectId(proj.id); setselectedProjectId(proj.id);
onClose(); onClose();
}} }}
w="full" w="full"
justifyContent="space-between" justifyContent="space-between"
_hover={{ bgColor: "gray.200", textDecoration: "none" }} bgColor={isActive ? "gray.100" : "transparent"}
bgColor={isActive ? "gray.100" : undefined} _hover={gearHovered ? undefined : { bgColor: "gray.200", textDecoration: "none" }}
py={2} p={2}
px={4}
borderRadius={4} borderRadius={4}
spacing={4} spacing={4}
> >
<Text>{proj.name}</Text> <Text>{proj.name}</Text>
<IconButton
as={Link}
href="/project/settings"
aria-label={`Open ${proj.name} settings`}
icon={<Icon as={BsGear} boxSize={5} strokeWidth={0.5} color="gray.500" />}
variant="ghost"
size="xs"
p={0}
onMouseEnter={() => setGearHovered(true)}
onMouseLeave={() => setGearHovered(false)}
_hover={{ bgColor: isActive ? "gray.300" : "gray.100", transitionDelay: 0 }}
borderRadius={4}
/>
</HStack> </HStack>
); );
}; };

View File

@@ -23,48 +23,50 @@ export default function UserMenu({ user, ...rest }: { user: Session } & StackPro
); );
return ( return (
<Popover placement="right"> <>
<PopoverTrigger> <Popover placement="right">
<NavSidebarOption> <PopoverTrigger>
<HStack <NavSidebarOption>
// Weird values to make mobile look right; can clean up when we make the sidebar disappear on mobile <HStack
py={2} // Weird values to make mobile look right; can clean up when we make the sidebar disappear on mobile
px={1} py={2}
spacing={3} px={1}
{...rest} spacing={3}
> {...rest}
{profileImage} >
<VStack spacing={0} align="start" flex={1} flexShrink={1}> {profileImage}
<Text fontWeight="bold" fontSize="sm"> <VStack spacing={0} align="start" flex={1} flexShrink={1}>
{user.user.name} <Text fontWeight="bold" fontSize="sm">
</Text> {user.user.name}
<Text color="gray.500" fontSize="xs"> </Text>
{/* {user.user.email} */} <Text color="gray.500" fontSize="xs">
</Text> {/* {user.user.email} */}
</VStack> </Text>
<Icon as={BsChevronRight} boxSize={4} color="gray.500" /> </VStack>
</HStack> <Icon as={BsChevronRight} boxSize={4} color="gray.500" />
</NavSidebarOption> </HStack>
</PopoverTrigger> </NavSidebarOption>
<PopoverContent _focusVisible={{ outline: "unset" }} ml={-1} minW={48} w="full"> </PopoverTrigger>
<VStack align="stretch" spacing={0}> <PopoverContent _focusVisible={{ outline: "unset" }} ml={-1} minW={48} w="full">
{/* sign out */} <VStack align="stretch" spacing={0}>
<HStack {/* sign out */}
as={Link} <HStack
onClick={() => { as={Link}
signOut().catch(console.error); onClick={() => {
}} signOut().catch(console.error);
px={4} }}
py={2} px={4}
spacing={4} py={2}
color="gray.500" spacing={4}
fontSize="sm" color="gray.500"
> fontSize="sm"
<Icon as={BsBoxArrowRight} boxSize={6} /> >
<Text>Sign out</Text> <Icon as={BsBoxArrowRight} boxSize={6} />
</HStack> <Text>Sign out</Text>
</VStack> </HStack>
</PopoverContent> </VStack>
</Popover> </PopoverContent>
</Popover>
</>
); );
} }

View File

@@ -1,128 +0,0 @@
import {
Button,
FormControl,
FormLabel,
Input,
FormHelperText,
HStack,
Modal,
ModalBody,
ModalCloseButton,
ModalContent,
ModalFooter,
ModalHeader,
ModalOverlay,
Spinner,
Text,
VStack,
RadioGroup,
Radio,
} from "@chakra-ui/react";
import { useState, useEffect } from "react";
import { api } from "~/utils/api";
import { useHandledAsyncCallback, useSelectedProject } from "~/utils/hooks";
import { maybeReportError } from "~/utils/errorHandling/maybeReportError";
import { type ProjectUserRole } from "@prisma/client";
export const InviteMemberModal = ({
isOpen,
onClose,
}: {
isOpen: boolean;
onClose: () => void;
}) => {
const selectedProject = useSelectedProject().data;
const utils = api.useContext();
const [email, setEmail] = useState("");
const [role, setRole] = useState<ProjectUserRole>("MEMBER");
useEffect(() => {
setEmail("");
setRole("MEMBER");
}, [isOpen]);
const emailIsValid = !email || !email.match(/.+@.+\..+/);
const inviteMemberMutation = api.users.inviteToProject.useMutation();
const [inviteMember, isInviting] = useHandledAsyncCallback(async () => {
if (!selectedProject?.id || !role) return;
const resp = await inviteMemberMutation.mutateAsync({
projectId: selectedProject.id,
email,
role,
});
if (maybeReportError(resp)) return;
await utils.projects.get.invalidate();
onClose();
}, [inviteMemberMutation, email, role, selectedProject?.id, onClose]);
return (
<Modal isOpen={isOpen} onClose={onClose}>
<ModalOverlay />
<ModalContent w={1200}>
<ModalHeader>
<HStack>
<Text>Invite Member</Text>
</HStack>
</ModalHeader>
<ModalCloseButton />
<ModalBody>
<VStack spacing={8} alignItems="flex-start">
<Text>
Invite a new member to <b>{selectedProject?.name}</b>.
</Text>
<RadioGroup
value={role}
onChange={(e) => setRole(e as ProjectUserRole)}
colorScheme="orange"
>
<VStack w="full" alignItems="flex-start">
<Radio value="MEMBER">
<Text fontSize="sm">MEMBER</Text>
</Radio>
<Radio value="ADMIN">
<Text fontSize="sm">ADMIN</Text>
</Radio>
</VStack>
</RadioGroup>
<FormControl>
<FormLabel>Email</FormLabel>
<Input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && (e.metaKey || e.ctrlKey || e.shiftKey)) {
e.preventDefault();
e.currentTarget.blur();
inviteMember();
}
}}
/>
<FormHelperText>Enter the email of the person you want to invite.</FormHelperText>
</FormControl>
</VStack>
</ModalBody>
<ModalFooter mt={4}>
<HStack>
<Button colorScheme="gray" onClick={onClose} minW={24}>
<Text>Cancel</Text>
</Button>
<Button
colorScheme="orange"
onClick={inviteMember}
minW={24}
isDisabled={emailIsValid || isInviting}
>
{isInviting ? <Spinner boxSize={4} /> : <Text>Send Invitation</Text>}
</Button>
</HStack>
</ModalFooter>
</ModalContent>
</Modal>
);
};

View File

@@ -1,145 +0,0 @@
import { useMemo, useState } from "react";
import {
Table,
Thead,
Tr,
Th,
Tbody,
Td,
IconButton,
useDisclosure,
Text,
Button,
} from "@chakra-ui/react";
import { useSession } from "next-auth/react";
import { BsTrash } from "react-icons/bs";
import { type User } from "@prisma/client";
import { useHandledAsyncCallback, useSelectedProject } from "~/utils/hooks";
import { InviteMemberModal } from "./InviteMemberModal";
import { RemoveMemberDialog } from "./RemoveMemberDialog";
import { api } from "~/utils/api";
import { maybeReportError } from "~/utils/errorHandling/maybeReportError";
const MemberTable = () => {
const selectedProject = useSelectedProject().data;
const session = useSession().data;
const utils = api.useContext();
const [memberToRemove, setMemberToRemove] = useState<User | null>(null);
const inviteMemberModal = useDisclosure();
const cancelInvitationMutation = api.users.cancelProjectInvitation.useMutation();
const [cancelInvitation, isCancelling] = useHandledAsyncCallback(
async (invitationToken: string) => {
if (!selectedProject?.id) return;
const resp = await cancelInvitationMutation.mutateAsync({
invitationToken,
});
if (maybeReportError(resp)) return;
await utils.projects.get.invalidate();
},
[selectedProject?.id, cancelInvitationMutation],
);
const sortedMembers = useMemo(() => {
if (!selectedProject?.projectUsers) return [];
return selectedProject.projectUsers.sort((a, b) => {
if (a.role === b.role) return a.createdAt < b.createdAt ? -1 : 1;
// Take advantage of fact that ADMIN is alphabetically before MEMBER
return a.role < b.role ? -1 : 1;
});
}, [selectedProject?.projectUsers]);
return (
<>
<Table fontSize={{ base: "sm", md: "md" }}>
<Thead
sx={{
th: {
base: { px: 0 },
md: { px: 6 },
},
}}
>
<Tr>
<Th>Name</Th>
<Th display={{ base: "none", md: "table-cell" }}>Email</Th>
<Th>Role</Th>
{selectedProject?.role === "ADMIN" && <Th />}
</Tr>
</Thead>
<Tbody
sx={{
td: {
base: { px: 0 },
md: { px: 6 },
},
}}
>
{selectedProject &&
sortedMembers.map((member) => {
return (
<Tr key={member.id}>
<Td>
<Text fontWeight="bold">{member.user.name}</Text>
</Td>
<Td display={{ base: "none", md: "table-cell" }} h="full">
{member.user.email}
</Td>
<Td fontSize={{ base: "xs", md: "sm" }}>{member.role}</Td>
{selectedProject.role === "ADMIN" && (
<Td textAlign="end">
{member.user.id !== session?.user?.id &&
member.user.id !== selectedProject.personalProjectUserId && (
<IconButton
aria-label="Remove member"
colorScheme="red"
icon={<BsTrash />}
onClick={() => setMemberToRemove(member.user)}
/>
)}
</Td>
)}
</Tr>
);
})}
{selectedProject?.projectUserInvitations?.map((invitation) => {
return (
<Tr key={invitation.id}>
<Td>
<Text as="i">Invitation pending</Text>
</Td>
<Td>{invitation.email}</Td>
<Td fontSize="sm">{invitation.role}</Td>
{selectedProject.role === "ADMIN" && (
<Td textAlign="end">
<Button
size="sm"
colorScheme="red"
variant="ghost"
onClick={() => cancelInvitation(invitation.invitationToken)}
isLoading={isCancelling}
>
Cancel
</Button>
</Td>
)}
</Tr>
);
})}
</Tbody>
</Table>
<InviteMemberModal isOpen={inviteMemberModal.isOpen} onClose={inviteMemberModal.onClose} />
<RemoveMemberDialog
member={memberToRemove}
isOpen={!!memberToRemove}
onClose={() => setMemberToRemove(null)}
/>
</>
);
};
export default MemberTable;

View File

@@ -1,71 +0,0 @@
import {
Button,
AlertDialog,
AlertDialogBody,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogContent,
AlertDialogOverlay,
Text,
VStack,
Spinner,
} from "@chakra-ui/react";
import { type User } from "@prisma/client";
import { useRouter } from "next/router";
import { useRef } from "react";
import { api } from "~/utils/api";
import { useHandledAsyncCallback, useSelectedProject } from "~/utils/hooks";
export const RemoveMemberDialog = ({
isOpen,
onClose,
member,
}: {
isOpen: boolean;
onClose: () => void;
member: User | null;
}) => {
const selectedProject = useSelectedProject();
const removeUserMutation = api.users.removeUserFromProject.useMutation();
const utils = api.useContext();
const router = useRouter();
const cancelRef = useRef<HTMLButtonElement>(null);
const [onRemoveConfirm, isRemoving] = useHandledAsyncCallback(async () => {
if (!selectedProject.data?.id || !member?.id) return;
await removeUserMutation.mutateAsync({ projectId: selectedProject.data.id, userId: member.id });
await utils.projects.get.invalidate();
onClose();
}, [removeUserMutation, selectedProject, router]);
return (
<AlertDialog isOpen={isOpen} leastDestructiveRef={cancelRef} onClose={onClose}>
<AlertDialogOverlay>
<AlertDialogContent>
<AlertDialogHeader fontSize="lg" fontWeight="bold">
Remove Member
</AlertDialogHeader>
<AlertDialogBody>
<VStack spacing={4} alignItems="flex-start">
<Text>
Are you sure you want to remove <b>{member?.name}</b> from the project?
</Text>
</VStack>
</AlertDialogBody>
<AlertDialogFooter>
<Button ref={cancelRef} onClick={onClose}>
Cancel
</Button>
<Button colorScheme="red" onClick={onRemoveConfirm} ml={3} w={20}>
{isRemoving ? <Spinner /> : "Remove"}
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialogOverlay>
</AlertDialog>
);
};

View File

@@ -1,30 +0,0 @@
import { Button, HStack, type ButtonProps, Icon, Text } from "@chakra-ui/react";
import { type IconType } from "react-icons";
const ActionButton = ({
icon,
label,
...buttonProps
}: { icon: IconType; label: string } & ButtonProps) => {
return (
<Button
colorScheme="blue"
color="black"
bgColor="white"
borderColor="gray.300"
borderRadius={4}
variant="outline"
size="sm"
fontSize="sm"
fontWeight="normal"
{...buttonProps}
>
<HStack spacing={1}>
{icon && <Icon as={icon} />}
<Text display={{ base: "none", md: "flex" }}>{label}</Text>
</HStack>
</Button>
);
};
export default ActionButton;

View File

@@ -1,117 +0,0 @@
import {
Icon,
Popover,
PopoverTrigger,
PopoverContent,
VStack,
HStack,
Button,
Text,
useDisclosure,
Box,
} from "@chakra-ui/react";
import { BiCheck } from "react-icons/bi";
import { BsToggles } from "react-icons/bs";
import { useMemo } from "react";
import { useIsClientRehydrated, useTagNames } from "~/utils/hooks";
import { useAppStore } from "~/state/store";
import { StaticColumnKeys } from "~/state/columnVisiblitySlice";
import ActionButton from "./ActionButton";
const ColumnVisiblityDropdown = () => {
const tagNames = useTagNames().data;
const visibleColumns = useAppStore((s) => s.columnVisibility.visibleColumns);
const toggleColumnVisibility = useAppStore((s) => s.columnVisibility.toggleColumnVisibility);
const totalColumns = Object.keys(StaticColumnKeys).length + (tagNames?.length ?? 0);
const popover = useDisclosure();
const columnVisiblityOptions = useMemo(() => {
const options: { label: string; key: string }[] = [
{
label: "Sent At",
key: StaticColumnKeys.SENT_AT,
},
{
label: "Model",
key: StaticColumnKeys.MODEL,
},
{
label: "Duration",
key: StaticColumnKeys.DURATION,
},
{
label: "Input Tokens",
key: StaticColumnKeys.INPUT_TOKENS,
},
{
label: "Output Tokens",
key: StaticColumnKeys.OUTPUT_TOKENS,
},
{
label: "Status Code",
key: StaticColumnKeys.STATUS_CODE,
},
];
for (const tagName of tagNames ?? []) {
options.push({
label: tagName,
key: tagName,
});
}
return options;
}, [tagNames]);
const isClientRehydrated = useIsClientRehydrated();
if (!isClientRehydrated) return null;
return (
<Popover
placement="bottom-start"
isOpen={popover.isOpen}
onOpen={popover.onOpen}
onClose={popover.onClose}
>
<PopoverTrigger>
<Box>
<ActionButton
label={`Columns (${visibleColumns.size}/${totalColumns})`}
icon={BsToggles}
/>
</Box>
</PopoverTrigger>
<PopoverContent boxShadow="0 0 40px 4px rgba(0, 0, 0, 0.1);" minW={0} w="auto">
<VStack spacing={0} maxH={400} overflowY="auto">
{columnVisiblityOptions?.map((option, index) => (
<HStack
key={index}
as={Button}
onClick={() => toggleColumnVisibility(option.key)}
w="full"
minH={10}
variant="ghost"
justifyContent="space-between"
fontWeight="semibold"
borderRadius={0}
colorScheme="blue"
color="black"
fontSize="sm"
borderBottomWidth={1}
>
<Text mr={16}>{option.label}</Text>
<Box w={5}>
{visibleColumns.has(option.key) && (
<Icon as={BiCheck} color="blue.500" boxSize={5} />
)}
</Box>
</HStack>
))}
</VStack>
</PopoverContent>
</Popover>
);
};
export default ColumnVisiblityDropdown;

View File

@@ -1,161 +0,0 @@
import { useState, useEffect } from "react";
import {
Modal,
ModalOverlay,
ModalContent,
ModalHeader,
ModalCloseButton,
ModalBody,
ModalFooter,
HStack,
VStack,
Icon,
Text,
Button,
useDisclosure,
type UseDisclosureReturn,
Input,
} from "@chakra-ui/react";
import { FaRobot } from "react-icons/fa";
import humanId from "human-id";
import { useRouter } from "next/router";
import { useHandledAsyncCallback } from "~/utils/hooks";
import { api } from "~/utils/api";
import { useAppStore } from "~/state/store";
import ActionButton from "./ActionButton";
import InputDropdown from "../InputDropdown";
import { FiChevronDown } from "react-icons/fi";
const SUPPORTED_BASE_MODELS = ["llama2-7b", "llama2-13b", "llama2-70b", "gpt-3.5-turbo"];
const FineTuneButton = () => {
const selectedLogIds = useAppStore((s) => s.selectedLogs.selectedLogIds);
const disclosure = useDisclosure();
return (
<>
<ActionButton
onClick={disclosure.onOpen}
label="Fine Tune"
icon={FaRobot}
isDisabled={selectedLogIds.size === 0}
/>
<FineTuneModal disclosure={disclosure} />
</>
);
};
export default FineTuneButton;
const FineTuneModal = ({ disclosure }: { disclosure: UseDisclosureReturn }) => {
const selectedProjectId = useAppStore((s) => s.selectedProjectId);
const selectedLogIds = useAppStore((s) => s.selectedLogs.selectedLogIds);
const clearSelectedLogIds = useAppStore((s) => s.selectedLogs.clearSelectedLogIds);
const [selectedBaseModel, setSelectedBaseModel] = useState(SUPPORTED_BASE_MODELS[0]);
const [modelSlug, setModelSlug] = useState(humanId({ separator: "-", capitalize: false }));
useEffect(() => {
if (disclosure.isOpen) {
setSelectedBaseModel(SUPPORTED_BASE_MODELS[0]);
setModelSlug(humanId({ separator: "-", capitalize: false }));
}
}, [disclosure.isOpen]);
const utils = api.useContext();
const router = useRouter();
const createFineTuneMutation = api.fineTunes.create.useMutation();
const [createFineTune, creationInProgress] = useHandledAsyncCallback(async () => {
if (!selectedProjectId || !modelSlug || !selectedBaseModel || !selectedLogIds.size) return;
await createFineTuneMutation.mutateAsync({
projectId: selectedProjectId,
slug: modelSlug,
baseModel: selectedBaseModel,
selectedLogIds: Array.from(selectedLogIds),
});
await utils.fineTunes.list.invalidate();
await router.push({ pathname: "/fine-tunes" });
clearSelectedLogIds();
disclosure.onClose();
}, [createFineTuneMutation, selectedProjectId, selectedLogIds, modelSlug, selectedBaseModel]);
return (
<Modal size={{ base: "xl", md: "2xl" }} {...disclosure}>
<ModalOverlay />
<ModalContent w={1200}>
<ModalHeader>
<HStack>
<Icon as={FaRobot} />
<Text>Fine Tune</Text>
</HStack>
</ModalHeader>
<ModalCloseButton />
<ModalBody maxW="unset">
<VStack w="full" spacing={8} pt={4} alignItems="flex-start">
<Text>
We'll train on the <b>{selectedLogIds.size}</b> logs you've selected.
</Text>
<VStack>
<HStack spacing={2} w="full">
<Text fontWeight="bold" w={36}>
Model ID:
</Text>
<Input
value={modelSlug}
onChange={(e) => setModelSlug(e.target.value)}
w={48}
placeholder="unique-id"
onKeyDown={(e) => {
// If the user types anything other than a-z, A-Z, or 0-9, replace it with -
if (!/[a-zA-Z0-9]/.test(e.key)) {
e.preventDefault();
setModelSlug((s) => s && `${s}-`);
}
}}
/>
</HStack>
<HStack spacing={2}>
<Text fontWeight="bold" w={36}>
Base model:
</Text>
<InputDropdown
options={SUPPORTED_BASE_MODELS}
selectedOption={selectedBaseModel}
onSelect={(option) => setSelectedBaseModel(option)}
inputGroupProps={{ w: 48 }}
/>
</HStack>
</VStack>
<Button variant="unstyled" color="blue.600">
<HStack>
<Text>Advanced Options</Text>
<Icon as={FiChevronDown} />
</HStack>
</Button>
</VStack>
</ModalBody>
<ModalFooter>
<HStack>
<Button colorScheme="gray" onClick={disclosure.onClose} minW={24}>
Cancel
</Button>
<Button
colorScheme="blue"
onClick={createFineTune}
isLoading={creationInProgress}
minW={24}
isDisabled={!modelSlug}
>
Start Training
</Button>
</HStack>
</ModalFooter>
</ModalContent>
</Modal>
);
};

View File

@@ -1,55 +0,0 @@
import { Box, IconButton, useToast } from "@chakra-ui/react";
import { CopyIcon } from "lucide-react";
import SyntaxHighlighter from "react-syntax-highlighter";
import { atelierCaveLight } from "react-syntax-highlighter/dist/cjs/styles/hljs";
import stringify from "json-stringify-pretty-compact";
const FormattedJson = ({ json }: { json: any }) => {
const jsonString = stringify(json, { maxLength: 40 });
const toast = useToast();
const copyToClipboard = async (text: string) => {
try {
await navigator.clipboard.writeText(text);
toast({
title: "Copied to clipboard",
status: "success",
duration: 2000,
});
} catch (err) {
toast({
title: "Failed to copy to clipboard",
status: "error",
duration: 2000,
});
}
};
return (
<Box position="relative" fontSize="sm" borderRadius="md" overflow="hidden">
<SyntaxHighlighter
customStyle={{ overflowX: "unset" }}
language="json"
style={atelierCaveLight}
lineProps={{
style: { wordBreak: "break-all", whiteSpace: "pre-wrap" },
}}
wrapLines
>
{jsonString}
</SyntaxHighlighter>
<IconButton
aria-label="Copy"
icon={<CopyIcon />}
position="absolute"
top={1}
right={1}
size="xs"
variant="ghost"
onClick={() => void copyToClipboard(jsonString)}
/>
</Box>
);
};
export { FormattedJson };

View File

@@ -1,30 +0,0 @@
import { Button, HStack, Icon, Text } from "@chakra-ui/react";
import { BsPlus } from "react-icons/bs";
import { comparators, defaultFilterableFields } from "~/state/logFiltersSlice";
import { useAppStore } from "~/state/store";
const AddFilterButton = () => {
const addFilter = useAppStore((s) => s.logFilters.addFilter);
return (
<HStack
as={Button}
variant="ghost"
onClick={() =>
addFilter({
id: Date.now().toString(),
field: defaultFilterableFields[0],
comparator: comparators[0],
value: "",
})
}
spacing={0}
fontSize="sm"
>
<Icon as={BsPlus} boxSize={5} />
<Text>Add Filter</Text>
</HStack>
);
};
export default AddFilterButton;

View File

@@ -1,44 +0,0 @@
import { useCallback, useState } from "react";
import { HStack, IconButton, Input } from "@chakra-ui/react";
import { BsTrash } from "react-icons/bs";
import { type LogFilter } from "~/state/logFiltersSlice";
import { useAppStore } from "~/state/store";
import { debounce } from "lodash-es";
import SelectFieldDropdown from "./SelectFieldDropdown";
import SelectComparatorDropdown from "./SelectComparatorDropdown";
const LogFilter = ({ filter }: { filter: LogFilter }) => {
const updateFilter = useAppStore((s) => s.logFilters.updateFilter);
const deleteFilter = useAppStore((s) => s.logFilters.deleteFilter);
const [editedValue, setEditedValue] = useState(filter.value);
const debouncedUpdateFilter = useCallback(
debounce((filter: LogFilter) => updateFilter(filter), 500, {
leading: true,
}),
[updateFilter],
);
return (
<HStack>
<SelectFieldDropdown filter={filter} />
<SelectComparatorDropdown filter={filter} />
<Input
value={editedValue}
onChange={(e) => {
setEditedValue(e.target.value);
debouncedUpdateFilter({ ...filter, value: e.target.value });
}}
/>
<IconButton
aria-label="Delete Filter"
icon={<BsTrash />}
onClick={() => deleteFilter(filter.id)}
/>
</HStack>
);
};
export default LogFilter;

View File

@@ -1,30 +0,0 @@
import { VStack, Text } from "@chakra-ui/react";
import AddFilterButton from "./AddFilterButton";
import { useAppStore } from "~/state/store";
import LogFilter from "./LogFilter";
const LogFilters = () => {
const filters = useAppStore((s) => s.logFilters.filters);
return (
<VStack
bgColor="white"
borderRadius={8}
borderWidth={1}
w="full"
alignItems="flex-start"
p={4}
spacing={4}
>
<Text fontWeight="bold" color="gray.500">
Filters
</Text>
{filters.map((filter) => (
<LogFilter key={filter.id} filter={filter} />
))}
<AddFilterButton />
</VStack>
);
};
export default LogFilters;

View File

@@ -1,19 +0,0 @@
import { comparators, type LogFilter } from "~/state/logFiltersSlice";
import { useAppStore } from "~/state/store";
import InputDropdown from "~/components/InputDropdown";
const SelectComparatorDropdown = ({ filter }: { filter: LogFilter }) => {
const updateFilter = useAppStore((s) => s.logFilters.updateFilter);
const { comparator } = filter;
return (
<InputDropdown
options={comparators}
selectedOption={comparator}
onSelect={(option) => updateFilter({ ...filter, comparator: option })}
/>
);
};
export default SelectComparatorDropdown;

View File

@@ -1,22 +0,0 @@
import { defaultFilterableFields, type LogFilter } from "~/state/logFiltersSlice";
import { useAppStore } from "~/state/store";
import { useTagNames } from "~/utils/hooks";
import InputDropdown from "~/components/InputDropdown";
const SelectFieldDropdown = ({ filter }: { filter: LogFilter }) => {
const tagNames = useTagNames().data;
const updateFilter = useAppStore((s) => s.logFilters.updateFilter);
const { field } = filter;
return (
<InputDropdown
options={[...defaultFilterableFields, ...(tagNames || [])]}
selectedOption={field}
onSelect={(option) => updateFilter({ ...filter, field: option })}
/>
);
};
export default SelectFieldDropdown;

View File

@@ -1,16 +0,0 @@
import { type StackProps } from "@chakra-ui/react";
import { useLoggedCalls } from "~/utils/hooks";
import Paginator from "../Paginator";
const LoggedCallsPaginator = (props: StackProps) => {
const { data } = useLoggedCalls();
if (!data) return null;
const { count } = data;
return <Paginator count={count} {...props} />;
};
export default LoggedCallsPaginator;

View File

@@ -1,36 +0,0 @@
import { Card, Table, Tbody } from "@chakra-ui/react";
import { useState } from "react";
import { useLoggedCalls } from "~/utils/hooks";
import { TableHeader, TableRow } from "./TableRow";
export default function LoggedCallsTable() {
const [expandedRow, setExpandedRow] = useState<string | null>(null);
const loggedCalls = useLoggedCalls().data;
return (
<Card width="100%" overflowX="auto">
<Table>
<TableHeader showOptions />
<Tbody>
{loggedCalls?.calls?.map((loggedCall) => {
return (
<TableRow
key={loggedCall.id}
loggedCall={loggedCall}
isExpanded={loggedCall.id === expandedRow}
onToggle={() => {
if (loggedCall.id === expandedRow) {
setExpandedRow(null);
} else {
setExpandedRow(loggedCall.id);
}
}}
showOptions
/>
);
})}
</Tbody>
</Table>
</Card>
);
}

View File

@@ -1,200 +0,0 @@
import {
Box,
Heading,
Td,
Tr,
Thead,
Th,
Tooltip,
Collapse,
HStack,
VStack,
Button,
ButtonGroup,
Text,
Checkbox,
} from "@chakra-ui/react";
import Link from "next/link";
import dayjs from "~/utils/dayjs";
import { type RouterOutputs } from "~/utils/api";
import { FormattedJson } from "./FormattedJson";
import { useAppStore } from "~/state/store";
import { useIsClientRehydrated, useLoggedCalls, useTagNames } from "~/utils/hooks";
import { useMemo } from "react";
import { StaticColumnKeys } from "~/state/columnVisiblitySlice";
type LoggedCall = RouterOutputs["loggedCalls"]["list"]["calls"][0];
export const TableHeader = ({ showOptions }: { showOptions?: boolean }) => {
const matchingLogIds = useLoggedCalls().data?.matchingLogIds;
const selectedLogIds = useAppStore((s) => s.selectedLogs.selectedLogIds);
const addAll = useAppStore((s) => s.selectedLogs.addSelectedLogIds);
const clearAll = useAppStore((s) => s.selectedLogs.clearSelectedLogIds);
const allSelected = useMemo(() => {
if (!matchingLogIds || !matchingLogIds.length) return false;
return matchingLogIds.every((id) => selectedLogIds.has(id));
}, [selectedLogIds, matchingLogIds]);
const tagNames = useTagNames().data;
const visibleColumns = useAppStore((s) => s.columnVisibility.visibleColumns);
const isClientRehydrated = useIsClientRehydrated();
if (!isClientRehydrated) return null;
return (
<Thead>
<Tr>
{showOptions && (
<Th pr={0}>
<HStack minW={16}>
<Checkbox
isChecked={allSelected}
onChange={() => {
allSelected ? clearAll() : addAll(matchingLogIds || []);
}}
/>
<Text>
({selectedLogIds.size ? `${selectedLogIds.size}/` : ""}
{matchingLogIds?.length || 0})
</Text>
</HStack>
</Th>
)}
{visibleColumns.has(StaticColumnKeys.SENT_AT) && <Th>Sent At</Th>}
{visibleColumns.has(StaticColumnKeys.MODEL) && <Th>Model</Th>}
{tagNames
?.filter((tagName) => visibleColumns.has(tagName))
.map((tagName) => (
<Th key={tagName} textTransform={"none"}>
{tagName}
</Th>
))}
{visibleColumns.has(StaticColumnKeys.DURATION) && <Th isNumeric>Duration</Th>}
{visibleColumns.has(StaticColumnKeys.INPUT_TOKENS) && <Th isNumeric>Input tokens</Th>}
{visibleColumns.has(StaticColumnKeys.OUTPUT_TOKENS) && <Th isNumeric>Output tokens</Th>}
{visibleColumns.has(StaticColumnKeys.STATUS_CODE) && <Th isNumeric>Status</Th>}
</Tr>
</Thead>
);
};
export const TableRow = ({
loggedCall,
isExpanded,
onToggle,
showOptions,
}: {
loggedCall: LoggedCall;
isExpanded: boolean;
onToggle: () => void;
showOptions?: boolean;
}) => {
const isError = loggedCall.modelResponse?.statusCode !== 200;
const requestedAt = dayjs(loggedCall.requestedAt).format("MMMM D h:mm A");
const fullTime = dayjs(loggedCall.requestedAt).toString();
const isChecked = useAppStore((s) => s.selectedLogs.selectedLogIds.has(loggedCall.id));
const toggleChecked = useAppStore((s) => s.selectedLogs.toggleSelectedLogId);
const tagNames = useTagNames().data;
const visibleColumns = useAppStore((s) => s.columnVisibility.visibleColumns);
const visibleTagNames = useMemo(() => {
return tagNames?.filter((tagName) => visibleColumns.has(tagName)) ?? [];
}, [tagNames, visibleColumns]);
const isClientRehydrated = useIsClientRehydrated();
if (!isClientRehydrated) return null;
return (
<>
<Tr
onClick={onToggle}
key={loggedCall.id}
_hover={{ bgColor: "gray.50", cursor: "pointer" }}
sx={{
"> td": { borderBottom: "none" },
}}
fontSize="sm"
>
{showOptions && (
<Td>
<Checkbox isChecked={isChecked} onChange={() => toggleChecked(loggedCall.id)} />
</Td>
)}
{visibleColumns.has(StaticColumnKeys.SENT_AT) && (
<Td>
<Tooltip label={fullTime} placement="top">
<Box whiteSpace="nowrap" minW="120px">
{requestedAt}
</Box>
</Tooltip>
</Td>
)}
{visibleColumns.has(StaticColumnKeys.MODEL) && (
<Td>
<HStack justifyContent="flex-start">
<Text
colorScheme="purple"
color="purple.500"
borderColor="purple.500"
px={1}
borderRadius={4}
borderWidth={1}
fontSize="xs"
whiteSpace="nowrap"
>
{loggedCall.model}
</Text>
</HStack>
</Td>
)}
{visibleTagNames.map((tagName) => (
<Td key={tagName}>{loggedCall.tags[tagName]}</Td>
))}
{visibleColumns.has(StaticColumnKeys.DURATION) && (
<Td isNumeric>
{loggedCall.cacheHit ? (
<Text color="gray.500">Cached</Text>
) : (
((loggedCall.modelResponse?.durationMs ?? 0) / 1000).toFixed(2) + "s"
)}
</Td>
)}
{visibleColumns.has(StaticColumnKeys.INPUT_TOKENS) && (
<Td isNumeric>{loggedCall.modelResponse?.inputTokens}</Td>
)}
{visibleColumns.has(StaticColumnKeys.OUTPUT_TOKENS) && (
<Td isNumeric>{loggedCall.modelResponse?.outputTokens}</Td>
)}
{visibleColumns.has(StaticColumnKeys.STATUS_CODE) && (
<Td sx={{ color: isError ? "red.500" : "green.500", fontWeight: "semibold" }} isNumeric>
{loggedCall.modelResponse?.statusCode ?? "No response"}
</Td>
)}
</Tr>
<Tr>
<Td colSpan={visibleColumns.size + 1} w="full" p={0}>
<Collapse in={isExpanded} unmountOnExit={true}>
<VStack p={4} align="stretch">
<HStack align="stretch">
<VStack flex={1} align="stretch">
<Heading size="sm">Input</Heading>
<FormattedJson json={loggedCall.modelResponse?.reqPayload} />
</VStack>
<VStack flex={1} align="stretch">
<Heading size="sm">Output</Heading>
<FormattedJson json={loggedCall.modelResponse?.respPayload} />
</VStack>
</HStack>
<ButtonGroup alignSelf="flex-end">
<Button as={Link} colorScheme="blue" href={{ pathname: "/experiments" }}>
Experiments
</Button>
</ButtonGroup>
</VStack>
</Collapse>
</Td>
</Tr>
</>
);
};

View File

@@ -21,19 +21,6 @@ export const env = createEnv({
ANTHROPIC_API_KEY: z.string().default("placeholder"), ANTHROPIC_API_KEY: z.string().default("placeholder"),
SENTRY_AUTH_TOKEN: z.string().optional(), SENTRY_AUTH_TOKEN: z.string().optional(),
OPENPIPE_API_KEY: z.string().optional(), OPENPIPE_API_KEY: z.string().optional(),
SENDER_EMAIL: z.string().default("placeholder"),
SMTP_HOST: z.string().default("placeholder"),
SMTP_PORT: z.string().default("placeholder"),
SMTP_LOGIN: z.string().default("placeholder"),
SMTP_PASSWORD: z.string().default("placeholder"),
WORKER_CONCURRENCY: z
.string()
.default("10")
.transform((val) => parseInt(val)),
WORKER_MAX_POOL_SIZE: z
.string()
.default("10")
.transform((val) => parseInt(val)),
}, },
/** /**
@@ -46,7 +33,8 @@ export const env = createEnv({
NEXT_PUBLIC_SOCKET_URL: z.string().url().default("http://localhost:3318"), NEXT_PUBLIC_SOCKET_URL: z.string().url().default("http://localhost:3318"),
NEXT_PUBLIC_HOST: z.string().url().default("http://localhost:3000"), NEXT_PUBLIC_HOST: z.string().url().default("http://localhost:3000"),
NEXT_PUBLIC_SENTRY_DSN: z.string().optional(), NEXT_PUBLIC_SENTRY_DSN: z.string().optional(),
NEXT_PUBLIC_FF_SHOW_BETA_FEATURES: z.string().optional(), NEXT_PUBLIC_SHOW_DATA: z.string().optional(),
NEXT_PUBLIC_FF_SHOW_LOGGED_CALLS: z.string().optional(),
}, },
/** /**
@@ -61,6 +49,7 @@ export const env = createEnv({
NEXT_PUBLIC_POSTHOG_KEY: process.env.NEXT_PUBLIC_POSTHOG_KEY, NEXT_PUBLIC_POSTHOG_KEY: process.env.NEXT_PUBLIC_POSTHOG_KEY,
NEXT_PUBLIC_SOCKET_URL: process.env.NEXT_PUBLIC_SOCKET_URL, NEXT_PUBLIC_SOCKET_URL: process.env.NEXT_PUBLIC_SOCKET_URL,
NEXT_PUBLIC_HOST: process.env.NEXT_PUBLIC_HOST, NEXT_PUBLIC_HOST: process.env.NEXT_PUBLIC_HOST,
NEXT_PUBLIC_SHOW_DATA: process.env.NEXT_PUBLIC_SHOW_DATA,
GITHUB_CLIENT_ID: process.env.GITHUB_CLIENT_ID, GITHUB_CLIENT_ID: process.env.GITHUB_CLIENT_ID,
GITHUB_CLIENT_SECRET: process.env.GITHUB_CLIENT_SECRET, GITHUB_CLIENT_SECRET: process.env.GITHUB_CLIENT_SECRET,
REPLICATE_API_TOKEN: process.env.REPLICATE_API_TOKEN, REPLICATE_API_TOKEN: process.env.REPLICATE_API_TOKEN,
@@ -68,14 +57,7 @@ export const env = createEnv({
NEXT_PUBLIC_SENTRY_DSN: process.env.NEXT_PUBLIC_SENTRY_DSN, NEXT_PUBLIC_SENTRY_DSN: process.env.NEXT_PUBLIC_SENTRY_DSN,
SENTRY_AUTH_TOKEN: process.env.SENTRY_AUTH_TOKEN, SENTRY_AUTH_TOKEN: process.env.SENTRY_AUTH_TOKEN,
OPENPIPE_API_KEY: process.env.OPENPIPE_API_KEY, OPENPIPE_API_KEY: process.env.OPENPIPE_API_KEY,
NEXT_PUBLIC_FF_SHOW_BETA_FEATURES: process.env.NEXT_PUBLIC_FF_SHOW_BETA_FEATURES, NEXT_PUBLIC_FF_SHOW_LOGGED_CALLS: process.env.NEXT_PUBLIC_FF_SHOW_LOGGED_CALLS,
SENDER_EMAIL: process.env.SENDER_EMAIL,
SMTP_HOST: process.env.SMTP_HOST,
SMTP_PORT: process.env.SMTP_PORT,
SMTP_LOGIN: process.env.SMTP_LOGIN,
SMTP_PASSWORD: process.env.SMTP_PASSWORD,
WORKER_CONCURRENCY: process.env.WORKER_CONCURRENCY,
WORKER_MAX_POOL_SIZE: process.env.WORKER_MAX_POOL_SIZE,
}, },
/** /**
* Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation. * Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation.

View File

@@ -1,7 +1,6 @@
import openaiChatCompletionFrontend from "./openai-ChatCompletion/frontend"; import openaiChatCompletionFrontend from "./openai-ChatCompletion/frontend";
import replicateLlama2Frontend from "./replicate-llama2/frontend"; import replicateLlama2Frontend from "./replicate-llama2/frontend";
import anthropicFrontend from "./anthropic-completion/frontend"; import anthropicFrontend from "./anthropic-completion/frontend";
import openpipeFrontend from "./openpipe-chat/frontend";
import { type SupportedProvider, type FrontendModelProvider } from "./types"; import { type SupportedProvider, type FrontendModelProvider } from "./types";
// Keep attributes here that need to be accessible from the frontend. We can't // Keep attributes here that need to be accessible from the frontend. We can't
@@ -11,7 +10,6 @@ const frontendModelProviders: Record<SupportedProvider, FrontendModelProvider<an
"openai/ChatCompletion": openaiChatCompletionFrontend, "openai/ChatCompletion": openaiChatCompletionFrontend,
"replicate/llama2": replicateLlama2Frontend, "replicate/llama2": replicateLlama2Frontend,
"anthropic/completion": anthropicFrontend, "anthropic/completion": anthropicFrontend,
"openpipe/Chat": openpipeFrontend,
}; };
export default frontendModelProviders; export default frontendModelProviders;

View File

@@ -1,14 +1,12 @@
import openaiChatCompletion from "./openai-ChatCompletion"; import openaiChatCompletion from "./openai-ChatCompletion";
import replicateLlama2 from "./replicate-llama2"; import replicateLlama2 from "./replicate-llama2";
import anthropicCompletion from "./anthropic-completion"; import anthropicCompletion from "./anthropic-completion";
import openpipeChatCompletion from "./openpipe-chat";
import { type SupportedProvider, type ModelProvider } from "./types"; import { type SupportedProvider, type ModelProvider } from "./types";
const modelProviders: Record<SupportedProvider, ModelProvider<any, any, any>> = { const modelProviders: Record<SupportedProvider, ModelProvider<any, any, any>> = {
"openai/ChatCompletion": openaiChatCompletion, "openai/ChatCompletion": openaiChatCompletion,
"replicate/llama2": replicateLlama2, "replicate/llama2": replicateLlama2,
"anthropic/completion": anthropicCompletion, "anthropic/completion": anthropicCompletion,
"openpipe/Chat": openpipeChatCompletion,
}; };
export default modelProviders; export default modelProviders;

View File

@@ -1,10 +1,56 @@
/* eslint-disable @typescript-eslint/no-unsafe-call */ /* eslint-disable @typescript-eslint/no-unsafe-call */
import { isArray, isString } from "lodash-es"; import {
import { APIError } from "openai"; type ChatCompletionChunk,
import { type ChatCompletion, type CompletionCreateParams } from "openai/resources/chat"; type ChatCompletion,
import mergeChunks from "openpipe/src/openai/mergeChunks"; type CompletionCreateParams,
import { openai } from "~/server/utils/openai"; } from "openai/resources/chat";
import { type CompletionResponse } from "../types"; import { type CompletionResponse } from "../types";
import { isArray, isString, omit } from "lodash-es";
import { openai } from "~/server/utils/openai";
import { APIError } from "openai";
import frontendModelProvider from "./frontend";
import modelProvider, { type SupportedModel } from ".";
const mergeStreamedChunks = (
base: ChatCompletion | null,
chunk: ChatCompletionChunk,
): ChatCompletion => {
if (base === null) {
return mergeStreamedChunks({ ...chunk, choices: [] }, chunk);
}
const choices = [...base.choices];
for (const choice of chunk.choices) {
const baseChoice = choices.find((c) => c.index === choice.index);
if (baseChoice) {
baseChoice.finish_reason = choice.finish_reason ?? baseChoice.finish_reason;
baseChoice.message = baseChoice.message ?? { role: "assistant" };
if (choice.delta?.content)
baseChoice.message.content =
((baseChoice.message.content as string) ?? "") + (choice.delta.content ?? "");
if (choice.delta?.function_call) {
const fnCall = baseChoice.message.function_call ?? {};
fnCall.name =
((fnCall.name as string) ?? "") + ((choice.delta.function_call.name as string) ?? "");
fnCall.arguments =
((fnCall.arguments as string) ?? "") +
((choice.delta.function_call.arguments as string) ?? "");
}
} else {
// @ts-expect-error the types are correctly telling us that finish_reason
// could be null, but don't want to fix it right now.
choices.push({ ...omit(choice, "delta"), message: { role: "assistant", ...choice.delta } });
}
}
const merged: ChatCompletion = {
...base,
choices,
};
return merged;
};
export async function getCompletion( export async function getCompletion(
input: CompletionCreateParams, input: CompletionCreateParams,
@@ -15,25 +61,19 @@ export async function getCompletion(
try { try {
if (onStream) { if (onStream) {
console.log("got started");
const resp = await openai.chat.completions.create( const resp = await openai.chat.completions.create(
{ { ...input, stream: true },
...input,
stream: true,
openpipe: {
tags: {
prompt_id: "getCompletion",
stream: "true",
},
},
},
{ {
maxRetries: 0, maxRetries: 0,
}, },
); );
for await (const part of resp) { for await (const part of resp) {
finalCompletion = mergeChunks(finalCompletion, part); console.log("got part", part);
finalCompletion = mergeStreamedChunks(finalCompletion, part);
onStream(finalCompletion); onStream(finalCompletion);
} }
console.log("got final", finalCompletion);
if (!finalCompletion) { if (!finalCompletion) {
return { return {
type: "error", type: "error",
@@ -43,16 +83,7 @@ export async function getCompletion(
} }
} else { } else {
const resp = await openai.chat.completions.create( const resp = await openai.chat.completions.create(
{ { ...input, stream: false },
...input,
stream: false,
openpipe: {
tags: {
prompt_id: "getCompletion",
stream: "false",
},
},
},
{ {
maxRetries: 0, maxRetries: 0,
}, },

View File

@@ -12,6 +12,7 @@ export const refinementActions: Record<string, RefinementAction> = {
definePrompt("openai/ChatCompletion", { definePrompt("openai/ChatCompletion", {
model: "gpt-4", model: "gpt-4",
stream: true,
messages: [ messages: [
{ {
role: "system", role: "system",
@@ -28,6 +29,7 @@ export const refinementActions: Record<string, RefinementAction> = {
definePrompt("openai/ChatCompletion", { definePrompt("openai/ChatCompletion", {
model: "gpt-4", model: "gpt-4",
stream: true,
messages: [ messages: [
{ {
role: "system", role: "system",
@@ -118,12 +120,13 @@ export const refinementActions: Record<string, RefinementAction> = {
"Convert to function call": { "Convert to function call": {
icon: TfiThought, icon: TfiThought,
description: "Use function calls to get output from the model in a more structured way.", description: "Use function calls to get output from the model in a more structured way.",
instructions: `OpenAI functions are a specialized way for an LLM to return its final output. instructions: `OpenAI functions are a specialized way for an LLM to return output.
Example 1 before: This is what a prompt looks like before adding a function:
definePrompt("openai/ChatCompletion", { definePrompt("openai/ChatCompletion", {
model: "gpt-4", model: "gpt-4",
stream: true,
messages: [ messages: [
{ {
role: "system", role: "system",
@@ -136,10 +139,11 @@ export const refinementActions: Record<string, RefinementAction> = {
], ],
}); });
Example 1 after: This is what one looks like after adding a function:
definePrompt("openai/ChatCompletion", { definePrompt("openai/ChatCompletion", {
model: "gpt-4", model: "gpt-4",
stream: true,
messages: [ messages: [
{ {
role: "system", role: "system",
@@ -152,7 +156,7 @@ export const refinementActions: Record<string, RefinementAction> = {
], ],
functions: [ functions: [
{ {
name: "log_extracted_sentiment", name: "extract_sentiment",
parameters: { parameters: {
type: "object", // parameters must always be an object with a properties key type: "object", // parameters must always be an object with a properties key
properties: { // properties key is required properties: { // properties key is required
@@ -165,13 +169,13 @@ export const refinementActions: Record<string, RefinementAction> = {
}, },
], ],
function_call: { function_call: {
name: "log_extracted_sentiment", name: "extract_sentiment",
}, },
}); });
========= Here's another example of adding a function:
Example 2 before: Before:
definePrompt("openai/ChatCompletion", { definePrompt("openai/ChatCompletion", {
model: "gpt-3.5-turbo", model: "gpt-3.5-turbo",
@@ -193,7 +197,7 @@ export const refinementActions: Record<string, RefinementAction> = {
temperature: 0, temperature: 0,
}); });
Example 2 after: After:
definePrompt("openai/ChatCompletion", { definePrompt("openai/ChatCompletion", {
model: "gpt-3.5-turbo", model: "gpt-3.5-turbo",
@@ -211,7 +215,7 @@ export const refinementActions: Record<string, RefinementAction> = {
temperature: 0, temperature: 0,
functions: [ functions: [
{ {
name: "log_post_score", name: "score_post",
parameters: { parameters: {
type: "object", type: "object",
properties: { properties: {
@@ -223,16 +227,17 @@ export const refinementActions: Record<string, RefinementAction> = {
}, },
], ],
function_call: { function_call: {
name: "log_post_score", name: "score_post",
}, },
}); });
========= Another example
Example 3 before: Before:
definePrompt("openai/ChatCompletion", { definePrompt("openai/ChatCompletion", {
model: "gpt-3.5-turbo", model: "gpt-3.5-turbo",
stream: true,
messages: [ messages: [
{ {
role: "system", role: "system",
@@ -241,7 +246,7 @@ export const refinementActions: Record<string, RefinementAction> = {
], ],
}); });
Example 3 after: After:
definePrompt("openai/ChatCompletion", { definePrompt("openai/ChatCompletion", {
model: "gpt-3.5-turbo", model: "gpt-3.5-turbo",
@@ -253,25 +258,22 @@ export const refinementActions: Record<string, RefinementAction> = {
], ],
functions: [ functions: [
{ {
name: "log_translated_text", name: "write_in_language",
parameters: { parameters: {
type: "object", type: "object",
properties: { properties: {
translated_text: { text: {
type: "string", type: "string",
description: "The text, written in the language specified in the prompt",
}, },
}, },
}, },
}, },
], ],
function_call: { function_call: {
name: "log_translated_text", name: "write_in_language",
}, },
}); });
=========
Add an OpenAI function that takes one or more nested parameters that match the expected output from this prompt.`, Add an OpenAI function that takes one or more nested parameters that match the expected output from this prompt.`,
}, },
}; };

View File

@@ -1,98 +0,0 @@
import { type OpenpipeChatOutput, type SupportedModel } from ".";
import { type FrontendModelProvider } from "../types";
import { refinementActions } from "./refinementActions";
import {
templateOpenOrcaPrompt,
templateAlpacaInstructPrompt,
// templateSystemUserAssistantPrompt,
templateInstructionInputResponsePrompt,
templateAiroborosPrompt,
templateGryphePrompt,
templateVicunaPrompt,
} from "./templatePrompt";
const frontendModelProvider: FrontendModelProvider<SupportedModel, OpenpipeChatOutput> = {
name: "OpenAI ChatCompletion",
models: {
"Open-Orca/OpenOrcaxOpenChat-Preview2-13B": {
name: "OpenOrcaxOpenChat-Preview2-13B",
contextWindow: 4096,
pricePerSecond: 0.0003,
speed: "medium",
provider: "openpipe/Chat",
learnMoreUrl: "https://huggingface.co/Open-Orca/OpenOrcaxOpenChat-Preview2-13B",
templatePrompt: templateOpenOrcaPrompt,
},
"Open-Orca/OpenOrca-Platypus2-13B": {
name: "OpenOrca-Platypus2-13B",
contextWindow: 4096,
pricePerSecond: 0.0003,
speed: "medium",
provider: "openpipe/Chat",
learnMoreUrl: "https://huggingface.co/Open-Orca/OpenOrca-Platypus2-13B",
templatePrompt: templateAlpacaInstructPrompt,
defaultStopTokens: ["</s>"],
},
// "stabilityai/StableBeluga-13B": {
// name: "StableBeluga-13B",
// contextWindow: 4096,
// pricePerSecond: 0.0003,
// speed: "medium",
// provider: "openpipe/Chat",
// learnMoreUrl: "https://huggingface.co/stabilityai/StableBeluga-13B",
// templatePrompt: templateSystemUserAssistantPrompt,
// },
"NousResearch/Nous-Hermes-Llama2-13b": {
name: "Nous-Hermes-Llama2-13b",
contextWindow: 4096,
pricePerSecond: 0.0003,
speed: "medium",
provider: "openpipe/Chat",
learnMoreUrl: "https://huggingface.co/NousResearch/Nous-Hermes-Llama2-13b",
templatePrompt: templateInstructionInputResponsePrompt,
},
"jondurbin/airoboros-l2-13b-gpt4-2.0": {
name: "airoboros-l2-13b-gpt4-2.0",
contextWindow: 4096,
pricePerSecond: 0.0003,
speed: "medium",
provider: "openpipe/Chat",
learnMoreUrl: "https://huggingface.co/jondurbin/airoboros-l2-13b-gpt4-2.0",
templatePrompt: templateAiroborosPrompt,
},
"lmsys/vicuna-13b-v1.5": {
name: "vicuna-13b-v1.5",
contextWindow: 4096,
pricePerSecond: 0.0003,
speed: "medium",
provider: "openpipe/Chat",
learnMoreUrl: "https://huggingface.co/lmsys/vicuna-13b-v1.5",
templatePrompt: templateVicunaPrompt,
},
"Gryphe/MythoMax-L2-13b": {
name: "MythoMax-L2-13b",
contextWindow: 4096,
pricePerSecond: 0.0003,
speed: "medium",
provider: "openpipe/Chat",
learnMoreUrl: "https://huggingface.co/Gryphe/MythoMax-L2-13b",
templatePrompt: templateGryphePrompt,
},
"NousResearch/Nous-Hermes-llama-2-7b": {
name: "Nous-Hermes-llama-2-7b",
contextWindow: 4096,
pricePerSecond: 0.0003,
speed: "medium",
provider: "openpipe/Chat",
learnMoreUrl: "https://huggingface.co/NousResearch/Nous-Hermes-llama-2-7b",
templatePrompt: templateInstructionInputResponsePrompt,
},
},
refinementActions,
normalizeOutput: (output) => ({ type: "text", value: output }),
};
export default frontendModelProvider;

View File

@@ -1,121 +0,0 @@
/* eslint-disable @typescript-eslint/no-unsafe-call */
import { isArray, isString } from "lodash-es";
import OpenAI, { APIError } from "openai";
import { type CompletionResponse } from "../types";
import { type OpenpipeChatInput, type OpenpipeChatOutput } from ".";
import frontendModelProvider from "./frontend";
const modelEndpoints: Record<OpenpipeChatInput["model"], string> = {
"Open-Orca/OpenOrcaxOpenChat-Preview2-13B": "https://5ef82gjxk8kdys-8000.proxy.runpod.net/v1",
"Open-Orca/OpenOrca-Platypus2-13B": "https://lt5qlel6qcji8t-8000.proxy.runpod.net/v1",
// "stabilityai/StableBeluga-13B": "https://vcorl8mxni2ou1-8000.proxy.runpod.net/v1",
"NousResearch/Nous-Hermes-Llama2-13b": "https://ncv8pw3u0vb8j2-8000.proxy.runpod.net/v1",
"jondurbin/airoboros-l2-13b-gpt4-2.0": "https://9nrbx7oph4btou-8000.proxy.runpod.net/v1",
"lmsys/vicuna-13b-v1.5": "https://h88hkt3ux73rb7-8000.proxy.runpod.net/v1",
"Gryphe/MythoMax-L2-13b": "https://3l5jvhnxdgky3v-8000.proxy.runpod.net/v1",
"NousResearch/Nous-Hermes-llama-2-7b": "https://ua1bpc6kv3dgge-8000.proxy.runpod.net/v1",
};
export async function getCompletion(
input: OpenpipeChatInput,
onStream: ((partialOutput: OpenpipeChatOutput) => void) | null,
): Promise<CompletionResponse<OpenpipeChatOutput>> {
const { model, messages, ...rest } = input;
const templatedPrompt = frontendModelProvider.models[model].templatePrompt?.(messages);
if (!templatedPrompt) {
return {
type: "error",
message: "Failed to generate prompt",
autoRetry: false,
};
}
const openai = new OpenAI({
baseURL: modelEndpoints[model],
});
const start = Date.now();
let finalCompletion: OpenpipeChatOutput = "";
const completionParams = {
model,
prompt: templatedPrompt,
...rest,
};
if (!completionParams.stop && frontendModelProvider.models[model].defaultStopTokens) {
completionParams.stop = frontendModelProvider.models[model].defaultStopTokens;
}
try {
if (onStream) {
const resp = await openai.completions.create(
{ ...completionParams, stream: true },
{
maxRetries: 0,
},
);
for await (const part of resp) {
finalCompletion += part.choices[0]?.text;
onStream(finalCompletion);
}
if (!finalCompletion) {
return {
type: "error",
message: "Streaming failed to return a completion",
autoRetry: false,
};
}
} else {
const resp = await openai.completions.create(
{ ...completionParams, stream: false },
{
maxRetries: 0,
},
);
finalCompletion = resp.choices[0]?.text || "";
if (!finalCompletion) {
return {
type: "error",
message: "Failed to return a completion",
autoRetry: false,
};
}
}
const timeToComplete = Date.now() - start;
return {
type: "success",
statusCode: 200,
value: finalCompletion,
timeToComplete,
};
} catch (error: unknown) {
if (error instanceof APIError) {
// The types from the sdk are wrong
const rawMessage = error.message as string | string[];
// If the message is not a string, stringify it
const message = isString(rawMessage)
? rawMessage
: isArray(rawMessage)
? rawMessage.map((m) => m.toString()).join("\n")
: (rawMessage as any).toString();
return {
type: "error",
message,
autoRetry: error.status === 429 || error.status === 503,
statusCode: error.status,
};
} else {
console.error(error);
return {
type: "error",
message: (error as Error).message,
autoRetry: true,
};
}
}
}

View File

@@ -1,54 +0,0 @@
import { type JSONSchema4 } from "json-schema";
import { type ModelProvider } from "../types";
import inputSchema from "./input.schema.json";
import { getCompletion } from "./getCompletion";
import frontendModelProvider from "./frontend";
const supportedModels = [
"Open-Orca/OpenOrcaxOpenChat-Preview2-13B",
"Open-Orca/OpenOrca-Platypus2-13B",
// "stabilityai/StableBeluga-13B",
"NousResearch/Nous-Hermes-Llama2-13b",
"jondurbin/airoboros-l2-13b-gpt4-2.0",
"lmsys/vicuna-13b-v1.5",
"Gryphe/MythoMax-L2-13b",
"NousResearch/Nous-Hermes-llama-2-7b",
] as const;
export type SupportedModel = (typeof supportedModels)[number];
export type OpenpipeChatInput = {
model: SupportedModel;
messages: {
role: "system" | "user" | "assistant";
content: string;
}[];
temperature?: number;
top_p?: number;
stop?: string[] | string;
max_tokens?: number;
presence_penalty?: number;
frequency_penalty?: number;
};
export type OpenpipeChatOutput = string;
export type OpenpipeChatModelProvider = ModelProvider<
SupportedModel,
OpenpipeChatInput,
OpenpipeChatOutput
>;
const modelProvider: OpenpipeChatModelProvider = {
getModel: (input) => input.model,
inputSchema: inputSchema as JSONSchema4,
canStream: true,
getCompletion,
getUsage: (input, output) => {
// TODO: Implement this
return null;
},
...frontendModelProvider,
};
export default modelProvider;

View File

@@ -1,96 +0,0 @@
{
"type": "object",
"properties": {
"model": {
"description": "ID of the model to use.",
"example": "Open-Orca/OpenOrcaxOpenChat-Preview2-13B",
"type": "string",
"enum": [
"Open-Orca/OpenOrcaxOpenChat-Preview2-13B",
"Open-Orca/OpenOrca-Platypus2-13B",
"NousResearch/Nous-Hermes-Llama2-13b",
"jondurbin/airoboros-l2-13b-gpt4-2.0",
"lmsys/vicuna-13b-v1.5",
"Gryphe/MythoMax-L2-13b",
"NousResearch/Nous-Hermes-llama-2-7b"
]
},
"messages": {
"description": "A list of messages comprising the conversation so far.",
"type": "array",
"minItems": 1,
"items": {
"type": "object",
"properties": {
"role": {
"type": "string",
"enum": ["system", "user", "assistant"],
"description": "The role of the messages author. One of `system`, `user`, or `assistant`."
},
"content": {
"type": "string",
"description": "The contents of the message. `content` is required for all messages."
}
},
"required": ["role", "content"]
}
},
"temperature": {
"type": "number",
"minimum": 0,
"maximum": 2,
"default": 1,
"example": 1,
"nullable": true,
"description": "What sampling temperature to use, between 0 and 2. Higher values like 0.8 will make the output more random, while lower values like 0.2 will make it more focused and deterministic.\n\nWe generally recommend altering this or `top_p` but not both.\n"
},
"top_p": {
"type": "number",
"minimum": 0,
"maximum": 1,
"default": 1,
"example": 1,
"nullable": true,
"description": "An alternative to sampling with temperature, called nucleus sampling, where the model considers the results of the tokens with top_p probability mass. So 0.1 means only the tokens comprising the top 10% probability mass are considered.\n\nWe generally recommend altering this or `temperature` but not both.\n"
},
"stop": {
"description": "Up to 4 sequences where the API will stop generating further tokens.\n",
"default": null,
"oneOf": [
{
"type": "string",
"nullable": true
},
{
"type": "array",
"minItems": 1,
"maxItems": 4,
"items": {
"type": "string"
}
}
]
},
"max_tokens": {
"description": "The maximum number of [tokens](/tokenizer) to generate in the chat completion.\n\nThe total length of input tokens and generated tokens is limited by the model's context length. [Example Python code](https://github.com/openai/openai-cookbook/blob/main/examples/How_to_count_tokens_with_tiktoken.ipynb) for counting tokens.\n",
"type": "integer"
},
"presence_penalty": {
"type": "number",
"default": 0,
"minimum": -2,
"maximum": 2,
"nullable": true,
"description": "Number between -2.0 and 2.0. Positive values penalize new tokens based on whether they appear in the text so far, increasing the model's likelihood to talk about new topics.\n\n[See more information about frequency and presence penalties.](/docs/api-reference/parameter-details)\n"
},
"frequency_penalty": {
"type": "number",
"default": 0,
"minimum": -2,
"maximum": 2,
"nullable": true,
"description": "Number between -2.0 and 2.0. Positive values penalize new tokens based on their existing frequency in the text so far, decreasing the model's likelihood to repeat the same line verbatim.\n\n[See more information about frequency and presence penalties.](/docs/api-reference/parameter-details)\n"
}
},
"required": ["model", "messages"]
}

View File

@@ -1,3 +0,0 @@
import { type RefinementAction } from "../types";
export const refinementActions: Record<string, RefinementAction> = {};

View File

@@ -1,274 +0,0 @@
import { type OpenpipeChatInput } from ".";
// User: Hello<|end_of_turn|>Assistant: Hi<|end_of_turn|>User: How are you today?<|end_of_turn|>Assistant:
export const templateOpenOrcaPrompt = (messages: OpenpipeChatInput["messages"]) => {
const splitter = "<|end_of_turn|>";
const formattedMessages = messages.map((message) => {
if (message.role === "system" || message.role === "user") {
return "User: " + message.content;
} else {
return "Assistant: " + message.content;
}
});
let prompt = formattedMessages.join(splitter);
// Ensure that the prompt ends with an assistant message
const lastUserIndex = prompt.lastIndexOf("User:");
const lastAssistantIndex = prompt.lastIndexOf("Assistant:");
if (lastUserIndex > lastAssistantIndex) {
prompt += splitter + "Assistant:";
}
return prompt;
};
// ### Instruction:
// <prompt> (without the <>)
// ### Response: (leave two newlines for model to respond)
export const templateAlpacaInstructPrompt = (messages: OpenpipeChatInput["messages"]) => {
const splitter = "\n\n";
const userTag = "### Instruction:\n\n";
const assistantTag = "### Response:\n\n";
const formattedMessages = messages.map((message) => {
if (message.role === "system" || message.role === "user") {
return userTag + message.content;
} else {
return assistantTag + message.content;
}
});
let prompt = formattedMessages.join(splitter);
// Ensure that the prompt ends with an assistant message
const lastUserIndex = prompt.lastIndexOf(userTag);
const lastAssistantIndex = prompt.lastIndexOf(assistantTag);
if (lastUserIndex > lastAssistantIndex) {
prompt += splitter + assistantTag;
}
return prompt;
};
// ### System:
// This is a system prompt, please behave and help the user.
// ### User:
// Your prompt here
// ### Assistant
// The output of Stable Beluga 13B
export const templateSystemUserAssistantPrompt = (messages: OpenpipeChatInput["messages"]) => {
const splitter = "\n\n";
const systemTag = "### System:\n";
const userTag = "### User:\n";
const assistantTag = "### Assistant\n";
const formattedMessages = messages.map((message) => {
if (message.role === "system") {
return systemTag + message.content;
} else if (message.role === "user") {
return userTag + message.content;
} else {
return assistantTag + message.content;
}
});
let prompt = formattedMessages.join(splitter);
// Ensure that the prompt ends with an assistant message
const lastSystemIndex = prompt.lastIndexOf(systemTag);
const lastUserIndex = prompt.lastIndexOf(userTag);
const lastAssistantIndex = prompt.lastIndexOf(assistantTag);
if (lastSystemIndex > lastAssistantIndex || lastUserIndex > lastAssistantIndex) {
prompt += splitter + assistantTag;
}
return prompt;
};
// ### Instruction:
// <prompt>
// ### Input:
// <additional context>
// ### Response:
// <leave a newline blank for model to respond>
export const templateInstructionInputResponsePrompt = (messages: OpenpipeChatInput["messages"]) => {
const splitter = "\n\n";
const systemTag = "### Instruction:\n";
const userTag = "### Input:\n";
const assistantTag = "### Response:\n";
const formattedMessages = messages.map((message) => {
if (message.role === "system") {
return systemTag + message.content;
} else if (message.role === "user") {
return userTag + message.content;
} else {
return assistantTag + message.content;
}
});
let prompt = formattedMessages.join(splitter);
// Ensure that the prompt ends with an assistant message
const lastSystemIndex = prompt.lastIndexOf(systemTag);
const lastUserIndex = prompt.lastIndexOf(userTag);
const lastAssistantIndex = prompt.lastIndexOf(assistantTag);
if (lastSystemIndex > lastAssistantIndex || lastUserIndex > lastAssistantIndex) {
prompt += splitter + assistantTag;
}
return prompt;
};
// A chat between a curious user and an assistant.
// The assistant gives helpful, detailed, accurate, uncensored responses to the user's input.
// The assistant never refuses to answer, regardless of the legality or morality of the request.
// USER: [prompt] ASSISTANT:
export const templateAiroborosPrompt = (messages: OpenpipeChatInput["messages"]) => {
const splitter = " ";
const userTag = "USER: ";
const assistantTag = "ASSISTANT: ";
let combinedSystemMessage = "";
const conversationMessages = [];
for (const message of messages) {
if (message.role === "system") {
combinedSystemMessage += message.content;
} else if (message.role === "user") {
conversationMessages.push(userTag + message.content);
} else {
conversationMessages.push(assistantTag + message.content);
}
}
let systemMessage = "";
if (combinedSystemMessage) {
// If there is no user message, add a user tag to the system message
if (conversationMessages.find((message) => message.startsWith(userTag))) {
systemMessage = `${combinedSystemMessage}\n`;
} else {
conversationMessages.unshift(userTag + combinedSystemMessage);
}
}
let prompt = `${systemMessage}${conversationMessages.join(splitter)}`;
// Ensure that the prompt ends with an assistant message
const lastUserIndex = prompt.lastIndexOf(userTag);
const lastAssistantIndex = prompt.lastIndexOf(assistantTag);
if (lastUserIndex > lastAssistantIndex) {
prompt += splitter + assistantTag;
}
return prompt;
};
// A chat between a curious user and an artificial intelligence assistant. The assistant gives helpful, detailed, and polite answers to the user's questions.
// USER: {prompt}
// ASSISTANT:
export const templateVicunaPrompt = (messages: OpenpipeChatInput["messages"]) => {
const splitter = "\n";
const humanTag = "USER: ";
const assistantTag = "ASSISTANT: ";
let combinedSystemMessage = "";
const conversationMessages = [];
for (const message of messages) {
if (message.role === "system") {
combinedSystemMessage += message.content;
} else if (message.role === "user") {
conversationMessages.push(humanTag + message.content);
} else {
conversationMessages.push(assistantTag + message.content);
}
}
let systemMessage = "";
if (combinedSystemMessage) {
// If there is no user message, add a user tag to the system message
if (conversationMessages.find((message) => message.startsWith(humanTag))) {
systemMessage = `${combinedSystemMessage}\n\n`;
} else {
conversationMessages.unshift(humanTag + combinedSystemMessage);
}
}
let prompt = `${systemMessage}${conversationMessages.join(splitter)}`;
// Ensure that the prompt ends with an assistant message
const lastHumanIndex = prompt.lastIndexOf(humanTag);
const lastAssistantIndex = prompt.lastIndexOf(assistantTag);
if (lastHumanIndex > lastAssistantIndex) {
prompt += splitter + assistantTag;
}
return prompt.trim();
};
// <System prompt/Character Card>
// ### Instruction:
// Your instruction or question here.
// For roleplay purposes, I suggest the following - Write <CHAR NAME>'s next reply in a chat between <YOUR NAME> and <CHAR NAME>. Write a single reply only.
// ### Response:
export const templateGryphePrompt = (messages: OpenpipeChatInput["messages"]) => {
const splitter = "\n\n";
const instructionTag = "### Instruction:\n";
const responseTag = "### Response:\n";
let combinedSystemMessage = "";
const conversationMessages = [];
for (const message of messages) {
if (message.role === "system") {
combinedSystemMessage += message.content;
} else if (message.role === "user") {
conversationMessages.push(instructionTag + message.content);
} else {
conversationMessages.push(responseTag + message.content);
}
}
let systemMessage = "";
if (combinedSystemMessage) {
// If there is no user message, add a user tag to the system message
if (conversationMessages.find((message) => message.startsWith(instructionTag))) {
systemMessage = `${combinedSystemMessage}\n\n`;
} else {
conversationMessages.unshift(instructionTag + combinedSystemMessage);
}
}
let prompt = `${systemMessage}${conversationMessages.join(splitter)}`;
// Ensure that the prompt ends with an assistant message
const lastInstructionIndex = prompt.lastIndexOf(instructionTag);
const lastAssistantIndex = prompt.lastIndexOf(responseTag);
if (lastInstructionIndex > lastAssistantIndex) {
prompt += splitter + responseTag;
}
return prompt;
};

View File

@@ -8,7 +8,7 @@ const replicate = new Replicate({
}); });
const modelIds: Record<ReplicateLlama2Input["model"], string> = { const modelIds: Record<ReplicateLlama2Input["model"], string> = {
"7b-chat": "7b0bfc9aff140d5b75bacbed23e91fd3c34b01a1e958d32132de6e0a19796e2c", "7b-chat": "4f0b260b6a13eb53a6b1891f089d57c08f41003ae79458be5011303d81a394dc",
"13b-chat": "2a7f981751ec7fdf87b5b91ad4db53683a98082e9ff7bfd12c8cd5ea85980a52", "13b-chat": "2a7f981751ec7fdf87b5b91ad4db53683a98082e9ff7bfd12c8cd5ea85980a52",
"70b-chat": "2c1608e18606fad2812020dc541930f2d0495ce32eee50074220b87300bc16e1", "70b-chat": "2c1608e18606fad2812020dc541930f2d0495ce32eee50074220b87300bc16e1",
}; };

View File

@@ -2,13 +2,11 @@ import { type JSONSchema4 } from "json-schema";
import { type IconType } from "react-icons"; import { type IconType } from "react-icons";
import { type JsonValue } from "type-fest"; import { type JsonValue } from "type-fest";
import { z } from "zod"; import { z } from "zod";
import { type OpenpipeChatInput } from "./openpipe-chat";
export const ZodSupportedProvider = z.union([ export const ZodSupportedProvider = z.union([
z.literal("openai/ChatCompletion"), z.literal("openai/ChatCompletion"),
z.literal("replicate/llama2"), z.literal("replicate/llama2"),
z.literal("anthropic/completion"), z.literal("anthropic/completion"),
z.literal("openpipe/Chat"),
]); ]);
export type SupportedProvider = z.infer<typeof ZodSupportedProvider>; export type SupportedProvider = z.infer<typeof ZodSupportedProvider>;
@@ -24,8 +22,6 @@ export type Model = {
description?: string; description?: string;
learnMoreUrl?: string; learnMoreUrl?: string;
apiDocsUrl?: string; apiDocsUrl?: string;
templatePrompt?: (initialPrompt: OpenpipeChatInput["messages"]) => string;
defaultStopTokens?: string[];
}; };
export type ProviderModel = { provider: z.infer<typeof ZodSupportedProvider>; model: string }; export type ProviderModel = { provider: z.infer<typeof ZodSupportedProvider>; model: string };

View File

@@ -8,7 +8,7 @@ import { ChakraThemeProvider } from "~/theme/ChakraThemeProvider";
import { SyncAppStore } from "~/state/sync"; import { SyncAppStore } from "~/state/sync";
import NextAdapterApp from "next-query-params/app"; import NextAdapterApp from "next-query-params/app";
import { QueryParamProvider } from "use-query-params"; import { QueryParamProvider } from "use-query-params";
import { PosthogAppProvider } from "~/utils/analytics/posthog"; import { SessionIdentifier } from "~/utils/analytics/clientAnalytics";
const MyApp: AppType<{ session: Session | null }> = ({ const MyApp: AppType<{ session: Session | null }> = ({
Component, Component,
@@ -34,15 +34,14 @@ const MyApp: AppType<{ session: Session | null }> = ({
<meta name="twitter:image" content="/og.png" /> <meta name="twitter:image" content="/og.png" />
</Head> </Head>
<SessionProvider session={session}> <SessionProvider session={session}>
<PosthogAppProvider> <SyncAppStore />
<SyncAppStore /> <Favicon />
<Favicon /> <SessionIdentifier />
<ChakraThemeProvider> <ChakraThemeProvider>
<QueryParamProvider adapter={NextAdapterApp}> <QueryParamProvider adapter={NextAdapterApp}>
<Component {...pageProps} /> <Component {...pageProps} />
</QueryParamProvider> </QueryParamProvider>
</ChakraThemeProvider> </ChakraThemeProvider>
</PosthogAppProvider>
</SessionProvider> </SessionProvider>
</> </>
); );

View File

@@ -1,54 +0,0 @@
import { Card, Table, Tbody, Td, Th, Thead, Tr } from "@chakra-ui/react";
import dayjs from "dayjs";
import { isDate, isObject, isString } from "lodash-es";
import AppShell from "~/components/nav/AppShell";
import { type RouterOutputs, api } from "~/utils/api";
const fieldsToShow: (keyof RouterOutputs["adminJobs"]["list"][0])[] = [
"id",
"queue_name",
"payload",
"priority",
"attempts",
"last_error",
"created_at",
"key",
"locked_at",
"run_at",
];
export default function Jobs() {
const jobs = api.adminJobs.list.useQuery({});
return (
<AppShell title="Admin Jobs">
<Card m={4} overflowX="auto">
<Table>
<Thead>
<Tr>
{fieldsToShow.map((field) => (
<Th key={field}>{field}</Th>
))}
</Tr>
</Thead>
<Tbody>
{jobs.data?.map((job) => (
<Tr key={job.id}>
{fieldsToShow.map((field) => {
// Check if object
let value = job[field];
if (isDate(value)) {
value = dayjs(value).format("YYYY-MM-DD HH:mm:ss");
} else if (isObject(value) && !isString(value)) {
value = JSON.stringify(value);
} // check if date
return <Td key={field}>{value}</Td>;
})}
</Tr>
))}
</Tbody>
</Table>
</Card>
</AppShell>
);
}

View File

@@ -1,14 +1,17 @@
import { type NextApiRequest, type NextApiResponse } from "next"; import { type NextApiRequest, type NextApiResponse } from "next";
import cors from "nextjs-cors"; import cors from "nextjs-cors";
import { createOpenApiNextHandler } from "trpc-openapi"; import { createOpenApiNextHandler } from "trpc-openapi";
import { v1ApiRouter } from "~/server/api/external/v1Api.router"; import { createProcedureCache } from "trpc-openapi/dist/adapters/node-http/procedures";
import { createOpenApiContext } from "~/server/api/external/openApiTrpc"; import { appRouter } from "~/server/api/root.router";
import { createTRPCContext } from "~/server/api/trpc";
const openApiHandler = createOpenApiNextHandler({ const openApiHandler = createOpenApiNextHandler({
router: v1ApiRouter, router: appRouter,
createContext: createOpenApiContext, createContext: createTRPCContext,
}); });
const cache = createProcedureCache(appRouter);
const handler = async (req: NextApiRequest, res: NextApiResponse) => { const handler = async (req: NextApiRequest, res: NextApiResponse) => {
// Setup CORS // Setup CORS
await cors(req, res); await cors(req, res);

View File

@@ -1,12 +1,12 @@
import { type NextApiRequest, type NextApiResponse } from "next"; import { type NextApiRequest, type NextApiResponse } from "next";
import { generateOpenApiDocument } from "trpc-openapi"; import { generateOpenApiDocument } from "trpc-openapi";
import { v1ApiRouter } from "~/server/api/external/v1Api.router"; import { appRouter } from "~/server/api/root.router";
export const openApiDocument = generateOpenApiDocument(v1ApiRouter, { export const openApiDocument = generateOpenApiDocument(appRouter, {
title: "OpenPipe API", title: "OpenPipe API",
description: "The public API for reporting API calls to OpenPipe", description: "The public API for reporting API calls to OpenPipe",
version: "0.1.1", version: "0.1.0",
baseUrl: "https://app.openpipe.ai/api/v1", baseUrl: "https://app.openpipe.ai/api",
}); });
// Respond with our OpenAPI schema // Respond with our OpenAPI schema
const hander = (req: NextApiRequest, res: NextApiResponse) => { const hander = (req: NextApiRequest, res: NextApiResponse) => {

Some files were not shown because too many files have changed in this diff Show More