Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ec70ca3951 | ||
|
|
142d3a1612 | ||
|
|
1dee7092b3 | ||
|
|
83b64f5e77 | ||
|
|
48b05247c8 | ||
|
|
9732262466 | ||
|
|
e51e2df2dc | ||
|
|
74d1dfe5ac | ||
|
|
5633a8867e | ||
|
|
6a8bdae22e | ||
|
|
dbf994f9bb | ||
|
|
4d5c288d1e | ||
|
|
cf119fb9cc | ||
|
|
5af8c4e400 | ||
|
|
835a19bbd2 | ||
|
|
ae91a98ad9 | ||
|
|
e38cc24205 | ||
|
|
7ab45ec685 | ||
|
|
b101bf48d3 | ||
|
|
4c4f0ff3c0 | ||
|
|
a42ca96225 | ||
|
|
f63420827b | ||
|
|
e6fd056ae5 | ||
|
|
4708fe4f47 | ||
|
|
362246b616 | ||
|
|
b3de5fd71a | ||
|
|
ad236ecc99 | ||
|
|
beb2063dcb | ||
|
|
4de10298dc | ||
|
|
f9924975fa | ||
|
|
98774cbd61 | ||
|
|
80361ef53f | ||
|
|
8350507434 | ||
|
|
0a976edb70 | ||
|
|
6a67c93010 | ||
|
|
e51ee05d9c |
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(find:*)",
|
||||
"Bash(rg:*)",
|
||||
"Bash(grep:*)",
|
||||
"Bash(ls:*)",
|
||||
"Bash(cat:*)",
|
||||
"Bash(head:*)",
|
||||
"Bash(tail:*)"
|
||||
],
|
||||
"deny": []
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,7 @@ updates:
|
||||
patterns:
|
||||
- "express"
|
||||
- "@types/express"
|
||||
- "@types/express-serve-static-core"
|
||||
observability:
|
||||
patterns:
|
||||
- "dd-trace"
|
||||
|
||||
+10
-42
@@ -1,60 +1,28 @@
|
||||
name: Snyk Container
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- "main"
|
||||
# Snyk cannot upload results in merge group. Hence, we only run when pushing https://github.com/github/codeql-action/issues/1572
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
branches: ["main"]
|
||||
|
||||
jobs:
|
||||
snyk:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: Run Snyk to check Docker image for vulnerabilities (langfuse-web)
|
||||
continue-on-error: true
|
||||
- name: Scan web image with Snyk
|
||||
uses: snyk/actions/docker@master
|
||||
continue-on-error: true # let upload step always run
|
||||
env:
|
||||
SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}
|
||||
with:
|
||||
image: langfuse/langfuse
|
||||
args: --file=web/Dockerfile
|
||||
image: langfuse/langfuse # pulled from Docker Hub
|
||||
args: --severity-threshold=high # (any extra CLI flags, but NO --file)
|
||||
|
||||
# Workaround for https://github.com/github/codeql-action/issues/2187
|
||||
- name: Replace security-severity undefined for license-related findings
|
||||
run: 'sed -i ''s/"security-severity": "undefined"/"security-severity": "0"/g'' snyk.sarif'
|
||||
|
||||
- name: Replace security-severity null for license-related findings
|
||||
run: 'sed -i ''s/"security-severity": "null"/"security-severity": "0"/g'' snyk.sarif'
|
||||
|
||||
- name: Upload result to GitHub Code Scanning
|
||||
uses: github/codeql-action/upload-sarif@v3
|
||||
with:
|
||||
sarif_file: snyk.sarif
|
||||
category: web
|
||||
|
||||
- name: Run Snyk to check Docker image for vulnerabilities (langfuse-worker)
|
||||
continue-on-error: true
|
||||
- name: Scan worker image with Snyk
|
||||
uses: snyk/actions/docker@master
|
||||
continue-on-error: true # let upload step always run
|
||||
env:
|
||||
SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}
|
||||
with:
|
||||
image: langfuse/langfuse-worker
|
||||
args: --file=worker/Dockerfile
|
||||
|
||||
# Workaround for https://github.com/github/codeql-action/issues/2187
|
||||
- name: Replace security-severity undefined for license-related findings
|
||||
run: 'sed -i ''s/"security-severity": "undefined"/"security-severity": "0"/g'' snyk.sarif'
|
||||
|
||||
- name: Replace security-severity null for license-related findings
|
||||
run: 'sed -i ''s/"security-severity": "null"/"security-severity": "0"/g'' snyk.sarif'
|
||||
|
||||
- name: Upload result to GitHub Code Scanning
|
||||
uses: github/codeql-action/upload-sarif@v3
|
||||
with:
|
||||
sarif_file: snyk.sarif
|
||||
category: worker
|
||||
image: langfuse/langfuse-worker # pulled from Docker Hub
|
||||
args: --severity-threshold=high # (any extra CLI flags, but NO --file)
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
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
|
||||
@@ -62,6 +62,8 @@ services:
|
||||
REDIS_TLS_CA: ${REDIS_TLS_CA:-/certs/ca.crt}
|
||||
REDIS_TLS_CERT: ${REDIS_TLS_CERT:-/certs/redis.crt}
|
||||
REDIS_TLS_KEY: ${REDIS_TLS_KEY:-/certs/redis.key}
|
||||
EMAIL_FROM_ADDRESS: ${EMAIL_FROM_ADDRESS:-}
|
||||
SMTP_CONNECTION_URL: ${SMTP_CONNECTION_URL:-}
|
||||
|
||||
langfuse-web:
|
||||
image: docker.io/langfuse/langfuse:3
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "langfuse",
|
||||
"version": "3.84.0",
|
||||
"version": "3.85.2",
|
||||
"author": "engineering@langfuse.com",
|
||||
"license": "MIT",
|
||||
"private": true,
|
||||
|
||||
@@ -79,7 +79,7 @@
|
||||
"bullmq": "^5.34.10",
|
||||
"dd-trace": "^5.36.0",
|
||||
"decimal.js": "^10.4.3",
|
||||
"exponential-backoff": "^3.1.1",
|
||||
"exponential-backoff": "^3.1.2",
|
||||
"https-proxy-agent": "^7.0.6",
|
||||
"ioredis": "^5.4.1",
|
||||
"jsonpath-plus": "10.3.0",
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
SEED_TEXT_PROMPTS,
|
||||
} from "./utils/postgres-seed-constants";
|
||||
import {
|
||||
generateDatasetItemId,
|
||||
generateDatasetRunTraceId,
|
||||
generateEvalObservationId,
|
||||
generateEvalScoreId,
|
||||
@@ -517,6 +518,7 @@ export async function createDatasets(
|
||||
description: data.description,
|
||||
projectId,
|
||||
metadata: data.metadata,
|
||||
id: `${datasetName}-${projectId.slice(-8)}`,
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -532,13 +534,23 @@ export async function createDatasets(
|
||||
const datasetItem = await prisma.datasetItem.upsert({
|
||||
where: {
|
||||
id_projectId: {
|
||||
id: `${dataset.id}-${index}`,
|
||||
id: generateDatasetItemId(
|
||||
datasetName,
|
||||
index,
|
||||
projectId,
|
||||
SEED_DATASETS.indexOf(data) || 0,
|
||||
),
|
||||
projectId,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
projectId,
|
||||
id: `${dataset.id}-${index}`,
|
||||
id: generateDatasetItemId(
|
||||
datasetName,
|
||||
index,
|
||||
projectId,
|
||||
SEED_DATASETS.indexOf(data) || 0,
|
||||
),
|
||||
datasetId: dataset.id,
|
||||
sourceTraceId: sourceTraceId ?? null,
|
||||
sourceObservationId: null,
|
||||
@@ -554,14 +566,14 @@ export async function createDatasets(
|
||||
for (let datasetRunNumber = 0; datasetRunNumber < 3; datasetRunNumber++) {
|
||||
const datasetRun = await prisma.datasetRuns.upsert({
|
||||
where: {
|
||||
datasetId_projectId_name: {
|
||||
datasetId: dataset.id,
|
||||
id_projectId: {
|
||||
id: `demo-dataset-run-${datasetRunNumber}-${projectId.slice(-8)}`,
|
||||
projectId,
|
||||
name: `demo-dataset-run-${datasetRunNumber}`,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
projectId,
|
||||
id: `demo-dataset-run-${datasetRunNumber}-${projectId.slice(-8)}`,
|
||||
name: `demo-dataset-run-${datasetRunNumber}`,
|
||||
description: Math.random() > 0.5 ? "Dataset run description" : "",
|
||||
datasetId: dataset.id,
|
||||
|
||||
@@ -2,6 +2,8 @@ import {
|
||||
TraceRecordInsertType,
|
||||
ObservationRecordInsertType,
|
||||
ScoreRecordInsertType,
|
||||
DatasetRunItemRecordInsertType,
|
||||
createDatasetRunItemsCh,
|
||||
} from "../../../src/server";
|
||||
import { SEED_TEXT_PROMPTS } from "./postgres-seed-constants";
|
||||
import {
|
||||
@@ -42,6 +44,16 @@ export class ClickHouseQueryBuilder {
|
||||
return await createObservationsCh(observations);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates INSERT query for dataset run items data using VALUES syntax.
|
||||
* Use for: Small datasets, dataset run items that link to postgres data (e.g. dataset runs)
|
||||
*/
|
||||
async executeDatasetRunItemsInsert(
|
||||
datasetRunItems: DatasetRunItemRecordInsertType[],
|
||||
): Promise<InsertResult> {
|
||||
return await createDatasetRunItemsCh(datasetRunItems);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates INSERT query for score data using VALUES syntax.
|
||||
* Use for: Small datasets, scores with custom values and metadata.
|
||||
|
||||
@@ -6,6 +6,8 @@ import {
|
||||
REALISTIC_MODELS,
|
||||
} from "./clickhouse-seed-constants";
|
||||
import {
|
||||
generateDatasetItemId,
|
||||
generateDatasetRunItemId,
|
||||
generateDatasetRunTraceId,
|
||||
generateEvalObservationId,
|
||||
generateEvalScoreId,
|
||||
@@ -23,6 +25,8 @@ import {
|
||||
ObservationRecordInsertType,
|
||||
ScoreRecordInsertType,
|
||||
TraceRecordInsertType,
|
||||
DatasetRunItemRecordInsertType,
|
||||
createDatasetRunItem,
|
||||
} from "../../../src/server";
|
||||
|
||||
/**
|
||||
@@ -60,6 +64,49 @@ export class DataGenerator {
|
||||
return Math.floor(Math.random() * (max - min + 1)) + min;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates dataset run items for dataset runs.
|
||||
* Use for: Dataset experiment scenarios.
|
||||
*/
|
||||
generateDatasetRunItem(
|
||||
input: DatasetItemInput & { runCreatedAt: number },
|
||||
projectId: string,
|
||||
): DatasetRunItemRecordInsertType {
|
||||
const datasetRunItemId = generateDatasetRunItemId(
|
||||
input.datasetName,
|
||||
input.itemIndex,
|
||||
projectId,
|
||||
input.runNumber || 0,
|
||||
);
|
||||
|
||||
// TODO: there are too many dataset run items in the postgres database?
|
||||
return createDatasetRunItem({
|
||||
id: datasetRunItemId,
|
||||
project_id: projectId,
|
||||
trace_id: generateDatasetRunTraceId(
|
||||
input.datasetName,
|
||||
input.itemIndex,
|
||||
projectId,
|
||||
input.runNumber || 0,
|
||||
),
|
||||
dataset_id: `${input.datasetName}-${projectId.slice(-8)}`,
|
||||
dataset_run_id: `demo-dataset-run-${input.runNumber}-${projectId.slice(-8)}`,
|
||||
dataset_run_name: `demo-dataset-run-${input.runNumber}-${projectId.slice(-8)}`,
|
||||
dataset_run_created_at: input.runCreatedAt,
|
||||
dataset_run_description:
|
||||
(input.runNumber || 0) % 2 === 0 ? "Dataset run description" : "",
|
||||
dataset_run_metadata: { key: "value" },
|
||||
dataset_item_id: generateDatasetItemId(
|
||||
input.datasetName,
|
||||
input.itemIndex,
|
||||
projectId,
|
||||
input.runNumber || 0,
|
||||
),
|
||||
dataset_item_input: input.item.input,
|
||||
dataset_item_expected_output: input.item.output,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates traces from dataset items for experiment runs.
|
||||
* Use for: Dataset experiments scenarios.
|
||||
|
||||
@@ -1,3 +1,21 @@
|
||||
export const generateDatasetRunItemId = (
|
||||
datasetName: string,
|
||||
itemIndex: number,
|
||||
projectId: string,
|
||||
runNumber: number,
|
||||
) => {
|
||||
return `dataset-run-item-${datasetName}-${itemIndex}-${projectId.slice(-8)}-${runNumber}`;
|
||||
};
|
||||
|
||||
export const generateDatasetItemId = (
|
||||
datasetName: string,
|
||||
itemIndex: number,
|
||||
projectId: string,
|
||||
runNumber: number,
|
||||
) => {
|
||||
return `dataset-item-${datasetName}-${itemIndex}-${projectId.slice(-8)}-${runNumber}`;
|
||||
};
|
||||
|
||||
export const generateDatasetRunTraceId = (
|
||||
datasetName: string,
|
||||
itemIndex: number,
|
||||
|
||||
@@ -92,12 +92,26 @@ export class SeederOrchestrator {
|
||||
logger.info(
|
||||
`Processing run ${runNumber + 1}/${numberOfRuns} for project ${projectId}`,
|
||||
);
|
||||
// const now = Date.now();
|
||||
|
||||
const traces: TraceRecordInsertType[] = [];
|
||||
const observations: ObservationRecordInsertType[] = [];
|
||||
// const datasetRunItems: DatasetRunItemRecordInsertType[] = [];
|
||||
|
||||
for (const seedDataset of SEED_DATASETS) {
|
||||
for (const [itemIndex, datasetItem] of seedDataset.items.entries()) {
|
||||
// // Generate dataset run item data
|
||||
// const datasetRunItem = this.dataGenerator.generateDatasetRunItem(
|
||||
// {
|
||||
// datasetName: seedDataset.name,
|
||||
// itemIndex,
|
||||
// item: datasetItem,
|
||||
// runNumber,
|
||||
// runCreatedAt: now,
|
||||
// },
|
||||
// projectId,
|
||||
// );
|
||||
|
||||
// Generate trace data
|
||||
const trace = this.dataGenerator.generateDatasetTrace(
|
||||
{
|
||||
@@ -123,12 +137,14 @@ export class SeederOrchestrator {
|
||||
|
||||
traces.push(trace);
|
||||
observations.push(observation);
|
||||
// datasetRunItems.push(datasetRunItem);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await this.queryBuilder.executeTracesInsert(traces);
|
||||
await this.queryBuilder.executeObservationsInsert(observations);
|
||||
// await this.queryBuilder.executeDatasetRunItemsInsert(datasetRunItems);
|
||||
} catch (error) {
|
||||
logger.error(`✗ Insert failed:`, error);
|
||||
throw error;
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import z from "zod/v4";
|
||||
import { jsonSchema } from "../utils/zod";
|
||||
import { MetadataDomain } from "./traces";
|
||||
|
||||
export const DatasetRunItemSchema = z.object({
|
||||
id: z.string(),
|
||||
projectId: z.string(),
|
||||
datasetRunId: z.string(),
|
||||
datasetItemId: z.string(),
|
||||
datasetId: z.string(),
|
||||
traceId: z.string(),
|
||||
observationId: z.string().nullable(),
|
||||
error: z.string().nullable(),
|
||||
// timestamps
|
||||
createdAt: z.date(),
|
||||
updatedAt: z.date(),
|
||||
// dataset run fields
|
||||
datasetRunName: z.string(),
|
||||
datasetRunDescription: z.string().nullable(),
|
||||
datasetRunMetadata: MetadataDomain,
|
||||
datasetRunCreatedAt: z.date(),
|
||||
// dataset item fields
|
||||
datasetItemInput: jsonSchema,
|
||||
datasetItemExpectedOutput: jsonSchema,
|
||||
datasetItemMetadata: MetadataDomain,
|
||||
});
|
||||
|
||||
export type DatasetRunItemDomain = z.infer<typeof DatasetRunItemSchema>;
|
||||
@@ -16,6 +16,7 @@ const EnvSchema = z.object({
|
||||
.nullable(),
|
||||
REDIS_AUTH: z.string().nullish(),
|
||||
REDIS_CONNECTION_STRING: z.string().nullish(),
|
||||
REDIS_KEY_PREFIX: z.string().nullish(),
|
||||
REDIS_TLS_ENABLED: z.enum(["true", "false"]).default("false"),
|
||||
REDIS_TLS_CA_PATH: z.string().optional(),
|
||||
REDIS_TLS_CERT_PATH: z.string().optional(),
|
||||
@@ -31,8 +32,10 @@ const EnvSchema = z.object({
|
||||
"ENCRYPTION_KEY must be 256 bits, 64 string characters in hex format, generate via: openssl rand -hex 32",
|
||||
)
|
||||
.optional(),
|
||||
LANGFUSE_CACHE_MODEL_MATCH_ENABLED: z.enum(["true", "false"]).default("true"),
|
||||
LANGFUSE_CACHE_MODEL_MATCH_TTL_SECONDS: z.coerce.number().default(86400), // 24 hours
|
||||
LANGFUSE_CACHE_PROMPT_ENABLED: z.enum(["true", "false"]).default("true"),
|
||||
LANGFUSE_CACHE_PROMPT_TTL_SECONDS: z.coerce.number().default(300),
|
||||
LANGFUSE_CACHE_PROMPT_TTL_SECONDS: z.coerce.number().default(300), // 5 minutes
|
||||
CLICKHOUSE_URL: z.string().url(),
|
||||
CLICKHOUSE_CLUSTER_NAME: z.string().default("default"),
|
||||
CLICKHOUSE_DB: z.string().default("default"),
|
||||
@@ -107,7 +110,13 @@ const EnvSchema = z.object({
|
||||
LANGFUSE_CLICKHOUSE_DELETION_TIMEOUT_MS: z.coerce.number().default(240_000), // 4 minutes
|
||||
LANGFUSE_CLICKHOUSE_QUERY_MAX_ATTEMPTS: z.coerce.number().default(3), // Maximum attempts for socket hang up errors
|
||||
LANGFUSE_SKIP_S3_LIST_FOR_OBSERVATIONS_PROJECT_IDS: z.string().optional(),
|
||||
|
||||
// Dataset Run Items Migration Environment Variables
|
||||
LANGFUSE_EXPERIMENT_DATASET_RUN_ITEMS_WRITE_CH: z
|
||||
.enum(["true", "false"])
|
||||
.default("false"),
|
||||
LANGFUSE_EXPERIMENT_DATASET_RUN_ITEMS_READ_CH: z
|
||||
.enum(["true", "false"])
|
||||
.default("false"),
|
||||
LANGFUSE_EXPERIMENT_COMPARE_READ_FROM_AGGREGATING_MERGE_TREES: z
|
||||
.enum(["true", "false"])
|
||||
.default("false"),
|
||||
|
||||
@@ -11,7 +11,8 @@ export type IngestionEntityTypes =
|
||||
| "trace"
|
||||
| "observation"
|
||||
| "score"
|
||||
| "sdk_log";
|
||||
| "sdk_log"
|
||||
| "dataset_run_item";
|
||||
|
||||
export const getClickhouseEntityType = (
|
||||
eventType: string,
|
||||
@@ -29,6 +30,8 @@ export const getClickhouseEntityType = (
|
||||
return "observation";
|
||||
case eventTypes.SCORE_CREATE:
|
||||
return "score";
|
||||
case eventTypes.DATASET_RUN_ITEM_CREATE:
|
||||
return "dataset_run_item";
|
||||
case eventTypes.SDK_LOG:
|
||||
return "sdk_log";
|
||||
default:
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { env } from "../../env";
|
||||
import { logger } from "../../server/logger";
|
||||
import {
|
||||
DatasetRunItemsExecutionStrategy,
|
||||
DatasetRunItemsOperationType,
|
||||
} from "./types";
|
||||
/**
|
||||
* Returns the execution strategy for dataset run items based on environment variables.
|
||||
*
|
||||
* Two-phase migration approach:
|
||||
* 1. Dual-write phase: DATASET_RUN_ITEMS_WRITE_TO_CLICKHOUSE=true (write to both databases)
|
||||
* 2. Read migration phase: DATASET_RUN_ITEMS_READ_FROM_CLICKHOUSE=true (read from ClickHouse)
|
||||
*/
|
||||
function getDatasetRunItemsExecutionStrategy(): DatasetRunItemsExecutionStrategy {
|
||||
return {
|
||||
shouldWriteToClickHouse:
|
||||
env.LANGFUSE_EXPERIMENT_DATASET_RUN_ITEMS_WRITE_CH === "true",
|
||||
shouldReadFromClickHouse:
|
||||
env.LANGFUSE_EXPERIMENT_DATASET_RUN_ITEMS_READ_CH === "true",
|
||||
};
|
||||
}
|
||||
|
||||
// Re-export the enum for backward compatibility
|
||||
|
||||
/**
|
||||
* Executes the appropriate database operation based on the execution strategy.
|
||||
*
|
||||
* @param postgresExecution - Function to execute PostgreSQL operation
|
||||
* @param clickhouseExecution - Function to execute ClickHouse operation
|
||||
* @param operationType - Type of operation ("read" or "write")
|
||||
* @returns Result from the selected execution strategy
|
||||
*/
|
||||
export async function executeWithDatasetRunItemsStrategy<TInput, TOutput>({
|
||||
input,
|
||||
operationType,
|
||||
postgresExecution,
|
||||
clickhouseExecution,
|
||||
}: {
|
||||
input: TInput;
|
||||
operationType: DatasetRunItemsOperationType;
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
postgresExecution: (input: TInput) => Promise<TOutput>;
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
clickhouseExecution: (input: TInput) => Promise<TOutput>;
|
||||
}): Promise<TOutput> {
|
||||
const strategy = getDatasetRunItemsExecutionStrategy();
|
||||
|
||||
if (operationType === DatasetRunItemsOperationType.WRITE) {
|
||||
// For write operations, implement dual-write strategy
|
||||
if (strategy.shouldWriteToClickHouse) {
|
||||
// Dual-write phase: write to both databases
|
||||
const postgresResult = await postgresExecution(input);
|
||||
|
||||
try {
|
||||
await clickhouseExecution(input);
|
||||
logger.debug("Successfully wrote to both PostgreSQL and ClickHouse", {
|
||||
operation: `dataset_run_items_${operationType}`,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("ClickHouse write failed during dual-write phase", {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
operation: `dataset_run_items_${operationType}`,
|
||||
});
|
||||
// Continue with PostgreSQL result since it succeeded
|
||||
}
|
||||
|
||||
return postgresResult;
|
||||
} else {
|
||||
// Write only to PostgreSQL
|
||||
return await postgresExecution(input);
|
||||
}
|
||||
} else {
|
||||
// For read operations, rely on the strategy
|
||||
const shouldExecuteClickhouse = strategy.shouldReadFromClickHouse;
|
||||
|
||||
if (shouldExecuteClickhouse) {
|
||||
try {
|
||||
return await clickhouseExecution(input);
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
"ClickHouse execution failed, falling back to PostgreSQL",
|
||||
{
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
operation: `dataset_run_items_${operationType}`,
|
||||
},
|
||||
);
|
||||
// Fallback to PostgreSQL for reliability
|
||||
return await postgresExecution(input);
|
||||
}
|
||||
} else {
|
||||
return await postgresExecution(input);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* Types and enums for dataset run items execution.
|
||||
* This file is frontend-safe and doesn't import server-side dependencies.
|
||||
*/
|
||||
|
||||
export enum DatasetRunItemsOperationType {
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
READ = "read",
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
WRITE = "write",
|
||||
}
|
||||
|
||||
export type DatasetRunItemsExecutionStrategy = {
|
||||
shouldWriteToClickHouse: boolean;
|
||||
shouldReadFromClickHouse: boolean;
|
||||
};
|
||||
@@ -20,6 +20,7 @@ export * from "./clickhouse/schemaUtils";
|
||||
export * from "./clickhouse/schema";
|
||||
export * from "./repositories/definitions";
|
||||
export * from "../server/ingestion/types";
|
||||
export * from "../server/ingestion/modelMatch";
|
||||
export * from "./ingestion/processEventBatch";
|
||||
export * from "../server/ingestion/validateAndInflateScore";
|
||||
export * from "./redis/redis";
|
||||
@@ -62,10 +63,13 @@ export * from "./services/DashboardService";
|
||||
export * from "./services/TableViewService";
|
||||
export * from "./services/DefaultEvaluationModelService";
|
||||
export * from "./clickhouse/measureAndReturn";
|
||||
|
||||
export * from "./data-deletion/ingestionFileDeletion";
|
||||
export * from "./s3";
|
||||
|
||||
// dataset run items
|
||||
export * from "./dataset-run-items/datasetExecution";
|
||||
export * from "./dataset-run-items/types";
|
||||
|
||||
// test utils
|
||||
export * from "./test-utils";
|
||||
export * from "./utils/headerUtils";
|
||||
|
||||
+44
-5
@@ -1,13 +1,15 @@
|
||||
import { Model, Prisma } from "@langfuse/shared";
|
||||
import { Model, Prisma } from "../../";
|
||||
import {
|
||||
instrumentAsync,
|
||||
logger,
|
||||
recordIncrement,
|
||||
} from "@langfuse/shared/src/server";
|
||||
import { env } from "../env";
|
||||
import { redis } from "@langfuse/shared/src/server";
|
||||
redis,
|
||||
safeMultiDel,
|
||||
} from "../";
|
||||
import { type Cluster } from "ioredis";
|
||||
import { env } from "../../env";
|
||||
import { Decimal } from "decimal.js";
|
||||
import { prisma } from "@langfuse/shared/src/db";
|
||||
import { prisma } from "../../db";
|
||||
|
||||
export type ModelMatchProps = {
|
||||
projectId: string;
|
||||
@@ -178,6 +180,11 @@ export const getRedisModelKey = (p: ModelMatchProps) => {
|
||||
};
|
||||
|
||||
const getModelMatchKeyPrefix = () => {
|
||||
if (env.REDIS_CLUSTER_ENABLED === "true") {
|
||||
// Use hash tags for Redis cluster compatibility
|
||||
// This ensures all model cache keys are placed on the same hash slot
|
||||
return "{model-match}";
|
||||
}
|
||||
return "model-match";
|
||||
};
|
||||
|
||||
@@ -205,3 +212,35 @@ export const redisModelToPrismaModel = (redisModel: string): Model => {
|
||||
: null,
|
||||
};
|
||||
};
|
||||
|
||||
export async function clearModelCacheForProject(
|
||||
projectId: string,
|
||||
): Promise<void> {
|
||||
if (env.LANGFUSE_CACHE_MODEL_MATCH_ENABLED === "false" || !redis) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const pattern = `${getModelMatchKeyPrefix()}:${projectId}:*`;
|
||||
|
||||
const keys =
|
||||
env.REDIS_CLUSTER_ENABLED === "true"
|
||||
? (
|
||||
await Promise.all(
|
||||
(redis as Cluster)
|
||||
.nodes("master")
|
||||
.map((node) => node.keys(pattern) || []),
|
||||
)
|
||||
).flat()
|
||||
: await redis.keys(pattern);
|
||||
|
||||
if (keys.length > 0) {
|
||||
await safeMultiDel(redis, keys);
|
||||
logger.info(
|
||||
`Cleared ${keys.length} model cache entries for project ${projectId}`,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Error clearing model cache for project ${projectId}`, error);
|
||||
}
|
||||
}
|
||||
@@ -256,11 +256,18 @@ export const processEventBatch = async (
|
||||
const shardingKey = `${authCheck.scope.projectId}-${eventData.eventBodyId}`;
|
||||
const queue = IngestionQueue.getInstance({ shardingKey });
|
||||
|
||||
const shouldSkipS3List =
|
||||
getClickhouseEntityType(eventData.type) === "observation" &&
|
||||
const isDatasetRunItemEvent =
|
||||
getClickhouseEntityType(eventData.type) === "dataset_run_item";
|
||||
const isObservationEvent =
|
||||
getClickhouseEntityType(eventData.type) === "observation";
|
||||
|
||||
const isOtelOrSkipS3Project =
|
||||
authCheck.scope.projectId !== null &&
|
||||
(projectIdsToSkipS3List.includes(authCheck.scope.projectId) ||
|
||||
source === "otel");
|
||||
(source === "otel" ||
|
||||
projectIdsToSkipS3List.includes(authCheck.scope.projectId));
|
||||
|
||||
const shouldSkipS3List =
|
||||
isDatasetRunItemEvent || (isObservationEvent && isOtelOrSkipS3Project);
|
||||
|
||||
return queue
|
||||
? queue.add(
|
||||
|
||||
@@ -224,6 +224,7 @@ export const eventTypes = {
|
||||
GENERATION_CREATE: "generation-create",
|
||||
GENERATION_UPDATE: "generation-update",
|
||||
SDK_LOG: "sdk-log",
|
||||
DATASET_RUN_ITEM_CREATE: "dataset-run-item-create",
|
||||
// LEGACY, only required for backwards compatibility
|
||||
OBSERVATION_CREATE: "observation-create",
|
||||
OBSERVATION_UPDATE: "observation-update",
|
||||
@@ -343,9 +344,15 @@ export const SdkLogEvent = z.object({
|
||||
// As we allow plain values, arrays, and objects the JSON parse via bodyParser should suffice.
|
||||
|
||||
// Complete schema factory - single source of truth for ALL schemas
|
||||
const createAllIngestionSchemas = (
|
||||
environmentSchema: z.ZodDefault<z.ZodString>,
|
||||
) => {
|
||||
const createAllIngestionSchemas = ({
|
||||
isPublic = true,
|
||||
}: {
|
||||
isPublic: boolean;
|
||||
}) => {
|
||||
const environmentSchema = isPublic
|
||||
? PublicEnvironmentName
|
||||
: InternalEnvironmentName;
|
||||
|
||||
// Base schemas with environment
|
||||
const TraceBody = z.object({
|
||||
id: idSchema.nullish(),
|
||||
@@ -504,6 +511,22 @@ const createAllIngestionSchemas = (
|
||||
]),
|
||||
);
|
||||
|
||||
const DatasetRunItemBody = z.object({
|
||||
// Core identifiers
|
||||
id: idSchema.nullish(),
|
||||
traceId: z.string(),
|
||||
observationId: z.string().nullish(),
|
||||
error: z.string().nullish(),
|
||||
// Metadata (optional)
|
||||
createdAt: stringDateTime.nullish(),
|
||||
// Dataset identification
|
||||
datasetId: z.string(),
|
||||
// Run identification
|
||||
runId: z.string(),
|
||||
// Dataset item identification
|
||||
datasetItemId: z.string(),
|
||||
});
|
||||
|
||||
// Event schemas
|
||||
const base = z.object({
|
||||
id: idSchema,
|
||||
@@ -546,6 +569,17 @@ const createAllIngestionSchemas = (
|
||||
body: ScoreBody,
|
||||
});
|
||||
|
||||
const baseDatasetRunItemCreateEvent = base.extend({
|
||||
type: z.literal(eventTypes.DATASET_RUN_ITEM_CREATE),
|
||||
body: DatasetRunItemBody,
|
||||
});
|
||||
|
||||
const datasetRunItemCreateEvent = isPublic
|
||||
? baseDatasetRunItemCreateEvent.refine(() => false, {
|
||||
message: "Dataset run item creation is only allowed for internal usage",
|
||||
})
|
||||
: baseDatasetRunItemCreateEvent;
|
||||
|
||||
const sdkLogEvent = base.extend({
|
||||
type: z.literal(eventTypes.SDK_LOG),
|
||||
body: SdkLogEvent,
|
||||
@@ -570,6 +604,7 @@ const createAllIngestionSchemas = (
|
||||
generationCreateEvent,
|
||||
generationUpdateEvent,
|
||||
sdkLogEvent,
|
||||
datasetRunItemCreateEvent,
|
||||
// LEGACY, only required for backwards compatibility
|
||||
legacyObservationCreateEvent,
|
||||
legacyObservationUpdateEvent,
|
||||
@@ -595,6 +630,7 @@ const createAllIngestionSchemas = (
|
||||
generationCreateEvent,
|
||||
generationUpdateEvent,
|
||||
scoreEvent,
|
||||
datasetRunItemCreateEvent,
|
||||
sdkLogEvent,
|
||||
legacyObservationCreateEvent,
|
||||
legacyObservationUpdateEvent,
|
||||
@@ -604,8 +640,8 @@ const createAllIngestionSchemas = (
|
||||
};
|
||||
|
||||
// Create both public and internal schema instances
|
||||
const publicSchemas = createAllIngestionSchemas(PublicEnvironmentName);
|
||||
const internalSchemas = createAllIngestionSchemas(InternalEnvironmentName);
|
||||
const publicSchemas = createAllIngestionSchemas({ isPublic: true });
|
||||
const internalSchemas = createAllIngestionSchemas({ isPublic: false });
|
||||
|
||||
// Export individual schemas for backwards compatibility
|
||||
export const TraceBody = publicSchemas.TraceBody;
|
||||
@@ -632,6 +668,8 @@ export const generationCreateEvent = publicSchemas.generationCreateEvent;
|
||||
export const generationUpdateEvent = publicSchemas.generationUpdateEvent;
|
||||
export const scoreEvent = publicSchemas.scoreEvent;
|
||||
export const sdkLogEvent = publicSchemas.sdkLogEvent;
|
||||
export const datasetRunItemCreateEvent =
|
||||
publicSchemas.datasetRunItemCreateEvent;
|
||||
export const legacyObservationCreateEvent =
|
||||
publicSchemas.legacyObservationCreateEvent;
|
||||
export const legacyObservationUpdateEvent =
|
||||
@@ -649,6 +687,7 @@ export const ingestionEvent = publicSchemas.ingestionEvent;
|
||||
export type IngestionEventType = z.infer<typeof ingestionEvent>;
|
||||
export type TraceEventType = z.infer<typeof traceEvent>;
|
||||
export type ScoreEventType = z.infer<typeof scoreEvent>;
|
||||
export type DatasetRunItemEventType = z.infer<typeof datasetRunItemCreateEvent>;
|
||||
|
||||
/**
|
||||
* Creates an ingestion event schema with appropriate environment validation.
|
||||
|
||||
@@ -233,11 +233,6 @@ export enum LLMAdapter {
|
||||
GoogleAIStudio = "google-ai-studio",
|
||||
}
|
||||
|
||||
export const SYSTEM_ROLES: string[] = [
|
||||
ChatMessageRole.System,
|
||||
ChatMessageRole.Developer,
|
||||
];
|
||||
|
||||
export const TextPromptContentSchema = z.string().min(1, "Enter a prompt");
|
||||
|
||||
export const PromptContentSchema = z.union([
|
||||
|
||||
@@ -6,6 +6,7 @@ export const clickhouseSearchCondition = (
|
||||
query?: string,
|
||||
searchType?: TracingSearchType[],
|
||||
tablePrefix?: string,
|
||||
useTracesAmtCompatMode: boolean = false,
|
||||
) => {
|
||||
const prefix = tablePrefix ? `${tablePrefix}.` : "";
|
||||
|
||||
@@ -13,9 +14,11 @@ export const clickhouseSearchCondition = (
|
||||
!searchType || searchType.includes("id")
|
||||
? `${prefix}id ILIKE {searchString: String} OR user_id ILIKE {searchString: String} OR ${prefix}name ILIKE {searchString: String}`
|
||||
: null,
|
||||
searchType && searchType.includes("content")
|
||||
searchType && searchType.includes("content") && !useTracesAmtCompatMode
|
||||
? `${prefix}input ILIKE {searchString: String} OR ${prefix}output ILIKE {searchString: String}`
|
||||
: null,
|
||||
: searchType && searchType.includes("content") && useTracesAmtCompatMode
|
||||
? `finalizeAggregation(${prefix}input) ILIKE {searchString: String} OR finalizeAggregation(${prefix}output) ILIKE {searchString: String}`
|
||||
: null,
|
||||
].filter(Boolean);
|
||||
|
||||
return {
|
||||
|
||||
@@ -6,6 +6,7 @@ import { logger } from "../logger";
|
||||
const defaultRedisOptions: Partial<RedisOptions> = {
|
||||
maxRetriesPerRequest: null,
|
||||
enableAutoPipelining: env.REDIS_ENABLE_AUTO_PIPELINING === "true",
|
||||
keyPrefix: env.REDIS_KEY_PREFIX ?? undefined,
|
||||
};
|
||||
|
||||
export const redisQueueRetryOptions: Partial<RedisOptions> = {
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { DatasetRunItemDomain } from "../../domain/dataset-run-items";
|
||||
import { convertDateToClickhouseDateTime } from "../clickhouse/client";
|
||||
import { parseMetadataCHRecordToDomain } from "../utils/metadata_conversion";
|
||||
import { parseClickhouseUTCDateTimeFormat } from "./clickhouse";
|
||||
import { DatasetRunItemRecordReadType } from "./definitions";
|
||||
|
||||
export const convertToDatasetRunMetrics = (row: any) => {
|
||||
return {
|
||||
id: row.dataset_run_id,
|
||||
projectId: row.project_id,
|
||||
createdAt: new Date(row.created_at),
|
||||
updatedAt: new Date(row.updated_at),
|
||||
name: row.datasetRunName,
|
||||
description: row.datasetRunDescription ?? "",
|
||||
metadata: row.datasetRunMetadata,
|
||||
countRunItems: row.count_run_items,
|
||||
avgTotalCost: undefined,
|
||||
avgLatency: undefined,
|
||||
scores: undefined,
|
||||
datasetId: row.dataset_id,
|
||||
};
|
||||
};
|
||||
|
||||
export const convertDatasetRunItemDomainToClickhouse = (
|
||||
datasetRunItem: DatasetRunItemDomain,
|
||||
): DatasetRunItemRecordReadType => {
|
||||
return {
|
||||
id: datasetRunItem.id,
|
||||
project_id: datasetRunItem.projectId,
|
||||
trace_id: datasetRunItem.traceId,
|
||||
observation_id: datasetRunItem.observationId,
|
||||
dataset_id: datasetRunItem.datasetId,
|
||||
dataset_run_id: datasetRunItem.datasetRunId,
|
||||
dataset_run_name: datasetRunItem.datasetRunName,
|
||||
dataset_run_description: datasetRunItem.datasetRunDescription,
|
||||
dataset_run_metadata: datasetRunItem.datasetRunMetadata as Record<
|
||||
string,
|
||||
string
|
||||
>,
|
||||
dataset_item_id: datasetRunItem.datasetItemId,
|
||||
dataset_item_input: datasetRunItem.datasetItemInput as string,
|
||||
dataset_item_expected_output:
|
||||
datasetRunItem.datasetItemExpectedOutput as string,
|
||||
dataset_item_metadata: datasetRunItem.datasetItemMetadata as Record<
|
||||
string,
|
||||
string
|
||||
>,
|
||||
created_at: convertDateToClickhouseDateTime(datasetRunItem.createdAt),
|
||||
updated_at: convertDateToClickhouseDateTime(datasetRunItem.updatedAt),
|
||||
event_ts: convertDateToClickhouseDateTime(new Date()),
|
||||
is_deleted: 0,
|
||||
dataset_run_created_at: convertDateToClickhouseDateTime(
|
||||
datasetRunItem.datasetRunCreatedAt,
|
||||
),
|
||||
error: datasetRunItem.error,
|
||||
};
|
||||
};
|
||||
|
||||
export const convertDatasetRunItemClickhouseToDomain = (
|
||||
row: DatasetRunItemRecordReadType,
|
||||
): DatasetRunItemDomain => {
|
||||
return {
|
||||
id: row.id,
|
||||
projectId: row.project_id,
|
||||
traceId: row.trace_id,
|
||||
observationId: row.observation_id ?? null,
|
||||
datasetRunId: row.dataset_run_id,
|
||||
datasetRunName: row.dataset_run_name,
|
||||
datasetRunDescription: row.dataset_run_description ?? null,
|
||||
datasetRunCreatedAt: parseClickhouseUTCDateTimeFormat(
|
||||
row.dataset_run_created_at,
|
||||
),
|
||||
datasetRunMetadata:
|
||||
parseMetadataCHRecordToDomain(row.dataset_run_metadata) ?? null,
|
||||
datasetItemId: row.dataset_item_id,
|
||||
datasetItemInput: row.dataset_item_input,
|
||||
datasetItemExpectedOutput: row.dataset_item_expected_output,
|
||||
datasetItemMetadata: parseMetadataCHRecordToDomain(
|
||||
row.dataset_item_metadata,
|
||||
),
|
||||
createdAt: parseClickhouseUTCDateTimeFormat(row.created_at),
|
||||
updatedAt: parseClickhouseUTCDateTimeFormat(row.updated_at),
|
||||
datasetId: row.dataset_id,
|
||||
error: row.error ?? null,
|
||||
};
|
||||
};
|
||||
@@ -196,6 +196,45 @@ export const scoreRecordInsertSchema = scoreRecordBaseSchema.extend({
|
||||
});
|
||||
export type ScoreRecordInsertType = z.infer<typeof scoreRecordInsertSchema>;
|
||||
|
||||
const datasetRunItemRecordBaseSchema = z.object({
|
||||
id: z.string(),
|
||||
project_id: z.string(),
|
||||
trace_id: z.string(),
|
||||
observation_id: z.string().nullish(),
|
||||
dataset_id: z.string(),
|
||||
dataset_run_id: z.string(),
|
||||
dataset_item_id: z.string(),
|
||||
dataset_run_name: z.string(),
|
||||
dataset_run_description: z.string().nullish(),
|
||||
dataset_run_metadata: z.record(z.string(), z.string()),
|
||||
dataset_item_input: z.string(),
|
||||
dataset_item_expected_output: z.string(),
|
||||
dataset_item_metadata: z.record(z.string(), z.string()),
|
||||
is_deleted: z.number(),
|
||||
error: z.string().nullish(),
|
||||
});
|
||||
|
||||
const datasetRunItemRecordReadSchema = datasetRunItemRecordBaseSchema.extend({
|
||||
dataset_run_created_at: clickhouseStringDateSchema,
|
||||
created_at: clickhouseStringDateSchema,
|
||||
updated_at: clickhouseStringDateSchema,
|
||||
event_ts: clickhouseStringDateSchema,
|
||||
});
|
||||
export type DatasetRunItemRecordReadType = z.infer<
|
||||
typeof datasetRunItemRecordReadSchema
|
||||
>;
|
||||
|
||||
export const datasetRunItemRecordInsertSchema =
|
||||
datasetRunItemRecordBaseSchema.extend({
|
||||
created_at: z.number(),
|
||||
updated_at: z.number(),
|
||||
event_ts: z.number(),
|
||||
dataset_run_created_at: z.number(),
|
||||
});
|
||||
export type DatasetRunItemRecordInsertType = z.infer<
|
||||
typeof datasetRunItemRecordInsertSchema
|
||||
>;
|
||||
|
||||
export const blobStorageFileLogRecordBaseSchema = z.object({
|
||||
id: z.string(),
|
||||
project_id: z.string(),
|
||||
@@ -308,6 +347,44 @@ export const convertPostgresTraceToInsert = (
|
||||
};
|
||||
};
|
||||
|
||||
export const convertPostgresDatasetRunItemToInsert = (
|
||||
datasetRunItem: Record<string, any>,
|
||||
): DatasetRunItemRecordInsertType => {
|
||||
return {
|
||||
id: datasetRunItem.id,
|
||||
project_id: datasetRunItem.project_id,
|
||||
dataset_run_id: datasetRunItem.dataset_run_id,
|
||||
dataset_item_id: datasetRunItem.dataset_item_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 }
|
||||
: Array.isArray(datasetRunItem.dataset_run_metadata)
|
||||
? { dataset_run_metadata: datasetRunItem.dataset_run_metadata }
|
||||
: datasetRunItem.dataset_run_metadata,
|
||||
// denormalized item data
|
||||
dataset_item_input: datasetRunItem.dataset_item_input,
|
||||
dataset_item_expected_output: datasetRunItem.dataset_item_expected_output,
|
||||
dataset_item_metadata:
|
||||
typeof datasetRunItem.dataset_item_metadata === "string"
|
||||
? { dataset_item_metadata: datasetRunItem.dataset_item_metadata }
|
||||
: Array.isArray(datasetRunItem.dataset_item_metadata)
|
||||
? { dataset_item_metadata: datasetRunItem.dataset_item_metadata }
|
||||
: datasetRunItem.dataset_item_metadata,
|
||||
event_ts: datasetRunItem.created_at?.getTime(),
|
||||
is_deleted: 0,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Expects a single record from a
|
||||
* `select o.*,
|
||||
|
||||
@@ -13,3 +13,4 @@ export * from "./scores-utils";
|
||||
export * from "./blobStorageLog";
|
||||
export * from "./environments";
|
||||
export * from "./automation-repository";
|
||||
export * from "./dataset-run-items-converters";
|
||||
|
||||
@@ -1335,6 +1335,8 @@ export const getScoresForPostHog = async function* (
|
||||
s.timestamp as timestamp,
|
||||
s.name as name,
|
||||
s.value as value,
|
||||
s.string_value as string_value,
|
||||
s.data_type as data_type,
|
||||
s.comment as comment,
|
||||
s.environment as environment,
|
||||
t.name as trace_name,
|
||||
@@ -1342,6 +1344,7 @@ export const getScoresForPostHog = async function* (
|
||||
t.user_id as trace_user_id,
|
||||
t.release as trace_release,
|
||||
t.tags as trace_tags,
|
||||
s.metadata as metadata,
|
||||
t.metadata['$posthog_session_id'] as posthog_session_id
|
||||
FROM scores s FINAL
|
||||
LEFT JOIN traces t FINAL ON s.trace_id = t.id AND s.project_id = t.project_id
|
||||
@@ -1382,6 +1385,9 @@ export const getScoresForPostHog = async function* (
|
||||
langfuse_score_name: record.name,
|
||||
langfuse_score_value: record.value,
|
||||
langfuse_score_comment: record.comment,
|
||||
langfuse_score_metadata: record.metadata,
|
||||
langfuse_score_string_value: record.string_value,
|
||||
langfuse_score_data_type: record.data_type,
|
||||
langfuse_trace_name: record.trace_name,
|
||||
langfuse_id: record.id,
|
||||
langfuse_session_id: record.trace_session_id,
|
||||
|
||||
@@ -325,26 +325,71 @@ export const getTracesBySessionId = async (
|
||||
sessionIds: string[],
|
||||
timestamp?: Date,
|
||||
) => {
|
||||
const query = `
|
||||
SELECT *
|
||||
FROM traces
|
||||
WHERE session_id IN ({sessionIds: Array(String)})
|
||||
AND project_id = {projectId: String}
|
||||
${timestamp ? `AND timestamp >= {timestamp: DateTime64(3)}` : ""}
|
||||
ORDER BY event_ts DESC
|
||||
LIMIT 1 by id, project_id;`;
|
||||
const records = await queryClickhouse<TraceRecordReadType>({
|
||||
query,
|
||||
params: {
|
||||
sessionIds,
|
||||
projectId,
|
||||
timestamp: timestamp ? convertDateToClickhouseDateTime(timestamp) : null,
|
||||
const records = await measureAndReturn({
|
||||
operationName: "getTracesBySessionId",
|
||||
projectId,
|
||||
input: {
|
||||
params: {
|
||||
sessionIds,
|
||||
projectId,
|
||||
timestamp: timestamp
|
||||
? convertDateToClickhouseDateTime(timestamp)
|
||||
: null,
|
||||
},
|
||||
tags: {
|
||||
feature: "tracing",
|
||||
type: "trace",
|
||||
kind: "list",
|
||||
projectId,
|
||||
},
|
||||
timestamp,
|
||||
},
|
||||
tags: {
|
||||
feature: "tracing",
|
||||
type: "trace",
|
||||
kind: "list",
|
||||
projectId,
|
||||
existingExecution: (input) => {
|
||||
const query = `
|
||||
SELECT *
|
||||
FROM traces
|
||||
WHERE session_id IN ({sessionIds: Array(String)})
|
||||
AND project_id = {projectId: String}
|
||||
${timestamp ? `AND timestamp >= {timestamp: DateTime64(3)}` : ""}
|
||||
ORDER BY event_ts DESC
|
||||
LIMIT 1 by id, project_id;
|
||||
`;
|
||||
return queryClickhouse<TraceRecordReadType>({
|
||||
query,
|
||||
params: input.params,
|
||||
tags: input.tags,
|
||||
});
|
||||
},
|
||||
newExecution: (input) => {
|
||||
const traceAmt = getTimeframesTracesAMT(input.timestamp);
|
||||
const query = `
|
||||
SELECT
|
||||
id,
|
||||
name as name,
|
||||
user_id as user_id,
|
||||
metadata as metadata,
|
||||
release as release,
|
||||
version as version,
|
||||
project_id,
|
||||
environment,
|
||||
finalizeAggregation(public) as public,
|
||||
finalizeAggregation(bookmarked) as bookmarked,
|
||||
tags,
|
||||
finalizeAggregation(input) as input,
|
||||
finalizeAggregation(output) as output,
|
||||
session_id as session_id,
|
||||
start_time as timestamp,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM ${traceAmt} FINAL
|
||||
WHERE session_id IN ({sessionIds: Array(String)})
|
||||
AND project_id = {projectId: String}
|
||||
`;
|
||||
return queryClickhouse<TraceRecordReadType>({
|
||||
query,
|
||||
params: input.params,
|
||||
tags: input.tags,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -600,39 +645,76 @@ export const getTracesGroupedByName = async (
|
||||
? new FilterList(chFilter).apply()
|
||||
: undefined;
|
||||
|
||||
// We mainly use queries like this to retrieve filter options.
|
||||
// Therefore, we can skip final as some inaccuracy in count is acceptable.
|
||||
const query = `
|
||||
select
|
||||
name as name,
|
||||
count(*) as count
|
||||
from traces t FINAL
|
||||
WHERE t.project_id = {projectId: String}
|
||||
AND t.name IS NOT NULL
|
||||
${timestampFilterRes?.query ? `AND ${timestampFilterRes.query}` : ""}
|
||||
GROUP BY name
|
||||
ORDER BY count(*) desc
|
||||
LIMIT 1000;
|
||||
`;
|
||||
|
||||
const rows = await queryClickhouse<{
|
||||
name: string;
|
||||
count: string;
|
||||
}>({
|
||||
query: query,
|
||||
params: {
|
||||
projectId: projectId,
|
||||
...(timestampFilterRes ? timestampFilterRes.params : {}),
|
||||
return measureAndReturn({
|
||||
operationName: "getTracesGroupedByName",
|
||||
projectId,
|
||||
input: {
|
||||
params: {
|
||||
projectId,
|
||||
...(timestampFilterRes ? timestampFilterRes.params : {}),
|
||||
},
|
||||
tags: {
|
||||
feature: "tracing",
|
||||
type: "trace",
|
||||
kind: "analytic",
|
||||
projectId,
|
||||
},
|
||||
},
|
||||
tags: {
|
||||
feature: "tracing",
|
||||
type: "trace",
|
||||
kind: "analytic",
|
||||
projectId,
|
||||
existingExecution: async (input) => {
|
||||
// We mainly use queries like this to retrieve filter options.
|
||||
// Therefore, we can skip final as some inaccuracy in count is acceptable.
|
||||
const query = `
|
||||
select
|
||||
name as name,
|
||||
count(*) as count
|
||||
from traces t FINAL
|
||||
WHERE t.project_id = {projectId: String}
|
||||
AND t.name IS NOT NULL
|
||||
${timestampFilterRes?.query ? `AND ${timestampFilterRes.query}` : ""}
|
||||
GROUP BY name
|
||||
ORDER BY count(*) desc
|
||||
LIMIT 1000;
|
||||
`;
|
||||
|
||||
return queryClickhouse<{
|
||||
name: string;
|
||||
count: string;
|
||||
}>({
|
||||
query,
|
||||
params: input.params,
|
||||
tags: input.tags,
|
||||
});
|
||||
},
|
||||
newExecution: async (input) => {
|
||||
// Extract the timestamp from filter for AMT table selection
|
||||
const fromTimestamp = timestampFilter?.find(
|
||||
(f) =>
|
||||
f.column === "timestamp" &&
|
||||
(f.operator === ">=" || f.operator === ">"),
|
||||
)?.value as Date | undefined;
|
||||
const traceAmt = getTimeframesTracesAMT(fromTimestamp);
|
||||
const query = `
|
||||
select
|
||||
name as name,
|
||||
count(*) as count
|
||||
from ${traceAmt} t
|
||||
WHERE t.project_id = {projectId: String}
|
||||
AND t.name IS NOT NULL
|
||||
GROUP BY name
|
||||
ORDER BY count(*) desc
|
||||
LIMIT 1000;
|
||||
`;
|
||||
|
||||
return queryClickhouse<{
|
||||
name: string;
|
||||
count: string;
|
||||
}>({
|
||||
query,
|
||||
params: input.params,
|
||||
tags: input.tags,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return rows;
|
||||
};
|
||||
|
||||
export const getTracesGroupedByUsers = async (
|
||||
@@ -657,44 +739,84 @@ export const getTracesGroupedByUsers = async (
|
||||
const tracesFilterRes = tracesFilter.apply();
|
||||
const search = clickhouseSearchCondition(searchQuery, undefined, "t");
|
||||
|
||||
// We mainly use queries like this to retrieve filter options.
|
||||
// Therefore, we can skip final as some inaccuracy in count is acceptable.
|
||||
const query = `
|
||||
select
|
||||
user_id as user,
|
||||
count(*) as count
|
||||
from traces t
|
||||
WHERE t.project_id = {projectId: String}
|
||||
AND t.user_id IS NOT NULL
|
||||
AND t.user_id != ''
|
||||
${tracesFilterRes?.query ? `AND ${tracesFilterRes.query}` : ""}
|
||||
${search.query}
|
||||
GROUP BY user
|
||||
ORDER BY count desc
|
||||
${limit !== undefined && offset !== undefined ? `LIMIT {limit: Int32} OFFSET {offset: Int32}` : ""}
|
||||
`;
|
||||
|
||||
const rows = await queryClickhouse<{
|
||||
user: string;
|
||||
count: string;
|
||||
}>({
|
||||
query: query,
|
||||
params: {
|
||||
limit,
|
||||
offset,
|
||||
projectId,
|
||||
...(tracesFilterRes ? tracesFilterRes.params : {}),
|
||||
...(searchQuery ? search.params : {}),
|
||||
return measureAndReturn({
|
||||
operationName: "getTracesGroupedByUsers",
|
||||
projectId,
|
||||
input: {
|
||||
params: {
|
||||
limit,
|
||||
offset,
|
||||
projectId,
|
||||
...(tracesFilterRes ? tracesFilterRes.params : {}),
|
||||
...(searchQuery ? search.params : {}),
|
||||
},
|
||||
tags: {
|
||||
feature: "tracing",
|
||||
type: "trace",
|
||||
kind: "analytic",
|
||||
projectId,
|
||||
},
|
||||
},
|
||||
tags: {
|
||||
feature: "tracing",
|
||||
type: "trace",
|
||||
kind: "analytic",
|
||||
projectId,
|
||||
existingExecution: async (input) => {
|
||||
// We mainly use queries like this to retrieve filter options.
|
||||
// Therefore, we can skip final as some inaccuracy in count is acceptable.
|
||||
const query = `
|
||||
select
|
||||
user_id as user,
|
||||
count(*) as count
|
||||
from traces t
|
||||
WHERE t.project_id = {projectId: String}
|
||||
AND t.user_id IS NOT NULL
|
||||
AND t.user_id != ''
|
||||
${tracesFilterRes?.query ? `AND ${tracesFilterRes.query}` : ""}
|
||||
${search.query}
|
||||
GROUP BY user
|
||||
ORDER BY count desc
|
||||
${limit !== undefined && offset !== undefined ? `LIMIT {limit: Int32} OFFSET {offset: Int32}` : ""}
|
||||
`;
|
||||
|
||||
return queryClickhouse<{
|
||||
user: string;
|
||||
count: string;
|
||||
}>({
|
||||
query,
|
||||
params: input.params,
|
||||
tags: input.tags,
|
||||
});
|
||||
},
|
||||
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 query = `
|
||||
select
|
||||
user_id as user,
|
||||
count(*) as count
|
||||
from ${traceAmt} t
|
||||
WHERE t.project_id = {projectId: String}
|
||||
AND t.user_id IS NOT NULL
|
||||
AND t.user_id != ''
|
||||
${tracesFilterRes?.query ? `AND ${tracesFilterRes.query}` : ""}
|
||||
${search.query}
|
||||
GROUP BY user
|
||||
ORDER BY count desc
|
||||
${limit !== undefined && offset !== undefined ? `LIMIT {limit: Int32} OFFSET {offset: Int32}` : ""}
|
||||
`;
|
||||
|
||||
return queryClickhouse<{
|
||||
user: string;
|
||||
count: string;
|
||||
}>({
|
||||
query,
|
||||
params: input.params,
|
||||
tags: input.tags,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return rows;
|
||||
};
|
||||
|
||||
export type GroupedTracesQueryProp = {
|
||||
@@ -713,69 +835,138 @@ export const getTracesGroupedByTags = async (props: GroupedTracesQueryProp) => {
|
||||
|
||||
const filterRes = new FilterList(chFilter).apply();
|
||||
|
||||
const query = `
|
||||
select distinct(arrayJoin(tags)) as value
|
||||
from traces t
|
||||
WHERE t.project_id = {projectId: String}
|
||||
${filterRes?.query ? `AND ${filterRes.query}` : ""}
|
||||
LIMIT 1000;
|
||||
`;
|
||||
|
||||
const rows = await queryClickhouse<{
|
||||
value: string;
|
||||
}>({
|
||||
query: query,
|
||||
params: {
|
||||
projectId: projectId,
|
||||
...(filterRes ? filterRes.params : {}),
|
||||
return measureAndReturn({
|
||||
operationName: "getTracesGroupedByTags",
|
||||
projectId,
|
||||
input: {
|
||||
params: {
|
||||
projectId,
|
||||
...(filterRes ? filterRes.params : {}),
|
||||
},
|
||||
tags: {
|
||||
feature: "tracing",
|
||||
type: "trace",
|
||||
kind: "analytic",
|
||||
projectId,
|
||||
},
|
||||
},
|
||||
tags: {
|
||||
feature: "tracing",
|
||||
type: "trace",
|
||||
kind: "analytic",
|
||||
projectId,
|
||||
existingExecution: async (input) => {
|
||||
const query = `
|
||||
select distinct(arrayJoin(tags)) as value
|
||||
from traces t
|
||||
WHERE t.project_id = {projectId: String}
|
||||
${filterRes?.query ? `AND ${filterRes.query}` : ""}
|
||||
LIMIT 1000;
|
||||
`;
|
||||
|
||||
return queryClickhouse<{
|
||||
value: string;
|
||||
}>({
|
||||
query,
|
||||
params: input.params,
|
||||
tags: input.tags,
|
||||
});
|
||||
},
|
||||
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 query = `
|
||||
select distinct(arrayJoin(tags)) as value
|
||||
from ${traceAmt} t
|
||||
WHERE t.project_id = {projectId: String}
|
||||
${filterRes?.query ? `AND ${filterRes.query}` : ""}
|
||||
LIMIT 1000;
|
||||
`;
|
||||
|
||||
return queryClickhouse<{
|
||||
value: string;
|
||||
}>({
|
||||
query,
|
||||
params: input.params,
|
||||
tags: input.tags,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return rows;
|
||||
};
|
||||
|
||||
export const getTracesIdentifierForSession = async (
|
||||
projectId: string,
|
||||
sessionId: string,
|
||||
) => {
|
||||
const query = `
|
||||
SELECT
|
||||
id,
|
||||
user_id,
|
||||
name,
|
||||
timestamp,
|
||||
project_id,
|
||||
environment
|
||||
FROM traces
|
||||
WHERE (project_id = {projectId: String})
|
||||
AND (session_id = {sessionId: String})
|
||||
ORDER BY timestamp ASC
|
||||
LIMIT 1 BY id, project_id;
|
||||
`;
|
||||
|
||||
const rows = await queryClickhouse<{
|
||||
id: string;
|
||||
user_id: string;
|
||||
name: string;
|
||||
timestamp: string;
|
||||
environment: string;
|
||||
}>({
|
||||
query: query,
|
||||
params: {
|
||||
projectId,
|
||||
sessionId,
|
||||
const rows = await measureAndReturn({
|
||||
operationName: "getTracesIdentifierForSession",
|
||||
projectId,
|
||||
input: {
|
||||
params: {
|
||||
projectId,
|
||||
sessionId,
|
||||
},
|
||||
tags: {
|
||||
feature: "tracing",
|
||||
type: "trace",
|
||||
kind: "list",
|
||||
projectId,
|
||||
},
|
||||
},
|
||||
tags: {
|
||||
feature: "tracing",
|
||||
type: "trace",
|
||||
kind: "list",
|
||||
projectId,
|
||||
existingExecution: (input) => {
|
||||
const query = `
|
||||
SELECT
|
||||
id,
|
||||
user_id,
|
||||
name,
|
||||
timestamp,
|
||||
project_id,
|
||||
environment
|
||||
FROM traces
|
||||
WHERE (project_id = {projectId: String})
|
||||
AND (session_id = {sessionId: String})
|
||||
ORDER BY timestamp ASC
|
||||
LIMIT 1 BY id, project_id;
|
||||
`;
|
||||
|
||||
return queryClickhouse<{
|
||||
id: string;
|
||||
user_id: string;
|
||||
name: string;
|
||||
timestamp: string;
|
||||
environment: string;
|
||||
}>({
|
||||
query,
|
||||
params: input.params,
|
||||
tags: input.tags,
|
||||
});
|
||||
},
|
||||
newExecution: (input) => {
|
||||
const query = `
|
||||
SELECT
|
||||
id,
|
||||
user_id,
|
||||
name,
|
||||
start_time as timestamp,
|
||||
project_id,
|
||||
environment
|
||||
FROM traces_all_amt FINAL
|
||||
WHERE (project_id = {projectId: String})
|
||||
AND (session_id = {sessionId: String})
|
||||
ORDER BY start_time ASC;
|
||||
`;
|
||||
|
||||
return queryClickhouse<{
|
||||
id: string;
|
||||
user_id: string;
|
||||
name: string;
|
||||
timestamp: string;
|
||||
environment: string;
|
||||
}>({
|
||||
query,
|
||||
params: input.params,
|
||||
tags: input.tags,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -862,29 +1053,59 @@ export const deleteTracesByProjectId = async (projectId: string) => {
|
||||
};
|
||||
|
||||
export const hasAnyUser = async (projectId: string) => {
|
||||
const query = `
|
||||
SELECT 1
|
||||
FROM traces
|
||||
WHERE project_id = {projectId: String}
|
||||
AND user_id IS NOT NULL
|
||||
AND user_id != ''
|
||||
LIMIT 1
|
||||
`;
|
||||
|
||||
const rows = await queryClickhouse<{ 1: number }>({
|
||||
query,
|
||||
params: {
|
||||
return measureAndReturn({
|
||||
operationName: "hasAnyUser",
|
||||
projectId,
|
||||
input: {
|
||||
projectId,
|
||||
tags: {
|
||||
feature: "tracing",
|
||||
type: "user",
|
||||
kind: "hasAny",
|
||||
projectId,
|
||||
},
|
||||
},
|
||||
tags: {
|
||||
feature: "tracing",
|
||||
type: "user",
|
||||
kind: "hasAny",
|
||||
projectId,
|
||||
existingExecution: async (input) => {
|
||||
const query = `
|
||||
SELECT 1
|
||||
FROM traces
|
||||
WHERE project_id = {projectId: String}
|
||||
AND user_id IS NOT NULL
|
||||
AND user_id != ''
|
||||
LIMIT 1
|
||||
`;
|
||||
|
||||
const rows = await queryClickhouse<{ 1: number }>({
|
||||
query,
|
||||
params: {
|
||||
projectId: input.projectId,
|
||||
},
|
||||
tags: input.tags,
|
||||
});
|
||||
|
||||
return rows.length > 0;
|
||||
},
|
||||
newExecution: async (input) => {
|
||||
const query = `
|
||||
SELECT 1
|
||||
FROM traces_all_amt
|
||||
WHERE project_id = {projectId: String}
|
||||
AND user_id IS NOT NULL
|
||||
AND user_id != ''
|
||||
LIMIT 1
|
||||
`;
|
||||
|
||||
const rows = await queryClickhouse<{ 1: number }>({
|
||||
query,
|
||||
params: {
|
||||
projectId: input.projectId,
|
||||
},
|
||||
tags: input.tags,
|
||||
});
|
||||
|
||||
return rows.length > 0;
|
||||
},
|
||||
});
|
||||
|
||||
return rows.length > 0;
|
||||
};
|
||||
|
||||
export const getTotalUserCount = async (
|
||||
@@ -903,26 +1124,59 @@ export const getTotalUserCount = async (
|
||||
const tracesFilterRes = tracesFilter.apply();
|
||||
const search = clickhouseSearchCondition(searchQuery, undefined, "t");
|
||||
|
||||
const query = `
|
||||
SELECT COUNT(DISTINCT t.user_id) AS totalCount
|
||||
FROM traces t
|
||||
WHERE ${tracesFilterRes.query}
|
||||
${search.query}
|
||||
AND t.user_id IS NOT NULL
|
||||
AND t.user_id != ''
|
||||
`;
|
||||
|
||||
return queryClickhouse({
|
||||
query,
|
||||
params: {
|
||||
...tracesFilterRes.params,
|
||||
...search.params,
|
||||
return measureAndReturn({
|
||||
operationName: "getTotalUserCount",
|
||||
projectId,
|
||||
input: {
|
||||
params: {
|
||||
...tracesFilterRes.params,
|
||||
...search.params,
|
||||
},
|
||||
tags: {
|
||||
feature: "tracing",
|
||||
type: "trace",
|
||||
kind: "analytic",
|
||||
projectId,
|
||||
},
|
||||
},
|
||||
tags: {
|
||||
feature: "tracing",
|
||||
type: "trace",
|
||||
kind: "analytic",
|
||||
projectId,
|
||||
existingExecution: async (input) => {
|
||||
const query = `
|
||||
SELECT COUNT(DISTINCT t.user_id) AS totalCount
|
||||
FROM traces t
|
||||
WHERE ${tracesFilterRes.query}
|
||||
${search.query}
|
||||
AND t.user_id IS NOT NULL
|
||||
AND t.user_id != ''
|
||||
`;
|
||||
|
||||
return queryClickhouse({
|
||||
query,
|
||||
params: input.params,
|
||||
tags: input.tags,
|
||||
});
|
||||
},
|
||||
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 query = `
|
||||
SELECT COUNT(DISTINCT t.user_id) AS totalCount
|
||||
FROM ${traceAmt} t
|
||||
WHERE ${tracesFilterRes.query}
|
||||
${search.query}
|
||||
AND t.user_id IS NOT NULL
|
||||
AND t.user_id != ''
|
||||
`;
|
||||
|
||||
return queryClickhouse({
|
||||
query,
|
||||
params: input.params,
|
||||
tags: input.tags,
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -1241,35 +1495,6 @@ export const getTracesByIdsForAnyProject = async (traceIds: string[]) => {
|
||||
}));
|
||||
};
|
||||
|
||||
export const traceWithSessionIdExists = async (
|
||||
projectId: string,
|
||||
sessionId: string,
|
||||
) => {
|
||||
const query = `
|
||||
SELECT id, project_id
|
||||
FROM traces
|
||||
WHERE session_id = {sessionId: String}
|
||||
AND project_id = {projectId: String}
|
||||
LIMIT 1
|
||||
`;
|
||||
|
||||
const result = await queryClickhouse<{ id: string; project_id: string }>({
|
||||
query,
|
||||
params: {
|
||||
sessionId,
|
||||
projectId,
|
||||
},
|
||||
tags: {
|
||||
feature: "tracing",
|
||||
type: "trace",
|
||||
kind: "exists",
|
||||
projectId,
|
||||
},
|
||||
});
|
||||
|
||||
return result.length > 0;
|
||||
};
|
||||
|
||||
export async function getAgentGraphData(params: {
|
||||
projectId: string;
|
||||
traceId: string;
|
||||
|
||||
@@ -9,7 +9,7 @@ type FetchDatasetItemsTableProps = {
|
||||
filter: FilterState;
|
||||
};
|
||||
|
||||
const getDatasetRunItemsTableGeneric = async <T>(
|
||||
const getDatasetRunItemsTableGenericPg = async <T>(
|
||||
props: FetchDatasetItemsTableProps,
|
||||
) => {
|
||||
const { select, projectId, filter } = props;
|
||||
@@ -45,11 +45,11 @@ const getDatasetRunItemsTableGeneric = async <T>(
|
||||
return res;
|
||||
};
|
||||
|
||||
export const getDatasetRunItemsTableCount = async (props: {
|
||||
export const getDatasetRunItemsTableCountPg = async (props: {
|
||||
projectId: string;
|
||||
filter: FilterState;
|
||||
}) => {
|
||||
const res = await getDatasetRunItemsTableGeneric<Array<{ count: bigint }>>({
|
||||
const res = await getDatasetRunItemsTableGenericPg<Array<{ count: bigint }>>({
|
||||
select: "count",
|
||||
projectId: props.projectId,
|
||||
filter: props.filter,
|
||||
|
||||
@@ -288,7 +288,7 @@ const getSessionsTableGeneric = async <T>(props: FetchSessionsTableProps) => {
|
||||
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('millisecond', minIf(min_start_time, min_start_time > '1970-01-01'), max(max_end_time)) as duration,
|
||||
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,
|
||||
|
||||
@@ -21,7 +21,9 @@ import {
|
||||
parseClickhouseUTCDateTimeFormat,
|
||||
queryClickhouse,
|
||||
reduceUsageOrCostDetails,
|
||||
getTimeframesTracesAMT,
|
||||
} from "../repositories";
|
||||
import { measureAndReturn } from "../clickhouse/measureAndReturn";
|
||||
import { TracingSearchType } from "../../interfaces/search";
|
||||
import { ObservationLevelType, TraceDomain } from "../../domain";
|
||||
import { ClickHouseClientConfigOptions } from "@clickhouse/client";
|
||||
@@ -217,54 +219,6 @@ async function getTracesTableGeneric(props: FetchTracesTableProps) {
|
||||
clickhouseConfigs,
|
||||
} = props;
|
||||
|
||||
let sqlSelect: string;
|
||||
switch (select) {
|
||||
case "count":
|
||||
sqlSelect = "count(*) as count";
|
||||
break;
|
||||
case "metrics":
|
||||
sqlSelect = `
|
||||
t.id as id,
|
||||
t.project_id as project_id,
|
||||
t.timestamp as timestamp,
|
||||
os.latency_milliseconds / 1000 as latency,
|
||||
os.cost_details as cost_details,
|
||||
os.usage_details as usage_details,
|
||||
os.aggregated_level as level,
|
||||
os.error_count as error_count,
|
||||
os.warning_count as warning_count,
|
||||
os.default_count as default_count,
|
||||
os.debug_count as debug_count,
|
||||
os.observation_count as observation_count,
|
||||
s.scores_avg as scores_avg,
|
||||
s.score_categories as score_categories,
|
||||
t.public as public`;
|
||||
break;
|
||||
case "rows":
|
||||
sqlSelect = `
|
||||
t.id as id,
|
||||
t.project_id as project_id,
|
||||
t.timestamp as timestamp,
|
||||
t.tags as tags,
|
||||
t.bookmarked as bookmarked,
|
||||
t.name as name,
|
||||
t.release as release,
|
||||
t.version as version,
|
||||
t.user_id as user_id,
|
||||
t.environment as environment,
|
||||
t.session_id as session_id,
|
||||
t.public as public`;
|
||||
break;
|
||||
case "identifiers":
|
||||
sqlSelect = `
|
||||
t.id as id,
|
||||
t.project_id as projectId,
|
||||
t.timestamp as timestamp`;
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unknown select type: ${select}`);
|
||||
}
|
||||
|
||||
const { tracesFilter, scoresFilter, observationsFilter } =
|
||||
getProjectIdDefaultFilter(projectId, { tracesPrefix: "t" });
|
||||
|
||||
@@ -329,149 +283,318 @@ async function getTracesTableGeneric(props: FetchTracesTableProps) {
|
||||
const scoresFilterRes = scoresFilter.apply();
|
||||
const observationFilterRes = observationsFilter.apply();
|
||||
|
||||
const search = clickhouseSearchCondition(searchQuery, searchType, "t");
|
||||
|
||||
const defaultOrder = orderBy?.order && orderBy?.column === "timestamp";
|
||||
const orderByCols = [
|
||||
...tracesTableUiColumnDefinitions,
|
||||
{
|
||||
clickhouseSelect: "toDate(t.timestamp)",
|
||||
uiTableName: "timestamp_to_date",
|
||||
uiTableId: "timestamp_to_date",
|
||||
clickhouseTableName: "traces",
|
||||
},
|
||||
{
|
||||
clickhouseSelect: "t.event_ts",
|
||||
uiTableName: "event_ts",
|
||||
uiTableId: "event_ts",
|
||||
clickhouseTableName: "traces",
|
||||
},
|
||||
];
|
||||
const chOrderBy = orderByToClickhouseSql(
|
||||
[
|
||||
defaultOrder
|
||||
? [
|
||||
{
|
||||
column: "timestamp_to_date",
|
||||
order: orderBy.order,
|
||||
},
|
||||
{ column: "timestamp", order: orderBy.order },
|
||||
{ column: "event_ts", order: "DESC" as "DESC" },
|
||||
]
|
||||
: null,
|
||||
orderBy ?? null,
|
||||
].flat(),
|
||||
orderByCols,
|
||||
);
|
||||
|
||||
// complex query ahead:
|
||||
// - we only join scores and observations if we really need them to speed up default views
|
||||
// - we use FINAL on traces only in case we not need to order by something different than time. Otherwise we cannot guarantee correct reads.
|
||||
// - we filter the observations and scores as much as possible before joining them to traces.
|
||||
// - we order by todate(timestamp), event_ts desc per default and do not use FINAL.
|
||||
// In this case, CH is able to read the data only from the latest date from disk and filtering them in memory. No need to read all data e.g. for 1 month from disk.
|
||||
|
||||
const query = `
|
||||
const observationsAndScoresCTE = `
|
||||
WITH observations_stats AS (
|
||||
SELECT
|
||||
COUNT(*) AS observation_count,
|
||||
sumMap(usage_details) as usage_details,
|
||||
SUM(total_cost) AS total_cost,
|
||||
date_diff('millisecond', least(min(start_time), min(end_time)), greatest(max(start_time), max(end_time))) as latency_milliseconds,
|
||||
countIf(level = 'ERROR') as error_count,
|
||||
countIf(level = 'WARNING') as warning_count,
|
||||
countIf(level = 'DEFAULT') as default_count,
|
||||
countIf(level = 'DEBUG') as debug_count,
|
||||
multiIf(
|
||||
arrayExists(x -> x = 'ERROR', groupArray(level)), 'ERROR',
|
||||
arrayExists(x -> x = 'WARNING', groupArray(level)), 'WARNING',
|
||||
arrayExists(x -> x = 'DEFAULT', groupArray(level)), 'DEFAULT',
|
||||
'DEBUG'
|
||||
) AS aggregated_level,
|
||||
sumMap(cost_details) as cost_details,
|
||||
trace_id,
|
||||
project_id
|
||||
FROM observations o FINAL
|
||||
sumMap(usage_details) as usage_details,
|
||||
SUM(total_cost) AS total_cost,
|
||||
date_diff('millisecond', least(min(start_time), min(end_time)), greatest(max(start_time), max(end_time))) as latency_milliseconds,
|
||||
countIf(level = 'ERROR') as error_count,
|
||||
countIf(level = 'WARNING') as warning_count,
|
||||
countIf(level = 'DEFAULT') as default_count,
|
||||
countIf(level = 'DEBUG') as debug_count,
|
||||
multiIf(
|
||||
arrayExists(x -> x = 'ERROR', groupArray(level)), 'ERROR',
|
||||
arrayExists(x -> x = 'WARNING', groupArray(level)), 'WARNING',
|
||||
arrayExists(x -> x = 'DEFAULT', groupArray(level)), 'DEFAULT',
|
||||
'DEBUG'
|
||||
) AS aggregated_level,
|
||||
sumMap(cost_details) as cost_details,
|
||||
trace_id,
|
||||
project_id
|
||||
FROM observations o FINAL
|
||||
WHERE o.project_id = {projectId: String}
|
||||
${timeStampFilter ? `AND o.start_time >= {traceTimestamp: DateTime64(3)} - ${OBSERVATIONS_TO_TRACE_INTERVAL}` : ""}
|
||||
${observationsFilter ? `AND ${observationFilterRes.query}` : ""}
|
||||
${timeStampFilter ? `AND o.start_time >= {traceTimestamp: DateTime64(3)} - ${OBSERVATIONS_TO_TRACE_INTERVAL}` : ""}
|
||||
${observationsFilter ? `AND ${observationFilterRes.query}` : ""}
|
||||
GROUP BY trace_id, project_id
|
||||
),
|
||||
scores_avg AS (
|
||||
SELECT
|
||||
project_id,
|
||||
trace_id,
|
||||
-- For numeric scores, use tuples of (name, avg_value)
|
||||
groupArrayIf(
|
||||
tuple(name, avg_value),
|
||||
data_type IN ('NUMERIC', 'BOOLEAN')
|
||||
) AS scores_avg,
|
||||
-- For categorical scores, use name:value format for improved query performance
|
||||
groupArrayIf(
|
||||
concat(name, ':', string_value),
|
||||
data_type = 'CATEGORICAL' AND notEmpty(string_value)
|
||||
) AS score_categories
|
||||
FROM (
|
||||
SELECT
|
||||
project_id,
|
||||
trace_id,
|
||||
name,
|
||||
data_type,
|
||||
string_value,
|
||||
avg(value) as avg_value
|
||||
FROM scores s FINAL
|
||||
WHERE
|
||||
project_id = {projectId: String}
|
||||
${timeStampFilter ? `AND s.timestamp >= {traceTimestamp: DateTime64(3)} - ${SCORE_TO_TRACE_OBSERVATIONS_INTERVAL}` : ""}
|
||||
${scoresFilterRes ? `AND ${scoresFilterRes.query}` : ""}
|
||||
GROUP BY
|
||||
project_id,
|
||||
trace_id,
|
||||
name,
|
||||
data_type,
|
||||
string_value
|
||||
) tmp
|
||||
GROUP BY project_id, trace_id
|
||||
)
|
||||
SELECT ${sqlSelect}
|
||||
-- FINAL is used for non default ordering and count.
|
||||
FROM traces t ${["metrics", "rows", "identifiers"].includes(select) && defaultOrder ? "" : "FINAL"}
|
||||
${select === "metrics" || requiresObservationsJoin ? `LEFT JOIN observations_stats os on os.project_id = t.project_id and os.trace_id = t.id` : ""}
|
||||
${select === "metrics" || requiresScoresJoin ? `LEFT JOIN scores_avg s on s.project_id = t.project_id and s.trace_id = t.id` : ""}
|
||||
WHERE t.project_id = {projectId: String}
|
||||
${tracesFilterRes ? `AND ${tracesFilterRes.query}` : ""}
|
||||
${search.query}
|
||||
${chOrderBy}
|
||||
-- This is used for metrics and row queries. Count has only one result.
|
||||
-- This is only used for default ordering. Otherwise, we use final.
|
||||
${["metrics", "rows", "identifiers"].includes(select) && defaultOrder ? "LIMIT 1 BY id, project_id" : ""}
|
||||
${limit !== undefined && page !== undefined ? `LIMIT {limit: Int32} OFFSET {offset: Int32}` : ""}
|
||||
scores_avg AS (
|
||||
SELECT
|
||||
project_id,
|
||||
trace_id,
|
||||
-- For numeric scores, use tuples of (name, avg_value)
|
||||
groupArrayIf(
|
||||
tuple(name, avg_value),
|
||||
data_type IN ('NUMERIC', 'BOOLEAN')
|
||||
) AS scores_avg,
|
||||
-- For categorical scores, use name:value format for improved query performance
|
||||
groupArrayIf(
|
||||
concat(name, ':', string_value),
|
||||
data_type = 'CATEGORICAL' AND notEmpty(string_value)
|
||||
) AS score_categories
|
||||
FROM (
|
||||
SELECT
|
||||
project_id,
|
||||
trace_id,
|
||||
name,
|
||||
data_type,
|
||||
string_value,
|
||||
avg(value) as avg_value
|
||||
FROM scores s FINAL
|
||||
WHERE
|
||||
project_id = {projectId: String}
|
||||
${timeStampFilter ? `AND s.timestamp >= {traceTimestamp: DateTime64(3)} - ${SCORE_TO_TRACE_OBSERVATIONS_INTERVAL}` : ""}
|
||||
${scoresFilterRes ? `AND ${scoresFilterRes.query}` : ""}
|
||||
GROUP BY
|
||||
project_id,
|
||||
trace_id,
|
||||
name,
|
||||
data_type,
|
||||
string_value
|
||||
) tmp
|
||||
GROUP BY project_id, trace_id
|
||||
)
|
||||
`;
|
||||
|
||||
const res = await queryClickhouse<
|
||||
SelectReturnTypeMap[keyof SelectReturnTypeMap]
|
||||
>({
|
||||
query: query,
|
||||
params: {
|
||||
limit: limit,
|
||||
offset: limit && page ? limit * page : 0,
|
||||
traceTimestamp: timeStampFilter?.value.getTime(),
|
||||
projectId: projectId,
|
||||
...tracesFilterRes.params,
|
||||
...observationFilterRes.params,
|
||||
...scoresFilterRes.params,
|
||||
...search.params,
|
||||
},
|
||||
tags: {
|
||||
...(props.tags ?? {}),
|
||||
feature: "tracing",
|
||||
type: "traces-table",
|
||||
projectId,
|
||||
},
|
||||
clickhouseConfigs,
|
||||
});
|
||||
return measureAndReturn({
|
||||
operationName: "getTracesTableGeneric",
|
||||
projectId: props.projectId,
|
||||
input: props,
|
||||
existingExecution: async (props) => {
|
||||
let sqlSelect: string;
|
||||
switch (select) {
|
||||
case "count":
|
||||
sqlSelect = "count(*) as count";
|
||||
break;
|
||||
case "metrics":
|
||||
sqlSelect = `
|
||||
t.id as id,
|
||||
t.project_id as project_id,
|
||||
t.timestamp as timestamp,
|
||||
os.latency_milliseconds / 1000 as latency,
|
||||
os.cost_details as cost_details,
|
||||
os.usage_details as usage_details,
|
||||
os.aggregated_level as level,
|
||||
os.error_count as error_count,
|
||||
os.warning_count as warning_count,
|
||||
os.default_count as default_count,
|
||||
os.debug_count as debug_count,
|
||||
os.observation_count as observation_count,
|
||||
s.scores_avg as scores_avg,
|
||||
s.score_categories as score_categories,
|
||||
t.public as public`;
|
||||
break;
|
||||
case "rows":
|
||||
sqlSelect = `
|
||||
t.id as id,
|
||||
t.project_id as project_id,
|
||||
t.timestamp as timestamp,
|
||||
t.tags as tags,
|
||||
t.bookmarked as bookmarked,
|
||||
t.name as name,
|
||||
t.release as release,
|
||||
t.version as version,
|
||||
t.user_id as user_id,
|
||||
t.environment as environment,
|
||||
t.session_id as session_id,
|
||||
t.public as public`;
|
||||
break;
|
||||
case "identifiers":
|
||||
sqlSelect = `
|
||||
t.id as id,
|
||||
t.project_id as projectId,
|
||||
t.timestamp as timestamp`;
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unknown select type: ${select}`);
|
||||
}
|
||||
|
||||
return res;
|
||||
const search = clickhouseSearchCondition(searchQuery, searchType, "t");
|
||||
|
||||
const defaultOrder = orderBy?.order && orderBy?.column === "timestamp";
|
||||
const orderByCols = [
|
||||
...tracesTableUiColumnDefinitions,
|
||||
{
|
||||
clickhouseSelect: "toDate(t.timestamp)",
|
||||
uiTableName: "timestamp_to_date",
|
||||
uiTableId: "timestamp_to_date",
|
||||
clickhouseTableName: "traces",
|
||||
},
|
||||
{
|
||||
clickhouseSelect: "t.event_ts",
|
||||
uiTableName: "event_ts",
|
||||
uiTableId: "event_ts",
|
||||
clickhouseTableName: "traces",
|
||||
},
|
||||
];
|
||||
const chOrderBy = orderByToClickhouseSql(
|
||||
[
|
||||
defaultOrder
|
||||
? [
|
||||
{
|
||||
column: "timestamp_to_date",
|
||||
order: orderBy.order,
|
||||
},
|
||||
{ column: "timestamp", order: orderBy.order },
|
||||
{ column: "event_ts", order: "DESC" as "DESC" },
|
||||
]
|
||||
: null,
|
||||
orderBy ?? null,
|
||||
].flat(),
|
||||
orderByCols,
|
||||
);
|
||||
|
||||
// complex query ahead:
|
||||
// - we only join scores and observations if we really need them to speed up default views
|
||||
// - we use FINAL on traces only in case we not need to order by something different than time. Otherwise we cannot guarantee correct reads.
|
||||
// - we filter the observations and scores as much as possible before joining them to traces.
|
||||
// - we order by todate(timestamp), event_ts desc per default and do not use FINAL.
|
||||
// In this case, CH is able to read the data only from the latest date from disk and filtering them in memory. No need to read all data e.g. for 1 month from disk.
|
||||
|
||||
const query = `
|
||||
${observationsAndScoresCTE}
|
||||
|
||||
SELECT ${sqlSelect}
|
||||
-- FINAL is used for non default ordering and count.
|
||||
FROM traces t ${["metrics", "rows", "identifiers"].includes(select) && defaultOrder ? "" : "FINAL"}
|
||||
${select === "metrics" || requiresObservationsJoin ? `LEFT JOIN observations_stats os on os.project_id = t.project_id and os.trace_id = t.id` : ""}
|
||||
${select === "metrics" || requiresScoresJoin ? `LEFT JOIN scores_avg s on s.project_id = t.project_id and s.trace_id = t.id` : ""}
|
||||
WHERE t.project_id = {projectId: String}
|
||||
${tracesFilterRes ? `AND ${tracesFilterRes.query}` : ""}
|
||||
${search.query}
|
||||
${chOrderBy}
|
||||
-- This is used for metrics and row queries. Count has only one result.
|
||||
-- This is only used for default ordering. Otherwise, we use final.
|
||||
${["metrics", "rows", "identifiers"].includes(select) && defaultOrder ? "LIMIT 1 BY id, project_id" : ""}
|
||||
${limit !== undefined && page !== undefined ? `LIMIT {limit: Int32} OFFSET {offset: Int32}` : ""}
|
||||
`;
|
||||
|
||||
const res = await queryClickhouse<
|
||||
SelectReturnTypeMap[keyof SelectReturnTypeMap]
|
||||
>({
|
||||
query: query,
|
||||
params: {
|
||||
limit: limit,
|
||||
offset: limit && page ? limit * page : 0,
|
||||
traceTimestamp: timeStampFilter?.value.getTime(),
|
||||
projectId: projectId,
|
||||
...tracesFilterRes.params,
|
||||
...observationFilterRes.params,
|
||||
...scoresFilterRes.params,
|
||||
...search.params,
|
||||
},
|
||||
tags: {
|
||||
...(props.tags ?? {}),
|
||||
feature: "tracing",
|
||||
type: "traces-table",
|
||||
projectId,
|
||||
},
|
||||
clickhouseConfigs,
|
||||
});
|
||||
|
||||
return res;
|
||||
},
|
||||
newExecution: async () => {
|
||||
let sqlSelect: string;
|
||||
switch (select) {
|
||||
case "count":
|
||||
sqlSelect = "count(*) as count";
|
||||
break;
|
||||
case "metrics":
|
||||
sqlSelect = `
|
||||
t.id as id,
|
||||
t.project_id as project_id,
|
||||
t.timestamp as timestamp,
|
||||
os.latency_milliseconds / 1000 as latency,
|
||||
os.cost_details as cost_details,
|
||||
os.usage_details as usage_details,
|
||||
os.aggregated_level as level,
|
||||
os.error_count as error_count,
|
||||
os.warning_count as warning_count,
|
||||
os.default_count as default_count,
|
||||
os.debug_count as debug_count,
|
||||
os.observation_count as observation_count,
|
||||
s.scores_avg as scores_avg,
|
||||
s.score_categories as score_categories,
|
||||
t.public as public`;
|
||||
break;
|
||||
case "rows":
|
||||
sqlSelect = `
|
||||
t.id as id,
|
||||
t.project_id as project_id,
|
||||
t.timestamp as timestamp,
|
||||
t.tags as tags,
|
||||
finalizeAggregation(t.bookmarked) as bookmarked,
|
||||
t.name as name,
|
||||
t.release as release,
|
||||
t.version as version,
|
||||
t.user_id as user_id,
|
||||
t.environment as environment,
|
||||
t.session_id as session_id,
|
||||
finalizeAggregation(t.public) as public`;
|
||||
break;
|
||||
case "identifiers":
|
||||
sqlSelect = `
|
||||
t.id as id,
|
||||
t.project_id as projectId,
|
||||
t.timestamp as timestamp`;
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unknown select type: ${select}`);
|
||||
}
|
||||
|
||||
const search = clickhouseSearchCondition(
|
||||
searchQuery,
|
||||
searchType,
|
||||
"t",
|
||||
true,
|
||||
);
|
||||
|
||||
const defaultOrder = orderBy?.order && orderBy?.column === "timestamp";
|
||||
const chOrderBy = orderByToClickhouseSql(
|
||||
[
|
||||
defaultOrder ? [{ column: "timestamp", order: orderBy.order }] : null,
|
||||
orderBy ?? null,
|
||||
].flat(),
|
||||
tracesTableUiColumnDefinitions,
|
||||
);
|
||||
|
||||
const tracesAmt =
|
||||
select === "metrics"
|
||||
? "traces_all_amt"
|
||||
: getTimeframesTracesAMT(timeStampFilter?.value);
|
||||
|
||||
const query = `
|
||||
${observationsAndScoresCTE}
|
||||
|
||||
SELECT ${sqlSelect}
|
||||
FROM ${tracesAmt} t FINAL
|
||||
${select === "metrics" || requiresObservationsJoin ? `LEFT JOIN observations_stats os on os.project_id = t.project_id and os.trace_id = t.id` : ""}
|
||||
${select === "metrics" || requiresScoresJoin ? `LEFT JOIN scores_avg s on s.project_id = t.project_id and s.trace_id = t.id` : ""}
|
||||
WHERE t.project_id = {projectId: String}
|
||||
${tracesFilterRes ? `AND ${tracesFilterRes.query}` : ""}
|
||||
${search.query}
|
||||
${chOrderBy}
|
||||
${limit !== undefined && page !== undefined ? `LIMIT {limit: Int32} OFFSET {offset: Int32}` : ""}
|
||||
`;
|
||||
|
||||
const res = await queryClickhouse<
|
||||
SelectReturnTypeMap[keyof SelectReturnTypeMap]
|
||||
>({
|
||||
query: query,
|
||||
params: {
|
||||
limit: limit,
|
||||
offset: limit && page ? limit * page : 0,
|
||||
traceTimestamp: timeStampFilter?.value.getTime(),
|
||||
projectId: projectId,
|
||||
...tracesFilterRes.params,
|
||||
...observationFilterRes.params,
|
||||
...scoresFilterRes.params,
|
||||
...search.params,
|
||||
},
|
||||
tags: {
|
||||
...(props.tags ?? {}),
|
||||
feature: "tracing",
|
||||
type: "traces-table",
|
||||
projectId,
|
||||
},
|
||||
clickhouseConfigs,
|
||||
});
|
||||
|
||||
return res;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export const getTracesTableCount = async (props: {
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
TraceRecordInsertType,
|
||||
ObservationRecordInsertType,
|
||||
ScoreRecordInsertType,
|
||||
DatasetRunItemRecordInsertType,
|
||||
convertTraceToTraceMt,
|
||||
} from "../repositories/definitions";
|
||||
import { env } from "../../env";
|
||||
@@ -41,3 +42,13 @@ export const createScoresCh = async (scores: ScoreRecordInsertType[]) => {
|
||||
values: scores,
|
||||
});
|
||||
};
|
||||
|
||||
export const createDatasetRunItemsCh = async (
|
||||
datasetRunItems: DatasetRunItemRecordInsertType[],
|
||||
) => {
|
||||
return await clickhouseClient().insert({
|
||||
table: "dataset_run_items",
|
||||
format: "JSONEachRow",
|
||||
values: datasetRunItems,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
TraceRecordInsertType,
|
||||
ObservationRecordInsertType,
|
||||
ScoreRecordInsertType,
|
||||
DatasetRunItemRecordInsertType,
|
||||
} from "../repositories/definitions";
|
||||
|
||||
export const createTrace = (
|
||||
@@ -33,6 +34,32 @@ export const createTrace = (
|
||||
};
|
||||
};
|
||||
|
||||
export const createDatasetRunItem = (
|
||||
datasetRunItem: Partial<DatasetRunItemRecordInsertType>,
|
||||
): DatasetRunItemRecordInsertType => {
|
||||
return {
|
||||
id: v4(),
|
||||
project_id: v4(),
|
||||
trace_id: v4(),
|
||||
observation_id: null,
|
||||
dataset_run_id: v4(),
|
||||
dataset_item_id: v4(),
|
||||
dataset_id: v4(),
|
||||
dataset_run_name: "test-run-name" + v4(),
|
||||
dataset_run_metadata: { key: "value" },
|
||||
dataset_item_input: "{}",
|
||||
dataset_item_expected_output: "{}",
|
||||
dataset_item_metadata: { key: "value" },
|
||||
dataset_run_created_at: Date.now(),
|
||||
created_at: Date.now(),
|
||||
updated_at: Date.now(),
|
||||
event_ts: Date.now(),
|
||||
is_deleted: 0,
|
||||
error: datasetRunItem.error ?? null,
|
||||
...datasetRunItem,
|
||||
};
|
||||
};
|
||||
|
||||
export const createObservation = (
|
||||
observation: Partial<ObservationRecordInsertType>,
|
||||
): ObservationRecordInsertType => {
|
||||
|
||||
Generated
+341
-368
File diff suppressed because it is too large
Load Diff
+4
-4
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "web",
|
||||
"version": "3.84.0",
|
||||
"version": "3.85.2",
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -158,12 +158,12 @@
|
||||
"zod": "^3.25.62"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@repo/eslint-config": "workspace:*",
|
||||
"@repo/typescript-config": "workspace:*",
|
||||
"@jedmao/location": "^3.0.0",
|
||||
"@mermaid-js/mermaid-cli": "^11.2.0",
|
||||
"@next/bundle-analyzer": "^15.4.1",
|
||||
"@playwright/test": "^1.47.2",
|
||||
"@repo/eslint-config": "workspace:*",
|
||||
"@repo/typescript-config": "workspace:*",
|
||||
"@tailwindcss/forms": "^0.5.7",
|
||||
"@testing-library/jest-dom": "^6.4.6",
|
||||
"@testing-library/react": "^15.0.7",
|
||||
@@ -174,7 +174,7 @@
|
||||
"@types/eslint": "^8.56.7",
|
||||
"@types/jest": "^29.5.12",
|
||||
"@types/lodash": "^4.17.10",
|
||||
"@types/node": "20.10.5",
|
||||
"@types/node": "20.11.29",
|
||||
"@types/react": "~18.2.79",
|
||||
"@types/react-dom": "~18.2.25",
|
||||
"@types/react-grid-layout": "^1.3.5",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import {
|
||||
createOrgProjectAndApiKey,
|
||||
getDatasetRunItemsTableCount,
|
||||
getDatasetRunItemsTableCountPg,
|
||||
} from "@langfuse/shared/src/server";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { prisma } from "@langfuse/shared/src/db";
|
||||
@@ -65,7 +65,7 @@ describe("trpc.datasets", () => {
|
||||
});
|
||||
describe("GET datasetItems.countAll", () => {
|
||||
it("should GET all dataset run items with no filter", async () => {
|
||||
const { totalCount } = await getDatasetRunItemsTableCount({
|
||||
const { totalCount } = await getDatasetRunItemsTableCountPg({
|
||||
projectId: projectId,
|
||||
filter: [],
|
||||
});
|
||||
@@ -74,7 +74,7 @@ describe("trpc.datasets", () => {
|
||||
});
|
||||
|
||||
it("should GET all dataset run items with filter", async () => {
|
||||
const { totalCount } = await getDatasetRunItemsTableCount({
|
||||
const { totalCount } = await getDatasetRunItemsTableCountPg({
|
||||
projectId: projectId,
|
||||
filter: generateFilter([datasetIds[0]]),
|
||||
});
|
||||
|
||||
@@ -679,6 +679,7 @@ describe("prompts trpc", () => {
|
||||
filter: [],
|
||||
orderBy: { column: "createdAt", order: "DESC" },
|
||||
searchQuery: "customer support agent",
|
||||
searchType: ["content"],
|
||||
});
|
||||
|
||||
expect(contentSearchResults.prompts).toHaveLength(1);
|
||||
@@ -694,6 +695,7 @@ describe("prompts trpc", () => {
|
||||
filter: [],
|
||||
orderBy: { column: "createdAt", order: "DESC" },
|
||||
searchQuery: "marketing",
|
||||
searchType: ["id"],
|
||||
});
|
||||
|
||||
expect(nameSearchResults.prompts).toHaveLength(1);
|
||||
@@ -707,6 +709,7 @@ describe("prompts trpc", () => {
|
||||
filter: [],
|
||||
orderBy: { column: "createdAt", order: "DESC" },
|
||||
searchQuery: "documentation",
|
||||
searchType: ["id"],
|
||||
});
|
||||
|
||||
expect(tagSearchResults.prompts).toHaveLength(1);
|
||||
@@ -720,6 +723,7 @@ describe("prompts trpc", () => {
|
||||
filter: [],
|
||||
orderBy: { column: "createdAt", order: "DESC" },
|
||||
searchQuery: "nonexistent content",
|
||||
searchType: ["id", "content"],
|
||||
});
|
||||
|
||||
expect(noMatchResults.prompts).toHaveLength(0);
|
||||
@@ -732,6 +736,7 @@ describe("prompts trpc", () => {
|
||||
filter: [],
|
||||
orderBy: { column: "createdAt", order: "DESC" },
|
||||
searchQuery: "TECHNICAL",
|
||||
searchType: ["id"],
|
||||
});
|
||||
|
||||
expect(caseInsensitiveResults.prompts).toHaveLength(1);
|
||||
@@ -780,6 +785,7 @@ describe("prompts trpc", () => {
|
||||
filter: [],
|
||||
orderBy: { column: "createdAt", order: "DESC" },
|
||||
searchQuery: "machine learning",
|
||||
searchType: ["content"],
|
||||
});
|
||||
|
||||
// Should find the prompt because one of its versions contains the search term
|
||||
@@ -789,4 +795,222 @@ describe("prompts trpc", () => {
|
||||
expect(searchResults.prompts[0].version).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("prompts.count with search and searchType", () => {
|
||||
it("should count prompts correctly with different search types", async () => {
|
||||
const { project, caller } = await prepare();
|
||||
|
||||
// Create test prompts with different searchable content
|
||||
await prisma.prompt.create({
|
||||
data: {
|
||||
id: v4(),
|
||||
projectId: project.id,
|
||||
name: "customer-service-prompt",
|
||||
version: 1,
|
||||
type: "text",
|
||||
prompt: {
|
||||
text: "You are a helpful customer support agent. Answer questions about billing and account issues.",
|
||||
},
|
||||
createdBy: "test-user",
|
||||
tags: ["support", "customer"],
|
||||
labels: ["production"],
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.prompt.create({
|
||||
data: {
|
||||
id: v4(),
|
||||
projectId: project.id,
|
||||
name: "marketing-prompt",
|
||||
version: 1,
|
||||
type: "text",
|
||||
prompt: {
|
||||
text: "Create engaging marketing content that drives sales and conversions.",
|
||||
},
|
||||
createdBy: "test-user",
|
||||
tags: ["marketing", "content"],
|
||||
labels: ["staging"],
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.prompt.create({
|
||||
data: {
|
||||
id: v4(),
|
||||
projectId: project.id,
|
||||
name: "technical-docs",
|
||||
version: 1,
|
||||
type: "text",
|
||||
prompt: {
|
||||
text: "Generate comprehensive technical documentation for APIs and software systems.",
|
||||
},
|
||||
createdBy: "test-user",
|
||||
tags: ["technical", "documentation"],
|
||||
labels: ["latest"],
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.prompt.create({
|
||||
data: {
|
||||
id: v4(),
|
||||
projectId: project.id,
|
||||
name: "sales-support-prompt",
|
||||
version: 1,
|
||||
type: "text",
|
||||
prompt: {
|
||||
text: "Assist sales teams with lead qualification and customer outreach.",
|
||||
},
|
||||
createdBy: "test-user",
|
||||
tags: ["sales", "support"],
|
||||
labels: ["production"],
|
||||
},
|
||||
});
|
||||
|
||||
// Test 1: Count all prompts (no search)
|
||||
const totalCountResult = await caller.prompts.count({
|
||||
projectId: project.id,
|
||||
});
|
||||
expect(totalCountResult.totalCount).toBe(BigInt(4));
|
||||
|
||||
// Test 2: Count with ID search type (default) - search in names and tags
|
||||
const idSearchCountResult = await caller.prompts.count({
|
||||
projectId: project.id,
|
||||
searchQuery: "support",
|
||||
searchType: ["id"],
|
||||
});
|
||||
// Should find "customer-service-prompt" and "sales-support-prompt" (by name and tags)
|
||||
expect(idSearchCountResult.totalCount).toBe(BigInt(2));
|
||||
|
||||
// Test 3: Count with content search type - search in prompt content
|
||||
const contentSearchCountResult = await caller.prompts.count({
|
||||
projectId: project.id,
|
||||
searchQuery: "customer",
|
||||
searchType: ["content"],
|
||||
});
|
||||
// Should find "customer-service-prompt" and "sales-support-prompt" (by content: "customer" appears in both)
|
||||
expect(contentSearchCountResult.totalCount).toBe(BigInt(2));
|
||||
|
||||
// Test 4: Count with both ID and content search types
|
||||
const combinedSearchCountResult = await caller.prompts.count({
|
||||
projectId: project.id,
|
||||
searchQuery: "marketing",
|
||||
searchType: ["id", "content"],
|
||||
});
|
||||
// Should find "marketing-prompt" (by both name and content)
|
||||
expect(combinedSearchCountResult.totalCount).toBe(BigInt(1));
|
||||
|
||||
// Test 5: Count with tag-specific search (ID search type)
|
||||
const tagSearchCountResult = await caller.prompts.count({
|
||||
projectId: project.id,
|
||||
searchQuery: "documentation",
|
||||
searchType: ["id"],
|
||||
});
|
||||
// Should find "technical-docs" (by tag)
|
||||
expect(tagSearchCountResult.totalCount).toBe(BigInt(1));
|
||||
|
||||
// Test 6: Count with content that doesn't exist in names/tags but exists in prompt text
|
||||
const contentOnlySearchResult = await caller.prompts.count({
|
||||
projectId: project.id,
|
||||
searchQuery: "billing",
|
||||
searchType: ["content"],
|
||||
});
|
||||
// Should find "customer-service-prompt" (by content only)
|
||||
expect(contentOnlySearchResult.totalCount).toBe(BigInt(1));
|
||||
|
||||
// Test 7: Count with no matches
|
||||
const noMatchCountResult = await caller.prompts.count({
|
||||
projectId: project.id,
|
||||
searchQuery: "nonexistent",
|
||||
searchType: ["id", "content"],
|
||||
});
|
||||
expect(noMatchCountResult.totalCount).toBe(BigInt(0));
|
||||
|
||||
// Test 8: Count with case insensitive search
|
||||
const caseInsensitiveCountResult = await caller.prompts.count({
|
||||
projectId: project.id,
|
||||
searchQuery: "TECHNICAL",
|
||||
searchType: ["id"],
|
||||
});
|
||||
// Should find "technical-docs" (case insensitive)
|
||||
expect(caseInsensitiveCountResult.totalCount).toBe(BigInt(1));
|
||||
|
||||
// Test 9: Count with filters and search combined
|
||||
const filteredSearchCountResult = await caller.prompts.count({
|
||||
projectId: project.id,
|
||||
searchQuery: "support",
|
||||
searchType: ["id"],
|
||||
filter: [
|
||||
{
|
||||
column: "labels",
|
||||
type: "arrayOptions",
|
||||
operator: "any of",
|
||||
value: ["production"],
|
||||
},
|
||||
],
|
||||
});
|
||||
// Should find prompts with "support" AND "production" label
|
||||
expect(filteredSearchCountResult.totalCount).toBe(BigInt(2));
|
||||
});
|
||||
|
||||
it("should count prompts with folder path prefix and search", async () => {
|
||||
const { project, caller } = await prepare();
|
||||
|
||||
// Create prompts in different folder structures
|
||||
await prisma.prompt.create({
|
||||
data: {
|
||||
id: v4(),
|
||||
projectId: project.id,
|
||||
name: "folder1/customer-prompt",
|
||||
version: 1,
|
||||
type: "text",
|
||||
prompt: { text: "Customer service prompt" },
|
||||
createdBy: "test-user",
|
||||
tags: ["customer"],
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.prompt.create({
|
||||
data: {
|
||||
id: v4(),
|
||||
projectId: project.id,
|
||||
name: "folder1/support-prompt",
|
||||
version: 1,
|
||||
type: "text",
|
||||
prompt: { text: "Technical support prompt" },
|
||||
createdBy: "test-user",
|
||||
tags: ["support"],
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.prompt.create({
|
||||
data: {
|
||||
id: v4(),
|
||||
projectId: project.id,
|
||||
name: "folder2/customer-prompt",
|
||||
version: 1,
|
||||
type: "text",
|
||||
prompt: { text: "Another customer prompt" },
|
||||
createdBy: "test-user",
|
||||
tags: ["customer"],
|
||||
},
|
||||
});
|
||||
|
||||
// Test: Count with path prefix and search
|
||||
const folderSearchCountResult = await caller.prompts.count({
|
||||
projectId: project.id,
|
||||
pathPrefix: "folder1",
|
||||
searchQuery: "customer",
|
||||
searchType: ["id"],
|
||||
});
|
||||
// Should only find prompts in folder1 that match "customer"
|
||||
expect(folderSearchCountResult.totalCount).toBe(BigInt(1));
|
||||
|
||||
// Test: Count with path prefix but no search
|
||||
const folderCountResult = await caller.prompts.count({
|
||||
projectId: project.id,
|
||||
pathPrefix: "folder1",
|
||||
});
|
||||
// Should find all prompts in folder1
|
||||
expect(folderCountResult.totalCount).toBe(BigInt(2));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -52,7 +52,7 @@ describe("traces trpc", () => {
|
||||
const ctx = createInnerTRPCContext({ session });
|
||||
const caller = appRouter.createCaller({ ...ctx, prisma });
|
||||
|
||||
describe("sessions.byId", () => {
|
||||
describe("sessions.byIdWithScores", () => {
|
||||
it("access private session", async () => {
|
||||
const sessionId = randomUUID();
|
||||
|
||||
@@ -92,7 +92,7 @@ describe("traces trpc", () => {
|
||||
|
||||
await createObservationsCh([observation, observation2, observation3]);
|
||||
|
||||
const sessionRes = await caller.sessions.byId({
|
||||
const sessionRes = await caller.sessions.byIdWithScores({
|
||||
projectId,
|
||||
sessionId,
|
||||
});
|
||||
@@ -105,6 +105,7 @@ describe("traces trpc", () => {
|
||||
environment: "default",
|
||||
bookmarked: false,
|
||||
public: false,
|
||||
scores: [],
|
||||
traces: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: trace.id,
|
||||
|
||||
@@ -383,7 +383,6 @@ describe("trpc.sessions", () => {
|
||||
|
||||
expect(sessions[0]).toBeDefined();
|
||||
expect(sessions[0]?.trace_count).toBe(2);
|
||||
expect(parseInt(sessions[0]?.duration as any)).toBeGreaterThan(995);
|
||||
expect(parseInt(sessions[0]?.duration as any)).toBeLessThan(1005);
|
||||
expect(parseInt(sessions[0]?.duration as any)).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -486,7 +486,7 @@ describe("OTel Resource Span Mapping", () => {
|
||||
expect(traceEvent.body).toMatchObject({
|
||||
id: "95f3b926c7d009925bcb5dbc27311120",
|
||||
timestamp: "2025-05-05T13:42:33.936Z",
|
||||
name: undefined,
|
||||
name: "my-span-with-custom-trace-id",
|
||||
environment: "production",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
type ChatMessage,
|
||||
ChatMessageRole,
|
||||
ChatMessageType,
|
||||
SYSTEM_ROLES,
|
||||
type ChatMessageWithId,
|
||||
type LLMToolCall,
|
||||
type PlaceholderMessage,
|
||||
@@ -223,9 +222,6 @@ export const ChatMessageComponent: React.FC<ChatMessageProps> = ({
|
||||
[message.id, message.type, updateMessage],
|
||||
);
|
||||
|
||||
const showDragHandle = !(
|
||||
"role" in message && SYSTEM_ROLES.includes(message.role)
|
||||
);
|
||||
const showToolCallSelect = message.type === ChatMessageType.ToolResult;
|
||||
const isPlaceholder = message.type === ChatMessageType.Placeholder;
|
||||
|
||||
@@ -242,20 +238,15 @@ export const ChatMessageComponent: React.FC<ChatMessageProps> = ({
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-row justify-center">
|
||||
{showDragHandle && (
|
||||
<div
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
className="flex w-3 cursor-move items-center justify-center opacity-50 transition-opacity hover:opacity-100"
|
||||
>
|
||||
<GripVertical className="h-3 w-3" />
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
className="flex w-3 cursor-move items-center justify-center opacity-50 transition-opacity hover:opacity-100"
|
||||
>
|
||||
<GripVertical className="h-3 w-3" />
|
||||
</div>
|
||||
<CardContent
|
||||
className={cn(
|
||||
"flex flex-1 flex-row items-center gap-2 p-0",
|
||||
showDragHandle ? "pl-1" : "pl-4",
|
||||
)}
|
||||
className={cn("flex flex-1 flex-row items-center gap-2 p-0 pl-1")}
|
||||
>
|
||||
<div className="flex w-[4rem] flex-shrink-0 flex-col gap-1">
|
||||
{isPlaceholder ? (
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { type LLMToolCall } from "@langfuse/shared";
|
||||
import { JSONView } from "@/src/components/ui/CodeJsonViewer";
|
||||
import { PrettyJsonView } from "@/src/components/ui/PrettyJsonView";
|
||||
|
||||
export const ToolCallCard: React.FC<{ toolCall: LLMToolCall }> = ({
|
||||
toolCall,
|
||||
@@ -19,9 +19,10 @@ export const ToolCallCard: React.FC<{ toolCall: LLMToolCall }> = ({
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">
|
||||
Arguments
|
||||
</div>
|
||||
<JSONView
|
||||
json={JSON.stringify(toolCall.args, null, 2)}
|
||||
<PrettyJsonView
|
||||
json={toolCall.args}
|
||||
codeClassName="border-none p-1"
|
||||
currentView="pretty"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex w-[25%] flex-col overflow-hidden">
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
ChatMessageRole,
|
||||
ChatMessageType,
|
||||
type ChatMessageWithId,
|
||||
SYSTEM_ROLES,
|
||||
} from "@langfuse/shared";
|
||||
|
||||
import { ChatMessageComponent } from "./ChatMessageComponent";
|
||||
@@ -67,14 +66,7 @@ export const ChatMessages: React.FC<ChatMessagesProps> = (props) => {
|
||||
if (newIndex < 0 || oldIndex < 0) {
|
||||
return;
|
||||
}
|
||||
// prevent reordering system messages, placeholders can be reordered
|
||||
const targetMessage = messages[newIndex];
|
||||
if (
|
||||
targetMessage.type !== ChatMessageType.Placeholder &&
|
||||
SYSTEM_ROLES.includes(targetMessage.role)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
// allow any message to be reordered
|
||||
const newMessages = arrayMove(messages, oldIndex, newIndex);
|
||||
props.setMessages(newMessages);
|
||||
}
|
||||
@@ -90,7 +82,7 @@ export const ChatMessages: React.FC<ChatMessagesProps> = (props) => {
|
||||
>
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="flex-1 overflow-auto scroll-smooth" ref={scrollAreaRef}>
|
||||
<div className="mb-4 flex-1 space-y-2">
|
||||
<div className="flex-1 space-y-2">
|
||||
<SortableContext
|
||||
items={props.messages.map((message) => message.id)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
@@ -110,11 +102,11 @@ export const ChatMessages: React.FC<ChatMessagesProps> = (props) => {
|
||||
);
|
||||
})}
|
||||
</SortableContext>
|
||||
<div className="mb-4 py-3">
|
||||
<AddMessageButton {...props} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="py-3">
|
||||
<AddMessageButton {...props} />
|
||||
</div>
|
||||
</div>
|
||||
</DndContext>
|
||||
);
|
||||
@@ -171,7 +163,7 @@ const AddMessageButton: React.FC<AddMessageButtonProps> = ({
|
||||
onClick={addRegularMessage}
|
||||
>
|
||||
<PlusCircleIcon size={14} className="mr-2" />
|
||||
<p>Add message</p>
|
||||
<p>Message</p>
|
||||
</Button>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
@@ -183,7 +175,7 @@ const AddMessageButton: React.FC<AddMessageButtonProps> = ({
|
||||
onClick={addPlaceholderMessage}
|
||||
>
|
||||
<PlusCircleIcon size={14} className="mr-2" />
|
||||
<p>Add message placeholder</p>
|
||||
<p>Placeholder</p>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
|
||||
@@ -41,6 +41,7 @@ export type ModelParamsContext = {
|
||||
formDisabled?: boolean;
|
||||
modelParamsDescription?: string;
|
||||
customHeader?: React.ReactNode;
|
||||
layout?: "vertical" | "compact";
|
||||
};
|
||||
|
||||
export const ModelParameters: React.FC<ModelParamsContext> = ({
|
||||
@@ -52,6 +53,7 @@ export const ModelParameters: React.FC<ModelParamsContext> = ({
|
||||
formDisabled = false,
|
||||
modelParamsDescription,
|
||||
customHeader,
|
||||
layout = "vertical",
|
||||
}) => {
|
||||
const projectId = useProjectIdFromURL();
|
||||
const [modelSettingsOpen, setModelSettingsOpen] = useState(false);
|
||||
@@ -89,77 +91,159 @@ export const ModelParameters: React.FC<ModelParamsContext> = ({
|
||||
);
|
||||
}
|
||||
|
||||
// Settings button component for reuse
|
||||
const SettingsButton = (
|
||||
<Popover open={modelSettingsOpen} onOpenChange={setModelSettingsOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="relative h-7 w-7"
|
||||
disabled={formDisabled}
|
||||
>
|
||||
<Settings2 size={14} />
|
||||
{modelSettingsUsed && (
|
||||
<div className="absolute -right-0.5 -top-0.5 h-2 w-2 rounded-full bg-primary" />
|
||||
)}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="p-4"
|
||||
align={layout === "compact" ? "start" : "end"}
|
||||
sideOffset={5}
|
||||
>
|
||||
<div className="mb-3">
|
||||
<h4 className="mb-1 text-sm font-medium">Model Advanced Settings</h4>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Configure advanced parameters for your model.
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<ModelParamsSlider
|
||||
title="Temperature"
|
||||
modelParamsKey="temperature"
|
||||
formDisabled={formDisabled}
|
||||
enabled={modelParams.temperature.enabled}
|
||||
setModelParamEnabled={setModelParamEnabled}
|
||||
value={modelParams.temperature.value}
|
||||
min={0}
|
||||
max={modelParams.maxTemperature.value}
|
||||
step={0.01}
|
||||
tooltip="The sampling temperature. Higher values will make the output more random, while lower values will make it more focused and deterministic."
|
||||
updateModelParam={updateModelParamValue}
|
||||
/>
|
||||
<ModelParamsSlider
|
||||
title="Output token limit"
|
||||
modelParamsKey="max_tokens"
|
||||
formDisabled={formDisabled}
|
||||
enabled={modelParams.max_tokens.enabled}
|
||||
setModelParamEnabled={setModelParamEnabled}
|
||||
value={modelParams.max_tokens.value}
|
||||
min={1}
|
||||
max={16384}
|
||||
step={1}
|
||||
tooltip="The maximum number of tokens that can be generated in the chat completion."
|
||||
updateModelParam={updateModelParamValue}
|
||||
/>
|
||||
<ModelParamsSlider
|
||||
title="Top P"
|
||||
modelParamsKey="top_p"
|
||||
formDisabled={formDisabled}
|
||||
enabled={modelParams.top_p.enabled}
|
||||
setModelParamEnabled={setModelParamEnabled}
|
||||
value={modelParams.top_p.value}
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.01}
|
||||
tooltip="An alternative to sampling with temperature, called nucleus sampling, where the model considers the results of the tokens with top_p probability mass. So 0.1 means only the tokens comprising the top 10% probability mass are considered. We generally recommend altering this or temperature but not both."
|
||||
updateModelParam={updateModelParamValue}
|
||||
/>
|
||||
<LLMApiKeyComponent {...{ projectId, modelParams }} />
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
|
||||
// Compact layout - single horizontal row following standard codebase patterns
|
||||
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}`;
|
||||
|
||||
const handleCombinedSelection = (combinedValue: string) => {
|
||||
// Parse the combined value back into provider and model
|
||||
const colonIndex = combinedValue.indexOf(": ");
|
||||
if (colonIndex !== -1) {
|
||||
const provider = combinedValue.substring(0, colonIndex);
|
||||
const model = combinedValue.substring(colonIndex + 2);
|
||||
updateModelParamValue("provider", provider);
|
||||
updateModelParamValue("model", model);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col space-y-2 pb-1 pr-1 pt-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="min-w-0 flex-1 space-y-1">
|
||||
<Select
|
||||
disabled={formDisabled}
|
||||
onValueChange={handleCombinedSelection}
|
||||
value={currentCombinedValue}
|
||||
>
|
||||
<SelectTrigger className="h-8">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{combinedOptions.map((option) => (
|
||||
<SelectItem value={option} key={option}>
|
||||
{option}
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectSeparator />
|
||||
<CreateLLMApiKeyDialog />
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{modelParamsDescription ? (
|
||||
<FormDescription className="mt-1 text-xs">
|
||||
{modelParamsDescription}
|
||||
</FormDescription>
|
||||
) : undefined}
|
||||
</div>
|
||||
<div className="flex-shrink-0">{SettingsButton}</div>
|
||||
</div>
|
||||
|
||||
{modelParams.model.value?.startsWith("o1-") ? (
|
||||
<p className="mt-1 text-xs text-dark-yellow">
|
||||
For {modelParams.model.value}, the system message and the
|
||||
temperature, max_tokens and top_p setting are not supported while it
|
||||
is in beta.{" "}
|
||||
<a
|
||||
href="https://platform.openai.com/docs/guides/reasoning/beta-limitations"
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
>
|
||||
More info ↗
|
||||
</a>
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Vertical layout (default) - existing behavior
|
||||
return (
|
||||
<div className="flex flex-col space-y-2 pb-1 pr-1 pt-2">
|
||||
<div className="flex items-center justify-between">
|
||||
{customHeader ? customHeader : <p className="font-semibold">Model</p>}
|
||||
<Popover open={modelSettingsOpen} onOpenChange={setModelSettingsOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="relative h-7 w-7"
|
||||
disabled={formDisabled}
|
||||
>
|
||||
<Settings2 size={14} />
|
||||
{modelSettingsUsed && (
|
||||
<div className="absolute -right-0.5 -top-0.5 h-2 w-2 rounded-full bg-primary" />
|
||||
)}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="p-4" align="end" sideOffset={5}>
|
||||
<div className="mb-3">
|
||||
<h4 className="mb-1 text-sm font-medium">
|
||||
Model Advanced Settings
|
||||
</h4>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Configure advanced parameters for your model.
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<ModelParamsSlider
|
||||
title="Temperature"
|
||||
modelParamsKey="temperature"
|
||||
formDisabled={formDisabled}
|
||||
enabled={modelParams.temperature.enabled}
|
||||
setModelParamEnabled={setModelParamEnabled}
|
||||
value={modelParams.temperature.value}
|
||||
min={0}
|
||||
max={modelParams.maxTemperature.value}
|
||||
step={0.01}
|
||||
tooltip="The sampling temperature. Higher values will make the output more random, while lower values will make it more focused and deterministic."
|
||||
updateModelParam={updateModelParamValue}
|
||||
/>
|
||||
<ModelParamsSlider
|
||||
title="Output token limit"
|
||||
modelParamsKey="max_tokens"
|
||||
formDisabled={formDisabled}
|
||||
enabled={modelParams.max_tokens.enabled}
|
||||
setModelParamEnabled={setModelParamEnabled}
|
||||
value={modelParams.max_tokens.value}
|
||||
min={1}
|
||||
max={16384}
|
||||
step={1}
|
||||
tooltip="The maximum number of tokens that can be generated in the chat completion."
|
||||
updateModelParam={updateModelParamValue}
|
||||
/>
|
||||
<ModelParamsSlider
|
||||
title="Top P"
|
||||
modelParamsKey="top_p"
|
||||
formDisabled={formDisabled}
|
||||
enabled={modelParams.top_p.enabled}
|
||||
setModelParamEnabled={setModelParamEnabled}
|
||||
value={modelParams.top_p.value}
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.01}
|
||||
tooltip="An alternative to sampling with temperature, called nucleus sampling, where the model considers the results of the tokens with top_p probability mass. So 0.1 means only the tokens comprising the top 10% probability mass are considered. We generally recommend altering this or temperature but not both."
|
||||
updateModelParam={updateModelParamValue}
|
||||
/>
|
||||
<LLMApiKeyComponent {...{ projectId, modelParams }} />
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
{SettingsButton}
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
@@ -171,6 +255,7 @@ export const ModelParameters: React.FC<ModelParamsContext> = ({
|
||||
value={modelParams.provider.value}
|
||||
options={availableProviders}
|
||||
updateModelParam={updateModelParamValue}
|
||||
layout="vertical"
|
||||
/>
|
||||
<ModelParamsSelect
|
||||
title="Model name"
|
||||
@@ -180,6 +265,7 @@ export const ModelParameters: React.FC<ModelParamsContext> = ({
|
||||
options={[...new Set(availableModels)]}
|
||||
updateModelParam={updateModelParamValue}
|
||||
modelParamsDescription={modelParamsDescription}
|
||||
layout="vertical"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -210,6 +296,7 @@ type ModelParamsSelectProps = {
|
||||
updateModelParam: ModelParamsContext["updateModelParamValue"];
|
||||
disabled?: boolean;
|
||||
modelParamsDescription?: string;
|
||||
layout?: "vertical" | "compact";
|
||||
};
|
||||
const ModelParamsSelect = ({
|
||||
title,
|
||||
@@ -219,7 +306,45 @@ const ModelParamsSelect = ({
|
||||
updateModelParam,
|
||||
disabled,
|
||||
modelParamsDescription,
|
||||
layout = "vertical",
|
||||
}: ModelParamsSelectProps) => {
|
||||
// Compact layout - simplified, space-efficient (no individual labels)
|
||||
if (layout === "compact") {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<Select
|
||||
disabled={disabled}
|
||||
onValueChange={(value) =>
|
||||
updateModelParam(
|
||||
modelParamsKey,
|
||||
value as (typeof supportedModels)[LLMAdapter][number],
|
||||
)
|
||||
}
|
||||
value={value}
|
||||
>
|
||||
<SelectTrigger className="h-8">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{options.map((option) => (
|
||||
<SelectItem value={option} key={option}>
|
||||
{option}
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectSeparator />
|
||||
<CreateLLMApiKeyDialog />
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{modelParamsDescription ? (
|
||||
<FormDescription className="mt-1 text-xs">
|
||||
{modelParamsDescription}
|
||||
</FormDescription>
|
||||
) : undefined}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Vertical layout (default) - existing behavior
|
||||
return (
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-24 flex-shrink-0">
|
||||
|
||||
@@ -58,6 +58,11 @@ interface SearchConfig {
|
||||
tableAllowsFullTextSearch?: boolean;
|
||||
setSearchType: ((newSearchType: TracingSearchType[]) => void) | undefined;
|
||||
searchType: TracingSearchType[] | undefined;
|
||||
customDropdownLabels?: {
|
||||
metadata: string;
|
||||
fullText: string;
|
||||
};
|
||||
hidePerformanceWarning?: boolean;
|
||||
}
|
||||
|
||||
interface TableViewControllers {
|
||||
@@ -185,8 +190,10 @@ export function DataTableToolbar<TData, TValue>({
|
||||
<span className="flex items-center gap-1 truncate">
|
||||
{searchConfig.tableAllowsFullTextSearch &&
|
||||
(searchConfig.searchType ?? []).includes("content")
|
||||
? "Full Text"
|
||||
: "IDs / Names"}
|
||||
? (searchConfig.customDropdownLabels?.fullText ??
|
||||
"Full Text")
|
||||
: (searchConfig.customDropdownLabels?.metadata ??
|
||||
"IDs / Names")}
|
||||
<DocPopup
|
||||
description={
|
||||
searchConfig.tableAllowsFullTextSearch &&
|
||||
@@ -196,8 +203,8 @@ export function DataTableToolbar<TData, TValue>({
|
||||
<p className="text-xs font-normal text-primary">
|
||||
Searches in Input/Output and{" "}
|
||||
{searchConfig.metadataSearchFields.join(", ")}.
|
||||
For improved performance, please filter the table
|
||||
down.
|
||||
{!searchConfig.hidePerformanceWarning &&
|
||||
" For improved performance, please filter the table down."}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-xs font-normal text-primary">
|
||||
@@ -234,13 +241,15 @@ export function DataTableToolbar<TData, TValue>({
|
||||
}}
|
||||
>
|
||||
<DropdownMenuRadioItem value="metadata">
|
||||
IDs / Names
|
||||
{searchConfig.customDropdownLabels?.metadata ??
|
||||
"IDs / Names"}
|
||||
</DropdownMenuRadioItem>
|
||||
<DropdownMenuRadioItem
|
||||
value="metadata_fulltext"
|
||||
disabled={!searchConfig.tableAllowsFullTextSearch}
|
||||
>
|
||||
Full Text
|
||||
{searchConfig.customDropdownLabels?.fullText ??
|
||||
"Full Text"}
|
||||
</DropdownMenuRadioItem>
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuContent>
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { JSONView } from "@/src/components/ui/CodeJsonViewer";
|
||||
import { PrettyJsonView } from "@/src/components/ui/PrettyJsonView";
|
||||
import { z } from "zod/v4";
|
||||
import { type Prisma, deepParseJson } from "@langfuse/shared";
|
||||
import { cn } from "@/src/utils/tailwind";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Button } from "@/src/components/ui/button";
|
||||
import { Fragment } from "react";
|
||||
import { StringOrMarkdownSchema } from "@/src/components/schemas/MarkdownSchema";
|
||||
import {
|
||||
ChatMlArraySchema,
|
||||
type ChatMlMessageSchema,
|
||||
@@ -84,11 +83,8 @@ export const IOPreview: React.FC<{
|
||||
Array.isArray(output) ? output : [output],
|
||||
);
|
||||
|
||||
const inMarkdown = StringOrMarkdownSchema.safeParse(input);
|
||||
const outMarkdown = StringOrMarkdownSchema.safeParse(output);
|
||||
|
||||
const isPrettyViewAvailable =
|
||||
inChatMlArray.success || inMarkdown.success || outMarkdown.success;
|
||||
// Pretty view is available for ChatML content OR any JSON content
|
||||
const isPrettyViewAvailable = true; // Always show the toggle, let individual components decide how to render
|
||||
|
||||
useEffect(() => {
|
||||
props.setIsPrettyViewAvailable?.(isPrettyViewAvailable);
|
||||
@@ -154,24 +150,28 @@ export const IOPreview: React.FC<{
|
||||
: undefined
|
||||
}
|
||||
media={media ?? []}
|
||||
currentView={selectedView}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{!(hideIfNull && !input) && !hideInput ? (
|
||||
<MarkdownJsonView
|
||||
<PrettyJsonView
|
||||
title="Input"
|
||||
className="ph-no-capture"
|
||||
content={input}
|
||||
json={input ?? null}
|
||||
isLoading={isLoading}
|
||||
media={media?.filter((m) => m.field === "input") ?? []}
|
||||
currentView={selectedView}
|
||||
/>
|
||||
) : null}
|
||||
{!(hideIfNull && !output) && !hideOutput ? (
|
||||
<MarkdownJsonView
|
||||
<PrettyJsonView
|
||||
title="Output"
|
||||
className="ph-no-capture"
|
||||
content={output}
|
||||
customCodeHeaderClassName="bg-secondary"
|
||||
json={outputClean}
|
||||
isLoading={isLoading}
|
||||
media={media?.filter((m) => m.field === "output") ?? []}
|
||||
currentView={selectedView}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
@@ -181,21 +181,23 @@ export const IOPreview: React.FC<{
|
||||
{selectedView === "json" || !isPrettyViewAvailable ? (
|
||||
<>
|
||||
{!(hideIfNull && !input) && !hideInput ? (
|
||||
<JSONView
|
||||
<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 ? (
|
||||
<JSONView
|
||||
<PrettyJsonView
|
||||
title="Output"
|
||||
className="ph-no-capture"
|
||||
json={outputClean}
|
||||
isLoading={isLoading}
|
||||
media={media?.filter((m) => m.field === "output") ?? []}
|
||||
currentView={selectedView}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
@@ -212,6 +214,7 @@ export const OpenAiMessageView: React.FC<{
|
||||
media?: MediaReturnType[];
|
||||
additionalInput?: Record<string, unknown>;
|
||||
projectIdForPromptButtons?: string;
|
||||
currentView?: "pretty" | "json";
|
||||
}> = ({
|
||||
title,
|
||||
messages,
|
||||
@@ -220,6 +223,7 @@ export const OpenAiMessageView: React.FC<{
|
||||
collapseLongHistory = true,
|
||||
additionalInput,
|
||||
projectIdForPromptButtons,
|
||||
currentView = "json",
|
||||
}) => {
|
||||
const COLLAPSE_THRESHOLD = 3;
|
||||
const [isCollapsed, setCollapsed] = useState(
|
||||
@@ -272,10 +276,11 @@ export const OpenAiMessageView: React.FC<{
|
||||
customCodeHeaderClassName={cn("bg-primary-foreground")}
|
||||
/>
|
||||
) : (
|
||||
<JSONView
|
||||
<PrettyJsonView
|
||||
title="Placeholder"
|
||||
json={message.name || "Unnamed placeholder"}
|
||||
projectIdForPromptButtons={projectIdForPromptButtons}
|
||||
currentView={currentView}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
@@ -298,7 +303,7 @@ export const OpenAiMessageView: React.FC<{
|
||||
audio={message.audio}
|
||||
/>
|
||||
) : (
|
||||
<JSONView
|
||||
<PrettyJsonView
|
||||
title={message.name ?? message.role}
|
||||
json={message.content}
|
||||
projectIdForPromptButtons={projectIdForPromptButtons}
|
||||
@@ -307,11 +312,12 @@ export const OpenAiMessageView: React.FC<{
|
||||
!isPlaceholderMessage(message) &&
|
||||
"rounded-b-none",
|
||||
)}
|
||||
currentView={currentView}
|
||||
/>
|
||||
))}
|
||||
{shouldRenderJson(message) &&
|
||||
!isPlaceholderMessage(message) && (
|
||||
<JSONView
|
||||
<PrettyJsonView
|
||||
title={
|
||||
message.content
|
||||
? undefined
|
||||
@@ -322,6 +328,7 @@ export const OpenAiMessageView: React.FC<{
|
||||
className={cn(
|
||||
!!message.content && "rounded-t-none border-t-0",
|
||||
)}
|
||||
currentView={shouldRenderMarkdown ? "pretty" : "json"}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
@@ -341,10 +348,11 @@ export const OpenAiMessageView: React.FC<{
|
||||
))}
|
||||
</div>
|
||||
{additionalInput && (
|
||||
<JSONView
|
||||
<PrettyJsonView
|
||||
title="Additional Input"
|
||||
json={additionalInput}
|
||||
projectIdForPromptButtons={projectIdForPromptButtons}
|
||||
currentView={shouldRenderMarkdown ? "pretty" : "json"}
|
||||
/>
|
||||
)}
|
||||
{media && media.length > 0 && (
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { JSONView } from "@/src/components/ui/CodeJsonViewer";
|
||||
import { PrettyJsonView } from "@/src/components/ui/PrettyJsonView";
|
||||
import { AnnotationQueueObjectType, type APIScoreV2 } from "@langfuse/shared";
|
||||
import { Badge } from "@/src/components/ui/badge";
|
||||
import { type ObservationReturnType } from "@/src/server/api/routers/traces";
|
||||
@@ -408,22 +408,24 @@ export const ObservationPreview = ({
|
||||
</div>
|
||||
<div>
|
||||
{preloadedObservation.statusMessage && (
|
||||
<JSONView
|
||||
<PrettyJsonView
|
||||
key={preloadedObservation.id + "-status"}
|
||||
title="Status Message"
|
||||
json={preloadedObservation.statusMessage}
|
||||
currentView={currentView}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
{observationWithInputAndOutput.data?.metadata && (
|
||||
<JSONView
|
||||
<PrettyJsonView
|
||||
key={observationWithInputAndOutput.data.id + "-metadata"}
|
||||
title="Metadata"
|
||||
json={observationWithInputAndOutput.data.metadata}
|
||||
media={observationMedia.data?.filter(
|
||||
(m) => m.field === "metadata",
|
||||
)}
|
||||
currentView={currentView}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { JSONView } from "@/src/components/ui/CodeJsonViewer";
|
||||
import { PrettyJsonView } from "@/src/components/ui/PrettyJsonView";
|
||||
import {
|
||||
type APIScoreV2,
|
||||
type TraceDomain,
|
||||
@@ -279,13 +279,14 @@ export const TracePreview = ({
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<JSONView
|
||||
<PrettyJsonView
|
||||
key={trace.id + "-metadata"}
|
||||
title="Metadata"
|
||||
json={trace.metadata}
|
||||
media={
|
||||
traceMedia.data?.filter((m) => m.field === "metadata") ?? []
|
||||
}
|
||||
currentView={currentView}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import { memo, useMemo, useState } from "react";
|
||||
import { Button } from "@/src/components/ui/button";
|
||||
import { Check, ChevronsDownUp, ChevronsUpDown, Copy } from "lucide-react";
|
||||
import {
|
||||
Check,
|
||||
ChevronsDownUp,
|
||||
ChevronsUpDown,
|
||||
Copy,
|
||||
FoldVertical,
|
||||
UnfoldVertical,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/src/utils/tailwind";
|
||||
import { default as React18JsonView } from "react18-json-view";
|
||||
import "react18-json-view/src/dark.css";
|
||||
@@ -21,6 +28,7 @@ export function JSONView(props: {
|
||||
canEnableMarkdown?: boolean;
|
||||
json?: unknown;
|
||||
title?: string;
|
||||
hideTitle?: boolean;
|
||||
className?: string;
|
||||
isLoading?: boolean;
|
||||
codeClassName?: string;
|
||||
@@ -29,18 +37,23 @@ export function JSONView(props: {
|
||||
scrollable?: boolean;
|
||||
projectIdForPromptButtons?: string;
|
||||
controlButtons?: React.ReactNode;
|
||||
externalJsonCollapsed?: boolean;
|
||||
onToggleCollapse?: () => void;
|
||||
}) {
|
||||
// some users ingest stringified json nested in json, parse it
|
||||
const parsedJson = useMemo(() => deepParseJson(props.json), [props.json]);
|
||||
const { resolvedTheme } = useTheme();
|
||||
const { setIsMarkdownEnabled } = useMarkdownContext();
|
||||
const capture = usePostHogClientCapture();
|
||||
const [internalCollapsed, setInternalCollapsed] = useState(false);
|
||||
|
||||
const collapseStringsAfterLength =
|
||||
props.collapseStringsAfterLength === null
|
||||
? 100_000_000 // if null, show all (100M chars)
|
||||
: (props.collapseStringsAfterLength ?? 500);
|
||||
|
||||
const isCollapsed = props.externalJsonCollapsed ?? internalCollapsed;
|
||||
|
||||
const handleOnCopy = (event?: React.MouseEvent<HTMLButtonElement>) => {
|
||||
if (event) {
|
||||
event.preventDefault();
|
||||
@@ -61,6 +74,14 @@ export function JSONView(props: {
|
||||
});
|
||||
};
|
||||
|
||||
const handleToggleCollapse = () => {
|
||||
if (props.onToggleCollapse) {
|
||||
props.onToggleCollapse();
|
||||
} else {
|
||||
setInternalCollapsed(!internalCollapsed);
|
||||
}
|
||||
};
|
||||
|
||||
const body = (
|
||||
<>
|
||||
<div
|
||||
@@ -90,7 +111,7 @@ export function JSONView(props: {
|
||||
src={parsedJson}
|
||||
theme="github"
|
||||
dark={resolvedTheme === "dark"}
|
||||
collapseObjectsAfterLength={20}
|
||||
collapseObjectsAfterLength={isCollapsed ? 0 : 20}
|
||||
collapseStringsAfterLength={collapseStringsAfterLength}
|
||||
collapseStringMode="word"
|
||||
customizeCollapseStringUI={(fullSTring, truncated) =>
|
||||
@@ -100,7 +121,7 @@ export function JSONView(props: {
|
||||
""
|
||||
)
|
||||
}
|
||||
displaySize={"collapsed"}
|
||||
displaySize={isCollapsed ? "collapsed" : "expanded"}
|
||||
matchesURL={true}
|
||||
customizeCopy={(node) => stringifyJsonNode(node)}
|
||||
className="w-full"
|
||||
@@ -134,13 +155,30 @@ export function JSONView(props: {
|
||||
props.scrollable ? "overflow-hidden" : "",
|
||||
)}
|
||||
>
|
||||
{props.title ? (
|
||||
{props.title && !props.hideTitle ? (
|
||||
<MarkdownJsonViewHeader
|
||||
title={props.title}
|
||||
canEnableMarkdown={props.canEnableMarkdown ?? false}
|
||||
handleOnValueChange={handleOnValueChange}
|
||||
handleOnCopy={handleOnCopy}
|
||||
controlButtons={props.controlButtons}
|
||||
controlButtons={
|
||||
<>
|
||||
{props.controlButtons}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
onClick={handleToggleCollapse}
|
||||
className="-mr-2 hover:bg-border"
|
||||
title={isCollapsed ? "Expand all" : "Collapse all"}
|
||||
>
|
||||
{isCollapsed ? (
|
||||
<UnfoldVertical className="h-3 w-3" />
|
||||
) : (
|
||||
<FoldVertical className="h-3 w-3" />
|
||||
)}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
{props.scrollable ? (
|
||||
@@ -304,7 +342,7 @@ export const IOTableCell = ({
|
||||
</div>
|
||||
) : (
|
||||
<JSONView
|
||||
json={stringifiedJson}
|
||||
json={data}
|
||||
className={cn(
|
||||
"ph-no-capture h-full w-full self-stretch rounded-sm",
|
||||
className,
|
||||
@@ -343,6 +381,7 @@ export const JsonSkeleton = ({
|
||||
);
|
||||
};
|
||||
|
||||
// TODO: deduplicate with PrettyJsonView.tsx
|
||||
function stringifyJsonNode(node: unknown) {
|
||||
// return single string nodes without quotes
|
||||
if (typeof node === "string") {
|
||||
|
||||
@@ -4,14 +4,12 @@ import {
|
||||
} from "@/src/components/schemas/ChatMlSchema";
|
||||
import { StringOrMarkdownSchema } from "@/src/components/schemas/MarkdownSchema";
|
||||
import { Button } from "@/src/components/ui/button";
|
||||
import { JSONView } from "@/src/components/ui/CodeJsonViewer";
|
||||
import { PrettyJsonView } from "@/src/components/ui/PrettyJsonView";
|
||||
import { MarkdownView } from "@/src/components/ui/MarkdownViewer";
|
||||
import { type MediaReturnType } from "@/src/features/media/validation";
|
||||
import { useMarkdownContext } from "@/src/features/theming/useMarkdownContext";
|
||||
import { Check, Copy } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { type z } from "zod/v4";
|
||||
import { cn } from "@/src/utils/tailwind";
|
||||
|
||||
type MarkdownJsonViewHeaderProps = {
|
||||
title: string;
|
||||
@@ -23,34 +21,18 @@ type MarkdownJsonViewHeaderProps = {
|
||||
|
||||
export function MarkdownJsonViewHeader({
|
||||
title,
|
||||
handleOnValueChange,
|
||||
handleOnValueChange: _handleOnValueChange,
|
||||
handleOnCopy,
|
||||
canEnableMarkdown = true,
|
||||
canEnableMarkdown: _canEnableMarkdown = true,
|
||||
controlButtons,
|
||||
}: MarkdownJsonViewHeaderProps) {
|
||||
const [isCopied, setIsCopied] = useState(false);
|
||||
const { isMarkdownEnabled } = useMarkdownContext();
|
||||
|
||||
return (
|
||||
<div className="flex flex-row items-center justify-between px-1 py-1 text-sm font-medium capitalize">
|
||||
{title}
|
||||
<div className="mr-1 flex min-w-0 flex-shrink flex-row items-center gap-1">
|
||||
{controlButtons}
|
||||
{canEnableMarkdown && (
|
||||
<Button
|
||||
title={isMarkdownEnabled ? "Disable Markdown" : "Enable Markdown"}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
type="button"
|
||||
onClick={handleOnValueChange}
|
||||
className={cn(
|
||||
"hover:bg-border",
|
||||
!isMarkdownEnabled ? "opacity-50" : "opacity-100",
|
||||
)}
|
||||
>
|
||||
{isMarkdownEnabled ? "View as JSON" : "View as markdown"}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
title="Copy to clipboard"
|
||||
variant="ghost"
|
||||
@@ -105,7 +87,6 @@ export function MarkdownJsonView({
|
||||
[content],
|
||||
);
|
||||
|
||||
const { isMarkdownEnabled } = useMarkdownContext();
|
||||
const canEnableMarkdown = isSupportedMarkdownFormat(
|
||||
content,
|
||||
validatedOpenAIContent,
|
||||
@@ -113,7 +94,7 @@ export function MarkdownJsonView({
|
||||
|
||||
return (
|
||||
<>
|
||||
{isMarkdownEnabled && canEnableMarkdown ? (
|
||||
{canEnableMarkdown ? (
|
||||
<MarkdownView
|
||||
markdown={stringOrValidatedMarkdown.data ?? content}
|
||||
title={title}
|
||||
@@ -122,12 +103,12 @@ export function MarkdownJsonView({
|
||||
media={media}
|
||||
/>
|
||||
) : (
|
||||
<JSONView
|
||||
<PrettyJsonView
|
||||
json={content ?? (audio ? { audio } : null)}
|
||||
canEnableMarkdown={canEnableMarkdown}
|
||||
title={title}
|
||||
className={className}
|
||||
media={media}
|
||||
currentView="pretty"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -0,0 +1,799 @@
|
||||
import { useMemo, useState, useEffect, memo, useRef, useCallback } from "react";
|
||||
import { cn } from "@/src/utils/tailwind";
|
||||
import { deepParseJson } from "@langfuse/shared";
|
||||
import { Skeleton } from "@/src/components/ui/skeleton";
|
||||
import { type MediaReturnType } from "@/src/features/media/validation";
|
||||
import { LangfuseMediaView } from "@/src/components/ui/LangfuseMediaView";
|
||||
import { MarkdownJsonViewHeader } from "@/src/components/ui/MarkdownJsonView";
|
||||
import { copyTextToClipboard } from "@/src/utils/clipboard";
|
||||
import { JSONView } from "@/src/components/ui/CodeJsonViewer";
|
||||
import { Button } from "@/src/components/ui/button";
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
UnfoldVertical,
|
||||
FoldVertical,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
useReactTable,
|
||||
getCoreRowModel,
|
||||
getExpandedRowModel,
|
||||
flexRender,
|
||||
type ExpandedState,
|
||||
type Row,
|
||||
} from "@tanstack/react-table";
|
||||
import { type LangfuseColumnDef } from "@/src/components/table/types";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/src/components/ui/table";
|
||||
import { ChatMlArraySchema } from "@/src/components/schemas/ChatMlSchema";
|
||||
import { MarkdownView } from "@/src/components/ui/MarkdownViewer";
|
||||
import {
|
||||
StringOrMarkdownSchema,
|
||||
containsAnyMarkdown,
|
||||
} from "@/src/components/schemas/MarkdownSchema";
|
||||
|
||||
// Constants for array display logic
|
||||
const SMALL_ARRAY_THRESHOLD = 5;
|
||||
const ARRAY_PREVIEW_ITEMS = 3;
|
||||
const OBJECT_PREVIEW_KEYS = 2;
|
||||
|
||||
// Constants for table layout
|
||||
const INDENTATION_PER_LEVEL = 16;
|
||||
const INDENTATION_BASE = 8;
|
||||
const BUTTON_WIDTH = 16;
|
||||
const MARGIN_LEFT_1 = 4;
|
||||
const CELL_PADDING_X = 8; // px-2
|
||||
|
||||
const ASSISTANT_TITLES = ["assistant", "Output"];
|
||||
const SYSTEM_TITLES = ["system", "Input"];
|
||||
|
||||
const MONO_TEXT_CLASSES = "font-mono text-xs break-words";
|
||||
const PREVIEW_TEXT_CLASSES = "italic text-gray-500 dark:text-gray-400";
|
||||
|
||||
function getEmptyValueDisplay(value: unknown): string | null {
|
||||
if (value === null) return "null";
|
||||
if (value === undefined) return "undefined";
|
||||
if (value === "") return "empty string";
|
||||
if (
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
!Array.isArray(value) &&
|
||||
Object.keys(value).length === 0
|
||||
) {
|
||||
return "empty object";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function getContainerClasses(
|
||||
title: string | undefined,
|
||||
scrollable: boolean | undefined,
|
||||
codeClassName: string | undefined,
|
||||
baseClasses = "whitespace-pre-wrap break-words p-3 text-xs",
|
||||
) {
|
||||
return cn(
|
||||
baseClasses,
|
||||
ASSISTANT_TITLES.includes(title || "")
|
||||
? "bg-accent-light-green dark:border-accent-dark-green"
|
||||
: "",
|
||||
SYSTEM_TITLES.includes(title || "") ? "bg-primary-foreground" : "",
|
||||
scrollable ? "" : "rounded-sm border",
|
||||
codeClassName,
|
||||
);
|
||||
}
|
||||
|
||||
function isChatMLFormat(json: unknown): boolean {
|
||||
if (!json || typeof json !== "object") return false;
|
||||
|
||||
if (Array.isArray(json)) {
|
||||
const directArray = ChatMlArraySchema.safeParse(json);
|
||||
if (directArray.success) return true;
|
||||
}
|
||||
|
||||
if ("messages" in json && Array.isArray((json as any).messages)) {
|
||||
const messagesArray = ChatMlArraySchema.safeParse((json as any).messages);
|
||||
if (messagesArray.success) return true;
|
||||
}
|
||||
|
||||
if (Array.isArray(json) && json.length === 1 && Array.isArray(json[0])) {
|
||||
const nestedArray = ChatMlArraySchema.safeParse(json[0]);
|
||||
if (nestedArray.success) return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function isMarkdownContent(json: unknown): {
|
||||
isMarkdown: boolean;
|
||||
content?: string;
|
||||
} {
|
||||
if (typeof json === "string") {
|
||||
const markdownResult = StringOrMarkdownSchema.safeParse(json);
|
||||
if (markdownResult.success) {
|
||||
return { isMarkdown: true, content: json };
|
||||
}
|
||||
}
|
||||
|
||||
// Check if render as markdown: object has one key and the value is a markdown like string
|
||||
if (
|
||||
typeof json === "object" &&
|
||||
json !== null &&
|
||||
!Array.isArray(json) &&
|
||||
json.constructor === Object
|
||||
) {
|
||||
const entries = Object.entries(json);
|
||||
if (entries.length === 1) {
|
||||
const [, value] = entries[0];
|
||||
if (typeof value === "string") {
|
||||
if (containsAnyMarkdown(value)) {
|
||||
return { isMarkdown: true, content: value };
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { isMarkdown: false };
|
||||
}
|
||||
|
||||
interface JsonTableRow {
|
||||
id: string;
|
||||
key: string;
|
||||
value: unknown;
|
||||
type:
|
||||
| "string"
|
||||
| "number"
|
||||
| "boolean"
|
||||
| "object"
|
||||
| "array"
|
||||
| "null"
|
||||
| "undefined";
|
||||
hasChildren: boolean;
|
||||
level: number;
|
||||
subRows?: JsonTableRow[];
|
||||
}
|
||||
|
||||
function getValueType(value: unknown): JsonTableRow["type"] {
|
||||
if (value === null) return "null";
|
||||
if (value === undefined) return "undefined";
|
||||
if (Array.isArray(value)) return "array";
|
||||
return typeof value as JsonTableRow["type"];
|
||||
}
|
||||
|
||||
function hasChildren(value: unknown, valueType: JsonTableRow["type"]): boolean {
|
||||
return (
|
||||
(valueType === "object" &&
|
||||
Object.keys(value as Record<string, unknown>).length > 0) ||
|
||||
(valueType === "array" && Array.isArray(value) && value.length > 0)
|
||||
);
|
||||
}
|
||||
|
||||
function transformJsonToTableData(
|
||||
json: unknown,
|
||||
parentKey = "",
|
||||
level = 0,
|
||||
parentId = "",
|
||||
): JsonTableRow[] {
|
||||
const rows: JsonTableRow[] = [];
|
||||
|
||||
if (typeof json !== "object" || json === null) {
|
||||
return [
|
||||
{
|
||||
id: parentId || "0",
|
||||
key: parentKey || "root",
|
||||
value: json,
|
||||
type: getValueType(json),
|
||||
hasChildren: false,
|
||||
level,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const entries = Array.isArray(json)
|
||||
? json.map((item, index) => [index.toString(), item])
|
||||
: Object.entries(json);
|
||||
|
||||
entries.forEach(([key, value]) => {
|
||||
const id = parentId ? `${parentId}-${key}` : key;
|
||||
const valueType = getValueType(value);
|
||||
const childrenExist = hasChildren(value, valueType);
|
||||
|
||||
const row: JsonTableRow = {
|
||||
id,
|
||||
key,
|
||||
value,
|
||||
type: valueType,
|
||||
hasChildren: childrenExist,
|
||||
level,
|
||||
};
|
||||
|
||||
if (childrenExist) {
|
||||
const children = transformJsonToTableData(value, key, level + 1, id);
|
||||
row.subRows = children;
|
||||
}
|
||||
|
||||
rows.push(row);
|
||||
});
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
function renderArrayValue(arr: unknown[]): JSX.Element {
|
||||
if (arr.length === 0) {
|
||||
return <span className={PREVIEW_TEXT_CLASSES}>empty list</span>;
|
||||
}
|
||||
|
||||
if (arr.length <= SMALL_ARRAY_THRESHOLD) {
|
||||
// Show inline values for small arrays
|
||||
const displayItems = arr
|
||||
.map((item) => {
|
||||
const itemType = getValueType(item);
|
||||
if (itemType === "string") return `"${String(item)}"`;
|
||||
if (itemType === "object" && item !== null) {
|
||||
const obj = item as Record<string, unknown>;
|
||||
const keys = Object.keys(obj);
|
||||
if (keys.length === 0) return "{}";
|
||||
if (keys.length <= OBJECT_PREVIEW_KEYS) {
|
||||
const keyPreview = keys.map((k) => `"${k}": ...`).join(", ");
|
||||
return `{${keyPreview}}`;
|
||||
} else {
|
||||
return `{"${keys[0]}": ...}`;
|
||||
}
|
||||
}
|
||||
if (itemType === "array") return "...";
|
||||
return String(item);
|
||||
})
|
||||
.join(", ");
|
||||
return <span className={PREVIEW_TEXT_CLASSES}>[{displayItems}]</span>;
|
||||
} else {
|
||||
// Show truncated values for large arrays
|
||||
const preview = arr
|
||||
.slice(0, ARRAY_PREVIEW_ITEMS)
|
||||
.map((item) => {
|
||||
const itemType = getValueType(item);
|
||||
if (itemType === "string") return `"${String(item)}"`;
|
||||
if (itemType === "object" || itemType === "array") return "...";
|
||||
return String(item);
|
||||
})
|
||||
.join(", ");
|
||||
return (
|
||||
<span className={PREVIEW_TEXT_CLASSES}>
|
||||
[{preview}, ...{arr.length - ARRAY_PREVIEW_ITEMS} more]
|
||||
</span>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function renderObjectValue(obj: Record<string, unknown>): JSX.Element {
|
||||
const keys = Object.keys(obj);
|
||||
if (keys.length === 0) {
|
||||
return <span className={PREVIEW_TEXT_CLASSES}>empty object</span>;
|
||||
}
|
||||
return <span className={PREVIEW_TEXT_CLASSES}>{keys.length} items</span>;
|
||||
}
|
||||
|
||||
const ValueCell = memo(({ row }: { row: Row<JsonTableRow> }) => {
|
||||
const { value, type } = row.original;
|
||||
|
||||
const renderValue = () => {
|
||||
switch (type) {
|
||||
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">
|
||||
{String(value)}
|
||||
</span>
|
||||
);
|
||||
case "boolean":
|
||||
return (
|
||||
<span className="text-orange-600 dark:text-orange-400">
|
||||
{String(value)}
|
||||
</span>
|
||||
);
|
||||
case "null":
|
||||
return (
|
||||
<span className="italic text-gray-500 dark:text-gray-400">null</span>
|
||||
);
|
||||
case "undefined":
|
||||
return (
|
||||
<span className="text-gray-500 dark:text-gray-400">undefined</span>
|
||||
);
|
||||
case "array":
|
||||
return renderArrayValue(value as unknown[]);
|
||||
case "object":
|
||||
return renderObjectValue(value as Record<string, unknown>);
|
||||
default:
|
||||
return (
|
||||
<span className="text-gray-600 dark:text-gray-400">
|
||||
{String(value)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`${MONO_TEXT_CLASSES} max-w-full`}>{renderValue()}</div>
|
||||
);
|
||||
});
|
||||
|
||||
ValueCell.displayName = "ValueCell";
|
||||
|
||||
function JsonPrettyTable({
|
||||
data,
|
||||
expandAllRef,
|
||||
onExpandStateChange,
|
||||
noBorder = false,
|
||||
}: {
|
||||
data: JsonTableRow[];
|
||||
expandAllRef?: React.MutableRefObject<(() => void) | null>;
|
||||
onExpandStateChange?: (allExpanded: boolean) => void;
|
||||
noBorder?: boolean;
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState<ExpandedState>({});
|
||||
|
||||
const columns: LangfuseColumnDef<JsonTableRow, unknown>[] = [
|
||||
{
|
||||
accessorKey: "key",
|
||||
header: "Path",
|
||||
size: 35,
|
||||
cell: ({ row }) => {
|
||||
// we need to calculate the indentation here for a good line break
|
||||
// because of the padding, we don't know when to break the line otherwise
|
||||
const indentationWidth =
|
||||
row.original.level * INDENTATION_PER_LEVEL + INDENTATION_BASE;
|
||||
const buttonWidth = row.original.hasChildren ? BUTTON_WIDTH : 0;
|
||||
const availableTextWidth = `calc(100% - ${indentationWidth + buttonWidth + CELL_PADDING_X + MARGIN_LEFT_1}px)`;
|
||||
|
||||
return (
|
||||
<div className="flex items-start break-words">
|
||||
<div
|
||||
className="flex flex-shrink-0 items-center justify-end"
|
||||
style={{ width: `${indentationWidth}px` }}
|
||||
>
|
||||
{row.original.hasChildren && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
row.toggleExpanded();
|
||||
}}
|
||||
className="h-4 w-4 p-0"
|
||||
>
|
||||
{row.getIsExpanded() ? (
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
) : (
|
||||
<ChevronRight className="h-3 w-3" />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<span
|
||||
className={`ml-1 ${MONO_TEXT_CLASSES} font-medium`}
|
||||
style={{ maxWidth: availableTextWidth }}
|
||||
>
|
||||
{row.original.key}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "value",
|
||||
header: "Value",
|
||||
size: 65,
|
||||
cell: ({ row }) => <ValueCell row={row} />,
|
||||
},
|
||||
];
|
||||
|
||||
const table = useReactTable({
|
||||
data,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getExpandedRowModel: getExpandedRowModel(),
|
||||
getSubRows: (row) => row.subRows,
|
||||
state: {
|
||||
expanded,
|
||||
},
|
||||
onExpandedChange: setExpanded,
|
||||
enableColumnResizing: false,
|
||||
});
|
||||
|
||||
const allRowsExpanded = useMemo(() => {
|
||||
const allRows = table.getRowModel().flatRows;
|
||||
const expandableRows = allRows.filter((row) => row.original.hasChildren);
|
||||
return (
|
||||
expandableRows.length > 0 &&
|
||||
expandableRows.every((row) => row.getIsExpanded())
|
||||
);
|
||||
// expanded is required for the collapse button to work
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [table, expanded]);
|
||||
|
||||
// Notify parent of expand state changes
|
||||
useEffect(() => {
|
||||
onExpandStateChange?.(allRowsExpanded);
|
||||
}, [allRowsExpanded, onExpandStateChange]);
|
||||
|
||||
const handleToggleExpandAll = useCallback(() => {
|
||||
if (allRowsExpanded) {
|
||||
table.toggleAllRowsExpanded(false);
|
||||
} else {
|
||||
table.toggleAllRowsExpanded(true);
|
||||
}
|
||||
}, [allRowsExpanded, table]);
|
||||
|
||||
useEffect(() => {
|
||||
if (expandAllRef) {
|
||||
expandAllRef.current = handleToggleExpandAll;
|
||||
}
|
||||
}, [expandAllRef, handleToggleExpandAll]);
|
||||
|
||||
useEffect(() => {
|
||||
setExpanded({});
|
||||
onExpandStateChange?.(false);
|
||||
}, [data, onExpandStateChange]);
|
||||
|
||||
return (
|
||||
<div className={cn("w-full", !noBorder && "rounded-sm border")}>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<TableHead
|
||||
key={header.id}
|
||||
className="h-8 bg-transparent px-2 py-1"
|
||||
style={{ width: `${header.column.columnDef.size}%` }}
|
||||
>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext(),
|
||||
)}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{table.getRowModel().rows.map((row) => (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
onClick={() => {
|
||||
if (row.original.hasChildren) {
|
||||
row.toggleExpanded();
|
||||
}
|
||||
}}
|
||||
className={row.original.hasChildren ? "cursor-pointer" : ""}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell
|
||||
key={cell.id}
|
||||
className="whitespace-normal px-2 py-1"
|
||||
style={{ width: `${cell.column.columnDef.size}%` }}
|
||||
>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PrettyJsonView(props: {
|
||||
json?: unknown;
|
||||
title?: string;
|
||||
className?: string;
|
||||
isLoading?: boolean;
|
||||
codeClassName?: string;
|
||||
collapseStringsAfterLength?: number | null;
|
||||
media?: MediaReturnType[];
|
||||
scrollable?: boolean;
|
||||
projectIdForPromptButtons?: string;
|
||||
controlButtons?: React.ReactNode;
|
||||
currentView?: "pretty" | "json";
|
||||
}) {
|
||||
const parsedJson = useMemo(() => deepParseJson(props.json), [props.json]);
|
||||
const actualCurrentView = props.currentView ?? "pretty";
|
||||
const expandAllRef = useRef<(() => void) | null>(null);
|
||||
const [allRowsExpanded, setAllRowsExpanded] = useState(false);
|
||||
const [jsonIsCollapsed, setJsonIsCollapsed] = useState(false);
|
||||
|
||||
const isChatML = useMemo(() => isChatMLFormat(parsedJson), [parsedJson]);
|
||||
const markdownCheck = useMemo(
|
||||
() => isMarkdownContent(parsedJson),
|
||||
[parsedJson],
|
||||
);
|
||||
|
||||
const tableData = useMemo(() => {
|
||||
try {
|
||||
if (
|
||||
actualCurrentView === "pretty" &&
|
||||
parsedJson !== null &&
|
||||
parsedJson !== undefined &&
|
||||
!isChatML &&
|
||||
!markdownCheck.isMarkdown
|
||||
) {
|
||||
// Helper function to create rows from object entries at level 0
|
||||
const createTopLevelRows = (
|
||||
obj: Record<string, unknown>,
|
||||
): JsonTableRow[] => {
|
||||
const entries = Object.entries(obj);
|
||||
const rows: JsonTableRow[] = [];
|
||||
|
||||
entries.forEach(([key, value]) => {
|
||||
const valueType = getValueType(value);
|
||||
const childrenExist = hasChildren(value, valueType);
|
||||
|
||||
const row: JsonTableRow = {
|
||||
id: key,
|
||||
key,
|
||||
value,
|
||||
type: valueType,
|
||||
hasChildren: childrenExist,
|
||||
level: 0,
|
||||
};
|
||||
|
||||
if (childrenExist) {
|
||||
const children = transformJsonToTableData(value, key, 1, key);
|
||||
row.subRows = children;
|
||||
}
|
||||
|
||||
rows.push(row);
|
||||
});
|
||||
|
||||
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
|
||||
) {
|
||||
return createTopLevelRows(parsedJson as Record<string, unknown>);
|
||||
}
|
||||
|
||||
return transformJsonToTableData(parsedJson);
|
||||
}
|
||||
return [];
|
||||
} catch (error) {
|
||||
console.error("Error transforming JSON to table data:", error);
|
||||
return [];
|
||||
}
|
||||
}, [parsedJson, actualCurrentView, isChatML, markdownCheck.isMarkdown]);
|
||||
|
||||
const handleOnCopy = (event?: React.MouseEvent<HTMLButtonElement>) => {
|
||||
if (event) {
|
||||
event.preventDefault();
|
||||
}
|
||||
const textToCopy = stringifyJsonNode(parsedJson);
|
||||
void copyTextToClipboard(textToCopy);
|
||||
|
||||
if (event) {
|
||||
event.currentTarget.focus();
|
||||
}
|
||||
};
|
||||
|
||||
const handleJsonToggleCollapse = () => {
|
||||
setJsonIsCollapsed(!jsonIsCollapsed);
|
||||
};
|
||||
|
||||
const emptyValueDisplay = getEmptyValueDisplay(parsedJson);
|
||||
const isMarkdownMode =
|
||||
markdownCheck.isMarkdown && actualCurrentView === "pretty";
|
||||
const shouldUseTableView =
|
||||
actualCurrentView === "pretty" &&
|
||||
!isChatML &&
|
||||
!markdownCheck.isMarkdown &&
|
||||
!emptyValueDisplay;
|
||||
|
||||
const getBackgroundColorClass = () =>
|
||||
cn(
|
||||
ASSISTANT_TITLES.includes(props.title || "")
|
||||
? "bg-accent-light-green"
|
||||
: "",
|
||||
SYSTEM_TITLES.includes(props.title || "") ? "bg-primary-foreground" : "",
|
||||
);
|
||||
|
||||
const body = (
|
||||
<>
|
||||
{emptyValueDisplay && actualCurrentView === "pretty" ? (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center",
|
||||
getContainerClasses(
|
||||
props.title,
|
||||
props.scrollable,
|
||||
props.codeClassName,
|
||||
),
|
||||
)}
|
||||
>
|
||||
{props.isLoading ? (
|
||||
<Skeleton className="h-3 w-3/4" />
|
||||
) : (
|
||||
<span className={`font-mono ${PREVIEW_TEXT_CLASSES}`}>
|
||||
{emptyValueDisplay}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
) : isMarkdownMode ? (
|
||||
props.isLoading ? (
|
||||
<Skeleton className="h-3 w-3/4" />
|
||||
) : (
|
||||
<MarkdownView markdown={markdownCheck.content || ""} />
|
||||
)
|
||||
) : 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}
|
||||
/>
|
||||
)}
|
||||
{props.media && props.media.length > 0 && (
|
||||
<>
|
||||
<div className="mx-3 border-t px-2 py-1 text-xs text-muted-foreground">
|
||||
Media
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 p-4 pt-1">
|
||||
{props.media.map((m) => (
|
||||
<LangfuseMediaView
|
||||
mediaAPIReturnValue={m}
|
||||
asFileIcon={true}
|
||||
key={m.mediaId}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex max-h-full min-h-0 flex-col",
|
||||
props.className,
|
||||
props.scrollable ? "overflow-hidden" : "",
|
||||
)}
|
||||
>
|
||||
{props.title ? (
|
||||
<MarkdownJsonViewHeader
|
||||
title={props.title}
|
||||
canEnableMarkdown={false}
|
||||
handleOnValueChange={() => {}} // No-op, parent handles state
|
||||
handleOnCopy={handleOnCopy}
|
||||
controlButtons={
|
||||
<>
|
||||
{props.controlButtons}
|
||||
{shouldUseTableView && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
onClick={() => expandAllRef.current?.()}
|
||||
className="-mr-2 hover:bg-border"
|
||||
title={
|
||||
allRowsExpanded ? "Collapse all rows" : "Expand all rows"
|
||||
}
|
||||
>
|
||||
{allRowsExpanded ? (
|
||||
<FoldVertical className="h-3 w-3" />
|
||||
) : (
|
||||
<UnfoldVertical className="h-3 w-3" />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
{actualCurrentView === "json" && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
onClick={handleJsonToggleCollapse}
|
||||
className="-mr-2 hover:bg-border"
|
||||
title={jsonIsCollapsed ? "Expand all" : "Collapse all"}
|
||||
>
|
||||
{jsonIsCollapsed ? (
|
||||
<UnfoldVertical className="h-3 w-3" />
|
||||
) : (
|
||||
<FoldVertical className="h-3 w-3" />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
{props.scrollable ? (
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-full min-h-0 overflow-hidden",
|
||||
isMarkdownMode ? getBackgroundColorClass() : "rounded-sm border",
|
||||
)}
|
||||
>
|
||||
<div className="max-h-full min-h-0 w-full overflow-y-auto">
|
||||
{body}
|
||||
</div>
|
||||
</div>
|
||||
) : isMarkdownMode ? (
|
||||
<div className={getBackgroundColorClass()}>{body}</div>
|
||||
) : (
|
||||
body
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// TODO: deduplicate with CodeJsonViewer.tsx
|
||||
function stringifyJsonNode(node: unknown) {
|
||||
// return single string nodes without quotes
|
||||
if (typeof node === "string") {
|
||||
return node;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.stringify(
|
||||
node,
|
||||
(key, value) => {
|
||||
switch (typeof value) {
|
||||
case "bigint":
|
||||
return String(value) + "n";
|
||||
case "number":
|
||||
case "boolean":
|
||||
case "object":
|
||||
case "string":
|
||||
return value as string;
|
||||
default:
|
||||
return String(value);
|
||||
}
|
||||
},
|
||||
4,
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("JSON stringify error", error);
|
||||
return "Error: JSON.stringify failed";
|
||||
}
|
||||
}
|
||||
@@ -1 +1 @@
|
||||
export const VERSION = "v3.84.0";
|
||||
export const VERSION = "v3.85.2";
|
||||
|
||||
@@ -90,7 +90,7 @@ export const DatasetActionButton = (props: DatasetActionButtonProps) => {
|
||||
) : (
|
||||
<LockIcon className="mr-2 h-4 w-4" aria-hidden="true" />
|
||||
)}
|
||||
Rename
|
||||
Edit
|
||||
</Button>
|
||||
)
|
||||
) : props.mode === "delete" ? (
|
||||
|
||||
@@ -63,11 +63,13 @@ export function DatasetRunItemsTable(
|
||||
pageIndex: withDefault(NumberParam, 0),
|
||||
pageSize: withDefault(NumberParam, 20),
|
||||
});
|
||||
|
||||
const runItems = api.datasets.runitemsByRunIdOrItemId.useQuery({
|
||||
...props,
|
||||
page: paginationState.pageIndex,
|
||||
limit: paginationState.pageSize,
|
||||
});
|
||||
|
||||
const [rowHeight, setRowHeight] = useRowHeightLocalStorage("traces", "m");
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -23,9 +23,9 @@ import {
|
||||
getRunItemsByRunIdOrItemId,
|
||||
} from "@/src/features/datasets/server/service";
|
||||
import {
|
||||
getDatasetRunItemsTableCount,
|
||||
logger,
|
||||
getRunScoresGroupedByNameSourceType,
|
||||
getDatasetRunItemsTableCountPg,
|
||||
} from "@langfuse/shared/src/server";
|
||||
import { createId as createCuid } from "@paralleldrive/cuid2";
|
||||
import { composeAggregateScoreKey } from "@/src/features/scores/lib/aggregateScores";
|
||||
@@ -203,7 +203,7 @@ export const datasetRouter = createTRPCRouter({
|
||||
}),
|
||||
)
|
||||
.query(async ({ input }) => {
|
||||
const count = await getDatasetRunItemsTableCount({
|
||||
const count = await getDatasetRunItemsTableCountPg({
|
||||
projectId: input.projectId,
|
||||
filter: input.filter ?? [],
|
||||
});
|
||||
@@ -325,7 +325,7 @@ export const datasetRouter = createTRPCRouter({
|
||||
}),
|
||||
)
|
||||
.query(async ({ input, ctx }) => {
|
||||
return fetchDatasetItems({
|
||||
return await fetchDatasetItems({
|
||||
projectId: input.projectId,
|
||||
datasetId: input.datasetId,
|
||||
limit: input.limit,
|
||||
@@ -527,6 +527,7 @@ export const datasetRouter = createTRPCRouter({
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await auditLog({
|
||||
session: ctx.session,
|
||||
resourceType: "dataset",
|
||||
@@ -536,6 +537,7 @@ export const datasetRouter = createTRPCRouter({
|
||||
});
|
||||
return deletedDataset;
|
||||
}),
|
||||
|
||||
deleteDatasetItem: protectedProjectProcedure
|
||||
.input(
|
||||
z.object({
|
||||
@@ -811,7 +813,6 @@ export const datasetRouter = createTRPCRouter({
|
||||
|
||||
return;
|
||||
}),
|
||||
|
||||
runitemsByRunIdOrItemId: protectedProjectProcedure
|
||||
.input(
|
||||
z
|
||||
@@ -850,34 +851,33 @@ export const datasetRouter = createTRPCRouter({
|
||||
datasetRunName: string;
|
||||
}>
|
||||
>`
|
||||
SELECT
|
||||
di.id AS "datasetItemId",
|
||||
di.created_at AS "datasetItemCreatedAt",
|
||||
dri.id,
|
||||
dri.trace_id AS "traceId",
|
||||
dri.observation_id AS "observationId",
|
||||
dri.created_at AS "createdAt",
|
||||
dri.updated_at AS "updatedAt",
|
||||
dri.project_id AS "projectId",
|
||||
dri.dataset_run_id AS "datasetRunId",
|
||||
dr.name AS "datasetRunName"
|
||||
FROM dataset_run_items dri
|
||||
INNER JOIN dataset_items di
|
||||
ON dri.dataset_item_id = di.id
|
||||
AND dri.project_id = di.project_id
|
||||
INNER JOIN dataset_runs dr
|
||||
ON dri.dataset_run_id = dr.id
|
||||
AND dri.project_id = dr.project_id
|
||||
WHERE
|
||||
dri.project_id = ${input.projectId}
|
||||
${filterQuery}
|
||||
ORDER BY
|
||||
di.created_at DESC,
|
||||
di.id DESC
|
||||
LIMIT ${input.limit}
|
||||
OFFSET ${input.page * input.limit}
|
||||
`;
|
||||
|
||||
SELECT
|
||||
di.id AS "datasetItemId",
|
||||
di.created_at AS "datasetItemCreatedAt",
|
||||
dri.id,
|
||||
dri.trace_id AS "traceId",
|
||||
dri.observation_id AS "observationId",
|
||||
dri.created_at AS "createdAt",
|
||||
dri.updated_at AS "updatedAt",
|
||||
dri.project_id AS "projectId",
|
||||
dri.dataset_run_id AS "datasetRunId",
|
||||
dr.name AS "datasetRunName"
|
||||
FROM dataset_run_items dri
|
||||
INNER JOIN dataset_items di
|
||||
ON dri.dataset_item_id = di.id
|
||||
AND dri.project_id = di.project_id
|
||||
INNER JOIN dataset_runs dr
|
||||
ON dri.dataset_run_id = dr.id
|
||||
AND dri.project_id = dr.project_id
|
||||
WHERE
|
||||
dri.project_id = ${input.projectId}
|
||||
${filterQuery}
|
||||
ORDER BY
|
||||
di.created_at DESC,
|
||||
di.id DESC
|
||||
LIMIT ${input.limit}
|
||||
OFFSET ${input.page * input.limit}
|
||||
`;
|
||||
if (runItems.length === 0) return { totalRunItems: 0, runItems: [] };
|
||||
|
||||
const totalRunItems = await ctx.prisma.datasetRunItems.count({
|
||||
@@ -902,7 +902,6 @@ export const datasetRouter = createTRPCRouter({
|
||||
...ri,
|
||||
datasetRunName: runItemNameMap[ri.id],
|
||||
}));
|
||||
|
||||
// Note: We early return in case of no run items, when adding parameters here, make sure to update the early return above
|
||||
return {
|
||||
totalRunItems,
|
||||
|
||||
@@ -146,7 +146,7 @@ export const createDatasetRunsTable = async (input: DatasetRunsTableInput) => {
|
||||
}
|
||||
};
|
||||
|
||||
export const insertPostgresDatasetRunsIntoClickhouse = async (
|
||||
const insertPostgresDatasetRunsIntoClickhouse = async (
|
||||
runs: PostgresDatasetRun[],
|
||||
tableName: string,
|
||||
projectId: string,
|
||||
@@ -175,7 +175,7 @@ export const insertPostgresDatasetRunsIntoClickhouse = async (
|
||||
});
|
||||
};
|
||||
|
||||
export const createTempTableInClickhouse = async (tableName: string) => {
|
||||
const createTempTableInClickhouse = async (tableName: string) => {
|
||||
const query = `
|
||||
CREATE TABLE IF NOT EXISTS ${tableName} ${env.CLICKHOUSE_CLUSTER_ENABLED === "true" ? "ON CLUSTER " + env.CLICKHOUSE_CLUSTER_NAME : ""}
|
||||
(
|
||||
@@ -196,7 +196,7 @@ export const createTempTableInClickhouse = async (tableName: string) => {
|
||||
});
|
||||
};
|
||||
|
||||
export const deleteTempTableInClickhouse = async (tableName: string) => {
|
||||
const deleteTempTableInClickhouse = async (tableName: string) => {
|
||||
const query = `
|
||||
DROP TABLE IF EXISTS ${tableName} ${env.CLICKHOUSE_CLUSTER_ENABLED === "true" ? "ON CLUSTER " + env.CLICKHOUSE_CLUSTER_NAME : ""}
|
||||
`;
|
||||
@@ -207,9 +207,7 @@ export const deleteTempTableInClickhouse = async (tableName: string) => {
|
||||
});
|
||||
};
|
||||
|
||||
export const getDatasetRunsFromPostgres = async (
|
||||
input: DatasetRunsTableInput,
|
||||
) => {
|
||||
const getDatasetRunsFromPostgres = async (input: DatasetRunsTableInput) => {
|
||||
return await prisma.$queryRaw<PostgresDatasetRun[]>(
|
||||
Prisma.sql`
|
||||
SELECT
|
||||
|
||||
@@ -11,6 +11,7 @@ const entitlements = [
|
||||
"trace-deletion", // Not in use anymore, but necessary to use the TableAction type.
|
||||
"audit-logs",
|
||||
"data-retention",
|
||||
"scheduled-blob-exports",
|
||||
"prompt-protected-labels",
|
||||
"admin-api",
|
||||
] as const;
|
||||
@@ -21,7 +22,10 @@ const cloudAllPlansEntitlements: Entitlement[] = [
|
||||
"trace-deletion",
|
||||
];
|
||||
|
||||
const selfHostedAllPlansEntitlements: Entitlement[] = ["trace-deletion"];
|
||||
const selfHostedAllPlansEntitlements: Entitlement[] = [
|
||||
"trace-deletion",
|
||||
"scheduled-blob-exports",
|
||||
];
|
||||
|
||||
// Entitlement Limits: Limits on the number of resources that can be created/used
|
||||
const entitlementLimits = [
|
||||
@@ -85,6 +89,7 @@ export const entitlementAccess: Record<
|
||||
"cloud-multi-tenant-sso",
|
||||
"prompt-protected-labels",
|
||||
"admin-api",
|
||||
"scheduled-blob-exports",
|
||||
],
|
||||
entitlementLimits: {
|
||||
"annotation-queue-count": false,
|
||||
@@ -103,6 +108,7 @@ export const entitlementAccess: Record<
|
||||
"cloud-multi-tenant-sso",
|
||||
"prompt-protected-labels",
|
||||
"admin-api",
|
||||
"scheduled-blob-exports",
|
||||
],
|
||||
entitlementLimits: {
|
||||
"annotation-queue-count": false,
|
||||
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
import { Separator } from "@/src/components/ui/separator";
|
||||
import { type FilterOption } from "@langfuse/shared";
|
||||
import { Input } from "@/src/components/ui/input";
|
||||
import { useRef, useState } from "react";
|
||||
import { useRef, useState, useMemo, useCallback } from "react";
|
||||
import { PropertyHoverCard } from "@/src/features/widgets/components/WidgetPropertySelectItem";
|
||||
|
||||
const getFreeTextInput = (
|
||||
@@ -54,7 +54,7 @@ export function MultiSelect({
|
||||
isCustomSelectEnabled?: boolean;
|
||||
labelTruncateCutOff?: number;
|
||||
}) {
|
||||
const selectedValues = new Set(values);
|
||||
const selectedValues = useMemo(() => new Set(values), [values]);
|
||||
const optionValues = new Set(options.map((option) => option.value));
|
||||
const freeTextInput = getFreeTextInput(
|
||||
isCustomSelectEnabled,
|
||||
@@ -63,6 +63,35 @@ export function MultiSelect({
|
||||
);
|
||||
const [freeText, setFreeText] = useState(freeTextInput || "");
|
||||
|
||||
const selectableOptions = useMemo(
|
||||
() => options.filter((option) => option.value.length > 0),
|
||||
[options],
|
||||
);
|
||||
|
||||
const allSelectedState = useMemo(() => {
|
||||
if (selectableOptions.length === 0) return false;
|
||||
return selectableOptions.every((option) =>
|
||||
selectedValues.has(option.value),
|
||||
);
|
||||
}, [selectableOptions, selectedValues]);
|
||||
|
||||
const handleSelectAll = useCallback(() => {
|
||||
const newSelectedValues = new Set(selectedValues);
|
||||
if (allSelectedState) {
|
||||
// Deselect all selectable options
|
||||
selectableOptions.forEach((option) =>
|
||||
newSelectedValues.delete(option.value),
|
||||
);
|
||||
} else {
|
||||
// Select all selectable options
|
||||
selectableOptions.forEach((option) =>
|
||||
newSelectedValues.add(option.value),
|
||||
);
|
||||
}
|
||||
const filterValues = Array.from(newSelectedValues);
|
||||
onValueChange(filterValues.length ? filterValues : []);
|
||||
}, [allSelectedState, selectableOptions, selectedValues, onValueChange]);
|
||||
|
||||
const debounceTimeout = useRef<NodeJS.Timeout | null>(null);
|
||||
const handleDebouncedChange = (value: string) => {
|
||||
const freeTextInput = getFreeTextInput(
|
||||
@@ -150,6 +179,26 @@ export function MultiSelect({
|
||||
<InputCommandEmpty>No results found.</InputCommandEmpty>
|
||||
)}
|
||||
<InputCommandGroup>
|
||||
{selectableOptions.length > 0 && (
|
||||
<>
|
||||
<InputCommandItem key="select-all" onSelect={handleSelectAll}>
|
||||
<div
|
||||
className={cn(
|
||||
"mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary",
|
||||
allSelectedState
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "opacity-50 [&_svg]:invisible",
|
||||
)}
|
||||
>
|
||||
<Check className={cn("h-4 w-4")} />
|
||||
</div>
|
||||
<div className="font-medium">
|
||||
{allSelectedState ? "Deselect All" : "Select All"}
|
||||
</div>
|
||||
</InputCommandItem>
|
||||
<InputCommandSeparator />
|
||||
</>
|
||||
)}
|
||||
{options.map((option) => {
|
||||
if (option.value.length === 0) return;
|
||||
const isSelected = selectedValues.has(option.value);
|
||||
|
||||
@@ -435,7 +435,6 @@ export class OtelIngestionProcessor {
|
||||
isLangfuseSDKSpans,
|
||||
isRootSpan,
|
||||
hasTraceUpdates,
|
||||
parentObservationId,
|
||||
span,
|
||||
} = params;
|
||||
|
||||
@@ -447,14 +446,12 @@ export class OtelIngestionProcessor {
|
||||
};
|
||||
|
||||
// Create full trace for root spans or spans with trace updates
|
||||
if (isRootSpan || hasTraceUpdates) {
|
||||
if (isRootSpan) {
|
||||
trace = {
|
||||
...trace,
|
||||
name:
|
||||
(attributes[LangfuseOtelSpanAttributes.TRACE_NAME] as string) ??
|
||||
(!parentObservationId
|
||||
? this.extractName(span.name, attributes)
|
||||
: undefined),
|
||||
this.extractName(span.name, attributes),
|
||||
metadata: {
|
||||
...resourceAttributeMetadata,
|
||||
...this.extractMetadata(attributes, "trace"),
|
||||
@@ -477,17 +474,47 @@ export class OtelIngestionProcessor {
|
||||
null,
|
||||
userId: this.extractUserId(attributes),
|
||||
sessionId: this.extractSessionId(attributes),
|
||||
public:
|
||||
attributes?.[LangfuseOtelSpanAttributes.TRACE_PUBLIC] === true ||
|
||||
attributes?.[LangfuseOtelSpanAttributes.TRACE_PUBLIC] === "true" ||
|
||||
attributes?.["langfuse.public"] === true ||
|
||||
attributes?.["langfuse.public"] === "true",
|
||||
public: this.isTracePublic(attributes),
|
||||
tags: this.extractTags(attributes),
|
||||
environment: this.extractEnvironment(attributes, resourceAttributes),
|
||||
...this.extractInputAndOutput(span?.events ?? [], attributes, "trace"),
|
||||
};
|
||||
}
|
||||
|
||||
if (hasTraceUpdates && !isRootSpan) {
|
||||
trace = {
|
||||
...trace,
|
||||
name: attributes[LangfuseOtelSpanAttributes.TRACE_NAME] as string,
|
||||
metadata: {
|
||||
...resourceAttributeMetadata,
|
||||
...this.extractMetadata(attributes, "trace"),
|
||||
...(isLangfuseSDKSpans
|
||||
? {}
|
||||
: { attributes: spanAttributesInMetadata }),
|
||||
resourceAttributes,
|
||||
scope: {
|
||||
...(scopeSpan.scope || {}),
|
||||
attributes: scopeAttributes,
|
||||
},
|
||||
} as Record<string, string | Record<string, string | number>>,
|
||||
version:
|
||||
(attributes?.[LangfuseOtelSpanAttributes.VERSION] as string) ??
|
||||
resourceAttributes?.["service.version"] ??
|
||||
null,
|
||||
release:
|
||||
(attributes?.[LangfuseOtelSpanAttributes.RELEASE] as string) ??
|
||||
resourceAttributes?.[LangfuseOtelSpanAttributes.RELEASE] ??
|
||||
null,
|
||||
userId: this.extractUserId(attributes),
|
||||
sessionId: this.extractSessionId(attributes),
|
||||
public: this.isTracePublic(attributes),
|
||||
tags: this.extractTags(attributes),
|
||||
environment: this.extractEnvironment(attributes, resourceAttributes),
|
||||
input: attributes[LangfuseOtelSpanAttributes.TRACE_INPUT],
|
||||
output: attributes[LangfuseOtelSpanAttributes.TRACE_OUTPUT],
|
||||
};
|
||||
}
|
||||
|
||||
if (isRootSpan) {
|
||||
this.traceEventCounts.rootSpanClosed += 1;
|
||||
} else if (hasTraceUpdates) {
|
||||
@@ -504,6 +531,18 @@ export class OtelIngestionProcessor {
|
||||
};
|
||||
}
|
||||
|
||||
private isTracePublic(
|
||||
attributes?: Record<string, unknown>,
|
||||
): boolean | undefined {
|
||||
const value =
|
||||
attributes?.[LangfuseOtelSpanAttributes.TRACE_PUBLIC] ??
|
||||
attributes?.["langfuse.public"];
|
||||
|
||||
if (value == null) return;
|
||||
|
||||
return value === true || value === "true" ? true : false;
|
||||
}
|
||||
|
||||
private createObservationEvent(
|
||||
params: CreateObservationEventParams,
|
||||
): IngestionEventType {
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import React, { useState } from "react";
|
||||
import { Button } from "@/src/components/ui/button";
|
||||
import { Badge } from "@/src/components/ui/badge";
|
||||
import { ChevronRight, ChevronDown } from "lucide-react";
|
||||
import { cn } from "@/src/utils/tailwind";
|
||||
|
||||
interface CollapsibleSectionProps {
|
||||
title: string;
|
||||
badge?: string;
|
||||
count?: number;
|
||||
defaultExpanded?: boolean;
|
||||
actionButton?: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
isEmpty?: boolean;
|
||||
emptyMessage?: string;
|
||||
className?: string;
|
||||
summaryContent?: string | React.ReactNode;
|
||||
}
|
||||
|
||||
export const CollapsibleSection: React.FC<CollapsibleSectionProps> = ({
|
||||
title,
|
||||
badge,
|
||||
count,
|
||||
defaultExpanded = false,
|
||||
actionButton,
|
||||
children,
|
||||
isEmpty = false,
|
||||
emptyMessage,
|
||||
className,
|
||||
summaryContent,
|
||||
}) => {
|
||||
const [isExpanded, setIsExpanded] = useState(defaultExpanded);
|
||||
|
||||
const toggleExpanded = () => {
|
||||
setIsExpanded(!isExpanded);
|
||||
};
|
||||
|
||||
const hasContent = count !== undefined ? count > 0 : !isEmpty;
|
||||
|
||||
return (
|
||||
<div className={cn("space-y-1", className)}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={toggleExpanded}
|
||||
className="h-8 gap-2 px-1 hover:bg-transparent"
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
) : (
|
||||
<ChevronRight className="h-3 w-3" />
|
||||
)}
|
||||
<span className="text-sm font-semibold">{title}</span>
|
||||
{badge && (
|
||||
<Badge variant="secondary" className="h-4 text-xs">
|
||||
{badge}
|
||||
</Badge>
|
||||
)}
|
||||
{count !== undefined && count > 0 && (
|
||||
<Badge variant="outline" className="h-4 text-xs">
|
||||
{count}
|
||||
</Badge>
|
||||
)}
|
||||
</Button>
|
||||
{actionButton && (
|
||||
<div className="flex items-center gap-1">{actionButton}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
{isExpanded && (
|
||||
<div className="space-y-2">
|
||||
{hasContent ? (
|
||||
children
|
||||
) : (
|
||||
<div className="px-2 py-1">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{emptyMessage || "No items configured."}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Compact summary when collapsed */}
|
||||
{!isExpanded && (hasContent || summaryContent) && (
|
||||
<div className="px-2 py-0.5">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{summaryContent ||
|
||||
(count !== undefined && count > 0
|
||||
? `${count} item${count === 1 ? "" : "s"} configured`
|
||||
: "Configured")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,185 @@
|
||||
import React from "react";
|
||||
import { Button } from "@/src/components/ui/button";
|
||||
import { Badge } from "@/src/components/ui/badge";
|
||||
import { ChevronDown, Wrench, Braces, Variable } from "lucide-react";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/src/components/ui/popover";
|
||||
import { usePlaygroundContext } from "../context";
|
||||
import { usePlaygroundWindowSize } from "../hooks/usePlaygroundWindowSize";
|
||||
import { PlaygroundTools, PlaygroundToolsPopover } from "./PlaygroundTools";
|
||||
import {
|
||||
StructuredOutputSchemaSection,
|
||||
StructuredOutputSchemaPopover,
|
||||
} from "./StructuredOutputSchemaSection";
|
||||
import { Variables } from "./Variables";
|
||||
import { MessagePlaceholders } from "./MessagePlaceholders";
|
||||
|
||||
export const ConfigurationDropdowns: React.FC = () => {
|
||||
const { containerRef, isVeryCompact, isCompact } = usePlaygroundWindowSize();
|
||||
const {
|
||||
tools,
|
||||
structuredOutputSchema,
|
||||
promptVariables,
|
||||
messagePlaceholders,
|
||||
} = usePlaygroundContext();
|
||||
|
||||
const toolsCount = tools.length;
|
||||
const hasSchema = structuredOutputSchema ? 1 : 0;
|
||||
const variablesCount = promptVariables.length + messagePlaceholders.length;
|
||||
|
||||
// Helper function to get responsive content (text or icon)
|
||||
const getResponsiveContent = (
|
||||
fullText: string,
|
||||
IconComponent: React.ComponentType<{ className?: string }>,
|
||||
abbreviation?: string,
|
||||
) => {
|
||||
if (isVeryCompact) {
|
||||
return <IconComponent className="h-3 w-3" />;
|
||||
}
|
||||
if (isCompact) {
|
||||
return (
|
||||
<>
|
||||
<IconComponent className="h-3 w-3" />
|
||||
<span className="text-sm">{abbreviation ?? fullText}</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<IconComponent className="h-3 w-3" />
|
||||
<span className="text-sm">{fullText}</span>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="flex-shrink-0 border-b bg-muted/25 px-3 py-2"
|
||||
>
|
||||
<div className="flex items-center justify-start gap-2">
|
||||
{/* Tools Dropdown */}
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="h-8 gap-2">
|
||||
{getResponsiveContent("Tools", Wrench)}
|
||||
{toolsCount > 0 && (
|
||||
<Badge variant="secondary" className="h-4 text-xs">
|
||||
{toolsCount}
|
||||
</Badge>
|
||||
)}
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-80 p-4" align="start">
|
||||
<div className="mb-3">
|
||||
<h4 className="mb-1 text-sm font-medium">Tools</h4>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Configure tools for your model to use.
|
||||
</p>
|
||||
</div>
|
||||
{toolsCount > 0 ? (
|
||||
<div className="mb-3">
|
||||
<PlaygroundTools />
|
||||
</div>
|
||||
) : (
|
||||
<div className="mb-3">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
No tools attached.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="border-t pt-3">
|
||||
<PlaygroundToolsPopover />
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
{/* Structured Output Dropdown */}
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="h-8 gap-2">
|
||||
{getResponsiveContent("Schema", Braces)}
|
||||
{hasSchema > 0 && (
|
||||
<Badge variant="secondary" className="h-4 text-xs">
|
||||
{hasSchema}
|
||||
</Badge>
|
||||
)}
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-80 p-4" align="start">
|
||||
<div className="mb-3">
|
||||
<h4 className="mb-1 text-sm font-medium">Structured Output</h4>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Configure JSON schema for structured output.
|
||||
</p>
|
||||
</div>
|
||||
{structuredOutputSchema ? (
|
||||
<div className="mb-3">
|
||||
<StructuredOutputSchemaSection />
|
||||
</div>
|
||||
) : (
|
||||
<div className="mb-3">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
No schema provided.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="border-t pt-3">
|
||||
<StructuredOutputSchemaPopover />
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
{/* Variables & Placeholders Dropdown */}
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="h-8 gap-2">
|
||||
{getResponsiveContent("Variables", Variable, "Vars")}
|
||||
{variablesCount > 0 && (
|
||||
<Badge variant="secondary" className="h-4 text-xs">
|
||||
{variablesCount}
|
||||
</Badge>
|
||||
)}
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-80 p-4" align="start">
|
||||
<div className="mb-3">
|
||||
<h4 className="mb-1 text-sm font-medium">
|
||||
Variables & Message Placeholders
|
||||
</h4>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Configure variables and message placeholders for your prompts.
|
||||
</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>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mb-3">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
No variables or message placeholders defined.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,11 +1,18 @@
|
||||
import { Terminal } from "lucide-react";
|
||||
import { Terminal, ChevronDown } from "lucide-react";
|
||||
import { useEffect, useState, useMemo } from "react";
|
||||
import { useRouter } from "next/router";
|
||||
import { z } from "zod/v4";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
import { createEmptyMessage } from "@/src/components/ChatMessages/utils/createEmptyMessage";
|
||||
import { Button } from "@/src/components/ui/button";
|
||||
import usePlaygroundCache from "@/src/features/playground/page/hooks/usePlaygroundCache";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/src/components/ui/dropdown-menu";
|
||||
import { usePersistedWindowIds } from "@/src/features/playground/page/hooks/usePersistedWindowIds";
|
||||
import {
|
||||
type PlaygroundCache,
|
||||
type PlaygroundSchema,
|
||||
@@ -38,6 +45,7 @@ import {
|
||||
} from "@/src/features/trace-graph-view/types";
|
||||
import { api } from "@/src/utils/api";
|
||||
import { cn } from "@/src/utils/tailwind";
|
||||
import usePlaygroundCache from "@/src/features/playground/page/hooks/usePlaygroundCache";
|
||||
|
||||
type JumpToPlaygroundButtonProps = (
|
||||
| {
|
||||
@@ -65,10 +73,21 @@ export const JumpToPlaygroundButton: React.FC<JumpToPlaygroundButtonProps> = (
|
||||
const router = useRouter();
|
||||
const capture = usePostHogClientCapture();
|
||||
const projectId = useProjectIdFromURL();
|
||||
const { setPlaygroundCache } = usePlaygroundCache();
|
||||
const { addWindowWithId, clearAllCache } = usePersistedWindowIds();
|
||||
const [capturedState, setCapturedState] = useState<PlaygroundCache>(null);
|
||||
const [isAvailable, setIsAvailable] = useState<boolean>(false);
|
||||
|
||||
// Generate a stable window ID based on the source data
|
||||
const stableWindowId = useMemo(() => {
|
||||
if (props.source === "prompt") {
|
||||
return `playground-prompt-${props.prompt.id}`;
|
||||
} else if (props.source === "generation") {
|
||||
return `playground-generation-${props.generation.id}`;
|
||||
}
|
||||
return `playground-${uuidv4()}`;
|
||||
}, [props]);
|
||||
const { setPlaygroundCache } = usePlaygroundCache(stableWindowId);
|
||||
|
||||
const apiKeys = api.llmApiKey.all.useQuery(
|
||||
{
|
||||
projectId: projectId as string,
|
||||
@@ -112,35 +131,84 @@ export const JumpToPlaygroundButton: React.FC<JumpToPlaygroundButtonProps> = (
|
||||
}
|
||||
}, [capturedState, setIsAvailable]);
|
||||
|
||||
const handleClick = () => {
|
||||
capture(props.analyticsEventName);
|
||||
setPlaygroundCache(capturedState);
|
||||
const handlePlaygroundAction = (useFreshPlayground: boolean) => {
|
||||
capture(props.analyticsEventName, {
|
||||
playgroundMode: useFreshPlayground ? "fresh" : "add_to_existing",
|
||||
});
|
||||
|
||||
router.push(`/project/${projectId}/playground`);
|
||||
// First, ensure we have state to save
|
||||
if (!capturedState) {
|
||||
console.warn("No captured state available for playground");
|
||||
return;
|
||||
}
|
||||
|
||||
if (useFreshPlayground) {
|
||||
// Clear all existing playground data and reset to single window
|
||||
clearAllCache(stableWindowId);
|
||||
} else {
|
||||
// Add to existing playground
|
||||
const addedWindowId = addWindowWithId(stableWindowId);
|
||||
|
||||
if (!addedWindowId) {
|
||||
console.warn(
|
||||
"Failed to add window to existing playground, maximum windows reached",
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Use requestAnimationFrame to ensure the state update has been processed
|
||||
requestAnimationFrame(() => {
|
||||
try {
|
||||
setPlaygroundCache(capturedState);
|
||||
console.log(
|
||||
`Cache saved for existing playground window ${stableWindowId}`,
|
||||
);
|
||||
|
||||
// Navigate after cache is successfully saved
|
||||
router.push(`/project/${projectId}/playground`);
|
||||
} catch (error) {
|
||||
console.error("Failed to save playground cache:", error);
|
||||
// Navigate anyway, but user might not see their data
|
||||
router.push(`/project/${projectId}/playground`);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const tooltipMessage = isAvailable
|
||||
? "Test in LLM playground"
|
||||
: "Test in LLM playground is not available since messages are not in valid ChatML format or tool calls have been used. If you think this is not correct, please open a GitHub issue.";
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant={props.variant ?? "secondary"}
|
||||
disabled={!isAvailable}
|
||||
title={
|
||||
isAvailable
|
||||
? "Test in LLM playground"
|
||||
: "Test in LLM playground is not available since messages are not in valid ChatML format or tool calls have been used. If you think this is not correct, please open a GitHub issue."
|
||||
}
|
||||
onClick={handleClick}
|
||||
asChild
|
||||
className={
|
||||
!isAvailable ? "cursor-not-allowed opacity-50" : "cursor-pointer"
|
||||
}
|
||||
>
|
||||
<span>
|
||||
<Terminal className="h-4 w-4" />
|
||||
<span className={cn("hidden md:ml-2 md:inline", props.className)}>
|
||||
Playground
|
||||
</span>
|
||||
</span>
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant={props.variant ?? "secondary"}
|
||||
disabled={!isAvailable}
|
||||
title={tooltipMessage}
|
||||
className={cn(
|
||||
"flex items-center gap-1",
|
||||
!isAvailable ? "cursor-not-allowed opacity-50" : "cursor-pointer",
|
||||
)}
|
||||
>
|
||||
<Terminal className="h-4 w-4" />
|
||||
<span className={cn("hidden md:inline", props.className)}>
|
||||
Playground
|
||||
</span>
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => handlePlaygroundAction(true)}>
|
||||
<Terminal className="mr-2 h-4 w-4" />
|
||||
Fresh playground
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => handlePlaygroundAction(false)}>
|
||||
<Terminal className="mr-2 h-4 w-4" />
|
||||
Add to existing
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -8,38 +8,33 @@ export const MessagePlaceholders = () => {
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<p className="font-semibold">Message Placeholders</p>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto overflow-x-hidden">
|
||||
{messagePlaceholders.length === 0 ? (
|
||||
<div className="mt-4 text-xs">
|
||||
<p className="mb-2">No message placeholders defined.</p>
|
||||
<p>
|
||||
Placeholders can be used to e.g. inject message histories into
|
||||
prompts.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{messagePlaceholders
|
||||
.slice()
|
||||
.sort((a, b) => {
|
||||
if (a.isUsed && !b.isUsed) return -1;
|
||||
if (!a.isUsed && b.isUsed) return 1;
|
||||
return a.name.localeCompare(b.name);
|
||||
})
|
||||
.map((placeholder, index) => (
|
||||
<div key={placeholder.name}>
|
||||
<MessagePlaceholderComponent
|
||||
messagePlaceholder={placeholder}
|
||||
/>
|
||||
{index !== messagePlaceholders.length - 1 && (
|
||||
<Divider className="my-2 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{messagePlaceholders.length === 0 ? (
|
||||
<div className="text-xs">
|
||||
<p className="mb-2">No message placeholders defined.</p>
|
||||
<p>
|
||||
Placeholders can be used to e.g. inject message histories into
|
||||
prompts.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-full overflow-auto">
|
||||
{messagePlaceholders
|
||||
.slice()
|
||||
.sort((a, b) => {
|
||||
if (a.isUsed && !b.isUsed) return -1;
|
||||
if (!a.isUsed && b.isUsed) return 1;
|
||||
return a.name.localeCompare(b.name);
|
||||
})
|
||||
.map((placeholder, index) => (
|
||||
<div key={placeholder.name}>
|
||||
<MessagePlaceholderComponent messagePlaceholder={placeholder} />
|
||||
{index !== messagePlaceholders.length - 1 && (
|
||||
<Divider className="my-2 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -10,7 +10,6 @@ import { Switch } from "@/src/components/ui/switch";
|
||||
import { Settings } from "lucide-react";
|
||||
import useLocalStorage from "@/src/components/useLocalStorage";
|
||||
import { env } from "@/src/env.mjs";
|
||||
import useCommandEnter from "@/src/features/playground/page/hooks/useCommandEnter";
|
||||
|
||||
import { GenerationOutput } from "./GenerationOutput";
|
||||
import { ChatMessages } from "@/src/components/ChatMessages";
|
||||
@@ -30,7 +29,7 @@ export const Messages: React.FC<MessagesContext> = (props) => {
|
||||
</ResizablePanel>
|
||||
<ResizableHandle withHandle className="bg-transparent" />
|
||||
<ResizablePanel
|
||||
minSize={10}
|
||||
minSize={20}
|
||||
defaultSize={20}
|
||||
className="flex flex-col space-y-4"
|
||||
>
|
||||
@@ -51,11 +50,6 @@ const SubmitButton = () => {
|
||||
defaultStreamingEnabled,
|
||||
);
|
||||
|
||||
// Handle command+enter with streaming preference
|
||||
useCommandEnter(!isStreaming, async () => {
|
||||
await handleSubmit(streamingEnabled);
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
@@ -65,7 +59,7 @@ const SubmitButton = () => {
|
||||
}}
|
||||
loading={isStreaming}
|
||||
>
|
||||
<p>Submit (Ctrl + Enter)</p>
|
||||
<p>Submit</p>
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
import React, { useMemo, useCallback } from "react";
|
||||
import { PlaygroundProvider } from "../context";
|
||||
import { SaveToPromptButton } from "./SaveToPromptButton";
|
||||
import { Button } from "@/src/components/ui/button";
|
||||
import { Copy, X } from "lucide-react";
|
||||
import { MULTI_WINDOW_CONFIG, type MultiWindowState } from "../types";
|
||||
import { ModelParameters } from "@/src/components/ModelParameters";
|
||||
import { usePlaygroundContext } from "../context";
|
||||
import { Messages } from "@/src/features/playground/page/components/Messages";
|
||||
import { ConfigurationDropdowns } from "@/src/features/playground/page/components/ConfigurationDropdowns";
|
||||
|
||||
/**
|
||||
* MultiWindowPlayground Component
|
||||
*
|
||||
* Container component that renders multiple playground windows for side-by-side
|
||||
* prompt comparison and testing. Receives window state and management functions
|
||||
* from the parent page component.
|
||||
*
|
||||
* Key Features:
|
||||
* - Responsive layout with horizontal scrolling
|
||||
* - Window-specific state isolation
|
||||
* - Equal-width distribution with minimum width constraints
|
||||
* - Individual window controls (save, copy, close)
|
||||
* - Window state copying for rapid iteration
|
||||
*
|
||||
* Architecture:
|
||||
* - Receives window state from parent (page-level management)
|
||||
* - Each window gets its own PlaygroundProvider with unique windowId
|
||||
* - State copying handled by parent component through hooks
|
||||
* - Clean separation of concerns with parent handling global actions
|
||||
*/
|
||||
|
||||
interface MultiWindowPlaygroundProps {
|
||||
windowState: MultiWindowState;
|
||||
onRemoveWindow: (windowId: string) => void;
|
||||
onAddWindow: (sourceWindowId?: string) => void;
|
||||
}
|
||||
|
||||
export default function MultiWindowPlayground({
|
||||
windowState,
|
||||
onRemoveWindow,
|
||||
onAddWindow,
|
||||
}: MultiWindowPlaygroundProps) {
|
||||
/**
|
||||
* Calculate responsive window width based on screen size and window count
|
||||
* Ensures minimum width while distributing available space equally
|
||||
*/
|
||||
const windowWidth = useMemo(() => {
|
||||
const minWidth = MULTI_WINDOW_CONFIG.MIN_WINDOW_WIDTH;
|
||||
const windowCount = windowState.windowIds.length;
|
||||
|
||||
// Calculate ideal width: 100% divided by number of windows
|
||||
const idealWidth = `${100 / windowCount}%`;
|
||||
|
||||
// Use CSS minmax to ensure minimum width is respected
|
||||
return `minmax(${minWidth}px, ${idealWidth})`;
|
||||
}, [windowState.windowIds.length]);
|
||||
|
||||
/**
|
||||
* Handle copying a specific window to create a new window
|
||||
* This is called when the individual window "Copy" button is clicked
|
||||
*
|
||||
* @param sourceWindowId - The window ID to copy from
|
||||
*/
|
||||
const handleCopyWindow = useCallback(
|
||||
(sourceWindowId: string) => {
|
||||
onAddWindow(sourceWindowId);
|
||||
},
|
||||
[onAddWindow],
|
||||
);
|
||||
|
||||
const firstWindowId = windowState.windowIds[0];
|
||||
if (!firstWindowId) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Mobile layout: single window only - visible below md breakpoint */}
|
||||
<div className="block h-full p-4 md:hidden">
|
||||
<PlaygroundProvider windowId={firstWindowId}>
|
||||
<PlaygroundWindowContent
|
||||
windowId={firstWindowId}
|
||||
onRemove={onRemoveWindow}
|
||||
onCopy={handleCopyWindow}
|
||||
canRemove={false}
|
||||
isMobile={true}
|
||||
/>
|
||||
</PlaygroundProvider>
|
||||
</div>
|
||||
|
||||
{/* Desktop layout: multi-window with horizontal grid - visible at md breakpoint and above */}
|
||||
<div className="hidden h-full md:block">
|
||||
<div
|
||||
className="h-full overflow-x-auto"
|
||||
style={{
|
||||
display: "grid",
|
||||
gridAutoFlow: "column",
|
||||
gridAutoColumns: windowWidth,
|
||||
gap: "1rem",
|
||||
padding: "1rem",
|
||||
scrollBehavior: "smooth",
|
||||
}}
|
||||
>
|
||||
{windowState.windowIds.map((windowId) => (
|
||||
<PlaygroundProvider key={windowId} windowId={windowId}>
|
||||
<PlaygroundWindowContent
|
||||
windowId={windowId}
|
||||
onRemove={onRemoveWindow}
|
||||
onCopy={handleCopyWindow}
|
||||
canRemove={windowState.windowIds.length > 1}
|
||||
isMobile={false}
|
||||
/>
|
||||
</PlaygroundProvider>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Inner component that has access to the PlaygroundProvider context
|
||||
*/
|
||||
function PlaygroundWindowContent({
|
||||
windowId,
|
||||
onRemove,
|
||||
onCopy,
|
||||
canRemove,
|
||||
isMobile,
|
||||
}: {
|
||||
windowId: string;
|
||||
onRemove: (windowId: string) => void;
|
||||
onCopy: (windowId: string) => void;
|
||||
canRemove: boolean;
|
||||
windowCount?: number;
|
||||
isMobile?: boolean;
|
||||
}) {
|
||||
const playgroundContext = usePlaygroundContext();
|
||||
|
||||
const handleRemove = useCallback(() => {
|
||||
onRemove(windowId);
|
||||
}, [windowId, onRemove]);
|
||||
|
||||
const handleCopy = useCallback(() => {
|
||||
onCopy(windowId);
|
||||
}, [windowId, onCopy]);
|
||||
|
||||
return (
|
||||
<div className="playground-window flex h-full min-w-0 flex-col rounded-lg border bg-background shadow-sm">
|
||||
{/* Window Header */}
|
||||
<div className="relative flex-shrink-0 border-b bg-muted/50 px-3 py-1">
|
||||
<div className="flex items-center pr-24">
|
||||
<div className="flex items-center gap-2">
|
||||
<ModelParameters {...playgroundContext} layout="compact" />
|
||||
</div>
|
||||
|
||||
<div className="absolute right-3 top-1/2 flex -translate-y-1/2 items-center gap-2">
|
||||
<SaveToPromptButton />
|
||||
|
||||
{/* Hide copy button on mobile */}
|
||||
{!isMobile && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={handleCopy}
|
||||
className="h-6 w-6 p-0 hover:bg-muted"
|
||||
title="Duplicate window configuration"
|
||||
>
|
||||
<Copy size={14} />
|
||||
<span className="sr-only">Copy window</span>
|
||||
</Button>
|
||||
)}
|
||||
{canRemove && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={handleRemove}
|
||||
className="h-6 w-6 p-0 hover:bg-destructive/10 hover:text-destructive"
|
||||
title="Remove window"
|
||||
>
|
||||
<X size={14} />
|
||||
<span className="sr-only">Remove window</span>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Window Content */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<div className="flex h-full flex-col">
|
||||
<ConfigurationDropdowns />
|
||||
|
||||
<div className="flex-1 overflow-auto p-4">
|
||||
<Messages {...playgroundContext} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import React, { useCallback, useEffect } from "react";
|
||||
|
||||
import { usePlaygroundContext } from "@/src/features/playground/page/context";
|
||||
import { Button } from "@/src/components/ui/button";
|
||||
@@ -17,18 +17,128 @@ import {
|
||||
CommandList,
|
||||
CommandSeparator,
|
||||
} from "@/src/components/ui/command";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/src/components/ui/popover";
|
||||
|
||||
import { type PlaygroundTool } from "@/src/features/playground/page/types";
|
||||
|
||||
// Popover content component for use in CollapsibleSection action buttons
|
||||
export const PlaygroundToolsPopover = () => {
|
||||
const { setTools } = usePlaygroundContext();
|
||||
const projectId = useProjectIdFromURL();
|
||||
|
||||
const { data: savedTools = [] } = api.llmTools.getAll.useQuery(
|
||||
{
|
||||
projectId: projectId as string,
|
||||
},
|
||||
{
|
||||
enabled: Boolean(projectId),
|
||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||
},
|
||||
);
|
||||
|
||||
const handleSelectTool = (selectedLLMTool: LlmTool) => {
|
||||
setTools((prev: PlaygroundTool[]) => {
|
||||
let existingToolIndex = -1;
|
||||
existingToolIndex = prev.findIndex((t) => t.id === selectedLLMTool.id);
|
||||
|
||||
if (existingToolIndex === -1) {
|
||||
const unsavedToolIndexWithSameName = prev.findIndex(
|
||||
(t) => t.name === selectedLLMTool.name,
|
||||
);
|
||||
|
||||
if (unsavedToolIndexWithSameName !== -1) {
|
||||
existingToolIndex = unsavedToolIndexWithSameName;
|
||||
}
|
||||
}
|
||||
|
||||
const newTool: PlaygroundTool = {
|
||||
id: selectedLLMTool.id,
|
||||
name: selectedLLMTool.name,
|
||||
description: selectedLLMTool.description,
|
||||
parameters: selectedLLMTool.parameters as Record<string, unknown>,
|
||||
existingLlmTool: selectedLLMTool,
|
||||
};
|
||||
|
||||
if (existingToolIndex !== -1) {
|
||||
const newTools = [...prev];
|
||||
newTools[existingToolIndex] = newTool;
|
||||
return newTools;
|
||||
}
|
||||
|
||||
return [...prev, newTool];
|
||||
});
|
||||
};
|
||||
|
||||
const handleRemoveTool = (toolId: string) => {
|
||||
setTools(
|
||||
(prev: PlaygroundTool[]) =>
|
||||
prev.filter((t) => !(t.id === toolId)) as PlaygroundTool[],
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Command className="flex flex-col">
|
||||
<CommandInput
|
||||
placeholder="Search tools..."
|
||||
className="h-8 border-none p-1 focus:ring-0 focus:ring-offset-0"
|
||||
/>
|
||||
<CommandList className="max-h-[300px] overflow-y-auto">
|
||||
<CommandEmpty>No tools found.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{savedTools.map((tool) => (
|
||||
<CommandItem
|
||||
key={tool.id}
|
||||
value={tool.name}
|
||||
onSelect={() => handleSelectTool(tool)}
|
||||
className="flex items-center justify-between px-1 py-2"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<WrenchIcon size={12} className="text-muted-foreground" />
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<div className="truncate font-medium">{tool.name}</div>
|
||||
<div className="line-clamp-1 text-xs text-muted-foreground">
|
||||
{tool.description}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<CreateOrEditLLMToolDialog
|
||||
projectId={projectId as string}
|
||||
onSave={handleSelectTool}
|
||||
onDelete={() => handleRemoveTool(tool.id)}
|
||||
existingLlmTool={tool}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="ml-2 h-7 w-7 shrink-0"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<PencilIcon className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</CreateOrEditLLMToolDialog>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
<CommandSeparator />
|
||||
</CommandList>
|
||||
<div className="mt-auto p-1">
|
||||
<CreateOrEditLLMToolDialog
|
||||
projectId={projectId as string}
|
||||
onSave={handleSelectTool}
|
||||
>
|
||||
<Button variant="outline" size="default" className="w-full">
|
||||
<PlusIcon className="mr-2 h-4 w-4" />
|
||||
Create new tool
|
||||
</Button>
|
||||
</CreateOrEditLLMToolDialog>
|
||||
</div>
|
||||
</Command>
|
||||
);
|
||||
};
|
||||
|
||||
// Main component for embedding in CollapsibleSection content
|
||||
export const PlaygroundTools = () => {
|
||||
const { tools, setTools } = usePlaygroundContext();
|
||||
const projectId = useProjectIdFromURL();
|
||||
const [isSearchOpen, setIsSearchOpen] = useState(false);
|
||||
|
||||
const { data: savedTools = [] } = api.llmTools.getAll.useQuery(
|
||||
{
|
||||
@@ -99,13 +209,11 @@ export const PlaygroundTools = () => {
|
||||
if (existingToolIndex !== -1) {
|
||||
const newTools = [...prev];
|
||||
newTools[existingToolIndex] = newTool;
|
||||
|
||||
return newTools;
|
||||
}
|
||||
|
||||
return [...prev, newTool];
|
||||
});
|
||||
setIsSearchOpen(false);
|
||||
};
|
||||
|
||||
const handleRemoveTool = (toolId: string) => {
|
||||
@@ -116,159 +224,71 @@ export const PlaygroundTools = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col pr-1">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="font-semibold">Tools</p>
|
||||
<a
|
||||
href="https://github.com/orgs/langfuse/discussions/3166"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center"
|
||||
title="Tool calling is currently in beta. Click here to learn more and provide feedback!"
|
||||
>
|
||||
<span className="rounded bg-muted px-1.5 py-0.5 text-xs font-medium text-muted-foreground">
|
||||
Beta
|
||||
</span>
|
||||
</a>
|
||||
<ScrollArea className="h-full">
|
||||
{tools.length === 0 ? (
|
||||
<div className="flex h-16 flex-col items-center justify-center p-4 text-center">
|
||||
<p className="text-xs text-muted-foreground">No tools attached.</p>
|
||||
</div>
|
||||
<Popover open={isSearchOpen} onOpenChange={setIsSearchOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button className="h-7 w-7" variant="outline" size="icon">
|
||||
<PlusIcon size={14} />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" className="p-1">
|
||||
<Command className="flex flex-col">
|
||||
<CommandInput
|
||||
placeholder="Search tools..."
|
||||
className="h-8 border-none p-1 focus:ring-0 focus:ring-offset-0"
|
||||
/>
|
||||
<CommandList className="max-h-[300px] overflow-y-auto">
|
||||
<CommandEmpty>No tools found.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{savedTools.map((tool) => (
|
||||
<CommandItem
|
||||
key={tool.id}
|
||||
value={tool.name}
|
||||
onSelect={() => handleSelectTool(tool)}
|
||||
className="flex items-center justify-between px-1 py-2"
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{tools.map((tool) => (
|
||||
<CreateOrEditLLMToolDialog
|
||||
key={tool.id}
|
||||
projectId={projectId as string}
|
||||
onSave={handleSelectTool}
|
||||
onDelete={() => handleRemoveTool(tool.id)}
|
||||
existingLlmTool={tool.existingLlmTool}
|
||||
defaultValues={
|
||||
!isToolSaved(tool)
|
||||
? {
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
parameters: JSON.stringify(tool.parameters, null, 2),
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<div className="cursor-pointer rounded-md border bg-background p-2 transition-colors duration-200 hover:bg-accent/50">
|
||||
<div className="mb-1 flex items-center justify-between">
|
||||
<div className="flex items-center gap-1">
|
||||
<WrenchIcon className="h-4 w-4 text-muted-foreground" />
|
||||
<h3
|
||||
className="max-w-[200px] truncate text-ellipsis text-sm font-medium"
|
||||
title={tool.name}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<WrenchIcon
|
||||
size={12}
|
||||
className="text-muted-foreground"
|
||||
/>
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<div className="truncate font-medium">
|
||||
{tool.name}
|
||||
</div>
|
||||
<div className="line-clamp-1 text-xs text-muted-foreground">
|
||||
{tool.description}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<CreateOrEditLLMToolDialog
|
||||
projectId={projectId as string}
|
||||
onSave={handleSelectTool}
|
||||
onDelete={() => handleRemoveTool(tool.id)}
|
||||
existingLlmTool={tool}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="ml-2 h-7 w-7 shrink-0"
|
||||
>
|
||||
<PencilIcon className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</CreateOrEditLLMToolDialog>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
<CommandSeparator />
|
||||
</CommandList>
|
||||
<div className="mt-auto p-1">
|
||||
<CreateOrEditLLMToolDialog
|
||||
projectId={projectId as string}
|
||||
onSave={handleSelectTool}
|
||||
>
|
||||
<Button variant="outline" size="default" className="w-full">
|
||||
<PlusIcon className="mr-2 h-4 w-4" />
|
||||
Create new tool
|
||||
</Button>
|
||||
</CreateOrEditLLMToolDialog>
|
||||
</div>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="h-[calc(100%-2rem)]">
|
||||
{tools.length === 0 ? (
|
||||
<div className="flex h-16 flex-col items-center justify-center p-4 text-center">
|
||||
<p className="text-xs text-muted-foreground">No tools attached.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{tools.map((tool) => (
|
||||
<CreateOrEditLLMToolDialog
|
||||
key={tool.id}
|
||||
projectId={projectId as string}
|
||||
onSave={handleSelectTool}
|
||||
onDelete={() => handleRemoveTool(tool.id)}
|
||||
existingLlmTool={tool.existingLlmTool}
|
||||
defaultValues={
|
||||
!isToolSaved(tool)
|
||||
? {
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
parameters: JSON.stringify(tool.parameters, null, 2),
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<div className="cursor-pointer rounded-md border bg-background p-2 transition-colors duration-200 hover:bg-accent/50">
|
||||
<div className="mb-1 flex items-center justify-between">
|
||||
<div className="flex items-center gap-1">
|
||||
<WrenchIcon className="h-4 w-4 text-muted-foreground" />
|
||||
<h3
|
||||
className="max-w-[200px] truncate text-ellipsis text-sm font-medium"
|
||||
title={tool.name}
|
||||
>
|
||||
{tool.name}
|
||||
</h3>
|
||||
{!isToolSaved(tool) ? (
|
||||
<span className="rounded bg-muted px-1 py-0.5 text-xs text-muted-foreground">
|
||||
Unsaved
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleRemoveTool(tool.id);
|
||||
}}
|
||||
>
|
||||
<MinusCircle className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
{tool.name}
|
||||
</h3>
|
||||
{!isToolSaved(tool) ? (
|
||||
<span className="rounded bg-muted px-1 py-0.5 text-xs text-muted-foreground">
|
||||
Unsaved
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleRemoveTool(tool.id);
|
||||
}}
|
||||
>
|
||||
<MinusCircle className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<p
|
||||
className="line-clamp-2 break-all text-xs text-muted-foreground"
|
||||
title={tool.description}
|
||||
>
|
||||
{tool.description}
|
||||
</p>
|
||||
</div>
|
||||
</CreateOrEditLLMToolDialog>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</div>
|
||||
<p
|
||||
className="line-clamp-2 break-all text-xs text-muted-foreground"
|
||||
title={tool.description}
|
||||
>
|
||||
{tool.description}
|
||||
</p>
|
||||
</div>
|
||||
</CreateOrEditLLMToolDialog>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -2,26 +2,26 @@ import { ListRestartIcon } from "lucide-react";
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
import { Button } from "@/src/components/ui/button";
|
||||
import usePlaygroundCache from "@/src/features/playground/page/hooks/usePlaygroundCache";
|
||||
import { usePersistedWindowIds } from "@/src/features/playground/page/hooks/usePersistedWindowIds";
|
||||
|
||||
export const ResetPlaygroundButton: React.FC = () => {
|
||||
const router = useRouter();
|
||||
const { setPlaygroundCache } = usePlaygroundCache();
|
||||
const { clearAllCache } = usePersistedWindowIds();
|
||||
|
||||
const handleClick = () => {
|
||||
setPlaygroundCache(null);
|
||||
|
||||
clearAllCache();
|
||||
router.reload();
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant={"outline"}
|
||||
variant="outline"
|
||||
title="Reset playground state"
|
||||
onClick={handleClick}
|
||||
className="gap-1"
|
||||
>
|
||||
<ListRestartIcon className="mr-1 h-4 w-4" />
|
||||
<span>Reset playground</span>
|
||||
<ListRestartIcon className="h-4 w-4" />
|
||||
<span className="hidden lg:inline">Reset playground</span>
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Check, FileInput } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { Check, Save } from "lucide-react";
|
||||
import { useRouter } from "next/router";
|
||||
import { useState } from "react";
|
||||
|
||||
@@ -26,7 +25,13 @@ import { cn } from "@/src/utils/tailwind";
|
||||
import DocPopup from "@/src/components/layouts/doc-popup";
|
||||
import { PromptType } from "@langfuse/shared";
|
||||
|
||||
export const SaveToPromptButton: React.FC = () => {
|
||||
interface SaveToPromptButtonProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const SaveToPromptButton: React.FC<SaveToPromptButtonProps> = ({
|
||||
className,
|
||||
}) => {
|
||||
const [selectedPromptId, setSelectedPromptId] = useState("");
|
||||
const { modelParams, messages, output, promptVariables } =
|
||||
usePlaygroundContext();
|
||||
@@ -82,11 +87,13 @@ export const SaveToPromptButton: React.FC = () => {
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant={"outline"} title="Save to prompt" asChild>
|
||||
<Link href={`/project/${projectId}/playground`}>
|
||||
<FileInput className="mr-1 h-4 w-4" />
|
||||
<span>Save as prompt</span>
|
||||
</Link>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn("h-6 w-6 p-0 hover:bg-muted", className)}
|
||||
title="Save current configuration as a prompt template for reuse across your project"
|
||||
>
|
||||
<Save size={14} />
|
||||
<span className="sr-only">Save as prompt</span>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useCallback, useEffect } from "react";
|
||||
import React, { useCallback, useEffect } from "react";
|
||||
|
||||
import { usePlaygroundContext } from "@/src/features/playground/page/context";
|
||||
import { Button } from "@/src/components/ui/button";
|
||||
@@ -17,18 +17,144 @@ import {
|
||||
CommandList,
|
||||
CommandSeparator,
|
||||
} from "@/src/components/ui/command";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/src/components/ui/popover";
|
||||
import { type PlaygroundSchema } from "@/src/features/playground/page/types";
|
||||
|
||||
// Popover content component for use in CollapsibleSection action buttons
|
||||
export const StructuredOutputSchemaPopover = () => {
|
||||
const { structuredOutputSchema, setStructuredOutputSchema } =
|
||||
usePlaygroundContext();
|
||||
const projectId = useProjectIdFromURL();
|
||||
|
||||
const { data: savedSchemas = [] } = api.llmSchemas.getAll.useQuery(
|
||||
{
|
||||
projectId: projectId as string,
|
||||
},
|
||||
{
|
||||
enabled: Boolean(projectId),
|
||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||
},
|
||||
);
|
||||
|
||||
const isSchemaSaved = useCallback(
|
||||
(schema: PlaygroundSchema) => {
|
||||
return savedSchemas.some(
|
||||
(savedSchema) =>
|
||||
savedSchema.id === schema.id &&
|
||||
savedSchema.description === schema.description &&
|
||||
JSON.stringify(savedSchema.schema) === JSON.stringify(schema.schema),
|
||||
);
|
||||
},
|
||||
[savedSchemas],
|
||||
);
|
||||
|
||||
const handleSelectSchema = (selectedLLMSchema: LlmSchema) => {
|
||||
if (
|
||||
structuredOutputSchema &&
|
||||
structuredOutputSchema.id === selectedLLMSchema.id
|
||||
) {
|
||||
// Schema already selected, just update it
|
||||
setStructuredOutputSchema({
|
||||
...structuredOutputSchema,
|
||||
name: selectedLLMSchema.name,
|
||||
description: selectedLLMSchema.description,
|
||||
schema: selectedLLMSchema.schema as Record<string, unknown>,
|
||||
existingLlmSchema: selectedLLMSchema,
|
||||
});
|
||||
} else if (
|
||||
structuredOutputSchema &&
|
||||
structuredOutputSchema.name === selectedLLMSchema.name &&
|
||||
!isSchemaSaved(structuredOutputSchema)
|
||||
) {
|
||||
// Replace unsaved schema with same name
|
||||
setStructuredOutputSchema({
|
||||
id: selectedLLMSchema.id,
|
||||
name: selectedLLMSchema.name,
|
||||
description: selectedLLMSchema.description,
|
||||
schema: selectedLLMSchema.schema as Record<string, unknown>,
|
||||
existingLlmSchema: selectedLLMSchema,
|
||||
});
|
||||
} else {
|
||||
// New schema
|
||||
const newPlaygroundSchema: PlaygroundSchema = {
|
||||
id: selectedLLMSchema.id,
|
||||
name: selectedLLMSchema.name,
|
||||
description: selectedLLMSchema.description,
|
||||
schema: selectedLLMSchema.schema as Record<string, unknown>,
|
||||
existingLlmSchema: selectedLLMSchema,
|
||||
};
|
||||
setStructuredOutputSchema(newPlaygroundSchema);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveSchema = () => {
|
||||
setStructuredOutputSchema(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<Command className="flex flex-col">
|
||||
<CommandInput
|
||||
placeholder="Search schemas..."
|
||||
className="h-8 border-none p-1 focus:ring-0 focus:ring-offset-0"
|
||||
/>
|
||||
<CommandList className="max-h-[300px] overflow-y-auto">
|
||||
<CommandEmpty>No schemas found.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{savedSchemas.map((schema) => (
|
||||
<CommandItem
|
||||
key={schema.id}
|
||||
value={schema.name}
|
||||
onSelect={() => handleSelectSchema(schema)}
|
||||
className="flex items-center justify-between px-1 py-2"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<BoxIcon className="h-4 w-4 text-muted-foreground" />
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<div className="truncate font-medium">{schema.name}</div>
|
||||
<div className="line-clamp-1 text-xs text-muted-foreground">
|
||||
{schema.description}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<CreateOrEditLLMSchemaDialog
|
||||
projectId={projectId as string}
|
||||
onSave={handleSelectSchema}
|
||||
onDelete={() => handleRemoveSchema()}
|
||||
existingLlmSchema={schema}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="ml-2 h-7 w-7 shrink-0"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<PencilIcon className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</CreateOrEditLLMSchemaDialog>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
<CommandSeparator />
|
||||
</CommandList>
|
||||
<div className="mt-auto p-1">
|
||||
<CreateOrEditLLMSchemaDialog
|
||||
projectId={projectId as string}
|
||||
onSave={handleSelectSchema}
|
||||
>
|
||||
<Button variant="outline" size="default" className="w-full">
|
||||
<PlusIcon className="mr-2 h-4 w-4" />
|
||||
Create new schema
|
||||
</Button>
|
||||
</CreateOrEditLLMSchemaDialog>
|
||||
</div>
|
||||
</Command>
|
||||
);
|
||||
};
|
||||
|
||||
// Main component for embedding in CollapsibleSection content
|
||||
export const StructuredOutputSchemaSection = () => {
|
||||
const { structuredOutputSchema, setStructuredOutputSchema } =
|
||||
usePlaygroundContext();
|
||||
const projectId = useProjectIdFromURL();
|
||||
const [isSearchOpen, setIsSearchOpen] = useState(false);
|
||||
|
||||
const { data: savedSchemas = [] } = api.llmSchemas.getAll.useQuery(
|
||||
{
|
||||
@@ -105,7 +231,6 @@ export const StructuredOutputSchemaSection = () => {
|
||||
};
|
||||
setStructuredOutputSchema(newPlaygroundSchema);
|
||||
}
|
||||
setIsSearchOpen(false);
|
||||
};
|
||||
|
||||
const handleRemoveSchema = () => {
|
||||
@@ -113,158 +238,72 @@ export const StructuredOutputSchemaSection = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col pr-1">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="font-semibold">Structured Output</p>
|
||||
<a
|
||||
href="https://github.com/orgs/langfuse/discussions/3166"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center"
|
||||
title="Structured output is currently in beta. Click here to learn more and provide feedback!"
|
||||
>
|
||||
<span className="rounded bg-muted px-1.5 py-0.5 text-xs font-medium text-muted-foreground">
|
||||
Beta
|
||||
</span>
|
||||
</a>
|
||||
<ScrollArea className="h-full">
|
||||
{!structuredOutputSchema ? (
|
||||
<div className="flex h-16 flex-col items-center justify-center p-4 text-center">
|
||||
<p className="text-xs text-muted-foreground">No schema provided.</p>
|
||||
</div>
|
||||
<Popover open={isSearchOpen} onOpenChange={setIsSearchOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button className="h-7 w-7" variant="outline" size="icon">
|
||||
<PlusIcon size={14} />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" className="p-1">
|
||||
<Command className="flex flex-col">
|
||||
<CommandInput
|
||||
placeholder="Search schemas..."
|
||||
className="h-8 border-none p-1 focus:ring-0 focus:ring-offset-0"
|
||||
/>
|
||||
<CommandList className="max-h-[300px] overflow-y-auto">
|
||||
<CommandEmpty>No schemas found.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{savedSchemas.map((schema) => (
|
||||
<CommandItem
|
||||
key={schema.id}
|
||||
value={schema.name}
|
||||
onSelect={() => handleSelectSchema(schema)}
|
||||
className="flex items-center justify-between px-1 py-2"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<BoxIcon className="h-4 w-4 text-muted-foreground" />
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<div className="truncate font-medium">
|
||||
{schema.name}
|
||||
</div>
|
||||
<div className="line-clamp-1 text-xs text-muted-foreground">
|
||||
{schema.description}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<CreateOrEditLLMSchemaDialog
|
||||
projectId={projectId as string}
|
||||
onSave={handleSelectSchema}
|
||||
onDelete={() => handleRemoveSchema()}
|
||||
existingLlmSchema={schema}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="ml-2 h-7 w-7 shrink-0"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<PencilIcon className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</CreateOrEditLLMSchemaDialog>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
<CommandSeparator />
|
||||
</CommandList>
|
||||
<div className="mt-auto p-1">
|
||||
<CreateOrEditLLMSchemaDialog
|
||||
projectId={projectId as string}
|
||||
onSave={handleSelectSchema}
|
||||
>
|
||||
<Button variant="outline" size="default" className="w-full">
|
||||
<PlusIcon className="mr-2 h-4 w-4" />
|
||||
Create new schema
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
<CreateOrEditLLMSchemaDialog
|
||||
projectId={projectId as string}
|
||||
onSave={handleSelectSchema}
|
||||
onDelete={() => handleRemoveSchema()}
|
||||
existingLlmSchema={structuredOutputSchema.existingLlmSchema}
|
||||
defaultValues={
|
||||
!isSchemaSaved(structuredOutputSchema)
|
||||
? {
|
||||
name: structuredOutputSchema.name,
|
||||
description: structuredOutputSchema.description,
|
||||
schema: JSON.stringify(
|
||||
structuredOutputSchema.schema,
|
||||
null,
|
||||
2,
|
||||
),
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<div className="cursor-pointer rounded-md border bg-background p-2 transition-colors duration-200 hover:bg-accent/50">
|
||||
<div className="mb-1 flex items-center justify-between">
|
||||
<div className="flex items-center gap-1">
|
||||
<BoxIcon className="h-4 w-4 text-muted-foreground" />
|
||||
<h3
|
||||
className="max-w-[200px] truncate text-ellipsis text-sm font-medium"
|
||||
title={structuredOutputSchema.name}
|
||||
>
|
||||
{structuredOutputSchema.name}
|
||||
</h3>
|
||||
{!isSchemaSaved(structuredOutputSchema) ? (
|
||||
<span className="rounded bg-muted px-1 py-0.5 text-xs text-muted-foreground">
|
||||
Unsaved
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleRemoveSchema();
|
||||
}}
|
||||
>
|
||||
<MinusCircle className="h-4 w-4" />
|
||||
</Button>
|
||||
</CreateOrEditLLMSchemaDialog>
|
||||
</div>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="h-[calc(100%-2rem)]">
|
||||
{!structuredOutputSchema ? (
|
||||
<div className="flex h-16 flex-col items-center justify-center p-4 text-center">
|
||||
<p className="text-xs text-muted-foreground">No schema provided.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
<CreateOrEditLLMSchemaDialog
|
||||
projectId={projectId as string}
|
||||
onSave={handleSelectSchema}
|
||||
onDelete={() => handleRemoveSchema()}
|
||||
existingLlmSchema={structuredOutputSchema.existingLlmSchema}
|
||||
defaultValues={
|
||||
!isSchemaSaved(structuredOutputSchema)
|
||||
? {
|
||||
name: structuredOutputSchema.name,
|
||||
description: structuredOutputSchema.description,
|
||||
schema: JSON.stringify(
|
||||
structuredOutputSchema.schema,
|
||||
null,
|
||||
2,
|
||||
),
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<div className="cursor-pointer rounded-md border bg-background p-2 transition-colors duration-200 hover:bg-accent/50">
|
||||
<div className="mb-1 flex items-center justify-between">
|
||||
<div className="flex items-center gap-1">
|
||||
<BoxIcon className="h-4 w-4 text-muted-foreground" />
|
||||
<h3
|
||||
className="max-w-[200px] truncate text-ellipsis text-sm font-medium"
|
||||
title={structuredOutputSchema.name}
|
||||
>
|
||||
{structuredOutputSchema.name}
|
||||
</h3>
|
||||
{!isSchemaSaved(structuredOutputSchema) ? (
|
||||
<span className="rounded bg-muted px-1 py-0.5 text-xs text-muted-foreground">
|
||||
Unsaved
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleRemoveSchema();
|
||||
}}
|
||||
>
|
||||
<MinusCircle className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<p
|
||||
className="line-clamp-2 break-all text-xs text-muted-foreground"
|
||||
title={structuredOutputSchema.description}
|
||||
>
|
||||
{structuredOutputSchema.description}
|
||||
</p>
|
||||
</div>
|
||||
</CreateOrEditLLMSchemaDialog>
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</div>
|
||||
<p
|
||||
className="line-clamp-2 break-all text-xs text-muted-foreground"
|
||||
title={structuredOutputSchema.description}
|
||||
>
|
||||
{structuredOutputSchema.description}
|
||||
</p>
|
||||
</div>
|
||||
</CreateOrEditLLMSchemaDialog>
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -5,8 +5,9 @@ import { PromptVariableComponent } from "./PromptVariableComponent";
|
||||
|
||||
export const Variables = () => {
|
||||
const { promptVariables } = usePlaygroundContext();
|
||||
|
||||
const renderNoVariables = () => (
|
||||
<div className="mt-4 text-xs">
|
||||
<div className="text-xs">
|
||||
<p className="mb-2">No variables defined.</p>
|
||||
<p>
|
||||
Use handlebars in your prompts to add a variable:
|
||||
@@ -14,6 +15,7 @@ export const Variables = () => {
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderVariables = () => (
|
||||
<div className="min-h-0 flex-1 overflow-y-auto overflow-x-hidden">
|
||||
{promptVariables
|
||||
@@ -36,7 +38,6 @@ export const Variables = () => {
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<p className="font-semibold">Variables</p>
|
||||
{promptVariables.length === 0 ? renderNoVariables() : renderVariables()}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import React, {
|
||||
createContext,
|
||||
type PropsWithChildren,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useState,
|
||||
useRef,
|
||||
} from "react";
|
||||
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
@@ -37,7 +37,15 @@ import {
|
||||
type PlaygroundSchema,
|
||||
type PlaygroundTool,
|
||||
type PlaceholderMessageFillIn,
|
||||
type PlaygroundProviderProps,
|
||||
type PlaygroundHandle,
|
||||
PLAYGROUND_EVENTS,
|
||||
MULTI_WINDOW_CONFIG,
|
||||
} from "@/src/features/playground/page/types";
|
||||
import {
|
||||
getPlaygroundEventBus,
|
||||
useWindowCoordination,
|
||||
} from "@/src/features/playground/page/hooks/useWindowCoordination";
|
||||
import { getFinalModelParams } from "@/src/utils/getFinalModelParams";
|
||||
|
||||
type PlaygroundContextType = {
|
||||
@@ -78,12 +86,13 @@ export const usePlaygroundContext = () => {
|
||||
return context;
|
||||
};
|
||||
|
||||
export const PlaygroundProvider: React.FC<PropsWithChildren> = ({
|
||||
export const PlaygroundProvider: React.FC<PlaygroundProviderProps> = ({
|
||||
children,
|
||||
windowId,
|
||||
}) => {
|
||||
const capture = usePostHogClientCapture();
|
||||
const projectId = useProjectIdFromURL();
|
||||
const { playgroundCache, setPlaygroundCache } = usePlaygroundCache();
|
||||
const { playgroundCache, setPlaygroundCache } = usePlaygroundCache(windowId);
|
||||
const [promptVariables, setPromptVariables] = useState<PromptVariable[]>([]);
|
||||
const [messagePlaceholders, setMessagePlaceholders] = useState<
|
||||
PlaceholderMessageFillIn[]
|
||||
@@ -92,6 +101,7 @@ export const PlaygroundProvider: React.FC<PropsWithChildren> = ({
|
||||
const [outputToolCalls, setOutputToolCalls] = useState<LLMToolCall[]>([]);
|
||||
const [outputJson, setOutputJson] = useState("");
|
||||
const [isStreaming, setIsStreaming] = useState(false);
|
||||
const isStreamingRef = useRef(isStreaming);
|
||||
const [tools, setTools] = useState<PlaygroundTool[]>([]);
|
||||
const [structuredOutputSchema, setStructuredOutputSchema] =
|
||||
useState<PlaygroundSchema | null>(null);
|
||||
@@ -115,7 +125,8 @@ export const PlaygroundProvider: React.FC<PropsWithChildren> = ({
|
||||
availableModels,
|
||||
updateModelParamValue,
|
||||
setModelParamEnabled,
|
||||
} = useModelParams();
|
||||
} = useModelParams(windowId);
|
||||
const { registerWindow, unregisterWindow } = useWindowCoordination();
|
||||
|
||||
const toolCallIds = messages.reduce((acc, m) => {
|
||||
if (m.type === ChatMessageType.AssistantToolCall) {
|
||||
@@ -125,14 +136,19 @@ export const PlaygroundProvider: React.FC<PropsWithChildren> = ({
|
||||
}, [] as string[]);
|
||||
|
||||
// Load state from cache
|
||||
const [cacheLoaded, setCacheLoaded] = useState(false);
|
||||
useEffect(() => {
|
||||
if (!playgroundCache) return;
|
||||
if (!playgroundCache) {
|
||||
setCacheLoaded(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const {
|
||||
messages: cachedMessages,
|
||||
modelParams: cachedModelParams,
|
||||
output: cachedOutput,
|
||||
promptVariables: cachedPromptVariables,
|
||||
messagePlaceholders: cachedMessagePlaceholders,
|
||||
tools: cachedTools,
|
||||
structuredOutputSchema: cachedStructuredOutputSchema,
|
||||
} = playgroundCache;
|
||||
@@ -166,6 +182,10 @@ export const PlaygroundProvider: React.FC<PropsWithChildren> = ({
|
||||
setPromptVariables(cachedPromptVariables);
|
||||
}
|
||||
|
||||
if (cachedMessagePlaceholders) {
|
||||
setMessagePlaceholders(cachedMessagePlaceholders);
|
||||
}
|
||||
|
||||
if (cachedTools) {
|
||||
setTools(cachedTools);
|
||||
}
|
||||
@@ -173,6 +193,8 @@ export const PlaygroundProvider: React.FC<PropsWithChildren> = ({
|
||||
if (cachedStructuredOutputSchema) {
|
||||
setStructuredOutputSchema(cachedStructuredOutputSchema);
|
||||
}
|
||||
|
||||
setCacheLoaded(true);
|
||||
}, [playgroundCache, setModelParams]);
|
||||
|
||||
const updatePromptVariables = useCallback(() => {
|
||||
@@ -294,6 +316,11 @@ export const PlaygroundProvider: React.FC<PropsWithChildren> = ({
|
||||
messages,
|
||||
messagePlaceholders,
|
||||
);
|
||||
|
||||
if (finalMessages.length === 0) {
|
||||
throw new Error("Please add at least one message with content");
|
||||
}
|
||||
|
||||
const leftOverVariables = extractVariables(
|
||||
finalMessages
|
||||
.map((m) => (typeof m.content === "string" ? m.content : ""))
|
||||
@@ -381,6 +408,7 @@ export const PlaygroundProvider: React.FC<PropsWithChildren> = ({
|
||||
modelParams,
|
||||
output: response,
|
||||
promptVariables,
|
||||
messagePlaceholders,
|
||||
tools,
|
||||
structuredOutputSchema,
|
||||
});
|
||||
@@ -480,6 +508,106 @@ export const PlaygroundProvider: React.FC<PropsWithChildren> = ({
|
||||
|
||||
useEffect(updateMessagePlaceholders, [messages, updateMessagePlaceholders]);
|
||||
|
||||
// Save state to cache whenever it changes
|
||||
// This ensures that user changes are persisted across refreshes and navigation
|
||||
useEffect(() => {
|
||||
// Only save after cache has been loaded to avoid overwriting with initial state
|
||||
if (!cacheLoaded) return;
|
||||
|
||||
// Don't save empty initial state to avoid overwriting valid cache
|
||||
if (messages.length > 0 && modelParams.provider.value) {
|
||||
setPlaygroundCache({
|
||||
messages,
|
||||
modelParams,
|
||||
output,
|
||||
promptVariables,
|
||||
messagePlaceholders,
|
||||
tools,
|
||||
structuredOutputSchema,
|
||||
});
|
||||
}
|
||||
}, [
|
||||
messages,
|
||||
modelParams,
|
||||
output,
|
||||
promptVariables,
|
||||
messagePlaceholders,
|
||||
tools,
|
||||
structuredOutputSchema,
|
||||
setPlaygroundCache,
|
||||
cacheLoaded,
|
||||
]);
|
||||
|
||||
// Window self-registration for global coordination
|
||||
// This effect registers the window with the global coordination system
|
||||
// and sets up event listeners for global actions like "Run All" and "Stop All"
|
||||
useEffect(() => {
|
||||
const effectiveWindowId = windowId || MULTI_WINDOW_CONFIG.DEFAULT_WINDOW_ID;
|
||||
const playgroundEventBus = getPlaygroundEventBus();
|
||||
|
||||
const playgroundHandle: PlaygroundHandle = {
|
||||
handleSubmit,
|
||||
stopExecution: () => {
|
||||
setIsStreaming(false);
|
||||
isStreamingRef.current = false;
|
||||
},
|
||||
getIsStreaming: () => isStreamingRef.current,
|
||||
};
|
||||
|
||||
registerWindow(effectiveWindowId, playgroundHandle);
|
||||
|
||||
const handleGlobalExecute = () => {
|
||||
if (!isStreamingRef.current) {
|
||||
handleSubmit(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleGlobalStop = () => {
|
||||
if (isStreamingRef.current) {
|
||||
setIsStreaming(false);
|
||||
isStreamingRef.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
playgroundEventBus.addEventListener(
|
||||
PLAYGROUND_EVENTS.EXECUTE_ALL,
|
||||
handleGlobalExecute,
|
||||
);
|
||||
playgroundEventBus.addEventListener(
|
||||
PLAYGROUND_EVENTS.STOP_ALL,
|
||||
handleGlobalStop,
|
||||
);
|
||||
|
||||
return () => {
|
||||
unregisterWindow(effectiveWindowId);
|
||||
|
||||
playgroundEventBus.removeEventListener(
|
||||
PLAYGROUND_EVENTS.EXECUTE_ALL,
|
||||
handleGlobalExecute,
|
||||
);
|
||||
playgroundEventBus.removeEventListener(
|
||||
PLAYGROUND_EVENTS.STOP_ALL,
|
||||
handleGlobalStop,
|
||||
);
|
||||
};
|
||||
}, [windowId, handleSubmit, registerWindow, unregisterWindow]);
|
||||
|
||||
// Keep ref in sync with state for external consumers
|
||||
useEffect(() => {
|
||||
isStreamingRef.current = isStreaming;
|
||||
|
||||
// Dispatch execution state change event so global coordinator can update counts
|
||||
const playgroundEventBus = getPlaygroundEventBus();
|
||||
playgroundEventBus.dispatchEvent(
|
||||
new CustomEvent(PLAYGROUND_EVENTS.WINDOW_EXECUTION_STATE_CHANGE, {
|
||||
detail: {
|
||||
windowId: windowId || MULTI_WINDOW_CONFIG.DEFAULT_WINDOW_ID,
|
||||
isStreaming,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}, [windowId, isStreaming]);
|
||||
|
||||
return (
|
||||
<PlaygroundContext.Provider
|
||||
value={{
|
||||
|
||||
@@ -9,8 +9,16 @@ import {
|
||||
type UIModelParams,
|
||||
} from "@langfuse/shared";
|
||||
import { type ModelParamsContext } from "@/src/components/ModelParameters";
|
||||
import { getModelNameKey, getModelProviderKey } from "../storage/keys";
|
||||
|
||||
export const useModelParams = () => {
|
||||
/**
|
||||
* Hook for managing model parameters with window isolation support
|
||||
* Supports both single-window and multi-window scenarios through window-specific localStorage keys
|
||||
*
|
||||
* @param windowId - Optional window identifier for state isolation. Defaults to "default" for backward compatibility
|
||||
* @returns Object with model parameters state and management functions
|
||||
*/
|
||||
export const useModelParams = (windowId?: string) => {
|
||||
const [modelParams, setModelParams] = useState<UIModelParams>({
|
||||
...getDefaultAdapterParams(LLMAdapter.OpenAI),
|
||||
provider: { value: "", enabled: true },
|
||||
@@ -26,13 +34,17 @@ export const useModelParams = () => {
|
||||
{ enabled: Boolean(projectId) },
|
||||
);
|
||||
|
||||
// Generate window-specific localStorage keys
|
||||
const modelNameKey = getModelNameKey(windowId ?? "");
|
||||
const modelProviderKey = getModelProviderKey(windowId ?? "");
|
||||
|
||||
const [persistedModelName, setPersistedModelName] = useLocalStorage<
|
||||
string | null
|
||||
>("llmModelName", null);
|
||||
>(modelNameKey, null);
|
||||
|
||||
const [persistedModelProvider, setPersistedModelProvider] = useLocalStorage<
|
||||
string | null
|
||||
>("llmModelProvider", null);
|
||||
>(modelProviderKey, null);
|
||||
|
||||
const availableProviders = useMemo(() => {
|
||||
const adapter = availableLLMApiKeys.data?.data ?? [];
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { MULTI_WINDOW_CONFIG } from "../types";
|
||||
import {
|
||||
getWindowIds,
|
||||
saveWindowIds,
|
||||
cloneWindowState,
|
||||
removeWindowState,
|
||||
clearAllPlaygroundData,
|
||||
} from "../storage/windowStorage";
|
||||
|
||||
/**
|
||||
* Hook to persist window IDs across page refreshes.
|
||||
* Manages the list of active playground windows by orchestrating with storage utilities.
|
||||
*/
|
||||
export function usePersistedWindowIds() {
|
||||
const [windowIds, setWindowIds] = useState<string[]>([]);
|
||||
const [isLoaded, setIsLoaded] = useState(false);
|
||||
|
||||
// Load window IDs from storage on initial mount
|
||||
useEffect(() => {
|
||||
const savedWindowIds = getWindowIds();
|
||||
setWindowIds(savedWindowIds ?? [uuidv4()]);
|
||||
setIsLoaded(true);
|
||||
}, []);
|
||||
|
||||
// Save window IDs to storage whenever they change
|
||||
useEffect(() => {
|
||||
if (isLoaded) {
|
||||
saveWindowIds(windowIds);
|
||||
}
|
||||
}, [windowIds, isLoaded]);
|
||||
|
||||
/**
|
||||
* Adds a window with a specific ID, if it doesn't already exist.
|
||||
* @param windowId - The ID of the window to add.
|
||||
* @returns The windowId if it was successfully added or already existed, otherwise null.
|
||||
*/
|
||||
const addWindowWithId = useCallback(
|
||||
(windowId: string) => {
|
||||
if (windowIds.includes(windowId)) {
|
||||
return windowId;
|
||||
}
|
||||
if (windowIds.length >= MULTI_WINDOW_CONFIG.MAX_WINDOWS) {
|
||||
console.warn(
|
||||
`Maximum window limit of ${MULTI_WINDOW_CONFIG.MAX_WINDOWS} reached`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
setWindowIds((prev) => [...prev, windowId]);
|
||||
return windowId;
|
||||
},
|
||||
[windowIds],
|
||||
);
|
||||
|
||||
/**
|
||||
* Adds a new window, copying the state from a source window.
|
||||
* @param sourceWindowId - Optional ID of the window to copy. Defaults to the most recent window.
|
||||
* @returns The ID of the newly created window, or null if the window limit is reached.
|
||||
*/
|
||||
const addWindowWithCopy = useCallback(
|
||||
(sourceWindowId?: string) => {
|
||||
if (windowIds.length >= MULTI_WINDOW_CONFIG.MAX_WINDOWS) {
|
||||
console.warn(
|
||||
`Maximum window limit of ${MULTI_WINDOW_CONFIG.MAX_WINDOWS} reached`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
const newWindowId = uuidv4();
|
||||
const sourceId = sourceWindowId ?? windowIds[windowIds.length - 1];
|
||||
|
||||
if (sourceId) {
|
||||
cloneWindowState(sourceId, newWindowId);
|
||||
}
|
||||
|
||||
setWindowIds((prev) => [...prev, newWindowId]);
|
||||
return newWindowId;
|
||||
},
|
||||
[windowIds],
|
||||
);
|
||||
|
||||
/**
|
||||
* Removes a window and its associated storage.
|
||||
* @param windowId - The ID of the window to remove.
|
||||
*/
|
||||
const removeWindowId = useCallback(
|
||||
(windowId: string) => {
|
||||
if (windowIds.length <= 1) {
|
||||
console.warn("Cannot remove the last remaining window");
|
||||
return;
|
||||
}
|
||||
|
||||
removeWindowState(windowId);
|
||||
setWindowIds((prev) => prev.filter((id) => id !== windowId));
|
||||
},
|
||||
[windowIds.length],
|
||||
);
|
||||
|
||||
/**
|
||||
* Clears all playground data from storage and resets to a single window.
|
||||
*/
|
||||
const clearAllCache = useCallback((windowId?: string) => {
|
||||
clearAllPlaygroundData();
|
||||
setWindowIds([windowId ?? uuidv4()]);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
windowIds,
|
||||
isLoaded,
|
||||
addWindowWithId,
|
||||
addWindowWithCopy,
|
||||
removeWindowId,
|
||||
clearAllCache,
|
||||
};
|
||||
}
|
||||
@@ -1,26 +1,55 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { type PlaygroundCache } from "../types";
|
||||
import { getCacheKey } from "../storage/keys";
|
||||
|
||||
const playgroundCacheKey = "playgroundCache";
|
||||
|
||||
export default function usePlaygroundCache() {
|
||||
/**
|
||||
* Hook for managing playground cache with window isolation support.
|
||||
* Relies on storage utilities for key generation and persistence.
|
||||
*
|
||||
* @param windowId - Optional window identifier for state isolation.
|
||||
* @returns Object with playgroundCache state and setPlaygroundCache function.
|
||||
*/
|
||||
export default function usePlaygroundCache(windowId?: string) {
|
||||
const [cache, setCache] = useState<PlaygroundCache>(null);
|
||||
|
||||
const playgroundCacheKey = getCacheKey(windowId ?? "");
|
||||
|
||||
/**
|
||||
* Set playground cache for this specific window.
|
||||
* Stores the cache in sessionStorage with a window-specific key.
|
||||
*
|
||||
* @param cache - PlaygroundCache object to store, or null to clear
|
||||
*/
|
||||
const setPlaygroundCache = (cache: PlaygroundCache) => {
|
||||
sessionStorage.setItem(playgroundCacheKey, JSON.stringify(cache));
|
||||
if (cache === null) {
|
||||
sessionStorage.removeItem(playgroundCacheKey);
|
||||
} else {
|
||||
sessionStorage.setItem(playgroundCacheKey, JSON.stringify(cache));
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Load cached state from sessionStorage on mount and when windowId changes
|
||||
* Attempts to parse JSON and handles errors gracefully
|
||||
*/
|
||||
useEffect(() => {
|
||||
const savedCache = sessionStorage.getItem(playgroundCacheKey);
|
||||
if (savedCache) {
|
||||
try {
|
||||
setCache(JSON.parse(savedCache));
|
||||
} catch (e) {
|
||||
console.error("Failed to parse playground cache", e);
|
||||
console.error(
|
||||
`Failed to parse playground cache for window ${windowId}`,
|
||||
e,
|
||||
);
|
||||
// Clear corrupted cache
|
||||
sessionStorage.removeItem(playgroundCacheKey);
|
||||
setCache(null);
|
||||
}
|
||||
} else {
|
||||
setCache(null);
|
||||
}
|
||||
}, []);
|
||||
}, [playgroundCacheKey, windowId]);
|
||||
|
||||
return {
|
||||
playgroundCache: cache,
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
|
||||
/**
|
||||
* Custom hook to track playground window container size and provide responsive breakpoints
|
||||
*
|
||||
* Uses ResizeObserver to monitor the container width and provides boolean flags
|
||||
* for different responsive states based on the playground window size rather than
|
||||
* the browser window size.
|
||||
*/
|
||||
export const usePlaygroundWindowSize = () => {
|
||||
const [width, setWidth] = useState(0);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
const resizeObserver = new ResizeObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
setWidth(entry.contentRect.width);
|
||||
}
|
||||
});
|
||||
|
||||
resizeObserver.observe(container);
|
||||
|
||||
return () => {
|
||||
resizeObserver.disconnect();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return {
|
||||
containerRef,
|
||||
width,
|
||||
isVeryCompact: width < 423,
|
||||
isCompact: width < 460,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,256 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
type PlaygroundHandle,
|
||||
type WindowCoordinationReturn,
|
||||
PLAYGROUND_EVENTS,
|
||||
} from "../types";
|
||||
|
||||
/**
|
||||
* Playground window registry for coordinating actions across multiple playground windows
|
||||
* This Map stores references to all active playground windows and their handles
|
||||
* Key: windowId, Value: PlaygroundHandle interface
|
||||
*/
|
||||
const playgroundWindowRegistry = new Map<string, PlaygroundHandle>();
|
||||
|
||||
/**
|
||||
* Playground event bus for coordinating actions across playground windows
|
||||
* Uses the native EventTarget API for performance and simplicity
|
||||
*/
|
||||
const playgroundEventBus = new EventTarget();
|
||||
|
||||
/**
|
||||
* Hook for managing global coordination between multiple playground windows
|
||||
* Provides functions to register/unregister windows and execute global actions
|
||||
*
|
||||
* Key features:
|
||||
* - Window registration/unregistration
|
||||
* - Parallel execution of all windows
|
||||
* - Global stop functionality
|
||||
* - Execution status tracking
|
||||
* - Event-based coordination (no React re-renders)
|
||||
*
|
||||
* @returns WindowCoordinationReturn interface with coordination functions
|
||||
*/
|
||||
export const useWindowCoordination = (): WindowCoordinationReturn => {
|
||||
const [isExecutingAll, setIsExecutingAll] = useState(false);
|
||||
const [executionVersion, setExecutionVersion] = useState(0);
|
||||
const executionTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
/**
|
||||
* Register a playground window with the global coordination system
|
||||
* Adds the window to the registry and dispatches a registration event
|
||||
*
|
||||
* @param windowId - Unique identifier for the window
|
||||
* @param handle - PlaygroundHandle interface for the window
|
||||
*/
|
||||
const registerWindow = useCallback(
|
||||
(windowId: string, handle: PlaygroundHandle) => {
|
||||
playgroundWindowRegistry.set(windowId, handle);
|
||||
|
||||
// Dispatch registration event for potential listeners
|
||||
playgroundEventBus.dispatchEvent(
|
||||
new CustomEvent(PLAYGROUND_EVENTS.WINDOW_REGISTERED, {
|
||||
detail: { windowId },
|
||||
}),
|
||||
);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
/**
|
||||
* Unregister a playground window from the global coordination system
|
||||
* Removes the window from the registry and dispatches an unregistration event
|
||||
*
|
||||
* @param windowId - Unique identifier for the window to remove
|
||||
*/
|
||||
const unregisterWindow = useCallback((windowId: string) => {
|
||||
const wasRegistered = playgroundWindowRegistry.has(windowId);
|
||||
playgroundWindowRegistry.delete(windowId);
|
||||
|
||||
if (wasRegistered) {
|
||||
// Dispatch unregistration event for potential listeners
|
||||
playgroundEventBus.dispatchEvent(
|
||||
new CustomEvent(PLAYGROUND_EVENTS.WINDOW_UNREGISTERED, {
|
||||
detail: { windowId },
|
||||
}),
|
||||
);
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Execute all registered playground windows in parallel
|
||||
* Dispatches execute-all event to all windows and manages global execution state
|
||||
*/
|
||||
const executeAllWindows = useCallback(() => {
|
||||
const registeredWindows = Array.from(playgroundWindowRegistry.values());
|
||||
|
||||
if (registeredWindows.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if any windows are already executing
|
||||
const alreadyExecuting = registeredWindows.some((handle) =>
|
||||
handle.getIsStreaming(),
|
||||
);
|
||||
if (alreadyExecuting) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsExecutingAll(true);
|
||||
|
||||
// Clear any existing timeout
|
||||
if (executionTimeoutRef.current) {
|
||||
clearTimeout(executionTimeoutRef.current);
|
||||
}
|
||||
|
||||
// Dispatch execute-all event
|
||||
playgroundEventBus.dispatchEvent(
|
||||
new CustomEvent(PLAYGROUND_EVENTS.EXECUTE_ALL),
|
||||
);
|
||||
|
||||
// Set a timeout to reset the execution state
|
||||
// This provides a fallback in case some windows don't respond
|
||||
executionTimeoutRef.current = setTimeout(() => {
|
||||
setIsExecutingAll(false);
|
||||
}, 30000); // 30 second timeout
|
||||
|
||||
// Monitor execution completion
|
||||
const checkExecutionCompletion = () => {
|
||||
const stillExecuting = Array.from(playgroundWindowRegistry.values()).some(
|
||||
(handle) => handle.getIsStreaming(),
|
||||
);
|
||||
|
||||
if (!stillExecuting) {
|
||||
setIsExecutingAll(false);
|
||||
if (executionTimeoutRef.current) {
|
||||
clearTimeout(executionTimeoutRef.current);
|
||||
executionTimeoutRef.current = null;
|
||||
}
|
||||
} else {
|
||||
// Check again in a short interval
|
||||
setTimeout(checkExecutionCompletion, 500);
|
||||
}
|
||||
};
|
||||
|
||||
// Start monitoring after a short delay to allow windows to start
|
||||
setTimeout(checkExecutionCompletion, 1000);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Stop all currently executing playground windows
|
||||
* Dispatches stop-all event to all windows and resets global execution state
|
||||
*/
|
||||
const stopAllWindows = useCallback(() => {
|
||||
setIsExecutingAll(false);
|
||||
|
||||
// Clear any existing timeout
|
||||
if (executionTimeoutRef.current) {
|
||||
clearTimeout(executionTimeoutRef.current);
|
||||
executionTimeoutRef.current = null;
|
||||
}
|
||||
|
||||
// Dispatch stop-all event
|
||||
playgroundEventBus.dispatchEvent(
|
||||
new CustomEvent(PLAYGROUND_EVENTS.STOP_ALL),
|
||||
);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Get current execution status across all registered windows
|
||||
* Provides a summary of the execution state for display purposes
|
||||
*
|
||||
* @returns String describing current execution status or null if no windows
|
||||
*/
|
||||
const getExecutionStatus = useCallback((): string | null => {
|
||||
const registeredWindows = Array.from(playgroundWindowRegistry.values());
|
||||
|
||||
if (registeredWindows.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const executingCount = registeredWindows.filter((handle) =>
|
||||
handle.getIsStreaming(),
|
||||
).length;
|
||||
const totalCount = registeredWindows.length;
|
||||
|
||||
if (executingCount === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (executingCount === totalCount) {
|
||||
return `Executing all ${totalCount} windows`;
|
||||
}
|
||||
|
||||
return `Executing ${executingCount} of ${totalCount} windows`;
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Cleanup function to clear timeouts when component unmounts
|
||||
* Prevents memory leaks from lingering timeouts
|
||||
*/
|
||||
useEffect(() => {
|
||||
const handleExecutionChange = () => {
|
||||
setExecutionVersion((v) => v + 1);
|
||||
};
|
||||
|
||||
playgroundEventBus.addEventListener(
|
||||
PLAYGROUND_EVENTS.WINDOW_EXECUTION_STATE_CHANGE,
|
||||
handleExecutionChange,
|
||||
);
|
||||
|
||||
return () => {
|
||||
playgroundEventBus.removeEventListener(
|
||||
PLAYGROUND_EVENTS.WINDOW_EXECUTION_STATE_CHANGE,
|
||||
handleExecutionChange,
|
||||
);
|
||||
|
||||
if (executionTimeoutRef.current) {
|
||||
clearTimeout(executionTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
executionVersion;
|
||||
|
||||
return {
|
||||
registerWindow,
|
||||
unregisterWindow,
|
||||
executeAllWindows,
|
||||
stopAllWindows,
|
||||
getExecutionStatus,
|
||||
isExecutingAll,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Utility function to get the current playground window registry
|
||||
* Useful for debugging and testing purposes
|
||||
*
|
||||
* @returns Map of windowId to PlaygroundHandle
|
||||
*/
|
||||
export const getPlaygroundWindowRegistry = (): Map<
|
||||
string,
|
||||
PlaygroundHandle
|
||||
> => {
|
||||
return playgroundWindowRegistry;
|
||||
};
|
||||
|
||||
/**
|
||||
* Utility function to get the playground event bus
|
||||
* Useful for advanced coordination scenarios and testing
|
||||
*
|
||||
* @returns EventTarget instance
|
||||
*/
|
||||
export const getPlaygroundEventBus = (): EventTarget => {
|
||||
return playgroundEventBus;
|
||||
};
|
||||
|
||||
/**
|
||||
* Utility function to get the current window count
|
||||
* Useful for UI components that need to display window statistics
|
||||
*
|
||||
* @returns Number of currently registered windows
|
||||
*/
|
||||
export const getWindowCount = (): number => {
|
||||
return playgroundWindowRegistry.size;
|
||||
};
|
||||
@@ -1,32 +1,193 @@
|
||||
import React, { useCallback } from "react";
|
||||
import { Button } from "@/src/components/ui/button";
|
||||
import { Plus, Play, Loader2 } from "lucide-react";
|
||||
import { ResetPlaygroundButton } from "@/src/features/playground/page/components/ResetPlaygroundButton";
|
||||
import { SaveToPromptButton } from "@/src/features/playground/page/components/SaveToPromptButton";
|
||||
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 Page from "@/src/components/layouts/page";
|
||||
import { PlaygroundProvider } from "@/src/features/playground/page/context";
|
||||
import Playground from "@/src/features/playground/page/playground";
|
||||
import MultiWindowPlayground from "@/src/features/playground/page/components/MultiWindowPlayground";
|
||||
|
||||
/**
|
||||
* PlaygroundPage Component
|
||||
*
|
||||
* Main playground page that provides the multi-window playground experience
|
||||
* for prompt testing and comparison. Manages window state at the page level
|
||||
* to enable header controls integration.
|
||||
*
|
||||
* Key Features:
|
||||
* - Multi-window playground for side-by-side comparison
|
||||
* - Integrated header controls for window management
|
||||
* - Global execution controls (Run All, Stop All)
|
||||
* - Window count display and management
|
||||
* - Reset playground functionality for starting fresh
|
||||
* - Persistent window IDs across page refreshes
|
||||
*
|
||||
* Architecture:
|
||||
* - Page-level window state management
|
||||
* - Header integration with multi-window controls
|
||||
* - Global coordination through useWindowCoordination hook
|
||||
* - Clean single-header design
|
||||
*/
|
||||
export default function PlaygroundPage() {
|
||||
return (
|
||||
<PlaygroundProvider>
|
||||
const { windowIds, isLoaded, addWindowWithCopy, removeWindowId } =
|
||||
usePersistedWindowIds();
|
||||
|
||||
// Global coordination hook for managing window actions
|
||||
const {
|
||||
executeAllWindows,
|
||||
getExecutionStatus,
|
||||
isExecutingAll: globalIsExecutingAll,
|
||||
} = useWindowCoordination();
|
||||
|
||||
/**
|
||||
* Add a new window to the playground
|
||||
* @param sourceWindowId - Optional source window ID to copy state from. If not provided, copies from the most recent window.
|
||||
*/
|
||||
const addWindow = useCallback(
|
||||
(sourceWindowId?: string) => {
|
||||
const newWindowId = addWindowWithCopy(sourceWindowId);
|
||||
if (newWindowId) {
|
||||
console.log(`Added new window: ${newWindowId}`);
|
||||
} else {
|
||||
console.warn("Failed to add new window");
|
||||
}
|
||||
},
|
||||
[addWindowWithCopy],
|
||||
);
|
||||
|
||||
/**
|
||||
* Remove a window from the playground
|
||||
*/
|
||||
const removeWindow = useCallback(
|
||||
(windowId: string) => {
|
||||
removeWindowId(windowId);
|
||||
},
|
||||
[removeWindowId],
|
||||
);
|
||||
|
||||
/**
|
||||
* Handle global execution of all windows
|
||||
*/
|
||||
const handleExecuteAll = useCallback(() => {
|
||||
executeAllWindows();
|
||||
}, [executeAllWindows]);
|
||||
|
||||
// Handle command+enter for "Run All" button
|
||||
useCommandEnter(!globalIsExecutingAll, async () => {
|
||||
executeAllWindows();
|
||||
});
|
||||
|
||||
// Don't render until window IDs are loaded
|
||||
if (!isLoaded) {
|
||||
return (
|
||||
<Page
|
||||
withPadding
|
||||
withPadding={false}
|
||||
headerProps={{
|
||||
title: "Playground",
|
||||
help: {
|
||||
description: "A sandbox to test and iterate your prompts",
|
||||
description:
|
||||
"A sandbox to test and iterate your prompts across multiple windows",
|
||||
href: "https://langfuse.com/docs/playground",
|
||||
},
|
||||
actionButtonsRight: (
|
||||
<>
|
||||
<SaveToPromptButton />
|
||||
<ResetPlaygroundButton />
|
||||
</>
|
||||
),
|
||||
}}
|
||||
>
|
||||
<div className="flex-1 overflow-auto">
|
||||
<Playground />
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<Loader2 className="h-8 w-8 animate-spin" />
|
||||
</div>
|
||||
</Page>
|
||||
</PlaygroundProvider>
|
||||
);
|
||||
}
|
||||
|
||||
// Execution status and control states
|
||||
const executionStatus = globalIsExecutingAll
|
||||
? 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 = {
|
||||
windowIds,
|
||||
isExecutingAll: globalIsExecutingAll,
|
||||
};
|
||||
|
||||
return (
|
||||
<Page
|
||||
scrollable={false}
|
||||
withPadding={false}
|
||||
headerProps={{
|
||||
title: "Playground",
|
||||
help: {
|
||||
description:
|
||||
"A sandbox to test and iterate your prompts across multiple windows",
|
||||
href: "https://langfuse.com/docs/playground",
|
||||
},
|
||||
actionButtonsRight: (
|
||||
<div className="flex flex-nowrap items-center gap-2">
|
||||
{/* Window Count Display - Hidden on mobile */}
|
||||
<div className="hidden items-center gap-2 text-sm text-muted-foreground md:flex">
|
||||
<span className="whitespace-nowrap">
|
||||
{windowIds.length} window
|
||||
{windowIds.length === 1 ? "" : "s"}
|
||||
</span>
|
||||
{executionStatus && (
|
||||
<>
|
||||
<span className="hidden sm:inline">•</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
<span className="hidden whitespace-nowrap sm:inline">
|
||||
{executionStatus}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</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"
|
||||
onClick={handleExecuteAll}
|
||||
disabled={isRunAllDisabled}
|
||||
className="hidden flex-shrink-0 gap-1 md:flex"
|
||||
title="Execute all playground windows simultaneously"
|
||||
>
|
||||
{isRunAllDisabled ? (
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
) : (
|
||||
<Play className="h-3 w-3" />
|
||||
)}
|
||||
<span className="hidden lg:inline">Run All (Ctrl + Enter)</span>
|
||||
</Button>
|
||||
|
||||
{/* Reset Playground Button */}
|
||||
<ResetPlaygroundButton />
|
||||
</div>
|
||||
),
|
||||
}}
|
||||
>
|
||||
<MultiWindowPlayground
|
||||
windowState={windowState}
|
||||
onRemoveWindow={removeWindow}
|
||||
onAddWindow={addWindow}
|
||||
/>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
import { ModelParameters } from "@/src/components/ModelParameters";
|
||||
import { usePlaygroundContext } from "./context";
|
||||
import { Variables } from "./components/Variables";
|
||||
import { MessagePlaceholders } from "./components/MessagePlaceholders";
|
||||
import { Messages } from "./components/Messages";
|
||||
import { PlaygroundTools } from "./components/PlaygroundTools";
|
||||
import { StructuredOutputSchemaSection } from "./components/StructuredOutputSchemaSection";
|
||||
|
||||
export default function Playground() {
|
||||
const playgroundContext = usePlaygroundContext();
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-row space-x-8">
|
||||
<div className="h-full basis-3/4 overflow-auto">
|
||||
<Messages {...playgroundContext} />
|
||||
</div>
|
||||
<div className="max-h-full min-h-0 basis-1/4 pr-2">
|
||||
<div className="flex h-full flex-col gap-4">
|
||||
<div className="mb-4 flex-shrink-0 overflow-y-auto">
|
||||
<ModelParameters {...playgroundContext} />
|
||||
</div>
|
||||
<div className="mb-4 max-h-[25vh] flex-shrink-0 overflow-y-auto">
|
||||
<PlaygroundTools />
|
||||
</div>
|
||||
<div className="mb-4 flex-shrink-0">
|
||||
<StructuredOutputSchemaSection />
|
||||
</div>
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-6">
|
||||
<div className="min-h-0 flex-1">
|
||||
<Variables />
|
||||
</div>
|
||||
<div className="min-h-0 flex-1">
|
||||
<MessagePlaceholders />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { MULTI_WINDOW_CONFIG } from "../types";
|
||||
|
||||
export const WINDOW_IDS_KEY = "playgroundWindowIds";
|
||||
|
||||
const getPrefixedKey = (prefix: string, windowId: string) => {
|
||||
const effectiveWindowId = windowId || MULTI_WINDOW_CONFIG.DEFAULT_WINDOW_ID;
|
||||
if (effectiveWindowId === MULTI_WINDOW_CONFIG.DEFAULT_WINDOW_ID) {
|
||||
return prefix.endsWith("_") ? prefix.slice(0, -1) : prefix;
|
||||
}
|
||||
return `${prefix}${effectiveWindowId}`;
|
||||
};
|
||||
|
||||
export const getCacheKey = (windowId: string) =>
|
||||
getPrefixedKey("langfuse-playgroundCache_", windowId);
|
||||
|
||||
export const getModelNameKey = (windowId: string) =>
|
||||
getPrefixedKey("langfuse-llmModelName_", windowId);
|
||||
|
||||
export const getModelProviderKey = (windowId: string) =>
|
||||
getPrefixedKey("langfuse-llmModelProvider_", windowId);
|
||||
@@ -0,0 +1,129 @@
|
||||
import { type PlaygroundCache } from "../types";
|
||||
import {
|
||||
getCacheKey,
|
||||
getModelNameKey,
|
||||
getModelProviderKey,
|
||||
WINDOW_IDS_KEY,
|
||||
} from "./keys";
|
||||
|
||||
/**
|
||||
* Retrieves the list of window IDs from sessionStorage.
|
||||
* @returns An array of window IDs.
|
||||
*/
|
||||
export const getWindowIds = (): string[] | null => {
|
||||
const saved = sessionStorage.getItem(WINDOW_IDS_KEY);
|
||||
if (saved) {
|
||||
try {
|
||||
const parsed = JSON.parse(saved);
|
||||
if (Array.isArray(parsed) && parsed.length > 0) {
|
||||
return parsed;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to parse saved window IDs, clearing.", e);
|
||||
sessionStorage.removeItem(WINDOW_IDS_KEY);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Saves the list of window IDs to sessionStorage.
|
||||
* @param ids - The array of window IDs to save.
|
||||
*/
|
||||
export const saveWindowIds = (ids: string[]): void => {
|
||||
sessionStorage.setItem(WINDOW_IDS_KEY, JSON.stringify(ids));
|
||||
};
|
||||
|
||||
/**
|
||||
* Retrieves the cached state for a specific window.
|
||||
* @param windowId - The ID of the window.
|
||||
* @returns The cached state or null if not found.
|
||||
*/
|
||||
export const getWindowState = (windowId: string): PlaygroundCache | null => {
|
||||
const key = getCacheKey(windowId);
|
||||
const cachedState = sessionStorage.getItem(key);
|
||||
if (!cachedState) return null;
|
||||
try {
|
||||
return JSON.parse(cachedState) as PlaygroundCache;
|
||||
} catch (error) {
|
||||
console.error(`Failed to parse cache for window ${windowId}:`, error);
|
||||
sessionStorage.removeItem(key);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Clones the entire state (cache and model params) from a source window to a target window.
|
||||
* @param sourceWindowId - The ID of the window to copy from.
|
||||
* @param targetWindowId - The ID of the window to copy to.
|
||||
*/
|
||||
export const cloneWindowState = (
|
||||
sourceWindowId: string,
|
||||
targetWindowId: string,
|
||||
): void => {
|
||||
try {
|
||||
// 1. Clone sessionStorage (cache)
|
||||
const sourceState = getWindowState(sourceWindowId);
|
||||
if (sourceState) {
|
||||
const clonedState = JSON.parse(JSON.stringify(sourceState));
|
||||
sessionStorage.setItem(
|
||||
getCacheKey(targetWindowId),
|
||||
JSON.stringify(clonedState),
|
||||
);
|
||||
}
|
||||
|
||||
const sourceModelName = sessionStorage.getItem(
|
||||
getModelNameKey(sourceWindowId),
|
||||
);
|
||||
if (sourceModelName) {
|
||||
sessionStorage.setItem(getModelNameKey(targetWindowId), sourceModelName);
|
||||
}
|
||||
const sourceModelProvider = sessionStorage.getItem(
|
||||
getModelProviderKey(sourceWindowId),
|
||||
);
|
||||
if (sourceModelProvider) {
|
||||
sessionStorage.setItem(
|
||||
getModelProviderKey(targetWindowId),
|
||||
sourceModelProvider,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Failed to clone window state from ${sourceWindowId} to ${targetWindowId}`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Removes all storage associated with a single window.
|
||||
* @param windowId - The ID of the window to remove.
|
||||
*/
|
||||
export const removeWindowState = (windowId: string): void => {
|
||||
sessionStorage.removeItem(getCacheKey(windowId));
|
||||
sessionStorage.removeItem(getModelNameKey(windowId));
|
||||
sessionStorage.removeItem(getModelProviderKey(windowId));
|
||||
};
|
||||
|
||||
/**
|
||||
* Clears all playground-related data from storage.
|
||||
*/
|
||||
export const clearAllPlaygroundData = (): void => {
|
||||
const sessionKeysToRemove: string[] = [];
|
||||
for (let i = 0; i < sessionStorage.length; i++) {
|
||||
const key = sessionStorage.key(i);
|
||||
if (key?.startsWith("playground")) {
|
||||
sessionKeysToRemove.push(key);
|
||||
}
|
||||
}
|
||||
sessionKeysToRemove.forEach((key) => sessionStorage.removeItem(key));
|
||||
|
||||
const localKeysToRemove: string[] = [];
|
||||
for (let i = 0; i < sessionStorage.length; i++) {
|
||||
const key = sessionStorage.key(i);
|
||||
if (key?.startsWith("llmModel")) {
|
||||
localKeysToRemove.push(key);
|
||||
}
|
||||
}
|
||||
localKeysToRemove.forEach((key) => sessionStorage.removeItem(key));
|
||||
};
|
||||
@@ -34,7 +34,91 @@ export type PlaygroundCache = {
|
||||
Pick<UIModelParams, "provider" | "model">;
|
||||
output?: string | null;
|
||||
promptVariables?: PromptVariable[];
|
||||
// TODO: also cache placeholders?
|
||||
messagePlaceholders?: PlaceholderMessageFillIn[];
|
||||
tools?: PlaygroundTool[];
|
||||
structuredOutputSchema?: PlaygroundSchema | null;
|
||||
} | null;
|
||||
|
||||
// Multi-window types and interfaces
|
||||
|
||||
/**
|
||||
* Handle interface for coordinating actions with individual playground windows
|
||||
* Used by the global coordination system to execute actions on specific windows
|
||||
*/
|
||||
export interface PlaygroundHandle {
|
||||
/** Execute the playground with optional streaming */
|
||||
handleSubmit: (streaming?: boolean) => Promise<void>;
|
||||
/** Stop the current execution */
|
||||
stopExecution: () => void;
|
||||
/** Getter for current streaming state */
|
||||
getIsStreaming: () => boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return type for the useWindowCoordination hook
|
||||
* Provides functions for managing the global window coordination system
|
||||
*/
|
||||
export interface WindowCoordinationReturn {
|
||||
/** Register a window with the global coordination system */
|
||||
registerWindow: (windowId: string, handle: PlaygroundHandle) => void;
|
||||
/** Unregister a window from the global coordination system */
|
||||
unregisterWindow: (windowId: string) => void;
|
||||
/** Execute all registered windows in parallel */
|
||||
executeAllWindows: () => void;
|
||||
/** Stop all currently executing windows */
|
||||
stopAllWindows: () => void;
|
||||
/** Get current execution status across all windows */
|
||||
getExecutionStatus: () => string | null;
|
||||
/** Whether any windows are currently executing */
|
||||
isExecutingAll: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Props for PlaygroundProvider with multi-window support
|
||||
* Extends the existing provider to support window-specific state isolation
|
||||
*/
|
||||
export interface PlaygroundProviderProps {
|
||||
children: React.ReactNode;
|
||||
/** Optional window ID for state isolation. Defaults to "default" for single-window mode */
|
||||
windowId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* State management for the multi-window playground container
|
||||
* Tracks all active windows and global execution state
|
||||
*/
|
||||
export interface MultiWindowState {
|
||||
/** Array of window IDs currently active */
|
||||
windowIds: string[];
|
||||
/** Whether a global "execute all" operation is in progress */
|
||||
isExecutingAll: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Event types for the global coordination system
|
||||
* Custom events dispatched through the EventTarget-based event bus
|
||||
*/
|
||||
export const PLAYGROUND_EVENTS = {
|
||||
EXECUTE_ALL: "playground:execute-all",
|
||||
STOP_ALL: "playground:stop-all",
|
||||
WINDOW_REGISTERED: "playground:window-registered",
|
||||
WINDOW_UNREGISTERED: "playground:window-unregistered",
|
||||
WINDOW_EXECUTION_STATE_CHANGE: "playground:window-execution-state-change",
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Configuration for window limits and validation
|
||||
* Defines constraints for multi-window functionality
|
||||
*/
|
||||
export const MULTI_WINDOW_CONFIG = {
|
||||
/** Maximum number of windows allowed */
|
||||
MAX_WINDOWS: 10,
|
||||
/** Maximum number of windows allowed on mobile/small screens */
|
||||
MAX_WINDOWS_MOBILE: 1,
|
||||
/** Minimum window width in pixels */
|
||||
MIN_WINDOW_WIDTH: 400,
|
||||
/** Mobile breakpoint in pixels (below this, mobile behavior applies) */
|
||||
MOBILE_BREAKPOINT: 768,
|
||||
/** Default window ID for single-window mode */
|
||||
DEFAULT_WINDOW_ID: "default",
|
||||
} as const;
|
||||
|
||||
@@ -90,7 +90,7 @@ export const NewPromptForm: React.FC<NewPromptFormProps> = (props) => {
|
||||
name: initialPrompt?.name ?? (folderPath ? `${folderPath}/` : ""),
|
||||
config: JSON.stringify(initialPrompt?.config?.valueOf(), null, 2) || "{}",
|
||||
isActive: !Boolean(initialPrompt),
|
||||
commitMessage: initialPrompt?.commitMessage ?? undefined,
|
||||
commitMessage: undefined,
|
||||
};
|
||||
|
||||
const form = useForm({
|
||||
|
||||
@@ -18,8 +18,8 @@ const NewPromptBaseSchema = z.object({
|
||||
commitMessage: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1)
|
||||
.max(COMMIT_MESSAGE_MAX_LENGTH)
|
||||
.transform((val) => (val === "" ? undefined : val))
|
||||
.optional(),
|
||||
});
|
||||
|
||||
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
StringParam,
|
||||
useQueryParams,
|
||||
withDefault,
|
||||
useQueryParam,
|
||||
} from "use-query-params";
|
||||
import { createColumnHelper } from "@tanstack/react-table";
|
||||
import { joinTableCoreAndMetrics } from "@/src/components/table/utils/joinTableCoreAndMetrics";
|
||||
@@ -33,6 +32,7 @@ import {
|
||||
} from "@/src/components/ui/breadcrumb";
|
||||
import { Slash, Folder, Home } from "lucide-react";
|
||||
import { promptsTableColsWithOptions } from "@langfuse/shared";
|
||||
import { useFullTextSearch } from "@/src/components/table/use-cases/useFullTextSearch";
|
||||
|
||||
type PromptTableRow = {
|
||||
id: string;
|
||||
@@ -101,10 +101,8 @@ export function PromptTable() {
|
||||
folder: StringParam,
|
||||
});
|
||||
|
||||
const [searchQuery, setSearchQuery] = useQueryParam(
|
||||
"search",
|
||||
withDefault(StringParam, null),
|
||||
);
|
||||
const { searchQuery, searchType, setSearchQuery, setSearchType } =
|
||||
useFullTextSearch();
|
||||
|
||||
// Reset pagination when search query changes
|
||||
useEffect(() => {
|
||||
@@ -132,6 +130,7 @@ export function PromptTable() {
|
||||
orderBy: orderByState,
|
||||
pathPrefix: currentFolderPath,
|
||||
searchQuery: searchQuery || undefined,
|
||||
searchType: searchType,
|
||||
},
|
||||
{
|
||||
enabled: Boolean(projectId),
|
||||
@@ -464,8 +463,13 @@ export function PromptTable() {
|
||||
updateQuery: useDebounce(setSearchQuery, 300),
|
||||
currentQuery: searchQuery ?? undefined,
|
||||
tableAllowsFullTextSearch: true,
|
||||
setSearchType: undefined,
|
||||
searchType: undefined,
|
||||
setSearchType,
|
||||
searchType,
|
||||
customDropdownLabels: {
|
||||
metadata: "Names, Tags",
|
||||
fullText: "Full Text",
|
||||
},
|
||||
hidePerformanceWarning: true,
|
||||
}}
|
||||
/>
|
||||
<DataTable
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
promptsTableCols,
|
||||
PromptType,
|
||||
StringNoHTMLNonEmpty,
|
||||
TracingSearchType,
|
||||
} from "@langfuse/shared";
|
||||
import { orderBy, singleFilter } from "@langfuse/shared";
|
||||
import {
|
||||
@@ -36,6 +37,34 @@ import { aggregateScores } from "@/src/features/scores/lib/aggregateScores";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { promptChangeEventSourcing } from "@/src/features/prompts/server/promptChangeEventSourcing";
|
||||
|
||||
const buildPromptSearchFilter = (
|
||||
searchQuery: string | undefined | null,
|
||||
searchType?: TracingSearchType[],
|
||||
): Prisma.Sql => {
|
||||
if (searchQuery === undefined || searchQuery === null || searchQuery === "") {
|
||||
return Prisma.empty;
|
||||
}
|
||||
|
||||
const q = searchQuery;
|
||||
const types = searchType ?? ["id"];
|
||||
const searchConditions: Prisma.Sql[] = [];
|
||||
|
||||
if (types.includes("id")) {
|
||||
searchConditions.push(Prisma.sql`p.name ILIKE ${`%${q}%`}`);
|
||||
searchConditions.push(
|
||||
Prisma.sql`EXISTS (SELECT 1 FROM UNNEST(p.tags) AS tag WHERE tag ILIKE ${`%${q}%`})`,
|
||||
);
|
||||
}
|
||||
|
||||
if (types.includes("content")) {
|
||||
searchConditions.push(Prisma.sql`p.prompt::text ILIKE ${`%${q}%`}`);
|
||||
}
|
||||
|
||||
return searchConditions.length > 0
|
||||
? Prisma.sql` AND (${Prisma.join(searchConditions, " OR ")})`
|
||||
: Prisma.empty;
|
||||
};
|
||||
|
||||
const PromptFilterOptions = z.object({
|
||||
projectId: z.string(), // Required for protectedProjectProcedure
|
||||
filter: z.array(singleFilter),
|
||||
@@ -43,6 +72,7 @@ const PromptFilterOptions = z.object({
|
||||
...paginationZod,
|
||||
pathPrefix: z.string().optional(),
|
||||
searchQuery: z.string().optional(),
|
||||
searchType: z.array(TracingSearchType).optional(),
|
||||
});
|
||||
|
||||
export const promptRouter = createTRPCRouter({
|
||||
@@ -97,19 +127,10 @@ export const promptRouter = createTRPCRouter({
|
||||
})()
|
||||
: Prisma.empty;
|
||||
|
||||
const searchFilter =
|
||||
input.searchQuery !== undefined &&
|
||||
input.searchQuery !== null &&
|
||||
input.searchQuery !== ""
|
||||
? (() => {
|
||||
const q = input.searchQuery;
|
||||
return Prisma.sql` AND (
|
||||
p.name ILIKE ${`%${q}%`}
|
||||
OR EXISTS (SELECT 1 FROM UNNEST(p.tags) AS tag WHERE tag ILIKE ${`%${q}%`})
|
||||
OR p.prompt::text ILIKE ${`%${q}%`}
|
||||
)`;
|
||||
})()
|
||||
: Prisma.empty;
|
||||
const searchFilter = buildPromptSearchFilter(
|
||||
input.searchQuery,
|
||||
input.searchType,
|
||||
);
|
||||
|
||||
const [prompts, promptCount] = await Promise.all([
|
||||
// prompts
|
||||
@@ -159,7 +180,15 @@ export const promptRouter = createTRPCRouter({
|
||||
};
|
||||
}),
|
||||
count: protectedProjectProcedure
|
||||
.input(z.object({ projectId: z.string() }))
|
||||
.input(
|
||||
z.object({
|
||||
projectId: z.string(),
|
||||
searchQuery: z.string().optional(),
|
||||
searchType: z.array(TracingSearchType).optional(),
|
||||
pathPrefix: z.string().optional(),
|
||||
filter: z.array(singleFilter).optional(),
|
||||
}),
|
||||
)
|
||||
.query(async ({ input, ctx }) => {
|
||||
throwIfNoProjectAccess({
|
||||
session: ctx.session,
|
||||
@@ -167,14 +196,37 @@ export const promptRouter = createTRPCRouter({
|
||||
scope: "prompts:read",
|
||||
});
|
||||
|
||||
const filterCondition = input.filter
|
||||
? tableColumnsToSqlFilterAndPrefix(
|
||||
input.filter,
|
||||
promptsTableCols,
|
||||
"prompts",
|
||||
)
|
||||
: Prisma.empty;
|
||||
|
||||
const pathFilter = input.pathPrefix
|
||||
? (() => {
|
||||
const prefix = input.pathPrefix;
|
||||
return Prisma.sql` AND (p.name LIKE ${`${prefix}/%`} OR p.name = ${prefix})`;
|
||||
})()
|
||||
: Prisma.empty;
|
||||
|
||||
const searchFilter = buildPromptSearchFilter(
|
||||
input.searchQuery,
|
||||
input.searchType,
|
||||
);
|
||||
|
||||
const count = await ctx.prisma.$queryRaw<Array<{ totalCount: bigint }>>(
|
||||
generatePromptQuery(
|
||||
Prisma.sql` count(*) AS "totalCount"`,
|
||||
input.projectId,
|
||||
Prisma.empty,
|
||||
filterCondition,
|
||||
Prisma.empty,
|
||||
1, // limit
|
||||
0, // page
|
||||
pathFilter,
|
||||
searchFilter,
|
||||
input.pathPrefix,
|
||||
),
|
||||
);
|
||||
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { type jsonSchema } from "@langfuse/shared";
|
||||
import { prisma } from "@langfuse/shared/src/db";
|
||||
import { v4 } from "uuid";
|
||||
import type z from "zod/v4";
|
||||
|
||||
type Json = z.infer<typeof jsonSchema>;
|
||||
|
||||
const isUniqueConstraintError = (error: any): boolean => {
|
||||
return (
|
||||
error.code === "P2002" || // Prisma unique constraint
|
||||
error.message?.includes("duplicate key") ||
|
||||
error.message?.includes("UNIQUE constraint") ||
|
||||
error.message?.includes("violates unique constraint")
|
||||
);
|
||||
};
|
||||
|
||||
export const createOrFetchDatasetRun = async ({
|
||||
projectId,
|
||||
datasetId,
|
||||
name,
|
||||
description,
|
||||
metadata,
|
||||
}: {
|
||||
projectId: string;
|
||||
datasetId: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
metadata?: Json | null;
|
||||
}) => {
|
||||
try {
|
||||
// Attempt optimistic creation
|
||||
const datasetRun = await prisma.datasetRuns.create({
|
||||
data: {
|
||||
id: v4(),
|
||||
datasetId,
|
||||
projectId,
|
||||
name,
|
||||
description: description ?? null,
|
||||
metadata: metadata ?? {},
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
return datasetRun;
|
||||
} catch (error) {
|
||||
// Check if it's a unique constraint violation
|
||||
if (isUniqueConstraintError(error)) {
|
||||
// Fetch existing run
|
||||
const existingRun = await prisma.datasetRuns.findUnique({
|
||||
where: {
|
||||
datasetId_projectId_name: {
|
||||
datasetId,
|
||||
projectId,
|
||||
name: name,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (existingRun) {
|
||||
return existingRun;
|
||||
}
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error("Failed to create or fetch dataset run");
|
||||
};
|
||||
@@ -53,6 +53,8 @@ const APIDatasetRunItem = z
|
||||
})
|
||||
.strict();
|
||||
|
||||
export type APIDatasetRunItem = z.infer<typeof APIDatasetRunItem>;
|
||||
|
||||
const APIDatasetItem = z
|
||||
.object({
|
||||
datasetName: z.string(),
|
||||
@@ -83,7 +85,7 @@ export const transformDbDatasetItemToAPIDatasetItem = (
|
||||
): z.infer<typeof APIDatasetItem> =>
|
||||
removeObjectKeys(dbDatasetItem, ["projectId"]);
|
||||
|
||||
export const transformDbDatasetRunItemToAPIDatasetRunItem = (
|
||||
export const transformDbDatasetRunItemToAPIDatasetRunItemPg = (
|
||||
dbDatasetRunItem: DbDatasetRunItems & { datasetRunName: string },
|
||||
): z.infer<typeof APIDatasetRunItem> =>
|
||||
removeObjectKeys(dbDatasetRunItem, ["projectId"]);
|
||||
|
||||
@@ -2,15 +2,25 @@ import { prisma } from "@langfuse/shared/src/db";
|
||||
import { withMiddlewares } from "@/src/features/public-api/server/withMiddlewares";
|
||||
import { createAuthedProjectAPIRoute } from "@/src/features/public-api/server/createAuthedProjectAPIRoute";
|
||||
import {
|
||||
type APIDatasetRunItem,
|
||||
GetDatasetRunItemsV1Query,
|
||||
GetDatasetRunItemsV1Response,
|
||||
PostDatasetRunItemsV1Body,
|
||||
PostDatasetRunItemsV1Response,
|
||||
transformDbDatasetRunItemToAPIDatasetRunItem,
|
||||
transformDbDatasetRunItemToAPIDatasetRunItemPg,
|
||||
} from "@/src/features/public-api/types/datasets";
|
||||
import { LangfuseNotFoundError, InvalidRequestError } from "@langfuse/shared";
|
||||
import { LangfuseNotFoundError } from "@langfuse/shared";
|
||||
import { addDatasetRunItemsToEvalQueue } from "@/src/features/evals/server/addDatasetRunItemsToEvalQueue";
|
||||
import { getObservationById } from "@langfuse/shared/src/server";
|
||||
import {
|
||||
eventTypes,
|
||||
logger,
|
||||
processEventBatch,
|
||||
executeWithDatasetRunItemsStrategy,
|
||||
DatasetRunItemsOperationType,
|
||||
getObservationById,
|
||||
} from "@langfuse/shared/src/server";
|
||||
import { v4 } from "uuid";
|
||||
import { createOrFetchDatasetRun } from "@/src/features/public-api/server/dataset-runs";
|
||||
|
||||
export default withMiddlewares({
|
||||
POST: createAuthedProjectAPIRoute({
|
||||
@@ -18,19 +28,11 @@ export default withMiddlewares({
|
||||
bodySchema: PostDatasetRunItemsV1Body,
|
||||
responseSchema: PostDatasetRunItemsV1Response,
|
||||
rateLimitResource: "datasets",
|
||||
fn: async ({ body, auth }) => {
|
||||
const {
|
||||
datasetItemId,
|
||||
observationId,
|
||||
traceId,
|
||||
runName,
|
||||
runDescription,
|
||||
metadata,
|
||||
} = body;
|
||||
|
||||
fn: async ({ body, auth, res }) => {
|
||||
/**************
|
||||
* VALIDATION *
|
||||
**************/
|
||||
const { traceId, observationId, datasetItemId } = body;
|
||||
|
||||
const datasetItem = await prisma.datasetItem.findUnique({
|
||||
where: {
|
||||
@@ -40,13 +42,17 @@ export default withMiddlewares({
|
||||
},
|
||||
status: "ACTIVE",
|
||||
},
|
||||
include: {
|
||||
dataset: true,
|
||||
select: {
|
||||
id: true,
|
||||
datasetId: true,
|
||||
input: true,
|
||||
expectedOutput: true,
|
||||
metadata: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!datasetItem) {
|
||||
throw new LangfuseNotFoundError("Dataset item not found or not active");
|
||||
throw new LangfuseNotFoundError("Dataset item not found");
|
||||
}
|
||||
|
||||
let finalTraceId = traceId;
|
||||
@@ -56,7 +62,7 @@ export default withMiddlewares({
|
||||
const observation = await getObservationById({
|
||||
id: observationId,
|
||||
projectId: auth.scope.projectId,
|
||||
fetchWithInputOutput: true,
|
||||
fetchWithInputOutput: false,
|
||||
});
|
||||
if (observationId && !observation) {
|
||||
throw new LangfuseNotFoundError("Observation not found");
|
||||
@@ -65,58 +71,122 @@ export default withMiddlewares({
|
||||
}
|
||||
|
||||
if (!finalTraceId) {
|
||||
throw new InvalidRequestError("No traceId set");
|
||||
throw new LangfuseNotFoundError("Trace not found");
|
||||
}
|
||||
|
||||
/********************
|
||||
* RUN ITEM CREATION *
|
||||
* RUN CREATION *
|
||||
********************/
|
||||
|
||||
const run = await prisma.datasetRuns.upsert({
|
||||
where: {
|
||||
datasetId_projectId_name: {
|
||||
datasetId: datasetItem.datasetId,
|
||||
name: runName,
|
||||
projectId: auth.scope.projectId,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
name: runName,
|
||||
description: runDescription ?? undefined,
|
||||
datasetId: datasetItem.datasetId,
|
||||
metadata: metadata ?? undefined,
|
||||
projectId: auth.scope.projectId,
|
||||
},
|
||||
update: {
|
||||
metadata: metadata ?? undefined,
|
||||
description: runDescription ?? undefined,
|
||||
},
|
||||
});
|
||||
|
||||
const runItem = await prisma.datasetRunItems.create({
|
||||
data: {
|
||||
datasetItemId,
|
||||
traceId: finalTraceId,
|
||||
observationId,
|
||||
datasetRunId: run.id,
|
||||
projectId: auth.scope.projectId,
|
||||
},
|
||||
});
|
||||
|
||||
/********************
|
||||
* ASYNC RUN ITEM EVAL *
|
||||
********************/
|
||||
|
||||
await addDatasetRunItemsToEvalQueue({
|
||||
const run = await createOrFetchDatasetRun({
|
||||
name: body.runName,
|
||||
description: body.runDescription ?? undefined,
|
||||
metadata: body.metadata ?? undefined,
|
||||
projectId: auth.scope.projectId,
|
||||
datasetItemId,
|
||||
traceId: finalTraceId,
|
||||
observationId: observationId ?? undefined,
|
||||
datasetId: datasetItem.datasetId,
|
||||
});
|
||||
|
||||
return transformDbDatasetRunItemToAPIDatasetRunItem({
|
||||
...runItem,
|
||||
datasetRunName: run.name,
|
||||
const runItemId = v4();
|
||||
|
||||
return await executeWithDatasetRunItemsStrategy({
|
||||
input: body,
|
||||
operationType: DatasetRunItemsOperationType.WRITE,
|
||||
postgresExecution: async () => {
|
||||
/********************
|
||||
* RUN ITEM CREATION *
|
||||
********************/
|
||||
|
||||
const runItem = await prisma.datasetRunItems.create({
|
||||
data: {
|
||||
id: runItemId,
|
||||
datasetItemId,
|
||||
traceId: finalTraceId,
|
||||
observationId: observationId ?? undefined,
|
||||
datasetRunId: run.id,
|
||||
projectId: auth.scope.projectId,
|
||||
},
|
||||
});
|
||||
|
||||
/********************
|
||||
* ASYNC RUN ITEM EVAL *
|
||||
********************/
|
||||
|
||||
await addDatasetRunItemsToEvalQueue({
|
||||
projectId: auth.scope.projectId,
|
||||
datasetItemId,
|
||||
traceId: finalTraceId,
|
||||
observationId: observationId ?? undefined,
|
||||
});
|
||||
|
||||
return transformDbDatasetRunItemToAPIDatasetRunItemPg({
|
||||
...runItem,
|
||||
datasetRunName: run.name,
|
||||
});
|
||||
},
|
||||
clickhouseExecution: async () => {
|
||||
/********************
|
||||
* RUN ITEM CREATION *
|
||||
********************/
|
||||
|
||||
const createdAt = new Date();
|
||||
|
||||
const event = {
|
||||
id: runItemId,
|
||||
type: eventTypes.DATASET_RUN_ITEM_CREATE,
|
||||
timestamp: new Date().toISOString(),
|
||||
body: {
|
||||
id: runItemId,
|
||||
traceId: finalTraceId,
|
||||
observationId: observationId ?? undefined,
|
||||
error: null,
|
||||
createdAt: createdAt.toISOString(),
|
||||
datasetId: datasetItem.datasetId,
|
||||
runId: run.id,
|
||||
datasetItemId: datasetItem.id,
|
||||
},
|
||||
};
|
||||
// note: currently we do not accept user defined ids for dataset run items
|
||||
const ingestionResult = await processEventBatch([event], auth, {
|
||||
isLangfuseInternal: true,
|
||||
});
|
||||
if (ingestionResult.errors.length > 0) {
|
||||
const error = ingestionResult.errors[0];
|
||||
res
|
||||
.status(error.status)
|
||||
.json({ message: error.error ?? error.message });
|
||||
// We will still return the mock dataset run item in the response for now. Logs are to be monitored.
|
||||
}
|
||||
if (ingestionResult.successes.length !== 1) {
|
||||
logger.error("Failed to create dataset run item", {
|
||||
result: ingestionResult,
|
||||
});
|
||||
throw new Error("Failed to create dataset run item");
|
||||
}
|
||||
|
||||
/********************
|
||||
* ASYNC RUN ITEM EVAL *
|
||||
********************/
|
||||
|
||||
await addDatasetRunItemsToEvalQueue({
|
||||
projectId: auth.scope.projectId,
|
||||
datasetItemId: datasetItem.id,
|
||||
traceId: finalTraceId,
|
||||
observationId: observationId ?? undefined,
|
||||
});
|
||||
|
||||
const mockDatasetRunItem: APIDatasetRunItem = {
|
||||
id: event.body.id,
|
||||
datasetRunId: run.id,
|
||||
datasetRunName: run.name,
|
||||
datasetItemId: datasetItem.id,
|
||||
traceId: finalTraceId,
|
||||
observationId: observationId ?? null,
|
||||
createdAt: createdAt,
|
||||
updatedAt: createdAt,
|
||||
};
|
||||
|
||||
return mockDatasetRunItem;
|
||||
},
|
||||
});
|
||||
},
|
||||
}),
|
||||
@@ -124,7 +194,6 @@ export default withMiddlewares({
|
||||
name: "Get Dataset Run Items",
|
||||
querySchema: GetDatasetRunItemsV1Query,
|
||||
responseSchema: GetDatasetRunItemsV1Response,
|
||||
rateLimitResource: "datasets",
|
||||
fn: async ({ query, auth }) => {
|
||||
const { datasetId, runName, ...pagination } = query;
|
||||
|
||||
@@ -177,7 +246,7 @@ export default withMiddlewares({
|
||||
|
||||
return {
|
||||
data: datasetRunItems.map((runItem) =>
|
||||
transformDbDatasetRunItemToAPIDatasetRunItem({
|
||||
transformDbDatasetRunItemToAPIDatasetRunItemPg({
|
||||
...runItem,
|
||||
datasetRunName: datasetRun.name,
|
||||
}),
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
GetDatasetRunV1Response,
|
||||
DeleteDatasetRunV1Query,
|
||||
DeleteDatasetRunV1Response,
|
||||
transformDbDatasetRunItemToAPIDatasetRunItem,
|
||||
transformDbDatasetRunItemToAPIDatasetRunItemPg,
|
||||
transformDbDatasetRunToAPIDatasetRun,
|
||||
} from "@/src/features/public-api/types/datasets";
|
||||
import { withMiddlewares } from "@/src/features/public-api/server/withMiddlewares";
|
||||
@@ -55,7 +55,7 @@ export default withMiddlewares({
|
||||
...item,
|
||||
datasetRunName: run.name,
|
||||
}))
|
||||
.map(transformDbDatasetRunItemToAPIDatasetRunItem),
|
||||
.map(transformDbDatasetRunItemToAPIDatasetRunItemPg),
|
||||
};
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { prisma } from "@langfuse/shared/src/db";
|
||||
import { withMiddlewares } from "@/src/features/public-api/server/withMiddlewares";
|
||||
import { clearModelCacheForProject } from "@langfuse/shared/src/server";
|
||||
import { createAuthedProjectAPIRoute } from "@/src/features/public-api/server/createAuthedProjectAPIRoute";
|
||||
import {
|
||||
GetModelsV1Query,
|
||||
@@ -130,6 +131,9 @@ export default withMiddlewares({
|
||||
return createdModel;
|
||||
});
|
||||
|
||||
// Clear model cache for the project after successful creation
|
||||
await clearModelCacheForProject(auth.scope.projectId);
|
||||
|
||||
return prismaToApiModelDefinition({
|
||||
...model,
|
||||
Price: (["inputPrice", "outputPrice", "totalPrice"] as const)
|
||||
|
||||
@@ -12,7 +12,7 @@ export default async function handler(
|
||||
) {
|
||||
await runMiddleware(req, res, cors);
|
||||
|
||||
if (!["GET", "DELETE"].includes(req.method || "")) {
|
||||
if (!["GET", "DELETE", "PATCH"].includes(req.method || "")) {
|
||||
logger.error(
|
||||
`Method not allowed for ${req.method} on /api/public/scim/Users/[id]`,
|
||||
);
|
||||
@@ -74,6 +74,8 @@ export default async function handler(
|
||||
// Route to the appropriate handler based on HTTP method
|
||||
try {
|
||||
switch (req.method) {
|
||||
case "PATCH":
|
||||
return handlePatch(req, res, orgMembership.user, authCheck.scope.orgId);
|
||||
case "GET":
|
||||
return handleGet(req, res, orgMembership.user);
|
||||
case "DELETE":
|
||||
@@ -133,6 +135,111 @@ async function handleGet(
|
||||
});
|
||||
}
|
||||
|
||||
// PATCH - Update user details (Use only for deprovisioning for now)
|
||||
// Payload is a string like: "{\"schemas\":[\"urn:ietf:params:scim:api:messages:2.0:PatchOp\"],\"Operations\":[{\"op\":\"replace\",\"value\":{\"active\":false}}]}"
|
||||
async function handlePatch(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse,
|
||||
user: User,
|
||||
orgId: string,
|
||||
) {
|
||||
let body = req.body;
|
||||
|
||||
// Check if body is a string and parse it
|
||||
if (typeof body === "string") {
|
||||
try {
|
||||
body = JSON.parse(body);
|
||||
} catch (error) {
|
||||
logger.warn("Failed to parse JSON body", error);
|
||||
return res.status(400).json({
|
||||
schemas: ["urn:ietf:params:scim:api:messages:2.0:Error"],
|
||||
detail: "Invalid JSON body",
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Validate the request body
|
||||
if (
|
||||
!body.schemas ||
|
||||
!Array.isArray(body.schemas) ||
|
||||
!body.schemas.includes("urn:ietf:params:scim:api:messages:2.0:PatchOp")
|
||||
) {
|
||||
logger.warn(
|
||||
"Invalid request body. Must include 'schemas' with 'urn:ietf:params:scim:api:messages:2.0:PatchOp'.",
|
||||
body,
|
||||
);
|
||||
return res.status(400).json({
|
||||
schemas: ["urn:ietf:params:scim:api:messages:2.0:Error"],
|
||||
detail:
|
||||
"Invalid request body. Must include 'schemas' with 'urn:ietf:params:scim:api:messages:2.0:PatchOp'.",
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
// Check for operations
|
||||
if (!body.Operations || !Array.isArray(body.Operations)) {
|
||||
logger.warn(
|
||||
"Invalid request body. Must include 'Operations' array with at least one operation.",
|
||||
body,
|
||||
);
|
||||
return res.status(400).json({
|
||||
schemas: ["urn:ietf:params:scim:api:messages:2.0:Error"],
|
||||
detail:
|
||||
"Invalid request body. Must include 'Operations' array with at least one operation.",
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
// Process each operation
|
||||
for (const op of body.Operations) {
|
||||
if (
|
||||
op.op === "replace" &&
|
||||
op.value &&
|
||||
typeof op.value.active === "boolean"
|
||||
) {
|
||||
if (op.value.active) {
|
||||
// Provision the user by adding them to the organization
|
||||
await prisma.organizationMembership.upsert({
|
||||
where: {
|
||||
orgId_userId: {
|
||||
orgId: orgId,
|
||||
userId: user.id,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
userId: user.id,
|
||||
orgId: orgId,
|
||||
role: "NONE",
|
||||
},
|
||||
update: {},
|
||||
});
|
||||
} else {
|
||||
// Deprovision the user by removing them from the organization
|
||||
await prisma.organizationMembership.deleteMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
orgId: orgId,
|
||||
},
|
||||
});
|
||||
}
|
||||
} else {
|
||||
logger.error(
|
||||
"Unsupported operation or invalid value in request body. Only 'replace' with 'active' field is supported.",
|
||||
op,
|
||||
);
|
||||
return res.status(400).json({
|
||||
schemas: ["urn:ietf:params:scim:api:messages:2.0:Error"],
|
||||
detail:
|
||||
"Unsupported operation or invalid value in request body. Only 'replace' with 'active' field is supported.",
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return handleGet(req, res, user);
|
||||
}
|
||||
|
||||
// DELETE - Remove user from organization
|
||||
async function handleDelete(
|
||||
req: NextApiRequest,
|
||||
|
||||
@@ -133,9 +133,24 @@ export default async function handler(
|
||||
|
||||
if (req.method === "POST") {
|
||||
try {
|
||||
const { userName, name, password } = req.body;
|
||||
const body = req.body;
|
||||
if (typeof body === "string") {
|
||||
try {
|
||||
req.body = JSON.parse(body);
|
||||
} catch (error) {
|
||||
logger.error("Failed to parse JSON body", error);
|
||||
return res.status(400).json({
|
||||
schemas: ["urn:ietf:params:scim:api:messages:2.0:Error"],
|
||||
detail: "Invalid JSON body",
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const { userName, name, password, displayName } = req.body;
|
||||
|
||||
if (!userName) {
|
||||
logger.warn("userName is required for SCIM user creation");
|
||||
return res.status(400).json({
|
||||
schemas: ["urn:ietf:params:scim:api:messages:2.0:Error"],
|
||||
detail: "userName is required",
|
||||
@@ -154,6 +169,9 @@ export default async function handler(
|
||||
});
|
||||
|
||||
if (existingUser.length > 0) {
|
||||
logger.warn(
|
||||
`User with userName ${userName} already exists in organization ${authCheck.scope.orgId}`,
|
||||
);
|
||||
return res.status(409).json({
|
||||
schemas: ["urn:ietf:params:scim:api:messages:2.0:Error"],
|
||||
detail: "User with this userName already exists",
|
||||
@@ -168,7 +186,7 @@ export default async function handler(
|
||||
},
|
||||
create: {
|
||||
email: userName.toLowerCase(),
|
||||
name: name.formatted,
|
||||
name: name?.formatted || displayName,
|
||||
password: password ? await hashPassword(password) : undefined,
|
||||
},
|
||||
update: {},
|
||||
|
||||
@@ -247,6 +247,10 @@ const Integrations = (props: { projectId: string }) => {
|
||||
scope: "integrations:CRUD",
|
||||
});
|
||||
|
||||
const allowBlobStorageIntegration = useHasEntitlement(
|
||||
"scheduled-blob-exports",
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Header title="Integrations" />
|
||||
@@ -288,6 +292,7 @@ const Integrations = (props: { projectId: string }) => {
|
||||
<ActionButton
|
||||
variant="secondary"
|
||||
hasAccess={hasAccess}
|
||||
hasEntitlement={allowBlobStorageIntegration}
|
||||
href={`/project/${props.projectId}/settings/integrations/blobstorage`}
|
||||
>
|
||||
Configure
|
||||
|
||||
@@ -14,7 +14,10 @@ import {
|
||||
protectedProjectProcedure,
|
||||
} from "@/src/server/api/trpc";
|
||||
import { ModelUsageUnit, paginationZod } from "@langfuse/shared";
|
||||
import { queryClickhouse } from "@langfuse/shared/src/server";
|
||||
import {
|
||||
clearModelCacheForProject,
|
||||
queryClickhouse,
|
||||
} from "@langfuse/shared/src/server";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
const ModelAllOptions = z.object({
|
||||
@@ -221,7 +224,7 @@ export const modelRouter = createTRPCRouter({
|
||||
|
||||
const modelId = providedModelId ?? uuidv4();
|
||||
|
||||
return await ctx.prisma.$transaction(async (tx) => {
|
||||
const result = await ctx.prisma.$transaction(async (tx) => {
|
||||
// Check whether model belongs to project
|
||||
// This check is important to prevent users from updating prices for models that they do not have access to
|
||||
const existingModel = await tx.model.findUnique({
|
||||
@@ -308,6 +311,11 @@ export const modelRouter = createTRPCRouter({
|
||||
|
||||
return upsertedModel;
|
||||
});
|
||||
|
||||
// Clear model cache for the project after successful upsert
|
||||
await clearModelCacheForProject(projectId);
|
||||
|
||||
return result;
|
||||
}),
|
||||
delete: protectedProjectProcedure
|
||||
.input(
|
||||
|
||||
@@ -44,7 +44,7 @@ import {
|
||||
QueueJobs,
|
||||
getScoreMetadataById,
|
||||
deleteScores,
|
||||
traceWithSessionIdExists,
|
||||
getTracesIdentifierForSession,
|
||||
} from "@langfuse/shared/src/server";
|
||||
import { v4 } from "uuid";
|
||||
import { throwIfNoEntitlement } from "@/src/features/entitlements/server/hasEntitlement";
|
||||
@@ -306,11 +306,11 @@ export const scoresRouter = createTRPCRouter({
|
||||
}
|
||||
} else if (inflatedParams.sessionId) {
|
||||
// We consider no longer writing all sessions into postgres, hence we should search for traces with the session id
|
||||
const isSessionReferenced = await traceWithSessionIdExists(
|
||||
const traceIdentifiers = await getTracesIdentifierForSession(
|
||||
input.projectId,
|
||||
inflatedParams.sessionId,
|
||||
);
|
||||
if (!isSessionReferenced) {
|
||||
if (traceIdentifiers.length === 0) {
|
||||
logger.error(
|
||||
`No trace referencing session with id ${inflatedParams.sessionId} in project ${input.projectId} in Clickhouse`,
|
||||
);
|
||||
|
||||
@@ -281,7 +281,7 @@ export const sessionRouter = createTRPCRouter({
|
||||
environment: s.trace_environment,
|
||||
trace_count: Number(s.trace_count),
|
||||
total_observations: Number(s.total_observations),
|
||||
sessionDuration: Number(s.duration) / 1000,
|
||||
sessionDuration: Number(s.duration),
|
||||
inputCost: new Decimal(s.session_input_cost),
|
||||
outputCost: new Decimal(s.session_output_cost),
|
||||
totalCost: new Decimal(s.session_total_cost),
|
||||
@@ -359,20 +359,6 @@ export const sessionRouter = createTRPCRouter({
|
||||
});
|
||||
}
|
||||
}),
|
||||
byId: protectedGetSessionProcedure
|
||||
.input(
|
||||
z.object({
|
||||
projectId: z.string(),
|
||||
sessionId: z.string(),
|
||||
}),
|
||||
)
|
||||
.query(async ({ input, ctx }) => {
|
||||
return await handleGetSessionById({
|
||||
sessionId: input.sessionId,
|
||||
projectId: input.projectId,
|
||||
ctx,
|
||||
});
|
||||
}),
|
||||
byIdWithScores: protectedGetSessionProcedure
|
||||
.input(
|
||||
z.object({
|
||||
|
||||
+6
-6
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "worker",
|
||||
"version": "3.84.0",
|
||||
"version": "3.85.2",
|
||||
"description": "",
|
||||
"license": "MIT",
|
||||
"private": true,
|
||||
@@ -45,8 +45,8 @@
|
||||
"dd-trace": "^5.36.0",
|
||||
"decimal.js": "^10.4.3",
|
||||
"dotenv": "^16.4.5",
|
||||
"exponential-backoff": "^3.1.1",
|
||||
"express": "^4.21.0",
|
||||
"exponential-backoff": "^3.1.2",
|
||||
"express": "^5.1.0",
|
||||
"handlebars": "^4.7.8",
|
||||
"helmet": "^7.1.0",
|
||||
"ioredis": "^5.4.1",
|
||||
@@ -64,10 +64,10 @@
|
||||
"@repo/eslint-config": "workspace:*",
|
||||
"@repo/typescript-config": "workspace:*",
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/express-serve-static-core": "^4.19.3",
|
||||
"@types/express": "^5.0.3",
|
||||
"@types/express-serve-static-core": "^5.0.7",
|
||||
"@types/lodash": "^4.17.10",
|
||||
"@types/node": "^20.11.19",
|
||||
"@types/node": "^20.11.29",
|
||||
"@types/pg": "^8.11.10",
|
||||
"@types/uuid": "^9.0.8",
|
||||
"@typescript-eslint/parser": "^7.12.0",
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { expect, describe, it, beforeEach, afterEach } from "vitest";
|
||||
import { prisma } from "@langfuse/shared/src/db";
|
||||
import { createOrgProjectAndApiKey } from "@langfuse/shared/src/server";
|
||||
import { createOrgProjectAndApiKey, redis } from "@langfuse/shared/src/server";
|
||||
import {
|
||||
findModel,
|
||||
findModelInPostgres,
|
||||
getRedisModelKey,
|
||||
} from "../services/modelMatch";
|
||||
clearModelCacheForProject,
|
||||
} from "@langfuse/shared/src/server";
|
||||
|
||||
describe("modelMatch", () => {
|
||||
describe("findModel", () => {
|
||||
@@ -162,4 +163,75 @@ describe("modelMatch", () => {
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("clearModelCacheForProject", () => {
|
||||
it("should clear all cached models for a project", async () => {
|
||||
const { projectId } = await createOrgProjectAndApiKey();
|
||||
|
||||
// Create and cache multiple models
|
||||
await prisma.model.create({
|
||||
data: {
|
||||
projectId,
|
||||
modelName: "gpt-4",
|
||||
matchPattern: "gpt-4",
|
||||
unit: "TOKENS",
|
||||
inputPrice: "1.0",
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.model.create({
|
||||
data: {
|
||||
projectId,
|
||||
modelName: "gpt-3.5-turbo",
|
||||
matchPattern: "gpt-3.5-turbo",
|
||||
unit: "TOKENS",
|
||||
inputPrice: "0.5",
|
||||
},
|
||||
});
|
||||
|
||||
// Cache both models by finding them
|
||||
await findModel({ projectId, model: "gpt-4" });
|
||||
await findModel({ projectId, model: "gpt-3.5-turbo" });
|
||||
|
||||
// Verify models are cached in Redis
|
||||
const redisKey1 = getRedisModelKey({ projectId, model: "gpt-4" });
|
||||
const redisKey2 = getRedisModelKey({ projectId, model: "gpt-3.5-turbo" });
|
||||
|
||||
const cachedModel1 = await redis?.get(redisKey1);
|
||||
const cachedModel2 = await redis?.get(redisKey2);
|
||||
|
||||
expect(cachedModel1).not.toBeNull();
|
||||
expect(cachedModel2).not.toBeNull();
|
||||
|
||||
// Clear the cache for the project
|
||||
await clearModelCacheForProject(projectId);
|
||||
|
||||
// Verify models are no longer cached
|
||||
const clearedModel1 = await redis?.get(redisKey1);
|
||||
const clearedModel2 = await redis?.get(redisKey2);
|
||||
|
||||
expect(clearedModel1).toBeNull();
|
||||
expect(clearedModel2).toBeNull();
|
||||
});
|
||||
|
||||
it("should clear cached not-found tokens for a project", async () => {
|
||||
const { projectId } = await createOrgProjectAndApiKey();
|
||||
const nonExistentModel = "nonexistent-model";
|
||||
|
||||
// Cache a not-found result
|
||||
await findModel({ projectId, model: nonExistentModel });
|
||||
|
||||
// Verify the not-found token is cached
|
||||
const redisKey = getRedisModelKey({ projectId, model: nonExistentModel });
|
||||
const cachedValue = await redis?.get(redisKey);
|
||||
expect(cachedValue).toBe("LANGFUSE_MODEL_MATCH_NOT_FOUND");
|
||||
|
||||
// Clear the cache for the project
|
||||
await clearModelCacheForProject(projectId);
|
||||
|
||||
// Verify the not-found token is cleared
|
||||
const clearedValue = await redis?.get(redisKey);
|
||||
expect(clearedValue).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -33,6 +33,8 @@ import {
|
||||
import { env } from "./env";
|
||||
import { ingestionQueueProcessorBuilder } from "./queues/ingestionQueue";
|
||||
import { BackgroundMigrationManager } from "./backgroundMigrations/backgroundMigrationManager";
|
||||
import { prisma } from "@langfuse/shared/src/db";
|
||||
import { ClickhouseReadSkipCache } from "./utils/clickhouseReadSkipCache";
|
||||
import { experimentCreateQueueProcessor } from "./queues/experimentQueue";
|
||||
import { traceDeleteProcessor } from "./queues/traceDelete";
|
||||
import { projectDeleteProcessor } from "./queues/projectDelete";
|
||||
@@ -79,6 +81,13 @@ if (env.LANGFUSE_ENABLE_BACKGROUND_MIGRATIONS === "true") {
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize ClickhouseReadSkipCache on container start
|
||||
ClickhouseReadSkipCache.getInstance(prisma)
|
||||
.initialize()
|
||||
.catch((err) => {
|
||||
logger.error("Error initializing ClickhouseReadSkipCache", err);
|
||||
});
|
||||
|
||||
if (env.QUEUE_CONSUMER_TRACE_UPSERT_QUEUE_IS_ENABLED === "true") {
|
||||
WorkerManager.register(
|
||||
QueueName.TraceUpsert,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user