Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a1a48d5e7b | ||
|
|
fcb7563763 | ||
|
|
ae754b146d | ||
|
|
49343b9a0c | ||
|
|
76bf5c0f4e | ||
|
|
e91a29be61 | ||
|
|
81694363a2 | ||
|
|
5998266680 | ||
|
|
462e8e847d | ||
|
|
d7c186858b | ||
|
|
e686aedac9 | ||
|
|
85f75a5e8e | ||
|
|
1ea643300d | ||
|
|
596486c1ec | ||
|
|
cb65391c2d | ||
|
|
419e07260d | ||
|
|
825f030e81 | ||
|
|
34ded9c927 | ||
|
|
c935d4ae73 | ||
|
|
e7283ac06e | ||
|
|
20d0106626 | ||
|
|
849591fdf2 | ||
|
|
0c7b50e564 | ||
|
|
37b4f43351 | ||
|
|
7a3b0379e5 | ||
|
|
f458d7626c | ||
|
|
258dde4691 | ||
|
|
f15246d9df | ||
|
|
edb43a24ab | ||
|
|
c45ae1b140 | ||
|
|
90eeeeaf9d | ||
|
|
66accb563c | ||
|
|
9f2e8906d0 | ||
|
|
c5d61b7ed2 | ||
|
|
f95dd872e6 | ||
|
|
04339741a9 | ||
|
|
9c85e46996 | ||
|
|
4a7236451f | ||
|
|
5cb5204181 | ||
|
|
c19066afa0 | ||
|
|
bbb9dc285d | ||
|
|
b750acba06 | ||
|
|
bb06e079eb | ||
|
|
5f711780e0 | ||
|
|
0f58ea1ebf |
+4
-4
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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
@@ -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": {
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
-- AlterEnum
|
||||
ALTER TYPE "AnnotationQueueObjectType" ADD VALUE 'SESSION';
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
Generated
+113
-252
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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]}
|
||||
|
||||
@@ -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">
|
||||
"{String(value)}"
|
||||
</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 @@
|
||||
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>
|
||||
))
|
||||
|
||||
+167
@@ -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}) ↗
|
||||
</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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
+114
@@ -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 ? (
|
||||
<>"{dataset.data?.name}"</>
|
||||
) : (
|
||||
<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,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user