Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fa26275634 | ||
|
|
7587c75459 | ||
|
|
a956d0115c | ||
|
|
cb82835489 | ||
|
|
d1c1e60eca | ||
|
|
81b612273a | ||
|
|
2c1abf2f6e | ||
|
|
db1a662f7f | ||
|
|
1b0e3de709 | ||
|
|
be8aec2f8f | ||
|
|
adc01014c0 | ||
|
|
215af995fb | ||
|
|
74ff237145 | ||
|
|
5c2ae0e678 | ||
|
|
a6d8ce8c96 | ||
|
|
fa4ba016b5 | ||
|
|
9b7e0abfbc | ||
|
|
5556772388 | ||
|
|
68e0270051 | ||
|
|
5bd7d879ad | ||
|
|
87d34c05d9 | ||
|
|
ae27144346 | ||
|
|
f5101083a2 | ||
|
|
124c9e8107 | ||
|
|
fabbb75e40 | ||
|
|
857507195b | ||
|
|
cd7864cad4 | ||
|
|
cd9fd110e4 |
@@ -58,6 +58,11 @@ LANGFUSE_CSP_ENFORCE_HTTPS="true"
|
||||
# AUTH_AUTH0_CLIENT_SECRET=
|
||||
# AUTH_AUTH0_ISSUER=
|
||||
# AUTH_AUTH0_ALLOW_ACCOUNT_LINKING=false
|
||||
# AUTH_COGNITO_CLIENT_ID=
|
||||
# AUTH_COGNITO_CLIENT_SECRET=
|
||||
# AUTH_COGNITO_ISSUER=
|
||||
# AUTH_COGNITO_ALLOW_ACCOUNT_LINKING=false
|
||||
|
||||
|
||||
# Transactional email, optional
|
||||
# Defines the email address to use as the from address.
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
"on":
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
name: Deploy to worker
|
||||
jobs:
|
||||
porter-deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
- name: Set Github tag
|
||||
id: vars
|
||||
run: echo "sha_short=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
|
||||
- name: Setup porter
|
||||
uses: porter-dev/setup-porter@v0.1.0
|
||||
- name: Deploy stack
|
||||
timeout-minutes: 30
|
||||
run: exec porter apply
|
||||
env:
|
||||
PORTER_CLUSTER: "3959"
|
||||
PORTER_DEPLOYMENT_TARGET_ID: d2bb23e4-1d77-48f2-a113-383d076959b3
|
||||
PORTER_HOST: https://dashboard.getporter.dev
|
||||
PORTER_PR_NUMBER: ${{ github.event.number }}
|
||||
PORTER_PROJECT: "12565"
|
||||
PORTER_REPO_NAME: ${{ github.event.repository.name }}
|
||||
PORTER_STACK_NAME: worker
|
||||
PORTER_TAG: ${{ steps.vars.outputs.sha_short }}
|
||||
PORTER_TOKEN: ${{ secrets.PORTER_STACK_12565_3959 }}
|
||||
@@ -290,6 +290,74 @@ When a new release is tagged on the `main` branch (excluding prereleases), it tr
|
||||
1. The Docker image is published to GitHub Packages with the version number and `latest` tag.
|
||||
2. The deployment is carried out on Langfuse Cloud. This is done by force pushing the `main` branch to the `production` branch during every release, using the [`release.yml`](.github/workflows/release.yml) GitHub Action.
|
||||
|
||||
## Theming
|
||||
|
||||
At Langfuse, we utilize CSS variables to manage our theme settings across the platform.
|
||||
|
||||
Our approach leverages separate CSS variables for backgrounds (--background) and foregrounds (--foreground), fully adhering to the [shadcn/ui](https://ui.shadcn.com/docs/theming) color conventions. The background suffix can be omitted if the variable is used for the background color of the component. We recommend using HSL values for these colors to enhance consistency and customization. There is no need to manually handle dark mode styling with "dark:" prefixes, as next-themes automatically manages the theme switching.
|
||||
|
||||
Given the following CSS variables:
|
||||
|
||||
```
|
||||
--primary: 222.2 47.4% 11.2%; // e.g. background-color
|
||||
--primary-foreground: 210 40% 98%; // e.g. text-color
|
||||
```
|
||||
|
||||
The background color of the following component will be `hsl(var(--primary))` and the foreground color will be `hsl(var(--primary-foreground))`.
|
||||
|
||||
```
|
||||
<div class="bg-primary text-primary-foreground">Hello</div>
|
||||
```
|
||||
|
||||
### Color Variables
|
||||
|
||||
| Variable | Description | Examples |
|
||||
| ------------------------ | ------------------------------------------------------------------ | -------------------------------- |
|
||||
| --background | Background color | Default background color of body |
|
||||
| --foreground | Foreground color | Default text color of body |
|
||||
| --muted | Muted background color | TabsList, Skeleton and Switch |
|
||||
| --muted-foreground | Muted foreground color | |
|
||||
| --popover | Popover background color | DropdownMenu, HoverCard, Popover |
|
||||
| --popover-foreground | Popover foreground color | |
|
||||
| --card | Card background color | Card |
|
||||
| --card-foreground | Card foreground color | |
|
||||
| --border | Border color | Default border color |
|
||||
| --input | Input field border color | Input, Select, Textarea |
|
||||
| --primary | Primary button background colors | Button variant="primary" |
|
||||
| --primary-foreground | Primary button foreground color | |
|
||||
| --secondary | Secondary button background color | Button variant="secondary" |
|
||||
| --secondary-foreground | Secondary button foreground color | |
|
||||
| --accent | Used for accents such as hover effects | DropdownMenuItem, SelectItem |
|
||||
| --accent-foreground | Used for texts on hover effects | DropdownMenuItem, SelectItem |
|
||||
| --destructive | Destructive action color for background | Button variant="destructive" |
|
||||
| --destructive-foreground | Destructive action color for text | |
|
||||
| --ring | Focus ring color | MultiSelect |
|
||||
| --primary-accent | Primary accent color used for branding | Layout |
|
||||
| --hover-primary-accent | Primary accent color used for hover effects for links | SignIn and AuthCloudRegionSwitch |
|
||||
| --muted-green | Muted green for Event label | ObservationTree |
|
||||
| --muted-orange | Muted orange for Generation label | ObservationTree |
|
||||
| --muted-blue | Muted blue for Span label | ObservationTree |
|
||||
| --muted-gray | Muted gray for disabled status badges | StatusBadge |
|
||||
| --accent-light-green | Light green accent for background of output and assistant messages | IOPreview, Generations, Traces |
|
||||
| --accent-dark-green | Dark green accent for border of output and assistant messages | CodeJsonViewer and IOPReview |
|
||||
| --light-red | Light red for error background | level-color and StatusBadge |
|
||||
| --dark-red | Dark red for error text and error badge dot color | level-color and ErrorPage |
|
||||
| --light-yellow | Light yellow for warning background | LevelColor |
|
||||
| --dark-yellow | Dark yellow for warning text | LevelColor |
|
||||
| --light-green | Light green for success status badge background | StatusBadge |
|
||||
| --dark-green | Dark green for success status badge text and dot | StatusBadge |
|
||||
| --light-blue | Light blue for background of Staging label | LangfuseLogo |
|
||||
| --dark-blue | Dark blue for text and border of Staging label | LangfuseLogo |
|
||||
| --accent-light-blue | Light blue accent for table link hover effect | TableLink |
|
||||
| --accent-dark-blue | Dark blue accent for table link text | TableLink |
|
||||
|
||||
### Adding New Colors
|
||||
|
||||
1. Global Definitions: Add new CSS variable definitions in the global.css file.
|
||||
2. Tailwind Configuration: Reflect these new colors in the tailwind.config.js to maintain alignment with Tailwind's utility classes.
|
||||
|
||||
By following these guidelines, you can ensure that any contributions to our theme are consistent, maintainable, and aligned with our design system.
|
||||
|
||||
## Using secrets stored in 1Password
|
||||
|
||||
When applying changes to non-local environments, you may need to use secrets stored in 1Password. We use the 1Password CLI for this purpose.
|
||||
|
||||
@@ -3,9 +3,9 @@ Join us in scaling Langfuse in Berlin, Germany. We are an open source company, w
|
||||
|
||||
_Open Roles_
|
||||
|
||||
- Backend Engineer, 70-110k EUR, 0.25-0.75% Equity, https://www.ycombinator.com/companies/langfuse/jobs/mnrdwla-backend-engineer
|
||||
- Product Engineer, 70-110k EUR, 0.25-0.75% Equity, https://www.ycombinator.com/companies/langfuse/jobs/aAvmoFB-product-engineer
|
||||
- Developer Advocate, 60-100k EUR, 0.25-0.5% Equity, https://www.ycombinator.com/companies/langfuse/jobs/uHysbKH-developer-advocate-devrel
|
||||
- Backend Engineer, 70-130k EUR, 0.25-0.75% Equity, https://www.ycombinator.com/companies/langfuse/jobs/mnrdwla-backend-engineer
|
||||
- Product Engineer, 70-130k EUR, 0.25-0.75% Equity, https://www.ycombinator.com/companies/langfuse/jobs/aAvmoFB-product-engineer
|
||||
- Developer Advocate, 60-110k EUR, 0.25-0.5% Equity, https://www.ycombinator.com/companies/langfuse/jobs/uHysbKH-developer-advocate-devrel
|
||||
|
||||
_More Info_
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { type Provider } from "next-auth/providers/index";
|
||||
import GoogleProvider from "next-auth/providers/google";
|
||||
import GitHubProvider from "next-auth/providers/github";
|
||||
import OktaProvider from "next-auth/providers/okta";
|
||||
import CognitoProvider from "next-auth/providers/cognito";
|
||||
import Auth0Provider from "next-auth/providers/auth0";
|
||||
import AzureADProvider from "next-auth/providers/azure-ad";
|
||||
import { isEeAvailable } from "..";
|
||||
@@ -144,6 +145,12 @@ const dbToNextAuthProvider = (provider: SsoProviderSchema): Provider | null => {
|
||||
...provider.authConfig,
|
||||
clientSecret: decrypt(provider.authConfig.clientSecret),
|
||||
});
|
||||
else if (provider.authProvider === "cognito")
|
||||
return CognitoProvider({
|
||||
id: getAuthProviderIdForSsoConfig(provider), // use the domain as the provider id as we use domain-specific credentials
|
||||
...provider.authConfig,
|
||||
clientSecret: decrypt(provider.authConfig.clientSecret),
|
||||
});
|
||||
else {
|
||||
// Type check to ensure we handle all providers
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
|
||||
@@ -64,11 +64,24 @@ export const AzureAdProviderSchema = base.extend({
|
||||
.nullish(),
|
||||
});
|
||||
|
||||
export const CognitoProviderSchema = base.extend({
|
||||
authProvider: z.literal("cognito"),
|
||||
authConfig: z
|
||||
.object({
|
||||
clientId: z.string(),
|
||||
clientSecret: z.string(),
|
||||
issuer: z.string(),
|
||||
allowDangerousEmailAccountLinking: z.boolean().optional().default(false),
|
||||
})
|
||||
.nullish(),
|
||||
});
|
||||
|
||||
export type GoogleProviderSchema = z.infer<typeof GoogleProviderSchema>;
|
||||
export type GithubProviderSchema = z.infer<typeof GithubProviderSchema>;
|
||||
export type Auth0ProviderSchema = z.infer<typeof Auth0ProviderSchema>;
|
||||
export type OktaProviderSchema = z.infer<typeof OktaProviderSchema>;
|
||||
export type AzureAdProviderSchema = z.infer<typeof AzureAdProviderSchema>;
|
||||
export type CognitoProviderSchema = z.infer<typeof CognitoProviderSchema>;
|
||||
|
||||
export const SsoProviderSchema = z.discriminatedUnion("authProvider", [
|
||||
GoogleProviderSchema,
|
||||
@@ -76,6 +89,7 @@ export const SsoProviderSchema = z.discriminatedUnion("authProvider", [
|
||||
Auth0ProviderSchema,
|
||||
OktaProviderSchema,
|
||||
AzureAdProviderSchema,
|
||||
CognitoProviderSchema,
|
||||
]);
|
||||
|
||||
export type SsoProviderSchema = z.infer<typeof SsoProviderSchema>;
|
||||
|
||||
@@ -209,8 +209,8 @@ types:
|
||||
- ARCHIVED
|
||||
ScoreSource:
|
||||
enum:
|
||||
- ANNOTATION
|
||||
- API
|
||||
- REVIEW
|
||||
- EVAL
|
||||
|
||||
errors:
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "langfuse",
|
||||
"version": "2.44.0",
|
||||
"version": "2.46.0",
|
||||
"author": "engineering@langfuse.com",
|
||||
"license": "MIT",
|
||||
"private": true,
|
||||
@@ -28,7 +28,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@release-it/bumper": "^6.0.1",
|
||||
"dotenv-cli": "^7.4.1",
|
||||
"dotenv-cli": "^7.4.2",
|
||||
"prettier": "^3.2.5",
|
||||
"release-it": "^17.2.1",
|
||||
"turbo": "^1.13.3"
|
||||
|
||||
@@ -44,9 +44,9 @@
|
||||
"seed": "ts-node -r tsconfig-paths/register -r dotenv/config --compiler-options {\"module\":\"CommonJS\"} prisma/seed.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@langchain/anthropic": "^0.1.16",
|
||||
"@langchain/anthropic": "^0.1.21",
|
||||
"@langchain/core": "^0.1.61",
|
||||
"@langchain/openai": "^0.0.28",
|
||||
"@langchain/openai": "^0.0.33",
|
||||
"@prisma/client": "^5.13.0",
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"bcryptjs": "^2.4.3",
|
||||
@@ -59,9 +59,9 @@
|
||||
"devDependencies": {
|
||||
"@repo/eslint-config": "*",
|
||||
"@repo/typescript-config": "*",
|
||||
"@types/lodash": "^4.14.202",
|
||||
"@types/lodash": "^4.17.4",
|
||||
"@types/node": "^20.11.29",
|
||||
"@types/pg": "^8.11.5",
|
||||
"@types/pg": "^8.11.6",
|
||||
"@types/uuid": "^9.0.8",
|
||||
"@typescript-eslint/parser": "^7.7.0",
|
||||
"eslint": "^8.57.0",
|
||||
|
||||
@@ -25,11 +25,16 @@ export const ObservationLevel = {
|
||||
} as const;
|
||||
export type ObservationLevel = (typeof ObservationLevel)[keyof typeof ObservationLevel];
|
||||
export const ScoreSource = {
|
||||
ANNOTATION: "ANNOTATION",
|
||||
API: "API",
|
||||
REVIEW: "REVIEW",
|
||||
EVAL: "EVAL"
|
||||
} as const;
|
||||
export type ScoreSource = (typeof ScoreSource)[keyof typeof ScoreSource];
|
||||
export const ScoreDataType = {
|
||||
CATEGORICAL: "CATEGORICAL",
|
||||
NUMERIC: "NUMERIC"
|
||||
} as const;
|
||||
export type ScoreDataType = (typeof ScoreDataType)[keyof typeof ScoreDataType];
|
||||
export const PricingUnit = {
|
||||
PER_1000_TOKENS: "PER_1000_TOKENS",
|
||||
PER_1000_CHARS: "PER_1000_CHARS"
|
||||
@@ -347,9 +352,26 @@ export type Score = {
|
||||
name: string;
|
||||
value: number;
|
||||
source: ScoreSource;
|
||||
author_user_id: string | null;
|
||||
comment: string | null;
|
||||
trace_id: string;
|
||||
observation_id: string | null;
|
||||
config_id: string | null;
|
||||
string_value: string | null;
|
||||
data_type: Generated<ScoreDataType>;
|
||||
};
|
||||
export type ScoreConfig = {
|
||||
id: string;
|
||||
created_at: Generated<Timestamp>;
|
||||
updated_at: Generated<Timestamp>;
|
||||
project_id: string;
|
||||
name: string;
|
||||
data_type: ScoreDataType;
|
||||
is_archived: Generated<boolean>;
|
||||
min_value: number | null;
|
||||
max_value: number | null;
|
||||
categories: unknown | null;
|
||||
description: string | null;
|
||||
};
|
||||
export type Session = {
|
||||
id: string;
|
||||
@@ -449,6 +471,7 @@ export type DB = {
|
||||
project_memberships: ProjectMembership;
|
||||
projects: Project;
|
||||
prompts: Prompt;
|
||||
score_configs: ScoreConfig;
|
||||
scores: Score;
|
||||
Session: Session;
|
||||
sso_configs: SsoConfig;
|
||||
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "scores" ADD COLUMN "author_user_id" TEXT;
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
-- CreateIndex
|
||||
CREATE INDEX CONCURRENTLY "scores_author_user_id_idx" ON "scores"("author_user_id");
|
||||
@@ -0,0 +1,42 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "ScoreDataType" AS ENUM ('CATEGORICAL', 'NUMERIC');
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "scores" ADD COLUMN "config_id" TEXT,
|
||||
ADD COLUMN "data_type" "ScoreDataType" NOT NULL DEFAULT 'NUMERIC',
|
||||
ADD COLUMN "string_value" TEXT;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "score_configs" (
|
||||
"id" TEXT NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"project_id" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"data_type" "ScoreDataType" NOT NULL,
|
||||
"is_archived" BOOLEAN NOT NULL DEFAULT false,
|
||||
"min_value" DOUBLE PRECISION,
|
||||
"max_value" DOUBLE PRECISION,
|
||||
"categories" JSONB,
|
||||
"description" TEXT,
|
||||
|
||||
CONSTRAINT "score_configs_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "score_configs_data_type_idx" ON "score_configs"("data_type");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "score_configs_is_archived_idx" ON "score_configs"("is_archived");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "score_configs_project_id_idx" ON "score_configs"("project_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "score_configs_categories_idx" ON "score_configs"("categories");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "score_configs_id_project_id_key" ON "score_configs"("id", "project_id");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "score_configs" ADD CONSTRAINT "score_configs_project_id_fkey" FOREIGN KEY ("project_id") REFERENCES "projects"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
-- CreateIndex
|
||||
CREATE INDEX CONCURRENTLY "scores_config_id_idx" ON "scores"("config_id");
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "scores" ADD CONSTRAINT "scores_config_id_fkey" FOREIGN KEY ("config_id") REFERENCES "score_configs"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
-- AlterEnum
|
||||
ALTER TYPE "ScoreSource" ADD VALUE 'ANNOTATION';
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
-- Backfill the scores source for 'REVIEW' to be 'ANNOTATION'
|
||||
UPDATE "scores"
|
||||
SET "source" = 'ANNOTATION'::"ScoreSource"
|
||||
WHERE "source" = 'REVIEW'::"ScoreSource";
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- The values [REVIEW] on the enum `ScoreSource` will be removed. If these variants are still used in the database, this will fail.
|
||||
|
||||
*/
|
||||
-- AlterEnum
|
||||
BEGIN;
|
||||
CREATE TYPE "ScoreSource_new" AS ENUM ('ANNOTATION', 'API', 'EVAL');
|
||||
ALTER TABLE "scores" ALTER COLUMN "source" TYPE "ScoreSource_new" USING ("source"::text::"ScoreSource_new");
|
||||
ALTER TYPE "ScoreSource" RENAME TO "ScoreSource_old";
|
||||
ALTER TYPE "ScoreSource_new" RENAME TO "ScoreSource";
|
||||
DROP TYPE "ScoreSource_old";
|
||||
COMMIT;
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
-- CreateIndex
|
||||
CREATE INDEX CONCURRENTLY "job_executions_job_configuration_id_idx" ON "job_executions"("job_configuration_id");
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
-- CreateIndex
|
||||
CREATE INDEX CONCURRENTLY "job_executions_job_output_score_id_idx" ON "job_executions"("job_output_score_id");
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
-- CreateIndex
|
||||
CREATE INDEX CONCURRENTLY "job_executions_job_input_trace_id_idx" ON "job_executions"("job_input_trace_id");
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
-- CreateIndex
|
||||
CREATE INDEX CONCURRENTLY "job_executions_created_at_idx" ON "job_executions"("created_at");
|
||||
@@ -114,6 +114,7 @@ model Project {
|
||||
LlmApiKeys LlmApiKeys[]
|
||||
PosthogIntegration PosthogIntegration[]
|
||||
Score Score[]
|
||||
scoreConfig ScoreConfig[]
|
||||
|
||||
@@map("projects")
|
||||
}
|
||||
@@ -399,15 +400,22 @@ model Score {
|
||||
name String
|
||||
value Float
|
||||
source ScoreSource
|
||||
authorUserId String? @map("author_user_id")
|
||||
comment String?
|
||||
traceId String @map("trace_id")
|
||||
observationId String? @map("observation_id")
|
||||
configId String? @map("config_id")
|
||||
stringValue String? @map("string_value")
|
||||
dataType ScoreDataType @default(NUMERIC) @map("data_type")
|
||||
JobExecution JobExecution[]
|
||||
scoreConfig ScoreConfig? @relation(fields: [configId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@unique([id, projectId]) // used for upserts via prisma
|
||||
@@index(timestamp)
|
||||
@@index([value])
|
||||
@@index([projectId])
|
||||
@@index([authorUserId])
|
||||
@@index([configId])
|
||||
@@index([traceId], type: Hash)
|
||||
@@index([observationId], type: Hash)
|
||||
@@index([source])
|
||||
@@ -415,11 +423,39 @@ model Score {
|
||||
}
|
||||
|
||||
enum ScoreSource {
|
||||
ANNOTATION
|
||||
API
|
||||
REVIEW
|
||||
EVAL
|
||||
}
|
||||
|
||||
model ScoreConfig {
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
|
||||
projectId String @map("project_id")
|
||||
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||
name String
|
||||
dataType ScoreDataType @map("data_type")
|
||||
isArchived Boolean @default(false) @map("is_archived")
|
||||
minValue Float? @map("min_value")
|
||||
maxValue Float? @map("max_value")
|
||||
categories Json? @map("categories")
|
||||
description String?
|
||||
score Score[]
|
||||
|
||||
@@unique([id, projectId]) // used for upserts via prisma
|
||||
@@index([dataType])
|
||||
@@index([isArchived])
|
||||
@@index([projectId])
|
||||
@@index([categories])
|
||||
@@map("score_configs")
|
||||
}
|
||||
|
||||
enum ScoreDataType {
|
||||
CATEGORICAL
|
||||
NUMERIC
|
||||
}
|
||||
|
||||
enum PricingUnit {
|
||||
PER_1000_TOKENS
|
||||
PER_1000_CHARS
|
||||
@@ -710,6 +746,10 @@ model JobExecution {
|
||||
@@index([projectId, status])
|
||||
@@index([projectId, id])
|
||||
@@index([projectId])
|
||||
@@index([jobConfigurationId])
|
||||
@@index([jobOutputScoreId])
|
||||
@@index([jobInputTraceId])
|
||||
@@index([createdAt])
|
||||
@@map("job_executions")
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import { chunk } from "lodash";
|
||||
import { v4 } from "uuid";
|
||||
import { ModelUsageUnit } from "../src";
|
||||
import { getDisplaySecretKey, hashSecretKey } from "../src/server/auth";
|
||||
import { encrypt } from "../src/encryption";
|
||||
|
||||
const LOAD_TRACE_VOLUME = 10_000;
|
||||
|
||||
@@ -165,6 +166,24 @@ async function main() {
|
||||
|
||||
await uploadObjects(traces, observations, scores, sessions, events);
|
||||
|
||||
// If openai key is in environment, add it to the projects LLM API keys
|
||||
const OPENAI_API_KEY = process.env.OPENAI_API_KEY;
|
||||
|
||||
if (OPENAI_API_KEY) {
|
||||
await prisma.llmApiKeys.create({
|
||||
data: {
|
||||
projectId: project1.id,
|
||||
secretKey: encrypt(OPENAI_API_KEY),
|
||||
displaySecretKey: getDisplaySecretKey(OPENAI_API_KEY),
|
||||
provider: "openai",
|
||||
},
|
||||
});
|
||||
} else {
|
||||
console.warn(
|
||||
"No OPENAI_API_KEY found in environment. Skipping seeding LLM API key."
|
||||
);
|
||||
}
|
||||
|
||||
// add eval objects
|
||||
const evalTemplate = await prisma.evalTemplate.upsert({
|
||||
where: {
|
||||
@@ -500,8 +519,9 @@ function createObjects(
|
||||
name: "manual-score",
|
||||
value: Math.floor(Math.random() * 3) - 1,
|
||||
timestamp: traceTs,
|
||||
source: ScoreSource.REVIEW,
|
||||
source: ScoreSource.ANNOTATION,
|
||||
projectId,
|
||||
authorUserId: `user-${i}`,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
|
||||
@@ -34,9 +34,9 @@ export type UIModelParams = RecordWithEnabledFlag<
|
||||
export type ModelConfig = z.infer<typeof ZodModelConfig>;
|
||||
|
||||
export const ZodModelConfig = z.object({
|
||||
max_tokens: z.number().optional(),
|
||||
temperature: z.number().optional(),
|
||||
top_p: z.number().optional(),
|
||||
max_tokens: z.coerce.number().optional(),
|
||||
temperature: z.coerce.number().optional(),
|
||||
top_p: z.coerce.number().optional(),
|
||||
});
|
||||
|
||||
// OpenAI
|
||||
|
||||
Generated
+390
-249
File diff suppressed because it is too large
Load Diff
+21
-17
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "web",
|
||||
"version": "2.44.0",
|
||||
"version": "2.46.0",
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -25,12 +25,13 @@
|
||||
"@aws-sdk/lib-storage": "^3.568.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.554.0",
|
||||
"@codemirror/lang-json": "^6.0.1",
|
||||
"@headlessui/react": "^1.7.19",
|
||||
"@headlessui/react": "1.7.18",
|
||||
"@headlessui/tailwindcss": "0.2.0",
|
||||
"@heroicons/react": "^2.1.3",
|
||||
"@hookform/resolvers": "^3.3.4",
|
||||
"@langchain/anthropic": "^0.1.16",
|
||||
"@langchain/anthropic": "^0.1.21",
|
||||
"@langchain/core": "^0.1.61",
|
||||
"@langchain/openai": "^0.0.28",
|
||||
"@langchain/openai": "^0.0.33",
|
||||
"@langfuse/ee": "workspace:*",
|
||||
"@langfuse/shared": "workspace:*",
|
||||
"@marsidev/react-turnstile": "^0.5.4",
|
||||
@@ -55,26 +56,27 @@
|
||||
"@radix-ui/react-tabs": "^1.0.4",
|
||||
"@radix-ui/react-toggle": "^1.0.3",
|
||||
"@radix-ui/react-tooltip": "^1.0.7",
|
||||
"@react-email/components": "^0.0.17",
|
||||
"@react-email/render": "^0.0.13",
|
||||
"@react-email/components": "^0.0.18",
|
||||
"@react-email/render": "^0.0.14",
|
||||
"@remixicon/react": "^4.2.0",
|
||||
"@repo/eslint-config": "workspace:*",
|
||||
"@repo/typescript-config": "workspace:*",
|
||||
"@sentry/nextjs": "^7.113.0",
|
||||
"@sentry/node": "^7.113.0",
|
||||
"@sentry/profiling-node": "^7.113.0",
|
||||
"@sentry/types": "^7.113.0",
|
||||
"@t3-oss/env-nextjs": "^0.8.0",
|
||||
"@t3-oss/env-nextjs": "^0.10.1",
|
||||
"@tailwindcss/container-queries": "^0.1.1",
|
||||
"@tailwindcss/forms": "^0.5.7",
|
||||
"@tanstack/react-query": "^4.36.1",
|
||||
"@tanstack/react-table": "^8.11.8",
|
||||
"@tanstack/react-virtual": "^3.5.0",
|
||||
"@tremor/react": "3.11.1",
|
||||
"@tremor/react": "3.16.2",
|
||||
"@trpc/client": "^10.45.0",
|
||||
"@trpc/next": "^10.45.0",
|
||||
"@trpc/react-query": "^10.45.0",
|
||||
"@trpc/server": "^10.45.0",
|
||||
"@uiw/codemirror-theme-github": "^4.21.25",
|
||||
"@uiw/codemirror-theme-tokyo-night": "^4.22.1",
|
||||
"@uiw/react-codemirror": "^4.21.25",
|
||||
"ai": "^3.0.23",
|
||||
"bcryptjs": "^2.4.3",
|
||||
@@ -85,7 +87,7 @@
|
||||
"cors": "^2.8.5",
|
||||
"date-fns": "^3.3.1",
|
||||
"decimal.js": "^10.4.3",
|
||||
"dompurify": "^3.1.2",
|
||||
"dompurify": "^3.1.4",
|
||||
"exponential-backoff": "^3.1.1",
|
||||
"js-tiktoken": "^1.0.12",
|
||||
"kysely": "^0.27.3",
|
||||
@@ -95,6 +97,7 @@
|
||||
"next": "^14.2.3",
|
||||
"next-auth": "^4.24.7",
|
||||
"next-query-params": "^5.0.0",
|
||||
"next-themes": "^0.3.0",
|
||||
"nodemailer": "^6.9.13",
|
||||
"posthog-js": "^1.122.0",
|
||||
"posthog-node": "^3.6.3",
|
||||
@@ -102,8 +105,8 @@
|
||||
"react": "18.2.0",
|
||||
"react-day-picker": "^8.10.1",
|
||||
"react-dom": "18.2.0",
|
||||
"react-hook-form": "^7.51.3",
|
||||
"react-icons": "^5.0.1",
|
||||
"react-hook-form": "^7.51.5",
|
||||
"react-icons": "^5.2.1",
|
||||
"react-responsive": "^10.0.0",
|
||||
"react18-json-view": "^0.2.8-canary.6",
|
||||
"sonner": "^1.4.41",
|
||||
@@ -118,23 +121,24 @@
|
||||
"@jedmao/location": "^3.0.0",
|
||||
"@mermaid-js/mermaid-cli": "^10.7.0",
|
||||
"@playwright/test": "^1.43.1",
|
||||
"@testing-library/jest-dom": "^6.4.2",
|
||||
"@testing-library/react": "^15.0.6",
|
||||
"@tailwindcss/forms": "^0.5.7",
|
||||
"@testing-library/jest-dom": "^6.4.5",
|
||||
"@testing-library/react": "^15.0.7",
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/dompurify": "^3.0.5",
|
||||
"@types/eslint": "^8.56.7",
|
||||
"@types/jest": "^29.5.12",
|
||||
"@types/lodash": "^4.14.202",
|
||||
"@types/lodash": "^4.17.4",
|
||||
"@types/node": "20.10.5",
|
||||
"@types/nodemailer": "^6.4.14",
|
||||
"@types/nodemailer": "^6.4.15",
|
||||
"@types/react": "^18.2.79",
|
||||
"@types/react-dom": "^18.2.25",
|
||||
"@types/uuid": "^9.0.8",
|
||||
"@typescript-eslint/eslint-plugin": "^6.21.0",
|
||||
"@typescript-eslint/parser": "^7.7.0",
|
||||
"autoprefixer": "^10.4.19",
|
||||
"dotenv-cli": "^7.3.0",
|
||||
"dotenv-cli": "^7.4.2",
|
||||
"eslint": "^8.56.0",
|
||||
"eslint-config-next": "^14.2.3",
|
||||
"jest": "^29.7.0",
|
||||
|
||||
@@ -1825,8 +1825,8 @@ components:
|
||||
title: ScoreSource
|
||||
type: string
|
||||
enum:
|
||||
- ANNOTATION
|
||||
- API
|
||||
- REVIEW
|
||||
- EVAL
|
||||
CreateDatasetItemRequest:
|
||||
title: CreateDatasetItemRequest
|
||||
|
||||
@@ -727,7 +727,7 @@ describe("/api/public/ingestion API Endpoint", () => {
|
||||
metadata: { key: "value" },
|
||||
release: "1.0.0",
|
||||
version: "2.0.0",
|
||||
tags: ["tag-1", "tag-2"],
|
||||
tags: ["tag-1", "tag-2", "tag-2"],
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -744,7 +744,7 @@ describe("/api/public/ingestion API Endpoint", () => {
|
||||
id: traceId,
|
||||
name: "trace-name",
|
||||
userId: "user-2",
|
||||
tags: ["tag-3", "tag-4"],
|
||||
tags: ["tag-1", "tag-4", "tag-3"],
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -766,6 +766,7 @@ describe("/api/public/ingestion API Endpoint", () => {
|
||||
expect(dbTrace[0]?.version).toBe("2.0.0");
|
||||
expect(dbTrace[0]?.projectId).toBe("7a88fb47-b4e2-43b8-a06c-a5ce950dc53a");
|
||||
expect(dbTrace[0]?.tags).toEqual(["tag-1", "tag-2", "tag-3", "tag-4"]);
|
||||
expect(dbTrace[0]?.tags.length).toBe(4);
|
||||
});
|
||||
|
||||
it("should fail for wrong event formats", async () => {
|
||||
|
||||
@@ -489,16 +489,16 @@ describe("/api/public/scores API Endpoint", () => {
|
||||
totalPages: 1,
|
||||
});
|
||||
expect(getScore.body.data).toMatchObject([
|
||||
{
|
||||
id: scoreId_1,
|
||||
name: scoreName,
|
||||
value: 10.5,
|
||||
},
|
||||
{
|
||||
id: scoreId_2,
|
||||
name: scoreName,
|
||||
value: 50.5,
|
||||
},
|
||||
{
|
||||
id: scoreId_1,
|
||||
name: scoreName,
|
||||
value: 10.5,
|
||||
},
|
||||
]);
|
||||
});
|
||||
it("test operator >=", async () => {
|
||||
@@ -514,16 +514,16 @@ describe("/api/public/scores API Endpoint", () => {
|
||||
totalPages: 1,
|
||||
});
|
||||
expect(getScore.body.data).toMatchObject([
|
||||
{
|
||||
id: scoreId_2,
|
||||
name: scoreName,
|
||||
value: 50.5,
|
||||
},
|
||||
{
|
||||
id: scoreId_3,
|
||||
name: scoreName,
|
||||
value: 100.8,
|
||||
},
|
||||
{
|
||||
id: scoreId_2,
|
||||
name: scoreName,
|
||||
value: 50.5,
|
||||
},
|
||||
]);
|
||||
});
|
||||
it("test operator !=", async () => {
|
||||
@@ -539,16 +539,16 @@ describe("/api/public/scores API Endpoint", () => {
|
||||
totalPages: 1,
|
||||
});
|
||||
expect(getScore.body.data).toMatchObject([
|
||||
{
|
||||
id: scoreId_1,
|
||||
name: scoreName,
|
||||
value: 10.5,
|
||||
},
|
||||
{
|
||||
id: scoreId_3,
|
||||
name: scoreName,
|
||||
value: 100.8,
|
||||
},
|
||||
{
|
||||
id: scoreId_1,
|
||||
name: scoreName,
|
||||
value: 10.5,
|
||||
},
|
||||
]);
|
||||
});
|
||||
it("test operator =", async () => {
|
||||
|
||||
@@ -182,6 +182,32 @@ describe("/api/public/traces API Endpoint", () => {
|
||||
endTime: "2021-01-01T00:20:00.000Z",
|
||||
});
|
||||
|
||||
// Simulate scores on the trace
|
||||
const scoreId1 = uuidv4();
|
||||
await makeAPICall("POST", "/api/public/scores", {
|
||||
id: scoreId1,
|
||||
name: "score-1",
|
||||
value: 75.0,
|
||||
traceId: traceId,
|
||||
comment: "First score",
|
||||
});
|
||||
const scoreId2 = uuidv4();
|
||||
await makeAPICall("POST", "/api/public/scores", {
|
||||
id: scoreId2,
|
||||
name: "score-2",
|
||||
value: 85.5,
|
||||
traceId: traceId,
|
||||
comment: "Second score",
|
||||
});
|
||||
const scoreId3 = uuidv4();
|
||||
await makeAPICall("POST", "/api/public/scores", {
|
||||
id: scoreId3,
|
||||
name: "score-3",
|
||||
value: 95.0,
|
||||
traceId: traceId,
|
||||
comment: "Third score",
|
||||
});
|
||||
|
||||
// GET traces
|
||||
// Retrieve the trace with totalCost and latency
|
||||
const traces = await makeAPICall<GetTracesAPIResponse>(
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import chatCompletionHandler from "@/src/ee/features/playground/server/chatCompletionHandler";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const maxDuration = 60;
|
||||
|
||||
export const POST = chatCompletionHandler;
|
||||
@@ -63,6 +63,7 @@ export const ChatMessageComponent: React.FC<ChatMessageProps> = ({
|
||||
|
||||
<Textarea
|
||||
ref={textAreaRef}
|
||||
id={message.id}
|
||||
className="height-[auto] min-h-8 w-full pt-3 font-mono text-xs focus:outline-none"
|
||||
placeholder={placeholder}
|
||||
value={message.content}
|
||||
|
||||
@@ -41,10 +41,10 @@ export const LangfuseLogo = ({
|
||||
className={cn(
|
||||
"flex items-center gap-2 self-stretch rounded-md px-1 py-1 text-xs ring-1 sm:px-3 sm:py-2 lg:-mx-2",
|
||||
env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION === "STAGING"
|
||||
? "bg-blue-100 text-blue-500 ring-blue-500"
|
||||
? "bg-light-blue text-dark-blue ring-dark-blue"
|
||||
: env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION === "DEV"
|
||||
? "bg-green-100 text-green-500 ring-green-500"
|
||||
: "bg-red-100 text-red-500 ring-red-500",
|
||||
? "bg-light-green text-dark-green ring-dark-green"
|
||||
: "bg-light-red text-dark-red ring-dark-red",
|
||||
)}
|
||||
>
|
||||
{env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION === "DEV" ? (
|
||||
@@ -76,7 +76,7 @@ export const LangfuseLogo = ({
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
title="View releases on GitHub"
|
||||
className="ml-2 text-xs text-gray-400"
|
||||
className="ml-2 text-xs text-muted-foreground"
|
||||
>
|
||||
{VERSION}
|
||||
</a>
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import Link from "next/link";
|
||||
|
||||
import { Label } from "@/src/components/ui/label";
|
||||
import { useHasAccess } from "@/src/features/rbac/utils/checkAccess";
|
||||
import { api } from "@/src/utils/api";
|
||||
import { supportedModels, type UIModelParams } from "@langfuse/shared";
|
||||
import { ArrowTopRightIcon } from "@radix-ui/react-icons";
|
||||
|
||||
export const LLMApiKeyComponent = (p: {
|
||||
projectId: string;
|
||||
modelParams: UIModelParams;
|
||||
}) => {
|
||||
const hasAccess = useHasAccess({
|
||||
projectId: p.projectId,
|
||||
scope: "llmApiKeys:read",
|
||||
});
|
||||
|
||||
if (!hasAccess) {
|
||||
return (
|
||||
<div>
|
||||
<Label className="text-xs font-semibold">API key</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
LLM API Key only visible to Owner and Admin roles.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const apiKeys = api.llmApiKey.all.useQuery({
|
||||
projectId: p.projectId,
|
||||
});
|
||||
|
||||
if (apiKeys.isLoading) {
|
||||
return (
|
||||
<div>
|
||||
<Label className="text-xs font-semibold">API key</Label>
|
||||
<p className="text-sm text-muted-foreground">Loading...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const model = p.modelParams.model.value;
|
||||
const modelProvider = Object.entries(supportedModels).find((providerData) =>
|
||||
(providerData[1] as any as string[]).includes(model),
|
||||
)?.[0];
|
||||
|
||||
const apiKey = apiKeys.data?.data.find((k) => k.provider === modelProvider);
|
||||
|
||||
return (
|
||||
<div className="space-y-2 text-xs">
|
||||
<Label className="text-xs font-semibold">API key</Label>
|
||||
<div>
|
||||
{apiKey ? (
|
||||
<span className="mr-2 rounded-sm bg-input p-1 text-xs">
|
||||
{apiKey.displaySecretKey}
|
||||
</span>
|
||||
) : undefined}
|
||||
</div>
|
||||
{/* Custom form message to include a link to the already existing prompt */}
|
||||
{!apiKey ? (
|
||||
<div className="flex flex-col font-medium text-destructive">
|
||||
{`No LLM API key found for provider ${modelProvider}.`}
|
||||
|
||||
<Link
|
||||
href={`/project/${p.projectId}/settings`}
|
||||
className="flex flex-row"
|
||||
>
|
||||
Create a new LLM API key here. <ArrowTopRightIcon />
|
||||
</Link>
|
||||
</div>
|
||||
) : undefined}
|
||||
<p className="text-muted-foreground">
|
||||
The LLM API key is used for each execution and will incur costs.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+16
-3
@@ -8,6 +8,7 @@ import {
|
||||
} from "@/src/components/ui/select";
|
||||
import { Slider } from "@/src/components/ui/slider";
|
||||
import { Switch } from "@/src/components/ui/switch";
|
||||
import useProjectIdFromURL from "@/src/hooks/useProjectIdFromURL";
|
||||
import { cn } from "@/src/utils/tailwind";
|
||||
import {
|
||||
ModelProvider,
|
||||
@@ -15,6 +16,8 @@ import {
|
||||
type UIModelParams,
|
||||
} from "@langfuse/shared";
|
||||
|
||||
import { LLMApiKeyComponent } from "./LLMApiKeyComponent";
|
||||
|
||||
export type ModelParamsContext = {
|
||||
modelParams: UIModelParams;
|
||||
availableModels?: UIModelParams[];
|
||||
@@ -33,10 +36,14 @@ export const ModelParameters: React.FC<ModelParamsContext> = ({
|
||||
setModelParamEnabled,
|
||||
formDisabled = false,
|
||||
}) => {
|
||||
const projectId = useProjectIdFromURL();
|
||||
|
||||
if (!projectId) return null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col space-y-4">
|
||||
<p className="font-semibold">Model</p>
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-4">
|
||||
<ModelParamsSelect
|
||||
title="Provider"
|
||||
modelParamsKey="provider"
|
||||
@@ -104,6 +111,7 @@ export const ModelParameters: React.FC<ModelParamsContext> = ({
|
||||
tooltip="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. We generally recommend altering this or temperature but not both."
|
||||
updateModelParam={updateModelParamValue}
|
||||
/>
|
||||
<LLMApiKeyComponent {...{ projectId, modelParams }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -127,7 +135,12 @@ const ModelParamsSelect = ({
|
||||
}: ModelParamsSelectProps) => {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<p className={cn("text-xs font-semibold", disabled && "text-gray-400")}>
|
||||
<p
|
||||
className={cn(
|
||||
"text-xs font-semibold",
|
||||
disabled && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{title}
|
||||
</p>
|
||||
<Select
|
||||
@@ -187,7 +200,7 @@ const ModelParamsSlider = ({
|
||||
<p
|
||||
className={cn(
|
||||
"flex-1 text-xs font-semibold",
|
||||
(!enabled || formDisabled) && "text-gray-400",
|
||||
(!enabled || formDisabled) && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{title}
|
||||
@@ -7,27 +7,23 @@ export const Slider = (props: {
|
||||
loading?: boolean;
|
||||
onChecked?: (checked: boolean) => void;
|
||||
isChecked?: boolean; // whether the slider is active
|
||||
}) => {
|
||||
console.log("props", props);
|
||||
|
||||
return (
|
||||
<Switch
|
||||
checked={props.isChecked}
|
||||
disabled={props.loading || props.disabled}
|
||||
onChange={props.onChecked}
|
||||
}) => (
|
||||
<Switch
|
||||
checked={props.isChecked}
|
||||
disabled={props.loading || props.disabled}
|
||||
onChange={props.onChecked}
|
||||
className={cn(
|
||||
props.isChecked ? "bg-background" : "bg-input",
|
||||
"relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-foreground focus:ring-offset-2",
|
||||
)}
|
||||
>
|
||||
<span className="sr-only">Use setting</span>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
props.isChecked ? "bg-black" : "bg-gray-200",
|
||||
"relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-black focus:ring-offset-2",
|
||||
props.isChecked ? "translate-x-5" : "translate-x-0",
|
||||
"pointer-events-none inline-block h-5 w-5 transform rounded-full bg-background shadow ring-0 transition duration-200 ease-in-out",
|
||||
)}
|
||||
>
|
||||
<span className="sr-only">Use setting</span>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
props.isChecked ? "translate-x-5" : "translate-x-0",
|
||||
"pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out",
|
||||
)}
|
||||
/>
|
||||
</Switch>
|
||||
);
|
||||
};
|
||||
/>
|
||||
</Switch>
|
||||
);
|
||||
|
||||
@@ -18,7 +18,7 @@ export const ErrorPage = ({
|
||||
|
||||
return (
|
||||
<div className="flex h-screen flex-col items-center justify-center">
|
||||
<AlertCircle className="mb-4 h-12 w-12 text-red-500" />
|
||||
<AlertCircle className="text-dark-red mb-4 h-12 w-12" />
|
||||
<h1 className="mb-4 text-xl font-bold">{title}</h1>
|
||||
<p className="mb-8 text-center">{message}</p>
|
||||
{session.status === "unauthenticated" ? (
|
||||
|
||||
@@ -58,7 +58,7 @@ export const GroupedScoreBadges = ({
|
||||
.sort(([a], [b]) => (a < b ? -1 : 1))
|
||||
.map(([name, scores]) => (
|
||||
<div key={name}>
|
||||
<div className="text-xs text-gray-500">{name}</div>
|
||||
<div className="text-xs text-muted-foreground">{name}</div>
|
||||
<ScoresOfGroup scores={scores} />
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import CodeMirror, { EditorView } from "@uiw/react-codemirror";
|
||||
import { githubLight } from "@uiw/codemirror-theme-github";
|
||||
import { tokyoNight } from "@uiw/codemirror-theme-tokyo-night";
|
||||
import { json } from "@codemirror/lang-json";
|
||||
import { useTheme } from "next-themes";
|
||||
import { cn } from "@/src/utils/tailwind";
|
||||
|
||||
// todo: add json linting
|
||||
@@ -18,10 +20,12 @@ export function JsonEditor({
|
||||
lineWrapping?: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
const { theme } = useTheme();
|
||||
const codeMirrorTheme = theme === "dark" ? tokyoNight : githubLight;
|
||||
return (
|
||||
<CodeMirror
|
||||
value={defaultValue}
|
||||
theme={githubLight}
|
||||
theme={codeMirrorTheme}
|
||||
basicSetup={{
|
||||
foldGutter: true,
|
||||
}}
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
HoverCardTrigger,
|
||||
} from "@/src/components/ui/hover-card";
|
||||
import { usePostHogClientCapture } from "@/src/features/posthog-analytics/usePostHogClientCapture";
|
||||
import { cn } from "@/src/utils/tailwind";
|
||||
import { HelpCircle, Info } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
|
||||
@@ -40,13 +41,16 @@ export default function DocPopup({
|
||||
}
|
||||
}}
|
||||
>
|
||||
<HoverCardTrigger className="mx-1 cursor-pointer" asChild>
|
||||
<HoverCardTrigger
|
||||
className={cn("mx-1", href ? "cursor-pointer" : "cursor-default")}
|
||||
asChild
|
||||
>
|
||||
{href ? (
|
||||
<Link
|
||||
href={href}
|
||||
rel="noopener"
|
||||
target="_blank"
|
||||
className="inline-block whitespace-nowrap text-gray-500 sm:pl-0"
|
||||
className="inline-block whitespace-nowrap text-muted-foreground sm:pl-0"
|
||||
onClick={() => {
|
||||
capture("help_popup:href_clicked", {
|
||||
href: href,
|
||||
@@ -62,7 +66,7 @@ export default function DocPopup({
|
||||
}
|
||||
</Link>
|
||||
) : (
|
||||
<div className="inline-block whitespace-nowrap text-gray-500 sm:pl-0">
|
||||
<div className="inline-block whitespace-nowrap text-muted-foreground sm:pl-0">
|
||||
{
|
||||
{
|
||||
question: <HelpCircle className={sizes[size]} />,
|
||||
@@ -74,7 +78,7 @@ export default function DocPopup({
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent>
|
||||
{typeof description === "string" ? (
|
||||
<div className="whitespace-break-spaces text-xs font-normal text-gray-800 sm:pl-0">
|
||||
<div className="whitespace-break-spaces text-xs font-normal text-primary sm:pl-0">
|
||||
{description}
|
||||
</div>
|
||||
) : (
|
||||
@@ -98,7 +102,7 @@ export function Popup({ triggerContent, description }: PopupProps) {
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent>
|
||||
{typeof description === "string" ? (
|
||||
<div className="whitespace-break-spaces text-xs font-normal text-gray-800 sm:pl-0">
|
||||
<div className="whitespace-break-spaces text-xs font-normal text-primary sm:pl-0">
|
||||
{description}
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -49,10 +49,10 @@ export default function Header({
|
||||
<nav className="sm:hidden" aria-label="Back">
|
||||
<Link
|
||||
href={backHref}
|
||||
className="flex items-center text-sm font-medium text-gray-500 hover:text-gray-700"
|
||||
className="flex items-center text-sm font-medium text-muted-foreground hover:text-primary"
|
||||
>
|
||||
<ChevronLeftIcon
|
||||
className="-ml-1 mr-1 h-5 w-5 flex-shrink-0 text-gray-400"
|
||||
className="-ml-1 mr-1 h-5 w-5 flex-shrink-0 text-muted-foreground"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
Back
|
||||
@@ -67,19 +67,19 @@ export default function Header({
|
||||
<div className="flex items-center">
|
||||
{index !== 0 && (
|
||||
<ChevronRightIcon
|
||||
className="mr-4 h-5 w-5 flex-shrink-0 text-gray-400"
|
||||
className="mr-4 h-5 w-5 flex-shrink-0 text-muted-foreground"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
{href ? (
|
||||
<Link
|
||||
href={href}
|
||||
className="text-sm font-medium text-gray-500 hover:text-gray-700"
|
||||
className="text-sm font-medium text-muted-foreground hover:text-primary"
|
||||
>
|
||||
{name}
|
||||
</Link>
|
||||
) : (
|
||||
<div className="text-sm font-medium text-gray-500">
|
||||
<div className="text-sm font-medium text-muted-foreground">
|
||||
{name}
|
||||
</div>
|
||||
)}
|
||||
@@ -94,11 +94,11 @@ export default function Header({
|
||||
<div className="flex items-center gap-3 md:gap-5">
|
||||
<div className="flex min-w-0 flex-row justify-center align-middle">
|
||||
{level === "h2" ? (
|
||||
<h2 className="text-2xl font-bold leading-7 text-gray-900 sm:truncate sm:text-3xl sm:tracking-tight">
|
||||
<h2 className="text-2xl font-bold leading-7 sm:truncate sm:text-3xl sm:tracking-tight">
|
||||
{props.title}
|
||||
</h2>
|
||||
) : (
|
||||
<h3 className="text-lg font-bold leading-7 text-gray-900 sm:truncate sm:text-xl sm:tracking-tight">
|
||||
<h3 className="text-lg font-bold leading-7 sm:truncate sm:text-xl sm:tracking-tight">
|
||||
{props.title}
|
||||
</h3>
|
||||
)}
|
||||
|
||||
@@ -31,14 +31,26 @@ import { ChevronDownIcon } from "@heroicons/react/20/solid";
|
||||
import useLocalStorage from "@/src/components/useLocalStorage";
|
||||
import { ProjectNavigation } from "@/src/components/projectNavigation";
|
||||
import DOMPurify from "dompurify";
|
||||
import { ThemeToggle } from "@/src/features/theming/ThemeToggle";
|
||||
|
||||
const signOutUser = async () => {
|
||||
localStorage.clear();
|
||||
sessionStorage.clear();
|
||||
|
||||
await signOut({
|
||||
callbackUrl: "/auth/sign-in",
|
||||
});
|
||||
};
|
||||
|
||||
const userNavigation = [
|
||||
{
|
||||
name: "Theme",
|
||||
onClick: () => {},
|
||||
content: <ThemeToggle />,
|
||||
},
|
||||
{
|
||||
name: "Sign out",
|
||||
onClick: () =>
|
||||
signOut({
|
||||
callbackUrl: "/auth/sign-in",
|
||||
}),
|
||||
onClick: signOutUser,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -135,9 +147,8 @@ export default function Layout(props: PropsWithChildren) {
|
||||
!publishablePaths.includes(router.pathname) &&
|
||||
!router.pathname.startsWith("/public/")
|
||||
) {
|
||||
void signOut({
|
||||
callbackUrl: "/auth/sign-in",
|
||||
});
|
||||
signOutUser();
|
||||
|
||||
return <Spinner message="Redirecting" />;
|
||||
}
|
||||
|
||||
@@ -178,7 +189,7 @@ export default function Layout(props: PropsWithChildren) {
|
||||
router.pathname.startsWith("/public/");
|
||||
if (hideNavigation)
|
||||
return (
|
||||
<main className="min-h-screen bg-gray-50 px-4 py-4 sm:px-6 lg:px-8">
|
||||
<main className="min-h-screen bg-primary-foreground px-4 py-4 sm:px-6 lg:px-8">
|
||||
{props.children}
|
||||
</main>
|
||||
);
|
||||
@@ -222,7 +233,7 @@ export default function Layout(props: PropsWithChildren) {
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<div className="fixed inset-0 bg-gray-900/80" />
|
||||
<div className="fixed inset-0 bg-primary/80" />
|
||||
</Transition.Child>
|
||||
|
||||
<div className="fixed inset-0 flex">
|
||||
@@ -253,14 +264,14 @@ export default function Layout(props: PropsWithChildren) {
|
||||
>
|
||||
<span className="sr-only">Close sidebar</span>
|
||||
<XMarkIcon
|
||||
className="h-5 w-5 text-white"
|
||||
className="h-5 w-5 text-background"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</Transition.Child>
|
||||
{/* Sidebar component, swap this element with another sidebar if you like */}
|
||||
<div className="flex grow flex-col gap-y-5 overflow-y-auto bg-white px-6 py-4">
|
||||
<div className="flex grow flex-col gap-y-5 overflow-y-auto bg-background px-6 py-4">
|
||||
<LangfuseLogo
|
||||
version
|
||||
size="xl"
|
||||
@@ -273,7 +284,7 @@ export default function Layout(props: PropsWithChildren) {
|
||||
<MainNavigation nav={navigation} />
|
||||
</ul>
|
||||
<div className="mb-2 flex flex-row place-content-between items-center">
|
||||
<div className="text-xs font-semibold text-gray-400">
|
||||
<div className="text-xs font-semibold text-muted-foreground">
|
||||
Project
|
||||
</div>
|
||||
<NewProjectButton size="xs" />
|
||||
@@ -293,7 +304,7 @@ export default function Layout(props: PropsWithChildren) {
|
||||
{/* Static sidebar for desktop */}
|
||||
<div className="hidden lg:fixed lg:inset-y-0 lg:z-50 lg:flex lg:w-56 lg:flex-col">
|
||||
{/* Sidebar component, swap this element with another sidebar if you like */}
|
||||
<div className="flex h-screen grow flex-col border-r border-gray-200 bg-white pt-7">
|
||||
<div className="flex h-screen grow flex-col border-r border-border bg-background pt-7">
|
||||
<LangfuseLogo
|
||||
version
|
||||
size="xl"
|
||||
@@ -312,16 +323,16 @@ export default function Layout(props: PropsWithChildren) {
|
||||
description="What do you think about this project? What can be improved?"
|
||||
type="feedback"
|
||||
>
|
||||
<li className="group -mx-2 my-1 flex cursor-pointer gap-x-3 rounded-md p-1.5 text-sm font-semibold text-gray-700 hover:bg-gray-50 hover:text-indigo-600">
|
||||
<li className="group -mx-2 my-1 flex cursor-pointer gap-x-3 rounded-md p-1.5 text-sm font-semibold text-primary hover:bg-primary-foreground hover:text-primary-accent">
|
||||
<MessageSquarePlus
|
||||
className="h-5 w-5 shrink-0 text-gray-400 group-hover:text-indigo-600"
|
||||
className="h-5 w-5 shrink-0 text-muted-foreground group-hover:text-primary-accent"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
Feedback
|
||||
</li>
|
||||
</FeedbackButtonWrapper>
|
||||
<div className="mb-2 flex flex-row place-content-between items-center">
|
||||
<div className="text-xs font-semibold text-gray-400">
|
||||
<div className="text-xs font-semibold text-muted-foreground">
|
||||
Project
|
||||
</div>
|
||||
<NewProjectButton size="xs" />
|
||||
@@ -334,7 +345,7 @@ export default function Layout(props: PropsWithChildren) {
|
||||
</nav>
|
||||
|
||||
<Menu as="div" className="relative">
|
||||
<Menu.Button className="flex w-full items-center gap-x-2 overflow-hidden p-1.5 py-3 pl-6 pr-8 text-sm font-semibold text-gray-900 hover:bg-gray-50">
|
||||
<Menu.Button className="flex w-full items-center gap-x-2 overflow-hidden p-1.5 py-3 pl-6 pr-8 text-sm font-semibold text-primary hover:bg-primary-foreground">
|
||||
<span className="sr-only">Open user menu</span>
|
||||
<Avatar className="h-7 w-7">
|
||||
<AvatarImage src={session.data?.user?.image ?? undefined} />
|
||||
@@ -348,12 +359,12 @@ export default function Layout(props: PropsWithChildren) {
|
||||
: null}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="flex-shrink truncate text-sm font-semibold text-gray-900">
|
||||
<span className="flex-shrink truncate text-sm font-semibold text-primary">
|
||||
{session.data?.user?.name}
|
||||
</span>
|
||||
<div className="flex-1" />
|
||||
<ChevronDownIcon
|
||||
className="h-5 w-5 text-gray-400"
|
||||
className="h-5 w-5 text-muted-foreground"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</Menu.Button>
|
||||
@@ -366,8 +377,8 @@ export default function Layout(props: PropsWithChildren) {
|
||||
leaveFrom="transform opacity-100 scale-100"
|
||||
leaveTo="transform opacity-0 scale-95"
|
||||
>
|
||||
<Menu.Items className="absolute -top-full right-0 z-10 mt-2.5 rounded-md bg-white py-2 shadow-lg ring-1 ring-gray-900/5 focus:outline-none">
|
||||
<span className="mb-1 block border-b px-3 pb-2 text-sm text-gray-500">
|
||||
<Menu.Items className="absolute -top-full bottom-1 right-0 z-10 overflow-hidden rounded-md bg-background py-2 shadow-lg ring-1 ring-border focus:outline-none">
|
||||
<span className="block border-b px-3 pb-2 text-sm leading-6 text-muted-foreground">
|
||||
{session.data?.user?.email}
|
||||
</span>
|
||||
{userNavigation.map((item) => (
|
||||
@@ -376,11 +387,12 @@ export default function Layout(props: PropsWithChildren) {
|
||||
<a
|
||||
onClick={() => void item.onClick()}
|
||||
className={cn(
|
||||
active ? "bg-gray-50" : "",
|
||||
"block cursor-pointer px-3 py-1 text-sm text-gray-900",
|
||||
active ? "bg-primary-foreground" : "",
|
||||
"flex cursor-pointer items-center justify-between px-2 py-0.5 text-sm leading-6 text-primary",
|
||||
)}
|
||||
>
|
||||
{item.name}
|
||||
{item.content}
|
||||
</a>
|
||||
)}
|
||||
</Menu.Item>
|
||||
@@ -391,10 +403,10 @@ export default function Layout(props: PropsWithChildren) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="sticky top-0 z-40 flex items-center gap-x-6 bg-white px-4 py-4 shadow-sm sm:px-6 lg:hidden">
|
||||
<div className="sticky top-0 z-40 flex items-center gap-x-6 bg-background px-4 py-4 shadow-sm sm:px-6 lg:hidden">
|
||||
<button
|
||||
type="button"
|
||||
className="-m-2.5 p-2.5 text-gray-700 lg:hidden"
|
||||
className="-m-2.5 p-2.5 text-primary lg:hidden"
|
||||
onClick={() => setSidebarOpen(true)}
|
||||
>
|
||||
<span className="sr-only">Open sidebar</span>
|
||||
@@ -406,7 +418,7 @@ export default function Layout(props: PropsWithChildren) {
|
||||
showEnvLabel={session.data?.user?.email?.endsWith("@langfuse.com")}
|
||||
/>
|
||||
<Menu as="div" className="relative">
|
||||
<Menu.Button className="flex items-center gap-x-4 text-sm font-semibold text-gray-900">
|
||||
<Menu.Button className="flex items-center gap-x-4 text-sm font-semibold text-primary">
|
||||
<span className="sr-only">Open user menu</span>
|
||||
<Avatar className="h-8 w-8">
|
||||
<AvatarImage src={session.data?.user?.image ?? undefined} />
|
||||
@@ -430,8 +442,8 @@ export default function Layout(props: PropsWithChildren) {
|
||||
leaveFrom="transform opacity-100 scale-100"
|
||||
leaveTo="transform opacity-0 scale-95"
|
||||
>
|
||||
<Menu.Items className="absolute right-0 z-10 mt-2.5 rounded-md bg-white py-2 shadow-lg ring-1 ring-gray-900/5 focus:outline-none">
|
||||
<span className="mb-1 block border-b px-3 pb-2 text-sm text-gray-500">
|
||||
<Menu.Items className="absolute right-0 z-10 mt-2.5 rounded-md bg-background py-2 pb-1 shadow-lg ring-1 ring-border focus:outline-none">
|
||||
<span className="mb-1 block border-b px-3 pb-2 text-sm leading-6 text-muted-foreground">
|
||||
{session.data?.user?.email}
|
||||
</span>
|
||||
{userNavigation.map((item) => (
|
||||
@@ -440,11 +452,12 @@ export default function Layout(props: PropsWithChildren) {
|
||||
<a
|
||||
onClick={() => void item.onClick()}
|
||||
className={cn(
|
||||
active ? "bg-gray-50" : "",
|
||||
"block cursor-pointer px-3 py-1 text-sm text-gray-900",
|
||||
active ? "bg-primary-foreground" : "",
|
||||
"flex cursor-pointer items-center justify-between px-2 py-1 text-sm leading-6 text-primary",
|
||||
)}
|
||||
>
|
||||
{item.name}
|
||||
{item.content}
|
||||
</a>
|
||||
)}
|
||||
</Menu.Item>
|
||||
@@ -459,7 +472,7 @@ export default function Layout(props: PropsWithChildren) {
|
||||
(env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION === "STAGING" ||
|
||||
env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION === "EU") &&
|
||||
!session.data?.user?.email?.endsWith("@langfuse.com") ? (
|
||||
<div className="flex w-full items-center border-b border-yellow-500 bg-yellow-100 px-4 py-2 lg:sticky lg:top-0 lg:z-40">
|
||||
<div className="flex w-full items-center border-b border-dark-yellow bg-light-yellow px-4 py-2 lg:sticky lg:top-0 lg:z-40">
|
||||
<div className="flex flex-1 flex-wrap gap-1">
|
||||
<div className="flex items-center gap-1">
|
||||
<Info className="h-4 w-4" />
|
||||
@@ -523,9 +536,9 @@ const MainNavigation: React.FC<{
|
||||
href={item.href}
|
||||
className={clsx(
|
||||
item.current
|
||||
? "bg-gray-50 text-indigo-600"
|
||||
: "text-gray-700 hover:bg-gray-50 hover:text-indigo-600",
|
||||
"group flex gap-x-3 rounded-md p-1.5 text-sm font-semibold",
|
||||
? "bg-primary-foreground text-primary-accent"
|
||||
: "text-primary hover:bg-primary-foreground hover:text-primary-accent",
|
||||
"group flex gap-x-3 rounded-md p-2 text-sm font-semibold",
|
||||
)}
|
||||
onClick={onNavitemClick}
|
||||
target={item.newTab ? "_blank" : undefined}
|
||||
@@ -534,8 +547,8 @@ const MainNavigation: React.FC<{
|
||||
<item.icon
|
||||
className={clsx(
|
||||
item.current
|
||||
? "text-indigo-600"
|
||||
: "text-gray-400 group-hover:text-indigo-600",
|
||||
? "text-primary-accent"
|
||||
: "text-muted-foreground group-hover:text-primary-accent",
|
||||
"h-5 w-5 shrink-0",
|
||||
)}
|
||||
aria-hidden="true"
|
||||
@@ -547,8 +560,8 @@ const MainNavigation: React.FC<{
|
||||
className={cn(
|
||||
"-my-0.5 self-center whitespace-nowrap break-keep rounded-sm border px-1 py-0.5 text-xs",
|
||||
item.current
|
||||
? "border-indigo-600 text-indigo-600"
|
||||
: "border-gray-200 text-gray-400 group-hover:border-indigo-600 group-hover:text-indigo-600",
|
||||
? "border-primary-accent text-primary-accent"
|
||||
: "border-border text-muted-foreground group-hover:border-primary-accent group-hover:text-primary-accent",
|
||||
)}
|
||||
>
|
||||
{item.label}
|
||||
@@ -565,12 +578,12 @@ const MainNavigation: React.FC<{
|
||||
{({ open }) => (
|
||||
<>
|
||||
<Disclosure.Button
|
||||
className="group flex w-full items-center gap-x-3 rounded-md p-1.5 text-left text-sm font-semibold hover:bg-gray-50 hover:text-indigo-600"
|
||||
className="group flex w-full items-center gap-x-3 rounded-md p-2 text-left text-sm font-semibold hover:bg-primary-foreground hover:text-primary-accent"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
>
|
||||
{item.icon && (
|
||||
<item.icon
|
||||
className="h-5 w-5 shrink-0 text-gray-400 group-hover:text-indigo-600"
|
||||
className="h-5 w-5 shrink-0 text-muted-foreground group-hover:text-primary-accent"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
@@ -580,8 +593,8 @@ const MainNavigation: React.FC<{
|
||||
className={cn(
|
||||
"-my-0.5 self-center whitespace-nowrap break-keep rounded-sm border px-1 py-0.5 text-xs",
|
||||
item.current
|
||||
? "border-indigo-600 text-indigo-600"
|
||||
: "border-gray-200 text-gray-400 group-hover:border-indigo-600 group-hover:text-indigo-600",
|
||||
? "border-primary-accent text-primary-accent"
|
||||
: "border-border text-muted-foreground group-hover:border-primary-accent group-hover:text-primary-accent",
|
||||
)}
|
||||
>
|
||||
{item.label}
|
||||
@@ -589,7 +602,9 @@ const MainNavigation: React.FC<{
|
||||
)}
|
||||
<ChevronRightIcon
|
||||
className={clsx(
|
||||
open ? "rotate-90 text-gray-500" : "text-gray-400",
|
||||
open
|
||||
? "rotate-90 text-muted-foreground"
|
||||
: "text-muted-foreground",
|
||||
"ml-auto h-5 w-5 shrink-0",
|
||||
)}
|
||||
aria-hidden="true"
|
||||
@@ -603,15 +618,15 @@ const MainNavigation: React.FC<{
|
||||
href={subItem.href ?? "#"}
|
||||
className={clsx(
|
||||
subItem.current
|
||||
? "bg-gray-50 text-indigo-600"
|
||||
: "text-gray-700 hover:bg-gray-50 hover:text-indigo-600",
|
||||
? "bg-primary-foreground text-primary-accent"
|
||||
: "text-primary hover:bg-primary-foreground hover:text-primary-accent",
|
||||
"ml-0.5 flex w-full items-center gap-x-3 rounded-md p-1.5 pl-7 pr-2 text-sm",
|
||||
)}
|
||||
target={subItem.newTab ? "_blank" : undefined}
|
||||
>
|
||||
{subItem.name}
|
||||
{subItem.label && (
|
||||
<span className="self-center whitespace-nowrap break-keep rounded-sm border border-gray-200 px-1 py-0.5 text-xs text-gray-400 group-hover:border-indigo-600 group-hover:text-indigo-600">
|
||||
<span className="self-center whitespace-nowrap break-keep rounded-sm border border-border px-1 py-0.5 text-xs text-muted-foreground group-hover:border-primary-accent group-hover:text-primary-accent">
|
||||
{subItem.label}
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -5,7 +5,7 @@ export function Spinner(props: { message: string }) {
|
||||
<div className="flex min-h-full flex-1 flex-col justify-center py-12 sm:px-6 lg:px-8">
|
||||
<div className="sm:mx-auto sm:w-full sm:max-w-md">
|
||||
<LangfuseIcon className="mx-auto motion-safe:animate-spin" size={42} />
|
||||
<h2 className="mt-5 text-center text-2xl font-bold leading-9 tracking-tight text-gray-900">
|
||||
<h2 className="mt-5 text-center text-2xl font-bold leading-9 tracking-tight text-primary">
|
||||
{props.message} ...
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
@@ -11,22 +11,22 @@ export type Status =
|
||||
(typeof statusCategories)[keyof typeof statusCategories][number];
|
||||
|
||||
export const StatusBadge = (props: { className?: string; type: Status }) => {
|
||||
let badgeColor = "bg-gray-100 text-gray-800";
|
||||
let dotColor = "bg-gray-500";
|
||||
let dotPingColor = "bg-gray-600";
|
||||
let badgeColor = "bg-muted-gray text-primary";
|
||||
let dotColor = "bg-muted-foreground";
|
||||
let dotPingColor = "bg-muted-foreground";
|
||||
let showDot = true;
|
||||
|
||||
if (statusCategories.active.includes(props.type)) {
|
||||
badgeColor = "bg-green-100 text-green-600";
|
||||
dotColor = "animate-ping bg-green-500";
|
||||
dotPingColor = "bg-green-600";
|
||||
badgeColor = "bg-light-green text-dark-green";
|
||||
dotColor = "animate-ping bg-dark-green";
|
||||
dotPingColor = "bg-dark-green";
|
||||
} else if (statusCategories.error.includes(props.type)) {
|
||||
badgeColor = "bg-red-100 text-red-600";
|
||||
dotColor = "bg-red-500";
|
||||
dotPingColor = "bg-red-600";
|
||||
badgeColor = "bg-light-red text-dark-red";
|
||||
dotColor = "animate-ping bg-dark-red";
|
||||
dotPingColor = "bg-dark-red";
|
||||
showDot = false;
|
||||
} else if (statusCategories.completed.includes(props.type)) {
|
||||
badgeColor = "bg-green-100 text-green-600";
|
||||
badgeColor = "bg-light-green text-dark-green";
|
||||
showDot = false;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export const LevelColors = {
|
||||
DEFAULT: { text: "", bg: "" },
|
||||
DEBUG: { text: "text-gray-500", bg: "bg-gray-50" },
|
||||
WARNING: { text: "text-yellow-800", bg: "bg-yellow-50" },
|
||||
ERROR: { text: "text-red-800", bg: "bg-red-50" },
|
||||
DEBUG: { text: "text-muted-foreground", bg: "bg-primary-foreground" },
|
||||
WARNING: { text: "text-dark-yellow", bg: "bg-light-yellow" },
|
||||
ERROR: { text: "text-dark-red", bg: "bg-light-red" },
|
||||
};
|
||||
|
||||
@@ -32,9 +32,9 @@ export const ProjectNavigation: React.FC<ProjectNavigationProps> = ({
|
||||
router.push(`/project/${value}`);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-gray-700 ring-transparent focus:ring-0 focus:ring-offset-0">
|
||||
<SelectTrigger className="h-8 text-primary ring-transparent focus:ring-0 focus:ring-offset-0">
|
||||
<SelectValue
|
||||
className="text-sm font-semibold text-gray-700"
|
||||
className="text-sm font-semibold text-primary"
|
||||
placeholder={currentProjectId}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
@@ -46,8 +46,8 @@ export const ProjectNavigation: React.FC<ProjectNavigationProps> = ({
|
||||
className={cn(
|
||||
"truncate",
|
||||
currentProjectId === project.id
|
||||
? "border-gray-700 text-gray-700"
|
||||
: "text-gray-400",
|
||||
? "border-primary text-primary"
|
||||
: "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{project.name}
|
||||
@@ -57,8 +57,8 @@ export const ProjectNavigation: React.FC<ProjectNavigationProps> = ({
|
||||
className={cn(
|
||||
"self-center whitespace-nowrap break-keep rounded-sm border px-1 py-0.5 text-xs",
|
||||
currentProjectId === project.id
|
||||
? "border-gray-700 text-gray-700"
|
||||
: "border-gray-200 text-gray-400 group-hover:border-gray-700 group-hover:text-gray-700",
|
||||
? "border-primary text-primary"
|
||||
: "border-border text-muted-foreground group-hover:border-primary group-hover:text-primary",
|
||||
)}
|
||||
>
|
||||
view-only
|
||||
|
||||
@@ -93,7 +93,7 @@ const Base = (props: {
|
||||
"Loading.."
|
||||
) : props.isPublic ? (
|
||||
<div
|
||||
className="flex cursor-pointer items-center gap-1 text-green-800"
|
||||
className="text-dark-green flex cursor-pointer items-center gap-1"
|
||||
onClick={() => copyUrl()}
|
||||
>
|
||||
{isCopied ? "Link copied ..." : "Public"}
|
||||
|
||||
@@ -171,7 +171,7 @@ const TraceCardList = ({
|
||||
}))
|
||||
.map(({ virtualItem, trace }) => (
|
||||
<Card
|
||||
className="border-border-gray-150 group grid w-full gap-3 overflow-hidden p-2 shadow-none hover:border-gray-300 md:grid-cols-3"
|
||||
className="group grid w-full gap-3 overflow-hidden border-border p-2 shadow-none hover:border-ring md:grid-cols-3"
|
||||
key={virtualItem.key}
|
||||
data-index={virtualItem.index}
|
||||
style={{
|
||||
@@ -193,10 +193,12 @@ const TraceCardList = ({
|
||||
>
|
||||
Trace: {trace.name} ({trace.id}) ↗
|
||||
</Link>
|
||||
<div className="text-xs text-gray-500">
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{trace.timestamp.toLocaleString()}
|
||||
</div>
|
||||
<div className="mb-1 mt-2 text-xs text-gray-500">Scores</div>
|
||||
<div className="mb-1 mt-2 text-xs text-muted-foreground">
|
||||
Scores
|
||||
</div>
|
||||
<div className="flex flex-wrap content-start items-start gap-1">
|
||||
<GroupedScoreBadges scores={trace.scores} />
|
||||
</div>
|
||||
@@ -244,7 +246,7 @@ const SessionIO = ({ traceId }: { traceId: string }) => {
|
||||
hideIfNull
|
||||
/>
|
||||
) : (
|
||||
<div className="p-2 text-xs text-gray-500">
|
||||
<div className="p-2 text-xs text-muted-foreground">
|
||||
This trace has no input or output.
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -32,7 +32,7 @@ export function StarToggle({
|
||||
<StarIcon
|
||||
className={cn(
|
||||
"h-4 w-4",
|
||||
value ? "fill-current text-yellow-500" : "text-gray-500",
|
||||
value ? "fill-current text-yellow-500" : "text-muted-foreground",
|
||||
)}
|
||||
/>
|
||||
</Button>
|
||||
|
||||
@@ -6,19 +6,19 @@ interface Statistic {
|
||||
export default function StatsCards({ stats }: { stats: Statistic[] }) {
|
||||
return (
|
||||
<div>
|
||||
<h3 className="text-base font-semibold leading-6 text-gray-900">
|
||||
<h3 className="text-base font-semibold leading-6 text-primary">
|
||||
Model configuration
|
||||
</h3>
|
||||
<dl className="mt-5 grid grid-cols-1 gap-5 sm:grid-cols-4">
|
||||
{stats.map((item) => (
|
||||
<div
|
||||
key={item.name}
|
||||
className="overflow-hidden rounded-lg bg-white px-4 py-5 shadow sm:p-6"
|
||||
className="overflow-hidden rounded-lg bg-background px-4 py-5 shadow sm:p-6"
|
||||
>
|
||||
<dt className="truncate text-sm font-medium text-gray-500">
|
||||
<dt className="truncate text-sm font-medium text-muted-foreground">
|
||||
{item.name}
|
||||
</dt>
|
||||
<dd className="mt-1 text-3xl font-semibold tracking-tight text-gray-900">
|
||||
<dd className="mt-1 text-3xl font-semibold tracking-tight text-primary">
|
||||
{item.stat}
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
@@ -83,7 +83,7 @@ export function DataTableColumnVisibilityFilter<TData, TValue>({
|
||||
>
|
||||
<Button variant="outline" title="Show/hide columns">
|
||||
<Columns className="mr-2 h-4 w-4" />
|
||||
<span className="text-xs text-gray-500">{`(${count}/${total})`}</span>
|
||||
<span className="text-xs text-muted-foreground">{`(${count}/${total})`}</span>
|
||||
<ChevronDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
@@ -55,7 +55,7 @@ export const DataTableRowHeightSwitch = ({
|
||||
<TabsTrigger
|
||||
key={id}
|
||||
value={id}
|
||||
className="px-2 shadow-none data-[state=active]:bg-slate-200 data-[state=active]:ring-border"
|
||||
className="px-2 shadow-none data-[state=active]:bg-input data-[state=active]:ring-border"
|
||||
>
|
||||
<span role="img" aria-label={`${label} size`}>
|
||||
{icon}
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableFooter,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
@@ -243,7 +242,7 @@ export function DataTable<TData extends object, TValue>({
|
||||
<div className="grow"></div>
|
||||
</div>
|
||||
{pagination !== undefined ? (
|
||||
<div className="sticky bottom-0 z-10 flex w-full justify-end bg-white font-medium">
|
||||
<div className="bg:background sticky bottom-0 z-10 flex w-full justify-end font-medium">
|
||||
<DataTablePagination
|
||||
table={table}
|
||||
paginationOptions={pagination.options}
|
||||
|
||||
@@ -19,7 +19,8 @@ export default function TableLink({
|
||||
: value;
|
||||
return (
|
||||
<Link
|
||||
className="inline-block rounded bg-indigo-50 px-2 py-1 text-xs font-semibold text-blue-600 shadow-sm hover:bg-indigo-100"
|
||||
className="inline-block rounded bg-primary-accent/20 px-2
|
||||
py-1 text-xs font-semibold text-accent-dark-blue shadow-sm hover:bg-accent-light-blue/45"
|
||||
href={path}
|
||||
title={value}
|
||||
>
|
||||
|
||||
@@ -750,7 +750,7 @@ const GenerationsIOCell = ({
|
||||
data={
|
||||
io === "output" ? observation.data?.output : observation.data?.input
|
||||
}
|
||||
className={cn(io === "output" && "bg-green-50")}
|
||||
className={cn(io === "output" && "bg-accent-light-green")}
|
||||
singleLine={singleLine}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -111,7 +111,7 @@ export default function ModelTable({ projectId }: { projectId: string }) {
|
||||
return (
|
||||
<>
|
||||
Input Price{" "}
|
||||
<span className="text-xs text-gray-400">/ 1k units</span>
|
||||
<span className="text-xs text-muted-foreground">/ 1k units</span>
|
||||
</>
|
||||
);
|
||||
},
|
||||
@@ -140,7 +140,7 @@ export default function ModelTable({ projectId }: { projectId: string }) {
|
||||
return (
|
||||
<>
|
||||
Output Price{" "}
|
||||
<span className="text-xs text-gray-400">/ 1k units</span>
|
||||
<span className="text-xs text-muted-foreground">/ 1k units</span>
|
||||
</>
|
||||
);
|
||||
},
|
||||
@@ -163,7 +163,7 @@ export default function ModelTable({ projectId }: { projectId: string }) {
|
||||
return (
|
||||
<>
|
||||
Total Price{" "}
|
||||
<span className="text-xs text-gray-400">/ 1k units</span>
|
||||
<span className="text-xs text-muted-foreground">/ 1k units</span>
|
||||
</>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -13,12 +13,14 @@ import {
|
||||
} from "@/src/server/api/definitions/scoresTable";
|
||||
import { api } from "@/src/utils/api";
|
||||
import type { RouterOutput, RouterInput } from "@/src/utils/types";
|
||||
import type { FilterState } from "@langfuse/shared";
|
||||
import { useQueryParams, withDefault, NumberParam } from "use-query-params";
|
||||
|
||||
export type ScoresTableRow = {
|
||||
id: string;
|
||||
traceId: string;
|
||||
timestamp: string;
|
||||
source: string;
|
||||
name: string;
|
||||
value: number;
|
||||
comment?: string;
|
||||
@@ -33,14 +35,38 @@ export type ScoreFilterInput = Omit<
|
||||
"projectId" | "userId"
|
||||
>;
|
||||
|
||||
function createFilterState(
|
||||
userFilterState: FilterState,
|
||||
omittedFilters: Record<string, string>[],
|
||||
): FilterState {
|
||||
return omittedFilters.reduce((filterState, { key, value }) => {
|
||||
return filterState.concat([
|
||||
{
|
||||
column: `${key}`,
|
||||
type: "string",
|
||||
operator: "=",
|
||||
value: value,
|
||||
},
|
||||
]);
|
||||
}, userFilterState);
|
||||
}
|
||||
|
||||
export default function ScoresTable({
|
||||
projectId,
|
||||
userId,
|
||||
traceId,
|
||||
observationId,
|
||||
omittedFilter = [],
|
||||
hiddenColumns = [],
|
||||
tableColumnVisibilityName = "scoresColumnVisibility",
|
||||
}: {
|
||||
projectId: string;
|
||||
userId?: string;
|
||||
traceId?: string;
|
||||
observationId?: string;
|
||||
omittedFilter?: string[];
|
||||
hiddenColumns?: string[];
|
||||
tableColumnVisibilityName?: string;
|
||||
}) {
|
||||
const [paginationState, setPaginationState] = useQueryParams({
|
||||
pageIndex: withDefault(NumberParam, 0),
|
||||
@@ -53,16 +79,12 @@ export default function ScoresTable({
|
||||
[],
|
||||
"scores",
|
||||
);
|
||||
const filterState = userId
|
||||
? userFilterState.concat([
|
||||
{
|
||||
column: "User ID",
|
||||
type: "string",
|
||||
operator: "=",
|
||||
value: userId,
|
||||
},
|
||||
])
|
||||
: userFilterState;
|
||||
|
||||
const filterState = createFilterState(userFilterState, [
|
||||
...(userId ? [{ key: "User ID", value: userId }] : []),
|
||||
...(traceId ? [{ key: "Trace ID", value: traceId }] : []),
|
||||
...(observationId ? [{ key: "Observation ID", value: observationId }] : []),
|
||||
]);
|
||||
|
||||
const [orderByState, setOrderByState] = useOrderByState({
|
||||
column: "timestamp",
|
||||
@@ -82,7 +104,7 @@ export default function ScoresTable({
|
||||
projectId,
|
||||
});
|
||||
|
||||
const columns: LangfuseColumnDef<ScoresTableRow>[] = [
|
||||
const rawColumns: LangfuseColumnDef<ScoresTableRow>[] = [
|
||||
{
|
||||
accessorKey: "traceId",
|
||||
id: "traceId",
|
||||
@@ -146,6 +168,13 @@ export default function ScoresTable({
|
||||
enableHiding: true,
|
||||
enableSorting: true,
|
||||
},
|
||||
{
|
||||
accessorKey: "source",
|
||||
header: "Source",
|
||||
id: "source",
|
||||
enableHiding: true,
|
||||
enableSorting: true,
|
||||
},
|
||||
{
|
||||
accessorKey: "name",
|
||||
header: "Name",
|
||||
@@ -218,16 +247,18 @@ export default function ScoresTable({
|
||||
cell: ({ row }) => {
|
||||
const value = row.getValue("comment") as ScoresTableRow["comment"];
|
||||
return (
|
||||
value !== undefined && (
|
||||
<IOTableCell data={value} singleLine={rowHeight === "s"} />
|
||||
)
|
||||
!!value && <IOTableCell data={value} singleLine={rowHeight === "s"} />
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const columns = rawColumns.filter(
|
||||
(c) => !!c.id && !hiddenColumns.includes(c.id),
|
||||
);
|
||||
|
||||
const [columnVisibility, setColumnVisibility] =
|
||||
useColumnVisibility<ScoresTableRow>("scoresColumnVisibility", columns);
|
||||
useColumnVisibility<ScoresTableRow>(tableColumnVisibilityName, columns);
|
||||
|
||||
const convertToTableRow = (
|
||||
score: RouterOutput["scores"]["all"]["scores"][0],
|
||||
@@ -235,6 +266,7 @@ export default function ScoresTable({
|
||||
return {
|
||||
id: score.id,
|
||||
timestamp: score.timestamp.toLocaleString(),
|
||||
source: score.source,
|
||||
name: score.name,
|
||||
value: score.value,
|
||||
comment: score.comment ?? undefined,
|
||||
@@ -250,7 +282,7 @@ export default function ScoresTable({
|
||||
traceFilterOptions: ScoreOptions | undefined,
|
||||
) => {
|
||||
return scoresTableColsWithOptions(traceFilterOptions).filter(
|
||||
(c) => !omittedFilter?.includes(c.name),
|
||||
(c) => !omittedFilter?.includes(c.name) && !hiddenColumns.includes(c.id),
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -547,6 +547,7 @@ export default function TracesTable({
|
||||
header: "Version",
|
||||
enableHiding: true,
|
||||
enableSorting: true,
|
||||
defaultHidden: true,
|
||||
},
|
||||
{
|
||||
accessorKey: "release",
|
||||
@@ -554,6 +555,7 @@ export default function TracesTable({
|
||||
header: "Release",
|
||||
enableHiding: true,
|
||||
enableSorting: true,
|
||||
defaultHidden: true,
|
||||
},
|
||||
{
|
||||
accessorKey: "tags",
|
||||
@@ -690,7 +692,7 @@ const TracesIOCell = ({
|
||||
<IOTableCell
|
||||
isLoading={trace.isLoading}
|
||||
data={io === "output" ? trace.data?.output : trace.data?.input}
|
||||
className={cn(io === "output" && "bg-green-50")}
|
||||
className={cn(io === "output" && "bg-accent-light-green")}
|
||||
singleLine={singleLine}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -112,7 +112,7 @@ export const IOPreview: React.FC<{
|
||||
title="Output"
|
||||
json={outputClean}
|
||||
isLoading={isLoading}
|
||||
className="flex-1 bg-green-50"
|
||||
className="flex-1 bg-accent-light-green dark:border-accent-dark-green"
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
@@ -187,10 +187,11 @@ export const OpenAiMessageView: React.FC<{
|
||||
title={message.name ?? message.role}
|
||||
json={message.content}
|
||||
className={cn(
|
||||
"bg-gray-100",
|
||||
message.role === "system" && "bg-gray-100",
|
||||
message.role === "assistant" && "bg-green-50",
|
||||
message.role === "user" && "bg-white",
|
||||
"bg-muted",
|
||||
message.role === "system" && "bg-primary-foreground",
|
||||
message.role === "assistant" &&
|
||||
"bg-accent-light-green dark:border-accent-dark-green",
|
||||
message.role === "user" && "bg-background",
|
||||
!!message.json && "rounded-b-none",
|
||||
)}
|
||||
/>
|
||||
@@ -202,10 +203,11 @@ export const OpenAiMessageView: React.FC<{
|
||||
}
|
||||
json={message.json}
|
||||
className={cn(
|
||||
"bg-gray-100",
|
||||
message.role === "system" && "bg-gray-100",
|
||||
message.role === "assistant" && "bg-green-50",
|
||||
message.role === "user" && "bg-white",
|
||||
"bg-muted",
|
||||
message.role === "system" && "bg-primary-foreground",
|
||||
message.role === "assistant" &&
|
||||
"bg-accent-light-green dark:border-accent-dark-green",
|
||||
message.role === "user" && "bg-foreground",
|
||||
!!message.content && "rounded-t-none border-t-0",
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { JSONView } from "@/src/components/ui/CodeJsonViewer";
|
||||
import { type Score } from "@langfuse/shared";
|
||||
import { type ScoreSource, type Score } from "@langfuse/shared";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
@@ -8,14 +8,6 @@ import {
|
||||
CardTitle,
|
||||
} from "@/src/components/ui/card";
|
||||
import { Badge } from "@/src/components/ui/badge";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/src/components/ui/table";
|
||||
import { ManualScoreButton } from "@/src/features/manual-scoring/components/ManualScoreButton";
|
||||
import { NewDatasetItemFromTrace } from "@/src/features/datasets/components/NewDatasetItemFromObservationButton";
|
||||
import { type ObservationReturnType } from "@/src/server/api/routers/traces";
|
||||
@@ -25,6 +17,11 @@ import { formatIntervalSeconds } from "@/src/utils/dates";
|
||||
import Link from "next/link";
|
||||
import { usdFormatter } from "@/src/utils/numbers";
|
||||
import { calculateDisplayTotalCost } from "@/src/components/trace";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/src/components/ui/tabs";
|
||||
import { withDefault, StringParam, useQueryParam } from "use-query-params";
|
||||
import ScoresTable from "@/src/components/table/use-cases/scores";
|
||||
import { ScoresPreview } from "@/src/components/trace/ScoresPreview";
|
||||
import { JumpToPlaygroundButton } from "@/src/ee/features/playground/page/components/JumpToPlaygroundButton";
|
||||
|
||||
export const ObservationPreview = (props: {
|
||||
observations: Array<ObservationReturnType>;
|
||||
@@ -33,6 +30,11 @@ export const ObservationPreview = (props: {
|
||||
currentObservationId: string;
|
||||
traceId: string;
|
||||
}) => {
|
||||
const [selectedTab, setSelectedTab] = useQueryParam(
|
||||
"view",
|
||||
withDefault(StringParam, "preview"),
|
||||
);
|
||||
|
||||
const observationWithInputAndOutput = api.observations.byId.useQuery({
|
||||
observationId: props.currentObservationId,
|
||||
traceId: props.traceId,
|
||||
@@ -47,152 +49,181 @@ export const ObservationPreview = (props: {
|
||||
);
|
||||
|
||||
if (!preloadedObservation) return <div className="flex-1">Not found</div>;
|
||||
return (
|
||||
<Card className="flex-1">
|
||||
<CardHeader className="flex flex-row flex-wrap justify-between gap-2">
|
||||
<div className="flex flex-col gap-1">
|
||||
<CardTitle>
|
||||
<span className="mr-2 rounded-sm bg-gray-200 p-1 text-xs">
|
||||
{preloadedObservation.type}
|
||||
</span>
|
||||
<span>{preloadedObservation.name}</span>
|
||||
</CardTitle>
|
||||
<CardDescription className="flex gap-2">
|
||||
{preloadedObservation.startTime.toLocaleString()}
|
||||
</CardDescription>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{preloadedObservation.promptId ? (
|
||||
<PromptBadge
|
||||
promptId={preloadedObservation.promptId}
|
||||
projectId={preloadedObservation.projectId}
|
||||
/>
|
||||
) : undefined}
|
||||
{preloadedObservation.timeToFirstToken ? (
|
||||
<Badge variant="outline">
|
||||
Time to first token:{" "}
|
||||
{formatIntervalSeconds(preloadedObservation.timeToFirstToken)}
|
||||
</Badge>
|
||||
) : null}
|
||||
{preloadedObservation.endTime ? (
|
||||
<Badge variant="outline">
|
||||
Latency:{" "}
|
||||
{formatIntervalSeconds(
|
||||
(preloadedObservation.endTime.getTime() -
|
||||
preloadedObservation.startTime.getTime()) /
|
||||
1000,
|
||||
)}
|
||||
</Badge>
|
||||
) : null}
|
||||
{preloadedObservation.type === "GENERATION" && (
|
||||
<Badge variant="outline">
|
||||
{preloadedObservation.promptTokens} prompt →{" "}
|
||||
{preloadedObservation.completionTokens} completion (∑{" "}
|
||||
{preloadedObservation.totalTokens})
|
||||
</Badge>
|
||||
)}
|
||||
{preloadedObservation.version ? (
|
||||
<Badge variant="outline">
|
||||
Version: {preloadedObservation.version}
|
||||
</Badge>
|
||||
) : undefined}
|
||||
{preloadedObservation.model ? (
|
||||
<Badge variant="outline">{preloadedObservation.model}</Badge>
|
||||
) : null}
|
||||
{totalCost ? (
|
||||
<Badge variant="outline">
|
||||
{usdFormatter(totalCost.toNumber())}
|
||||
</Badge>
|
||||
) : undefined}
|
||||
|
||||
{preloadedObservation.modelParameters &&
|
||||
typeof preloadedObservation.modelParameters === "object"
|
||||
? Object.entries(preloadedObservation.modelParameters)
|
||||
.filter(Boolean)
|
||||
.map(([key, value]) => (
|
||||
<Badge variant="outline" key={key}>
|
||||
{key}: {value?.toString()}
|
||||
</Badge>
|
||||
))
|
||||
: null}
|
||||
const observationScores = props.scores.filter(
|
||||
(s) => s.observationId === preloadedObservation.id,
|
||||
);
|
||||
const observationScoresBySource = observationScores.reduce((acc, score) => {
|
||||
if (!acc.get(score.source)) {
|
||||
acc.set(score.source, []);
|
||||
}
|
||||
acc.get(score.source)?.push(score);
|
||||
return acc;
|
||||
}, new Map<ScoreSource, Score[]>());
|
||||
|
||||
return (
|
||||
<Card className="col-span-2 flex max-h-full flex-col overflow-hidden">
|
||||
<div className="flex flex-shrink-0 flex-row justify-end gap-2">
|
||||
<Tabs
|
||||
value={selectedTab}
|
||||
onValueChange={setSelectedTab}
|
||||
className="flex w-full justify-end border-b bg-background"
|
||||
>
|
||||
<TabsList className="bg-background py-0">
|
||||
<TabsTrigger
|
||||
value="preview"
|
||||
className="h-full rounded-none border-b-4 border-transparent data-[state=active]:border-primary-accent data-[state=active]:shadow-none"
|
||||
>
|
||||
Preview
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="scores"
|
||||
className="h-full rounded-none border-b-4 border-transparent data-[state=active]:border-primary-accent data-[state=active]:shadow-none"
|
||||
>
|
||||
Scores
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</div>
|
||||
<div className="flex w-full flex-col overflow-y-auto">
|
||||
<CardHeader className="flex flex-row flex-wrap justify-between gap-2">
|
||||
<div className="flex flex-col gap-1">
|
||||
<CardTitle>
|
||||
<span className="mr-2 rounded-sm bg-input p-1 text-xs">
|
||||
{preloadedObservation.type}
|
||||
</span>
|
||||
<span>{preloadedObservation.name}</span>
|
||||
</CardTitle>
|
||||
<CardDescription className="flex gap-2">
|
||||
{preloadedObservation.startTime.toLocaleString()}
|
||||
</CardDescription>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{preloadedObservation.promptId ? (
|
||||
<PromptBadge
|
||||
promptId={preloadedObservation.promptId}
|
||||
projectId={preloadedObservation.projectId}
|
||||
/>
|
||||
) : undefined}
|
||||
{preloadedObservation.timeToFirstToken ? (
|
||||
<Badge variant="outline">
|
||||
Time to first token:{" "}
|
||||
{formatIntervalSeconds(preloadedObservation.timeToFirstToken)}
|
||||
</Badge>
|
||||
) : null}
|
||||
{preloadedObservation.endTime ? (
|
||||
<Badge variant="outline">
|
||||
Latency:{" "}
|
||||
{formatIntervalSeconds(
|
||||
(preloadedObservation.endTime.getTime() -
|
||||
preloadedObservation.startTime.getTime()) /
|
||||
1000,
|
||||
)}
|
||||
</Badge>
|
||||
) : null}
|
||||
{preloadedObservation.type === "GENERATION" && (
|
||||
<Badge variant="outline">
|
||||
{preloadedObservation.promptTokens} prompt →{" "}
|
||||
{preloadedObservation.completionTokens} completion (∑{" "}
|
||||
{preloadedObservation.totalTokens})
|
||||
</Badge>
|
||||
)}
|
||||
{preloadedObservation.version ? (
|
||||
<Badge variant="outline">
|
||||
Version: {preloadedObservation.version}
|
||||
</Badge>
|
||||
) : undefined}
|
||||
{preloadedObservation.model ? (
|
||||
<Badge variant="outline">{preloadedObservation.model}</Badge>
|
||||
) : null}
|
||||
{totalCost ? (
|
||||
<Badge variant="outline">
|
||||
{usdFormatter(totalCost.toNumber())}
|
||||
</Badge>
|
||||
) : undefined}
|
||||
|
||||
{preloadedObservation.modelParameters &&
|
||||
typeof preloadedObservation.modelParameters === "object"
|
||||
? Object.entries(preloadedObservation.modelParameters)
|
||||
.filter(Boolean)
|
||||
.map(([key, value]) => (
|
||||
<Badge variant="outline" key={key}>
|
||||
{key}: {value?.toString()}
|
||||
</Badge>
|
||||
))
|
||||
: null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<ManualScoreButton
|
||||
projectId={props.projectId}
|
||||
traceId={preloadedObservation.traceId}
|
||||
observationId={preloadedObservation.id}
|
||||
scores={props.scores}
|
||||
/>
|
||||
{observationWithInputAndOutput.data ? (
|
||||
<NewDatasetItemFromTrace
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<ManualScoreButton
|
||||
projectId={props.projectId}
|
||||
traceId={preloadedObservation.traceId}
|
||||
observationId={preloadedObservation.id}
|
||||
projectId={props.projectId}
|
||||
input={observationWithInputAndOutput.data.input}
|
||||
output={observationWithInputAndOutput.data.output}
|
||||
metadata={preloadedObservation.metadata}
|
||||
key={preloadedObservation.id}
|
||||
scores={props.scores}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
<IOPreview
|
||||
key={preloadedObservation.id + "-input"}
|
||||
input={observationWithInputAndOutput.data?.input ?? undefined}
|
||||
output={observationWithInputAndOutput.data?.output ?? undefined}
|
||||
isLoading={observationWithInputAndOutput.isLoading}
|
||||
/>
|
||||
{preloadedObservation.statusMessage ? (
|
||||
<JSONView
|
||||
key={preloadedObservation.id + "-status"}
|
||||
title="Status Message"
|
||||
json={preloadedObservation.statusMessage}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{observationWithInputAndOutput.data?.metadata ? (
|
||||
<JSONView
|
||||
key={observationWithInputAndOutput.data.id + "-metadata"}
|
||||
title="Metadata"
|
||||
json={observationWithInputAndOutput.data.metadata}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{props.scores.find(
|
||||
(s) => s.observationId === preloadedObservation.id,
|
||||
) ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
<h3>Scores</h3>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[100px]">Timestamp</TableHead>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead className="text-right">Value</TableHead>
|
||||
<TableHead>Comment</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{props.scores
|
||||
.filter((s) => s.observationId === preloadedObservation.id)
|
||||
.map((s) => (
|
||||
<TableRow key={s.id}>
|
||||
<TableCell className="text-xs">
|
||||
{s.timestamp.toLocaleString()}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs">{s.name}</TableCell>
|
||||
<TableCell className="text-right text-xs">
|
||||
{s.value}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs">{s.comment}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
{observationWithInputAndOutput.data?.type === "GENERATION" && (
|
||||
<JumpToPlaygroundButton
|
||||
source="generation"
|
||||
generation={observationWithInputAndOutput.data}
|
||||
analyticsEventName="trace_detail:test_in_playground_button_click"
|
||||
fullWidth
|
||||
/>
|
||||
)}
|
||||
{observationWithInputAndOutput.data ? (
|
||||
<NewDatasetItemFromTrace
|
||||
traceId={preloadedObservation.traceId}
|
||||
observationId={preloadedObservation.id}
|
||||
projectId={props.projectId}
|
||||
input={observationWithInputAndOutput.data.input}
|
||||
output={observationWithInputAndOutput.data.output}
|
||||
metadata={preloadedObservation.metadata}
|
||||
key={preloadedObservation.id}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
{selectedTab === "preview" && (
|
||||
<>
|
||||
<IOPreview
|
||||
key={preloadedObservation.id + "-input"}
|
||||
input={observationWithInputAndOutput.data?.input ?? undefined}
|
||||
output={observationWithInputAndOutput.data?.output ?? undefined}
|
||||
isLoading={observationWithInputAndOutput.isLoading}
|
||||
/>
|
||||
{preloadedObservation.statusMessage ? (
|
||||
<JSONView
|
||||
key={preloadedObservation.id + "-status"}
|
||||
title="Status Message"
|
||||
json={preloadedObservation.statusMessage}
|
||||
/>
|
||||
) : null}
|
||||
{observationWithInputAndOutput.data?.metadata ? (
|
||||
<JSONView
|
||||
key={observationWithInputAndOutput.data.id + "-metadata"}
|
||||
title="Metadata"
|
||||
json={observationWithInputAndOutput.data.metadata}
|
||||
/>
|
||||
) : null}
|
||||
<ScoresPreview itemScoresBySource={observationScoresBySource} />
|
||||
</>
|
||||
)}
|
||||
{selectedTab === "scores" && (
|
||||
<ScoresTable
|
||||
projectId={props.projectId}
|
||||
omittedFilter={["Observation ID"]}
|
||||
observationId={preloadedObservation.id}
|
||||
hiddenColumns={[
|
||||
"traceId",
|
||||
"observationId",
|
||||
"traceName",
|
||||
"jobConfigurationId",
|
||||
"userId",
|
||||
]}
|
||||
tableColumnVisibilityName="scoresColumnVisibilityObservationPreview"
|
||||
/>
|
||||
)}
|
||||
</CardContent>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -68,13 +68,13 @@ const ObservationTreeTraceNode = (props: {
|
||||
"group mb-0.5 flex cursor-pointer flex-col gap-1 rounded-sm p-1",
|
||||
props.currentObservationId === undefined ||
|
||||
props.currentObservationId === ""
|
||||
? "bg-gray-100"
|
||||
: "hover:bg-gray-50",
|
||||
? "bg-muted"
|
||||
: "hover:bg-primary-foreground",
|
||||
)}
|
||||
onClick={() => props.setCurrentObservationId(undefined)}
|
||||
>
|
||||
<div className="flex gap-2">
|
||||
<span className={cn("rounded-sm bg-gray-200 p-1 text-xs")}>TRACE</span>
|
||||
<span className={cn("rounded-sm bg-input p-1 text-xs")}>TRACE</span>
|
||||
<span className="flex-1 break-all text-sm">{props.trace.name}</span>
|
||||
<Button
|
||||
onClick={(ev) => (ev.stopPropagation(), props.expandAll())}
|
||||
@@ -96,7 +96,7 @@ const ObservationTreeTraceNode = (props: {
|
||||
|
||||
{props.showMetrics && props.trace.latency ? (
|
||||
<div className="flex gap-2">
|
||||
<span className="text-xs text-gray-500">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatIntervalSeconds(props.trace.latency)}
|
||||
</span>
|
||||
</div>
|
||||
@@ -141,8 +141,8 @@ const ObservationTreeNode = (props: {
|
||||
className={cn(
|
||||
"group my-0.5 flex flex-1 cursor-pointer flex-col gap-1 rounded-sm p-1",
|
||||
props.currentObservationId === observation.id
|
||||
? "bg-gray-100"
|
||||
: "hover:bg-gray-50",
|
||||
? "bg-muted"
|
||||
: "hover:bg-primary-foreground",
|
||||
)}
|
||||
onClick={() => props.setCurrentObservationId(observation.id)}
|
||||
>
|
||||
@@ -188,7 +188,7 @@ const ObservationTreeNode = (props: {
|
||||
observation.endTime) && (
|
||||
<div className="flex gap-2">
|
||||
{observation.endTime ? (
|
||||
<span className="text-xs text-gray-500">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatIntervalSeconds(
|
||||
(observation.endTime.getTime() -
|
||||
observation.startTime.getTime()) /
|
||||
@@ -199,7 +199,7 @@ const ObservationTreeNode = (props: {
|
||||
{observation.promptTokens ||
|
||||
observation.completionTokens ||
|
||||
observation.totalTokens ? (
|
||||
<span className="text-xs text-gray-500">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{observation.promptTokens} →{" "}
|
||||
{observation.completionTokens} (∑{" "}
|
||||
{observation.totalTokens})
|
||||
@@ -258,9 +258,9 @@ const ColorCodedObservationType = (props: {
|
||||
observationType: $Enums.ObservationType;
|
||||
}) => {
|
||||
const colors: Record<$Enums.ObservationType, string> = {
|
||||
[$Enums.ObservationType.SPAN]: "bg-blue-100",
|
||||
[$Enums.ObservationType.GENERATION]: "bg-orange-100",
|
||||
[$Enums.ObservationType.EVENT]: "bg-green-100",
|
||||
[$Enums.ObservationType.SPAN]: "bg-muted-blue",
|
||||
[$Enums.ObservationType.GENERATION]: "bg-muted-orange",
|
||||
[$Enums.ObservationType.EVENT]: "bg-muted-green",
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { GroupedScoreBadges } from "@/src/components/grouped-score-badge";
|
||||
import { type Score } from "@langfuse/shared";
|
||||
|
||||
export const ScoresPreview = ({
|
||||
itemScoresBySource,
|
||||
}: {
|
||||
itemScoresBySource: Map<string, Score[]>;
|
||||
}) => {
|
||||
if (!Boolean(itemScoresBySource.size)) return null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col rounded-md border">
|
||||
<span className="border-b px-3 py-1 text-xs font-medium">Scores</span>
|
||||
<div
|
||||
key={itemScoresBySource.size}
|
||||
className="grid grid-flow-row gap-2 overflow-x-auto px-3 pb-3 pt-1"
|
||||
>
|
||||
{Array.from(itemScoresBySource).map(([source, scores]) => (
|
||||
<div key={source} className="flex flex-col align-middle text-xs">
|
||||
<span className="min-w-16 p-1 font-medium">{source}</span>
|
||||
<div className="flex flex-col content-start items-start gap-1 text-nowrap">
|
||||
<GroupedScoreBadges scores={scores} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,5 +1,5 @@
|
||||
import { JSONView } from "@/src/components/ui/CodeJsonViewer";
|
||||
import { type Trace, type Score } from "@langfuse/shared";
|
||||
import { type Trace, type Score, type ScoreSource } from "@langfuse/shared";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
@@ -8,14 +8,6 @@ import {
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/src/components/ui/card";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/src/components/ui/table";
|
||||
import { TraceAggUsageBadge } from "@/src/components/token-usage-badge";
|
||||
import { ManualScoreButton } from "@/src/features/manual-scoring/components/ManualScoreButton";
|
||||
import { Badge } from "@/src/components/ui/badge";
|
||||
@@ -23,6 +15,10 @@ import { type ObservationReturnType } from "@/src/server/api/routers/traces";
|
||||
import { IOPreview } from "@/src/components/trace/IOPreview";
|
||||
import { formatIntervalSeconds } from "@/src/utils/dates";
|
||||
import { NewDatasetItemFromTrace } from "@/src/features/datasets/components/NewDatasetItemFromObservationButton";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/src/components/ui/tabs";
|
||||
import { withDefault, StringParam, useQueryParam } from "use-query-params";
|
||||
import ScoresTable from "@/src/components/table/use-cases/scores";
|
||||
import { ScoresPreview } from "@/src/components/trace/ScoresPreview";
|
||||
|
||||
export const TracePreview = ({
|
||||
trace,
|
||||
@@ -33,92 +29,115 @@ export const TracePreview = ({
|
||||
observations: ObservationReturnType[];
|
||||
scores: Score[];
|
||||
}) => {
|
||||
const [selectedTab, setSelectedTab] = useQueryParam(
|
||||
"view",
|
||||
withDefault(StringParam, "preview"),
|
||||
);
|
||||
|
||||
const traceScores = scores.filter((s) => s.observationId === null);
|
||||
const traceScoresBySource = traceScores.reduce((acc, score) => {
|
||||
if (!acc.get(score.source)) {
|
||||
acc.set(score.source, []);
|
||||
}
|
||||
acc.get(score.source)?.push(score);
|
||||
return acc;
|
||||
}, new Map<ScoreSource, Score[]>());
|
||||
|
||||
return (
|
||||
<Card className="flex-1">
|
||||
<CardHeader className="flex flex-row flex-wrap justify-between gap-2">
|
||||
<div className="flex flex-col gap-1">
|
||||
<CardTitle>
|
||||
<span className="mr-2 rounded-sm bg-gray-200 p-1 text-xs">
|
||||
TRACE
|
||||
</span>
|
||||
<span>{trace.name}</span>
|
||||
</CardTitle>
|
||||
<CardDescription>{trace.timestamp.toLocaleString()}</CardDescription>
|
||||
<Card className="col-span-2 flex max-h-full flex-col overflow-hidden">
|
||||
<div className="flex flex-shrink-0 flex-row justify-end gap-2">
|
||||
<Tabs
|
||||
value={selectedTab}
|
||||
onValueChange={setSelectedTab}
|
||||
className="flex w-full justify-end border-b bg-background"
|
||||
>
|
||||
<TabsList className="bg-background py-0">
|
||||
<TabsTrigger
|
||||
value="preview"
|
||||
className="data-[state=active]:border-primary-accent h-full rounded-none border-b-4 border-transparent data-[state=active]:shadow-none"
|
||||
>
|
||||
Preview
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="scores"
|
||||
className="data-[state=active]:border-primary-accent h-full rounded-none border-b-4 border-transparent data-[state=active]:shadow-none"
|
||||
>
|
||||
Scores
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</div>
|
||||
<div className="flex w-full flex-col overflow-y-auto">
|
||||
<CardHeader className="flex flex-row flex-wrap justify-between gap-2">
|
||||
<div className="flex flex-col gap-1">
|
||||
<CardTitle>
|
||||
<span className="mr-2 rounded-sm bg-input p-1 text-xs">
|
||||
TRACE
|
||||
</span>
|
||||
<span>{trace.name}</span>
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{trace.timestamp.toLocaleString()}
|
||||
</CardDescription>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{!!trace.latency && (
|
||||
<Badge variant="outline">
|
||||
{formatIntervalSeconds(trace.latency)}
|
||||
</Badge>
|
||||
)}
|
||||
<TraceAggUsageBadge observations={observations} />
|
||||
{!!trace.release && (
|
||||
<Badge variant="outline">Release: {trace.release}</Badge>
|
||||
)}
|
||||
{!!trace.version && (
|
||||
<Badge variant="outline">Version: {trace.version}</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{!!trace.latency && (
|
||||
<Badge variant="outline">
|
||||
{formatIntervalSeconds(trace.latency)}
|
||||
</Badge>
|
||||
)}
|
||||
<TraceAggUsageBadge observations={observations} />
|
||||
{!!trace.release && (
|
||||
<Badge variant="outline">Release: {trace.release}</Badge>
|
||||
)}
|
||||
{!!trace.version && (
|
||||
<Badge variant="outline">Version: {trace.version}</Badge>
|
||||
)}
|
||||
<ManualScoreButton
|
||||
projectId={trace.projectId}
|
||||
traceId={trace.id}
|
||||
scores={scores}
|
||||
/>
|
||||
<NewDatasetItemFromTrace
|
||||
traceId={trace.id}
|
||||
projectId={trace.projectId}
|
||||
input={trace.input}
|
||||
output={trace.output}
|
||||
metadata={trace.metadata}
|
||||
key={trace.id}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<ManualScoreButton
|
||||
projectId={trace.projectId}
|
||||
traceId={trace.id}
|
||||
scores={scores}
|
||||
/>
|
||||
<NewDatasetItemFromTrace
|
||||
traceId={trace.id}
|
||||
projectId={trace.projectId}
|
||||
input={trace.input}
|
||||
output={trace.output}
|
||||
metadata={trace.metadata}
|
||||
key={trace.id}
|
||||
/>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
<IOPreview
|
||||
key={trace.id + "-io"}
|
||||
input={trace.input ?? undefined}
|
||||
output={trace.output ?? undefined}
|
||||
/>
|
||||
<JSONView
|
||||
key={trace.id + "-metadata"}
|
||||
title="Metadata"
|
||||
json={trace.metadata}
|
||||
/>
|
||||
{scores.find((s) => s.observationId === null) ? (
|
||||
<div className="mt-5 flex flex-col gap-2">
|
||||
<h3>Scores</h3>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[100px]">Timestamp</TableHead>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead className="text-right">Value</TableHead>
|
||||
<TableHead>Comment</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{scores
|
||||
.filter((s) => s.observationId === null)
|
||||
.map((s) => (
|
||||
<TableRow key={s.id}>
|
||||
<TableCell className="text-xs">
|
||||
{s.timestamp.toLocaleString()}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs">{s.name}</TableCell>
|
||||
<TableCell className="text-right text-xs">
|
||||
{s.value}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs">{s.comment}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
) : null}
|
||||
</CardContent>
|
||||
<CardFooter></CardFooter>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
{selectedTab === "preview" && (
|
||||
<>
|
||||
<IOPreview
|
||||
key={trace.id + "-io"}
|
||||
input={trace.input ?? undefined}
|
||||
output={trace.output ?? undefined}
|
||||
/>
|
||||
<JSONView
|
||||
key={trace.id + "-metadata"}
|
||||
title="Metadata"
|
||||
json={trace.metadata}
|
||||
/>
|
||||
<ScoresPreview itemScoresBySource={traceScoresBySource} />
|
||||
</>
|
||||
)}
|
||||
{selectedTab === "scores" && (
|
||||
<ScoresTable
|
||||
projectId={trace.projectId}
|
||||
omittedFilter={["Trace ID"]}
|
||||
traceId={trace.id}
|
||||
hiddenColumns={["traceName", "jobConfigurationId", "userId"]}
|
||||
tableColumnVisibilityName="scoresColumnVisibilityTracePreview"
|
||||
/>
|
||||
)}
|
||||
</CardContent>
|
||||
<CardFooter></CardFooter>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -3,8 +3,10 @@ import { Button } from "@/src/components/ui/button";
|
||||
import { Check, ChevronsDownUp, ChevronsUpDown, Copy } from "lucide-react";
|
||||
import { cn } from "@/src/utils/tailwind";
|
||||
import { default as React18JsonView } from "react18-json-view";
|
||||
import "react18-json-view/src/dark.css";
|
||||
import { deepParseJson } from "@/src/utils/json";
|
||||
import { Skeleton } from "@/src/components/ui/skeleton";
|
||||
import { useTheme } from "next-themes";
|
||||
|
||||
export function JSONView(props: {
|
||||
json?: unknown;
|
||||
@@ -15,11 +17,18 @@ export function JSONView(props: {
|
||||
}) {
|
||||
// some users ingest stringified json nested in json, parse it
|
||||
const parsedJson = deepParseJson(props.json);
|
||||
|
||||
const { theme } = useTheme();
|
||||
return (
|
||||
<div className={cn("rounded-md border", props.className)}>
|
||||
{props.title ? (
|
||||
<div className="border-b px-3 py-1 text-xs font-medium">
|
||||
<div
|
||||
className={cn(
|
||||
props.title === "assistant" || props.title === "Output"
|
||||
? "dark:border-accent-dark-green"
|
||||
: "",
|
||||
"border-b px-3 py-1 text-xs font-medium",
|
||||
)}
|
||||
>
|
||||
{props.title}
|
||||
</div>
|
||||
) : undefined}
|
||||
@@ -35,6 +44,7 @@ export function JSONView(props: {
|
||||
<React18JsonView
|
||||
src={parsedJson}
|
||||
theme="github"
|
||||
dark={theme === "dark"}
|
||||
collapseObjectsAfterLength={20}
|
||||
collapseStringsAfterLength={500}
|
||||
displaySize={"collapsed"}
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
// https://tailwindui.com/components/application-ui/data-display/description-lists
|
||||
|
||||
import clsx from "clsx";
|
||||
import { type ReactNode } from "react";
|
||||
|
||||
export default function DescriptionList(props: {
|
||||
header?: {
|
||||
title: string;
|
||||
description: string;
|
||||
};
|
||||
items: { label: string; value: string | ReactNode }[];
|
||||
descriptionColumns?: number;
|
||||
valueColumns?: number;
|
||||
}) {
|
||||
const { descriptionColumns = 1, valueColumns = 2 } = props;
|
||||
const totalColumns = descriptionColumns + valueColumns;
|
||||
return (
|
||||
<div>
|
||||
{props.header ? (
|
||||
<div className="px-4 sm:px-0">
|
||||
<h3 className="text-base font-semibold leading-7 text-gray-900">
|
||||
{props.header.title}
|
||||
</h3>
|
||||
<p className="mt-1 max-w-2xl text-sm leading-6 text-gray-500">
|
||||
{props.header.description}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
<div className={clsx(props.header && "mt-6 border-t border-gray-100")}>
|
||||
<dl className="divide-y divide-gray-100">
|
||||
{props.items.map((item) => (
|
||||
<div
|
||||
key={item.label}
|
||||
className={`sm:grid-cols-${totalColumns} px-4 py-3 sm:grid sm:gap-4 sm:px-0`}
|
||||
>
|
||||
<dt className="text-sm font-medium leading-6 text-gray-900">
|
||||
{item.label}
|
||||
</dt>
|
||||
<dd
|
||||
className={`mt-1 text-sm leading-6 text-gray-700 sm:col-span-${valueColumns} sm:mt-0`}
|
||||
>
|
||||
{item.value}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -21,7 +21,7 @@ const DialogOverlay = React.forwardRef<
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
"fixed inset-0 z-50 bg-foreground/40 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -11,7 +11,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus:ring-0 focus-visible:ring-offset-0 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
|
||||
@@ -27,9 +27,9 @@ const PasswordInput = React.forwardRef<HTMLInputElement, PasswordInputProps>(
|
||||
title={showPassword ? "Hide password" : "Show password"}
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeOff className="h-5 w-5 text-gray-400" />
|
||||
<EyeOff className="h-5 w-5 text-muted-foreground" />
|
||||
) : (
|
||||
<Eye className="h-5 w-5 text-gray-400" />
|
||||
<Eye className="h-5 w-5 text-muted-foreground" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -11,7 +11,7 @@ const Switch = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SwitchPrimitives.Root
|
||||
className={cn(
|
||||
"peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
|
||||
"peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input dark:data-[state=unchecked]:bg-muted-foreground",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -74,7 +74,7 @@ const TableHead = React.forwardRef<
|
||||
<th
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"sticky top-0 z-10 border-b bg-white px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0",
|
||||
"sticky top-0 z-10 border-b bg-background px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -1 +1 @@
|
||||
export const VERSION = "v2.44.0";
|
||||
export const VERSION = "v2.46.0";
|
||||
|
||||
@@ -318,7 +318,7 @@ export const InnerEvalConfigForm = (props: {
|
||||
<JSONView
|
||||
title={"Eval Template"}
|
||||
json={props.evalTemplate.prompt ?? null}
|
||||
className={"min-h-48 bg-gray-100 lg:w-1/2"}
|
||||
className={"min-h-48 bg-muted lg:w-1/2"}
|
||||
/>
|
||||
<div className=" flex flex-col gap-2 lg:w-1/3">
|
||||
{fields.map((mappingField, index) => (
|
||||
|
||||
@@ -33,8 +33,6 @@ import {
|
||||
type ModelParams,
|
||||
} from "@langfuse/shared";
|
||||
import { PromptDescription } from "@/src/features/prompts/components/prompt-description";
|
||||
import Link from "next/dist/client/link";
|
||||
import { ArrowTopRightIcon } from "@radix-ui/react-icons";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -43,8 +41,6 @@ import {
|
||||
SelectValue,
|
||||
} from "@/src/components/ui/select";
|
||||
import { TEMPLATES } from "@/src/ee/features/evals/components/templates";
|
||||
import { Label } from "@/src/components/ui/label";
|
||||
import { useHasAccess } from "@/src/features/rbac/utils/checkAccess";
|
||||
import { usePostHogClientCapture } from "@/src/features/posthog-analytics/usePostHogClientCapture";
|
||||
import { getFinalModelParams } from "@/src/ee/utils/getFinalModelParams";
|
||||
|
||||
@@ -73,9 +69,9 @@ export const EvalTemplateForm = (props: {
|
||||
value={langfuseTemplate ?? ""}
|
||||
onValueChange={updateLangfuseTemplate}
|
||||
>
|
||||
<SelectTrigger className="text-gray-700 ring-transparent focus:ring-0 focus:ring-offset-0">
|
||||
<SelectTrigger className="text-primary ring-transparent focus:ring-0 focus:ring-offset-0">
|
||||
<SelectValue
|
||||
className="text-sm font-semibold text-gray-700"
|
||||
className="text-sm font-semibold text-primary"
|
||||
placeholder={"Select a Langfuse managed template"}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
@@ -439,10 +435,6 @@ export const InnerEvalTemplateForm = (props: {
|
||||
availableModels={[...evalLLMModels]}
|
||||
formDisabled={!props.isEditing}
|
||||
/>
|
||||
<LLMApiKeyComponent
|
||||
projectId={props.projectId}
|
||||
modelParams={modelParams}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -465,78 +457,6 @@ export const InnerEvalTemplateForm = (props: {
|
||||
);
|
||||
};
|
||||
|
||||
export const LLMApiKeyComponent = (p: {
|
||||
projectId: string;
|
||||
modelParams: UIModelParams;
|
||||
}) => {
|
||||
const hasAccess = useHasAccess({
|
||||
projectId: p.projectId,
|
||||
scope: "llmApiKeys:read",
|
||||
});
|
||||
|
||||
if (!hasAccess) {
|
||||
return (
|
||||
<div>
|
||||
<Label>API key</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
LLM API Key only visible to Owner and Admin roles.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const apiKeys = api.llmApiKey.all.useQuery({
|
||||
projectId: p.projectId,
|
||||
});
|
||||
|
||||
if (apiKeys.isLoading) {
|
||||
return (
|
||||
<div>
|
||||
<Label>API key</Label>
|
||||
<p className="text-sm text-muted-foreground">Loading...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const getModelProvider = (model: string) => {
|
||||
return evalLLMModels.find((m) => m.model.value === model)?.provider.value;
|
||||
};
|
||||
|
||||
const getApiKeyForModel = (model: string) => {
|
||||
const modelProvider = getModelProvider(model);
|
||||
return apiKeys.data?.data.find((k) => k.provider === modelProvider);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Label>API key</Label>
|
||||
<div>
|
||||
{getApiKeyForModel(p.modelParams.model.value) ? (
|
||||
<span className="mr-2 rounded-sm bg-gray-200 p-1 text-xs">
|
||||
{getApiKeyForModel(p.modelParams.model.value)?.displaySecretKey}
|
||||
</span>
|
||||
) : undefined}
|
||||
</div>
|
||||
{/* Custom form message to include a link to the already existing prompt */}
|
||||
{!getApiKeyForModel(p.modelParams.model.value) ? (
|
||||
<div className="flex flex-col text-sm font-medium text-destructive">
|
||||
{"No LLM API key found."}
|
||||
|
||||
<Link
|
||||
href={`/project/${p.projectId}/settings`}
|
||||
className="flex flex-row"
|
||||
>
|
||||
Create a new API key here. <ArrowTopRightIcon />
|
||||
</Link>
|
||||
</div>
|
||||
) : undefined}
|
||||
<p className="text-sm text-muted-foreground">
|
||||
The API key is used for each evaluation and will incur costs.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
function getModelParamsWithEnabledFlag(
|
||||
evalPreFill?: EvalTemplateFormPreFill,
|
||||
): UIModelParams {
|
||||
|
||||
@@ -67,7 +67,7 @@ export const GenerationOutput = () => {
|
||||
return (
|
||||
<div className="relative h-full overflow-auto">
|
||||
<div
|
||||
className="h-full overflow-auto rounded-lg bg-gray-100 p-4"
|
||||
className="h-full overflow-auto rounded-lg bg-muted p-4"
|
||||
ref={scrollAreaRef}
|
||||
>
|
||||
<div className="mb-4 flex w-full items-center">
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
import { Terminal } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
|
||||
import { Button } from "@/src/components/ui/button";
|
||||
import usePlaygroundCache from "@/src/ee/features/playground/page/hooks/usePlaygroundCache";
|
||||
import { type PlaygroundCache } from "@/src/ee/features/playground/page/types";
|
||||
import { usePostHogClientCapture } from "@/src/features/posthog-analytics/usePostHogClientCapture";
|
||||
import useProjectIdFromURL from "@/src/hooks/useProjectIdFromURL";
|
||||
import {
|
||||
ChatMessageRole,
|
||||
supportedModels as playgroundSupportedModels,
|
||||
type Observation,
|
||||
type Prompt,
|
||||
type UIModelParams,
|
||||
ZodModelConfig,
|
||||
} from "@langfuse/shared";
|
||||
import { useEffect, useState } from "react";
|
||||
import { PromptType } from "@/src/features/prompts/server/utils/validation";
|
||||
import { ChatMessageListSchema } from "@/src/features/prompts/components/NewPromptForm/validation";
|
||||
import { createEmptyMessage } from "@/src/components/ChatMessages/utils/createEmptyMessage";
|
||||
import { getIsCloudEnvironment } from "@/src/ee/utils/getIsCloudEnvironment";
|
||||
|
||||
type JumpToPlaygroundButtonProps = (
|
||||
| {
|
||||
source: "prompt";
|
||||
prompt: Prompt;
|
||||
analyticsEventName: "prompt_detail:test_in_playground_button_click";
|
||||
}
|
||||
| {
|
||||
source: "generation";
|
||||
generation: Observation;
|
||||
analyticsEventName: "trace_detail:test_in_playground_button_click";
|
||||
}
|
||||
) & {
|
||||
fullWidth?: boolean;
|
||||
};
|
||||
|
||||
export const JumpToPlaygroundButton: React.FC<JumpToPlaygroundButtonProps> = (
|
||||
props,
|
||||
) => {
|
||||
const capture = usePostHogClientCapture();
|
||||
const projectId = useProjectIdFromURL();
|
||||
const { setPlaygroundCache } = usePlaygroundCache();
|
||||
const [capturedState, setCapturedState] = useState<PlaygroundCache>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (props.source === "prompt") {
|
||||
setCapturedState(parsePrompt(props.prompt));
|
||||
} else if (props.source === "generation") {
|
||||
setCapturedState(parseGeneration(props.generation));
|
||||
}
|
||||
}, [props]);
|
||||
|
||||
const handleClick = () => {
|
||||
capture(props.analyticsEventName);
|
||||
setPlaygroundCache(capturedState);
|
||||
};
|
||||
|
||||
if (!getIsCloudEnvironment()) return null;
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant={props.fullWidth ? "secondary" : "outline"}
|
||||
title="Test in LLM playground"
|
||||
size={!props.fullWidth ? "icon" : undefined}
|
||||
onClick={handleClick}
|
||||
asChild
|
||||
>
|
||||
<Link href={`/project/${projectId}/playground`}>
|
||||
<Terminal className="h-5 w-5" />
|
||||
{props.fullWidth ? (
|
||||
<span className="ml-2">Test in playground</span>
|
||||
) : null}
|
||||
</Link>
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
const parsePrompt = (prompt: Prompt): PlaygroundCache => {
|
||||
if (prompt.type === PromptType.Chat) {
|
||||
const parsedMessages = ChatMessageListSchema.safeParse(prompt.prompt);
|
||||
|
||||
return parsedMessages.success ? { messages: parsedMessages.data } : null;
|
||||
} else {
|
||||
const promptString = prompt.prompt?.valueOf();
|
||||
|
||||
return {
|
||||
messages: [
|
||||
createEmptyMessage(
|
||||
ChatMessageRole.System,
|
||||
typeof promptString === "string" ? promptString : "",
|
||||
),
|
||||
],
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const parseGeneration = (generation: Observation): PlaygroundCache => {
|
||||
if (generation.type !== "GENERATION") return null;
|
||||
|
||||
const modelParams = parseModelParams(generation);
|
||||
const input = generation.input?.valueOf();
|
||||
|
||||
if (typeof input === "string") {
|
||||
return {
|
||||
messages: [createEmptyMessage(ChatMessageRole.System, input)],
|
||||
modelParams,
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof input === "object") {
|
||||
const parsedMessages = ChatMessageListSchema.safeParse(input);
|
||||
|
||||
if (parsedMessages.success)
|
||||
return { messages: parsedMessages.data, modelParams };
|
||||
}
|
||||
|
||||
if (typeof input === "object" && "messages" in input) {
|
||||
const parsedMessages = ChatMessageListSchema.safeParse(input["messages"]);
|
||||
|
||||
if (parsedMessages.success)
|
||||
return { messages: parsedMessages.data, modelParams };
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
function parseModelParams(
|
||||
generation: Observation,
|
||||
):
|
||||
| (Partial<UIModelParams> & Pick<UIModelParams, "provider" | "model">)
|
||||
| undefined {
|
||||
const generationModel = generation.model?.valueOf();
|
||||
let modelParams:
|
||||
| (Partial<UIModelParams> & Pick<UIModelParams, "provider" | "model">)
|
||||
| undefined = undefined;
|
||||
|
||||
if (generationModel) {
|
||||
const provider = Object.entries(playgroundSupportedModels).find(
|
||||
([_, models]) =>
|
||||
generationModel ? models.some((m) => m === generationModel) : false,
|
||||
)?.[0];
|
||||
|
||||
if (!provider) return;
|
||||
|
||||
modelParams = {
|
||||
provider: { value: provider, enabled: true },
|
||||
model: { value: generationModel, enabled: true },
|
||||
} as Partial<UIModelParams> & Pick<UIModelParams, "provider" | "model">;
|
||||
|
||||
const generationModelParams = generation.modelParameters?.valueOf();
|
||||
|
||||
if (generationModelParams && typeof generationModelParams === "object") {
|
||||
const parsedParams = ZodModelConfig.safeParse(generationModelParams);
|
||||
|
||||
if (parsedParams.success) {
|
||||
Object.entries(parsedParams.data).forEach(([key, value]) => {
|
||||
if (!modelParams) return;
|
||||
|
||||
modelParams[key as keyof typeof parsedParams.data] = {
|
||||
value,
|
||||
enabled: true,
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return modelParams;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { ListRestartIcon } from "lucide-react";
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
import { Button } from "@/src/components/ui/button";
|
||||
import usePlaygroundCache from "@/src/ee/features/playground/page/hooks/usePlaygroundCache";
|
||||
import { getIsCloudEnvironment } from "@/src/ee/utils/getIsCloudEnvironment";
|
||||
|
||||
export const ResetPlaygroundButton: React.FC = () => {
|
||||
const router = useRouter();
|
||||
const { setPlaygroundCache } = usePlaygroundCache();
|
||||
|
||||
const handleClick = () => {
|
||||
setPlaygroundCache(null);
|
||||
|
||||
router.reload();
|
||||
};
|
||||
|
||||
return getIsCloudEnvironment() ? (
|
||||
<Button
|
||||
variant={"outline"}
|
||||
title="Reset playground state"
|
||||
onClick={handleClick}
|
||||
>
|
||||
<ListRestartIcon className="mr-1 h-5 w-5" />
|
||||
<span>Reset playground</span>
|
||||
</Button>
|
||||
) : null;
|
||||
};
|
||||
@@ -0,0 +1,161 @@
|
||||
import { Check, FileInput } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import { useState } from "react";
|
||||
|
||||
import { Button } from "@/src/components/ui/button";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "@/src/components/ui/command";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/src/components/ui/popover";
|
||||
import { usePlaygroundContext } from "@/src/ee/features/playground/page/context";
|
||||
import usePlaygroundCache from "@/src/ee/features/playground/page/hooks/usePlaygroundCache";
|
||||
import { getIsCloudEnvironment } from "@/src/ee/utils/getIsCloudEnvironment";
|
||||
import { usePostHogClientCapture } from "@/src/features/posthog-analytics/usePostHogClientCapture";
|
||||
import { PromptType } from "@/src/features/prompts/server/utils/validation";
|
||||
import useProjectIdFromURL from "@/src/hooks/useProjectIdFromURL";
|
||||
import { api } from "@/src/utils/api";
|
||||
import { cn } from "@/src/utils/tailwind";
|
||||
|
||||
export const SaveToPromptButton: React.FC = () => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [selectedPromptId, setSelectedPromptId] = useState("");
|
||||
const { modelParams, messages, output, promptVariables } =
|
||||
usePlaygroundContext();
|
||||
const capture = usePostHogClientCapture();
|
||||
const router = useRouter();
|
||||
const projectId = useProjectIdFromURL();
|
||||
const { setPlaygroundCache } = usePlaygroundCache();
|
||||
|
||||
const allPromptNames =
|
||||
api.prompts.all
|
||||
.useQuery(
|
||||
{
|
||||
projectId: projectId as string, // Typecast as query is enabled only when projectId is present
|
||||
filter: [],
|
||||
orderBy: { column: "name", order: "ASC" },
|
||||
page: 0,
|
||||
},
|
||||
{ enabled: Boolean(projectId) },
|
||||
)
|
||||
.data?.prompts.filter((prompt) => prompt.type === PromptType.Chat)
|
||||
.map((prompt) => ({
|
||||
label:
|
||||
prompt.name.slice(0, 20) + (prompt.name.length > 25 ? "..." : ""),
|
||||
value: prompt.id,
|
||||
})) ?? [];
|
||||
|
||||
const handleNewPrompt = async () => {
|
||||
capture("playground:save_to_new_prompt_button_click", { projectId });
|
||||
|
||||
setPlaygroundCache({
|
||||
modelParams,
|
||||
messages,
|
||||
output,
|
||||
promptVariables,
|
||||
});
|
||||
|
||||
await router.push(
|
||||
`/project/${projectId}/prompts/new?loadPlaygroundCache=true`,
|
||||
);
|
||||
};
|
||||
|
||||
const handleNewPromptVersion = async () => {
|
||||
capture("playground:save_to_prompt_version_button_click", { projectId });
|
||||
|
||||
setPlaygroundCache({
|
||||
modelParams,
|
||||
messages,
|
||||
output,
|
||||
promptVariables,
|
||||
});
|
||||
|
||||
await router.push(
|
||||
`/project/${projectId}/prompts/new?promptId=${selectedPromptId}&loadPlaygroundCache=true`,
|
||||
);
|
||||
};
|
||||
|
||||
if (!getIsCloudEnvironment()) return null;
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant={"outline"} title="Save to prompt" asChild>
|
||||
<Link href={`/project/${projectId}/playground`}>
|
||||
<FileInput className="mr-1 h-5 w-5" />
|
||||
<span>Save as prompt</span>
|
||||
</Link>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent>
|
||||
<Button className="mt-2 w-full" onClick={handleNewPrompt}>
|
||||
Save as new prompt
|
||||
</Button>
|
||||
<Divider />
|
||||
<Command className="min-h-[8rem]">
|
||||
<CommandInput placeholder="Search chat prompts..." />
|
||||
<CommandEmpty>No chat prompt found.</CommandEmpty>
|
||||
<CommandGroup className="mt-2">
|
||||
<CommandList>
|
||||
{allPromptNames.map((promptName) => (
|
||||
<CommandItem
|
||||
key={promptName.value}
|
||||
title={promptName.label}
|
||||
value={promptName.value}
|
||||
onSelect={(currentValue) => {
|
||||
setSelectedPromptId(
|
||||
currentValue === selectedPromptId ? "" : currentValue,
|
||||
);
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
selectedPromptId === promptName.value
|
||||
? "opacity-100"
|
||||
: "opacity-0",
|
||||
)}
|
||||
/>
|
||||
{promptName.label}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandList>
|
||||
</CommandGroup>
|
||||
</Command>
|
||||
<Button
|
||||
className="mt-2 w-full"
|
||||
disabled={!Boolean(selectedPromptId)}
|
||||
onClick={handleNewPromptVersion}
|
||||
>
|
||||
Save as new prompt version
|
||||
</Button>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
|
||||
export function Divider() {
|
||||
return (
|
||||
<div className="my-6 flex flex-row justify-center align-middle">
|
||||
<div className="flex flex-1 flex-col">
|
||||
<div className="flex-1 border-b-2 border-gray-200" />
|
||||
<div className="flex-1" />
|
||||
</div>
|
||||
<p className="mx-2 text-sm text-gray-400">or</p>
|
||||
<div className="flex flex-1 flex-col">
|
||||
<div className="flex-1 border-b-2 border-gray-200" />
|
||||
<div className="flex-1" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -31,7 +31,7 @@ export const Variables = () => {
|
||||
key={promptVariable.name}
|
||||
/>
|
||||
{index !== promptVariables.length - 1 ? (
|
||||
<Divider className="my-2 text-gray-400" />
|
||||
<Divider className="my-2 text-muted-foreground" />
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -7,17 +7,14 @@ import React, {
|
||||
useState,
|
||||
} from "react";
|
||||
|
||||
import { StringParam, useQueryParam } from "use-query-params";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
import { createEmptyMessage } from "@/src/components/ChatMessages/utils/createEmptyMessage";
|
||||
import useCommandEnter from "@/src/ee/features/playground/page/hooks/useCommandEnter";
|
||||
import usePlaygroundCache from "@/src/ee/features/playground/page/hooks/usePlaygroundCache";
|
||||
import { getFinalModelParams } from "@/src/ee/utils/getFinalModelParams";
|
||||
import { usePostHogClientCapture } from "@/src/features/posthog-analytics/usePostHogClientCapture";
|
||||
import { ChatMessageListSchema } from "@/src/features/prompts/components/NewPromptForm/validation";
|
||||
import { PromptType } from "@/src/features/prompts/server/utils/validation";
|
||||
import useProjectIdFromURL from "@/src/hooks/useProjectIdFromURL";
|
||||
import { api } from "@/src/utils/api";
|
||||
import { extractVariables } from "@/src/utils/string";
|
||||
import {
|
||||
ChatMessageRole,
|
||||
@@ -40,7 +37,6 @@ type PlaygroundContextType = {
|
||||
|
||||
handleSubmit: () => Promise<void>;
|
||||
isStreaming: boolean;
|
||||
isInitializing: boolean;
|
||||
} & ModelParamsContext &
|
||||
MessagesContext;
|
||||
|
||||
@@ -61,9 +57,9 @@ export const usePlaygroundContext = () => {
|
||||
export const PlaygroundProvider: React.FC<PropsWithChildren> = ({
|
||||
children,
|
||||
}) => {
|
||||
const projectId = useProjectIdFromURL();
|
||||
const capture = usePostHogClientCapture();
|
||||
const [initialPromptId] = useQueryParam("promptId", StringParam);
|
||||
const projectId = useProjectIdFromURL();
|
||||
const { playgroundCache, setPlaygroundCache } = usePlaygroundCache();
|
||||
const [promptVariables, setPromptVariables] = useState<PromptVariable[]>([]);
|
||||
const [output, setOutput] = useState("");
|
||||
const [outputJson, setOutputJson] = useState("");
|
||||
@@ -76,35 +72,32 @@ export const PlaygroundProvider: React.FC<PropsWithChildren> = ({
|
||||
getDefaultModelParams(ModelProvider.OpenAI),
|
||||
);
|
||||
|
||||
const { data: initialPrompt, isInitialLoading } = api.prompts.byId.useQuery(
|
||||
{
|
||||
projectId: projectId as string, // Typecast as query is enabled only when projectId is present
|
||||
id: initialPromptId ?? "",
|
||||
},
|
||||
{ enabled: Boolean(initialPromptId && projectId), staleTime: Infinity }, // do not refetch as this would overwrite the user's input
|
||||
);
|
||||
|
||||
// Load state from cache
|
||||
useEffect(() => {
|
||||
if (!initialPrompt) return;
|
||||
if (initialPrompt.type === PromptType.Chat) {
|
||||
try {
|
||||
const initialMessages = ChatMessageListSchema.parse(
|
||||
initialPrompt.prompt,
|
||||
);
|
||||
setMessages(initialMessages.map((m) => ({ ...m, id: uuidv4() })));
|
||||
} catch (err) {
|
||||
console.warn("Failed to parse initial chat messages", err);
|
||||
}
|
||||
} else {
|
||||
const promptString = initialPrompt.prompt?.valueOf();
|
||||
setMessages([
|
||||
createEmptyMessage(
|
||||
ChatMessageRole.System,
|
||||
typeof promptString === "string" ? promptString : "",
|
||||
),
|
||||
]);
|
||||
if (!playgroundCache) return;
|
||||
|
||||
const {
|
||||
messages: cachedMessages,
|
||||
modelParams: cachedModelParams,
|
||||
output: cachedOutput,
|
||||
promptVariables: cachedPromptVariables,
|
||||
} = playgroundCache;
|
||||
|
||||
setMessages(cachedMessages.map((m) => ({ ...m, id: uuidv4() })));
|
||||
|
||||
if (cachedOutput) {
|
||||
setOutput(cachedOutput);
|
||||
setOutputJson("");
|
||||
}
|
||||
}, [initialPrompt]);
|
||||
|
||||
if (cachedModelParams) {
|
||||
setModelParams((prev) => ({ ...prev, ...cachedModelParams }));
|
||||
}
|
||||
|
||||
if (cachedPromptVariables) {
|
||||
setPromptVariables(cachedPromptVariables);
|
||||
}
|
||||
}, [playgroundCache]);
|
||||
|
||||
useEffect(() => {
|
||||
setModelParams(getDefaultModelParams(modelParams.provider.value));
|
||||
@@ -181,6 +174,7 @@ export const PlaygroundProvider: React.FC<PropsWithChildren> = ({
|
||||
}
|
||||
|
||||
const completionStream = getChatCompletionStream(
|
||||
projectId,
|
||||
finalMessages,
|
||||
modelParams,
|
||||
);
|
||||
@@ -191,6 +185,12 @@ export const PlaygroundProvider: React.FC<PropsWithChildren> = ({
|
||||
setOutput(response);
|
||||
}
|
||||
setOutputJson(getOutputJson(response, finalMessages, modelParams));
|
||||
setPlaygroundCache({
|
||||
messages,
|
||||
modelParams,
|
||||
output: response,
|
||||
promptVariables,
|
||||
});
|
||||
capture("playground:execute_button_click", {
|
||||
inputLength: finalMessages.length,
|
||||
modelName: modelParams.model,
|
||||
@@ -258,7 +258,6 @@ export const PlaygroundProvider: React.FC<PropsWithChildren> = ({
|
||||
outputJson,
|
||||
handleSubmit,
|
||||
isStreaming,
|
||||
isInitializing: isInitialLoading,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
@@ -267,10 +266,17 @@ export const PlaygroundProvider: React.FC<PropsWithChildren> = ({
|
||||
};
|
||||
|
||||
async function* getChatCompletionStream(
|
||||
projectId: string | undefined,
|
||||
messages: ChatMessageWithId[],
|
||||
modelParams: UIModelParams,
|
||||
) {
|
||||
if (!projectId) {
|
||||
console.error("Project ID is not set");
|
||||
return;
|
||||
}
|
||||
|
||||
const body = JSON.stringify({
|
||||
projectId,
|
||||
messages,
|
||||
modelParams: getFinalModelParams(modelParams),
|
||||
});
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { getIsCloudEnvironment } from "@/src/ee/utils/getIsCloudEnvironment";
|
||||
import { type PlaygroundCache } from "../types";
|
||||
|
||||
const playgroundCacheKey = "playgroundCache";
|
||||
|
||||
export default function usePlaygroundCache() {
|
||||
const [cache, setCache] = useState<PlaygroundCache>(null);
|
||||
|
||||
const setPlaygroundCache = (cache: PlaygroundCache) => {
|
||||
sessionStorage.setItem(playgroundCacheKey, JSON.stringify(cache));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const savedCache = sessionStorage.getItem(playgroundCacheKey);
|
||||
if (savedCache) {
|
||||
try {
|
||||
setCache(JSON.parse(savedCache));
|
||||
} catch (e) {
|
||||
console.error("Failed to parse playground cache", e);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
return {
|
||||
playgroundCache: getIsCloudEnvironment() ? cache : null,
|
||||
setPlaygroundCache: getIsCloudEnvironment() ? setPlaygroundCache : () => {},
|
||||
};
|
||||
}
|
||||
@@ -1,24 +1,32 @@
|
||||
import Header from "@/src/components/layouts/header";
|
||||
import Playground from "@/src/ee/features/playground/page/playground";
|
||||
import { ResetPlaygroundButton } from "@/src/ee/features/playground/page/components/ResetPlaygroundButton";
|
||||
import { SaveToPromptButton } from "@/src/ee/features/playground/page/components/SaveToPromptButton";
|
||||
import { PlaygroundProvider } from "@/src/ee/features/playground/page/context";
|
||||
import Playground from "@/src/ee/features/playground/page/playground";
|
||||
import { getIsCloudEnvironment } from "@/src/ee/utils/getIsCloudEnvironment";
|
||||
|
||||
export default function PlaygroundPage() {
|
||||
return getIsCloudEnvironment() ? (
|
||||
<div className="flex h-[95vh] flex-col">
|
||||
<Header
|
||||
title="Playground"
|
||||
help={{
|
||||
description: "A sandbox to test and iterate your prompts",
|
||||
href: "https://langfuse.com/docs/playground",
|
||||
}}
|
||||
featureBetaURL="https://github.com/orgs/langfuse/discussions/1170"
|
||||
/>
|
||||
<div className="flex-1 overflow-auto">
|
||||
<PlaygroundProvider>
|
||||
<PlaygroundProvider>
|
||||
<div className="flex h-[95vh] flex-col">
|
||||
<Header
|
||||
title="Playground"
|
||||
help={{
|
||||
description: "A sandbox to test and iterate your prompts",
|
||||
href: "https://langfuse.com/docs/playground",
|
||||
}}
|
||||
featureBetaURL="https://github.com/orgs/langfuse/discussions/1170"
|
||||
actionButtons={
|
||||
<>
|
||||
<SaveToPromptButton />
|
||||
<ResetPlaygroundButton />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<div className="flex-1 overflow-auto">
|
||||
<Playground />
|
||||
</PlaygroundProvider>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PlaygroundProvider>
|
||||
) : null;
|
||||
}
|
||||
|
||||
@@ -6,10 +6,6 @@ import { Messages } from "./components/Messages";
|
||||
export default function Playground() {
|
||||
const playgroundContext = usePlaygroundContext();
|
||||
|
||||
if (playgroundContext.isInitializing) {
|
||||
return <div>Loading...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-row space-x-8">
|
||||
<div className="h-full basis-3/4 overflow-auto">
|
||||
@@ -20,7 +16,7 @@ export default function Playground() {
|
||||
<div className="basis-[55%] ">
|
||||
<ModelParameters {...playgroundContext} />
|
||||
</div>
|
||||
<div className="basis-[45%] overflow-auto">
|
||||
<div className="mt-4 basis-[45%] overflow-auto">
|
||||
<Variables />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import {
|
||||
type PromptVariable,
|
||||
type ChatMessage,
|
||||
type UIModelParams,
|
||||
} from "@langfuse/shared";
|
||||
|
||||
export type PlaygroundCache = {
|
||||
messages: ChatMessage[];
|
||||
modelParams?: Partial<UIModelParams> &
|
||||
Pick<UIModelParams, "provider" | "model">;
|
||||
output?: string | null;
|
||||
promptVariables?: PromptVariable[];
|
||||
} | null;
|
||||
@@ -0,0 +1,26 @@
|
||||
import { getServerSession } from "next-auth";
|
||||
|
||||
import { getIsCloudEnvironment } from "@/src/ee/utils/getIsCloudEnvironment";
|
||||
import { getAuthOptions } from "@/src/server/auth";
|
||||
import { isProjectMemberOrAdmin } from "@/src/server/utils/checkProjectMembershipOrAdmin";
|
||||
import { ApiError, ForbiddenError, UnauthorizedError } from "@langfuse/shared";
|
||||
|
||||
export type AuthorizeRequestResult = {
|
||||
userId: string;
|
||||
};
|
||||
|
||||
export const authorizeRequestOrThrow = async (
|
||||
projectId: string,
|
||||
): Promise<AuthorizeRequestResult> => {
|
||||
if (!getIsCloudEnvironment())
|
||||
throw new ApiError("This endpoint is available in Langfuse cloud only.");
|
||||
|
||||
const authOptions = await getAuthOptions();
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user) throw new UnauthorizedError("Unauthenticated");
|
||||
|
||||
if (!isProjectMemberOrAdmin(session.user, projectId))
|
||||
throw new ForbiddenError("User is not a member of this project");
|
||||
|
||||
return { userId: session.user.id };
|
||||
};
|
||||
@@ -1,75 +1,60 @@
|
||||
import { StreamingTextResponse } from "ai";
|
||||
import { getToken } from "next-auth/jwt";
|
||||
import { type NextRequest, NextResponse } from "next/server";
|
||||
import { NextResponse, type NextRequest } from "next/server";
|
||||
|
||||
import { fetchLLMCompletion } from "@langfuse/shared";
|
||||
import {
|
||||
BaseError,
|
||||
ValidationError,
|
||||
fetchLLMCompletion,
|
||||
} from "@langfuse/shared";
|
||||
|
||||
import { PosthogCallbackHandler } from "./analytics/posthogCallback";
|
||||
import {
|
||||
validateChatCompletionBody,
|
||||
type ValidatedChatCompletionBody,
|
||||
} from "./validateChatCompletionBody";
|
||||
import { getCookieName } from "@/src/server/utils/cookies";
|
||||
import { env } from "@/src/env.mjs";
|
||||
import { getIsCloudEnvironment } from "@/src/ee/utils/getIsCloudEnvironment";
|
||||
import { authorizeRequestOrThrow } from "./authorizeRequest";
|
||||
import { validateChatCompletionBody } from "./validateChatCompletionBody";
|
||||
|
||||
import { prisma } from "@langfuse/shared/src/db";
|
||||
import { decrypt } from "@langfuse/shared/encryption";
|
||||
|
||||
export default async function chatCompletionHandler(req: NextRequest) {
|
||||
if (!getIsCloudEnvironment()) {
|
||||
return NextResponse.json(
|
||||
{ message: "This endpoint is available in Langfuse cloud only." },
|
||||
{ status: 501 },
|
||||
);
|
||||
}
|
||||
|
||||
const token = await getToken({
|
||||
req,
|
||||
cookieName: getCookieName("next-auth.session-token"),
|
||||
secret: env.NEXTAUTH_SECRET,
|
||||
});
|
||||
|
||||
if (!token || !token.sub)
|
||||
// sub is the user id
|
||||
return NextResponse.json({ message: "Unauthenticated" }, { status: 401 });
|
||||
|
||||
if (req.method !== "POST")
|
||||
return NextResponse.json(
|
||||
{ message: "Method not allowed" },
|
||||
{ status: 405 },
|
||||
);
|
||||
|
||||
let body: ValidatedChatCompletionBody;
|
||||
|
||||
try {
|
||||
body = validateChatCompletionBody(await req.json());
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
const body = validateChatCompletionBody(await req.json());
|
||||
const { userId } = await authorizeRequestOrThrow(body.projectId);
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: "Invalid request body",
|
||||
error: err,
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const { messages, modelParams } = body;
|
||||
|
||||
const LLMApiKey = await prisma.llmApiKeys.findFirst({
|
||||
where: {
|
||||
projectId: body.projectId,
|
||||
provider: modelParams.provider,
|
||||
},
|
||||
});
|
||||
|
||||
if (!LLMApiKey)
|
||||
throw new ValidationError(
|
||||
`No ${modelParams.provider} API key found in project. Please add one in the project settings.`,
|
||||
);
|
||||
|
||||
const stream = await fetchLLMCompletion({
|
||||
messages,
|
||||
modelParams,
|
||||
streaming: true,
|
||||
callbacks: [new PosthogCallbackHandler("playground", body, token.sub)],
|
||||
apiKey:
|
||||
modelParams.provider === "openai"
|
||||
? env.OPENAI_API_KEY
|
||||
: env.ANTHROPIC_API_KEY,
|
||||
callbacks: [new PosthogCallbackHandler("playground", body, userId)],
|
||||
apiKey: decrypt(LLMApiKey.secretKey),
|
||||
});
|
||||
|
||||
return new StreamingTextResponse(stream);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
if (err instanceof BaseError) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: err.name,
|
||||
message: err.message,
|
||||
},
|
||||
{ status: err.httpCode },
|
||||
);
|
||||
}
|
||||
|
||||
if (err instanceof Error) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
|
||||
@@ -32,6 +32,7 @@ const MessageSchema = z.object({
|
||||
id: z.string().optional(),
|
||||
});
|
||||
export const ChatCompletionBodySchema = z.object({
|
||||
projectId: z.string(),
|
||||
messages: z.array(MessageSchema),
|
||||
modelParams: ModelParamsSchema,
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { env } from "@/src/env.mjs";
|
||||
|
||||
export const getIsCloudEnvironment = () =>
|
||||
Boolean(env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION)
|
||||
export const getIsCloudEnvironment = () =>
|
||||
Boolean(env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION);
|
||||
|
||||
@@ -67,6 +67,10 @@ export const env = createEnv({
|
||||
AUTH_AUTH0_CLIENT_SECRET: z.string().optional(),
|
||||
AUTH_AUTH0_ISSUER: z.string().url().optional(),
|
||||
AUTH_AUTH0_ALLOW_ACCOUNT_LINKING: z.enum(["true", "false"]).optional(),
|
||||
AUTH_COGNITO_CLIENT_ID: z.string().optional(),
|
||||
AUTH_COGNITO_CLIENT_SECRET: z.string().optional(),
|
||||
AUTH_COGNITO_ISSUER: z.string().url().optional(),
|
||||
AUTH_COGNITO_ALLOW_ACCOUNT_LINKING: z.enum(["true", "false"]).optional(),
|
||||
AUTH_DOMAINS_WITH_SSO_ENFORCEMENT: z.string().optional(),
|
||||
AUTH_DISABLE_USERNAME_PASSWORD: z.enum(["true", "false"]).optional(),
|
||||
AUTH_DISABLE_SIGNUP: z.enum(["true", "false"]).optional(),
|
||||
@@ -167,6 +171,10 @@ export const env = createEnv({
|
||||
AUTH_AUTH0_ISSUER: process.env.AUTH_AUTH0_ISSUER,
|
||||
AUTH_AUTH0_ALLOW_ACCOUNT_LINKING:
|
||||
process.env.AUTH_AUTH0_ALLOW_ACCOUNT_LINKING,
|
||||
AUTH_COGNITO_CLIENT_ID: process.env.AUTH_COGNITO_CLIENT_ID,
|
||||
AUTH_COGNITO_CLIENT_SECRET: process.env.AUTH_COGNITO_CLIENT_SECRET,
|
||||
AUTH_COGNITO_ISSUER: process.env.AUTH_COGNITO_ISSUER,
|
||||
AUTH_COGNITO_ALLOW_ACCOUNT_LINKING: process.env.AUTH_COGNITO_ALLOW_ACCOUNT_LINKING,
|
||||
AUTH_DOMAINS_WITH_SSO_ENFORCEMENT:
|
||||
process.env.AUTH_DOMAINS_WITH_SSO_ENFORCEMENT,
|
||||
AUTH_DISABLE_USERNAME_PASSWORD: process.env.AUTH_DISABLE_USERNAME_PASSWORD,
|
||||
|
||||
@@ -2,7 +2,7 @@ import { env } from "@/src/env.mjs";
|
||||
|
||||
export const CloudPrivacyNotice = ({ action }: { action: string }) =>
|
||||
env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION !== undefined ? (
|
||||
<div className="mx-auto mt-10 max-w-lg text-center text-xs text-gray-500">
|
||||
<div className="mx-auto mt-10 max-w-lg text-center text-xs text-muted-foreground">
|
||||
By {action} you are agreeing to our{" "}
|
||||
<a
|
||||
href="https://langfuse.com/terms"
|
||||
|
||||
@@ -67,7 +67,7 @@ export function CloudRegionSwitch({
|
||||
<DataRegionInfo />
|
||||
</span>
|
||||
{isSignUpPage && env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION === "US" ? (
|
||||
<p className="text-xs text-gray-500">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Demo project is only available in the EU region.
|
||||
</p>
|
||||
) : null}
|
||||
@@ -113,7 +113,7 @@ const DataRegionInfo = () => (
|
||||
<DialogTrigger asChild>
|
||||
<a
|
||||
href="#"
|
||||
className="ml-1 text-xs text-indigo-600 hover:text-indigo-500"
|
||||
className="hover:text-hover-primary-accent ml-1 text-xs text-primary-accent"
|
||||
title="What is this?"
|
||||
>
|
||||
(what is this?)
|
||||
@@ -144,7 +144,7 @@ const DataRegionInfo = () => (
|
||||
href="https://langfuse.com/docs/data-security-privacy"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-500 underline"
|
||||
className="text-primary-accent underline"
|
||||
>
|
||||
langfuse.com/security
|
||||
</a>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { type DateTimeAggregationOption } from "@/src/features/dashboard/lib/timeseries-aggregation";
|
||||
import { getColorsForCategories } from "@/src/features/dashboard/utils/getColorsForCategories";
|
||||
import { compactNumberFormatter } from "@/src/utils/numbers";
|
||||
import { cn } from "@/src/utils/tailwind";
|
||||
import { AreaChart, LineChart } from "@tremor/react";
|
||||
@@ -57,6 +58,8 @@ export function BaseTimeSeriesChart(props: {
|
||||
};
|
||||
|
||||
const ChartComponent = props.chartType === "area" ? AreaChart : LineChart;
|
||||
const colors = getColorsForCategories(Array.from(labels));
|
||||
|
||||
return (
|
||||
<ChartComponent
|
||||
className={cn("mt-4", props.className)}
|
||||
@@ -64,10 +67,8 @@ export function BaseTimeSeriesChart(props: {
|
||||
index="timestamp"
|
||||
categories={Array.from(labels)}
|
||||
connectNulls={props.connectNulls}
|
||||
colors={["indigo", "cyan", "zinc", "purple"]}
|
||||
valueFormatter={
|
||||
props.valueFormatter ? props.valueFormatter : compactNumberFormatter
|
||||
}
|
||||
colors={colors}
|
||||
valueFormatter={props.valueFormatter ?? compactNumberFormatter}
|
||||
noDataText="No data"
|
||||
showLegend={props.showLegend}
|
||||
showAnimation={true}
|
||||
|
||||
@@ -17,7 +17,7 @@ export const NoData = ({
|
||||
justifyContent="center"
|
||||
className={cn(
|
||||
className,
|
||||
"min-h-[9rem] w-full flex-1 rounded-tremor-default border border-dashed border-tremor-border",
|
||||
"min-h-[9rem] w-full flex-1 rounded-tremor-default border border-dashed",
|
||||
)}
|
||||
>
|
||||
<Text className="text-tremor-content">{noDataText}</Text>
|
||||
|
||||
@@ -21,7 +21,7 @@ export const TabComponent = ({ tabs }: TabComponentProps) => {
|
||||
<select
|
||||
id="tabs"
|
||||
name="tabs"
|
||||
className="block w-full rounded-md border-gray-300 py-2 pl-3 pr-10 text-base focus:border-indigo-500 focus:outline-none focus:ring-indigo-500 sm:text-sm"
|
||||
className="focus:border-primary-accent focus:ring-primary-accent block w-full rounded-md border-border py-2 pl-3 pr-10 text-base focus:outline-none sm:text-sm"
|
||||
defaultValue={0}
|
||||
onChange={(e) => setSelectedIndex(Number(e.target.selectedIndex))}
|
||||
>
|
||||
@@ -31,7 +31,7 @@ export const TabComponent = ({ tabs }: TabComponentProps) => {
|
||||
</select>
|
||||
</div>
|
||||
<div className="hidden sm:block">
|
||||
<div className="border-b border-gray-200">
|
||||
<div className="border-b border-border">
|
||||
<nav
|
||||
className="-mb-px flex space-x-2 md:space-x-4 lg:space-x-6 xl:space-x-8"
|
||||
aria-label="Tabs"
|
||||
@@ -41,8 +41,8 @@ export const TabComponent = ({ tabs }: TabComponentProps) => {
|
||||
key={tab.tabTitle}
|
||||
className={cn(
|
||||
index === selectedIndex
|
||||
? "border-indigo-500 text-indigo-600"
|
||||
: "border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700",
|
||||
? "border-primary-accent text-primary-accent"
|
||||
: "border-transparent text-muted-foreground hover:border-border hover:text-primary",
|
||||
"cursor-pointer whitespace-nowrap border-b-2 px-1 py-3 text-sm font-medium",
|
||||
)}
|
||||
aria-current={index === selectedIndex ? "page" : undefined}
|
||||
|
||||
@@ -32,14 +32,14 @@ export const DashboardTable = ({
|
||||
<div className="mt-4">
|
||||
<div className="overflow-x-auto">
|
||||
<div className="inline-block min-w-full align-middle">
|
||||
<table className="min-w-full divide-y divide-gray-300 animate-in animate-out">
|
||||
<table className="min-w-full divide-y divide-border animate-in animate-out">
|
||||
<thead>
|
||||
<tr>
|
||||
{headers.map((header, i) => (
|
||||
<th
|
||||
key={i}
|
||||
scope="col"
|
||||
className="whitespace-nowrap py-3.5 pl-4 pr-3 text-left text-xs font-semibold text-gray-900 sm:pl-0"
|
||||
className="whitespace-nowrap py-3.5 pl-4 pr-3 text-left text-xs font-semibold text-primary sm:pl-0"
|
||||
>
|
||||
{header}
|
||||
</th>
|
||||
@@ -47,7 +47,7 @@ export const DashboardTable = ({
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody className="divide-y divide-gray-200 bg-white">
|
||||
<tbody className="divide-y divide-accent bg-background">
|
||||
{rows
|
||||
.slice(
|
||||
0,
|
||||
@@ -62,7 +62,7 @@ export const DashboardTable = ({
|
||||
{row.map((cell, j) => (
|
||||
<td
|
||||
key={j}
|
||||
className="whitespace-nowrap py-2 pl-3 pr-2 text-xs text-gray-500 sm:pl-0"
|
||||
className="whitespace-nowrap py-2 pl-3 pr-2 text-xs text-muted-foreground sm:pl-0"
|
||||
>
|
||||
{cell}
|
||||
</td>
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
type Color =
|
||||
| "indigo"
|
||||
| "cyan"
|
||||
| "zinc"
|
||||
| "purple"
|
||||
| "slate"
|
||||
| "gray"
|
||||
| "neutral"
|
||||
| "stone"
|
||||
| "red"
|
||||
| "orange"
|
||||
| "amber"
|
||||
| "yellow"
|
||||
| "lime"
|
||||
| "green"
|
||||
| "emerald"
|
||||
| "teal"
|
||||
| "sky"
|
||||
| "blue"
|
||||
| "violet"
|
||||
| "fuchsia"
|
||||
| "pink"
|
||||
| "rose";
|
||||
|
||||
const predefinedColors: Color[] = [
|
||||
"indigo",
|
||||
"cyan",
|
||||
"zinc",
|
||||
"purple",
|
||||
"yellow",
|
||||
"red",
|
||||
"lime",
|
||||
"pink",
|
||||
"emerald",
|
||||
"teal",
|
||||
"fuchsia",
|
||||
"sky",
|
||||
"blue",
|
||||
"orange",
|
||||
"violet",
|
||||
"rose",
|
||||
"green",
|
||||
"amber",
|
||||
"slate",
|
||||
"gray",
|
||||
"neutral",
|
||||
"stone",
|
||||
];
|
||||
|
||||
function getRandomColor() {
|
||||
return predefinedColors[Math.floor(Math.random() * predefinedColors.length)];
|
||||
}
|
||||
|
||||
export function getColorsForCategories(categories: string[]): Color[] {
|
||||
if (categories.length <= predefinedColors.length) {
|
||||
return predefinedColors.slice(0, categories.length);
|
||||
}
|
||||
const colors: Color[] = predefinedColors;
|
||||
while (colors.length < categories.length) {
|
||||
colors.push(getRandomColor());
|
||||
}
|
||||
return colors;
|
||||
}
|
||||
@@ -72,7 +72,7 @@ export const DatasetActionButton = (props: DatasetActionButtonProps) => {
|
||||
</Button>
|
||||
) : (
|
||||
<div
|
||||
className="relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors hover:bg-accent hover:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50"
|
||||
className="relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors hover:bg-accent data-[disabled]:pointer-events-none data-[disabled]:opacity-50"
|
||||
onClick={() => {
|
||||
setOpen(true);
|
||||
capture("datasets:update_form_open", {
|
||||
@@ -90,7 +90,7 @@ export const DatasetActionButton = (props: DatasetActionButtonProps) => {
|
||||
)
|
||||
) : props.mode === "delete" ? (
|
||||
<div
|
||||
className="relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors hover:bg-accent hover:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50"
|
||||
className="relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors hover:bg-accent data-[disabled]:pointer-events-none data-[disabled]:opacity-50"
|
||||
onClick={() => {
|
||||
setOpen(true);
|
||||
capture("datasets:delete_form_open", {
|
||||
|
||||
@@ -134,8 +134,8 @@ export function DatasetItemsTable({
|
||||
className={cn(
|
||||
"h-2 w-2 rounded-full",
|
||||
status === DatasetStatus.ACTIVE
|
||||
? "bg-green-600"
|
||||
: "bg-yellow-600",
|
||||
? "bg-dark-green"
|
||||
: "bg-dark-yellow",
|
||||
)}
|
||||
/>
|
||||
<span>{status}</span>
|
||||
@@ -173,7 +173,7 @@ export function DatasetItemsTable({
|
||||
return !!expectedOutput ? (
|
||||
<IOTableCell
|
||||
data={expectedOutput}
|
||||
className="bg-green-50"
|
||||
className="bg-accent-light-green"
|
||||
singleLine={rowHeight === "s"}
|
||||
/>
|
||||
) : null;
|
||||
|
||||
@@ -316,7 +316,7 @@ const TraceObservationIOCell = ({
|
||||
<IOTableCell
|
||||
isLoading={!!!observationId ? trace.isLoading : observation.isLoading}
|
||||
data={io === "output" ? data?.output : data?.input}
|
||||
className={cn(io === "output" && "bg-green-50")}
|
||||
className={cn(io === "output" && "bg-accent-light-green")}
|
||||
singleLine={singleLine}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -11,7 +11,7 @@ const langfuseUrls = {
|
||||
STAGING: "https://staging.langfuse.com",
|
||||
};
|
||||
|
||||
const authUrl =
|
||||
const getAuthURL = () =>
|
||||
env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION === "US" ||
|
||||
env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION === "EU" ||
|
||||
env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION === "STAGING"
|
||||
@@ -40,7 +40,7 @@ export const sendProjectInvitation = async (
|
||||
invitedByUserEmail: inviterEmail,
|
||||
projectName: projectName,
|
||||
recieverEmail: to,
|
||||
inviteLink: authUrl,
|
||||
inviteLink: getAuthURL(),
|
||||
langfuseCloudRegion: env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION,
|
||||
}),
|
||||
);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user