Compare commits

...
45 Commits
Author SHA1 Message Date
Marlies Mayerhofer a1a48d5e7b chore: release v3.87.0 2025-07-29 00:15:18 +02:00
NimarandGitHub fcb7563763 chore: upgrade turbo to 2.5.5 (#8171) 2025-07-28 21:00:45 +00:00
Thorsten SpiekerandGitHub ae754b146d fix: scroll area for large lists of variables (#8170) 2025-07-28 21:13:56 +02:00
marliessophieandGitHub 49343b9a0c feat(annotation-queues): add support for batch adding sessions to annotation queues (#8169)
* feat(annotation-queues): add support for batch adding sessions to annotation queues

* chore: eslint types
2025-07-28 18:44:06 +00:00
Steffen SchmitzandGitHub 76bf5c0f4e fix: add queue prefix to webhook queue (#8165) 2025-07-28 16:12:48 +00:00
marliessophieandGitHub e91a29be61 fix(members-table): update table column visibility and order logic based on project context (#8164)
refactor(members-table): update column visibility and order logic based on project context
2025-07-28 14:38:14 +00:00
marliessophieandGitHub 81694363a2 fix(metrics): convert latency from milliseconds to seconds for accurate display (#8162) 2025-07-28 12:38:43 +00:00
marliessophieandGitHub 5998266680 feat(dataset-run-items): implement deletion for CH writes (#8074)
* feat(dataset): implement dataset run items deletion queue and processing

* feat(dataset): add dataset run items deletion functionality and integrate with deletion queue

* chore: revert imports

* chore: handle DRI deletion on project deletion conditionally

* fix(dataset-router): await Promise.all for dataset run items deletion

* refactor(dataset-router): streamline dataset deletion process and ensure async handling of dataset run items

* fix(env): add new environment variable for dataset run items deletion concurrency duration

* chore: rm comment

* refactor(dataset-run-items): update delete functions to use object destructuring for parameters

* fix(dataset-run-items): replace hardcoded request timeout with environment variable for deletion timeout

* refactor(dataset): rename and restructure dataset deletion functionality, replacing dataset run items deletion with a unified dataset deletion queue
2025-07-28 11:36:05 +00:00
Nimar 462e8e847d chore: release v3.86.1 2025-07-28 12:53:59 +02:00
NimarandGitHub d7c186858b fix(observations): if value is 0 show it instead of null (#8158) 2025-07-28 10:22:03 +00:00
NimarandGitHub e686aedac9 fix(trace-table): smart expand json table if not many rows (#8142)
* fix(trace-table): smart expand json table if not many rows

* clean up comments

* better performance

* cleanup

* fix lint

* clean
2025-07-28 09:58:02 +00:00
Max DeichmannandGitHub 85f75a5e8e chore: improve webhook retries (#8098)
* chore: improve webhook retries

* chore: improve webhook retries

* chore: improve webhook retries

* chore: improve webhook retries

* fixes

* push

* fixes

* fixes
2025-07-26 13:26:15 +00:00
Max DeichmannandGitHub 1ea643300d chore: remove ingestion via bullmq (#8143) 2025-07-26 00:13:53 +02:00
596486c1ec chore: ingest secondary ingestion queue data (#8137)
* push

* add duration tracking

* push

---------

Co-authored-by: Hassieb Pakzad <68423100+hassiebp@users.noreply.github.com>
2025-07-25 17:13:03 +00:00
Hassieb PakzadandGitHub cb65391c2d fix(model-selector): correct combinations provider model (#8135)
* fix(model-selector): correct combinations provider model

* push

* push

* push

* push

* push
2025-07-25 16:29:06 +00:00
marliessophieandGitHub 419e07260d fix(evaluator): update button label based on form mode (#8134) 2025-07-25 14:06:59 +00:00
marliessophieandGitHub 825f030e81 feat(annotation): support annotation on sessions (#8130)
* feat(session): integrate CreateNewAnnotationQueueItem component into session page

* refactor(annotation-queues): enhance QueueItemRowData structure to support multiple object types

* chore: refactor to extract common functionality

* fixup: refactor to extract common functionality

* chore: handle session annotation type in annotation processing UI

* feat(annotation-queues): add object type column and enhance source display in AnnotationQueueItemsTable

* chore: add session annotation ui

* feat(annotation-queues): add tooltip for detailed view toggle and enhance session item handling

* chore: small improvement

* feat(annotation-queues): implement intersection observer for trace visibility and update pagination logic

* fix(annotation-queues): update current item type retrieval and improve session item handling logic

* chore: eslint

* chore: nits
2025-07-25 13:32:54 +00:00
Max DeichmannandGitHub 34ded9c927 chore: adjust sampling metric (#8132) 2025-07-25 11:53:15 +02:00
Hassieb PakzadandGitHub c935d4ae73 chore: add sampling to ingestion pipeline (#8128) 2025-07-25 11:26:30 +02:00
Hassieb PakzadandGitHub e7283ac06e fix(ingestion): cached model not found (#8122) 2025-07-25 06:51:18 +00:00
Max DeichmannandGitHub 20d0106626 chore: fix date parsing for eval retries (#8119) 2025-07-25 02:00:48 +02:00
Max DeichmannandGitHub 849591fdf2 chore: improve evals logging (#8118) 2025-07-25 01:34:52 +02:00
Max DeichmannandGitHub 0c7b50e564 chore: fix eval event envelope timestmp (#8117) 2025-07-25 01:10:40 +02:00
Max DeichmannandGitHub 37b4f43351 chore: increase observability and delay of eval executions (#8116) 2025-07-25 00:08:54 +02:00
marliessophieandGitHub 7a3b0379e5 fix(experiments): update loading condition in CreateExperimentsForm to check for datasetId (#8115) 2025-07-24 21:11:42 +00:00
marliessophieandGitHub f458d7626c chore: add sessions type for annotation queue (#8107) 2025-07-24 17:11:47 +00:00
Max DeichmannandGitHub 258dde4691 fix: fix automation updates (#8108)
* fix: fix automation updates

* fix: fix automation updates

* fix: fix automation updates
2025-07-24 16:35:33 +00:00
marliessophieandGitHub f15246d9df feat(experiments): support remote experiment trigger (#8094)
* fixup(experiments): support trigger for remote experiment run

* chore: remove instructions

* chore: rename webhook > remote experiment server side

* chore: rename webhook > remote experiment client side

* style: design review

* chore: lint

* fix(experiments): change URL validation to use z.url() in RemoteExperimentUpsertForm

* chore: rename

* feat(datasets): add transformation function for datasets and update API responses
2025-07-24 16:12:59 +00:00
NimarandGitHub edb43a24ab fix(trace): show linebreaks in only text markdown view (#8106) 2025-07-24 16:08:00 +00:00
Steffen SchmitzandGitHub c45ae1b140 chore: adjust getSessionsTable query for AMTs (#8102)
* chore: adjust getSessionsTable query for AMTs

* chore: add additional index on traces_all_amt
2025-07-24 14:56:43 +00:00
Ted KimandGitHub 90eeeeaf9d fix: remove 'ON CLUSTER' instruction from unclustered migration (#7959) 2025-07-24 16:49:00 +02:00
Nimar 66accb563c chore: release v3.86.0 2025-07-24 16:30:43 +02:00
NimarandGitHub 9f2e8906d0 fix(trace-table): expanded state and make it fast! (#8099)
* preserve expanded state

* lazy load table for better performance

* fix broken commit

* pending: verbose state tracking working again

* fix states

* fix lint

* cleanup

* cleaner even

* fix build
2025-07-24 14:24:47 +00:00
c5d61b7ed2 chore: Remove add window button and its imports (#8097)
* Remove Add Window button from playground page

Co-authored-by: max <max@langfuse.com>

* feat(playground): remove Add Window button from header

- Remove Add Window button from playground page header
- Clean up unused imports (Plus icon, MULTI_WINDOW_CONFIG)
- Remove unused isAddWindowDisabled variable
- Preserve addWindow function for copy functionality
- Fix linting warnings for unused imports

Users can still add windows via the copy button on individual windows.

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2025-07-24 13:05:27 +00:00
Max DeichmannandGitHub f95dd872e6 chore: fix output box for playground (#8096) 2025-07-24 12:38:11 +00:00
marliessophieandGitHub 04339741a9 chore(dataset-run-items): adjust converter for background migration (#8075)
* chore: reorder converter

* chore: treat numbers and booleans as strings

* chore: adjust metadata conversion

* Revert "chore: adjust metadata conversion"

This reverts commit d5ca3a5cb1b6e5972e5a321ab4884c589b938087.
2025-07-24 11:38:48 +00:00
marliessophieandGitHub 9c85e46996 feat(table): persist column sizes in local storage (#7816)
* feat(table): persist column sizing in localStorage

* chore: apply to all tables

* chore: push

* chore: add string
2025-07-24 11:37:16 +00:00
Steffen SchmitzandGitHub 4a7236451f chore: bump clickhouse client to 1.12.0 (#8092) 2025-07-24 11:20:21 +00:00
Steffen SchmitzandGitHub 5cb5204181 chore: migrate remaining trace reads to AMT (#8056)
* chore: migrate remaining trace reads to AMT

* chore: revert box ticks

* chore: revert streamed exports

* chore: confirm trace count by project in creation interval

* chore: confirm trace count since created

* chore: confirm getTracesByIdsForAnyProject

* chore: session table simplification

* chore: refactor user metrics

* chore: update
2025-07-24 10:49:44 +00:00
NimarandGitHub c19066afa0 fix(playground): api key link should show llm connection (#8080) 2025-07-24 09:43:03 +00:00
Max DeichmannandGitHub bbb9dc285d chore: remove delay for otel (#8085) 2025-07-24 11:16:59 +02:00
Steffen SchmitzandGitHub b750acba06 feat(admin-api): add okta role assignment capabilities (#8084)
* feat(admin-api): add okta role assignment capabilities

* chore: lint
2025-07-24 08:21:36 +00:00
Max DeichmannandGitHub bb06e079eb chore: upgrade prism (#8083)
* chore: upgrade prismjs

* fix
2025-07-24 07:49:50 +00:00
Max DeichmannandGitHub 5f711780e0 chore: upgrade prismjs (#8077) 2025-07-23 17:20:53 +00:00
Max DeichmannandGitHub 0f58ea1ebf chore: remove trivy (#8076) 2025-07-23 17:11:59 +00:00
129 changed files with 5037 additions and 1952 deletions
+4 -4
View File
@@ -1,6 +1,6 @@
{
"name": "langfuse-development",
"forwardPorts": [3000, 5432, 6379, 8123, 9000],
"onCreateCommand": "npm install -g pnpm@9.5.0",
"postCreateCommand": "curl -L https://github.com/golang-migrate/migrate/releases/download/v4.18.3/migrate.linux-amd64.tar.gz | tar xvz && git restore LICENSE README.md && chmod +x migrate && sudo mv migrate /usr/bin && cp .env.dev.example .env && npm install -g @anthropic-ai/claude-code && pnpm i"
"name": "langfuse-development",
"forwardPorts": [3000, 5432, 6379, 8123, 9000],
"onCreateCommand": "npm install -g pnpm@9.5.0",
"postCreateCommand": "curl -L https://github.com/golang-migrate/migrate/releases/download/v4.18.3/migrate.linux-amd64.tar.gz | tar xvz && git restore LICENSE README.md && chmod +x migrate && sudo mv migrate /usr/bin && cp .env.dev.example .env && npm install -g @anthropic-ai/claude-code && pnpm i"
}
-43
View File
@@ -1,43 +0,0 @@
name: Trivy Container Security
on:
push:
branches: ["main"]
jobs:
trivy-web:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@master
- name: Run Trivy vulnerability scanner (web)
uses: aquasecurity/trivy-action@master
with:
image-ref: "langfuse/langfuse"
format: "sarif"
output: "trivy-web.sarif"
- name: Upload Trivy scan results to GitHub Security tab (web)
if: always() && hashFiles('trivy-web.sarif') != ''
uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: "trivy-web.sarif"
category: trivy-web
trivy-worker:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@master
- name: Run Trivy vulnerability scanner (worker)
uses: aquasecurity/trivy-action@master
with:
image-ref: "langfuse/langfuse-worker"
format: "sarif"
output: "trivy-worker.sarif"
- name: Upload Trivy scan results to GitHub Security tab (worker)
if: always() && hashFiles('trivy-worker.sarif') != ''
uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: "trivy-worker.sarif"
category: trivy-worker
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "langfuse",
"version": "3.85.2",
"version": "3.87.0",
"author": "engineering@langfuse.com",
"license": "MIT",
"private": true,
@@ -40,7 +40,7 @@
"husky": "^9.0.11",
"prettier": "^3.6.2",
"release-it": "^19.0.3",
"turbo": "^2.5.4"
"turbo": "^2.5.5"
},
"release-it": {
"git": {
+1 -1
View File
@@ -13,7 +13,7 @@
"@vercel/style-guide": "^6.0.0",
"eslint-config-next": "^14.2.15",
"eslint-config-prettier": "^9.1.0",
"eslint-config-turbo": "^2.5.4",
"eslint-config-turbo": "^2.5.5",
"eslint-plugin-only-warn": "^1.1.0",
"typescript": "^5.4.5"
}
@@ -1 +1 @@
ALTER TABLE traces ON CLUSTER default DROP INDEX IF EXISTS idx_user_id;
ALTER TABLE traces DROP INDEX IF EXISTS idx_user_id;
+1 -1
View File
@@ -61,7 +61,7 @@
"@aws-sdk/lib-storage": "^3.675.0",
"@aws-sdk/s3-request-presigner": "^3.679.0",
"@azure/storage-blob": "^12.26.0",
"@clickhouse/client": "^1.11.2",
"@clickhouse/client": "^1.12.0",
"@google-cloud/storage": "^7.15.2",
"@langchain/anthropic": "^0.3.22",
"@langchain/aws": "^0.1.11",
@@ -55,6 +55,7 @@ export type AnnotationQueueStatus =
export const AnnotationQueueObjectType = {
TRACE: "TRACE",
OBSERVATION: "OBSERVATION",
SESSION: "SESSION",
} as const;
export type AnnotationQueueObjectType =
(typeof AnnotationQueueObjectType)[keyof typeof AnnotationQueueObjectType];
@@ -359,6 +360,8 @@ export type Dataset = {
name: string;
description: string | null;
metadata: unknown | null;
remote_experiment_url: string | null;
remote_experiment_payload: unknown | null;
created_at: Generated<Timestamp>;
updated_at: Generated<Timestamp>;
};
@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "datasets" ADD COLUMN "remote_experiment_payload" JSONB,
ADD COLUMN "remote_experiment_url" TEXT;
@@ -0,0 +1,2 @@
-- AlterEnum
ALTER TYPE "AnnotationQueueObjectType" ADD VALUE 'SESSION';
+13 -10
View File
@@ -539,6 +539,7 @@ enum AnnotationQueueStatus {
enum AnnotationQueueObjectType {
TRACE
OBSERVATION
SESSION
}
model CronJobs {
@@ -551,16 +552,18 @@ model CronJobs {
}
model Dataset {
id String @default(cuid())
projectId String @map("project_id")
name String
description String?
metadata Json?
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
datasetItems DatasetItem[]
datasetRuns DatasetRuns[]
id String @default(cuid())
projectId String @map("project_id")
name String
description String?
metadata Json?
remoteExperimentUrl String? @map("remote_experiment_url")
remoteExperimentPayload Json? @map("remote_experiment_payload")
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
datasetItems DatasetItem[]
datasetRuns DatasetRuns[]
@@id([id, projectId])
@@unique([projectId, name])
@@ -58,6 +58,7 @@ export const WebhookActionConfigSchema = z.object({
apiVersion: AvailableWebhookApiSchema,
secretKey: z.string(),
displaySecretKey: z.string(),
lastFailingExecutionId: z.string().nullish(),
});
export const SafeWebhookActionConfigSchema = WebhookActionConfigSchema.omit({
+32
View File
@@ -140,6 +140,38 @@ const EnvSchema = z.object({
LANGFUSE_EXPERIMENT_RETURN_NEW_RESULT: z
.enum(["true", "false"])
.default("false"),
LANGFUSE_INGESTION_PROCESSING_SAMPLED_PROJECTS: z
.string()
.optional()
.transform((val) => {
try {
if (!val) return new Map<string, number>();
const map = new Map<string, number>();
const parts = val.split(",");
for (const part of parts) {
const [projectId, sampleRateStr] = part.split(":");
if (!projectId || sampleRateStr === undefined) {
throw new Error(`Invalid format: ${part}`);
}
// Validate sample rate is between 0 and 1
const sampleRate = z.coerce
.number()
.min(0)
.max(1)
.parse(sampleRateStr);
map.set(projectId, sampleRate);
}
return map;
} catch (err) {
return new Map<string, number>();
}
}),
});
export const env: z.infer<typeof EnvSchema> =
@@ -14,6 +14,7 @@ const ActionIdSchema = z.enum([
"score-delete",
"trace-delete",
"trace-add-to-annotation-queue",
"session-add-to-annotation-queue",
]);
export type ActionId = z.infer<typeof ActionIdSchema>;
@@ -0,0 +1,34 @@
import { DatasetDeleteQueue } from "../redis/datasetDelete";
import { QueueJobs } from "../queues";
import { redis } from "../redis/redis";
import { randomUUID } from "crypto";
type DatasetDeletionType = "dataset" | "dataset-runs";
type DatasetDeletionPayload = {
deletionType: DatasetDeletionType;
projectId: string;
datasetId: string;
datasetRunIds?: string[];
};
export const addToDeleteDatasetQueue = async ({
deletionType,
projectId,
datasetId,
datasetRunIds = [],
}: DatasetDeletionPayload) => {
if (redis) {
await DatasetDeleteQueue.getInstance()?.add(QueueJobs.DatasetDelete, {
payload: {
deletionType,
projectId,
datasetId,
datasetRunIds,
},
id: randomUUID(),
timestamp: new Date(),
name: QueueJobs.DatasetDelete,
});
}
};
+2
View File
@@ -32,6 +32,7 @@ export * from "./redis/webhookQueue";
export * from "./redis/traceDelete";
export * from "./redis/projectDelete";
export * from "./redis/scoreDelete";
export * from "./redis/datasetDelete";
export * from "./redis/datasetRunItemUpsert";
export * from "./redis/batchExport";
export * from "./redis/batchActionQueue";
@@ -69,6 +70,7 @@ export * from "./s3";
// dataset run items
export * from "./dataset-run-items/datasetExecution";
export * from "./dataset-run-items/types";
export * from "./dataset-run-items/addToDeleteQueue";
// test utils
export * from "./test-utils";
@@ -83,7 +83,7 @@ const getModelFromRedis = async (
if (redisModel) {
recordIncrement("langfuse.model_match.cache_hit", 1);
if (redisModel === NOT_FOUND_TOKEN) {
return null;
return NOT_FOUND_TOKEN;
}
const model = redisModelToPrismaModel(redisModel);
return model;
@@ -30,6 +30,7 @@ import {
StorageService,
StorageServiceFactory,
} from "../services/StorageService";
import { isTraceIdInSample } from "./sampling";
let s3StorageServiceClient: StorageService;
@@ -61,7 +62,7 @@ export type TokenCountDelegate = (p: {
* We need the delay around date boundaries to avoid duplicates for out-of-order processing of events.
* @param delay - Delay overwrite. Used if non-null.
*/
const getDelay = (delay: number | null) => {
const getDelay = (delay: number | null, source: "api" | "otel") => {
if (delay !== null) {
return delay;
}
@@ -73,6 +74,10 @@ const getDelay = (delay: number | null) => {
return env.LANGFUSE_INGESTION_QUEUE_DELAY_MS;
}
if (source === "otel") {
return 0;
}
// Use 5s here to avoid duplicate processing on the worker. If the ingestion delay is set to a lower value,
// we use this instead.
// Values should be revisited based on a cost/performance trade-off.
@@ -269,6 +274,27 @@ export const processEventBatch = async (
const shouldSkipS3List =
isDatasetRunItemEvent || (isObservationEvent && isOtelOrSkipS3Project);
const { isSampled, isSamplingConfigured } = isTraceIdInSample({
projectId: authCheck.scope.projectId,
event: eventData.data[0],
});
if (!isSampled) {
recordIncrement("langfuse.ingestion.sampling", eventData.data.length, {
projectId: authCheck.scope.projectId ?? "<not set>",
sampling_decision: "out",
});
return;
}
if (isSamplingConfigured) {
recordIncrement("langfuse.ingestion.sampling", eventData.data.length, {
projectId: authCheck.scope.projectId ?? "<not set>",
sampling_decision: "in",
});
}
return queue
? queue.add(
QueueJobs.IngestionJob,
@@ -292,7 +318,7 @@ export const processEventBatch = async (
},
},
},
{ delay: getDelay(delay) },
{ delay: getDelay(delay, source) },
)
: Promise.reject("Failed to instantiate queue");
}),
@@ -0,0 +1,59 @@
import crypto from "node:crypto";
import { logger } from "../logger";
import { env } from "../../env";
import { IngestionEventType } from "./types";
export function isTraceIdInSample(params: {
projectId: string | null;
event: IngestionEventType;
}): { isSampled: boolean; isSamplingConfigured: boolean } {
const { projectId, event } = params;
const sampledProjects = env.LANGFUSE_INGESTION_PROCESSING_SAMPLED_PROJECTS;
if (!projectId || !sampledProjects.has(projectId))
return { isSampled: true, isSamplingConfigured: false };
const sampleRate = sampledProjects.get(projectId);
if (sampleRate === undefined)
return { isSampled: true, isSamplingConfigured: true };
const traceId = parseTraceId(event);
if (!traceId) return { isSampled: true, isSamplingConfigured: true };
return {
isSampled: isInSample(traceId, sampleRate),
isSamplingConfigured: true,
};
}
function isInSample(traceId: string, sampleRate: number) {
if (sampleRate < 0 || sampleRate > 1) {
logger.error(`Invalid sample rate ${sampleRate}`);
// Be conservative and keep the trace ID in sample for invalid configs
return true;
}
if (sampleRate === 0) return false;
if (sampleRate === 1) return true;
// Create SHA-256 hash of the input
const hash = crypto.createHash("sha256").update(traceId).digest("hex");
// Take first 8 characters and convert to integer
// Equivalent to 4 bytes, 32 bit integer
const hashInt = parseInt(hash.substring(0, 8), 16);
// Convert to a value between 0 and 1 by dividing by largest integer
const normalizedHash = hashInt / 0xffffffff;
// Return true if normalized hash is less than sample rate
return normalizedHash < sampleRate;
}
function parseTraceId(event: IngestionEventType): string | null | undefined {
if (event.type === "trace-create") return event.body.id;
return "traceId" in event.body ? event.body.traceId : null;
}
+42
View File
@@ -40,6 +40,21 @@ export const ScoresQueueEventSchema = z.object({
projectId: z.string(),
scoreIds: z.array(z.string()),
});
export const DatasetQueueEventSchema = z.discriminatedUnion("deletionType", [
// Delete all run items for a specific dataset
z.object({
deletionType: z.literal("dataset"),
projectId: z.string(),
datasetId: z.string(),
}),
// Delete all run items for multiple dataset runs (also used for single run deletion)
z.object({
deletionType: z.literal("dataset-runs"),
projectId: z.string(),
datasetId: z.string(),
datasetRunIds: z.array(z.string()),
}),
]);
export const ProjectQueueEventSchema = z.object({
projectId: z.string(),
orgId: z.string(),
@@ -101,6 +116,15 @@ export const BatchActionProcessingEventSchema = z.discriminatedUnion(
targetId: z.string().optional(),
type: z.enum(BatchActionType),
}),
z.object({
actionId: z.literal("session-add-to-annotation-queue"),
projectId: z.string(),
query: BatchActionQuerySchema,
tableName: z.enum(BatchTableNames),
cutoffCreatedAt: z.date(),
targetId: z.string().optional(),
type: z.enum(BatchActionType),
}),
z.object({
actionId: z.literal("eval-create"),
targetObject: z.enum(["trace", "dataset"]),
@@ -165,6 +189,7 @@ export type BatchExportJobType = z.infer<typeof BatchExportJobSchema>;
export type TraceQueueEventType = z.infer<typeof TraceQueueEventSchema>;
export type TracesQueueEventType = z.infer<typeof TracesQueueEventSchema>;
export type ScoresQueueEventType = z.infer<typeof ScoresQueueEventSchema>;
export type DatasetQueueEventType = z.infer<typeof DatasetQueueEventSchema>;
export type ProjectQueueEventType = z.infer<typeof ProjectQueueEventSchema>;
export type DatasetRunItemUpsertEventType = z.infer<
typeof DatasetRunItemUpsertEventSchema
@@ -192,6 +217,13 @@ export type DeadLetterRetryQueueEventType = z.infer<
export type WebhookQueueEventType = z.infer<typeof WebhookInputSchema>;
export const RetryBaggage = z.object({
originalJobTimestamp: z.date(),
attempt: z.number(),
});
export type RetryBaggage = z.infer<typeof RetryBaggage>;
export enum QueueName {
TraceUpsert = "trace-upsert", // Ingestion pipeline adds events on each Trace upsert
TraceDelete = "trace-delete",
@@ -214,6 +246,7 @@ export enum QueueName {
BatchActionQueue = "batch-action-queue",
CreateEvalQueue = "create-eval-queue",
ScoreDelete = "score-delete",
DatasetDelete = "dataset-delete-queue",
DeadLetterRetryQueue = "dead-letter-retry-queue",
WebhookQueue = "webhook-queue",
EntityChangeQueue = "entity-change-queue",
@@ -241,6 +274,7 @@ export enum QueueJobs {
BatchActionProcessingJob = "batch-action-processing-job",
CreateEvalJob = "create-eval-job",
ScoreDelete = "score-delete",
DatasetDelete = "dataset-delete-job",
DeadLetterRetryJob = "dead-letter-retry-job",
WebhookJob = "webhook-job",
EntityChangeJob = "entity-change-job",
@@ -265,6 +299,12 @@ export type TQueueJobTypes = {
payload: ScoresQueueEventType;
name: QueueJobs.ScoreDelete;
};
[QueueName.DatasetDelete]: {
timestamp: Date;
id: string;
payload: DatasetQueueEventType;
name: QueueJobs.DatasetDelete;
};
[QueueName.ProjectDelete]: {
timestamp: Date;
id: string;
@@ -282,6 +322,7 @@ export type TQueueJobTypes = {
id: string;
payload: EvalExecutionEventType;
name: QueueJobs.EvaluationExecution;
retryBaggage?: RetryBaggage;
};
[QueueName.BatchExport]: {
timestamp: Date;
@@ -306,6 +347,7 @@ export type TQueueJobTypes = {
id: string;
payload: ExperimentCreateEventType;
name: QueueJobs.ExperimentCreateJob;
retryBaggage?: RetryBaggage;
};
[QueueName.PostHogIntegrationProcessingQueue]: {
timestamp: Date;
@@ -0,0 +1,50 @@
import { QueueName, TQueueJobTypes } from "../queues";
import { Queue } from "bullmq";
import {
createNewRedisInstance,
redisQueueRetryOptions,
getQueuePrefix,
} from "./redis";
import { logger } from "../logger";
export class DatasetDeleteQueue {
private static instance: Queue<
TQueueJobTypes[QueueName.DatasetDelete]
> | null = null;
public static getInstance(): Queue<
TQueueJobTypes[QueueName.DatasetDelete]
> | null {
if (DatasetDeleteQueue.instance) return DatasetDeleteQueue.instance;
const newRedis = createNewRedisInstance({
enableOfflineQueue: false,
...redisQueueRetryOptions,
});
DatasetDeleteQueue.instance = newRedis
? new Queue<TQueueJobTypes[QueueName.DatasetDelete]>(
QueueName.DatasetDelete,
{
connection: newRedis,
prefix: getQueuePrefix(QueueName.DatasetDelete),
defaultJobOptions: {
removeOnComplete: true,
removeOnFail: 100_000,
attempts: 2,
backoff: {
type: "exponential",
delay: 30_000,
},
},
},
)
: null;
DatasetDeleteQueue.instance?.on("error", (err) => {
logger.error("DatasetDeleteQueue error", err);
});
return DatasetDeleteQueue.instance;
}
}
@@ -23,6 +23,7 @@ import { ScoreDeleteQueue } from "./scoreDelete";
import { DeadLetterRetryQueue } from "./dlqRetryQueue";
import { WebhookQueue } from "./webhookQueue";
import { EntityChangeQueue } from "./entityChangeQueue";
import { DatasetDeleteQueue } from "./datasetDelete";
// IngestionQueue is sharded and requires a sharding key
// Use IngestionQueue.getInstance({ shardName: queueName }) directly instead
@@ -36,6 +37,8 @@ export function getQueue(
return CloudUsageMeteringQueue.getInstance();
case QueueName.DatasetRunItemUpsert:
return DatasetRunItemUpsertQueue.getInstance();
case QueueName.DatasetDelete:
return DatasetDeleteQueue.getInstance();
case QueueName.EvaluationExecution:
return EvalExecutionQueue.getInstance();
case QueueName.ExperimentCreate:
@@ -1,6 +1,10 @@
import { QueueName, TQueueJobTypes } from "../queues";
import { Queue } from "bullmq";
import { createNewRedisInstance, redisQueueRetryOptions } from "./redis";
import {
createNewRedisInstance,
getQueuePrefix,
redisQueueRetryOptions,
} from "./redis";
import { logger } from "../logger";
export class WebhookQueue {
@@ -23,6 +27,7 @@ export class WebhookQueue {
QueueName.WebhookQueue,
{
connection: newRedis,
prefix: getQueuePrefix(QueueName.WebhookQueue),
defaultJobOptions: {
removeOnComplete: true,
removeOnFail: 100_000,
@@ -2,6 +2,7 @@ import {
Action,
ActionExecutionStatus,
JobConfigState,
Prisma,
prisma,
Trigger,
} from "../../db";
@@ -40,13 +41,15 @@ export const getActionByIdWithSecrets = async ({
// Decrypt secret headers for webhook execution using new structure
const decryptedHeaders = config.requestHeaders
? decryptSecretHeaders(mergeHeaders(config.headers, config.requestHeaders))
: Object.entries(config.headers).reduce(
(acc, [key, value]) => {
acc[key] = { secret: false, value };
return acc;
},
{} as Record<string, { secret: boolean; value: string }>,
);
: config.headers
? Object.entries(config.headers).reduce(
(acc, [key, value]) => {
acc[key] = { secret: false, value };
return acc;
},
{} as Record<string, { secret: boolean; value: string }>,
)
: {};
return {
...actionConfig,
@@ -54,10 +57,11 @@ export const getActionByIdWithSecrets = async ({
type: config.type,
url: config.url,
requestHeaders: decryptedHeaders,
displayHeaders: config.displayHeaders,
displayHeaders: getDisplayHeaders(config),
apiVersion: config.apiVersion,
displaySecretKey: config.displaySecretKey,
secretKey: config.secretKey,
lastFailingExecutionId: config.lastFailingExecutionId,
},
};
};
@@ -128,10 +132,7 @@ const convertTriggerToDomain = (trigger: Trigger): TriggerDomain => {
};
};
const convertActionToDomain = (action: Action): ActionDomain => {
const config = action.config as WebhookActionConfigWithSecrets;
// Handle legacy headers - convert them to displayHeaders format if displayHeaders is undefined
const getDisplayHeaders = (config: WebhookActionConfigWithSecrets) => {
let displayHeaders = config.displayHeaders;
if (!displayHeaders && config.headers) {
// Convert legacy headers to displayHeaders format
@@ -143,15 +144,21 @@ const convertActionToDomain = (action: Action): ActionDomain => {
{} as Record<string, { secret: boolean; value: string }>,
);
}
return displayHeaders;
};
const convertActionToDomain = (action: Action): ActionDomain => {
const config = action.config as WebhookActionConfigWithSecrets;
return {
...action,
config: {
type: config.type,
url: config.url,
displayHeaders,
displayHeaders: getDisplayHeaders(config),
apiVersion: config.apiVersion,
displaySecretKey: config.displaySecretKey,
lastFailingExecutionId: config.lastFailingExecutionId,
} as SafeWebhookActionConfig,
};
};
@@ -225,28 +232,46 @@ export const getConsecutiveAutomationFailures = async ({
automationId: string;
projectId: string;
}): Promise<number> => {
// First get the automation to extract triggerId and actionId
const automation = await prisma.automation.findFirst({
where: {
id: automationId,
projectId,
},
const automation = await getAutomationById({
automationId,
projectId,
});
if (!automation) {
return 0;
}
const { triggerId, actionId } = automation;
const executions = await prisma.automationExecution.findMany({
where: {
triggerId,
actionId,
projectId,
status: {
in: [ActionExecutionStatus.ERROR, ActionExecutionStatus.COMPLETED],
},
// Build where clause - if lastFailingExecutionId is set, only consider executions newer than it
const whereClause: Prisma.AutomationExecutionWhereInput = {
triggerId: automation.trigger.id,
actionId: automation.action.id,
projectId,
status: {
in: [ActionExecutionStatus.ERROR, ActionExecutionStatus.COMPLETED],
},
};
// If there's a lastFailingExecutionId, we need to get executions that are newer than that execution
if (automation.action.config.lastFailingExecutionId) {
// First get the timestamp of the last failing execution
const lastFailingExecution = await prisma.automationExecution.findUnique({
where: {
id: automation.action.config.lastFailingExecutionId,
},
select: {
createdAt: true,
},
});
if (lastFailingExecution) {
whereClause.createdAt = {
gt: lastFailingExecution.createdAt,
};
}
}
const executions = await prisma.automationExecution.findMany({
where: whereClause,
orderBy: {
createdAt: "desc",
},
@@ -0,0 +1,94 @@
import { env } from "../../env";
import { commandClickhouse } from "./clickhouse";
export const deleteDatasetRunItemsByProjectId = async ({
projectId,
}: {
projectId: string;
}) => {
const query = `
DELETE FROM dataset_run_items
WHERE project_id = {projectId: String};
`;
await commandClickhouse({
query: query,
params: {
projectId,
},
clickhouseConfigs: {
request_timeout: env.LANGFUSE_CLICKHOUSE_DELETION_TIMEOUT_MS,
},
tags: {
feature: "datasets",
type: "dataset-run-items",
kind: "delete",
projectId,
},
});
};
export const deleteDatasetRunItemsByDatasetId = async ({
projectId,
datasetId,
}: {
projectId: string;
datasetId: string;
}) => {
const query = `
DELETE FROM dataset_run_items
WHERE project_id = {projectId: String}
AND dataset_id = {datasetId: String}
`;
await commandClickhouse({
query,
params: {
projectId,
datasetId,
},
clickhouseConfigs: {
request_timeout: env.LANGFUSE_CLICKHOUSE_DELETION_TIMEOUT_MS,
},
tags: {
feature: "datasets",
type: "dataset-run-items",
kind: "delete",
projectId,
},
});
};
export const deleteDatasetRunItemsByDatasetRunIds = async ({
projectId,
datasetRunIds,
datasetId,
}: {
projectId: string;
datasetRunIds: string[];
datasetId: string;
}) => {
const query = `
DELETE FROM dataset_run_items
WHERE project_id = {projectId: String}
AND dataset_id = {datasetId: String}
AND dataset_run_id IN ({datasetRunIds: Array(String)})
`;
await commandClickhouse({
query,
params: {
projectId,
datasetRunIds,
datasetId,
},
clickhouseConfigs: {
request_timeout: env.LANGFUSE_CLICKHOUSE_DELETION_TIMEOUT_MS,
},
tags: {
feature: "datasets",
type: "dataset-run-items",
kind: "delete",
projectId,
},
});
};
@@ -355,31 +355,37 @@ export const convertPostgresDatasetRunItemToInsert = (
project_id: datasetRunItem.project_id,
dataset_run_id: datasetRunItem.dataset_run_id,
dataset_item_id: datasetRunItem.dataset_item_id,
dataset_id: datasetRunItem.dataset_id,
trace_id: datasetRunItem.trace_id,
observation_id: datasetRunItem.observation_id,
error: datasetRunItem.error,
created_at: datasetRunItem.created_at?.getTime(),
updated_at: datasetRunItem.updated_at?.getTime(),
// denormalized run data
dataset_run_created_at: datasetRunItem.dataset_run_created_at?.getTime(),
dataset_id: datasetRunItem.dataset_id,
dataset_run_name: datasetRunItem.dataset_run_name,
dataset_run_description: datasetRunItem.dataset_run_description,
dataset_run_metadata:
typeof datasetRunItem.dataset_run_metadata === "string"
? { dataset_run_metadata: datasetRunItem.dataset_run_metadata }
typeof datasetRunItem.dataset_run_metadata === "string" ||
typeof datasetRunItem.dataset_run_metadata === "number" ||
typeof datasetRunItem.dataset_run_metadata === "boolean"
? { metadata: datasetRunItem.dataset_run_metadata }
: Array.isArray(datasetRunItem.dataset_run_metadata)
? { dataset_run_metadata: datasetRunItem.dataset_run_metadata }
: datasetRunItem.dataset_run_metadata,
? { metadata: datasetRunItem.dataset_run_metadata }
: (datasetRunItem.dataset_run_metadata ?? {}),
dataset_run_created_at: datasetRunItem.dataset_run_created_at?.getTime(),
// denormalized item data
dataset_item_input: datasetRunItem.dataset_item_input,
dataset_item_expected_output: datasetRunItem.dataset_item_expected_output,
dataset_item_input: JSON.stringify(datasetRunItem.dataset_item_input),
dataset_item_expected_output: JSON.stringify(
datasetRunItem.dataset_item_expected_output,
),
dataset_item_metadata:
typeof datasetRunItem.dataset_item_metadata === "string"
? { dataset_item_metadata: datasetRunItem.dataset_item_metadata }
typeof datasetRunItem.dataset_item_metadata === "string" ||
typeof datasetRunItem.dataset_item_metadata === "number" ||
typeof datasetRunItem.dataset_item_metadata === "boolean"
? { metadata: datasetRunItem.dataset_item_metadata }
: Array.isArray(datasetRunItem.dataset_item_metadata)
? { dataset_item_metadata: datasetRunItem.dataset_item_metadata }
: datasetRunItem.dataset_item_metadata,
? { metadata: datasetRunItem.dataset_item_metadata }
: (datasetRunItem.dataset_item_metadata ?? {}),
event_ts: datasetRunItem.created_at?.getTime(),
is_deleted: 0,
};
@@ -14,3 +14,4 @@ export * from "./blobStorageLog";
export * from "./environments";
export * from "./automation-repository";
export * from "./dataset-run-items-converters";
export * from "./dataset-run-items";
+267 -130
View File
@@ -464,33 +464,71 @@ export const getTraceCountsByProjectInCreationInterval = async ({
start: Date;
end: Date;
}) => {
const query = `
SELECT
project_id,
count(*) as count
FROM traces
WHERE created_at >= {start: DateTime64(3)}
AND created_at < {end: DateTime64(3)}
GROUP BY project_id
`;
const rows = await queryClickhouse<{ project_id: string; count: string }>({
query,
params: {
start: convertDateToClickhouseDateTime(start),
end: convertDateToClickhouseDateTime(end),
return measureAndReturn({
operationName: "getTraceCountsByProjectInCreationInterval",
projectId: "__CROSS_PROJECT__",
input: {
params: {
start: convertDateToClickhouseDateTime(start),
end: convertDateToClickhouseDateTime(end),
},
tags: {
feature: "tracing",
type: "trace",
kind: "analytic",
},
timestamp: start,
},
tags: {
feature: "tracing",
type: "trace",
kind: "analytic",
existingExecution: async (input) => {
const query = `
SELECT
project_id,
count(*) as count
FROM traces
WHERE created_at >= {start: DateTime64(3)}
AND created_at < {end: DateTime64(3)}
GROUP BY project_id
`;
const rows = await queryClickhouse<{ project_id: string; count: string }>(
{
query,
params: input.params,
tags: input.tags,
},
);
return rows.map((row) => ({
projectId: row.project_id,
count: Number(row.count),
}));
},
newExecution: async (input) => {
const traceAmt = getTimeframesTracesAMT(input.timestamp);
const query = `
SELECT
project_id,
count(*) as count
FROM ${traceAmt}
WHERE created_at >= {start: DateTime64(3)}
AND created_at < {end: DateTime64(3)}
GROUP BY project_id
`;
const rows = await queryClickhouse<{ project_id: string; count: string }>(
{
query,
params: input.params,
tags: input.tags,
},
);
return rows.map((row) => ({
projectId: row.project_id,
count: Number(row.count),
}));
},
});
return rows.map((row) => ({
projectId: row.project_id,
count: Number(row.count),
}));
};
export const getTraceCountOfProjectsSinceCreationDate = async ({
@@ -500,28 +538,57 @@ export const getTraceCountOfProjectsSinceCreationDate = async ({
projectIds: string[];
start: Date;
}) => {
const query = `
SELECT
count(*) as count
FROM traces
WHERE project_id IN ({projectIds: Array(String)})
AND created_at >= {start: DateTime64(3)}
`;
const rows = await queryClickhouse<{ count: string }>({
query,
params: {
projectIds,
start: convertDateToClickhouseDateTime(start),
return measureAndReturn({
operationName: "getTraceCountOfProjectsSinceCreationDate",
projectId: "__CROSS_PROJECT__",
input: {
params: {
projectIds,
start: convertDateToClickhouseDateTime(start),
},
tags: {
feature: "tracing",
type: "trace",
kind: "analytic",
},
timestamp: start,
},
tags: {
feature: "tracing",
type: "trace",
kind: "analytic",
existingExecution: async (input) => {
const query = `
SELECT
count(*) as count
FROM traces
WHERE project_id IN ({projectIds: Array(String)})
AND created_at >= {start: DateTime64(3)}
`;
const rows = await queryClickhouse<{ count: string }>({
query,
params: input.params,
tags: input.tags,
});
return Number(rows[0]?.count ?? 0);
},
newExecution: async (input) => {
const traceAmt = getTimeframesTracesAMT(input.timestamp);
const query = `
SELECT
count(*) as count
FROM ${traceAmt}
WHERE project_id IN ({projectIds: Array(String)})
AND created_at >= {start: DateTime64(3)}
`;
const rows = await queryClickhouse<{ count: string }>({
query,
params: input.params,
tags: input.tags,
});
return Number(rows[0]?.count ?? 0);
},
});
return Number(rows[0]?.count ?? 0);
};
/**
@@ -1232,10 +1299,8 @@ export const getUserMetrics = async (
o.project_id = {projectId: String }
${timestampFilter ? `AND o.start_time >= {traceTimestamp: DateTime64(3)} - ${OBSERVATIONS_TO_TRACE_INTERVAL}` : ""}
AND o.trace_id in (
SELECT
distinct id
from
traces
SELECT distinct id
from __TRACE_TABLE__
where
user_id IN ({userIds: Array(String) })
AND project_id = {projectId: String }
@@ -1249,25 +1314,17 @@ export const getUserMetrics = async (
t.user_id,
t.project_id,
t.timestamp,
t.environment,
ROW_NUMBER() OVER (
PARTITION BY id
ORDER BY
event_ts DESC
) AS rn
t.environment
FROM
traces t
__TRACE_TABLE__ t FINAL
WHERE
t.user_id IN ({userIds: Array(String) })
AND t.project_id = {projectId: String }
${filter.length > 0 ? `AND ${chFilterRes.query}` : ""}
) as t on t.id = o.trace_id
and t.project_id = o.project_id
WHERE
o.rn = 1
and t.rn = 1
group by
t.user_id
WHERE o.rn = 1
group by t.user_id
)
SELECT
arraySum(mapValues(mapFilter(x -> positionCaseInsensitive(x.1, 'input') > 0, sum_usage_details))) as input_usage,
@@ -1280,56 +1337,101 @@ export const getUserMetrics = async (
sum_total_cost,
max_timestamp,
min_timestamp
FROM
stats
FROM stats`;
`;
const rows = await queryClickhouse<{
user_id: string;
environment: string;
max_timestamp: string;
min_timestamp: string;
input_usage: string;
output_usage: string;
total_usage: string;
obs_count: string;
trace_count: string;
sum_total_cost: string;
}>({
query,
params: {
projectId,
userIds,
...chFilterRes.params,
...(timestampFilter
? {
traceTimestamp: convertDateToClickhouseDateTime(
(timestampFilter as DateTimeFilter).value,
),
}
: {}),
return measureAndReturn({
operationName: "getUserMetrics",
projectId,
input: {
params: {
projectId,
userIds,
...chFilterRes.params,
...(timestampFilter
? {
traceTimestamp: convertDateToClickhouseDateTime(
(timestampFilter as DateTimeFilter).value,
),
}
: {}),
},
tags: {
feature: "tracing",
type: "trace",
kind: "analytic",
projectId,
},
},
tags: {
feature: "tracing",
type: "trace",
kind: "analytic",
projectId,
existingExecution: async (input) => {
const rows = await queryClickhouse<{
user_id: string;
environment: string;
max_timestamp: string;
min_timestamp: string;
input_usage: string;
output_usage: string;
total_usage: string;
obs_count: string;
trace_count: string;
sum_total_cost: string;
}>({
query: query.replaceAll("__TRACE_TABLE__", "traces"),
params: input.params,
tags: input.tags,
});
return rows.map((row) => ({
userId: row.user_id,
environment: row.environment,
maxTimestamp: parseClickhouseUTCDateTimeFormat(row.max_timestamp),
minTimestamp: parseClickhouseUTCDateTimeFormat(row.min_timestamp),
inputUsage: Number(row.input_usage),
outputUsage: Number(row.output_usage),
totalUsage: Number(row.total_usage),
observationCount: Number(row.obs_count),
traceCount: Number(row.trace_count),
totalCost: Number(row.sum_total_cost),
}));
},
newExecution: async (input) => {
// Extract the timestamp from filter for AMT table selection
const fromTimestamp = filter?.find(
(f) =>
f.column === "timestamp" &&
(f.operator === ">=" || f.operator === ">"),
)?.value as Date | undefined;
const traceAmt = getTimeframesTracesAMT(fromTimestamp);
const rows = await queryClickhouse<{
user_id: string;
environment: string;
max_timestamp: string;
min_timestamp: string;
input_usage: string;
output_usage: string;
total_usage: string;
obs_count: string;
trace_count: string;
sum_total_cost: string;
}>({
query: query.replaceAll("__TRACE_TABLE__", traceAmt),
params: input.params,
tags: input.tags,
});
return rows.map((row) => ({
userId: row.user_id,
environment: row.environment,
maxTimestamp: parseClickhouseUTCDateTimeFormat(row.max_timestamp),
minTimestamp: parseClickhouseUTCDateTimeFormat(row.min_timestamp),
inputUsage: Number(row.input_usage),
outputUsage: Number(row.output_usage),
totalUsage: Number(row.total_usage),
observationCount: Number(row.obs_count),
traceCount: Number(row.trace_count),
totalCost: Number(row.sum_total_cost),
}));
},
});
return rows.map((row) => ({
userId: row.user_id,
environment: row.environment,
maxTimestamp: parseClickhouseUTCDateTimeFormat(row.max_timestamp),
minTimestamp: parseClickhouseUTCDateTimeFormat(row.min_timestamp),
inputUsage: Number(row.input_usage),
outputUsage: Number(row.output_usage),
totalUsage: Number(row.total_usage),
observationCount: Number(row.obs_count),
traceCount: Number(row.trace_count),
totalCost: Number(row.sum_total_cost),
}));
};
export const getTracesForBlobStorageExport = function (
@@ -1360,7 +1462,7 @@ export const getTracesForBlobStorageExport = function (
AND timestamp <= {maxTimestamp: DateTime64(3)}
`;
const records = queryClickhouseStream<Record<string, unknown>>({
return queryClickhouseStream<Record<string, unknown>>({
query,
params: {
projectId,
@@ -1374,8 +1476,6 @@ export const getTracesForBlobStorageExport = function (
projectId,
},
});
return records;
};
export const getTracesForPostHog = async function* (
@@ -1440,6 +1540,7 @@ export const getTracesForPostHog = async function* (
});
const baseUrl = env.NEXTAUTH_URL?.replace("/api/auth", "");
for await (const record of records) {
yield {
timestamp: record.timestamp,
@@ -1467,32 +1568,68 @@ export const getTracesForPostHog = async function* (
}
};
/**
* This query is used only for legacy support of redirects without a projectId.
* We don't have an index on the traceId so it will be a full table scan.
* We expect at most 10s of calls per day, so this is acceptable.
*/
export const getTracesByIdsForAnyProject = async (traceIds: string[]) => {
const query = `
SELECT id, project_id
FROM traces
WHERE id IN ({traceIds: Array(String)})
ORDER BY event_ts DESC
LIMIT 1 by id, project_id;`;
const records = await queryClickhouse<{
id: string;
project_id: string;
}>({
query,
params: {
traceIds,
return measureAndReturn({
operationName: "getTracesByIdsForAnyProject",
projectId: "__CROSS_PROJECT__",
input: {
params: {
traceIds,
},
tags: {
feature: "tracing",
type: "trace",
kind: "list",
},
},
tags: {
feature: "tracing",
type: "trace",
kind: "list",
existingExecution: async (input) => {
const query = `
SELECT id, project_id
FROM traces
WHERE id IN ({traceIds: Array(String)})
ORDER BY event_ts DESC
LIMIT 1 by id, project_id;`;
const records = await queryClickhouse<{
id: string;
project_id: string;
}>({
query,
params: input.params,
tags: input.tags,
});
return records.map((record) => ({
id: record.id,
projectId: record.project_id,
}));
},
newExecution: async (input) => {
// For this query, we need to query all AMT tables as we don't have a specific timestamp
// We'll use the all AMT table as it contains all data
const query = `
SELECT DISTINCT id, project_id
FROM traces_all_amt
WHERE id IN ({traceIds: Array(String)})`;
const records = await queryClickhouse<{
id: string;
project_id: string;
}>({
query,
params: input.params,
tags: input.tags,
});
return records.map((record) => ({
id: record.id,
projectId: record.project_id,
}));
},
});
return records.map((record) => ({
id: record.id,
projectId: record.project_id,
}));
};
export async function getAgentGraphData(params: {
@@ -3,6 +3,7 @@ import { OrderByState } from "../../interfaces/orderBy";
import { sessionCols } from "../../tableDefinitions/mapSessionTable";
import { FilterState } from "../../types";
import { convertDateToClickhouseDateTime } from "../clickhouse/client";
import { measureAndReturn } from "../clickhouse/measureAndReturn";
import { DateTimeFilter, FilterList, orderByToClickhouseSql } from "../queries";
import {
getProjectIdDefaultFilter,
@@ -11,6 +12,7 @@ import {
import {
TRACE_TO_OBSERVATIONS_INTERVAL,
queryClickhouse,
getTimeframesTracesAMT,
} from "../repositories";
export type SessionDataReturnType = {
@@ -235,113 +237,133 @@ const getSessionsTableGeneric = async <T>(props: FetchSessionsTableProps) => {
// We use deduplicated traces and observations CTEs instead of final to be able to use Skip indices in Clickhouse.
const query = `
WITH deduplicated_traces AS (
SELECT * EXCEPT input, output, metadata
FROM traces t
WHERE t.session_id IS NOT NULL
AND t.project_id = {projectId: String}
${singleTraceFilter?.query ? ` AND ${singleTraceFilter.query}` : ""}
ORDER BY event_ts DESC
LIMIT 1 BY id, project_id
),
deduplicated_observations AS (
SELECT *
FROM observations o
WHERE o.project_id = {projectId: String}
${traceTimestampFilter ? `AND o.start_time >= {observationsStartTime: DateTime64(3)} - ${TRACE_TO_OBSERVATIONS_INTERVAL}` : ""}
AND o.trace_id IN (
SELECT id
FROM deduplicated_traces
)
ORDER BY event_ts DESC
LIMIT 1 BY id, project_id
),
observations_agg AS (
SELECT o.trace_id,
count(*) as obs_count,
min(o.start_time) as min_start_time,
max(o.end_time) as max_end_time,
sumMap(usage_details) as sum_usage_details,
sumMap(cost_details) as sum_cost_details,
anyLast(project_id) as project_id
FROM deduplicated_observations o
WHERE o.project_id = {projectId: String}
${traceTimestampFilter ? `AND o.start_time >= {observationsStartTime: DateTime64(3)} - ${TRACE_TO_OBSERVATIONS_INTERVAL}` : ""}
GROUP BY o.trace_id
),
session_data AS (
SELECT
t.session_id,
anyLast(t.project_id) as project_id,
max(t.timestamp) as max_timestamp,
min(t.timestamp) as min_timestamp,
groupArray(t.id) AS trace_ids,
groupUniqArray(t.user_id) AS user_ids,
count(*) as trace_count,
groupUniqArrayArray(t.tags) as trace_tags,
anyLast(t.environment) as trace_environment
-- Aggregate observations data at session level
${
selectMetrics
? `
,
sum(o.obs_count) as total_observations,
-- Use minIf, because ClickHouse fills 1970-01-01 on left joins. We assume that no
-- LLM session started on that date so this behaviour should yield better results.
date_diff('second', minIf(min_start_time, min_start_time > '1970-01-01'), max(max_end_time)) as duration,
sumMap(o.sum_usage_details) as session_usage_details,
sumMap(o.sum_cost_details) as session_cost_details,
arraySum(mapValues(mapFilter(x -> positionCaseInsensitive(x.1, 'input') > 0, sumMap(o.sum_cost_details)))) as session_input_cost,
arraySum(mapValues(mapFilter(x -> positionCaseInsensitive(x.1, 'output') > 0, sumMap(o.sum_cost_details)))) as session_output_cost,
sumMap(o.sum_cost_details)['total'] as session_total_cost,
arraySum(mapValues(mapFilter(x -> positionCaseInsensitive(x.1, 'input') > 0, sumMap(o.sum_usage_details)))) as session_input_usage,
arraySum(mapValues(mapFilter(x -> positionCaseInsensitive(x.1, 'output') > 0, sumMap(o.sum_usage_details)))) as session_output_usage,
sumMap(o.sum_usage_details)['total'] as session_total_usage`
: ""
}
FROM deduplicated_traces t
${
selectMetrics
? `LEFT JOIN observations_agg o
ON t.id = o.trace_id AND t.project_id = o.project_id`
: ""
}
WHERE t.session_id IS NOT NULL
WITH deduplicated_traces AS (
SELECT * EXCEPT input, output, metadata
FROM __TRACE_TABLE__ t FINAL
WHERE t.session_id IS NOT NULL
AND t.project_id = {projectId: String}
${singleTraceFilter?.query ? ` AND ${singleTraceFilter.query}` : ""}
GROUP BY t.session_id
)
SELECT ${sqlSelect}
FROM session_data s
WHERE ${tracesFilterRes.query ? tracesFilterRes.query : ""}
${orderByToClickhouseSql(orderBy ?? null, sessionCols)}
${limit !== undefined && page !== undefined ? `LIMIT {limit: Int32} OFFSET {offset: Int32}` : ""}
`;
),
deduplicated_observations AS (
SELECT *
FROM observations o
WHERE o.project_id = {projectId: String}
${traceTimestampFilter ? `AND o.start_time >= {observationsStartTime: DateTime64(3)} - ${TRACE_TO_OBSERVATIONS_INTERVAL}` : ""}
AND o.trace_id IN (
SELECT id
FROM deduplicated_traces
)
ORDER BY event_ts DESC
LIMIT 1 BY id, project_id
),
observations_agg AS (
SELECT o.trace_id,
count(*) as obs_count,
min(o.start_time) as min_start_time,
max(o.end_time) as max_end_time,
sumMap(usage_details) as sum_usage_details,
sumMap(cost_details) as sum_cost_details,
anyLast(project_id) as project_id
FROM deduplicated_observations o
WHERE o.project_id = {projectId: String}
${traceTimestampFilter ? `AND o.start_time >= {observationsStartTime: DateTime64(3)} - ${TRACE_TO_OBSERVATIONS_INTERVAL}` : ""}
GROUP BY o.trace_id
),
session_data AS (
SELECT
t.session_id,
anyLast(t.project_id) as project_id,
max(t.timestamp) as max_timestamp,
min(t.timestamp) as min_timestamp,
groupArray(t.id) AS trace_ids,
groupUniqArray(t.user_id) AS user_ids,
count(*) as trace_count,
groupUniqArrayArray(t.tags) as trace_tags,
anyLast(t.environment) as trace_environment
-- Aggregate observations data at session level
${
selectMetrics
? `,
sum(o.obs_count) as total_observations,
-- Use minIf, because ClickHouse fills 1970-01-01 on left joins. We assume that no
-- LLM session started on that date so this behaviour should yield better results.
date_diff('second', minIf(min_start_time, min_start_time > '1970-01-01'), max(max_end_time)) as duration,
sumMap(o.sum_usage_details) as session_usage_details,
sumMap(o.sum_cost_details) as session_cost_details,
arraySum(mapValues(mapFilter(x -> positionCaseInsensitive(x.1, 'input') > 0, sumMap(o.sum_cost_details)))) as session_input_cost,
arraySum(mapValues(mapFilter(x -> positionCaseInsensitive(x.1, 'output') > 0, sumMap(o.sum_cost_details)))) as session_output_cost,
sumMap(o.sum_cost_details)['total'] as session_total_cost,
arraySum(mapValues(mapFilter(x -> positionCaseInsensitive(x.1, 'input') > 0, sumMap(o.sum_usage_details)))) as session_input_usage,
arraySum(mapValues(mapFilter(x -> positionCaseInsensitive(x.1, 'output') > 0, sumMap(o.sum_usage_details)))) as session_output_usage,
sumMap(o.sum_usage_details)['total'] as session_total_usage`
: ""
}
FROM deduplicated_traces t
${
selectMetrics
? `LEFT JOIN observations_agg o
ON t.id = o.trace_id AND t.project_id = o.project_id`
: ""
}
WHERE t.session_id IS NOT NULL
AND t.project_id = {projectId: String}
${singleTraceFilter?.query ? ` AND ${singleTraceFilter.query}` : ""}
GROUP BY t.session_id
)
SELECT ${sqlSelect}
FROM session_data s
WHERE ${tracesFilterRes.query ? tracesFilterRes.query : ""}
${orderByToClickhouseSql(orderBy ?? null, sessionCols)}
${limit !== undefined && page !== undefined ? `LIMIT {limit: Int32} OFFSET {offset: Int32}` : ""}
`;
const obsStartTimeValue = traceTimestampFilter
? convertDateToClickhouseDateTime(traceTimestampFilter.value)
: null;
const res = await queryClickhouse<T>({
query: query,
params: {
projectId,
limit: limit,
offset: limit && page ? limit * page : 0,
...tracesFilterRes.params,
...singleTraceFilter?.params,
...(obsStartTimeValue
? { observationsStartTime: obsStartTimeValue }
: {}),
return measureAndReturn({
operationName: "getSessionsTableGeneric",
projectId,
input: {
params: {
projectId,
limit: limit,
offset: limit && page ? limit * page : 0,
...tracesFilterRes.params,
...singleTraceFilter?.params,
...(traceTimestampFilter
? {
observationsStartTime: convertDateToClickhouseDateTime(
traceTimestampFilter.value,
),
}
: {}),
},
tags: {
...(props.tags ?? {}),
feature: "tracing",
type: "sessions-table",
projectId,
},
},
tags: {
...(props.tags ?? {}),
feature: "tracing",
type: "sessions-table",
projectId,
existingExecution: async (input) => {
return queryClickhouse<T>({
query: query.replace("__TRACE_TABLE__", "traces"),
params: input.params,
tags: input.tags,
clickhouseConfigs,
});
},
newExecution: async (input) => {
// Extract the timestamp from filter for AMT table selection
const fromTimestamp = filter?.find(
(f) =>
f.column === "min_timestamp" &&
(f.operator === ">=" || f.operator === ">"),
)?.value as Date | undefined;
const traceAmt = getTimeframesTracesAMT(fromTimestamp);
return queryClickhouse<T>({
query: query.replace("__TRACE_TABLE__", traceAmt),
params: input.params,
tags: input.tags,
clickhouseConfigs,
});
},
clickhouseConfigs,
});
return res;
};
+113 -252
View File
@@ -38,8 +38,8 @@ importers:
specifier: ^19.0.3
version: 19.0.3(@types/node@20.14.8)
turbo:
specifier: ^2.5.4
version: 2.5.4
specifier: ^2.5.5
version: 2.5.5
ee:
dependencies:
@@ -60,7 +60,7 @@ importers:
version: 14.2.30(@babel/core@7.24.3)(@opentelemetry/api@1.9.0)(@playwright/test@1.47.2)(babel-plugin-macros@3.1.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
next-auth:
specifier: ^4.24.11
version: 4.24.11(patch_hash=cczsf6i6qe2m2htirvgxjtoclu)(next@14.2.30(@opentelemetry/api@1.9.0)(@playwright/test@1.47.2)(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(nodemailer@6.9.15)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
version: 4.24.11(patch_hash=cczsf6i6qe2m2htirvgxjtoclu)(next@14.2.30(@babel/core@7.24.3)(@opentelemetry/api@1.9.0)(@playwright/test@1.47.2)(babel-plugin-macros@3.1.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(nodemailer@6.9.15)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
zod:
specifier: ^3.25.62
version: 3.25.62
@@ -120,8 +120,8 @@ importers:
specifier: ^9.1.0
version: 9.1.0(eslint@8.57.0)
eslint-config-turbo:
specifier: ^2.5.4
version: 2.5.4(eslint@8.57.0)(turbo@2.5.4)
specifier: ^2.5.5
version: 2.5.5(eslint@8.57.0)(turbo@2.5.5)
eslint-plugin-only-warn:
specifier: ^1.1.0
version: 1.1.0
@@ -152,8 +152,8 @@ importers:
specifier: ^12.26.0
version: 12.26.0
'@clickhouse/client':
specifier: ^1.11.2
version: 1.11.2
specifier: ^1.12.0
version: 1.12.0
'@google-cloud/storage':
specifier: ^7.15.2
version: 7.15.2
@@ -234,7 +234,7 @@ importers:
version: 4.1.1
next-auth:
specifier: ^4.24.11
version: 4.24.11(patch_hash=cczsf6i6qe2m2htirvgxjtoclu)(next@14.2.30(@opentelemetry/api@1.9.0)(@playwright/test@1.47.2)(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(nodemailer@6.9.15)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
version: 4.24.11(patch_hash=cczsf6i6qe2m2htirvgxjtoclu)(next@14.2.30(@babel/core@7.24.3)(@opentelemetry/api@1.9.0)(@playwright/test@1.47.2)(babel-plugin-macros@3.1.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(nodemailer@6.9.15)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
nodemailer:
specifier: ^6.9.15
version: 6.9.15
@@ -547,7 +547,7 @@ importers:
version: 4.23.7(@codemirror/language@6.11.2)(@codemirror/state@6.5.2)(@codemirror/view@6.38.1)
'@uiw/react-codemirror':
specifier: ^4.24.1
version: 4.24.1(@babel/runtime@7.27.6)(@codemirror/autocomplete@6.17.0(@codemirror/language@6.11.2)(@codemirror/state@6.5.2)(@codemirror/view@6.38.1)(@lezer/common@1.2.3))(@codemirror/language@6.11.2)(@codemirror/lint@6.8.5)(@codemirror/search@6.5.6)(@codemirror/state@6.5.2)(@codemirror/theme-one-dark@6.1.2)(@codemirror/view@6.38.1)(codemirror@6.0.1(@lezer/common@1.2.3))(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
version: 4.24.1(@babel/runtime@7.28.2)(@codemirror/autocomplete@6.17.0(@codemirror/language@6.11.2)(@codemirror/state@6.5.2)(@codemirror/view@6.38.1)(@lezer/common@1.2.3))(@codemirror/language@6.11.2)(@codemirror/lint@6.8.5)(@codemirror/search@6.5.6)(@codemirror/state@6.5.2)(@codemirror/theme-one-dark@6.1.2)(@codemirror/view@6.38.1)(codemirror@6.0.1(@lezer/common@1.2.3))(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
ai:
specifier: ^3.4.9
version: 3.4.9(openai@4.104.0(zod@3.25.62))(react@18.2.0)(solid-js@1.8.18)(sswr@2.1.0(svelte@4.2.19))(svelte@4.2.19)(vue@3.4.27(typescript@5.4.5))(zod@3.25.62)
@@ -622,7 +622,7 @@ importers:
version: 14.2.30(@babel/core@7.24.3)(@opentelemetry/api@1.9.0)(@playwright/test@1.47.2)(babel-plugin-macros@3.1.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
next-auth:
specifier: ^4.24.11
version: 4.24.11(patch_hash=cczsf6i6qe2m2htirvgxjtoclu)(next@14.2.30(@opentelemetry/api@1.9.0)(@playwright/test@1.47.2)(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(nodemailer@6.9.15)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
version: 4.24.11(patch_hash=cczsf6i6qe2m2htirvgxjtoclu)(next@14.2.30(@babel/core@7.24.3)(@opentelemetry/api@1.9.0)(@playwright/test@1.47.2)(babel-plugin-macros@3.1.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(nodemailer@6.9.15)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
next-query-params:
specifier: ^5.0.1
version: 5.0.1(next@14.2.30(@babel/core@7.24.3)(@opentelemetry/api@1.9.0)(@playwright/test@1.47.2)(babel-plugin-macros@3.1.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(react@18.2.0)(use-query-params@2.2.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0))
@@ -638,6 +638,9 @@ importers:
prexit:
specifier: ^2.2.0
version: 2.2.0
prism-react-renderer:
specifier: ^2.4.1
version: 2.4.1(react@18.2.0)
prisma:
specifier: ^6.10.1
version: 6.10.1(typescript@5.4.5)
@@ -674,9 +677,6 @@ importers:
react-responsive:
specifier: ^10.0.0
version: 10.0.0(react@18.2.0)
react-syntax-highlighter:
specifier: ^15.6.1
version: 15.6.1(react@18.2.0)
react18-json-view:
specifier: ^0.2.8-canary.6
version: 0.2.8-canary.6(react@18.2.0)
@@ -792,9 +792,6 @@ importers:
'@types/react-grid-layout':
specifier: ^1.3.5
version: 1.3.5
'@types/react-syntax-highlighter':
specifier: ^15.5.13
version: 15.5.13
'@types/uuid':
specifier: ^9.0.8
version: 9.0.8
@@ -1708,8 +1705,8 @@ packages:
resolution: {integrity: sha512-FDSOghenHTiToteC/QRlv2q3DhPZ/oOXTBoirfWNx1Cx3TMVcGWQtMMmQcSvb/JjpNeGzx8Pq/b4fKEJuWm1sw==}
engines: {node: '>=6.9.0'}
'@babel/runtime@7.27.6':
resolution: {integrity: sha512-vbavdySgbTTrmFE+EsiqUTzlOr5bzlnJtUv9PynGCAKvfQqjIXbvFdumPM/GxMDfyuGMJaJAU6TO4zc1Jf1i8Q==}
'@babel/runtime@7.28.2':
resolution: {integrity: sha512-KHp2IflsnGywDjBWDkR9iEqiWSpc8GIi0lgTT3mOElT0PP1tG26P4tmFI2YvAdzgq9RGyoHZQEIEdZy6Ec5xCA==}
engines: {node: '>=6.9.0'}
'@babel/template@7.24.0':
@@ -1732,8 +1729,8 @@ packages:
resolution: {integrity: sha512-+j7a5c253RfKh8iABBhywc8NSfP5LURe7Uh4qpsh6jc+aLJguvmIUBdjSdEMQv2bENrCR5MfRdjGo7vzS/ob7w==}
engines: {node: '>=6.9.0'}
'@babel/types@7.28.1':
resolution: {integrity: sha512-x0LvFTekgSX+83TI28Y9wYPUfzrnl2aT5+5QLnO6v7mSJYtEEevuDRN0F0uSHRk1G1IWZC43o00Y0xDDrpBGPQ==}
'@babel/types@7.28.2':
resolution: {integrity: sha512-ruv7Ae4J5dUYULmeXw1gmb7rYRz57OWCPM57pHojnLq/3Z1CK2lNSLTCVjxVk1F/TZHwOZZrOWi0ur95BbLxNQ==}
engines: {node: '>=6.9.0'}
'@baselime/trpc-opentelemetry-middleware@0.1.2':
@@ -1787,11 +1784,11 @@ packages:
'@chevrotain/utils@11.0.3':
resolution: {integrity: sha512-YslZMgtJUyuMbZ+aKvfF3x1f5liK4mWNxghFRv7jqRR9C3R3fAOGTTKvxXDa2Y1s9zSbcpuO0cAxDYsc9SrXoQ==}
'@clickhouse/client-common@1.11.2':
resolution: {integrity: sha512-H4ECHqaipzMgiZKqpb1Z4N3Ofq+lVTCn8I59XsSynqrsfR4jWZD3PipXVvIzMpDmTMvrlJWrOwAdm0DMNiMQbA==}
'@clickhouse/client-common@1.12.0':
resolution: {integrity: sha512-cyI4n7u9jK30d9q1q0ceQ7IwJ/MtTs5HxoQfc8yHpN+ok5wqaU2jAtq5hpa1z7C7sS1pDy/ZOFmOzg1v1F683g==}
'@clickhouse/client@1.11.2':
resolution: {integrity: sha512-ZE7Q1qxsDNXCkGPf1zqmhpZpwAKxKT+1s4Z432J1Mb2Gm26Y4tG/sJoug81AfAJTt6s7taO2vzNBAKfSR3SStg==}
'@clickhouse/client@1.12.0':
resolution: {integrity: sha512-vJUSX8THhTzlVn0WxPukVjOgNRaSoY02ubQkB0LpqNoHFxXuF5jQZZAYvGZWpBGbYQ/4gfPrqu8g4TX5UKeNxA==}
engines: {node: '>=16'}
'@codemirror/autocomplete@6.17.0':
@@ -5358,9 +5355,6 @@ packages:
'@types/hammerjs@2.0.46':
resolution: {integrity: sha512-ynRvcq6wvqexJ9brDMS4BnBLzmr0e14d6ZJTEShTBWKymQiHwlAyGu0ZPEFI2Fh1U53F7tN9ufClWM5KvqkKOw==}
'@types/hast@2.3.10':
resolution: {integrity: sha512-McWspRw8xx8J9HurkVBfYj0xKoE25tOFlHGdx4MJ5xORQrMGZNqJhVQWaIbm6Oyla5kYOXtDiopzKRJzEOkwJw==}
'@types/hast@3.0.4':
resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==}
@@ -5439,6 +5433,9 @@ packages:
'@types/pg@8.6.1':
resolution: {integrity: sha512-1Kc4oAGzAl7uqUStZCDvaLFqZrW9qWSjXOmBfdgyBP5La7Us6Mg4GBvRlSoaZMhQF/zSj1C8CtKMBkoiT8eL8w==}
'@types/prismjs@1.26.5':
resolution: {integrity: sha512-AUZTa7hQ2KY5L7AmtSiqxlhWxb4ina0yd8hNbl4TWuqnv/pFP0nDMb3YrfSBf4hJVGLh2YEIBfKaBW/9UEl6IQ==}
'@types/prop-types@15.7.12':
resolution: {integrity: sha512-5zvhXYtRNRluoE/jAp4GVsSduVUzNWKkOZrCDBWYtE7biZywwdC2AcEzg+cSMLFRfVgeAFqpfNabiPjxFddV1Q==}
@@ -5460,9 +5457,6 @@ packages:
'@types/react-grid-layout@1.3.5':
resolution: {integrity: sha512-WH/po1gcEcoR6y857yAnPGug+ZhkF4PaTUxgAbwfeSH/QOgVSakKHBXoPGad/sEznmkiaK3pqHk+etdWisoeBQ==}
'@types/react-syntax-highlighter@15.5.13':
resolution: {integrity: sha512-uLGJ87j6Sz8UaBAooU0T6lWJ0dBmjZgN1PZTrj05TNql2/XpC6+4HhMT5syIdFUUt+FASfCeLLv4kBygNU+8qA==}
'@types/react-transition-group@4.4.11':
resolution: {integrity: sha512-RM05tAniPZ5DZPzzNFP+DmrcOdD0efDUxMy3145oljWSl3x9ZV5vhme98gTxFrj2lhXvmGNnUiuDyJgY9IKkNA==}
@@ -6347,21 +6341,12 @@ packages:
character-entities-html4@2.1.0:
resolution: {integrity: sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==}
character-entities-legacy@1.1.4:
resolution: {integrity: sha512-3Xnr+7ZFS1uxeiUDvV02wQ+QDbc55o97tIV5zHScSPJpcLm/r0DFPcoY3tYRp+VZukxuMeKgXYmsXQHO05zQeA==}
character-entities-legacy@3.0.0:
resolution: {integrity: sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==}
character-entities@1.2.4:
resolution: {integrity: sha512-iBMyeEHxfVnIakwOuDXpVkc54HijNgCyQB2w0VfGQThle6NXn50zU6V/u+LDhxHcDUPojn6Kpga3PTAD8W1bQw==}
character-entities@2.0.2:
resolution: {integrity: sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==}
character-reference-invalid@1.1.4:
resolution: {integrity: sha512-mKKUkUbhPpQlCOfIuZkvSEgktjPFIsZKRRbC6KWVEMvlzblj3i3asQv5ODsrwt0N3pHAEvjP8KTQPHkp0+6jOg==}
character-reference-invalid@2.0.1:
resolution: {integrity: sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==}
@@ -6529,9 +6514,6 @@ packages:
resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==}
engines: {node: '>= 0.8'}
comma-separated-tokens@1.0.8:
resolution: {integrity: sha512-GHuDRO12Sypu2cV70d1dkA2EUmXHgntrzbpvOB+Qy+49ypNfGgFQIC2fhhXbnyrJRynDCAARsT7Ou0M6hirpfw==}
comma-separated-tokens@2.0.3:
resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==}
@@ -7186,6 +7168,10 @@ packages:
resolution: {integrity: sha512-m/C+AwOAr9/W1UOIZUo232ejMNnJAJtYQjUbHoNTBNTJSvqzzDh7vnrei3o3r3m9blf6ZoDkvcw0VmozNRFJxg==}
engines: {node: '>=12'}
dotenv@16.6.1:
resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==}
engines: {node: '>=12'}
dunder-proto@1.0.1:
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
engines: {node: '>= 0.4'}
@@ -7211,8 +7197,8 @@ packages:
electron-to-chromium@1.5.167:
resolution: {integrity: sha512-LxcRvnYO5ez2bMOFpbuuVuAI5QNeY1ncVytE/KXaL6ZNfzX1yPlAO0nSOyIHx2fVAuUprMqPs/TdVhUFZy7SIQ==}
electron-to-chromium@1.5.187:
resolution: {integrity: sha512-cl5Jc9I0KGUoOoSbxvTywTa40uspGJt/BDBoDLoxJRSBpWh4FFXBsjNRHfQrONsV/OoEjDfHUmZQa2d6Ze4YgA==}
electron-to-chromium@1.5.192:
resolution: {integrity: sha512-rP8Ez0w7UNw/9j5eSXCe10o1g/8B1P5SM90PCCMVkIRQn2R0LEHWz4Eh9RnxkniuDe1W0cTSOB3MLlkTGDcuCg==}
electron-to-chromium@1.5.33:
resolution: {integrity: sha512-+cYTcFB1QqD4j4LegwLfpCNxifb6dDFUAwk6RsLusCwIaZI6or2f+q8rs5tTB2YC53HhOlIbEaqHMAAC8IOIwA==}
@@ -7372,8 +7358,8 @@ packages:
eslint-plugin-n: '^15.0.0 || ^16.0.0 '
eslint-plugin-promise: ^6.0.0
eslint-config-turbo@2.5.4:
resolution: {integrity: sha512-OpjpDLXIaus0N/Y+pMj17K430xjpd6WTo0xPUESqYZ9BkMngv2n0ZdjktgJTbJVnDmK7gHrXgJAljtdIMcYBIg==}
eslint-config-turbo@2.5.5:
resolution: {integrity: sha512-23lKrCr66HQR62aa94n2dBGUUFz0GzM6N1KwmcREZHxomZ5Ik2rDm00wAz95lOEkhzSt6IaxW00Uz0az/Fcq5Q==}
peerDependencies:
eslint: '>6.6.0'
turbo: '>2.0.0'
@@ -7517,8 +7503,8 @@ packages:
eslint-plugin-tsdoc@0.2.17:
resolution: {integrity: sha512-xRmVi7Zx44lOBuYqG8vzTXuL6IdGOeF9nHX17bjJ8+VE6fsxpdGem0/SBTmAwgYMKYB1WBkqRJVQ+n8GK041pA==}
eslint-plugin-turbo@2.5.4:
resolution: {integrity: sha512-IZsW61DFj5mLMMaCJxhh1VE4HvNhfdnHnAaXajgne+LUzdyHk2NvYT0ECSa/1SssArcqgTvV74MrLL68hWLLFw==}
eslint-plugin-turbo@2.5.5:
resolution: {integrity: sha512-IlN65X6W7rgK88u5xl1xC+7FIGKA7eyaca0yxZQ9CBNV6keAaqtjZQLw8ZfXdv7T+MzTLYkYOeOHAv8yCRUx4Q==}
peerDependencies:
eslint: '>6.6.0'
turbo: '>2.0.0'
@@ -7724,9 +7710,6 @@ packages:
fastq@1.17.1:
resolution: {integrity: sha512-sRVD3lWVIXWg6By68ZN7vho9a1pQcN/WBFaAAsDDFzlJjvoGx0P8z7V1t72grFJfJhu3YPZBuu25f7Kaw2jN1w==}
fault@1.0.4:
resolution: {integrity: sha512-CJ0HCB5tL5fYTEA7ToAq5+kTwd++Borf1/bifxd9iT70QcXr4MRrO3Llf8Ifs70q+SJcGHFtnIE/Nw6giCtECA==}
fb-watchman@2.0.2:
resolution: {integrity: sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==}
@@ -7823,10 +7806,6 @@ packages:
resolution: {integrity: sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w==}
engines: {node: '>= 6'}
format@0.2.2:
resolution: {integrity: sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==}
engines: {node: '>=0.4.x'}
formdata-node@4.4.1:
resolution: {integrity: sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ==}
engines: {node: '>= 12.20'}
@@ -8111,18 +8090,12 @@ packages:
resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==}
engines: {node: '>= 0.4'}
hast-util-parse-selector@2.2.5:
resolution: {integrity: sha512-7j6mrk/qqkSehsM92wQjdIgWM2/BW61u/53G6xmC8i1OmEdKLHbk419QKQUjz6LglWsfqoiHmyMRkP1BGjecNQ==}
hast-util-to-jsx-runtime@2.3.0:
resolution: {integrity: sha512-H/y0+IWPdsLLS738P8tDnrQ8Z+dj12zQQ6WC11TIM21C8WFVoIxcqWXf2H3hiTVZjF1AWqoimGwrTWecWrnmRQ==}
hast-util-whitespace@3.0.0:
resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==}
hastscript@6.0.0:
resolution: {integrity: sha512-nDM6bvd7lIqDUiYEiu5Sl/+6ReP0BMk/2f4U/Rooccxkj0P5nm+acM5PrGJ/t5I8qPGiqZSE6hVAwZEdZIvP4w==}
headers-polyfill@4.0.3:
resolution: {integrity: sha512-IScLbePpkvO846sIwOtOTDjutRMWdXdJmXdMvk6gCBHxFO8d+QKOQedyZSxFTTFYRSmlgSTDtXqqq4pcenBXLQ==}
@@ -8130,12 +8103,6 @@ packages:
resolution: {integrity: sha512-g+HZqgfbpXdCkme/Cd/mZkV0aV3BZZZSugecH03kl38m/Kmdx8jKjBikpDj2cr+Iynv4KpYEviojNdTJActJAg==}
engines: {node: '>=16.0.0'}
highlight.js@10.7.3:
resolution: {integrity: sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==}
highlightjs-vue@1.0.0:
resolution: {integrity: sha512-PDEfEF102G23vHmPhLyPboFCD+BkMGu+GuJe2d9/eH4FsCwvgBpnc9n0pGE+ffKdph38s6foEZiEjdgHdzp+IA==}
hoist-non-react-statics@3.3.2:
resolution: {integrity: sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==}
@@ -8322,15 +8289,9 @@ packages:
resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==}
engines: {node: '>= 0.10'}
is-alphabetical@1.0.4:
resolution: {integrity: sha512-DwzsA04LQ10FHTZuL0/grVDk4rFoVH1pjAToYwBrHSxcrBIGQuXrQMtD5U1b0U2XVgKZCTLLP8u2Qxqhy3l2Vg==}
is-alphabetical@2.0.1:
resolution: {integrity: sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==}
is-alphanumerical@1.0.4:
resolution: {integrity: sha512-UzoZUr+XfVz3t3v4KyGEniVL9BDRoQtY7tOyrRybkVNjDFWyo1yhXNGrrBTQxp3ib9BLAWs7k2YKBQsFRkZG9A==}
is-alphanumerical@2.0.1:
resolution: {integrity: sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==}
@@ -8383,9 +8344,6 @@ packages:
resolution: {integrity: sha512-9YQaSxsAiSwcvS33MBk3wTCVnWK+HhF8VZR2jRxehM16QcVOdHqPn4VPHmRK4lSr38n9JriurInLcP90xsYNfQ==}
engines: {node: '>= 0.4'}
is-decimal@1.0.4:
resolution: {integrity: sha512-RGdriMmQQvZ2aqaQq3awNA6dCGtKpiDFcOzrTWrDAT2MiWrKQVPmxLGHl7Y2nNu6led0kEyoX0enY0qXYsv9zw==}
is-decimal@2.0.1:
resolution: {integrity: sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==}
@@ -8422,9 +8380,6 @@ packages:
resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==}
engines: {node: '>=0.10.0'}
is-hexadecimal@1.0.4:
resolution: {integrity: sha512-gyPJuv83bHMpocVYoqof5VDiZveEoGoFL8m3BXNb2VW8Xs+rz9kqO8LOQ5DH6EsuvilT1ApazU0pyl+ytbPtlw==}
is-hexadecimal@2.0.1:
resolution: {integrity: sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==}
@@ -9156,9 +9111,6 @@ packages:
loupe@3.1.4:
resolution: {integrity: sha512-wJzkKwJrheKtknCOKNEtDK4iqg/MxmZheEMtSTYvnzRdEYaZzmgH976nenp8WdJRdx5Vc1X/9MO0Oszl6ezeXg==}
lowlight@1.20.0:
resolution: {integrity: sha512-8Ktj+prEb1RoCPkEOrPMYUN/nCggB7qAWe3a7OpMjWQkh3l2RD5wKRQ+o8Q8YuI9RG/xs95waaI/E6ym/7NsTw==}
lru-cache@10.4.3:
resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==}
@@ -9946,9 +9898,6 @@ packages:
resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
engines: {node: '>=6'}
parse-entities@2.0.0:
resolution: {integrity: sha512-kkywGpCcRYhqQIchaWqZ875wzpS/bMKhz5HnN3p7wveJTkTtyAB/AlnS0f8DFSqYW1T82t6yEAkEcB+A1I3MbQ==}
parse-entities@4.0.1:
resolution: {integrity: sha512-SWzvYcSJh4d/SGLIOQfZ/CoNv6BTlI6YEQ7Nj82oDVnRpwe/Z/F1EMx42x3JAOwGBlCjeCH0BRJQbQ/opHL17w==}
@@ -10341,6 +10290,11 @@ packages:
prexit@2.2.0:
resolution: {integrity: sha512-MrkWg5/0XFTM0txQtPFZlhk3qhMtyd+bP1vXJKQqOOYcdQ8/Wl5+G0Utxv69TLfH0PMm/ushn0YTYQ2SH2WzJg==}
prism-react-renderer@2.4.1:
resolution: {integrity: sha512-ey8Ls/+Di31eqzUxC46h8MksNuGx/n0AAC8uKpwFau4RPDYLuE3EXTp8N8G2vX2N7UC/+IXeNUnlWBGGcAG+Ig==}
peerDependencies:
react: '>=16.0.0'
prisma-erd-generator@1.11.2:
resolution: {integrity: sha512-abo2EBIjqJALfjm8p+uOsQk38DlU3rlLJA9/cOvcLLt8A9bjMkRwlqaZDAD/iXVZcXJRJPgJ98fEviTa+w8oTQ==}
engines: {node: '>=14'}
@@ -10367,10 +10321,6 @@ packages:
typescript:
optional: true
prismjs@1.27.0:
resolution: {integrity: sha512-t13BGPUlFDR7wRB5kQDG4jjl7XeuH6jbJGt11JHPL96qwsEHNX2+68tFXqc1/k+/jALsbSWJKUOT/hcYAZ5LkA==}
engines: {node: '>=6'}
prismjs@1.30.0:
resolution: {integrity: sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==}
engines: {node: '>=6'}
@@ -10389,9 +10339,6 @@ packages:
prop-types@15.8.1:
resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==}
property-information@5.6.0:
resolution: {integrity: sha512-YUHSPk+A30YPv+0Qf8i9Mbfe/C0hdPXk1s1jPVToV8pk8BQtpw10ct89Eo7OWkutrwqvT0eicAxlOg3dOAu8JA==}
property-information@6.5.0:
resolution: {integrity: sha512-PgTgs/BlvHxOu8QuEN7wi5A0OmXaBcHpmCSTehcs6Uuu9IkDIEo13Hy7n898RHfrQ49vKCoGeWZSaAK01nwVig==}
@@ -10524,8 +10471,8 @@ packages:
react-is@18.3.1:
resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==}
react-is@19.1.0:
resolution: {integrity: sha512-Oe56aUPnkHyyDxxkvqtd7KkdQP5uIUfHxd5XTb3wE9d/kRnZLmKbDB0GWk919tdQ+mxxPtG6EAs6RMT6i1qtHg==}
react-is@19.1.1:
resolution: {integrity: sha512-tr41fA15Vn8p4X9ntI+yCyeGSf1TlYaY5vlTZfQmeLBrFo3psOPX6HhTDnFNL9uj3EhP0KAQ80cugCl4b4BERA==}
react-markdown@9.0.1:
resolution: {integrity: sha512-186Gw/vF1uRkydbsOIkcGXw7aHq0sZOCRFFjGrr7b9+nVZg4UfA4enXCaxm4fUzecU38sWfrNDitGhshuU7rdg==}
@@ -10589,11 +10536,6 @@ packages:
'@types/react':
optional: true
react-syntax-highlighter@15.6.1:
resolution: {integrity: sha512-OqJ2/vL7lEeV5zTJyG7kmARppUjiB9h9udl4qHQjjgEos66z00Ia0OckwYfRxCSFrW8RJIBnsBwQsHZbVPspqg==}
peerDependencies:
react: '>= 0.14.0'
react-transition-group@4.4.5:
resolution: {integrity: sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==}
peerDependencies:
@@ -10674,9 +10616,6 @@ packages:
resolution: {integrity: sha512-fmfw4XgoDke3kdI6h4xcUz1dG8uaiv5q9gcEwLS4Pnth2kxT+GZ7YehS1JTMGBQmtV7Y4GFGbs2re2NqhdozUg==}
engines: {node: '>= 0.4'}
refractor@3.6.0:
resolution: {integrity: sha512-MY9W41IOWxxk31o+YvFCNyNzdkc9M20NoZK5vq6jkv4I/uh2zkWcfudj0Q1fovjUQJrNewS9NMzeTtqPf+n5EA==}
regenerator-runtime@0.14.1:
resolution: {integrity: sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==}
@@ -11080,9 +11019,6 @@ packages:
resolution: {integrity: sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA==}
engines: {node: '>= 8'}
space-separated-tokens@1.1.5:
resolution: {integrity: sha512-q/JSVd1Lptzhf5bkYm4ob4iWPjx0KiRe3sRFBNrVqbJkFaBm5vbbowy1mymoPNLRa52+oadOhJ+K49wsSeSjTA==}
space-separated-tokens@2.0.2:
resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==}
@@ -11581,38 +11517,38 @@ packages:
ttl-set@1.0.0:
resolution: {integrity: sha512-2fuHn/UR+8Z9HK49r97+p2Ru1b5Eewg2QqPrU14BVCQ9QoyU3+vLLZk2WEiyZ9sgJh6W8G1cZr9I2NBLywAHrA==}
turbo-darwin-64@2.5.4:
resolution: {integrity: sha512-ah6YnH2dErojhFooxEzmvsoZQTMImaruZhFPfMKPBq8sb+hALRdvBNLqfc8NWlZq576FkfRZ/MSi4SHvVFT9PQ==}
turbo-darwin-64@2.5.5:
resolution: {integrity: sha512-RYnTz49u4F5tDD2SUwwtlynABNBAfbyT2uU/brJcyh5k6lDLyNfYKdKmqd3K2ls4AaiALWrFKVSBsiVwhdFNzQ==}
cpu: [x64]
os: [darwin]
turbo-darwin-arm64@2.5.4:
resolution: {integrity: sha512-2+Nx6LAyuXw2MdXb7pxqle3MYignLvS7OwtsP9SgtSBaMlnNlxl9BovzqdYAgkUW3AsYiQMJ/wBRb7d+xemM5A==}
turbo-darwin-arm64@2.5.5:
resolution: {integrity: sha512-Tk+ZeSNdBobZiMw9aFypQt0DlLsWSFWu1ymqsAdJLuPoAH05qCfYtRxE1pJuYHcJB5pqI+/HOxtJoQ40726Btw==}
cpu: [arm64]
os: [darwin]
turbo-linux-64@2.5.4:
resolution: {integrity: sha512-5May2kjWbc8w4XxswGAl74GZ5eM4Gr6IiroqdLhXeXyfvWEdm2mFYCSWOzz0/z5cAgqyGidF1jt1qzUR8hTmOA==}
turbo-linux-64@2.5.5:
resolution: {integrity: sha512-2/XvMGykD7VgsvWesZZYIIVXMlgBcQy+ZAryjugoTcvJv8TZzSU/B1nShcA7IAjZ0q7OsZ45uP2cOb8EgKT30w==}
cpu: [x64]
os: [linux]
turbo-linux-arm64@2.5.4:
resolution: {integrity: sha512-/2yqFaS3TbfxV3P5yG2JUI79P7OUQKOUvAnx4MV9Bdz6jqHsHwc9WZPpO4QseQm+NvmgY6ICORnoVPODxGUiJg==}
turbo-linux-arm64@2.5.5:
resolution: {integrity: sha512-DW+8CjCjybu0d7TFm9dovTTVg1VRnlkZ1rceO4zqsaLrit3DgHnN4to4uwyuf9s2V/BwS3IYcRy+HG9BL596Iw==}
cpu: [arm64]
os: [linux]
turbo-windows-64@2.5.4:
resolution: {integrity: sha512-EQUO4SmaCDhO6zYohxIjJpOKRN3wlfU7jMAj3CgcyTPvQR/UFLEKAYHqJOnJtymbQmiiM/ihX6c6W6Uq0yC7mA==}
turbo-windows-64@2.5.5:
resolution: {integrity: sha512-q5p1BOy8ChtSZfULuF1BhFMYIx6bevXu4fJ+TE/hyNfyHJIfjl90Z6jWdqAlyaFLmn99X/uw+7d6T/Y/dr5JwQ==}
cpu: [x64]
os: [win32]
turbo-windows-arm64@2.5.4:
resolution: {integrity: sha512-oQ8RrK1VS8lrxkLriotFq+PiF7iiGgkZtfLKF4DDKsmdbPo0O9R2mQxm7jHLuXraRCuIQDWMIw6dpcr7Iykf4A==}
turbo-windows-arm64@2.5.5:
resolution: {integrity: sha512-AXbF1KmpHUq3PKQwddMGoKMYhHsy5t1YBQO8HZ04HLMR0rWv9adYlQ8kaeQJTko1Ay1anOBFTqaxfVOOsu7+1Q==}
cpu: [arm64]
os: [win32]
turbo@2.5.4:
resolution: {integrity: sha512-kc8ZibdRcuWUG1pbYSBFWqmIjynlD8Lp7IB6U3vIzvOv9VG+6Sp8bzyeBWE3Oi8XV5KsQrznyRTBPvrf99E4mA==}
turbo@2.5.5:
resolution: {integrity: sha512-eZ7wI6KjtT1eBqCnh2JPXWNUAxtoxxfi6VdBdZFvil0ychCOTxbm7YLRBi1JSt7U3c+u3CLxpoPxLdvr/Npr3A==}
hasBin: true
type-check@0.4.0:
@@ -13589,7 +13525,7 @@ snapshots:
'@babel/generator@7.28.0':
dependencies:
'@babel/parser': 7.28.0
'@babel/types': 7.28.1
'@babel/types': 7.28.2
'@jridgewell/gen-mapping': 0.3.12
'@jridgewell/trace-mapping': 0.3.29
jsesc: 3.1.0
@@ -13624,7 +13560,7 @@ snapshots:
'@babel/helper-module-imports@7.27.1':
dependencies:
'@babel/traverse': 7.28.0
'@babel/types': 7.28.1
'@babel/types': 7.28.2
transitivePeerDependencies:
- supports-color
optional: true
@@ -13681,7 +13617,7 @@ snapshots:
'@babel/parser@7.28.0':
dependencies:
'@babel/types': 7.28.1
'@babel/types': 7.28.2
'@babel/plugin-syntax-async-generators@7.8.4(@babel/core@7.24.3)':
dependencies:
@@ -13769,7 +13705,7 @@ snapshots:
dependencies:
regenerator-runtime: 0.14.1
'@babel/runtime@7.27.6': {}
'@babel/runtime@7.28.2': {}
'@babel/template@7.24.0':
dependencies:
@@ -13781,7 +13717,7 @@ snapshots:
dependencies:
'@babel/code-frame': 7.27.1
'@babel/parser': 7.28.0
'@babel/types': 7.28.1
'@babel/types': 7.28.2
optional: true
'@babel/traverse@7.24.1':
@@ -13806,7 +13742,7 @@ snapshots:
'@babel/helper-globals': 7.28.0
'@babel/parser': 7.28.0
'@babel/template': 7.27.2
'@babel/types': 7.28.1
'@babel/types': 7.28.2
debug: 4.4.1
transitivePeerDependencies:
- supports-color
@@ -13818,7 +13754,7 @@ snapshots:
'@babel/helper-validator-identifier': 7.24.5
to-fast-properties: 2.0.0
'@babel/types@7.28.1':
'@babel/types@7.28.2':
dependencies:
'@babel/helper-string-parser': 7.27.1
'@babel/helper-validator-identifier': 7.27.1
@@ -13880,11 +13816,11 @@ snapshots:
'@chevrotain/utils@11.0.3': {}
'@clickhouse/client-common@1.11.2': {}
'@clickhouse/client-common@1.12.0': {}
'@clickhouse/client@1.11.2':
'@clickhouse/client@1.12.0':
dependencies:
'@clickhouse/client-common': 1.11.2
'@clickhouse/client-common': 1.12.0
'@codemirror/autocomplete@6.17.0(@codemirror/language@6.11.2)(@codemirror/state@6.5.2)(@codemirror/view@6.38.1)(@lezer/common@1.2.3)':
dependencies:
@@ -14027,7 +13963,7 @@ snapshots:
'@emotion/babel-plugin@11.13.5':
dependencies:
'@babel/helper-module-imports': 7.27.1
'@babel/runtime': 7.27.6
'@babel/runtime': 7.28.2
'@emotion/hash': 0.9.2
'@emotion/memoize': 0.9.0
'@emotion/serialize': 1.3.3
@@ -14060,7 +13996,7 @@ snapshots:
'@emotion/react@11.11.4(@types/react@18.2.79)(react@18.2.0)':
dependencies:
'@babel/runtime': 7.27.6
'@babel/runtime': 7.28.2
'@emotion/babel-plugin': 11.13.5
'@emotion/cache': 11.14.0
'@emotion/serialize': 1.3.3
@@ -14087,7 +14023,7 @@ snapshots:
'@emotion/styled@11.11.5(@emotion/react@11.11.4(@types/react@18.2.79)(react@18.2.0))(@types/react@18.2.79)(react@18.2.0)':
dependencies:
'@babel/runtime': 7.27.6
'@babel/runtime': 7.28.2
'@emotion/babel-plugin': 11.13.5
'@emotion/is-prop-valid': 1.3.1
'@emotion/react': 11.11.4(@types/react@18.2.79)(react@18.2.0)
@@ -15019,7 +14955,7 @@ snapshots:
'@mui/base@5.0.0-beta.40(@types/react@18.2.79)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)':
dependencies:
'@babel/runtime': 7.27.6
'@babel/runtime': 7.28.2
'@floating-ui/react-dom': 2.1.4(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
'@mui/types': 7.4.4(@types/react@18.2.79)
'@mui/utils': 5.17.1(@types/react@18.2.79)(react@18.2.0)
@@ -15035,7 +14971,7 @@ snapshots:
'@mui/material@5.15.19(@emotion/react@11.11.4(@types/react@18.2.79)(react@18.2.0))(@emotion/styled@11.11.5(@emotion/react@11.11.4(@types/react@18.2.79)(react@18.2.0))(@types/react@18.2.79)(react@18.2.0))(@types/react@18.2.79)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)':
dependencies:
'@babel/runtime': 7.27.6
'@babel/runtime': 7.28.2
'@mui/base': 5.0.0-beta.40(@types/react@18.2.79)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
'@mui/core-downloads-tracker': 5.18.0
'@mui/system': 5.16.7(@emotion/react@11.11.4(@types/react@18.2.79)(react@18.2.0))(@emotion/styled@11.11.5(@emotion/react@11.11.4(@types/react@18.2.79)(react@18.2.0))(@types/react@18.2.79)(react@18.2.0))(@types/react@18.2.79)(react@18.2.0)
@@ -15056,7 +14992,7 @@ snapshots:
'@mui/private-theming@5.17.1(@types/react@18.2.79)(react@18.2.0)':
dependencies:
'@babel/runtime': 7.27.6
'@babel/runtime': 7.28.2
'@mui/utils': 5.17.1(@types/react@18.2.79)(react@18.2.0)
prop-types: 15.8.1
react: 18.2.0
@@ -15065,7 +15001,7 @@ snapshots:
'@mui/styled-engine@5.18.0(@emotion/react@11.11.4(@types/react@18.2.79)(react@18.2.0))(@emotion/styled@11.11.5(@emotion/react@11.11.4(@types/react@18.2.79)(react@18.2.0))(@types/react@18.2.79)(react@18.2.0))(react@18.2.0)':
dependencies:
'@babel/runtime': 7.27.6
'@babel/runtime': 7.28.2
'@emotion/cache': 11.14.0
'@emotion/serialize': 1.3.3
csstype: 3.1.3
@@ -15077,7 +15013,7 @@ snapshots:
'@mui/system@5.16.7(@emotion/react@11.11.4(@types/react@18.2.79)(react@18.2.0))(@emotion/styled@11.11.5(@emotion/react@11.11.4(@types/react@18.2.79)(react@18.2.0))(@types/react@18.2.79)(react@18.2.0))(@types/react@18.2.79)(react@18.2.0)':
dependencies:
'@babel/runtime': 7.27.6
'@babel/runtime': 7.28.2
'@mui/private-theming': 5.17.1(@types/react@18.2.79)(react@18.2.0)
'@mui/styled-engine': 5.18.0(@emotion/react@11.11.4(@types/react@18.2.79)(react@18.2.0))(@emotion/styled@11.11.5(@emotion/react@11.11.4(@types/react@18.2.79)(react@18.2.0))(@types/react@18.2.79)(react@18.2.0))(react@18.2.0)
'@mui/types': 7.4.4(@types/react@18.2.79)
@@ -15101,7 +15037,7 @@ snapshots:
'@mui/types@7.4.4(@types/react@18.2.79)':
dependencies:
'@babel/runtime': 7.27.6
'@babel/runtime': 7.28.2
optionalDependencies:
'@types/react': 18.2.79
@@ -15119,13 +15055,13 @@ snapshots:
'@mui/utils@5.17.1(@types/react@18.2.79)(react@18.2.0)':
dependencies:
'@babel/runtime': 7.27.6
'@babel/runtime': 7.28.2
'@mui/types': 7.2.24(@types/react@18.2.79)
'@types/prop-types': 15.7.15
clsx: 2.1.1
prop-types: 15.8.1
react: 18.2.0
react-is: 19.1.0
react-is: 19.1.1
optionalDependencies:
'@types/react': 18.2.79
@@ -15159,7 +15095,7 @@ snapshots:
'@next-auth/prisma-adapter@1.0.7(@prisma/client@6.10.1(prisma@6.10.1(typescript@5.4.5))(typescript@5.4.5))(next-auth@4.24.11(patch_hash=cczsf6i6qe2m2htirvgxjtoclu)(next@14.2.30(@babel/core@7.24.3)(@opentelemetry/api@1.9.0)(@playwright/test@1.47.2)(babel-plugin-macros@3.1.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(nodemailer@6.9.15)(react-dom@18.2.0(react@18.2.0))(react@18.2.0))':
dependencies:
'@prisma/client': 6.10.1(prisma@6.10.1(typescript@5.4.5))(typescript@5.4.5)
next-auth: 4.24.11(patch_hash=cczsf6i6qe2m2htirvgxjtoclu)(next@14.2.30(@opentelemetry/api@1.9.0)(@playwright/test@1.47.2)(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(nodemailer@6.9.15)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
next-auth: 4.24.11(patch_hash=cczsf6i6qe2m2htirvgxjtoclu)(next@14.2.30(@babel/core@7.24.3)(@opentelemetry/api@1.9.0)(@playwright/test@1.47.2)(babel-plugin-macros@3.1.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(nodemailer@6.9.15)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
'@next/bundle-analyzer@15.4.1':
dependencies:
@@ -16959,7 +16895,7 @@ snapshots:
'@babel/core': 7.24.3
'@sentry/babel-plugin-component-annotate': 2.22.7
'@sentry/cli': 2.39.1
dotenv: 16.5.0
dotenv: 16.6.1
find-up: 5.0.0
glob: 9.3.5
magic-string: 0.30.8
@@ -18065,10 +18001,6 @@ snapshots:
'@types/hammerjs@2.0.46': {}
'@types/hast@2.3.10':
dependencies:
'@types/unist': 2.0.10
'@types/hast@3.0.4':
dependencies:
'@types/unist': 3.0.2
@@ -18160,6 +18092,8 @@ snapshots:
pg-protocol: 1.7.0
pg-types: 2.2.0
'@types/prismjs@1.26.5': {}
'@types/prop-types@15.7.12': {}
'@types/prop-types@15.7.13': {}
@@ -18178,10 +18112,6 @@ snapshots:
dependencies:
'@types/react': 18.2.79
'@types/react-syntax-highlighter@15.5.13':
dependencies:
'@types/react': 18.2.79
'@types/react-transition-group@4.4.11':
dependencies:
'@types/react': 18.2.79
@@ -18495,9 +18425,9 @@ snapshots:
'@codemirror/state': 6.5.2
'@codemirror/view': 6.38.1
'@uiw/react-codemirror@4.24.1(@babel/runtime@7.27.6)(@codemirror/autocomplete@6.17.0(@codemirror/language@6.11.2)(@codemirror/state@6.5.2)(@codemirror/view@6.38.1)(@lezer/common@1.2.3))(@codemirror/language@6.11.2)(@codemirror/lint@6.8.5)(@codemirror/search@6.5.6)(@codemirror/state@6.5.2)(@codemirror/theme-one-dark@6.1.2)(@codemirror/view@6.38.1)(codemirror@6.0.1(@lezer/common@1.2.3))(react-dom@18.2.0(react@18.2.0))(react@18.2.0)':
'@uiw/react-codemirror@4.24.1(@babel/runtime@7.28.2)(@codemirror/autocomplete@6.17.0(@codemirror/language@6.11.2)(@codemirror/state@6.5.2)(@codemirror/view@6.38.1)(@lezer/common@1.2.3))(@codemirror/language@6.11.2)(@codemirror/lint@6.8.5)(@codemirror/search@6.5.6)(@codemirror/state@6.5.2)(@codemirror/theme-one-dark@6.1.2)(@codemirror/view@6.38.1)(codemirror@6.0.1(@lezer/common@1.2.3))(react-dom@18.2.0(react@18.2.0))(react@18.2.0)':
dependencies:
'@babel/runtime': 7.27.6
'@babel/runtime': 7.28.2
'@codemirror/commands': 6.8.1
'@codemirror/state': 6.5.2
'@codemirror/theme-one-dark': 6.1.2
@@ -19088,7 +19018,7 @@ snapshots:
babel-plugin-macros@3.1.0:
dependencies:
'@babel/runtime': 7.27.6
'@babel/runtime': 7.28.2
cosmiconfig: 7.1.0
resolve: 1.22.10
optional: true
@@ -19196,7 +19126,7 @@ snapshots:
browserslist@4.25.1:
dependencies:
caniuse-lite: 1.0.30001727
electron-to-chromium: 1.5.187
electron-to-chromium: 1.5.192
node-releases: 2.0.19
update-browserslist-db: 1.1.3(browserslist@4.25.1)
@@ -19331,16 +19261,10 @@ snapshots:
character-entities-html4@2.1.0: {}
character-entities-legacy@1.1.4: {}
character-entities-legacy@3.0.0: {}
character-entities@1.2.4: {}
character-entities@2.0.2: {}
character-reference-invalid@1.1.4: {}
character-reference-invalid@2.0.1: {}
chardet@0.7.0: {}
@@ -19549,8 +19473,6 @@ snapshots:
dependencies:
delayed-stream: 1.0.0
comma-separated-tokens@1.0.8: {}
comma-separated-tokens@2.0.3: {}
commander@10.0.1: {}
@@ -20218,6 +20140,8 @@ snapshots:
dotenv@16.5.0: {}
dotenv@16.6.1: {}
dunder-proto@1.0.1:
dependencies:
call-bind-apply-helpers: 1.0.2
@@ -20245,7 +20169,7 @@ snapshots:
electron-to-chromium@1.5.167: {}
electron-to-chromium@1.5.187: {}
electron-to-chromium@1.5.192: {}
electron-to-chromium@1.5.33: {}
@@ -20501,11 +20425,11 @@ snapshots:
eslint-plugin-n: 16.6.2(eslint@8.57.0)
eslint-plugin-promise: 6.4.0(eslint@8.57.0)
eslint-config-turbo@2.5.4(eslint@8.57.0)(turbo@2.5.4):
eslint-config-turbo@2.5.5(eslint@8.57.0)(turbo@2.5.5):
dependencies:
eslint: 8.57.0
eslint-plugin-turbo: 2.5.4(eslint@8.57.0)(turbo@2.5.4)
turbo: 2.5.4
eslint-plugin-turbo: 2.5.5(eslint@8.57.0)(turbo@2.5.5)
turbo: 2.5.5
eslint-import-resolver-alias@1.1.2(eslint-plugin-import@2.29.1(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.4.5))(eslint@8.57.0)):
dependencies:
@@ -20750,11 +20674,11 @@ snapshots:
'@microsoft/tsdoc': 0.14.2
'@microsoft/tsdoc-config': 0.16.2
eslint-plugin-turbo@2.5.4(eslint@8.57.0)(turbo@2.5.4):
eslint-plugin-turbo@2.5.5(eslint@8.57.0)(turbo@2.5.5):
dependencies:
dotenv: 16.0.3
eslint: 8.57.0
turbo: 2.5.4
turbo: 2.5.5
eslint-plugin-unicorn@51.0.1(eslint@8.57.0):
dependencies:
@@ -21038,10 +20962,6 @@ snapshots:
dependencies:
reusify: 1.0.4
fault@1.0.4:
dependencies:
format: 0.2.2
fb-watchman@2.0.2:
dependencies:
bser: 2.1.1
@@ -21140,8 +21060,6 @@ snapshots:
es-set-tostringtag: 2.1.0
mime-types: 2.1.35
format@0.2.2: {}
formdata-node@4.4.1:
dependencies:
node-domexception: 1.0.0
@@ -21469,8 +21387,6 @@ snapshots:
dependencies:
function-bind: 1.1.2
hast-util-parse-selector@2.2.5: {}
hast-util-to-jsx-runtime@2.3.0:
dependencies:
'@types/estree': 1.0.6
@@ -21495,22 +21411,10 @@ snapshots:
dependencies:
'@types/hast': 3.0.4
hastscript@6.0.0:
dependencies:
'@types/hast': 2.3.10
comma-separated-tokens: 1.0.8
hast-util-parse-selector: 2.2.5
property-information: 5.6.0
space-separated-tokens: 1.1.5
headers-polyfill@4.0.3: {}
helmet@7.1.0: {}
highlight.js@10.7.3: {}
highlightjs-vue@1.0.0: {}
hoist-non-react-statics@3.3.2:
dependencies:
react-is: 16.13.1
@@ -21730,15 +21634,8 @@ snapshots:
ipaddr.js@1.9.1: {}
is-alphabetical@1.0.4: {}
is-alphabetical@2.0.1: {}
is-alphanumerical@1.0.4:
dependencies:
is-alphabetical: 1.0.4
is-decimal: 1.0.4
is-alphanumerical@2.0.1:
dependencies:
is-alphabetical: 2.0.1
@@ -21792,8 +21689,6 @@ snapshots:
dependencies:
has-tostringtag: 1.0.2
is-decimal@1.0.4: {}
is-decimal@2.0.1: {}
is-docker@2.2.1: {}
@@ -21818,8 +21713,6 @@ snapshots:
dependencies:
is-extglob: 2.1.1
is-hexadecimal@1.0.4: {}
is-hexadecimal@2.0.1: {}
is-inside-container@1.0.0:
@@ -22723,11 +22616,6 @@ snapshots:
loupe@3.1.4: {}
lowlight@1.20.0:
dependencies:
fault: 1.0.4
highlight.js: 10.7.3
lru-cache@10.4.3: {}
lru-cache@5.1.1:
@@ -23344,7 +23232,7 @@ snapshots:
dependencies:
type-fest: 2.19.0
next-auth@4.24.11(patch_hash=cczsf6i6qe2m2htirvgxjtoclu)(next@14.2.30(@opentelemetry/api@1.9.0)(@playwright/test@1.47.2)(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(nodemailer@6.9.15)(react-dom@18.2.0(react@18.2.0))(react@18.2.0):
next-auth@4.24.11(patch_hash=cczsf6i6qe2m2htirvgxjtoclu)(next@14.2.30(@babel/core@7.24.3)(@opentelemetry/api@1.9.0)(@playwright/test@1.47.2)(babel-plugin-macros@3.1.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(nodemailer@6.9.15)(react-dom@18.2.0(react@18.2.0))(react@18.2.0):
dependencies:
'@babel/runtime': 7.26.0
'@panva/hkdf': 1.1.1
@@ -23754,15 +23642,6 @@ snapshots:
dependencies:
callsites: 3.1.0
parse-entities@2.0.0:
dependencies:
character-entities: 1.2.4
character-entities-legacy: 1.1.4
character-reference-invalid: 1.1.4
is-alphanumerical: 1.0.4
is-decimal: 1.0.4
is-hexadecimal: 1.0.4
parse-entities@4.0.1:
dependencies:
'@types/unist': 2.0.10
@@ -24087,6 +23966,12 @@ snapshots:
prexit@2.2.0: {}
prism-react-renderer@2.4.1(react@18.2.0):
dependencies:
'@types/prismjs': 1.26.5
clsx: 2.1.1
react: 18.2.0
prisma-erd-generator@1.11.2(@prisma/client@6.10.1(prisma@6.10.1(typescript@5.4.5))(typescript@5.4.5))(typescript@5.4.5):
dependencies:
'@mermaid-js/mermaid-cli': 10.8.0(typescript@5.4.5)
@@ -24122,8 +24007,6 @@ snapshots:
optionalDependencies:
typescript: 5.4.5
prismjs@1.27.0: {}
prismjs@1.30.0: {}
process-nextick-args@2.0.1: {}
@@ -24141,10 +24024,6 @@ snapshots:
object-assign: 4.1.1
react-is: 16.13.1
property-information@5.6.0:
dependencies:
xtend: 4.0.2
property-information@6.5.0: {}
protobufjs@7.4.0:
@@ -24319,7 +24198,7 @@ snapshots:
react-is@18.3.1: {}
react-is@19.1.0: {}
react-is@19.1.1: {}
react-markdown@9.0.1(@types/react@18.2.79)(react@18.2.0):
dependencies:
@@ -24398,16 +24277,6 @@ snapshots:
optionalDependencies:
'@types/react': 18.2.79
react-syntax-highlighter@15.6.1(react@18.2.0):
dependencies:
'@babel/runtime': 7.27.6
highlight.js: 10.7.3
highlightjs-vue: 1.0.0
lowlight: 1.20.0
prismjs: 1.30.0
react: 18.2.0
refractor: 3.6.0
react-transition-group@4.4.5(react-dom@18.2.0(react@18.2.0))(react@18.2.0):
dependencies:
'@babel/runtime': 7.25.7
@@ -24515,12 +24384,6 @@ snapshots:
globalthis: 1.0.4
which-builtin-type: 1.1.3
refractor@3.6.0:
dependencies:
hastscript: 6.0.0
parse-entities: 2.0.0
prismjs: 1.27.0
regenerator-runtime@0.14.1: {}
regexp-to-ast@0.5.0: {}
@@ -25026,8 +24889,6 @@ snapshots:
source-map@0.7.4: {}
space-separated-tokens@1.1.5: {}
space-separated-tokens@2.0.2: {}
spdx-correct@3.2.0:
@@ -25552,32 +25413,32 @@ snapshots:
dependencies:
fast-fifo: 1.3.2
turbo-darwin-64@2.5.4:
turbo-darwin-64@2.5.5:
optional: true
turbo-darwin-arm64@2.5.4:
turbo-darwin-arm64@2.5.5:
optional: true
turbo-linux-64@2.5.4:
turbo-linux-64@2.5.5:
optional: true
turbo-linux-arm64@2.5.4:
turbo-linux-arm64@2.5.5:
optional: true
turbo-windows-64@2.5.4:
turbo-windows-64@2.5.5:
optional: true
turbo-windows-arm64@2.5.4:
turbo-windows-arm64@2.5.5:
optional: true
turbo@2.5.4:
turbo@2.5.5:
optionalDependencies:
turbo-darwin-64: 2.5.4
turbo-darwin-arm64: 2.5.4
turbo-linux-64: 2.5.4
turbo-linux-arm64: 2.5.4
turbo-windows-64: 2.5.4
turbo-windows-arm64: 2.5.4
turbo-darwin-64: 2.5.5
turbo-darwin-arm64: 2.5.5
turbo-linux-64: 2.5.5
turbo-linux-arm64: 2.5.5
turbo-windows-64: 2.5.5
turbo-windows-arm64: 2.5.5
type-check@0.4.0:
dependencies:
+1 -1
View File
@@ -5,7 +5,7 @@ FROM --platform=${TARGETPLATFORM:-linux/amd64} node:20-alpine AS alpine
RUN apk update && apk upgrade --no-cache libcrypto3 libssl3 libc6-compat busybox ssl_client
FROM --platform=${TARGETPLATFORM:-linux/amd64} alpine AS base
RUN npm install turbo@^2.5.4 --global
RUN npm install turbo@^2.5.5 --global
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable
+2 -3
View File
@@ -1,6 +1,6 @@
{
"name": "web",
"version": "3.85.2",
"version": "3.87.0",
"private": true,
"license": "MIT",
"engines": {
@@ -125,6 +125,7 @@
"posthog-js": "^1.176.0",
"posthog-node": "^4.3.1",
"prexit": "^2.2.0",
"prism-react-renderer": "^2.4.1",
"prisma": "^6.10.1",
"protobufjs": "^7.4.0",
"rate-limiter-flexible": "^5.0.3",
@@ -137,7 +138,6 @@
"react-markdown": "^9.0.1",
"react-resizable-panels": "^2.1.1",
"react-responsive": "^10.0.0",
"react-syntax-highlighter": "^15.6.1",
"react18-json-view": "^0.2.8-canary.6",
"recharts": "^2.15.2",
"remark-gfm": "^4.0.0",
@@ -178,7 +178,6 @@
"@types/react": "~18.2.79",
"@types/react-dom": "~18.2.25",
"@types/react-grid-layout": "^1.3.5",
"@types/react-syntax-highlighter": "^15.5.13",
"@types/uuid": "^9.0.8",
"@typescript-eslint/eslint-plugin": "^6.21.0",
"@typescript-eslint/parser": "^7.12.0",
@@ -1780,6 +1780,199 @@ describe("automations trpc", () => {
expect(response.count).toBe(3);
});
it("should return 0 consecutive failures when lastFailingExecutionId is set", async () => {
const { project, caller } = await prepare();
// Create automation
const trigger = await prisma.trigger.create({
data: {
id: v4(),
projectId: project.id,
eventSource: "prompt",
eventActions: ["created"],
filter: [],
status: JobConfigState.INACTIVE, // Disabled due to failures
},
});
const { secretKey, displaySecretKey } = generateWebhookSecret();
const action = await prisma.action.create({
data: {
id: v4(),
projectId: project.id,
type: "WEBHOOK",
config: {
type: "WEBHOOK",
url: "https://example.com/webhook",
requestHeaders: {
"Content-Type": { secret: false, value: "application/json" },
},
apiVersion: { prompt: "v1" },
secretKey: encrypt(secretKey),
displaySecretKey,
lastFailingExecutionId: "some-failing-execution-id", // This simulates a webhook that was disabled
},
},
});
const automation = await prisma.automation.create({
data: {
projectId: project.id,
triggerId: trigger.id,
actionId: action.id,
name: "Test Automation",
},
});
// Create failed executions that occurred BEFORE the lastFailingExecutionId
await prisma.automationExecution.create({
data: {
id: "some-failing-execution-id",
automationId: automation.id,
projectId: project.id,
triggerId: trigger.id,
actionId: action.id,
status: ActionExecutionStatus.ERROR,
sourceId: v4(),
input: { iteration: 0 },
error: "Old failure",
createdAt: new Date(Date.now() - 60000), // 1 minute ago
},
});
// Create more failed executions that occurred BEFORE the lastFailingExecutionId
for (let i = 1; i < 5; i++) {
await prisma.automationExecution.create({
data: {
id: v4(),
automationId: automation.id,
projectId: project.id,
triggerId: trigger.id,
actionId: action.id,
status: ActionExecutionStatus.ERROR,
sourceId: v4(),
input: { iteration: i },
error: `Old failure ${i}`,
createdAt: new Date(Date.now() - (60000 + i * 1000)), // Before the lastFailingExecutionId
},
});
}
const response = await caller.automations.getCountOfConsecutiveFailures({
projectId: project.id,
automationId: automation.id,
});
// Should return 0 because all failures occurred before the lastFailingExecutionId
expect(response.count).toBe(0);
});
it("should count failures after lastFailingExecutionId correctly", async () => {
const { project, caller } = await prepare();
// Create automation
const trigger = await prisma.trigger.create({
data: {
id: v4(),
projectId: project.id,
eventSource: "prompt",
eventActions: ["created"],
filter: [],
status: JobConfigState.ACTIVE,
},
});
const { secretKey, displaySecretKey } = generateWebhookSecret();
const lastFailingExecutionId = v4();
const action = await prisma.action.create({
data: {
id: v4(),
projectId: project.id,
type: "WEBHOOK",
config: {
type: "WEBHOOK",
url: "https://example.com/webhook",
requestHeaders: {
"Content-Type": { secret: false, value: "application/json" },
},
apiVersion: { prompt: "v1" },
secretKey: encrypt(secretKey),
displaySecretKey,
lastFailingExecutionId,
},
},
});
const automation = await prisma.automation.create({
data: {
projectId: project.id,
triggerId: trigger.id,
actionId: action.id,
name: "Test Automation",
},
});
// Create the lastFailingExecution
await prisma.automationExecution.create({
data: {
id: lastFailingExecutionId,
automationId: automation.id,
projectId: project.id,
triggerId: trigger.id,
actionId: action.id,
status: ActionExecutionStatus.ERROR,
sourceId: v4(),
input: { iteration: 0 },
error: "Last failing execution",
createdAt: new Date(Date.now() - 60000), // 1 minute ago
},
});
// Create old failures that should be ignored
for (let i = 0; i < 3; i++) {
await prisma.automationExecution.create({
data: {
id: v4(),
automationId: automation.id,
projectId: project.id,
triggerId: trigger.id,
actionId: action.id,
status: ActionExecutionStatus.ERROR,
sourceId: v4(),
input: { iteration: i },
error: `Old failure ${i}`,
createdAt: new Date(Date.now() - (120000 + i * 1000)), // Before the lastFailingExecutionId
},
});
}
// Create new failures AFTER the lastFailingExecutionId
for (let i = 0; i < 2; i++) {
await prisma.automationExecution.create({
data: {
id: v4(),
automationId: automation.id,
projectId: project.id,
triggerId: trigger.id,
actionId: action.id,
status: ActionExecutionStatus.ERROR,
sourceId: v4(),
input: { iteration: i },
error: `New failure ${i}`,
createdAt: new Date(Date.now() - (30000 - i * 1000)), // After the lastFailingExecutionId
},
});
}
const response = await caller.automations.getCountOfConsecutiveFailures({
projectId: project.id,
automationId: automation.id,
});
// Should return 2 because only the 2 new failures after lastFailingExecutionId should be counted
expect(response.count).toBe(2);
});
});
describe("automations.regenerateWebhookSecret", () => {
@@ -46,7 +46,7 @@ export const LLMApiKeyComponent = (p: {
<Label className="text-xs font-semibold">API key</Label>
<div>
{apiKey ? (
<Link href={`/project/${p.projectId}/settings/api-keys`}>
<Link href={`/project/${p.projectId}/settings/llm-connections`}>
<span className="mr-2 rounded-sm bg-input p-1 text-xs">
{apiKey.displaySecretKey}
</span>
+4 -8
View File
@@ -33,6 +33,7 @@ export type ModelParamsContext = {
modelParams: UIModelParams;
availableProviders: string[];
availableModels: string[];
providerModelCombinations: string[];
updateModelParamValue: <Key extends keyof UIModelParams>(
key: Key,
value: UIModelParams[Key]["value"],
@@ -41,13 +42,14 @@ export type ModelParamsContext = {
formDisabled?: boolean;
modelParamsDescription?: string;
customHeader?: React.ReactNode;
layout?: "vertical" | "compact";
layout?: "compact" | "vertical";
};
export const ModelParameters: React.FC<ModelParamsContext> = ({
modelParams,
availableProviders,
availableModels,
providerModelCombinations,
updateModelParamValue,
setModelParamEnabled,
formDisabled = false,
@@ -168,12 +170,6 @@ export const ModelParameters: React.FC<ModelParamsContext> = ({
if (layout === "compact") {
// Create combined options in "Provider: model" format
// We create combinations of all available providers with all available models
const combinedOptions: string[] = [];
availableProviders.forEach((provider) => {
availableModels.forEach((model) => {
combinedOptions.push(`${provider}: ${model}`);
});
});
// Current combined value in "Provider: model" format
const currentCombinedValue = `${modelParams.provider.value}: ${modelParams.model.value}`;
@@ -202,7 +198,7 @@ export const ModelParameters: React.FC<ModelParamsContext> = ({
<SelectValue />
</SelectTrigger>
<SelectContent>
{combinedOptions.map((option) => (
{(providerModelCombinations ?? []).map((option) => (
<SelectItem value={option} key={option}>
{option}
</SelectItem>
+23 -13
View File
@@ -25,7 +25,8 @@ import {
} from "@/src/components/ui/popover";
import { ScrollArea } from "@/src/components/ui/scroll-area";
import { Label } from "@/src/components/ui/label";
import { type APIScoreV2 } from "@langfuse/shared";
import { AnnotationQueueObjectType, type APIScoreV2 } from "@langfuse/shared";
import { CreateNewAnnotationQueueItem } from "@/src/features/annotation-queues/components/CreateNewAnnotationQueueItem";
// some projects have thousands of traces in a sessions, paginate to avoid rendering all at once
const PAGE_SIZE = 50;
@@ -247,17 +248,26 @@ export const SessionPage: React.FC<{
objectType="SESSION"
count={sessionCommentCounts.data?.get(sessionId)}
/>
<AnnotateDrawer
projectId={projectId}
scoreTarget={{
type: "session",
sessionId,
}}
scores={session.data?.scores ?? []}
emptySelectedConfigIds={emptySelectedConfigIds}
setEmptySelectedConfigIds={setEmptySelectedConfigIds}
buttonVariant="outline"
/>
<div className="flex items-start">
<AnnotateDrawer
projectId={projectId}
scoreTarget={{
type: "session",
sessionId,
}}
scores={session.data?.scores ?? []}
emptySelectedConfigIds={emptySelectedConfigIds}
setEmptySelectedConfigIds={setEmptySelectedConfigIds}
buttonVariant="outline"
hasGroupedButton={true}
/>
<CreateNewAnnotationQueueItem
projectId={projectId}
objectId={sessionId}
objectType={AnnotationQueueObjectType.SESSION}
variant="outline"
/>
</div>
</>
),
}}
@@ -341,7 +351,7 @@ export const SessionPage: React.FC<{
);
};
const SessionIO = ({
export const SessionIO = ({
traceId,
projectId,
timestamp,
+7
View File
@@ -38,6 +38,7 @@ import { type PeekViewProps } from "@/src/components/table/peek/hooks/usePeekVie
import { usePeekView } from "@/src/components/table/peek/hooks/usePeekView";
import { isEqual } from "lodash";
import { useRouter } from "next/router";
import { useColumnSizing } from "@/src/components/table/hooks/useColumnSizing";
interface DataTableProps<TData, TValue> {
columns: LangfuseColumnDef<TData, TValue>[];
@@ -65,6 +66,7 @@ interface DataTableProps<TData, TValue> {
peekView?: PeekViewProps<TData>;
pinFirstColumn?: boolean;
hidePagination?: boolean;
tableName: string;
}
export interface AsyncTableData<T> {
@@ -120,6 +122,7 @@ export function DataTable<TData extends object, TValue>({
peekView,
pinFirstColumn = false,
hidePagination = false,
tableName,
}: DataTableProps<TData, TValue>) {
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
const rowheighttw = getRowHeightTailwindClass(rowHeight, customRowHeights);
@@ -136,6 +139,8 @@ export function DataTable<TData extends object, TValue>({
return flatColumnsByGroup;
}, [columns]);
const { columnSizing, setColumnSizing } = useColumnSizing(tableName);
const table = useReactTable({
data: data.data ?? [],
columns,
@@ -169,7 +174,9 @@ export function DataTable<TData extends object, TValue>({
? insertArrayAfterKey(columnOrder, flattedColumnsByGroup)
: undefined,
rowSelection,
columnSizing,
},
onColumnSizingChange: setColumnSizing,
manualFiltering: true,
defaultColumn: {
minSize: 20,
@@ -0,0 +1,44 @@
import useLocalStorage from "@/src/components/useLocalStorage";
import { type ColumnSizingState } from "@tanstack/react-table";
import { debounce } from "lodash";
import { useEffect, useMemo, useState } from "react";
/**
* Persists table column widths to localStorage with automatic debounced saving.
*
* @param tableId - Unique identifier for the table to scope column settings
* @returns Column sizing state, setter, and utility functions for TanStack Table integration
*
* @note Limitation: Multi-tab sync occurs only on page refresh/navigation, not in real-time.
* @note Column state is initialized once on mount from localStorage.
*/
export const useColumnSizing = (tableId: string) => {
const [storedSizing, setStoredSizing] = useLocalStorage<ColumnSizingState>(
`table-columns-${tableId}`,
{},
);
const [columnSizing, setColumnSizing] =
useState<ColumnSizingState>(storedSizing);
// Debounced storage update
const debouncedSave = useMemo(
() =>
debounce((sizing: ColumnSizingState) => {
if (tableId && Object.keys(sizing).length > 0) {
setStoredSizing(sizing);
}
}, 500),
[tableId, setStoredSizing],
);
// Save to storage when state changes
useEffect(() => {
debouncedSave(columnSizing);
}, [columnSizing, debouncedSave]);
return {
columnSizing,
setColumnSizing,
};
};
@@ -295,6 +295,7 @@ export default function ModelTable({ projectId }: { projectId: string }) {
/>
<SettingsTableCard>
<DataTable
tableName={"models"}
columns={columns}
data={
models.isLoading
@@ -995,6 +995,7 @@ export default function ObservationsTable({
}}
/>
<DataTable
tableName={"observations"}
columns={columns}
peekView={peekConfig}
data={
@@ -254,6 +254,7 @@ export function ScoreConfigsTable({ projectId }: { projectId: string }) {
/>
<SettingsTableCard>
<DataTable
tableName={"scoreConfigs"}
columns={columns}
data={
configs.isLoading
@@ -678,6 +678,7 @@ export default function ScoresTable({
}}
/>
<DataTable
tableName={"scores"}
columns={columns}
data={
scores.isLoading || isViewLoading
@@ -11,6 +11,8 @@ import {
sessionsTableColsWithOptions,
BatchExportTableName,
TableViewPresetTableName,
AnnotationQueueObjectType,
BatchActionType,
} from "@langfuse/shared";
import { useDetailPageLists } from "@/src/features/navigate-detail-pages/context";
import { useOrderByState } from "@/src/features/orderBy/hooks/useOrderByState";
@@ -19,9 +21,8 @@ import { formatIntervalSeconds } from "@/src/utils/dates";
import { numberFormatter, usdFormatter } from "@/src/utils/numbers";
import { type RouterOutput } from "@/src/utils/types";
import type Decimal from "decimal.js";
import { useEffect } from "react";
import { useEffect, useState } from "react";
import { NumberParam, useQueryParams, withDefault } from "use-query-params";
import { BatchExportTableButton } from "@/src/components/BatchExportTableButton";
import { useTableDateRange } from "@/src/hooks/useTableDateRange";
import { useDebounce } from "@/src/hooks/useDebounce";
import { joinTableCoreAndMetrics } from "@/src/components/table/utils/joinTableCoreAndMetrics";
@@ -43,6 +44,12 @@ import {
getScoreGroupColumnProps,
verifyAndPrefixScoreDataAgainstKeys,
} from "@/src/features/scores/components/ScoreDetailColumnHelpers";
import { useSelectAll } from "@/src/features/table/hooks/useSelectAll";
import { type TableAction } from "@/src/features/table/types";
import { TableActionMenu } from "@/src/features/table/components/TableActionMenu";
import { type RowSelectionState } from "@tanstack/react-table";
import { showSuccessToast } from "@/src/features/notifications/showSuccessToast";
import { TableSelectionManager } from "@/src/features/table/components/TableSelectionManager";
export type SessionTableRow = {
id: string;
@@ -76,6 +83,7 @@ export default function SessionsTable({
const { setDetailPageList } = useDetailPageLists();
const { selectedOption, dateRange, setDateRangeAndOption } =
useTableDateRange(projectId);
const [selectedRows, setSelectedRows] = useState<RowSelectionState>({});
const [userFilterState, setUserFilterState] = useQueryFilterState(
[],
@@ -134,6 +142,8 @@ export default function SessionsTable({
environmentFilter,
);
const { selectAll, setSelectAll } = useSelectAll(projectId, "sessions");
const [paginationState, setPaginationState] = useQueryParams({
pageIndex: withDefault(NumberParam, 0),
pageSize: withDefault(NumberParam, 50),
@@ -164,6 +174,19 @@ export default function SessionsTable({
const sessions = api.sessions.all.useQuery(payloadGetAll);
const sessionCountQuery = api.sessions.countAll.useQuery(payloadCount);
const addToQueueMutation = api.annotationQueueItems.createMany.useMutation({
onSuccess: (data) => {
showSuccessToast({
title: "Sessions added to queue",
description: `Selected sessions will be added to queue "${data.queueName}". This may take a minute.`,
link: {
href: `/project/${projectId}/annotation-queues/${data.queueId}`,
text: `View queue "${data.queueName}"`,
},
});
},
});
const { scoreColumns, scoreKeysAndProps, isColumnLoading } =
useIndividualScoreColumns<SessionTableRow>({
projectId,
@@ -222,7 +245,53 @@ export default function SessionsTable({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sessions.isSuccess, sessions.data]);
const { selectActionColumn } = TableSelectionManager<SessionTableRow>({
projectId,
tableName: "sessions",
setSelectedRows,
});
const handleAddToAnnotationQueue = async ({
projectId,
targetId,
}: {
projectId: string;
targetId: string;
}) => {
const selectedSessionIds = Object.keys(selectedRows).filter((sessionId) =>
sessions.data?.sessions.map((t) => t.id).includes(sessionId),
);
await addToQueueMutation.mutateAsync({
projectId,
objectIds: selectedSessionIds,
objectType: AnnotationQueueObjectType.SESSION,
queueId: targetId,
isBatchAction: selectAll,
query: {
filter: filterState,
orderBy: orderByState,
},
});
setSelectedRows({});
};
const tableActions: TableAction[] = [
{
id: "session-add-to-annotation-queue",
type: BatchActionType.Create,
label: "Add to Annotation Queue",
description: "Add selected sessions to an annotation queue.",
targetLabel: "Annotation Queue",
execute: handleAddToAnnotationQueue,
accessCheck: {
scope: "annotationQueues:CUD",
},
},
];
const columns: LangfuseColumnDef<SessionTableRow>[] = [
selectActionColumn,
{
accessorKey: "bookmarked",
id: "bookmarked",
@@ -561,6 +630,18 @@ export default function SessionsTable({
filterColumnDefinition={transformFilterOptions()}
filterState={userFilterState}
setFilterState={useDebounce(setUserFilterState)}
actionButtons={[
Object.keys(selectedRows).filter((sessionId) =>
sessions.data?.sessions.map((s) => s.id).includes(sessionId),
).length > 0 ? (
<TableActionMenu
key="sessions-multi-select-actions"
projectId={projectId}
actions={tableActions}
tableName={BatchExportTableName.Sessions}
/>
) : null,
]}
columns={columns}
columnVisibility={columnVisibility}
setColumnVisibility={setColumnVisibility}
@@ -571,18 +652,21 @@ export default function SessionsTable({
projectId,
controllers: viewControllers,
}}
actionButtons={[
<BatchExportTableButton
{...{ projectId, filterState, orderByState }}
tableName={BatchExportTableName.Sessions}
key="batchExport"
/>,
]}
selectedOption={selectedOption}
setDateRangeAndOption={setDateRangeAndOption}
columnsWithCustomSelect={["userIds"]}
rowHeight={rowHeight}
setRowHeight={setRowHeight}
multiSelect={{
selectAll,
setSelectAll,
selectedRowIds: Object.keys(selectedRows).filter((sessionId) =>
sessions.data?.sessions.map((s) => s.id).includes(sessionId),
),
setRowSelection: setSelectedRows,
totalCount,
...paginationState,
}}
environmentFilter={{
values: selectedEnvironments,
onValueChange: setSelectedEnvironments,
@@ -590,6 +674,7 @@ export default function SessionsTable({
}}
/>
<DataTable
tableName={"sessions"}
columns={columns}
data={
sessions.isLoading || isViewLoading
@@ -640,6 +725,8 @@ export default function SessionsTable({
onColumnVisibilityChange={setColumnVisibility}
columnOrder={columnOrder}
onColumnOrderChange={setColumnOrder}
rowSelection={selectedRows}
setRowSelection={setSelectedRows}
help={{
description:
"A session is a collection of related traces, such as a conversation or thread. To begin, add a sessionId to the trace.",
@@ -1207,6 +1207,7 @@ export default function TracesTable({
rowHeight={rowHeight}
pinFirstColumn={!hideControls}
peekView={peekConfig}
tableName={"traces"}
/>
</>
);
+153 -99
View File
@@ -123,62 +123,91 @@ export const IOPreview: React.FC<{
</Tabs>
</div>
) : null}
{isPrettyViewAvailable && selectedView === "pretty" ? (
{/* Always render components to preserve state, just hide via CSS*/}
{isPrettyViewAvailable ? (
<>
{inChatMlArray.success ? (
<OpenAiMessageView
messages={[
...inChatMlArray.data,
...(outChatMlArray.success
? outChatMlArray.data.map((m) => ({
...m,
role: m.role ?? "assistant",
}))
: [
{
role: "assistant",
...(typeof outputClean === "string"
? { content: outputClean }
: { json: outputClean }),
} as ChatMlMessageSchema,
]),
]}
shouldRenderMarkdown
additionalInput={
Object.keys(additionalInput ?? {}).length > 0
? additionalInput
: undefined
}
media={media ?? []}
currentView={selectedView}
/>
) : (
<>
{!(hideIfNull && !input) && !hideInput ? (
<PrettyJsonView
title="Input"
className="ph-no-capture"
json={input ?? null}
isLoading={isLoading}
media={media?.filter((m) => m.field === "input") ?? []}
currentView={selectedView}
/>
) : null}
{!(hideIfNull && !output) && !hideOutput ? (
<PrettyJsonView
title="Output"
className="ph-no-capture"
json={outputClean}
isLoading={isLoading}
media={media?.filter((m) => m.field === "output") ?? []}
currentView={selectedView}
/>
) : null}
</>
)}
{/* Pretty view content */}
<div
style={{ display: selectedView === "pretty" ? "block" : "none" }}
>
{inChatMlArray.success ? (
<OpenAiMessageView
messages={[
...inChatMlArray.data,
...(outChatMlArray.success
? outChatMlArray.data.map((m) => ({
...m,
role: m.role ?? "assistant",
}))
: [
{
role: "assistant",
...(typeof outputClean === "string"
? { content: outputClean }
: { json: outputClean }),
} as ChatMlMessageSchema,
]),
]}
shouldRenderMarkdown
additionalInput={
Object.keys(additionalInput ?? {}).length > 0
? additionalInput
: undefined
}
media={media ?? []}
currentView={selectedView}
/>
) : (
<>
{!(hideIfNull && !input) && !hideInput ? (
<PrettyJsonView
title="Input"
className="ph-no-capture"
json={input ?? null}
isLoading={isLoading}
media={media?.filter((m) => m.field === "input") ?? []}
currentView={selectedView}
/>
) : null}
{!(hideIfNull && !output) && !hideOutput ? (
<PrettyJsonView
title="Output"
className="ph-no-capture"
json={outputClean}
isLoading={isLoading}
media={media?.filter((m) => m.field === "output") ?? []}
currentView={selectedView}
/>
) : null}
</>
)}
</div>
{/* JSON view content */}
<div style={{ display: selectedView === "json" ? "block" : "none" }}>
{!(hideIfNull && !input) && !hideInput ? (
<PrettyJsonView
title="Input"
className="ph-no-capture"
json={input ?? null}
isLoading={isLoading}
media={media?.filter((m) => m.field === "input") ?? []}
currentView={selectedView}
/>
) : null}
{!(hideIfNull && !output) && !hideOutput ? (
<PrettyJsonView
title="Output"
className="ph-no-capture"
json={outputClean}
isLoading={isLoading}
media={media?.filter((m) => m.field === "output") ?? []}
currentView={selectedView}
/>
) : null}
</div>
</>
) : null}
{selectedView === "json" || !isPrettyViewAvailable ? (
) : (
<>
{!(hideIfNull && !input) && !hideInput ? (
<PrettyJsonView
@@ -201,7 +230,7 @@ export const IOPreview: React.FC<{
/>
) : null}
</>
) : null}
)}
</>
);
};
@@ -269,52 +298,77 @@ export const OpenAiMessageView: React.FC<{
.map((message, index) => (
<Fragment key={index}>
{isPlaceholderMessage(message) ? (
shouldRenderMarkdown ? (
<MarkdownJsonView
title="Placeholder"
content={message.name || "Unnamed placeholder"}
customCodeHeaderClassName={cn("bg-primary-foreground")}
/>
) : (
<PrettyJsonView
title="Placeholder"
json={message.name || "Unnamed placeholder"}
projectIdForPromptButtons={projectIdForPromptButtons}
currentView={currentView}
/>
)
<>
<div
style={{
display: shouldRenderMarkdown ? "block" : "none",
}}
>
<MarkdownJsonView
title="Placeholder"
content={message.name || "Unnamed placeholder"}
customCodeHeaderClassName={cn("bg-primary-foreground")}
/>
</div>
<div
style={{
display: shouldRenderMarkdown ? "none" : "block",
}}
>
<PrettyJsonView
title="Placeholder"
json={message.name || "Unnamed placeholder"}
projectIdForPromptButtons={projectIdForPromptButtons}
currentView={currentView}
/>
</div>
</>
) : (
<>
{shouldRenderContent(message) &&
(shouldRenderMarkdown ? (
<MarkdownJsonView
title={message.name ?? message.role}
content={message.content || '""'}
className={cn(
!!message.json &&
!isPlaceholderMessage(message) &&
"rounded-b-none",
)}
customCodeHeaderClassName={cn(
message.role === "assistant" && "bg-secondary",
message.role === "system" &&
"bg-primary-foreground",
)}
audio={message.audio}
/>
) : (
<PrettyJsonView
title={message.name ?? message.role}
json={message.content}
projectIdForPromptButtons={projectIdForPromptButtons}
className={cn(
!!message.json &&
!isPlaceholderMessage(message) &&
"rounded-b-none",
)}
currentView={currentView}
/>
))}
{shouldRenderContent(message) && (
<>
<div
style={{
display: shouldRenderMarkdown ? "block" : "none",
}}
>
<MarkdownJsonView
title={message.name ?? message.role}
content={message.content || '""'}
className={cn(
!!message.json &&
!isPlaceholderMessage(message) &&
"rounded-b-none",
)}
customCodeHeaderClassName={cn(
message.role === "assistant" && "bg-secondary",
message.role === "system" &&
"bg-primary-foreground",
)}
audio={message.audio}
/>
</div>
<div
style={{
display: shouldRenderMarkdown ? "none" : "block",
}}
>
<PrettyJsonView
title={message.name ?? message.role}
json={message.content}
projectIdForPromptButtons={
projectIdForPromptButtons
}
className={cn(
!!message.json &&
!isPlaceholderMessage(message) &&
"rounded-b-none",
)}
currentView={currentView}
/>
</div>
</>
)}
{shouldRenderJson(message) &&
!isPlaceholderMessage(message) && (
<PrettyJsonView
+28 -23
View File
@@ -3,11 +3,7 @@ import { copyTextToClipboard } from "@/src/utils/clipboard";
import { cn } from "@/src/utils/tailwind";
import { Check, Copy } from "lucide-react";
import { type FC, memo, useState } from "react";
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
import {
coldarkDark,
solarizedlight,
} from "react-syntax-highlighter/dist/cjs/styles/prism";
import { Highlight, themes } from "prism-react-renderer";
interface Props {
language: string;
@@ -80,26 +76,35 @@ const CodeBlock: FC<Props> = memo(({ language, value, theme, className }) => {
</Button>
</div>
</div>
<SyntaxHighlighter
<Highlight
theme={theme === "dark" ? themes.vsDark : themes.github}
code={value}
language={language}
style={theme === "dark" ? coldarkDark : solarizedlight}
PreTag="div"
customStyle={{
margin: 0,
width: "100%",
background: "transparent",
padding: "0.5rem",
}}
codeTagProps={{
style: {
fontSize: "0.75rem",
fontFamily: "var(--font-mono)",
display: "block",
},
}}
>
{value}
</SyntaxHighlighter>
{({ className, style, tokens, getLineProps, getTokenProps }) => (
<pre
className={className}
style={{
...style,
margin: 0,
width: "100%",
background: "transparent",
padding: "0.5rem",
fontSize: "0.75rem",
fontFamily: "var(--font-mono)",
overflow: "auto",
}}
>
{tokens.map((line, i) => (
<div key={i} {...getLineProps({ line })}>
{line.map((token, key) => (
<span key={key} {...getTokenProps({ token })} />
))}
</div>
))}
</pre>
)}
</Highlight>
</div>
);
});
+1 -1
View File
@@ -123,7 +123,7 @@ function MarkdownRenderer({
return (
<MemoizedReactMarkdown
className={cn(
"space-y-2 overflow-x-auto break-words text-sm",
"space-y-2 overflow-x-auto whitespace-pre-wrap break-words text-sm",
className,
)}
remarkPlugins={[remarkGfm]}
+407 -80
View File
@@ -50,6 +50,9 @@ const BUTTON_WIDTH = 16;
const MARGIN_LEFT_1 = 4;
const CELL_PADDING_X = 8; // px-2
const DEFAULT_MAX_ROWS = 20;
const DEEPEST_DEFAULT_EXPANSION_LEVEL = 10;
const ASSISTANT_TITLES = ["assistant", "Output"];
const SYSTEM_TITLES = ["system", "Input"];
@@ -156,6 +159,9 @@ interface JsonTableRow {
hasChildren: boolean;
level: number;
subRows?: JsonTableRow[];
// For lazy loading of sub-row table data
rawChildData?: unknown;
childrenGenerated?: boolean;
}
function getValueType(value: unknown): JsonTableRow["type"] {
@@ -178,6 +184,7 @@ function transformJsonToTableData(
parentKey = "",
level = 0,
parentId = "",
lazy = false,
): JsonTableRow[] {
const rows: JsonTableRow[] = [];
@@ -210,11 +217,26 @@ function transformJsonToTableData(
type: valueType,
hasChildren: childrenExist,
level,
childrenGenerated: false,
};
if (childrenExist) {
const children = transformJsonToTableData(value, key, level + 1, id);
row.subRows = children;
if (lazy && level === 0) {
// For lazy loading, store raw data instead of processing children
row.rawChildData = value;
row.subRows = []; // Empty initially
} else {
// Normal processing or nested children
const children = transformJsonToTableData(
value,
key,
level + 1,
id,
lazy,
);
row.subRows = children;
row.childrenGenerated = true;
}
}
rows.push(row);
@@ -223,6 +245,101 @@ function transformJsonToTableData(
return rows;
}
function generateChildRows(row: JsonTableRow): JsonTableRow[] {
if (!row.rawChildData || row.childrenGenerated) {
return row.subRows || [];
}
const children = transformJsonToTableData(
row.rawChildData,
row.key,
row.level + 1,
row.id,
false, // Don't use lazy loading for children
);
return children;
}
function generateAllChildrenRecursively(
row: JsonTableRow,
onRowGenerated?: (rowId: string) => void,
): void {
if (row.rawChildData && !row.childrenGenerated) {
const children = generateChildRows(row);
row.subRows = children;
row.childrenGenerated = true;
// this row now has generated children for state preservation (expand all)
onRowGenerated?.(row.id);
children.forEach((child) => {
generateAllChildrenRecursively(child, onRowGenerated);
});
}
}
function findOptimalExpansionLevel(
data: JsonTableRow[],
maxRows: number,
): number {
if (data.length > maxRows) {
return 0;
}
function findOptimalRecursively(
rows: JsonTableRow[],
currentLevel: number,
cumulativeCount: number,
): number {
const rowsAtThisLevel = rows.length;
const newCumulativeCount = cumulativeCount + rowsAtThisLevel;
// If expanding to this level exceeds maxRows, return previous level
if (newCumulativeCount > maxRows) {
return currentLevel - 1;
}
if (currentLevel >= DEEPEST_DEFAULT_EXPANSION_LEVEL) {
return currentLevel;
}
// Get all children for next level
const childRows: JsonTableRow[] = [];
for (const row of rows) {
if (row.hasChildren) {
let children: JsonTableRow[];
if (row.subRows && row.subRows.length > 0) {
children = row.subRows;
} else if (row.rawChildData) {
children = transformJsonToTableData(
row.rawChildData,
row.key,
row.level + 1,
row.id,
false,
);
} else {
continue;
}
childRows.push(...children);
}
}
if (childRows.length === 0) {
return currentLevel;
}
return findOptimalRecursively(
childRows,
currentLevel + 1,
newCumulativeCount,
);
}
return Math.max(0, findOptimalRecursively(data, 0, 0));
}
function renderArrayValue(arr: unknown[]): JSX.Element {
if (arr.length === 0) {
return <span className={PREVIEW_TEXT_CLASSES}>empty list</span>;
@@ -282,12 +399,13 @@ const ValueCell = memo(({ row }: { row: Row<JsonTableRow> }) => {
const renderValue = () => {
switch (type) {
case "string":
case "string": {
return (
<span className="whitespace-pre-line text-green-600 dark:text-green-400">
&quot;{String(value)}&quot;
</span>
);
}
case "number":
return (
<span className="text-blue-600 dark:text-blue-400">
@@ -328,19 +446,42 @@ const ValueCell = memo(({ row }: { row: Row<JsonTableRow> }) => {
ValueCell.displayName = "ValueCell";
function handleRowExpansion(
row: Row<JsonTableRow>,
onLazyLoadChildren?: (rowId: string) => void,
) {
if (row.original.hasChildren) {
const originalRow = row.original;
if (originalRow.rawChildData && !originalRow.childrenGenerated) {
onLazyLoadChildren?.(originalRow.id);
}
row.toggleExpanded();
}
}
function JsonPrettyTable({
data,
expandAllRef,
onExpandStateChange,
noBorder = false,
expanded,
onExpandedChange,
onLazyLoadChildren,
onForceUpdate,
smartDefaultsLevel,
}: {
data: JsonTableRow[];
expandAllRef?: React.MutableRefObject<(() => void) | null>;
onExpandStateChange?: (allExpanded: boolean) => void;
noBorder?: boolean;
expanded: ExpandedState;
onExpandedChange: (
updater: ExpandedState | ((prev: ExpandedState) => ExpandedState),
) => void;
onLazyLoadChildren?: (rowId: string) => void;
onForceUpdate?: () => void;
smartDefaultsLevel?: number | null;
}) {
const [expanded, setExpanded] = useState<ExpandedState>({});
const columns: LangfuseColumnDef<JsonTableRow, unknown>[] = [
{
accessorKey: "key",
@@ -366,7 +507,7 @@ function JsonPrettyTable({
size="sm"
onClick={(e) => {
e.stopPropagation();
row.toggleExpanded();
handleRowExpansion(row, onLazyLoadChildren);
}}
className="h-4 w-4 p-0"
>
@@ -405,8 +546,9 @@ function JsonPrettyTable({
state: {
expanded,
},
onExpandedChange: setExpanded,
onExpandedChange: onExpandedChange,
enableColumnResizing: false,
autoResetExpanded: false,
});
const allRowsExpanded = useMemo(() => {
@@ -425,13 +567,71 @@ function JsonPrettyTable({
onExpandStateChange?.(allRowsExpanded);
}, [allRowsExpanded, onExpandStateChange]);
const expandRowsWithLazyLoading = useCallback(
(
rowFilter: (rows: Row<JsonTableRow>[]) => Row<JsonTableRow>[],
shouldCollapse: boolean = false,
) => {
if (shouldCollapse) {
onExpandedChange({});
return;
}
const allRows = table.getRowModel().flatRows;
const expandableRows = allRows.filter((row) => row.original.hasChildren);
const targetRows = rowFilter(expandableRows);
const rowsNeedingParsing = targetRows.filter(
(row) => row.original.rawChildData && !row.original.childrenGenerated,
);
if (rowsNeedingParsing.length > 0) {
const generatedRowIds: string[] = [];
rowsNeedingParsing.forEach((row) => {
generateAllChildrenRecursively(row.original, (rowId) => {
generatedRowIds.push(rowId);
});
});
if (generatedRowIds.length > 0) {
onLazyLoadChildren?.(generatedRowIds.join(","));
}
onForceUpdate?.();
// setTimeout re-renders table once new data is available
setTimeout(() => {
const newExpanded: ExpandedState = {};
const updatedAllRows = table.getRowModel().flatRows;
const updatedExpandableRows = updatedAllRows.filter(
(row) => row.original.hasChildren,
);
const updatedTargetRows = rowFilter(updatedExpandableRows);
updatedTargetRows.forEach((row) => {
newExpanded[row.id] = true;
});
onExpandedChange(newExpanded);
}, 0);
} else {
// No lazy loading needed, just set expansion state
const newExpanded: ExpandedState = {};
targetRows.forEach((row) => {
newExpanded[row.id] = true;
});
onExpandedChange(newExpanded);
}
},
[table, onExpandedChange, onLazyLoadChildren, onForceUpdate],
);
const handleToggleExpandAll = useCallback(() => {
if (allRowsExpanded) {
table.toggleAllRowsExpanded(false);
} else {
table.toggleAllRowsExpanded(true);
}
}, [allRowsExpanded, table]);
expandRowsWithLazyLoading(
(expandableRows) => expandableRows, // All expandable rows
allRowsExpanded, // Should collapse if already expanded
);
}, [allRowsExpanded, expandRowsWithLazyLoading]);
useEffect(() => {
if (expandAllRef) {
@@ -440,9 +640,12 @@ function JsonPrettyTable({
}, [expandAllRef, handleToggleExpandAll]);
useEffect(() => {
setExpanded({});
onExpandStateChange?.(false);
}, [data, onExpandStateChange]);
if (smartDefaultsLevel != null && smartDefaultsLevel > 0) {
expandRowsWithLazyLoading((expandableRows) =>
expandableRows.filter((row) => row.depth < smartDefaultsLevel),
);
}
}, [smartDefaultsLevel, expandRowsWithLazyLoading]);
return (
<div className={cn("w-full", !noBorder && "rounded-sm border")}>
@@ -471,11 +674,7 @@ function JsonPrettyTable({
{table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
onClick={() => {
if (row.original.hasChildren) {
row.toggleExpanded();
}
}}
onClick={() => handleRowExpansion(row, onLazyLoadChildren)}
className={row.original.hasChildren ? "cursor-pointer" : ""}
>
{row.getVisibleCells().map((cell) => (
@@ -508,28 +707,53 @@ export function PrettyJsonView(props: {
controlButtons?: React.ReactNode;
currentView?: "pretty" | "json";
}) {
const parsedJson = useMemo(() => deepParseJson(props.json), [props.json]);
const jsonDependency = useMemo(
() =>
typeof props.json === "string" ? props.json : JSON.stringify(props.json),
[props.json],
);
const parsedJson = useMemo(() => {
return deepParseJson(props.json);
// We want to use jsonDependency as dep because it's more stable than props.json
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [jsonDependency]);
const actualCurrentView = props.currentView ?? "pretty";
const expandAllRef = useRef<(() => void) | null>(null);
const [allRowsExpanded, setAllRowsExpanded] = useState(false);
const [jsonIsCollapsed, setJsonIsCollapsed] = useState(false);
const [tableExpanded, setTableExpanded] = useState<ExpandedState>({});
const [expandedRowsWithChildren, setExpandedRowsWithChildren] = useState<
Set<string>
>(new Set());
const [, setForceUpdate] = useState(0);
const isChatML = useMemo(() => isChatMLFormat(parsedJson), [parsedJson]);
const markdownCheck = useMemo(
const { isMarkdown, content: markdownContent } = useMemo(
() => isMarkdownContent(parsedJson),
[parsedJson],
);
const tableData = useMemo(() => {
const baseTableData = useMemo(() => {
try {
if (
actualCurrentView === "pretty" &&
parsedJson !== null &&
parsedJson !== undefined &&
!isChatML &&
!markdownCheck.isMarkdown
!isMarkdown
) {
// Helper function to create rows from object entries at level 0
// early abort check for smart expansion
if (parsedJson?.constructor === Object) {
const topLevelKeys = Object.keys(
parsedJson as Record<string, unknown>,
);
if (topLevelKeys.length > DEFAULT_MAX_ROWS) {
return []; // Return empty array to skip table view entirely
}
}
// lazy load JSON data, generate only top-level rows initially; children on expand
const createTopLevelRows = (
obj: Record<string, unknown>,
): JsonTableRow[] => {
@@ -547,11 +771,12 @@ export function PrettyJsonView(props: {
type: valueType,
hasChildren: childrenExist,
level: 0,
childrenGenerated: false,
};
if (childrenExist) {
const children = transformJsonToTableData(value, key, 1, key);
row.subRows = children;
row.rawChildData = value;
row.subRows = []; // empty initially for lazy loading
}
rows.push(row);
@@ -560,24 +785,117 @@ export function PrettyJsonView(props: {
return rows;
};
// If top-level is an object, start with its properties directly
if (
typeof parsedJson === "object" &&
parsedJson !== null &&
!Array.isArray(parsedJson) &&
parsedJson.constructor === Object
) {
// If top-level is a plain object, start with its properties directly
if (parsedJson?.constructor === Object) {
return createTopLevelRows(parsedJson as Record<string, unknown>);
}
return transformJsonToTableData(parsedJson);
return transformJsonToTableData(parsedJson, "", 0, "", true);
}
return [];
} catch (error) {
console.error("Error transforming JSON to table data:", error);
return [];
}
}, [parsedJson, actualCurrentView, isChatML, markdownCheck.isMarkdown]);
}, [parsedJson, isChatML, isMarkdown, actualCurrentView]);
// smart initial expansion of the row based on number of subrows
const [smartDefaultsLevel, setSmartDefaultsLevel] = useState<number | null>(
null,
);
useEffect(() => {
// we want to default expand the table if the expanded table would have less than DEFAULT_MAX_ROWS of rows.
// we only expand a level of a JSON, if we can expand all keys of that JSON.
if (baseTableData.length > 0) {
const optimalLevel = findOptimalExpansionLevel(
baseTableData,
DEFAULT_MAX_ROWS,
);
if (optimalLevel > 0) {
setTimeout(() => {
setSmartDefaultsLevel(optimalLevel);
}, 0);
}
}
}, [baseTableData]);
// table data with lazy-loaded children
const tableData = useMemo(() => {
const updateRowWithChildren = (rows: JsonTableRow[]): JsonTableRow[] => {
return rows.map((row) => {
let updatedRow = row;
// generate children if this row needs them
if (
expandedRowsWithChildren.has(row.id) &&
row.rawChildData &&
!row.childrenGenerated
) {
const children = generateChildRows(row);
updatedRow = {
...row,
subRows: children,
childrenGenerated: true,
};
}
// recursively update existing children only if they exist
if (updatedRow.subRows && updatedRow.subRows.length > 0) {
updatedRow = {
...updatedRow,
subRows: updateRowWithChildren(updatedRow.subRows),
};
}
return updatedRow;
});
};
return updateRowWithChildren(baseTableData);
}, [baseTableData, expandedRowsWithChildren]);
const handleLazyLoadChildren = useCallback((rowId: string) => {
setExpandedRowsWithChildren((prev) => {
const newSet = new Set(prev);
// we track the IDs for batch updates when lazy loading children
if (rowId.includes(",")) {
rowId.split(",").forEach((id) => newSet.add(id));
} else {
newSet.add(rowId);
}
return newSet;
});
}, []);
const handleForceUpdate = useCallback(() => {
setForceUpdate((prev) => prev + 1);
}, []);
// required because the react-table expansion doesn't support lazy loading children
const handleTableExpandedChange = useCallback(
(
updater:
| ExpandedState
| ((prev: ExpandedState) => ExpandedState)
| boolean,
) => {
// single rows have been expanded
if (typeof updater === "function") {
setTableExpanded((prev) => {
const newState = updater(prev);
return newState;
});
} else {
// directly expand all
if (typeof updater !== "boolean") {
setTableExpanded(updater);
}
}
},
[],
);
const handleOnCopy = (event?: React.MouseEvent<HTMLButtonElement>) => {
if (event) {
@@ -596,13 +914,10 @@ export function PrettyJsonView(props: {
};
const emptyValueDisplay = getEmptyValueDisplay(parsedJson);
const isMarkdownMode =
markdownCheck.isMarkdown && actualCurrentView === "pretty";
const isPrettyView = actualCurrentView === "pretty";
const isMarkdownMode = isMarkdown && isPrettyView;
const shouldUseTableView =
actualCurrentView === "pretty" &&
!isChatML &&
!markdownCheck.isMarkdown &&
!emptyValueDisplay;
isPrettyView && !isChatML && !isMarkdown && !emptyValueDisplay;
const getBackgroundColorClass = () =>
cn(
@@ -614,7 +929,7 @@ export function PrettyJsonView(props: {
const body = (
<>
{emptyValueDisplay && actualCurrentView === "pretty" ? (
{emptyValueDisplay && isPrettyView ? (
<div
className={cn(
"flex items-center",
@@ -637,43 +952,55 @@ export function PrettyJsonView(props: {
props.isLoading ? (
<Skeleton className="h-3 w-3/4" />
) : (
<MarkdownView markdown={markdownCheck.content || ""} />
<MarkdownView markdown={markdownContent || ""} />
)
) : shouldUseTableView ? (
<div
className={getContainerClasses(
props.title,
props.scrollable,
props.codeClassName,
"flex whitespace-pre-wrap break-words text-xs",
)}
>
{props.isLoading ? (
<Skeleton className="m-3 h-3 w-3/4" />
) : (
<JsonPrettyTable
data={tableData}
expandAllRef={expandAllRef}
onExpandStateChange={setAllRowsExpanded}
noBorder={true}
/>
)}
</div>
) : (
<JSONView
json={props.json}
title={props.title} // Title value used for background styling
hideTitle={true} // But hide the title, we display it
className=""
isLoading={props.isLoading}
codeClassName={props.codeClassName}
collapseStringsAfterLength={props.collapseStringsAfterLength}
media={props.media}
scrollable={props.scrollable}
projectIdForPromptButtons={props.projectIdForPromptButtons}
externalJsonCollapsed={jsonIsCollapsed}
onToggleCollapse={handleJsonToggleCollapse}
/>
<>
{/* Always render JsonPrettyTable to preserve internal React Table state */}
<div
className={getContainerClasses(
props.title,
props.scrollable,
props.codeClassName,
"flex whitespace-pre-wrap break-words text-xs",
)}
style={{ display: shouldUseTableView ? "flex" : "none" }}
>
{props.isLoading ? (
<Skeleton className="m-3 h-3 w-3/4" />
) : (
<JsonPrettyTable
data={tableData}
expandAllRef={expandAllRef}
onExpandStateChange={setAllRowsExpanded}
noBorder={true}
expanded={tableExpanded}
onExpandedChange={handleTableExpandedChange}
onLazyLoadChildren={handleLazyLoadChildren}
onForceUpdate={handleForceUpdate}
smartDefaultsLevel={smartDefaultsLevel}
/>
)}
</div>
{/* Always render JSONView to preserve its state too */}
<div style={{ display: shouldUseTableView ? "none" : "block" }}>
<JSONView
json={props.json}
title={props.title} // Title value used for background styling
hideTitle={true} // But hide the title, we display it
className=""
isLoading={props.isLoading}
codeClassName={props.codeClassName}
collapseStringsAfterLength={props.collapseStringsAfterLength}
media={props.media}
scrollable={props.scrollable}
projectIdForPromptButtons={props.projectIdForPromptButtons}
externalJsonCollapsed={jsonIsCollapsed}
onToggleCollapse={handleJsonToggleCollapse}
/>
</div>
</>
)}
{props.media && props.media.length > 0 && (
<>
@@ -728,7 +1055,7 @@ export function PrettyJsonView(props: {
)}
</Button>
)}
{actualCurrentView === "json" && (
{!isPrettyView && (
<Button
variant="ghost"
size="icon-xs"
+1 -1
View File
@@ -1 +1 @@
export const VERSION = "v3.85.2";
export const VERSION = "v3.87.0";
@@ -138,6 +138,7 @@ export function AuditLogsTable(props: { projectId: string }) {
/>
<SettingsTableCard>
<DataTable
tableName={"auditLogs"}
columns={columns}
data={
auditLogs.isLoading
@@ -1,236 +1,21 @@
import { Trace } from "@/src/components/trace";
import { ObservationPreview } from "@/src/components/trace/ObservationPreview";
import { TracePreview } from "@/src/components/trace/TracePreview";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/src/components/ui/accordion";
import { Button } from "@/src/components/ui/button";
import { Card } from "@/src/components/ui/card";
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from "@/src/components/ui/resizable";
import { Skeleton } from "@/src/components/ui/skeleton";
import useSessionStorage from "@/src/components/useSessionStorage";
import { CommentList } from "@/src/features/comments/CommentList";
import { showSuccessToast } from "@/src/features/notifications/showSuccessToast";
import { useHasProjectAccess } from "@/src/features/rbac/utils/checkProjectAccess";
import { AnnotateDrawerContent } from "@/src/features/scores/components/AnnotateDrawerContent";
import { api } from "@/src/utils/api";
import { type RouterOutput } from "@/src/utils/types";
import {
type AnnotationQueueItem,
AnnotationQueueObjectType,
AnnotationQueueStatus,
isPresent,
type ValidatedScoreConfig,
AnnotationQueueObjectType,
} from "@langfuse/shared";
import {
ArrowLeft,
ArrowRight,
SearchXIcon,
TriangleAlertIcon,
} from "lucide-react";
import { useSession } from "next-auth/react";
import { ArrowLeft, ArrowRight, SearchXIcon } from "lucide-react";
import { useRouter } from "next/router";
import { useEffect, useMemo, useState } from "react";
import { StringParam, useQueryParam } from "use-query-params";
const AnnotateIOView = ({
item,
configs,
view,
}: {
item: AnnotationQueueItem & {
parentTraceId?: string | null;
lockedByUser: { name: string | null | undefined } | null;
};
configs: ValidatedScoreConfig[];
view: "showTree" | "hideTree";
}) => {
const router = useRouter();
const session = useSession();
const traceId = item.parentTraceId ?? item.objectId;
const projectId = router.query.projectId as string;
const [showSaving, setShowSaving] = useState(false);
const [showComments, setShowComments] = useSessionStorage(
`annotationQueueShowComments-${projectId}`,
false,
);
const [panelSize, setPanelSize] = useSessionStorage(
`annotationQueuePanelSize-${projectId}`,
65,
);
const [currentObservationId, setCurrentObservationId] = useQueryParam(
"observation",
StringParam,
);
useEffect(() => {
if (
view === "showTree" &&
item.objectType === AnnotationQueueObjectType.OBSERVATION
) {
setCurrentObservationId(item.objectId);
} else setCurrentObservationId(undefined);
}, [view, item, setCurrentObservationId]);
const isLockedByOtherUser = item.lockedByUserId !== session.data?.user?.id;
const trace = api.traces.byIdWithObservationsAndScores.useQuery(
{ traceId, projectId },
{
retry(failureCount, error) {
if (error.data?.code === "UNAUTHORIZED") return false;
return failureCount < 3;
},
},
);
const emptySelectedConfigIds = useMemo(() => {
return configs.map((c) => c.id);
}, [configs]);
if (trace.isLoading || !trace.data)
return <div className="p-3">Loading...</div>;
let isValidObservationId = false;
if (
currentObservationId &&
trace.data.observations.some(({ id }) => id === currentObservationId)
) {
isValidObservationId = true;
}
return (
<ResizablePanelGroup
direction="horizontal"
className="h-full overflow-hidden"
onLayout={(sizes) => {
setPanelSize(sizes[0]);
}}
>
<ResizablePanel
className="col-span-1 h-full !overflow-y-auto rounded-md border"
minSize={30}
defaultSize={panelSize}
>
{view === "hideTree" ? (
<div className="max-h-full min-h-0 overflow-y-auto pl-4">
{item.objectType === AnnotationQueueObjectType.TRACE ? (
<TracePreview
key={trace.data.id}
trace={trace.data}
scores={trace.data.scores}
observations={trace.data.observations}
viewType="focused"
/>
) : (
<ObservationPreview
observations={trace.data.observations}
scores={trace.data.scores}
projectId={item.projectId}
currentObservationId={item.objectId}
traceId={traceId}
viewType="focused"
/>
)}
</div>
) : (
<div className="max-h-full min-h-0 overflow-y-auto">
<Trace
key={trace.data.id}
trace={trace.data}
scores={trace.data.scores}
projectId={trace.data.projectId}
observations={trace.data.observations}
viewType="focused"
isValidObservationId={isValidObservationId}
/>
</div>
)}
</ResizablePanel>
<ResizableHandle withHandle className="ml-4 bg-transparent" />
<ResizablePanel
className="col-span-1 h-full md:flex md:flex-col md:overflow-hidden"
minSize={30}
>
<Card className="col-span-2 flex h-full flex-col overflow-hidden">
<div className="grid h-full w-full grid-cols-1 grid-rows-[minmax(auto,1fr),minmax(min-content,auto)] justify-between">
<div className="w-full overflow-auto">
<AnnotateDrawerContent
key={"annotation-drawer-content" + item.objectId}
scoreTarget={{
type: "trace",
traceId: traceId,
observationId: item.parentTraceId ? item.objectId : undefined,
}}
scores={trace.data?.scores ?? []}
configs={configs}
emptySelectedConfigIds={emptySelectedConfigIds}
setEmptySelectedConfigIds={() => {}}
projectId={item.projectId}
analyticsData={{
type: "trace",
source: "AnnotationQueue",
}}
isSelectHidden
queueId={item.queueId}
showSaving={showSaving}
setShowSaving={setShowSaving}
environment={trace.data?.environment}
actionButtons={
isLockedByOtherUser && isPresent(item.lockedByUser?.name) ? (
<div className="flex items-center justify-center rounded-sm border border-dark-red bg-light-red p-1">
<TriangleAlertIcon className="mr-1 h-4 w-4 text-dark-red" />
<span className="text-xs text-dark-red">
Currently edited by {item.lockedByUser.name}
</span>
</div>
) : undefined
}
isDrawerOpen={true}
/>
</div>
<div className="relative max-h-64 overflow-auto">
<Accordion
type="single"
collapsible
className="mx-4 mt-4"
value={showComments ? "item-1" : ""}
onValueChange={(value) => setShowComments(value === "item-1")}
>
<AccordionItem value="item-1" className="border-none">
<div className="sticky top-0 z-10 border-b bg-background">
<AccordionTrigger
onClick={() => setShowComments(!showComments)}
>
Comments
</AccordionTrigger>
</div>
<AccordionContent>
<CommentList
projectId={item.projectId}
objectId={item.objectId}
objectType={item.objectType}
className="rounded-t-none border-t-transparent"
cardView
/>
</AccordionContent>
</AccordionItem>
</Accordion>
</div>
</div>
</Card>
</ResizablePanel>
</ResizablePanelGroup>
);
};
import { Button } from "@/src/components/ui/button";
import { useAnnotationQueueData } from "./shared/hooks/useAnnotationQueueData";
import { useAnnotationObjectData } from "./shared/hooks/useAnnotationObjectData";
import { TraceAnnotationProcessor } from "./processors/TraceAnnotationProcessor";
import { SessionAnnotationProcessor } from "./processors/SessionAnnotationProcessor";
export const AnnotationQueueItemPage: React.FC<{
annotationQueueId: string;
@@ -250,6 +35,7 @@ export const AnnotationQueueItemPage: React.FC<{
projectId,
scope: "annotationQueues:CUD",
});
const itemId = isSingleItem ? queryItemId : seenItemIds[progressIndex];
const seenItemData = api.annotationQueueItems.byId.useQuery(
@@ -260,6 +46,7 @@ export const AnnotationQueueItemPage: React.FC<{
const fetchAndLockNextMutation =
api.annotationQueues.fetchAndLockNext.useMutation();
// Effects
useEffect(() => {
async function fetchNextItem() {
if (!itemId && !isSingleItem) {
@@ -274,18 +61,7 @@ export const AnnotationQueueItemPage: React.FC<{
fetchNextItem();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const queueData = api.annotationQueues.byId.useQuery(
{
queueId: annotationQueueId,
projectId,
},
{
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
},
);
const { configs } = useAnnotationQueueData({ annotationQueueId, projectId });
const unseenPendingItemCount =
api.annotationQueueItems.unseenPendingItemCountByQueueId.useQuery(
@@ -328,8 +104,6 @@ export const AnnotationQueueItemPage: React.FC<{
return seenItemIds.length + (unseenPendingItemCount.data ?? 0);
}, [unseenPendingItemCount.data, seenItemIds.length]);
const configs = queueData.data?.scoreConfigs ?? [];
const relevantItem = useMemo(() => {
if (isSingleItem) return seenItemData.data;
else
@@ -344,6 +118,8 @@ export const AnnotationQueueItemPage: React.FC<{
isSingleItem,
]);
const objectData = useAnnotationObjectData(relevantItem ?? null, projectId);
useEffect(() => {
if (relevantItem && router.query.itemId !== relevantItem.id) {
router.push(
@@ -369,7 +145,8 @@ export const AnnotationQueueItemPage: React.FC<{
if (
(seenItemData.isLoading && itemId) ||
(fetchAndLockNextMutation.isLoading && !itemId) ||
unseenPendingItemCount.isLoading
unseenPendingItemCount.isLoading ||
objectData.isLoading
) {
return <Skeleton className="h-full w-full" />;
}
@@ -380,11 +157,33 @@ export const AnnotationQueueItemPage: React.FC<{
const isNextItemAvailable = totalItems > progressIndex + 1;
return (
<div className="grid h-full grid-rows-[1fr,auto] gap-4 overflow-hidden">
{relevantItem ? (
<AnnotateIOView item={relevantItem} configs={configs} view={view} />
) : (
const handleNavigateBack = () => {
setProgressIndex(progressIndex - 1);
};
const handleNavigateNext = async () => {
if (progressIndex >= seenItemIds.length - 1) {
const nextItem = await fetchAndLockNextMutation.mutateAsync({
queueId: annotationQueueId,
projectId,
seenItemIds,
});
setNextItemData(nextItem);
}
setProgressIndex(Math.max(progressIndex + 1, 0));
};
const handleComplete = async () => {
if (!relevantItem) return;
await completeMutation.mutateAsync({
itemId: relevantItem.id,
projectId,
});
};
const renderContent = () => {
if (!relevantItem) {
return (
<Card className="flex h-full w-full flex-col items-center justify-center overflow-hidden">
<SearchXIcon className="mb-2 h-8 w-8 text-muted-foreground" />
<span className="max-w-96 text-wrap text-sm text-muted-foreground">
@@ -393,7 +192,38 @@ export const AnnotationQueueItemPage: React.FC<{
unaffected by this action.
</span>
</Card>
)}
);
}
switch (relevantItem.objectType) {
case AnnotationQueueObjectType.TRACE:
case AnnotationQueueObjectType.OBSERVATION:
return (
<TraceAnnotationProcessor
item={relevantItem}
data={objectData.data}
view={view}
configs={configs}
projectId={projectId}
/>
);
case AnnotationQueueObjectType.SESSION:
return (
<SessionAnnotationProcessor
item={relevantItem}
data={objectData.data}
configs={configs}
projectId={projectId}
/>
);
default:
throw new Error(`Unsupported object type: ${relevantItem.objectType}`);
}
};
return (
<div className="grid h-full grid-rows-[1fr,auto] gap-4 overflow-hidden">
{renderContent()}
<div className="grid h-full w-full grid-cols-1 justify-end gap-2 sm:grid-cols-[auto,min-content]">
{!isSingleItem && (
<div className="flex max-h-10 flex-row gap-2">
@@ -401,9 +231,7 @@ export const AnnotationQueueItemPage: React.FC<{
{progressIndex + 1} / {totalItems}
</span>
<Button
onClick={() => {
setProgressIndex(progressIndex - 1);
}}
onClick={handleNavigateBack}
variant="outline"
disabled={progressIndex === 0 || !hasAccess}
size="lg"
@@ -417,18 +245,8 @@ export const AnnotationQueueItemPage: React.FC<{
<div className="flex w-full min-w-[265px] justify-end gap-2">
{!isSingleItem && (
<Button
onClick={async () => {
if (progressIndex >= seenItemIds.length - 1) {
const nextItem = await fetchAndLockNextMutation.mutateAsync({
queueId: annotationQueueId,
projectId,
seenItemIds,
});
setNextItemData(nextItem);
}
setProgressIndex(Math.max(progressIndex + 1, 0));
}}
disabled={!isNextItemAvailable || !hasAccess} // Disable button during loading
onClick={handleNavigateNext}
disabled={!isNextItemAvailable || !hasAccess}
size="lg"
className={`px-4 ${!relevantItem ? "w-full" : ""}`}
variant="outline"
@@ -442,12 +260,7 @@ export const AnnotationQueueItemPage: React.FC<{
{!!relevantItem &&
(relevantItem.status === AnnotationQueueStatus.PENDING ? (
<Button
onClick={async () => {
await completeMutation.mutateAsync({
itemId: relevantItem.id,
projectId,
});
}}
onClick={handleComplete}
size="lg"
className="w-full"
disabled={completeMutation.isLoading || !hasAccess}
@@ -125,10 +125,6 @@ const QueueItemTableMultiSelectAction = ({
export type QueueItemRowData = {
id: string;
source: {
traceId: string;
observationId?: string;
};
status: AnnotationQueueStatus;
completedAt: string;
annotatorUser: {
@@ -136,7 +132,27 @@ export type QueueItemRowData = {
userName?: string;
image?: string;
};
};
} & (
| {
objectType: "OBSERVATION";
source: {
traceId: string;
observationId: string;
};
}
| {
objectType: "TRACE";
source: {
traceId: string;
};
}
| {
objectType: "SESSION";
source: {
sessionId: string;
};
}
);
export function AnnotationQueueItemsTable({
projectId,
@@ -215,35 +231,57 @@ export function AnnotationQueueItemsTable({
);
},
},
{
accessorKey: "objectType",
header: "Type",
id: "objectType",
size: 50,
cell: ({ row }) => {
const objectType: QueueItemRowData["objectType"] =
row.getValue("objectType");
return <span className="capitalize">{objectType.toLowerCase()}</span>;
},
},
{
accessorKey: "source",
header: "Source",
headerTooltip: {
description:
"Link to the source trace based on which this item was added",
"Link to the source trace, observation or session based on which this item was added",
},
id: "source",
size: 50,
cell: ({ row }) => {
const source: QueueItemRowData["source"] = row.getValue("source");
if (!source) return null;
const rowData = row.original;
if (!rowData.source) return null;
if (!!source.observationId) {
return (
<TableLink
path={`/project/${projectId}/traces/${source.traceId}?observation=${source.observationId}`}
value={source.observationId}
icon={<ListTree className="h-4 w-4" />}
/>
);
} else {
return (
<TableLink
path={`/project/${projectId}/traces/${source.traceId}`}
value={source.traceId}
icon={<ListTree className="h-4 w-4" />}
/>
);
switch (rowData.objectType) {
case "OBSERVATION":
return (
<TableLink
path={`/project/${projectId}/traces/${rowData.source.traceId}?observation=${rowData.source.observationId}`}
value={`Observation: ${rowData.source.observationId}`}
icon={<ListTree className="h-4 w-4" />}
/>
);
case "TRACE":
return (
<TableLink
path={`/project/${projectId}/traces/${rowData.source.traceId}`}
value={`Trace: ${rowData.source.traceId}`}
icon={<ListTree className="h-4 w-4" />}
/>
);
case "SESSION":
return (
<TableLink
path={`/project/${projectId}/sessions/${rowData.source.sessionId}`}
value={`Session: ${rowData.source.sessionId}`}
icon={<ListTree className="h-4 w-4" />}
/>
);
default:
throw new Error(`Unknown object type`);
}
},
},
@@ -310,25 +348,46 @@ export function AnnotationQueueItemsTable({
const convertToTableRow = (
item: RouterOutput["annotationQueueItems"]["itemsByQueueId"]["queueItems"][number],
): QueueItemRowData => {
return {
const baseData = {
id: item.id,
completedAt: item.completedAt?.toLocaleString() ?? "",
status: item.status,
source:
item.objectType === "OBSERVATION"
? {
traceId: item.parentTraceId ?? "",
observationId: item.objectId,
}
: {
traceId: item.objectId,
},
annotatorUser: {
userId: item.annotatorUserId ?? undefined,
userName: item.annotatorUserName ?? undefined,
image: item.annotatorUserImage ?? undefined,
},
};
switch (item.objectType) {
case "OBSERVATION":
return {
...baseData,
objectType: "OBSERVATION" as const,
source: {
traceId: item.parentTraceId ?? "",
observationId: item.objectId,
},
};
case "TRACE":
return {
...baseData,
objectType: "TRACE" as const,
source: {
traceId: item.objectId,
},
};
case "SESSION":
return {
...baseData,
objectType: "SESSION" as const,
source: {
sessionId: item.objectId,
},
};
default:
throw new Error(`Unknown object type: ${item.objectType}`);
}
};
const [columnVisibility, setColumnVisibility] =
@@ -370,6 +429,7 @@ export function AnnotationQueueItemsTable({
]}
/>
<DataTable
tableName={"annotationQueueItems"}
columns={columns}
data={
items.isLoading
@@ -1,9 +1,16 @@
import { Tabs, TabsList, TabsTrigger } from "@/src/components/ui/tabs";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/src/components/ui/tooltip";
import useSessionStorage from "@/src/components/useSessionStorage";
import { SupportOrUpgradePage } from "@/src/ee/features/billing/components/SupportOrUpgradePage";
import { useHasProjectAccess } from "@/src/features/rbac/utils/checkProjectAccess";
import { AnnotationQueueItemPage } from "@/src/features/annotation-queues/components/AnnotationQueueItemPage";
import { api } from "@/src/utils/api";
import { AnnotationQueueObjectType } from "@langfuse/shared";
import { Goal, Network } from "lucide-react";
import Page from "@/src/components/layouts/page";
@@ -36,18 +43,33 @@ export const AnnotationQueuesItem = ({
},
);
const currentItemType = api.annotationQueueItems.typeById.useQuery(
{
projectId,
itemId: itemId as string,
queueId: annotationQueueId,
},
{ enabled: !!itemId, refetchOnMount: false },
);
const [view, setView] = useSessionStorage<"hideTree" | "showTree">(
`annotationQueueView-${projectId}`,
"hideTree",
);
const isSessionItem =
currentItemType.data === AnnotationQueueObjectType.SESSION;
const isDetailedViewDisabled = isSessionItem;
if (!hasAccess) return <SupportOrUpgradePage />;
return (
<Page
withPadding
headerProps={{
title: `${queue.data?.name}: ${itemId}`,
title: itemId
? `${queue.data?.name}: ${itemId}`
: (queue.data?.name ?? annotationQueueId),
itemType: "QUEUE_ITEM",
breadcrumb: [
{
@@ -60,23 +82,49 @@ export const AnnotationQueuesItem = ({
},
],
actionButtonsRight: (
<Tabs
value={view}
onValueChange={(view: string) => {
setView(view as "hideTree" | "showTree");
}}
>
<TabsList>
<TabsTrigger value="hideTree">
<Goal className="mr-1 h-4 w-4"></Goal>
Focused
</TabsTrigger>
<TabsTrigger value="showTree">
<Network className="mr-1 h-4 w-4"></Network>
Detailed
</TabsTrigger>
</TabsList>
</Tabs>
<TooltipProvider>
<Tabs
value={isDetailedViewDisabled ? "hideTree" : view}
onValueChange={(view: string) => {
if (!isDetailedViewDisabled) {
setView(view as "hideTree" | "showTree");
}
}}
>
<TabsList>
<TabsTrigger value="hideTree">
<Goal className="mr-1 h-4 w-4"></Goal>
Focused
</TabsTrigger>
<Tooltip>
<TooltipTrigger asChild>
<span>
<TabsTrigger
value="showTree"
disabled={isDetailedViewDisabled}
className={
isDetailedViewDisabled
? "cursor-not-allowed opacity-50"
: ""
}
>
<Network className="mr-1 h-4 w-4"></Network>
Detailed
</TabsTrigger>
</span>
</TooltipTrigger>
{isDetailedViewDisabled && (
<TooltipContent>
<p>
Detailed view is only available for traces and
observations
</p>
</TooltipContent>
)}
</Tooltip>
</TabsList>
</Tabs>
</TooltipProvider>
),
}}
>
@@ -238,6 +238,7 @@ export function AnnotationQueuesTable({ projectId }: { projectId: string }) {
setRowHeight={setRowHeight}
/>
<DataTable
tableName={"annotationQueues"}
columns={columns}
data={
queues.isLoading
@@ -1,4 +1,4 @@
import { Badge } from "@/src/components/ui/badge";
import { StatusBadge } from "@/src/components/layouts/status-badge";
import { Button } from "@/src/components/ui/button";
import {
DropdownMenuItem,
@@ -11,11 +11,7 @@ import {
} from "@/src/components/ui/dropdown-menu";
import { useHasProjectAccess } from "@/src/features/rbac/utils/checkProjectAccess";
import { api } from "@/src/utils/api";
import { cn } from "@/src/utils/tailwind";
import {
AnnotationQueueStatus,
type AnnotationQueueObjectType,
} from "@langfuse/shared";
import { type AnnotationQueueObjectType } from "@langfuse/shared";
import { ChevronDown, ExternalLink } from "lucide-react";
import { useSession } from "next-auth/react";
import Link from "next/link";
@@ -25,10 +21,12 @@ export const CreateNewAnnotationQueueItem = ({
projectId,
objectId,
objectType,
variant = "secondary",
}: {
projectId: string;
objectId: string;
objectType: AnnotationQueueObjectType;
variant?: "outline" | "secondary";
}) => {
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const session = useSession();
@@ -93,7 +91,7 @@ export const CreateNewAnnotationQueueItem = ({
if (session.status !== "authenticated" || queues.isLoading) {
return (
<Button
variant="secondary"
variant={variant}
disabled={session.status !== "authenticated"}
className="rounded-l-none rounded-r-md border-l-2"
>
@@ -116,14 +114,14 @@ export const CreateNewAnnotationQueueItem = ({
>
<DropdownMenuTrigger asChild>
<Button
variant="secondary"
variant={variant}
disabled={!hasAccess}
className="rounded-l-none rounded-r-md border-l-2"
>
{queues.data?.totalCount ? (
<span className="relative mr-1 text-xs">
<ChevronDown className="h-3 w-3 text-secondary-foreground" />
<span className="absolute -top-1.5 left-2.5 flex max-h-[0.8rem] min-w-[0.8rem] items-center justify-center rounded-full border border-muted-foreground bg-accent-light-blue px-[0.2rem] text-[8px]">
<span className="absolute -top-1 left-2.5 flex h-3 min-w-3 items-center justify-center rounded-sm bg-slate-600 px-0.5 text-[8px] font-medium text-white shadow-sm">
{queues.data?.totalCount > 99 ? "99+" : queues.data?.totalCount}
</span>
</span>
@@ -153,17 +151,11 @@ export const CreateNewAnnotationQueueItem = ({
>
{queue.name}
{queue.status && (
<Badge
className={cn(
"ml-2 px-1 py-0.5 text-[10px] capitalize",
queue.status === AnnotationQueueStatus.COMPLETED
? "bg-light-green text-dark-green"
: "bg-light-yellow text-dark-yellow",
)}
variant="outline"
>
{queue.status.toLowerCase()}
</Badge>
<StatusBadge
type={queue.status.toLowerCase()}
isLive={false}
className="ml-2"
/>
)}
</DropdownMenuCheckboxItem>
))
@@ -0,0 +1,167 @@
import {
type AnnotationQueueItem,
type ValidatedScoreConfig,
} from "@langfuse/shared";
import { AnnotationDrawerSection } from "../shared/AnnotationDrawerSection";
import { AnnotationProcessingLayout } from "../shared/AnnotationProcessingLayout";
import { SessionIO } from "@/src/components/session";
import { useState, useEffect } from "react";
import { Button } from "@/src/components/ui/button";
import { ItemBadge } from "@/src/components/ItemBadge";
import { CopyIdsPopover } from "@/src/components/trace/CopyIdsPopover";
import { Badge } from "@/src/components/ui/badge";
import { Separator } from "@/src/components/ui/separator";
import Link from "next/link";
import { Card } from "@/src/components/ui/card";
interface SessionAnnotationProcessorProps {
item: AnnotationQueueItem & {
parentTraceId?: string | null;
lockedByUser: { name: string | null | undefined } | null;
};
data: any; // // Session data with scores
configs: ValidatedScoreConfig[];
projectId: string;
}
// some projects have thousands of traces in a session, paginate to avoid rendering all at once
const PAGE_SIZE = 10;
export const SessionAnnotationProcessor: React.FC<
SessionAnnotationProcessorProps
> = ({ item, data, configs, projectId }) => {
const [visibleTraces, setVisibleTraces] = useState(PAGE_SIZE);
const [currentTraceIndex, setCurrentTraceIndex] = useState(1);
// Intersection observer to which trace is currently in view
useEffect(() => {
if (!data?.traces) return;
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting && entry.intersectionRatio > 0.5) {
const index = parseInt(
entry.target.getAttribute("data-trace-index") || "0",
);
setCurrentTraceIndex(index + 1);
}
});
},
{
threshold: 0.5, // Trigger when 50% of trace is visible
rootMargin: "-25% 0px -25% 0px", // Focus on center area
},
);
// Observe all trace cards
const traceCards = document.querySelectorAll("[data-trace-index]");
traceCards.forEach((card) => observer.observe(card));
return () => observer.disconnect();
}, [data?.traces, visibleTraces]);
const leftPanel = (
<div className="flex h-full flex-col overflow-hidden">
{/* Sticky Header */}
<div className="flex-shrink-0 bg-background">
<div className="mt-3 grid w-full grid-cols-[auto,auto] items-start justify-between gap-2 px-4">
<div className="flex w-full flex-row items-start gap-1">
<div className="mt-1.5">
<ItemBadge type="SESSION" isSmall />
</div>
<span className="mb-0 ml-1 line-clamp-2 min-w-0 break-all font-medium md:break-normal md:break-words">
{item.objectId}
</span>
<CopyIdsPopover
idItems={[{ id: item.objectId, name: "Session ID" }]}
/>
</div>
{data?.traces && (
<div className="flex items-center">
<Badge variant="outline" className="text-xs">
Trace {currentTraceIndex} / {data.traces.length}
</Badge>
</div>
)}
</div>
<div className="mb-4 mt-2 grid w-full min-w-0 items-center justify-between px-4">
<div className="flex min-w-0 max-w-full flex-shrink flex-col">
<div className="flex min-w-0 max-w-full flex-wrap items-center gap-1">
{data.environment && (
<Badge variant="tertiary">Env: {data.environment}</Badge>
)}
<Badge variant="outline">
Total traces: {data?.traces.length}
</Badge>
</div>
</div>
</div>
<Separator />
</div>
{/* Scrollable Content */}
<div className="flex-1 overflow-y-auto">
<div className="p-4">
{data?.traces
.slice(0, visibleTraces)
.map((trace: any, index: number) => (
<Card
className="group mb-2 grid gap-2 border-border p-2 shadow-none hover:border-ring"
key={trace.id}
data-trace-index={index}
>
<div className="-mt-1 p-1 pt-0 opacity-50 transition-opacity group-hover:opacity-100">
<Link
href={`/project/${projectId}/traces/${trace.id}`}
className="text-xs hover:underline"
>
Trace: {trace.name} ({trace.id})&nbsp;
</Link>
<div className="text-xs text-muted-foreground">
{trace.timestamp.toLocaleString()}
</div>
</div>
<SessionIO
traceId={trace.id}
projectId={projectId}
timestamp={trace.timestamp}
/>
</Card>
))}
{data?.traces && data.traces.length > visibleTraces && (
<div className="flex justify-center py-4">
<Button
onClick={() => setVisibleTraces((prev) => prev + PAGE_SIZE)}
variant="ghost"
>
{`Load ${Math.min(data.traces.length - visibleTraces, PAGE_SIZE)} More`}
</Button>
</div>
)}
</div>
</div>
</div>
);
const rightPanel = (
<AnnotationDrawerSection
item={item}
scoreTarget={{
type: "session",
sessionId: item.objectId,
}}
scores={data?.scores ?? []}
configs={configs}
environment={data?.environment}
/>
);
return (
<AnnotationProcessingLayout
leftPanel={leftPanel}
rightPanel={rightPanel}
projectId={projectId}
/>
);
};
@@ -0,0 +1,114 @@
import { Trace } from "@/src/components/trace";
import { ObservationPreview } from "@/src/components/trace/ObservationPreview";
import { TracePreview } from "@/src/components/trace/TracePreview";
import {
type AnnotationQueueItem,
AnnotationQueueObjectType,
type ValidatedScoreConfig,
} from "@langfuse/shared";
import { useEffect } from "react";
import { StringParam, useQueryParam } from "use-query-params";
import { AnnotationDrawerSection } from "../shared/AnnotationDrawerSection";
import { AnnotationProcessingLayout } from "../shared/AnnotationProcessingLayout";
interface TraceAnnotationProcessorProps {
item: AnnotationQueueItem & {
parentTraceId?: string | null;
lockedByUser: { name: string | null | undefined } | null;
};
data: any; // Trace data with observations and scores
view: "showTree" | "hideTree";
configs: ValidatedScoreConfig[];
projectId: string;
}
export const TraceAnnotationProcessor: React.FC<
TraceAnnotationProcessorProps
> = ({ item, data, view, configs, projectId }) => {
const traceId = item.parentTraceId ?? item.objectId;
const [currentObservationId, setCurrentObservationId] = useQueryParam(
"observation",
StringParam,
);
useEffect(() => {
if (
view === "showTree" &&
item.objectType === AnnotationQueueObjectType.OBSERVATION
) {
setCurrentObservationId(item.objectId);
} else setCurrentObservationId(undefined);
}, [view, item, setCurrentObservationId]);
if (!data) return <div className="p-3">Loading...</div>;
let isValidObservationId = false;
if (
currentObservationId &&
data.observations.some(
({ id }: { id: string }) => id === currentObservationId,
)
) {
isValidObservationId = true;
}
const leftPanel =
view === "hideTree" ? (
<div className="flex h-full flex-col overflow-y-auto pl-4">
{item.objectType === AnnotationQueueObjectType.TRACE ? (
<TracePreview
key={data.id}
trace={data}
scores={data.scores}
observations={data.observations}
viewType="focused"
/>
) : (
<ObservationPreview
observations={data.observations}
scores={data.scores}
projectId={item.projectId}
currentObservationId={item.objectId}
traceId={traceId}
viewType="focused"
/>
)}
</div>
) : (
<div className="flex h-full flex-col overflow-y-auto">
<Trace
key={data.id}
trace={data}
scores={data.scores}
projectId={data.projectId}
observations={data.observations}
viewType="focused"
isValidObservationId={isValidObservationId}
/>
</div>
);
const rightPanel = (
<AnnotationDrawerSection
item={item}
scoreTarget={{
type: "trace",
traceId: traceId,
observationId: item.parentTraceId ? item.objectId : undefined,
}}
scores={data?.scores ?? []}
configs={configs}
environment={data?.environment}
/>
);
return (
<AnnotationProcessingLayout
leftPanel={leftPanel}
rightPanel={rightPanel}
projectId={projectId}
/>
);
};
@@ -0,0 +1,96 @@
import { Card } from "@/src/components/ui/card";
import useSessionStorage from "@/src/components/useSessionStorage";
import { AnnotateDrawerContent } from "@/src/features/scores/components/AnnotateDrawerContent";
import { type ScoreTarget } from "@/src/features/scores/types";
import {
type AnnotationQueueItem,
type APIScoreV2,
isPresent,
type ValidatedScoreConfig,
} from "@langfuse/shared";
import { TriangleAlertIcon } from "lucide-react";
import { useSession } from "next-auth/react";
import { useRouter } from "next/router";
import { useMemo, useState } from "react";
import { CommentsSection } from "./CommentsSection";
interface AnnotationDrawerSectionProps {
item: AnnotationQueueItem & {
parentTraceId?: string | null;
lockedByUser: { name: string | null | undefined } | null;
};
scoreTarget: ScoreTarget;
scores: APIScoreV2[];
configs: ValidatedScoreConfig[];
environment?: string;
}
export const AnnotationDrawerSection: React.FC<
AnnotationDrawerSectionProps
> = ({ item, scoreTarget, scores, configs, environment }) => {
const router = useRouter();
const session = useSession();
const projectId = router.query.projectId as string;
const [showSaving, setShowSaving] = useState(false);
const [showComments, setShowComments] = useSessionStorage(
`annotationQueueShowComments-${projectId}`,
false,
);
const isLockedByOtherUser = item.lockedByUserId !== session.data?.user?.id;
const emptySelectedConfigIds = useMemo(() => {
return configs.map((c) => c.id);
}, [configs]);
const handleSavingChange = (saving: boolean) => {
setShowSaving(saving);
};
return (
<Card className="col-span-2 flex h-full flex-col overflow-hidden">
<div className="grid h-full w-full grid-cols-1 grid-rows-[minmax(auto,1fr),minmax(min-content,auto)] justify-between">
<div className="w-full overflow-auto">
<AnnotateDrawerContent
key={"annotation-drawer-content" + item.objectId}
scoreTarget={scoreTarget}
scores={scores}
configs={configs}
emptySelectedConfigIds={emptySelectedConfigIds}
setEmptySelectedConfigIds={() => {}}
projectId={item.projectId}
analyticsData={{
type: scoreTarget.type,
source: "AnnotationQueue",
}}
isSelectHidden
queueId={item.queueId}
showSaving={showSaving}
setShowSaving={handleSavingChange}
environment={environment}
actionButtons={
isLockedByOtherUser && isPresent(item.lockedByUser?.name) ? (
<div className="flex items-center justify-center rounded-sm border border-dark-red bg-light-red p-1">
<TriangleAlertIcon className="mr-1 h-4 w-4 text-dark-red" />
<span className="text-xs text-dark-red">
Currently edited by {item.lockedByUser.name}
</span>
</div>
) : undefined
}
isDrawerOpen={true}
/>
</div>
<div className="relative max-h-64 overflow-auto">
<CommentsSection
projectId={item.projectId}
objectId={item.objectId}
objectType={item.objectType}
showComments={showComments}
onToggleComments={setShowComments}
/>
</div>
</div>
</Card>
);
};
@@ -0,0 +1,46 @@
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from "@/src/components/ui/resizable";
import useSessionStorage from "@/src/components/useSessionStorage";
interface AnnotationProcessingLayoutProps {
leftPanel: React.ReactNode;
rightPanel: React.ReactNode;
projectId: string;
}
export const AnnotationProcessingLayout: React.FC<
AnnotationProcessingLayoutProps
> = ({ leftPanel, rightPanel, projectId }) => {
const [panelSize, setPanelSize] = useSessionStorage(
`annotationQueuePanelSize-${projectId}`,
65,
);
return (
<ResizablePanelGroup
direction="horizontal"
className="h-full overflow-hidden"
onLayout={(sizes) => {
setPanelSize(sizes[0]);
}}
>
<ResizablePanel
className="col-span-1 h-full !overflow-y-auto rounded-md border"
minSize={30}
defaultSize={panelSize}
>
{leftPanel}
</ResizablePanel>
<ResizableHandle withHandle className="ml-4 bg-transparent" />
<ResizablePanel
className="col-span-1 h-full md:flex md:flex-col md:overflow-hidden"
minSize={30}
>
{rightPanel}
</ResizablePanel>
</ResizablePanelGroup>
);
};
@@ -0,0 +1,51 @@
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/src/components/ui/accordion";
import { CommentList } from "@/src/features/comments/CommentList";
import { type AnnotationQueueObjectType } from "@langfuse/shared";
interface CommentsSectionProps {
projectId: string;
objectId: string;
objectType: AnnotationQueueObjectType;
showComments: boolean;
onToggleComments: (show: boolean) => void;
}
export const CommentsSection: React.FC<CommentsSectionProps> = ({
projectId,
objectId,
objectType,
showComments,
onToggleComments,
}) => {
return (
<Accordion
type="single"
collapsible
className="mx-4 mt-4"
value={showComments ? "item-1" : ""}
onValueChange={(value) => onToggleComments(value === "item-1")}
>
<AccordionItem value="item-1" className="border-none">
<div className="sticky top-0 z-10 border-b bg-background">
<AccordionTrigger onClick={() => onToggleComments(!showComments)}>
Comments
</AccordionTrigger>
</div>
<AccordionContent>
<CommentList
projectId={projectId}
objectId={objectId}
objectType={objectType}
className="rounded-t-none border-t-transparent"
cardView
/>
</AccordionContent>
</AccordionItem>
</Accordion>
);
};
@@ -0,0 +1,73 @@
import { api } from "@/src/utils/api";
import {
type AnnotationQueueItem,
AnnotationQueueObjectType,
} from "@langfuse/shared";
export interface ObjectDataHook<TData> {
data: TData | undefined;
isLoading: boolean;
}
export const useAnnotationObjectData = (
item: (AnnotationQueueItem & { parentTraceId?: string | null }) | null,
projectId: string,
): ObjectDataHook<any> => {
const traceId = item?.parentTraceId ?? item?.objectId;
const traceQuery = api.traces.byIdWithObservationsAndScores.useQuery(
{ traceId: traceId as string, projectId },
{
enabled:
!!item &&
(item.objectType === AnnotationQueueObjectType.TRACE ||
item.objectType === AnnotationQueueObjectType.OBSERVATION),
retry(failureCount, error) {
if (error.data?.code === "UNAUTHORIZED") return false;
return failureCount < 3;
},
},
);
const sessionQuery = api.sessions.byIdWithScores.useQuery(
{
sessionId: item?.objectId as string,
projectId,
},
{
enabled: !!item && item.objectType === AnnotationQueueObjectType.SESSION,
retry(failureCount, error) {
if (
error.data?.code === "UNAUTHORIZED" ||
error.data?.code === "NOT_FOUND"
)
return false;
return failureCount < 3;
},
},
);
if (!item) {
return {
data: undefined,
isLoading: false,
};
}
switch (item.objectType) {
case AnnotationQueueObjectType.TRACE:
case AnnotationQueueObjectType.OBSERVATION:
return {
data: traceQuery.data,
isLoading: traceQuery.isLoading,
};
case AnnotationQueueObjectType.SESSION:
return {
data: sessionQuery.data,
isLoading: sessionQuery.isLoading,
};
default:
// This should never happen as it's a DB enum
throw new Error(`Unsupported object type: ${item.objectType}`);
}
};
@@ -0,0 +1,25 @@
import { api } from "@/src/utils/api";
export interface AnnotationQueueDataConfig {
annotationQueueId: string;
projectId: string;
}
export const useAnnotationQueueData = (config: AnnotationQueueDataConfig) => {
const queueData = api.annotationQueues.byId.useQuery(
{
queueId: config.annotationQueueId,
projectId: config.projectId,
},
{
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
},
);
return {
queueData,
configs: queueData.data?.scoreConfigs ?? [],
};
};
@@ -6,12 +6,14 @@ import {
protectedProjectProcedure,
} from "@/src/server/api/trpc";
import {
type ActionId,
type AnnotationQueueItem,
AnnotationQueueObjectType,
AnnotationQueueStatus,
BatchActionQuerySchema,
BatchActionType,
BatchExportTableName,
type BatchTableNames,
paginationZod,
Prisma,
} from "@langfuse/shared";
@@ -31,7 +33,42 @@ const isItemLocked = (item: AnnotationQueueItem) => {
);
};
const MAP_OBJECT_TYPE_TO_ACTION_PROPS: Record<
"TRACE" | "SESSION",
{ actionId: ActionId; tableName: BatchTableNames }
> = {
[AnnotationQueueObjectType.TRACE]: {
actionId: "trace-add-to-annotation-queue",
tableName: BatchExportTableName.Traces,
},
[AnnotationQueueObjectType.SESSION]: {
actionId: "session-add-to-annotation-queue",
tableName: BatchExportTableName.Sessions,
},
};
export const queueItemRouter = createTRPCRouter({
typeById: protectedProjectProcedure
.input(
z.object({
projectId: z.string(),
itemId: z.string(),
queueId: z.string(),
}),
)
.query(async ({ input, ctx }) => {
const item = await ctx.prisma.annotationQueueItem.findUnique({
where: {
id: input.itemId,
projectId: input.projectId,
queueId: input.queueId,
},
select: {
objectType: true,
},
});
return item?.objectType;
}),
byId: protectedProjectProcedure
.input(
z.object({
@@ -248,11 +285,22 @@ export const queueItemRouter = createTRPCRouter({
let createdCount = 0;
if (input.isBatchAction && input.query) {
const actionProps =
MAP_OBJECT_TYPE_TO_ACTION_PROPS[
input.objectType as "TRACE" | "SESSION"
];
if (!actionProps) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `Batch action not supported for object type: ${input.objectType}`,
});
}
const { actionId, tableName } = actionProps;
await createBatchActionJob({
projectId: input.projectId,
actionId: "trace-add-to-annotation-queue",
actionId,
actionType: BatchActionType.Create,
tableName: BatchExportTableName.Traces,
tableName,
session: ctx.session,
query: input.query,
targetId: input.queueId,
@@ -158,6 +158,7 @@ export const AutomationExecutionsTable: React.FC<
setRowHeight={setRowHeight}
/>
<DataTable
tableName={"automationExecutions"}
columns={columns}
data={{
isLoading,
@@ -320,7 +320,6 @@ export const automationsRouter = createTRPCRouter({
};
}),
// Update an existing automation
updateAutomation: protectedProjectProcedure
.input(UpdateAutomationInputSchema)
.mutation(async ({ ctx, input }) => {
@@ -389,7 +388,6 @@ export const automationsRouter = createTRPCRouter({
},
});
// Get the updated automation
const automation = await tx.automation.findFirst({
where: {
id: input.automationId,
@@ -23,16 +23,6 @@ interface WebhookConfigOptions {
projectId: string;
}
/**
* Processes webhook action configuration by:
* 1. Merging legacy headers with new requestHeaders
* 2. Handling header removal (headers not in input are removed)
* 3. Preserving existing values when empty values are submitted
* 4. Encrypting secret headers based on secret flag
* 5. Generating display values for secret headers
* 6. Generating or preserving webhook secrets
* 7. Encrypting secrets for storage
*/
export async function processWebhookActionConfig({
actionConfig,
actionId,
@@ -60,7 +50,6 @@ export async function processWebhookActionConfig({
actionConfig,
existingAction?.config as WebhookActionConfigWithSecrets | undefined,
);
return {
finalActionConfig: {
...finalActionConfig,
@@ -156,6 +145,7 @@ function processWebhookHeaders(
displayHeaders: createDisplayHeaders(finalRequestHeaders),
secretKey: "", // will be overwritten by the caller
displaySecretKey: "", // will be overwritten by the caller
lastFailingExecutionId: existingConfig?.lastFailingExecutionId,
};
}
@@ -87,6 +87,7 @@ export default function BackgroundMigrationsTable() {
<Header title="Background Migrations" />
<DataTableToolbar columns={columns} />
<DataTable
tableName={"backgroundMigrations"}
columns={columns}
data={
backgroundMigrations.isLoading
@@ -134,6 +134,7 @@ export function BatchExportsTable(props: { projectId: string }) {
return (
<>
<DataTable
tableName={"batchExports"}
columns={columns}
data={
batchExports.isLoading
@@ -282,6 +282,7 @@ export function DashboardTable() {
return (
<DataTable
tableName={"dashboards"}
columns={dashboardColumns}
data={
dashboards.isLoading
@@ -419,6 +419,7 @@ function DatasetCompareRunsTableInternal(props: {
}
/>
<DataTable
tableName={"datasetCompareRuns"}
columns={columns}
columnVisibility={columnVisibility}
onColumnVisibilityChange={setColumnVisibility}
@@ -368,6 +368,7 @@ export function DatasetItemsTable({
actionButtons={[menuItems, batchExportButton].filter(Boolean)}
/>
<DataTable
tableName={"datasetItems"}
columns={columns}
data={
items.isLoading
@@ -297,6 +297,7 @@ export function DatasetRunItemsTable(
setRowHeight={setRowHeight}
/>
<DataTable
tableName={"datasetRunItems"}
columns={columns}
data={
runItems.isLoading
@@ -162,6 +162,7 @@ const DatasetRunTableMultiSelectAction = ({
capture("dataset_run:delete_form_submit");
await mutDelete.mutateAsync({
projectId,
datasetId,
datasetRunIds: selectedRunIds,
});
setIsDeleteDialogOpen(false);
@@ -469,6 +470,7 @@ export function DatasetRunsTable(props: {
<DeleteDatasetRunButton
projectId={props.projectId}
datasetRunId={id}
datasetId={props.datasetId}
/>
</DropdownMenuContent>
</DropdownMenu>
@@ -618,6 +620,7 @@ export function DatasetRunsTable(props: {
]}
/>
<DataTable
tableName={"datasetRuns"}
columns={columns}
data={
runs.isLoading
@@ -678,6 +681,7 @@ export function DatasetRunsTable(props: {
]}
/>
<DataTable
tableName={"datasetRuns"}
columns={columns}
data={
runs.isLoading
@@ -282,6 +282,7 @@ export function DatasetsTable(props: { projectId: string }) {
}}
/>
<DataTable
tableName={"datasets"}
columns={columns}
data={
datasets.isLoading || isViewLoading
@@ -19,10 +19,12 @@ export const DeleteDatasetRunButton = ({
projectId,
datasetRunId,
redirectUrl,
datasetId,
}: {
projectId: string;
datasetRunId: string;
redirectUrl?: string;
datasetId: string;
}) => {
const [isDialogOpen, setIsDialogOpen] = useState(false);
const capture = usePostHogClientCapture();
@@ -80,6 +82,7 @@ export const DeleteDatasetRunButton = ({
capture("dataset_run:delete_form_submit");
await mutDelete.mutateAsync({
projectId,
datasetId: datasetId,
datasetRunIds: [datasetRunId],
});
setIsDialogOpen(false);
@@ -26,6 +26,9 @@ import {
logger,
getRunScoresGroupedByNameSourceType,
getDatasetRunItemsTableCountPg,
executeWithDatasetRunItemsStrategy,
DatasetRunItemsOperationType,
addToDeleteDatasetQueue,
} from "@langfuse/shared/src/server";
import { createId as createCuid } from "@paralleldrive/cuid2";
import { composeAggregateScoreKey } from "@/src/features/scores/lib/aggregateScores";
@@ -519,6 +522,7 @@ export const datasetRouter = createTRPCRouter({
projectId: input.projectId,
scope: "datasets:CUD",
});
const deletedDataset = await ctx.prisma.dataset.delete({
where: {
id_projectId: {
@@ -528,6 +532,19 @@ export const datasetRouter = createTRPCRouter({
},
});
await executeWithDatasetRunItemsStrategy({
input,
operationType: DatasetRunItemsOperationType.WRITE,
postgresExecution: async () => {},
clickhouseExecution: async (queryInput: typeof input) => {
await addToDeleteDatasetQueue({
deletionType: "dataset",
projectId: queryInput.projectId,
datasetId: deletedDataset.id,
});
},
});
await auditLog({
session: ctx.session,
resourceType: "dataset",
@@ -535,6 +552,7 @@ export const datasetRouter = createTRPCRouter({
action: "delete",
before: deletedDataset,
});
return deletedDataset;
}),
@@ -943,6 +961,8 @@ export const datasetRouter = createTRPCRouter({
.input(
z.object({
projectId: z.string(),
// temporary: make optional to not break existing contracts
datasetId: z.string().optional(),
datasetRunIds: z.array(z.string()),
}),
)
@@ -969,6 +989,23 @@ export const datasetRouter = createTRPCRouter({
},
});
await executeWithDatasetRunItemsStrategy({
input,
operationType: DatasetRunItemsOperationType.WRITE,
postgresExecution: async () => {},
clickhouseExecution: async () => {
// Trigger async delete of dataset run items
await addToDeleteDatasetQueue({
deletionType: "dataset-runs",
projectId: input.projectId,
// temporary: while dataset id is optional, we can pull it from the first run
// users can only use this on pages in UI that are pre-filtered by dataset id
datasetId: input.datasetId ?? datasetRuns[0].datasetId,
datasetRunIds: input.datasetRunIds,
});
},
});
// Log audit entries for each deleted run
await Promise.all(
datasetRuns.map((run) =>
@@ -981,6 +1018,8 @@ export const datasetRouter = createTRPCRouter({
}),
),
);
return datasetRuns;
}),
getRunLevelScoreKeysAndProps: protectedProjectProcedure
.input(
@@ -1036,4 +1075,207 @@ export const datasetRouter = createTRPCRouter({
dataType: dataType,
}));
}),
upsertRemoteExperiment: protectedProjectProcedure
.input(
z.object({
projectId: z.string(),
datasetId: z.string(),
url: z.string(),
defaultPayload: z.string(),
}),
)
.mutation(async ({ input, ctx }) => {
throwIfNoProjectAccess({
session: ctx.session,
projectId: input.projectId,
scope: "datasets:CUD",
});
const dataset = await ctx.prisma.dataset.findUnique({
where: {
id_projectId: {
id: input.datasetId,
projectId: input.projectId,
},
},
});
if (!dataset) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Dataset not found",
});
}
const updatedDataset = await ctx.prisma.dataset.update({
where: {
id_projectId: {
id: input.datasetId,
projectId: input.projectId,
},
},
data: {
remoteExperimentUrl: input.url,
remoteExperimentPayload: input.defaultPayload ?? {},
},
});
await auditLog({
session: ctx.session,
resourceType: "dataset",
resourceId: updatedDataset.id,
action: "update",
after: updatedDataset,
});
return updatedDataset;
}),
getRemoteExperiment: protectedProjectProcedure
.input(z.object({ projectId: z.string(), datasetId: z.string() }))
.query(async ({ input, ctx }) => {
const dataset = await ctx.prisma.dataset.findUnique({
where: {
id_projectId: { id: input.datasetId, projectId: input.projectId },
},
select: {
remoteExperimentUrl: true,
remoteExperimentPayload: true,
},
});
if (!dataset || !dataset.remoteExperimentUrl) return null;
return {
url: dataset.remoteExperimentUrl,
payload: dataset.remoteExperimentPayload,
};
}),
triggerRemoteExperiment: protectedProjectProcedure
.input(
z.object({
projectId: z.string(),
datasetId: z.string(),
payload: z.string().optional(),
}),
)
.mutation(async ({ input, ctx }) => {
throwIfNoProjectAccess({
session: ctx.session,
projectId: input.projectId,
scope: "datasets:CUD",
});
const dataset = await ctx.prisma.dataset.findUnique({
where: {
id_projectId: {
id: input.datasetId,
projectId: input.projectId,
},
},
select: {
id: true,
name: true,
remoteExperimentUrl: true,
remoteExperimentPayload: true,
},
});
if (!dataset) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Dataset not found",
});
}
if (!dataset.remoteExperimentUrl) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "No remoteExperiment URL configured for this dataset",
});
}
try {
const response = await fetch(dataset.remoteExperimentUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
projectId: input.projectId,
datasetId: input.datasetId,
datasetName: dataset.name,
payload: input.payload ?? dataset.remoteExperimentPayload,
}),
signal: AbortSignal.timeout(10000), // 10 second timeout
});
if (!response.ok) {
return {
success: false,
};
}
return {
success: true,
};
} catch (error) {
console.log({ error });
if (error instanceof Error) {
return {
success: false,
};
}
return {
success: false,
};
}
}),
deleteRemoteExperiment: protectedProjectProcedure
.input(z.object({ projectId: z.string(), datasetId: z.string() }))
.mutation(async ({ input, ctx }) => {
throwIfNoProjectAccess({
session: ctx.session,
projectId: input.projectId,
scope: "datasets:CUD",
});
const dataset = await ctx.prisma.dataset.findUnique({
where: {
id_projectId: {
id: input.datasetId,
projectId: input.projectId,
},
},
});
if (!dataset) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Dataset not found",
});
}
const updatedDataset = await ctx.prisma.dataset.update({
where: {
id_projectId: {
id: input.datasetId,
projectId: input.projectId,
},
},
data: {
remoteExperimentUrl: null,
remoteExperimentPayload: Prisma.DbNull,
},
});
await auditLog({
session: ctx.session,
resourceType: "dataset",
resourceId: updatedDataset.id,
action: "update",
after: updatedDataset,
});
return updatedDataset;
}),
});
@@ -206,6 +206,7 @@ export default function EvalLogTable({
filterColumnDefinition={evalExecutionsFilterCols}
/>
<DataTable
tableName={"evalLogs"}
columns={columns}
data={
logs.isLoading
@@ -324,6 +324,7 @@ export default function EvalsTemplateTable({
}}
/>
<DataTable
tableName={"evalTemplates"}
columns={columns}
peekView={{
itemType: "EVALUATOR",
@@ -371,6 +371,7 @@ export default function EvaluatorTable({ projectId }: { projectId: string }) {
}}
/>
<DataTable
tableName={"evalConfigs"}
columns={columns}
peekView={{
itemType: "RUNNING_EVALUATOR",
@@ -1068,7 +1068,7 @@ export const InnerEvaluatorForm = (props: {
loading={createJobMutation.isLoading || updateJobMutation.isLoading}
className="mt-3 max-w-fit"
>
Execute
{props.mode === "edit" ? "Update" : "Execute"}
</Button>
) : null}
{formError ? (
@@ -185,6 +185,7 @@ export const InnerEvalTemplateForm = (props: {
updateModelParamValue,
setModelParamEnabled,
availableModels,
providerModelCombinations,
availableProviders,
} = useModelParams();
@@ -424,6 +425,7 @@ export const InnerEvalTemplateForm = (props: {
{...{
modelParams,
availableModels,
providerModelCombinations,
availableProviders,
updateModelParamValue: updateModelParamValue,
setModelParamEnabled,
@@ -47,6 +47,7 @@ export default function DefaultEvaluationModelPage() {
updateModelParamValue,
setModelParamEnabled,
availableModels,
providerModelCombinations,
availableProviders,
} = useModelParams();
@@ -156,6 +157,7 @@ export default function DefaultEvaluationModelPage() {
{...{
modelParams,
availableModels,
providerModelCombinations,
availableProviders,
updateModelParamValue,
setModelParamEnabled,
@@ -1,46 +1,6 @@
import React, { useEffect, useState } from "react";
import React, { useState } from "react";
import { Button } from "@/src/components/ui/button";
import {
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/src/components/ui/form";
import { Form } from "@/src/components/ui/form";
import { Textarea } from "@/src/components/ui/textarea";
import { ModelParameters } from "@/src/components/ModelParameters";
import {
InputCommandEmpty,
InputCommandGroup,
InputCommandInput,
InputCommandList,
InputCommand,
InputCommandItem,
} from "@/src/components/ui/input-command";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/src/components/ui/select";
import { cn } from "@/src/utils/tailwind";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/src/components/ui/popover";
import {
ChevronDown,
CheckIcon,
Info,
CircleCheck,
Loader2,
Code2,
Wand2,
} from "lucide-react";
import { Code2, Wand2, Cog, Zap } from "lucide-react";
import { api } from "@/src/utils/api";
import {
Card,
@@ -50,51 +10,20 @@ import {
CardContent,
CardFooter,
} from "@/src/components/ui/card";
import { showErrorToast } from "@/src/features/notifications/showErrorToast";
import { useModelParams } from "@/src/features/playground/page/hooks/useModelParams";
import { ZodModelConfig } from "@langfuse/shared";
import { useHasProjectAccess } from "@/src/features/rbac/utils/checkProjectAccess";
import { Skeleton } from "@/src/components/ui/skeleton";
import { Input } from "@/src/components/ui/input";
import {
DialogHeader,
DialogTitle,
DialogDescription,
Dialog,
DialogContent,
DialogBody,
DialogFooter,
} from "@/src/components/ui/dialog";
import Link from "next/link";
import { usePostHogClientCapture } from "@/src/features/posthog-analytics/usePostHogClientCapture";
import { TemplateSelector } from "@/src/features/evals/components/template-selector";
import { EvaluatorForm } from "@/src/features/evals/components/evaluator-form";
import { useEvaluatorDefaults } from "@/src/features/experiments/hooks/useEvaluatorDefaults";
import { useExperimentEvaluatorData } from "@/src/features/experiments/hooks/useExperimentEvaluatorData";
import { z } from "zod/v4";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { getFinalModelParams } from "@/src/utils/getFinalModelParams";
import { useExperimentNameValidation } from "@/src/features/experiments/hooks/useExperimentNameValidation";
import { useExperimentPromptData } from "@/src/features/experiments/hooks/useExperimentPromptData";
const CreateExperimentData = z.object({
name: z
.union([z.string().length(0), z.string().min(1)])
.optional()
.transform((str) => str?.trim())
.transform((str) => (str === "" ? undefined : str)),
promptId: z.string().min(1, "Please select a prompt"),
datasetId: z.string().min(1, "Please select a dataset"),
description: z.string().max(1000).optional(),
modelConfig: z.object({
provider: z.string().min(1, "Please select a provider"),
model: z.string().min(1, "Please select a model"),
modelParams: ZodModelConfig,
}),
});
export type CreateExperiment = z.infer<typeof CreateExperimentData>;
import { type CreateExperiment } from "@/src/features/experiments/types";
import { PromptExperimentsForm } from "@/src/features/experiments/components/PromptExperimentsForm";
import { RemoteExperimentUpsertForm } from "@/src/features/experiments/components/RemoteExperimentUpsertForm";
import { RemoteExperimentTriggerModal } from "@/src/features/experiments/components/RemoteExperimentTriggerModal";
import { Skeleton } from "@/src/components/ui/skeleton";
export const CreateExperimentsForm = ({
projectId,
@@ -126,162 +55,46 @@ export const CreateExperimentsForm = ({
}) => Promise<void>;
showSDKRunInfoPage?: boolean;
}) => {
const [open, setOpen] = useState(false);
const capture = usePostHogClientCapture();
const [selectedPromptName, setSelectedPromptName] = useState<string>(
promptDefault?.name ?? "",
);
const [selectedPromptVersion, setSelectedPromptVersion] = useState<
number | null
>(promptDefault?.version ?? null);
const [showPromptForm, setShowPromptForm] = useState(false);
const { createDefaultEvaluator } = useEvaluatorDefaults();
const {
modelParams,
updateModelParamValue,
setModelParamEnabled,
availableModels,
availableProviders,
} = useModelParams();
const form = useForm({
resolver: zodResolver(CreateExperimentData),
defaultValues: {
promptId: "",
datasetId: "",
modelConfig: {},
...defaultValues,
},
});
const [showRemoteExperimentUpsertForm, setShowRemoteExperimentUpsertForm] =
useState(false);
const [
showRemoteExperimentTriggerModal,
setShowRemoteExperimentTriggerModal,
] = useState(false);
const hasExperimentWriteAccess = useHasProjectAccess({
projectId,
scope: "promptExperiments:CUD",
});
const hasEvalReadAccess = useHasProjectAccess({
projectId,
scope: "evalJob:read",
});
const datasetId = defaultValues.datasetId;
const datasetId = form.watch("datasetId");
const evaluators = api.evals.jobConfigsByTarget.useQuery(
{ projectId, targetObject: "dataset" },
{
enabled: hasEvalReadAccess && !!datasetId,
},
);
const evalTemplates = api.evals.allTemplates.useQuery(
{ projectId },
{
enabled: hasEvalReadAccess,
},
);
const datasets = api.datasets.allDatasetMeta.useQuery(
{ projectId },
{
trpc: {
context: {
skipBatch: true,
},
},
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
staleTime: Infinity,
enabled: true,
},
);
const {
activeEvaluators,
inActiveEvaluators,
selectedEvaluatorData,
showEvaluatorForm,
handleConfigureEvaluator,
handleCloseEvaluatorForm,
handleEvaluatorSuccess,
handleSelectEvaluator,
} = useExperimentEvaluatorData({
datasetId,
createDefaultEvaluator,
evaluatorsData: evaluators.data,
evalTemplatesData: evalTemplates.data,
refetchEvaluators: evaluators.refetch,
});
const { promptId, promptsByName, expectedColumns } = useExperimentPromptData({
projectId,
form,
});
const validationResult = api.experiments.validateConfig.useQuery(
const existingRemoteExperiment = api.datasets.getRemoteExperiment.useQuery(
{
projectId,
promptId: promptId as string,
datasetId: datasetId as string,
},
{
enabled: Boolean(promptId && datasetId),
enabled: !!datasetId,
},
);
const experimentMutation = api.experiments.createExperiment.useMutation({
onSuccess: handleExperimentSuccess ?? (() => {}),
onError: (error) => {
showErrorToast(
error.message || "Failed to trigger experiment run",
"Please try again.",
);
},
onSettled: handleExperimentSettled ?? (() => {}),
});
useExperimentNameValidation({
projectId,
datasetId,
form,
});
// Watch model config changes and update form
useEffect(() => {
form.setValue("modelConfig", {
provider: modelParams.provider.value,
model: modelParams.model.value,
modelParams: getFinalModelParams(modelParams),
});
// Clear errors when valid values are set
if (modelParams.provider.value && modelParams.model.value) {
form.clearErrors("modelConfig");
}
}, [modelParams, form]);
const onSubmit = async (data: CreateExperiment) => {
capture("dataset_run:new_form_submit");
const experiment = {
...data,
projectId,
};
await experimentMutation.mutateAsync(experiment);
form.reset();
setFormOpen(false);
};
// TODO: show a warning if someone has multiple configs defined for the same template that target this dataset. Do not let them submit the form to create experiment.
// Prompt them to delete duplicate running evaluators.
if (!hasExperimentWriteAccess) {
return null;
}
if (showSDKRunInfoPage && !showPromptForm) {
if (existingRemoteExperiment.isLoading && !!datasetId) {
return <Skeleton className="h-48 w-full" />;
}
if (
showSDKRunInfoPage &&
!showPromptForm &&
!showRemoteExperimentUpsertForm &&
!showRemoteExperimentTriggerModal
) {
return (
<>
<DialogHeader>
@@ -299,8 +112,8 @@ export const CreateExperimentsForm = ({
.
</DialogDescription>
</DialogHeader>
<DialogBody>
<div className="mt-4 flex flex-col gap-2 sm:flex-row">
<DialogBody className="pb-8">
<div className="mt-4 grid grid-cols-2 grid-rows-1 gap-2">
<Card className="flex flex-1 flex-col">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-lg">
@@ -326,7 +139,7 @@ export const CreateExperimentsForm = ({
Create
</Button>
<Button
variant="secondary"
variant="outline"
className="w-full"
asChild
onClick={() =>
@@ -357,10 +170,28 @@ export const CreateExperimentsForm = ({
<li>Integration with your codebase</li>
</ul>
</CardContent>
<CardFooter className="mt-auto">
<CardFooter className="mt-auto flex flex-row gap-2">
{!!existingRemoteExperiment.data && datasetId && (
<div className="flex items-start">
<Button
className="rounded-r-none"
onClick={() => setShowRemoteExperimentTriggerModal(true)}
>
Run
</Button>
<Button
className="rounded-l-none rounded-r-md border-l-2 px-2"
onClick={() => setShowRemoteExperimentUpsertForm(true)}
>
<span className="relative mr-1 text-xs">
<Cog className="h-3 w-3" />
</span>
</Button>
</div>
)}
<Button
className="w-full"
variant="secondary"
className="flex-1"
variant="outline"
asChild
onClick={() =>
capture("dataset_run:view_custom_experiment_docs")
@@ -373,6 +204,17 @@ export const CreateExperimentsForm = ({
View Docs
</Link>
</Button>
{!existingRemoteExperiment.data && (
<Button
variant="outline"
title="Set up remote experiment in UI trigger"
className="h-8 w-8 flex-shrink-0"
size="icon"
onClick={() => setShowRemoteExperimentUpsertForm(true)}
>
<Zap className="h-4 w-4" />
</Button>
)}
</CardFooter>
</Card>
</div>
@@ -380,445 +222,43 @@ export const CreateExperimentsForm = ({
</>
);
}
if (
!promptsByName ||
!datasets.data ||
(hasEvalReadAccess && !!datasetId && !evaluators.data)
showRemoteExperimentTriggerModal &&
datasetId &&
existingRemoteExperiment.data
) {
return <Skeleton className="min-h-[70dvh] w-full" />;
return (
<RemoteExperimentTriggerModal
projectId={projectId}
datasetId={datasetId}
remoteExperimentConfig={existingRemoteExperiment.data}
setShowTriggerModal={setShowRemoteExperimentTriggerModal}
/>
);
}
if (showRemoteExperimentUpsertForm && datasetId) {
return (
<RemoteExperimentUpsertForm
projectId={projectId}
datasetId={datasetId}
existingRemoteExperiment={existingRemoteExperiment.data}
setShowRemoteExperimentUpsertForm={setShowRemoteExperimentUpsertForm}
/>
);
}
return (
<>
<DialogHeader>
{showSDKRunInfoPage && (
<Button
variant="ghost"
onClick={() => setShowPromptForm(false)}
className="inline-block self-start"
>
Back
</Button>
)}
<DialogTitle>New Prompt Experiment</DialogTitle>
<DialogDescription>
Create an experiment to test a prompt version on a dataset. See{" "}
<Link
href="https://langfuse.com/docs/datasets/prompt-experiments"
target="_blank"
className="underline"
>
documentation
</Link>{" "}
to learn more.
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form className="space-y-6" onSubmit={form.handleSubmit(onSubmit)}>
<DialogBody>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Experiment name (optional)</FormLabel>
<FormControl>
<Input {...field} type="string" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>Description (optional)</FormLabel>
<FormControl>
<Textarea
{...field}
placeholder="Add description..."
className="focus:outline-none focus:ring-0 focus-visible:ring-0 focus-visible:ring-offset-0 active:ring-0"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="promptId"
render={() => (
<FormItem>
<FormLabel>Prompt</FormLabel>
<div className="mb-2 flex gap-2">
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className="w-2/3 justify-between px-2 font-normal"
>
{selectedPromptName || "Select a prompt"}
<ChevronDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
className="w-[--radix-popover-trigger-width] overflow-auto p-0"
align="start"
>
<InputCommand>
<InputCommandInput
placeholder="Search prompts..."
className="h-9"
/>
<InputCommandList>
<InputCommandEmpty>
No prompt found.
</InputCommandEmpty>
<InputCommandGroup>
{promptsByName &&
Object.entries(promptsByName).map(
([name, promptData]) => (
<InputCommandItem
key={name}
onSelect={() => {
setSelectedPromptName(name);
const latestVersion = promptData[0];
setSelectedPromptVersion(
latestVersion.version,
);
form.setValue(
"promptId",
latestVersion.id,
);
form.clearErrors("promptId");
}}
>
{name}
<CheckIcon
className={cn(
"ml-auto h-4 w-4",
name === selectedPromptName
? "opacity-100"
: "opacity-0",
)}
/>
</InputCommandItem>
),
)}
</InputCommandGroup>
</InputCommandList>
</InputCommand>
</PopoverContent>
</Popover>
<Popover>
<PopoverTrigger asChild>
<Button
disabled={!selectedPromptName}
variant="outline"
role="combobox"
className="w-1/3 justify-between px-2 font-normal"
>
{selectedPromptVersion
? `Version ${selectedPromptVersion}`
: "Version"}
<ChevronDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
className="w-[--radix-popover-trigger-width] p-0"
align="start"
>
<InputCommand>
<InputCommandList>
<InputCommandEmpty>
No version found.
</InputCommandEmpty>
<InputCommandGroup className="overflow-y-auto">
{promptsByName &&
selectedPromptName &&
promptsByName[selectedPromptName] ? (
promptsByName[selectedPromptName].map(
(prompt) => (
<InputCommandItem
key={prompt.id}
onSelect={() => {
setSelectedPromptVersion(
prompt.version,
);
form.setValue("promptId", prompt.id);
form.clearErrors("promptId");
}}
>
Version {prompt.version}
<CheckIcon
className={cn(
"ml-auto h-4 w-4",
prompt.version ===
selectedPromptVersion
? "opacity-100"
: "opacity-0",
)}
/>
</InputCommandItem>
),
)
) : (
<InputCommandItem disabled>
No versions available
</InputCommandItem>
)}
</InputCommandGroup>
</InputCommandList>
</InputCommand>
</PopoverContent>
</Popover>
</div>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="modelConfig"
render={() => (
<FormItem>
<Card className="p-4">
<ModelParameters
{...{
modelParams,
availableModels,
availableProviders,
updateModelParamValue: updateModelParamValue,
setModelParamEnabled,
}}
/>
</Card>
{form.formState.errors.modelConfig && (
<p
id="modelConfig"
className={cn("text-sm font-medium text-destructive")}
>
{[
form.formState.errors.modelConfig?.model?.message,
form.formState.errors.modelConfig?.provider?.message,
].join(", ")}
</p>
)}
</FormItem>
)}
/>
<FormField
control={form.control}
name="datasetId"
render={({ field }) => (
<FormItem>
<div className="flex items-center gap-2">
<FormLabel>Dataset</FormLabel>
<Popover>
<PopoverTrigger asChild>
<span className="cursor-pointer text-xs text-muted-foreground">
(expected columns)
</span>
</PopoverTrigger>
<PopoverContent className="w-80">
<div className="flex flex-col space-y-2">
<h4 className="text-sm font-medium leading-none">
Expected columns
</h4>
<span className="text-sm text-muted-foreground">
{promptId ? (
<div>
<span>
Given current prompt, dataset item input must
contain at least one of these first-level JSON
keys:
</span>
<ul className="my-2 ml-2 list-inside list-disc">
{expectedColumns.map((col) => (
<li key={col}>{col}</li>
))}
</ul>
<span>
Variables (like {"{{variable}}"}) should be
mapped to string values. Placeholders should
be mapped to arrays of message objects. These
will be used as the input to your prompt.
</span>
</div>
) : (
"Please select a prompt first"
)}
</span>
</div>
</PopoverContent>
</Popover>
</div>
<Select
onValueChange={field.onChange}
defaultValue={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a dataset" />
</SelectTrigger>
</FormControl>
<SelectContent>
{datasets.data?.map((dataset) => (
<SelectItem value={dataset.id} key={dataset.id}>
{dataset.name}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
{evaluators.data && !!datasetId ? (
<FormItem>
<FormLabel>Evaluators</FormLabel>
<FormDescription>
Will run against your experiment results.
</FormDescription>
<TemplateSelector
projectId={projectId}
datasetId={datasetId}
evalTemplates={evalTemplates.data?.templates ?? []}
onConfigureTemplate={handleConfigureEvaluator}
onSelectEvaluator={handleSelectEvaluator}
activeTemplateIds={activeEvaluators}
inactiveTemplateIds={inActiveEvaluators}
/>
</FormItem>
) : (
<FormItem>
<FormLabel>Evaluators</FormLabel>
{hasEvalReadAccess ? (
<FormDescription>
Select a dataset first to set up evaluators.
</FormDescription>
) : (
<FormDescription>
You do not have access to view evaluators. Please contact
your admin to upgrade your role.
</FormDescription>
)}
</FormItem>
)}
<div className="mt-4 flex flex-col gap-4">
{validationResult.isLoading && Boolean(promptId && datasetId) && (
<Card className="relative overflow-hidden rounded-md shadow-none group-data-[collapsible=icon]:hidden">
<CardHeader className="p-2">
<CardTitle className="flex items-center justify-between text-sm">
<span>Validating configuration...</span>
<Loader2 className="h-3 w-3 animate-spin" />
</CardTitle>
<CardDescription className="text-foreground">
Checking dataset items against prompt variables and
placeholders
</CardDescription>
</CardHeader>
</Card>
)}
{validationResult.data?.isValid === false && (
<Card className="relative overflow-hidden rounded-md border-dark-yellow bg-light-yellow shadow-none group-data-[collapsible=icon]:hidden">
<CardHeader className="p-2">
<CardTitle className="flex items-center justify-between text-sm text-dark-yellow">
<span>Invalid configuration</span>
<Info className="h-4 w-4" />
</CardTitle>
<CardDescription className="text-foreground">
{validationResult.data?.message}
</CardDescription>
</CardHeader>
</Card>
)}
{validationResult.data?.isValid === true && (
<Card className="relative overflow-hidden rounded-md border-dark-green bg-light-green shadow-none group-data-[collapsible=icon]:hidden">
<CardHeader className="p-2">
<CardTitle className="flex items-center justify-between text-sm text-dark-green">
<span>Valid configuration</span>
<CircleCheck className="h-4 w-4" />
</CardTitle>
<div className="text-sm">
Matches between dataset items and prompt
variables/placeholders
<ul className="my-2 ml-2 list-inside list-disc">
{Object.entries(
validationResult.data.variablesMap ?? {},
).map(([variable, count]) => (
<li key={variable}>
<strong>{variable}:</strong> {count} /{" "}
{validationResult.data?.isValid
? validationResult.data.totalItems
: "unknown"}
</li>
))}
</ul>
Items missing all required variables and placeholders will
be excluded from the experiment.
</div>
</CardHeader>
</Card>
)}
</div>
</DialogBody>
<DialogFooter>
<div className="flex justify-end">
<Button
type="submit"
disabled={
(Boolean(promptId && datasetId) &&
!validationResult.data?.isValid) ||
!!form.formState.errors.name
}
loading={form.formState.isSubmitting}
>
Create
</Button>
</div>
</DialogFooter>
</form>
</Form>
{/* Dialog for configuring evaluators */}
{selectedEvaluatorData && (
<Dialog
open={showEvaluatorForm}
onOpenChange={(open) => {
if (!open) {
handleCloseEvaluatorForm();
}
}}
>
<DialogContent className="max-h-[90vh] max-w-screen-md overflow-y-auto">
<DialogTitle>
{selectedEvaluatorData.evaluator.id ? "Edit" : "Configure"}{" "}
Evaluator
</DialogTitle>
<EvaluatorForm
projectId={projectId}
useDialog={true}
evalTemplates={evalTemplates.data?.templates ?? []}
templateId={selectedEvaluatorData.templateId}
existingEvaluator={selectedEvaluatorData.evaluator}
mode={selectedEvaluatorData.evaluator.id ? "edit" : "create"}
hideTargetSection={!selectedEvaluatorData.evaluator.id}
onFormSuccess={handleEvaluatorSuccess}
/>
</DialogContent>
</Dialog>
)}
</>
<PromptExperimentsForm
projectId={projectId}
setFormOpen={setFormOpen}
defaultValues={defaultValues}
promptDefault={promptDefault}
handleExperimentSettled={handleExperimentSettled}
handleExperimentSuccess={handleExperimentSuccess}
setShowPromptForm={setShowPromptForm}
showSDKRunInfoPage={showSDKRunInfoPage}
/>
);
};
@@ -0,0 +1,698 @@
import React, { useEffect, useState } from "react";
import { Button } from "@/src/components/ui/button";
import {
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/src/components/ui/form";
import { Form } from "@/src/components/ui/form";
import { Textarea } from "@/src/components/ui/textarea";
import { ModelParameters } from "@/src/components/ModelParameters";
import {
InputCommandEmpty,
InputCommandGroup,
InputCommandInput,
InputCommandList,
InputCommand,
InputCommandItem,
} from "@/src/components/ui/input-command";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/src/components/ui/select";
import { cn } from "@/src/utils/tailwind";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/src/components/ui/popover";
import {
ChevronDown,
CheckIcon,
Info,
CircleCheck,
Loader2,
} from "lucide-react";
import { api } from "@/src/utils/api";
import {
Card,
CardDescription,
CardHeader,
CardTitle,
} from "@/src/components/ui/card";
import { showErrorToast } from "@/src/features/notifications/showErrorToast";
import { useModelParams } from "@/src/features/playground/page/hooks/useModelParams";
import { useHasProjectAccess } from "@/src/features/rbac/utils/checkProjectAccess";
import { Input } from "@/src/components/ui/input";
import {
DialogHeader,
DialogTitle,
DialogDescription,
Dialog,
DialogContent,
DialogBody,
DialogFooter,
} from "@/src/components/ui/dialog";
import Link from "next/link";
import { usePostHogClientCapture } from "@/src/features/posthog-analytics/usePostHogClientCapture";
import { TemplateSelector } from "@/src/features/evals/components/template-selector";
import { EvaluatorForm } from "@/src/features/evals/components/evaluator-form";
import { useEvaluatorDefaults } from "@/src/features/experiments/hooks/useEvaluatorDefaults";
import { useExperimentEvaluatorData } from "@/src/features/experiments/hooks/useExperimentEvaluatorData";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { getFinalModelParams } from "@/src/utils/getFinalModelParams";
import { useExperimentNameValidation } from "@/src/features/experiments/hooks/useExperimentNameValidation";
import { useExperimentPromptData } from "@/src/features/experiments/hooks/useExperimentPromptData";
import {
CreateExperimentData,
type CreateExperiment,
} from "@/src/features/experiments/types";
import { Skeleton } from "@/src/components/ui/skeleton";
export const PromptExperimentsForm = ({
projectId,
setFormOpen,
defaultValues = {},
promptDefault,
handleExperimentSettled,
handleExperimentSuccess,
setShowPromptForm,
showSDKRunInfoPage,
}: {
projectId: string;
setFormOpen: (open: boolean) => void;
defaultValues?: Partial<CreateExperiment>;
promptDefault?: {
name: string;
version: number;
};
handleExperimentSuccess?: (data?: {
success: boolean;
datasetId: string;
runId: string;
runName: string;
}) => Promise<void>;
handleExperimentSettled?: (data?: {
success: boolean;
datasetId: string;
runId: string;
runName: string;
}) => Promise<void>;
setShowPromptForm: (open: boolean) => void;
showSDKRunInfoPage?: boolean;
}) => {
const capture = usePostHogClientCapture();
const [open, setOpen] = useState(false);
const [selectedPromptName, setSelectedPromptName] = useState<string>(
promptDefault?.name ?? "",
);
const [selectedPromptVersion, setSelectedPromptVersion] = useState<
number | null
>(promptDefault?.version ?? null);
const hasEvalReadAccess = useHasProjectAccess({
projectId,
scope: "evalJob:read",
});
const form = useForm({
resolver: zodResolver(CreateExperimentData),
defaultValues: {
promptId: "",
datasetId: "",
modelConfig: {},
...defaultValues,
},
});
const datasetId = form.watch("datasetId");
const evaluators = api.evals.jobConfigsByTarget.useQuery(
{ projectId, targetObject: "dataset" },
{
enabled: hasEvalReadAccess && !!datasetId,
},
);
const evalTemplates = api.evals.allTemplates.useQuery(
{ projectId },
{
enabled: hasEvalReadAccess,
},
);
const { createDefaultEvaluator } = useEvaluatorDefaults();
const {
activeEvaluators,
inActiveEvaluators,
selectedEvaluatorData,
showEvaluatorForm,
handleConfigureEvaluator,
handleCloseEvaluatorForm,
handleEvaluatorSuccess,
handleSelectEvaluator,
} = useExperimentEvaluatorData({
datasetId,
createDefaultEvaluator,
evaluatorsData: evaluators.data,
evalTemplatesData: evalTemplates.data,
refetchEvaluators: evaluators.refetch,
});
const {
modelParams,
updateModelParamValue,
setModelParamEnabled,
availableModels,
providerModelCombinations,
availableProviders,
} = useModelParams();
useExperimentNameValidation({
projectId,
datasetId,
form,
});
// TODO: show a warning if someone has multiple configs defined for the same template that target this dataset. Do not let them submit the form to create experiment.
// Prompt them to delete duplicate running evaluators.
// Watch model config changes and update form
useEffect(() => {
form.setValue("modelConfig", {
provider: modelParams.provider.value,
model: modelParams.model.value,
modelParams: getFinalModelParams(modelParams),
});
// Clear errors when valid values are set
if (modelParams.provider.value && modelParams.model.value) {
form.clearErrors("modelConfig");
}
}, [modelParams, form]);
const { promptId, promptsByName, expectedColumns } = useExperimentPromptData({
projectId,
form,
});
const experimentMutation = api.experiments.createExperiment.useMutation({
onSuccess: handleExperimentSuccess ?? (() => {}),
onError: (error) => {
showErrorToast(
error.message || "Failed to trigger experiment run",
"Please try again.",
);
},
onSettled: handleExperimentSettled ?? (() => {}),
});
const validationResult = api.experiments.validateConfig.useQuery(
{
projectId,
promptId: promptId as string,
datasetId: datasetId as string,
},
{
enabled: Boolean(promptId && datasetId),
},
);
const datasets = api.datasets.allDatasetMeta.useQuery(
{ projectId },
{
trpc: {
context: {
skipBatch: true,
},
},
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
staleTime: Infinity,
enabled: true,
},
);
const onSubmit = async (data: CreateExperiment) => {
capture("dataset_run:new_form_submit");
const experiment = {
...data,
projectId,
};
await experimentMutation.mutateAsync(experiment);
form.reset();
setFormOpen(false);
};
if (
!promptsByName ||
!datasets.data ||
(hasEvalReadAccess && !!datasetId && !evaluators.data)
) {
return <Skeleton className="min-h-[70dvh] w-full" />;
}
return (
<>
<DialogHeader>
{showSDKRunInfoPage && (
<Button
variant="ghost"
onClick={() => setShowPromptForm(false)}
className="inline-block self-start"
>
Back
</Button>
)}
<DialogTitle>New Prompt Experiment</DialogTitle>
<DialogDescription>
Create an experiment to test a prompt version on a dataset. See{" "}
<Link
href="https://langfuse.com/docs/datasets/prompt-experiments"
target="_blank"
className="underline"
>
documentation
</Link>{" "}
to learn more.
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form className="space-y-6" onSubmit={form.handleSubmit(onSubmit)}>
<DialogBody>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Experiment name (optional)</FormLabel>
<FormControl>
<Input {...field} type="string" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>Description (optional)</FormLabel>
<FormControl>
<Textarea
{...field}
placeholder="Add description..."
className="focus:outline-none focus:ring-0 focus-visible:ring-0 focus-visible:ring-offset-0 active:ring-0"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="promptId"
render={() => (
<FormItem>
<FormLabel>Prompt</FormLabel>
<div className="mb-2 flex gap-2">
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className="w-2/3 justify-between px-2 font-normal"
>
{selectedPromptName || "Select a prompt"}
<ChevronDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
className="w-[--radix-popover-trigger-width] overflow-auto p-0"
align="start"
>
<InputCommand>
<InputCommandInput
placeholder="Search prompts..."
className="h-9"
/>
<InputCommandList>
<InputCommandEmpty>
No prompt found.
</InputCommandEmpty>
<InputCommandGroup>
{promptsByName &&
Object.entries(promptsByName).map(
([name, promptData]) => (
<InputCommandItem
key={name}
onSelect={() => {
setSelectedPromptName(name);
const latestVersion = promptData[0];
setSelectedPromptVersion(
latestVersion.version,
);
form.setValue(
"promptId",
latestVersion.id,
);
form.clearErrors("promptId");
}}
>
{name}
<CheckIcon
className={cn(
"ml-auto h-4 w-4",
name === selectedPromptName
? "opacity-100"
: "opacity-0",
)}
/>
</InputCommandItem>
),
)}
</InputCommandGroup>
</InputCommandList>
</InputCommand>
</PopoverContent>
</Popover>
<Popover>
<PopoverTrigger asChild>
<Button
disabled={!selectedPromptName}
variant="outline"
role="combobox"
className="w-1/3 justify-between px-2 font-normal"
>
{selectedPromptVersion
? `Version ${selectedPromptVersion}`
: "Version"}
<ChevronDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
className="w-[--radix-popover-trigger-width] p-0"
align="start"
>
<InputCommand>
<InputCommandList>
<InputCommandEmpty>
No version found.
</InputCommandEmpty>
<InputCommandGroup className="overflow-y-auto">
{promptsByName &&
selectedPromptName &&
promptsByName[selectedPromptName] ? (
promptsByName[selectedPromptName].map(
(prompt) => (
<InputCommandItem
key={prompt.id}
onSelect={() => {
setSelectedPromptVersion(
prompt.version,
);
form.setValue("promptId", prompt.id);
form.clearErrors("promptId");
}}
>
Version {prompt.version}
<CheckIcon
className={cn(
"ml-auto h-4 w-4",
prompt.version ===
selectedPromptVersion
? "opacity-100"
: "opacity-0",
)}
/>
</InputCommandItem>
),
)
) : (
<InputCommandItem disabled>
No versions available
</InputCommandItem>
)}
</InputCommandGroup>
</InputCommandList>
</InputCommand>
</PopoverContent>
</Popover>
</div>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="modelConfig"
render={() => (
<FormItem>
<Card className="p-4">
<ModelParameters
{...{
modelParams,
availableModels,
providerModelCombinations,
availableProviders,
updateModelParamValue: updateModelParamValue,
setModelParamEnabled,
}}
/>
</Card>
{form.formState.errors.modelConfig && (
<p
id="modelConfig"
className={cn("text-sm font-medium text-destructive")}
>
{[
form.formState.errors.modelConfig?.model?.message,
form.formState.errors.modelConfig?.provider?.message,
].join(", ")}
</p>
)}
</FormItem>
)}
/>
<FormField
control={form.control}
name="datasetId"
render={({ field }) => (
<FormItem>
<div className="flex items-center gap-2">
<FormLabel>Dataset</FormLabel>
<Popover>
<PopoverTrigger asChild>
<span className="cursor-pointer text-xs text-muted-foreground">
(expected columns)
</span>
</PopoverTrigger>
<PopoverContent className="w-80">
<div className="flex flex-col space-y-2">
<h4 className="text-sm font-medium leading-none">
Expected columns
</h4>
<span className="text-sm text-muted-foreground">
{promptId ? (
<div>
<span>
Given current prompt, dataset item input must
contain at least one of these first-level JSON
keys:
</span>
<ul className="my-2 ml-2 list-inside list-disc">
{expectedColumns.map((col) => (
<li key={col}>{col}</li>
))}
</ul>
<span>
Variables (like {"{{variable}}"}) should be
mapped to string values. Placeholders should
be mapped to arrays of message objects. These
will be used as the input to your prompt.
</span>
</div>
) : (
"Please select a prompt first"
)}
</span>
</div>
</PopoverContent>
</Popover>
</div>
<Select
onValueChange={field.onChange}
defaultValue={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a dataset" />
</SelectTrigger>
</FormControl>
<SelectContent>
{datasets.data?.map((dataset) => (
<SelectItem value={dataset.id} key={dataset.id}>
{dataset.name}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
{evaluators.data && !!datasetId ? (
<FormItem>
<FormLabel>Evaluators</FormLabel>
<FormDescription>
Will run against your experiment results.
</FormDescription>
<TemplateSelector
projectId={projectId}
datasetId={datasetId}
evalTemplates={evalTemplates.data?.templates ?? []}
onConfigureTemplate={handleConfigureEvaluator}
onSelectEvaluator={handleSelectEvaluator}
activeTemplateIds={activeEvaluators}
inactiveTemplateIds={inActiveEvaluators}
/>
</FormItem>
) : (
<FormItem>
<FormLabel>Evaluators</FormLabel>
{hasEvalReadAccess ? (
<FormDescription>
Select a dataset first to set up evaluators.
</FormDescription>
) : (
<FormDescription>
You do not have access to view evaluators. Please contact
your admin to upgrade your role.
</FormDescription>
)}
</FormItem>
)}
<div className="mt-4 flex flex-col gap-4">
{validationResult.isLoading && Boolean(promptId && datasetId) && (
<Card className="relative overflow-hidden rounded-md shadow-none group-data-[collapsible=icon]:hidden">
<CardHeader className="p-2">
<CardTitle className="flex items-center justify-between text-sm">
<span>Validating configuration...</span>
<Loader2 className="h-3 w-3 animate-spin" />
</CardTitle>
<CardDescription className="text-foreground">
Checking dataset items against prompt variables and
placeholders
</CardDescription>
</CardHeader>
</Card>
)}
{validationResult.data?.isValid === false && (
<Card className="relative overflow-hidden rounded-md border-dark-yellow bg-light-yellow shadow-none group-data-[collapsible=icon]:hidden">
<CardHeader className="p-2">
<CardTitle className="flex items-center justify-between text-sm text-dark-yellow">
<span>Invalid configuration</span>
<Info className="h-4 w-4" />
</CardTitle>
<CardDescription className="text-foreground">
{validationResult.data?.message}
</CardDescription>
</CardHeader>
</Card>
)}
{validationResult.data?.isValid === true && (
<Card className="relative overflow-hidden rounded-md border-dark-green bg-light-green shadow-none group-data-[collapsible=icon]:hidden">
<CardHeader className="p-2">
<CardTitle className="flex items-center justify-between text-sm text-dark-green">
<span>Valid configuration</span>
<CircleCheck className="h-4 w-4" />
</CardTitle>
<div className="text-sm">
Matches between dataset items and prompt
variables/placeholders
<ul className="my-2 ml-2 list-inside list-disc">
{Object.entries(
validationResult.data.variablesMap ?? {},
).map(([variable, count]) => (
<li key={variable}>
<strong>{variable}:</strong> {count} /{" "}
{validationResult.data?.isValid
? validationResult.data.totalItems
: "unknown"}
</li>
))}
</ul>
Items missing all required variables and placeholders will
be excluded from the experiment.
</div>
</CardHeader>
</Card>
)}
</div>
</DialogBody>
<DialogFooter>
<div className="flex justify-end">
<Button
type="submit"
disabled={
(Boolean(promptId && datasetId) &&
!validationResult.data?.isValid) ||
!!form.formState.errors.name
}
loading={form.formState.isSubmitting}
>
Create
</Button>
</div>
</DialogFooter>
</form>
</Form>
{/* Dialog for configuring evaluators */}
{selectedEvaluatorData && (
<Dialog
open={showEvaluatorForm}
onOpenChange={(open) => {
if (!open) {
handleCloseEvaluatorForm();
}
}}
>
<DialogContent className="max-h-[90vh] max-w-screen-md overflow-y-auto">
<DialogTitle>
{selectedEvaluatorData.evaluator.id ? "Edit" : "Configure"}{" "}
Evaluator
</DialogTitle>
<EvaluatorForm
projectId={projectId}
useDialog={true}
evalTemplates={evalTemplates.data?.templates ?? []}
templateId={selectedEvaluatorData.templateId}
existingEvaluator={selectedEvaluatorData.evaluator}
mode={selectedEvaluatorData.evaluator.id ? "edit" : "create"}
hideTargetSection={!selectedEvaluatorData.evaluator.id}
onFormSuccess={handleEvaluatorSuccess}
/>
</DialogContent>
</Dialog>
)}
</>
);
};
@@ -0,0 +1,191 @@
import React from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod/v4";
import { Button } from "@/src/components/ui/button";
import {
DialogBody,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/src/components/ui/dialog";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/src/components/ui/form";
import { CodeMirrorEditor } from "@/src/components/editor/CodeMirrorEditor";
import { api } from "@/src/utils/api";
import { useHasProjectAccess } from "@/src/features/rbac/utils/checkProjectAccess";
import { showSuccessToast } from "@/src/features/notifications/showSuccessToast";
import { showErrorToast } from "@/src/features/notifications/showErrorToast";
import { Loader2 } from "lucide-react";
import { getFormattedPayload } from "@/src/features/experiments/utils/format";
import { type Prisma } from "@langfuse/shared";
const RemoteExperimentTriggerSchema = z.object({
payload: z.string(),
});
type RemoteExperimentTriggerForm = z.infer<
typeof RemoteExperimentTriggerSchema
>;
export const RemoteExperimentTriggerModal = ({
projectId,
datasetId,
remoteExperimentConfig,
setShowTriggerModal,
}: {
projectId: string;
datasetId: string;
remoteExperimentConfig: {
url: string;
payload?: Prisma.JsonValue;
};
setShowTriggerModal: (show: boolean) => void;
}) => {
const hasDatasetAccess = useHasProjectAccess({
projectId,
scope: "datasets:CUD",
});
const dataset = api.datasets.byId.useQuery({
projectId,
datasetId,
});
const form = useForm<RemoteExperimentTriggerForm>({
resolver: zodResolver(RemoteExperimentTriggerSchema),
defaultValues: {
payload: getFormattedPayload(remoteExperimentConfig.payload),
},
});
const runRemoteExperimentMutation =
api.datasets.triggerRemoteExperiment.useMutation({
onSuccess: (data) => {
if (data.success) {
showSuccessToast({
title: "Experiment started",
description: "Your experiment may take a few minutes to complete.",
});
} else {
showErrorToast(
"Failed to start experiment",
"Please try again or check your remote experiment configuration.",
);
}
setShowTriggerModal(false);
},
onError: (error) => {
showErrorToast(
error.message || "Failed to start experiment",
"Please try again or check your remote experiment configuration.",
);
},
});
const onSubmit = (data: RemoteExperimentTriggerForm) => {
if (data.payload.trim()) {
try {
JSON.parse(data.payload);
} catch (error) {
form.setError("payload", {
message: "Invalid JSON format",
});
return;
}
}
runRemoteExperimentMutation.mutate({
projectId,
datasetId,
payload: data.payload,
});
};
if (!hasDatasetAccess) {
return null;
}
return (
<>
<DialogHeader>
<Button
variant="ghost"
onClick={() => setShowTriggerModal(false)}
className="inline-block self-start"
>
Back
</Button>
<DialogTitle>Run remote experiment</DialogTitle>
<DialogDescription>
This action will send the following information to{" "}
<strong>{remoteExperimentConfig.url}</strong>.
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<DialogBody>
<div className="space-y-4">
<FormField
control={form.control}
name="payload"
render={({ field }) => (
<FormItem>
<FormLabel>Config</FormLabel>
<FormDescription>
Confirm the config you want to send to the remote
experiment URL along with the{" "}
<strong>{dataset.data?.name}</strong> dataset information.
</FormDescription>
<FormControl>
<CodeMirrorEditor
value={field.value}
onChange={field.onChange}
onBlur={field.onBlur}
editable
mode="json"
minHeight={200}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</DialogBody>
<DialogFooter>
<div className="flex w-full justify-between">
<Button
type="button"
variant="outline"
onClick={() => setShowTriggerModal(false)}
disabled={runRemoteExperimentMutation.isLoading}
>
Cancel
</Button>
<Button
type="submit"
disabled={runRemoteExperimentMutation.isLoading}
>
{runRemoteExperimentMutation.isLoading && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
Run
</Button>
</div>
</DialogFooter>
</form>
</Form>
</>
);
};
@@ -0,0 +1,260 @@
import React from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod/v4";
import { Button } from "@/src/components/ui/button";
import {
DialogBody,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/src/components/ui/dialog";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/src/components/ui/form";
import { Input } from "@/src/components/ui/input";
import { api } from "@/src/utils/api";
import { useHasProjectAccess } from "@/src/features/rbac/utils/checkProjectAccess";
import { showSuccessToast } from "@/src/features/notifications/showSuccessToast";
import { showErrorToast } from "@/src/features/notifications/showErrorToast";
import { CodeMirrorEditor } from "@/src/components/editor/CodeMirrorEditor";
import { Loader2 } from "lucide-react";
import { type Prisma } from "@langfuse/shared";
import { Skeleton } from "@/src/components/ui/skeleton";
import { getFormattedPayload } from "@/src/features/experiments/utils/format";
const RemoteExperimentSetupSchema = z.object({
url: z.url(),
defaultPayload: z.string(),
});
type RemoteExperimentSetupForm = z.infer<typeof RemoteExperimentSetupSchema>;
export const RemoteExperimentUpsertForm = ({
projectId,
datasetId,
existingRemoteExperiment,
setShowRemoteExperimentUpsertForm,
}: {
projectId: string;
datasetId: string;
existingRemoteExperiment?: {
url: string;
payload: Prisma.JsonValue;
} | null;
setShowRemoteExperimentUpsertForm: (show: boolean) => void;
}) => {
const hasDatasetAccess = useHasProjectAccess({
projectId,
scope: "datasets:CUD",
});
const dataset = api.datasets.byId.useQuery({
projectId,
datasetId,
});
const utils = api.useUtils();
const form = useForm<RemoteExperimentSetupForm>({
resolver: zodResolver(RemoteExperimentSetupSchema),
defaultValues: {
url: existingRemoteExperiment?.url || "",
defaultPayload: getFormattedPayload(existingRemoteExperiment?.payload),
},
});
const upsertRemoteExperimentMutation =
api.datasets.upsertRemoteExperiment.useMutation({
onSuccess: () => {
showSuccessToast({
title: "Setup successfully",
description: "Your changes have been saved.",
});
setShowRemoteExperimentUpsertForm(false);
utils.datasets.getRemoteExperiment.invalidate({
projectId,
datasetId,
});
},
onError: (error) => {
showErrorToast(
error.message || "Failed to setup",
"Please check your URL and config and try again.",
);
},
});
const deleteRemoteExperimentMutation =
api.datasets.deleteRemoteExperiment.useMutation({
onSuccess: () => {
showSuccessToast({
title: "Deleted successfully",
description:
"The remote experiment trigger has been removed from this dataset.",
});
setShowRemoteExperimentUpsertForm(false);
},
onError: (error) => {
showErrorToast(
error.message || "Failed to delete remote experiment trigger",
"Please try again.",
);
},
});
const onSubmit = (data: RemoteExperimentSetupForm) => {
if (data.defaultPayload.trim()) {
try {
JSON.parse(data.defaultPayload);
} catch (error) {
form.setError("defaultPayload", {
message: "Invalid JSON format",
});
return;
}
}
upsertRemoteExperimentMutation.mutate({
projectId,
datasetId,
url: data.url,
defaultPayload: data.defaultPayload,
});
};
const handleDelete = () => {
if (
confirm("Are you sure you want to delete this remote experiment trigger?")
) {
deleteRemoteExperimentMutation.mutate({
projectId,
datasetId,
});
}
};
if (!hasDatasetAccess) {
return null;
}
if (dataset.isLoading) {
return <Skeleton className="h-48 w-full" />;
}
return (
<>
<DialogHeader>
<Button
variant="ghost"
onClick={() => setShowRemoteExperimentUpsertForm(false)}
className="inline-block self-start"
>
Back
</Button>
<DialogTitle>
{existingRemoteExperiment
? "Edit remote experiment trigger"
: "Set up remote experiment trigger in UI"}
</DialogTitle>
<DialogDescription>
Enable your team to run custom experiments on dataset{" "}
<strong>
{dataset.isSuccess ? (
<>&quot;{dataset.data?.name}&quot;</>
) : (
<Loader2 className="inline h-4 w-4 animate-spin" />
)}
</strong>
. Configure a webhook URL to trigger remote custom experiments from
UI. We will send dataset info (name, id) and config to your service,
which can run experiments and post results to Langfuse.
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<DialogBody>
<FormField
control={form.control}
name="url"
render={({ field }) => (
<FormItem>
<FormLabel>URL</FormLabel>
<FormDescription>
The URL that will be called when the remote experiment is
triggered.
</FormDescription>
<FormControl>
<Input
placeholder="https://your-service.com/webhook"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="defaultPayload"
render={({ field }) => (
<FormItem>
<FormLabel>Default config</FormLabel>
<FormDescription>
Set a default config that will be sent to the remote
experiment URL. This can be modified when running the
experiment. View docs for more details.
</FormDescription>
<CodeMirrorEditor
value={field.value}
onChange={field.onChange}
onBlur={field.onBlur}
editable
mode="json"
minHeight={200}
/>
<FormMessage />
</FormItem>
)}
/>
</DialogBody>
<DialogFooter>
<div className="flex w-full justify-between">
{existingRemoteExperiment && (
<Button
type="button"
variant="destructive"
onClick={handleDelete}
disabled={deleteRemoteExperimentMutation.isLoading}
>
{deleteRemoteExperimentMutation.isLoading && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
Delete
</Button>
)}
<Button
type="submit"
disabled={upsertRemoteExperimentMutation.isLoading}
>
{upsertRemoteExperimentMutation.isLoading ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : null}
{existingRemoteExperiment ? "Update" : "Set up"}
</Button>
</div>
</DialogFooter>
</form>
</Form>
</>
);
};
@@ -205,6 +205,10 @@ export const experimentsRouter = createTRPCRouter({
runId: datasetRun.id,
description: input.description,
},
retryBaggage: {
originalJobTimestamp: new Date(),
attempt: 0,
},
});
}
+20
View File
@@ -0,0 +1,20 @@
import { ZodModelConfig } from "@langfuse/shared";
import z from "zod/v4";
export const CreateExperimentData = z.object({
name: z
.union([z.string().length(0), z.string().min(1)])
.optional()
.transform((str) => str?.trim())
.transform((str) => (str === "" ? undefined : str)),
promptId: z.string().min(1, "Please select a prompt"),
datasetId: z.string().min(1, "Please select a dataset"),
description: z.string().max(1000).optional(),
modelConfig: z.object({
provider: z.string().min(1, "Please select a provider"),
model: z.string().min(1, "Please select a model"),
modelParams: ZodModelConfig,
}),
});
export type CreateExperiment = z.infer<typeof CreateExperimentData>;
@@ -0,0 +1,23 @@
import { type Prisma } from "@langfuse/shared";
export const getFormattedPayload = (
payload: Prisma.JsonValue | undefined,
): string => {
if (!payload) return "{}";
if (typeof payload === "string") {
// Check if it's a double-stringified JSON
try {
const parsed = JSON.parse(payload);
return typeof parsed === "string"
? parsed
: JSON.stringify(parsed, null, 2);
} catch {
// If parsing fails, return as-is (it's just a regular string)
return payload;
}
}
// It's already an object, stringify it
return JSON.stringify(payload, null, 2);
};
@@ -158,16 +158,21 @@ export const ConfigurationDropdowns: React.FC = () => {
</p>
</div>
{variablesCount > 0 ? (
<div className="mb-3 space-y-4">
<div>
<h5 className="mb-2 text-xs font-medium">Variables</h5>
<Variables />
</div>
<div>
<h5 className="mb-2 text-xs font-medium">
Message Placeholders
</h5>
<MessagePlaceholders />
<div
className="mb-3"
style={{ maxHeight: "50vh", overflowY: "auto" }}
>
<div className="space-y-4">
<div>
<h5 className="mb-2 text-xs font-medium">Variables</h5>
<Variables />
</div>
<div>
<h5 className="mb-2 text-xs font-medium">
Message Placeholders
</h5>
<MessagePlaceholders />
</div>
</div>
</div>
) : (
@@ -88,25 +88,29 @@ export const GenerationOutput = () => {
) : null;
return (
<div className="relative h-full overflow-auto">
<div className="relative h-full">
<div
className="h-full overflow-auto rounded-lg bg-muted p-4"
className="h-full overflow-auto rounded-lg bg-muted"
ref={scrollAreaRef}
>
<div className="mb-4 flex w-full items-center">
<p className="flex-1 text-xs font-semibold">Output</p>
{copyButton}
<div className="sticky top-0 z-10 bg-muted p-3">
<div className="flex w-full items-center">
<p className="flex-1 text-xs font-semibold">Output</p>
{copyButton}
</div>
</div>
<div className="px-4">
<pre className="whitespace-break-spaces break-words text-xs">
{isJson ? outputJson : output}
</pre>
{outputToolCalls.length > 0
? outputToolCalls.map((toolCall) => (
<div className="mt-4" key={toolCall.id}>
<ToolCallCard toolCall={toolCall} />
</div>
))
: null}
</div>
<pre className="whitespace-break-spaces break-words text-xs">
{isJson ? outputJson : output}
</pre>
{outputToolCalls.length > 0
? outputToolCalls.map((toolCall) => (
<div className="mt-4" key={toolCall.id}>
<ToolCallCard toolCall={toolCall} />
</div>
))
: null}
</div>
</div>
);
@@ -125,6 +125,7 @@ export const PlaygroundProvider: React.FC<PlaygroundProviderProps> = ({
availableModels,
updateModelParamValue,
setModelParamEnabled,
providerModelCombinations,
} = useModelParams(windowId);
const { registerWindow, unregisterWindow } = useWindowCoordination();
@@ -635,6 +636,7 @@ export const PlaygroundProvider: React.FC<PlaygroundProviderProps> = ({
modelParams,
updateModelParamValue,
setModelParamEnabled,
providerModelCombinations,
output,
outputJson,
@@ -56,6 +56,18 @@ export const useModelParams = (windowId?: string) => {
(key) => key.provider === modelParams.provider.value,
);
const providerModelCombinations =
availableLLMApiKeys.data?.data.reduce((acc, v) => {
if (v.withDefaultModels) {
acc.push(
...supportedModels[v.adapter].map((m) => `${v.provider}: ${m}`),
);
}
acc.push(...v.customModels.map((m) => `${v.provider}: ${m}`));
return acc;
}, [] as string[]) ?? [];
const availableModels = useMemo(
() =>
!selectedProviderApiKey
@@ -182,6 +194,7 @@ export const useModelParams = (windowId?: string) => {
availableModels,
updateModelParamValue,
setModelParamEnabled,
providerModelCombinations,
};
};
+2 -19
View File
@@ -1,14 +1,11 @@
import React, { useCallback } from "react";
import { Button } from "@/src/components/ui/button";
import { Plus, Play, Loader2 } from "lucide-react";
import { Play, Loader2 } from "lucide-react";
import { ResetPlaygroundButton } from "@/src/features/playground/page/components/ResetPlaygroundButton";
import { useWindowCoordination } from "@/src/features/playground/page/hooks/useWindowCoordination";
import { usePersistedWindowIds } from "@/src/features/playground/page/hooks/usePersistedWindowIds";
import useCommandEnter from "@/src/features/playground/page/hooks/useCommandEnter";
import {
MULTI_WINDOW_CONFIG,
type MultiWindowState,
} from "@/src/features/playground/page/types";
import { type MultiWindowState } from "@/src/features/playground/page/types";
import Page from "@/src/components/layouts/page";
import MultiWindowPlayground from "@/src/features/playground/page/components/MultiWindowPlayground";
@@ -108,8 +105,6 @@ export default function PlaygroundPage() {
? getExecutionStatus() ||
`Executing ${windowIds.length} window${windowIds.length === 1 ? "" : "s"}`
: getExecutionStatus();
const isAddWindowDisabled =
windowIds.length >= MULTI_WINDOW_CONFIG.MAX_WINDOWS;
const isRunAllDisabled = globalIsExecutingAll;
const windowState: MultiWindowState = {
@@ -149,18 +144,6 @@ export default function PlaygroundPage() {
)}
</div>
{/* Add Window Button - Hidden on mobile */}
<Button
variant="outline"
onClick={() => addWindow()}
disabled={isAddWindowDisabled}
className="hidden flex-shrink-0 gap-1 md:flex"
title="Add a new playground window"
>
<Plus className="h-3 w-3" />
<span className="hidden lg:inline">Add Window</span>
</Button>
{/* Multi-Window Controls - Hidden on mobile */}
<Button
variant="outline"
@@ -473,6 +473,7 @@ export function PromptTable() {
}}
/>
<DataTable
tableName={"prompts"}
columns={promptColumns}
data={
prompts.isLoading
@@ -7,6 +7,7 @@ import {
type DatasetRuns as DbDatasetRuns,
type DatasetItem as DbDatasetItems,
type DatasetRunItems as DbDatasetRunItems,
type Dataset as DbDataset,
removeObjectKeys,
} from "@langfuse/shared";
import { z } from "zod/v4";
@@ -90,6 +91,11 @@ export const transformDbDatasetRunItemToAPIDatasetRunItemPg = (
): z.infer<typeof APIDatasetRunItem> =>
removeObjectKeys(dbDatasetRunItem, ["projectId"]);
export const transformDbDatasetToAPIDataset = (
dataset: DbDataset,
): z.infer<typeof APIDataset> =>
removeObjectKeys(dataset, ["remoteExperimentUrl", "remoteExperimentPayload"]);
/**
* Endpoints
*/
@@ -309,10 +309,13 @@ export function MembersTable({
];
const [columnVisibility, setColumnVisibility] =
useColumnVisibility<MembersTableRow>("membersColumnVisibility", columns);
useColumnVisibility<MembersTableRow>(
project ? "membersColumnVisibilityProject" : "membersColumnVisibilityOrg",
columns,
);
const [columnOrder, setColumnOrder] = useColumnOrder<MembersTableRow>(
"membersColumnOrder",
project ? "membersColumnOrderProject" : "membersColumnOrderOrg",
columns,
);
@@ -362,6 +365,7 @@ export function MembersTable({
{showSettingsCard ? (
<SettingsTableCard>
<DataTable
tableName={project ? "projectMembers" : "orgMembers"}
columns={columns}
data={
members.isLoading
@@ -393,6 +397,7 @@ export function MembersTable({
</SettingsTableCard>
) : (
<DataTable
tableName={project ? "projectMembers" : "orgMembers"}
columns={columns}
data={
members.isLoading
@@ -210,6 +210,7 @@ export function MembershipInvitesPage({
<Header title="Membership Invites" />
<DataTableToolbar columns={columns} />
<DataTable
tableName={"membershipInvites"}
columns={columns}
data={
invites.isLoading
@@ -2,4 +2,6 @@ import { api } from "@/src/utils/api";
export const targetOptionsQueryMap = {
"trace-add-to-annotation-queue": api.annotationQueues.allNamesAndIds.useQuery,
"session-add-to-annotation-queue":
api.annotationQueues.allNamesAndIds.useQuery,
} as const;
@@ -228,6 +228,7 @@ export function DashboardWidgetTable() {
return (
<DataTable
tableName={"widgets"}
columns={widgetColumns}
data={
widgets.isLoading
+62
View File
@@ -5,6 +5,7 @@ import {
QueueName,
getQueue,
IngestionQueue,
IngestionEvent,
} from "@langfuse/shared/src/server";
import { AdminApiAuthService } from "@/src/ee/features/admin-api/server/adminApiAuth";
@@ -32,6 +33,11 @@ const ManageBullBody = z.discriminatedUnion("action", [
queueNames: z.array(z.string()),
bullStatus: BullStatus,
}),
z.object({
action: z.literal("add"),
queueName: z.literal(QueueName.IngestionSecondaryQueue),
events: z.array(IngestionEvent),
}),
]);
export default async function handler(
@@ -156,6 +162,33 @@ export default async function handler(
return res.status(200).json({ message: "Retried all jobs" });
}
// if (req.method === "POST" && body.data.action === "add") {
// logger.info(
// `Adding ${body.data.events.length} events to ${body.data.queueName}`,
// );
// try {
// await insertJobs({
// queueName: body.data.queueName,
// data: body.data.events,
// });
// logger.info(
// `Successfully added ${body.data.events.length} events to ${body.data.queueName}`,
// );
// return res.status(200).json({
// message: `Added ${body.data.events.length} events to ${body.data.queueName}`,
// count: body.data.events.length,
// });
// } catch (error) {
// logger.error(`Failed to add events to ${body.data.queueName}`, error);
// return res.status(500).json({
// error: `Failed to add events to queue: ${error instanceof Error ? error.message : "Unknown error"}`,
// });
// }
// }
// return not implemented error
res.status(404).json({ error: "Action does not exist" });
} catch (e) {
@@ -163,3 +196,32 @@ export default async function handler(
res.status(500).json({ error: e });
}
}
// const insertJobType = z.discriminatedUnion("queueName", [
// z.object({
// queueName: z.literal(QueueName.IngestionSecondaryQueue),
// data: z.array(IngestionEvent),
// }),
// ]);
// const insertJobs = async (payload: z.infer<typeof insertJobType>) => {
// const queue = getQueue(
// payload.queueName as Exclude<QueueName, QueueName.IngestionQueue>,
// );
// if (!queue) {
// throw new Error("Failed to get queue");
// }
// await queue.addBulk(
// payload.data.map((data) => ({
// name: QueueJobs.IngestionSecondaryJob,
// data: {
// id: v4(),
// timestamp: new Date(),
// name: QueueJobs.IngestionSecondaryJob,
// payload: data,
// },
// })),
// );
// };

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