Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ebe85f2a67 | ||
|
|
c7b62adbab | ||
|
|
66d4926fd0 | ||
|
|
b26f5c512f | ||
|
|
9227eaab9c | ||
|
|
3debac082a | ||
|
|
fb43e03171 | ||
|
|
dd04da64ba | ||
|
|
b94cd9880a | ||
|
|
0f2d2b8850 | ||
|
|
69db85cfe4 | ||
|
|
b445471f98 | ||
|
|
2ce014980b | ||
|
|
40ad6761cc | ||
|
|
bd36669c6a | ||
|
|
98cc1bb7a1 | ||
|
|
3dcdbd99b3 | ||
|
|
1a5c5f6383 | ||
|
|
867f6c7484 | ||
|
|
1097f1b8d5 | ||
|
|
a73156bbe5 | ||
|
|
7a9c62b106 | ||
|
|
70cc910ee9 | ||
|
|
64a71f059f | ||
|
|
f5a0c7cfed | ||
|
|
2472d2f6da | ||
|
|
dd0446f4dd | ||
|
|
7263a1554c | ||
|
|
2c25c27b51 | ||
|
|
f2b92e1f23 | ||
|
|
593c0a567c | ||
|
|
ac1f465634 | ||
|
|
083b1357a1 | ||
|
|
30e594e839 | ||
|
|
2b8f583c44 |
@@ -11,7 +11,7 @@ alwaysApply: true
|
||||
## File Structure
|
||||
|
||||
- We generally put all code related to a net-new feature into a folder within web/src/features.
|
||||
- Checkout other features to learn about the common structure.
|
||||
- Check out other features to learn about the common structure.
|
||||
|
||||
## API for frontend features
|
||||
|
||||
|
||||
@@ -182,4 +182,7 @@ To get a project, use the `get_project` capability with the full project name as
|
||||
## Front-end Tips
|
||||
|
||||
### Window Location Handling
|
||||
- Whenever you want to use or do use window.location..., ensure that you also add proper handling for a custom basePath
|
||||
- Whenever you want to use or do use window.location..., ensure that you also add proper handling for a custom basePath
|
||||
|
||||
## TypeScript Best Practices
|
||||
- In TypeScript, if possible, don't use the `any` type
|
||||
|
||||
+1
-1
@@ -26,7 +26,7 @@
|
||||
<a href="https://langfuse.com/roadmap"><strong>路线图</strong></a> ·
|
||||
</div>
|
||||
<br/>
|
||||
<span>Langfuse 使用 <a href="https://github.com/orgs/langfuse/discussions"><strong>Github Discussions</strong></a> 作为支持和功能请求的平台。</span>
|
||||
<span>Langfuse 使用 <a href="https://github.com/orgs/langfuse/discussions"><strong>GitHub Discussions</strong></a> 作为支持和功能请求的平台。</span>
|
||||
<br/>
|
||||
<span><b>我们正在招聘。</b> <a href="https://langfuse.com/careers"><strong>加入我们</strong></a>,从事产品工程和技术市场职位。</span>
|
||||
<br/>
|
||||
|
||||
+1
-1
@@ -26,7 +26,7 @@
|
||||
<a href="https://langfuse.com/roadmap"><strong>ロードマップ</strong></a> ·
|
||||
</div>
|
||||
<br/>
|
||||
<span>Langfuseは、サポートと機能リクエストのために <a href="https://github.com/orgs/langfuse/discussions"><strong>Github Discussions</strong></a> を利用しています。</span>
|
||||
<span>Langfuseは、サポートと機能リクエストのために <a href="https://github.com/orgs/langfuse/discussions"><strong>GitHub Discussions</strong></a> を利用しています。</span>
|
||||
<br/>
|
||||
<span><b>We're hiring.</b> <a href="https://langfuse.com/careers"><strong>チームに加わる</strong></a> (製品エンジニアリングおよびテクニカルGTMのポジション)への応募をお待ちしています。</span>
|
||||
<br/>
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
<a href="https://langfuse.com/roadmap"><strong>Roadmap</strong></a> ·
|
||||
</div>
|
||||
<br/>
|
||||
<span>Langfuse uses <a href="https://github.com/orgs/langfuse/discussions"><strong>Github Discussions</strong></a> for Support and Feature Requests.</span>
|
||||
<span>Langfuse uses <a href="https://github.com/orgs/langfuse/discussions"><strong>GitHub Discussions</strong></a> for Support and Feature Requests.</span>
|
||||
<br/>
|
||||
<span><b>We're hiring.</b> <a href="https://langfuse.com/careers"><strong>Join us</strong></a> in product engineering and technical go-to-market roles.</span>
|
||||
<br/>
|
||||
|
||||
@@ -82,7 +82,7 @@ types:
|
||||
CreateChatPromptRequest:
|
||||
properties:
|
||||
name: string
|
||||
prompt: list<ChatMessage>
|
||||
prompt: list<ChatMessageWithPlaceholders>
|
||||
config: optional<unknown>
|
||||
labels:
|
||||
type: optional<list<string>>
|
||||
@@ -132,6 +132,11 @@ types:
|
||||
type: optional<map<string, unknown>>
|
||||
docs: The dependency resolution graph for the current prompt. Null if prompt has no dependencies.
|
||||
|
||||
ChatMessageWithPlaceholders:
|
||||
union:
|
||||
chatmessage: ChatMessage
|
||||
placeholder: PlaceholderMessage
|
||||
|
||||
ChatMessage:
|
||||
properties:
|
||||
role:
|
||||
@@ -139,6 +144,11 @@ types:
|
||||
content:
|
||||
type: string
|
||||
|
||||
PlaceholderMessage:
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
|
||||
TextPrompt:
|
||||
extends: BasePrompt
|
||||
properties:
|
||||
@@ -147,4 +157,4 @@ types:
|
||||
ChatPrompt:
|
||||
extends: BasePrompt
|
||||
properties:
|
||||
prompt: list<ChatMessage>
|
||||
prompt: list<ChatMessageWithPlaceholders>
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "langfuse",
|
||||
"version": "3.75.3",
|
||||
"version": "3.78.1",
|
||||
"author": "engineering@langfuse.com",
|
||||
"license": "MIT",
|
||||
"private": true,
|
||||
|
||||
@@ -111,7 +111,8 @@ export const DashboardWidgetChartType = {
|
||||
VERTICAL_BAR: "VERTICAL_BAR",
|
||||
PIE: "PIE",
|
||||
NUMBER: "NUMBER",
|
||||
HISTOGRAM: "HISTOGRAM"
|
||||
HISTOGRAM: "HISTOGRAM",
|
||||
PIVOT_TABLE: "PIVOT_TABLE"
|
||||
} as const;
|
||||
export type DashboardWidgetChartType = (typeof DashboardWidgetChartType)[keyof typeof DashboardWidgetChartType];
|
||||
export type Account = {
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
-- Database migration to add PIVOT_TABLE chart type to DashboardWidgetChartType enum
|
||||
-- This enables the creation of pivot table widgets in the dashboard system
|
||||
--
|
||||
-- This migration adds support for tabular data visualization with configurable
|
||||
-- row dimensions and metrics, extending the existing widget types (line charts,
|
||||
-- bar charts, pie charts, etc.) to include pivot table functionality.
|
||||
|
||||
-- AlterEnum
|
||||
ALTER TYPE "DashboardWidgetChartType" ADD VALUE 'PIVOT_TABLE';
|
||||
@@ -871,7 +871,7 @@ model JobExecution {
|
||||
jobConfiguration JobConfiguration @relation(fields: [jobConfigurationId], references: [id], onDelete: Cascade)
|
||||
|
||||
jobTemplateId String? @map("job_template_id")
|
||||
jobTemplate EvalTemplate? @relation(fields: [jobTemplateId], references: [id], onDelete: SetNull)
|
||||
jobTemplate EvalTemplate? @relation(fields: [jobTemplateId], references: [id], onDelete: SetNull, onUpdate: NoAction)
|
||||
|
||||
status JobExecutionStatus
|
||||
startTime DateTime? @map("start_time")
|
||||
@@ -902,7 +902,7 @@ model DefaultLlmModel {
|
||||
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
|
||||
|
||||
projectId String @map("project_id")
|
||||
Project Project @relation(fields: [projectId], references: [id])
|
||||
Project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||
|
||||
llmApiKeyId String @map("llm_api_key_id")
|
||||
LlmApiKey LlmApiKeys @relation("LlmApiKeyId", fields: [llmApiKeyId], references: [id], onDelete: Cascade)
|
||||
@@ -1157,6 +1157,7 @@ enum DashboardWidgetChartType {
|
||||
PIE
|
||||
NUMBER
|
||||
HISTOGRAM
|
||||
PIVOT_TABLE
|
||||
}
|
||||
|
||||
model DashboardWidget {
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
import {
|
||||
PrismaClient,
|
||||
type Project,
|
||||
ScoreDataType,
|
||||
JobConfiguration,
|
||||
JobExecutionStatus,
|
||||
} from "../../src/index";
|
||||
import { hash } from "bcryptjs";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { parseArgs } from "node:util";
|
||||
import { hash } from "bcryptjs";
|
||||
import { v4 } from "uuid";
|
||||
import { getDisplaySecretKey, hashSecretKey, logger } from "../../src/server";
|
||||
import { encrypt } from "../../src/encryption";
|
||||
import { redis } from "../../src/server/redis/redis";
|
||||
import { randomUUID } from "crypto";
|
||||
import {
|
||||
type JobConfiguration,
|
||||
JobExecutionStatus,
|
||||
PrismaClient,
|
||||
type Project,
|
||||
ScoreDataType,
|
||||
} from "../../src/index";
|
||||
import { getDisplaySecretKey, hashSecretKey, logger } from "../../src/server";
|
||||
import { redis } from "../../src/server/redis/redis";
|
||||
import {EVAL_TRACE_COUNT,
|
||||
FAILED_EVAL_TRACE_INTERVAL,
|
||||
SEED_CHAT_ML_PROMPTS,
|
||||
SEED_DATASETS,
|
||||
@@ -27,7 +27,6 @@ import {
|
||||
generateEvalScoreId,
|
||||
generateEvalTraceId,
|
||||
} from "./utils/seed-helpers";
|
||||
import { EVAL_TRACE_COUNT } from "./utils/postgres-seed-constants";
|
||||
|
||||
type ConfigCategory = {
|
||||
label: string;
|
||||
|
||||
@@ -307,6 +307,34 @@ export const SEED_CHAT_ML_PROMPTS = [
|
||||
labels: ["production", "latest"],
|
||||
tags: ["tag1", "tag2"],
|
||||
},
|
||||
{
|
||||
id: `prompt-chat-placeholder`,
|
||||
createdBy: "user-1",
|
||||
prompt: [
|
||||
{
|
||||
role: "system",
|
||||
content:
|
||||
'You are a very enthusiastic Langfuse representative who loves to help people! Langfuse is an open-source observability tool for developers of applications that use Large Language Models (LLMs). Given the following sections from the Langfuse documentation, answer the question using only that information, outputted in markdown format.\n\nPlease follow these guidelines:\n- Refer to the respective links of the documentation and select quality examples\n- Be kind.\n- Include emojis where it makes sense.\n- If the users have problems using Langfuse, tell them to reach out to the founders directly via the chat widget or GitHub at the end of your answer.\n- Answer as markdown, use highlights and paragraphs to structure the text.\n- Do not mention that you are "enthusiastic", the user does not need to know, will feel it from the style of your answers.\n- Only use information that is available in the context, do not make up any code that is not in the context.\n- Always put an empji at the end of the message.',
|
||||
},
|
||||
{
|
||||
type: "placeholder",
|
||||
name: "message_history",
|
||||
},
|
||||
{
|
||||
role: "assistant",
|
||||
content:
|
||||
"Answering in next message based on your instructions only. What is the question?",
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: "{{question}}",
|
||||
},
|
||||
],
|
||||
name: "prompt-chat-ml-with-placeholder",
|
||||
version: 1,
|
||||
labels: ["production", "latest"],
|
||||
tags: ["tag1", "tag2"],
|
||||
},
|
||||
];
|
||||
|
||||
export const SEED_PROMPT_VERSIONS = [
|
||||
|
||||
@@ -105,6 +105,7 @@ const EnvSchema = z.object({
|
||||
.number()
|
||||
.default(80e6), // 80MB
|
||||
LANGFUSE_CLICKHOUSE_DELETION_TIMEOUT_MS: z.coerce.number().default(240_000), // 4 minutes
|
||||
LANGFUSE_SKIP_S3_LIST_FOR_OBSERVATIONS_PROJECT_IDS: z.string().optional(),
|
||||
});
|
||||
|
||||
export const env: z.infer<typeof EnvSchema> =
|
||||
|
||||
+11
-9
@@ -1,15 +1,17 @@
|
||||
import { z } from "zod/v4";
|
||||
import { jsonSchema, PromptNameSchema } from "@langfuse/shared";
|
||||
import type { Prompt } from "@langfuse/shared";
|
||||
import { COMMIT_MESSAGE_MAX_LENGTH } from "@/src/features/prompts/constants";
|
||||
import type { Prompt } from "../../../prisma/generated/types";
|
||||
import { jsonSchema } from "../../utils/zod";
|
||||
import { COMMIT_MESSAGE_MAX_LENGTH } from "./constants";
|
||||
import { PromptChatMessageSchema } from "../../server/llm/types";
|
||||
import { PromptNameSchema } from "./validation";
|
||||
|
||||
export const ChatMessageSchema = z.object({
|
||||
role: z.string(),
|
||||
content: z.string(),
|
||||
});
|
||||
export const SingleChatMessageSchema = PromptChatMessageSchema;
|
||||
export type SingleChatMessage = z.infer<typeof SingleChatMessageSchema>;
|
||||
|
||||
export enum PromptType {
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
Chat = "chat",
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
Text = "text",
|
||||
}
|
||||
|
||||
@@ -41,7 +43,7 @@ const BaseCreateChatPromptSchema = z.object({
|
||||
name: PromptNameSchema,
|
||||
labels: z.array(PromptLabelSchema).default([]),
|
||||
type: z.literal(PromptType.Chat),
|
||||
prompt: z.array(ChatMessageSchema),
|
||||
prompt: z.array(PromptChatMessageSchema),
|
||||
config: jsonSchema.nullable().default({}),
|
||||
tags: z.array(z.string()).nullish(),
|
||||
});
|
||||
@@ -134,7 +136,7 @@ export const BaseChatPromptSchema = z.object({
|
||||
tags: z.array(z.string()),
|
||||
labels: z.array(PromptLabelSchema),
|
||||
type: z.literal(PromptType.Chat),
|
||||
prompt: z.array(ChatMessageSchema),
|
||||
prompt: z.array(PromptChatMessageSchema),
|
||||
config: jsonSchema,
|
||||
});
|
||||
|
||||
@@ -13,6 +13,7 @@ export * from "./utils/json";
|
||||
export * from "./utils/stringChecks";
|
||||
export * from "./utils/objects";
|
||||
export * from "./utils/typeChecks";
|
||||
export * from "./utils/prompts";
|
||||
export * from "./features/entitlements/plans";
|
||||
export * from "./interfaces/rate-limits";
|
||||
export * from "./tableDefinitions/typeHelpers";
|
||||
@@ -43,6 +44,9 @@ export * from "./features/experiments/utils";
|
||||
// prompts
|
||||
export * from "./features/prompts/parsePromptDependencyTags";
|
||||
export * from "./features/prompts/validation";
|
||||
export * from "./features/prompts/types";
|
||||
export * from "./features/prompts/constants";
|
||||
export { compileChatMessages, compileChatMessagesWithIds, isPlaceholder, type MessagePlaceholderValues, type PromptMessage as ServerPromptMessage } from "./server/llm/compileChatMessages";
|
||||
|
||||
// export db types only
|
||||
export * from "@prisma/client";
|
||||
|
||||
@@ -11,6 +11,7 @@ export * from "./auth/gitHubEnterpriseProvider";
|
||||
export * from "./llm/fetchLLMCompletion";
|
||||
export * from "./llm/utils";
|
||||
export * from "./llm/types";
|
||||
export * from "./llm/compileChatMessages";
|
||||
export * from "./utils/DatabaseReadStream";
|
||||
export * from "./utils/transforms";
|
||||
export * from "./clickhouse/client";
|
||||
|
||||
@@ -229,12 +229,21 @@ export const processEventBatch = async (
|
||||
throw new Error("Redis not initialized, aborting event processing");
|
||||
}
|
||||
|
||||
const projectIdsToSkipS3List =
|
||||
env.LANGFUSE_SKIP_S3_LIST_FOR_OBSERVATIONS_PROJECT_IDS?.split(",") ?? [];
|
||||
|
||||
await Promise.all(
|
||||
Object.keys(sortedBatchByEventBodyId).map(async (id) => {
|
||||
const eventData = sortedBatchByEventBodyId[id];
|
||||
const shardingKey = `${authCheck.scope.projectId}-${eventData.eventBodyId}`;
|
||||
const queue = IngestionQueue.getInstance({ shardingKey });
|
||||
|
||||
const shouldSkipS3List =
|
||||
getClickhouseEntityType(eventData.type) === "observation" &&
|
||||
authCheck.scope.projectId !== null &&
|
||||
(projectIdsToSkipS3List.includes(authCheck.scope.projectId) ||
|
||||
source === "otel");
|
||||
|
||||
return queue
|
||||
? queue.add(
|
||||
QueueJobs.IngestionJob,
|
||||
@@ -247,9 +256,7 @@ export const processEventBatch = async (
|
||||
type: eventData.type,
|
||||
eventBodyId: eventData.eventBodyId,
|
||||
fileKey: eventData.key,
|
||||
skipS3List:
|
||||
source === "otel" &&
|
||||
getClickhouseEntityType(eventData.type) === "observation",
|
||||
skipS3List: shouldSkipS3List,
|
||||
},
|
||||
authCheck: authCheck as {
|
||||
validKey: true;
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import { z } from "zod/v4";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { type ChatMessage, type PlaceholderMessage, ChatMessageType, type PromptChatMessageSchema, type ChatMessageWithId, type ChatMessageWithIdNoPlaceholders, ChatMessageSchema } from "./types";
|
||||
|
||||
export type MessagePlaceholderValues = Record<string, ChatMessage[]>;
|
||||
export type PromptMessage = z.infer<typeof PromptChatMessageSchema>;
|
||||
|
||||
export function isPlaceholder(message: PromptMessage): message is PlaceholderMessage {
|
||||
return "type" in message && message.type === ChatMessageType.Placeholder;
|
||||
}
|
||||
|
||||
function validateMessage(message: unknown): message is ChatMessage {
|
||||
return ChatMessageSchema.safeParse(message).success;
|
||||
}
|
||||
|
||||
function replaceTextVariables(
|
||||
content: string,
|
||||
textVariables: Record<string, string>
|
||||
): string {
|
||||
let result = content;
|
||||
for (const [varName, varValue] of Object.entries(textVariables)) {
|
||||
// Create regex that handles optional whitespace around variable name
|
||||
const variablePattern = new RegExp(`{{\\s*${varName}\\s*}}`, "g");
|
||||
result = result.replace(variablePattern, varValue);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function expandPlaceholder(
|
||||
placeholder: PlaceholderMessage,
|
||||
placeholderValues: MessagePlaceholderValues
|
||||
): ChatMessage[] {
|
||||
const replacementMessages = placeholderValues[placeholder.name];
|
||||
|
||||
if (!replacementMessages) {
|
||||
throw new Error(`Missing value for message placeholder: ${placeholder.name}`);
|
||||
}
|
||||
|
||||
if (!Array.isArray(replacementMessages)) {
|
||||
throw new Error(`Placeholder value for '${placeholder.name}' must be an array of messages`);
|
||||
}
|
||||
|
||||
for (const replacementMsg of replacementMessages) {
|
||||
if (!validateMessage(replacementMsg)) {
|
||||
throw new Error(`Invalid message format in placeholder '${placeholder.name}': messages must have 'role' and 'content' properties`);
|
||||
}
|
||||
}
|
||||
return replacementMessages;
|
||||
}
|
||||
|
||||
export function compileChatMessages(
|
||||
messages: PromptMessage[],
|
||||
placeholderValues: MessagePlaceholderValues,
|
||||
textVariables?: Record<string, string>
|
||||
): ChatMessage[] {
|
||||
const expandedMessages = messages.flatMap((message) =>
|
||||
isPlaceholder(message)
|
||||
? expandPlaceholder(message, placeholderValues)
|
||||
: [message as ChatMessage]
|
||||
);
|
||||
|
||||
// substitute text variables
|
||||
if (!textVariables || Object.keys(textVariables).length === 0) {
|
||||
return expandedMessages;
|
||||
}
|
||||
|
||||
return expandedMessages.map((message) => {
|
||||
if (!message.content) {
|
||||
return message;
|
||||
}
|
||||
|
||||
return {
|
||||
...message,
|
||||
content: replaceTextVariables(message.content, textVariables)
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function compileChatMessagesWithIds(
|
||||
messages: ChatMessageWithId[],
|
||||
placeholderValues: Record<string, ChatMessage[]>,
|
||||
textVariables?: Record<string, string>
|
||||
): ChatMessageWithIdNoPlaceholders[] {
|
||||
// TODO: check, is it even important to retain the IDs?
|
||||
const expandedMessages = messages.flatMap((message) => {
|
||||
if (isPlaceholder(message)) {
|
||||
const expandedMsgs = expandPlaceholder(message, placeholderValues);
|
||||
return expandedMsgs.map(msg => ({ ...msg, id: uuidv4() }));
|
||||
} else {
|
||||
// Preserve message IDs for already non-placeholder messages
|
||||
return [message as ChatMessageWithIdNoPlaceholders];
|
||||
}
|
||||
});
|
||||
|
||||
// substitute text variables
|
||||
if (!textVariables || Object.keys(textVariables).length === 0) {
|
||||
return expandedMessages;
|
||||
}
|
||||
|
||||
return expandedMessages.map((message) => {
|
||||
if (!message.content) {
|
||||
return message;
|
||||
}
|
||||
|
||||
return {
|
||||
...message,
|
||||
content: replaceTextVariables(message.content, textVariables)
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function extractPlaceholderNames(messages: PromptMessage[]): string[] {
|
||||
return messages
|
||||
.filter((msg): msg is PlaceholderMessage => "type" in msg && msg.type === ChatMessageType.Placeholder)
|
||||
.map(msg => msg.name);
|
||||
}
|
||||
@@ -115,6 +115,8 @@ export enum ChatMessageRole {
|
||||
Tool = "tool",
|
||||
}
|
||||
|
||||
// Thought: should placeholder not semantically be part of this, because it can be
|
||||
// PublicAPICreated of type? Works for now though.
|
||||
export enum ChatMessageType {
|
||||
System = "system",
|
||||
Developer = "developer",
|
||||
@@ -123,6 +125,7 @@ export enum ChatMessageType {
|
||||
AssistantToolCall = "assistant-tool-call",
|
||||
ToolResult = "tool-result",
|
||||
PublicAPICreated = "public-api-created",
|
||||
Placeholder = "placeholder",
|
||||
}
|
||||
|
||||
export const SystemMessageSchema = z.object({
|
||||
@@ -171,6 +174,12 @@ export const ToolResultMessageSchema = z.object({
|
||||
});
|
||||
export type ToolResultMessage = z.infer<typeof ToolResultMessageSchema>;
|
||||
|
||||
export const PlaceholderMessageSchema = z.object({
|
||||
type: z.literal(ChatMessageType.Placeholder),
|
||||
name: z.string().regex(/^[a-zA-Z][a-zA-Z0-9_]*$/, "Placeholder name must start with a letter and contain only alphanumeric characters and underscores"),
|
||||
});
|
||||
export type PlaceholderMessage = z.infer<typeof PlaceholderMessageSchema>;
|
||||
|
||||
export const ChatMessageDefaultRoleSchema = z.enum(ChatMessageRole);
|
||||
export const ChatMessageSchema = z.union([
|
||||
SystemMessageSchema,
|
||||
@@ -193,12 +202,16 @@ export const ChatMessageSchema = z.union([
|
||||
]);
|
||||
|
||||
export type ChatMessage = z.infer<typeof ChatMessageSchema>;
|
||||
export type ChatMessageWithId = ChatMessage & { id: string };
|
||||
export type ChatMessageWithId = (ChatMessage & { id: string }) | (PlaceholderMessage & { id: string });
|
||||
export type ChatMessageWithIdNoPlaceholders = (ChatMessage & { id: string });
|
||||
|
||||
export const PromptChatMessageSchema = z.object({
|
||||
role: z.string(),
|
||||
content: z.string(),
|
||||
});
|
||||
export const PromptChatMessageSchema = z.union([
|
||||
z.object({
|
||||
role: z.string(),
|
||||
content: z.string(),
|
||||
}),
|
||||
PlaceholderMessageSchema,
|
||||
]);
|
||||
export const PromptChatMessageListSchema = z.array(PromptChatMessageSchema);
|
||||
|
||||
export type PromptVariable = { name: string; value: string; isUsed: boolean };
|
||||
@@ -218,11 +231,11 @@ export const SYSTEM_ROLES: string[] = [
|
||||
ChatMessageRole.Developer,
|
||||
];
|
||||
|
||||
export const TextPromptSchema = z.string().min(1, "Enter a prompt");
|
||||
export const TextPromptContentSchema = z.string().min(1, "Enter a prompt");
|
||||
|
||||
export const PromptContentSchema = z.union([
|
||||
PromptChatMessageListSchema,
|
||||
TextPromptSchema,
|
||||
TextPromptContentSchema,
|
||||
]);
|
||||
export type PromptContent = z.infer<typeof PromptContentSchema>;
|
||||
|
||||
|
||||
@@ -122,6 +122,43 @@ export const traceRecordInsertSchema = traceRecordBaseSchema.extend({
|
||||
});
|
||||
export type TraceRecordInsertType = z.infer<typeof traceRecordInsertSchema>;
|
||||
|
||||
export const traceMtRecordInsertSchema = z.object({
|
||||
// Identifiers
|
||||
project_id: z.string(),
|
||||
id: z.string(),
|
||||
start_time: z.number(),
|
||||
end_time: z.number().nullish(),
|
||||
name: z.string(),
|
||||
|
||||
// Metadata properties
|
||||
metadata: z.record(z.string(), z.string()),
|
||||
user_id: z.string(),
|
||||
session_id: z.string(),
|
||||
environment: z.string(),
|
||||
tags: z.array(z.string()),
|
||||
version: z.string().nullish(),
|
||||
release: z.string().nullish(),
|
||||
|
||||
// UI properties - nullable to prevent absent values being interpreted as overwrites
|
||||
bookmarked: z.boolean().nullish(),
|
||||
public: z.boolean().nullish(),
|
||||
|
||||
// Aggregations
|
||||
observation_ids: z.array(z.string()),
|
||||
score_ids: z.array(z.string()),
|
||||
cost_details: z.record(z.string(), z.number()),
|
||||
usage_details: z.record(z.string(), z.number()),
|
||||
|
||||
// Input/Output
|
||||
input: z.string(),
|
||||
output: z.string(),
|
||||
|
||||
created_at: z.number(),
|
||||
updated_at: z.number(),
|
||||
event_ts: z.number(),
|
||||
});
|
||||
export type TraceMtRecordInsertType = z.infer<typeof traceMtRecordInsertSchema>;
|
||||
|
||||
export const scoreRecordBaseSchema = z.object({
|
||||
id: z.string(),
|
||||
project_id: z.string(),
|
||||
|
||||
@@ -687,7 +687,11 @@ const getObservationsTableInternal = async <T>(
|
||||
const appliedScoresFilter = scoresFilter.apply();
|
||||
const appliedObservationsFilter = observationsFilter.apply();
|
||||
|
||||
const search = clickhouseSearchCondition(opts.searchQuery, opts.searchType, "o");
|
||||
const search = clickhouseSearchCondition(
|
||||
opts.searchQuery,
|
||||
opts.searchType,
|
||||
"o",
|
||||
);
|
||||
|
||||
const scoresCte = `WITH scores_agg AS (
|
||||
SELECT
|
||||
@@ -1494,6 +1498,7 @@ export const getGenerationsForPostHog = async function* (
|
||||
o.provided_model_name as model,
|
||||
o.level as level,
|
||||
o.version as version,
|
||||
o.environment as environment,
|
||||
t.id as trace_id,
|
||||
t.name as trace_name,
|
||||
t.session_id as trace_session_id,
|
||||
@@ -1556,6 +1561,7 @@ export const getGenerationsForPostHog = async function* (
|
||||
langfuse_model: record.model,
|
||||
langfuse_level: record.level,
|
||||
langfuse_tags: record.trace_tags,
|
||||
langfuse_environment: record.environment,
|
||||
langfuse_event_version: "1.0.0",
|
||||
$session_id: record.posthog_session_id ?? null,
|
||||
$set: {
|
||||
|
||||
@@ -1336,6 +1336,7 @@ export const getScoresForPostHog = async function* (
|
||||
s.name as name,
|
||||
s.value as value,
|
||||
s.comment as comment,
|
||||
s.environment as environment,
|
||||
t.name as trace_name,
|
||||
t.session_id as trace_session_id,
|
||||
t.user_id as trace_user_id,
|
||||
@@ -1388,6 +1389,7 @@ export const getScoresForPostHog = async function* (
|
||||
langfuse_user_id: record.trace_user_id || "langfuse_unknown_user",
|
||||
langfuse_release: record.trace_release,
|
||||
langfuse_tags: record.trace_tags,
|
||||
langfuse_environment: record.environment,
|
||||
langfuse_event_version: "1.0.0",
|
||||
$session_id: record.posthog_session_id ?? null,
|
||||
$set: {
|
||||
|
||||
@@ -957,6 +957,7 @@ export const getTracesForPostHog = async function* (
|
||||
t.release as release,
|
||||
t.version as version,
|
||||
t.tags as tags,
|
||||
t.environment as environment,
|
||||
t.metadata['$posthog_session_id'] as posthog_session_id,
|
||||
o.total_cost as total_cost,
|
||||
o.latency_milliseconds / 1000 as latency,
|
||||
@@ -1006,6 +1007,7 @@ export const getTracesForPostHog = async function* (
|
||||
langfuse_release: record.release,
|
||||
langfuse_version: record.version,
|
||||
langfuse_tags: record.tags,
|
||||
langfuse_environment: record.environment,
|
||||
langfuse_event_version: "1.0.0",
|
||||
$session_id: record.posthog_session_id ?? null,
|
||||
$set: {
|
||||
|
||||
@@ -33,6 +33,10 @@ export const HistogramChartConfig = BaseTotalValueChartConfig.extend({
|
||||
bins: z.number().int().min(1).max(100).optional().default(10),
|
||||
});
|
||||
|
||||
export const PivotTableChartConfig = BaseTotalValueChartConfig.extend({
|
||||
type: z.literal("PIVOT_TABLE"),
|
||||
});
|
||||
|
||||
// Define dimension schema
|
||||
export const DimensionSchema = z.object({
|
||||
field: z.string(),
|
||||
@@ -53,6 +57,7 @@ export const ChartConfigSchema = z.discriminatedUnion("type", [
|
||||
PieChartConfig,
|
||||
BigNumberChartConfig,
|
||||
HistogramChartConfig,
|
||||
PivotTableChartConfig,
|
||||
]);
|
||||
|
||||
export const DashboardDefinitionWidgetWidgetSchema = z.object({
|
||||
|
||||
@@ -21,9 +21,12 @@ export class PromptService {
|
||||
private ttlSeconds: number;
|
||||
|
||||
constructor(
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
private prisma: PrismaClient,
|
||||
private redis: Redis | Cluster | null,
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
private metricIncrementer?: // used for otel metrics
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
(name: string, value?: number) => void,
|
||||
cacheEnabled?: boolean, // used for testing
|
||||
) {
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Client-safe utility functions for prompt handling
|
||||
*/
|
||||
|
||||
export interface PromptMessage {
|
||||
type?: string;
|
||||
name?: string;
|
||||
role?: string;
|
||||
content?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts placeholder names from prompt messages.
|
||||
* This is a client-safe version that doesn't depend on server-side types.
|
||||
* @param messages Array of prompt messages
|
||||
* @returns Array of placeholder names
|
||||
*/
|
||||
export function extractPlaceholderNames(messages: PromptMessage[]): string[] {
|
||||
return messages
|
||||
.filter((msg): msg is PromptMessage & { name: string } =>
|
||||
msg.type === "placeholder" && typeof msg.name === "string"
|
||||
)
|
||||
.map(msg => msg.name);
|
||||
}
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "web",
|
||||
"version": "3.75.3",
|
||||
"version": "3.78.1",
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 779 B |
Binary file not shown.
|
After Width: | Height: | Size: 1.2 KiB |
@@ -6740,7 +6740,7 @@ components:
|
||||
prompt:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/ChatMessage'
|
||||
$ref: '#/components/schemas/ChatMessageWithPlaceholders'
|
||||
config:
|
||||
nullable: true
|
||||
labels:
|
||||
@@ -6854,6 +6854,31 @@ components:
|
||||
- config
|
||||
- labels
|
||||
- tags
|
||||
ChatMessageWithPlaceholders:
|
||||
title: ChatMessageWithPlaceholders
|
||||
oneOf:
|
||||
- type: object
|
||||
allOf:
|
||||
- type: object
|
||||
properties:
|
||||
type:
|
||||
type: string
|
||||
enum:
|
||||
- chatmessage
|
||||
- $ref: '#/components/schemas/ChatMessage'
|
||||
required:
|
||||
- type
|
||||
- type: object
|
||||
allOf:
|
||||
- type: object
|
||||
properties:
|
||||
type:
|
||||
type: string
|
||||
enum:
|
||||
- placeholder
|
||||
- $ref: '#/components/schemas/PlaceholderMessage'
|
||||
required:
|
||||
- type
|
||||
ChatMessage:
|
||||
title: ChatMessage
|
||||
type: object
|
||||
@@ -6865,6 +6890,14 @@ components:
|
||||
required:
|
||||
- role
|
||||
- content
|
||||
PlaceholderMessage:
|
||||
title: PlaceholderMessage
|
||||
type: object
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
required:
|
||||
- name
|
||||
TextPrompt:
|
||||
title: TextPrompt
|
||||
type: object
|
||||
@@ -6882,7 +6915,7 @@ components:
|
||||
prompt:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/ChatMessage'
|
||||
$ref: '#/components/schemas/ChatMessageWithPlaceholders'
|
||||
required:
|
||||
- prompt
|
||||
allOf:
|
||||
|
||||
@@ -2151,7 +2151,7 @@
|
||||
"auth": null,
|
||||
"body": {
|
||||
"mode": "raw",
|
||||
"raw": "{\n \"type\": \"chat\",\n \"name\": \"example\",\n \"prompt\": [\n {\n \"role\": \"example\",\n \"content\": \"example\"\n }\n ],\n \"config\": \"UNKNOWN\",\n \"labels\": [\n \"example\"\n ],\n \"tags\": [\n \"example\"\n ],\n \"commitMessage\": \"example\"\n}",
|
||||
"raw": "{\n \"type\": \"chat\",\n \"name\": \"example\",\n \"prompt\": [\n {\n \"type\": \"chatmessage\",\n \"role\": \"example\",\n \"content\": \"example\"\n }\n ],\n \"config\": \"UNKNOWN\",\n \"labels\": [\n \"example\"\n ],\n \"tags\": [\n \"example\"\n ],\n \"commitMessage\": \"example\"\n}",
|
||||
"options": {
|
||||
"raw": {
|
||||
"language": "json"
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
/** @jest-environment node */
|
||||
|
||||
import { ChatMessageType, compileChatMessages, extractPlaceholderNames } from "@langfuse/shared";
|
||||
|
||||
describe("compileChatMessages", () => {
|
||||
it("should compile message placeholders with provided values", () => {
|
||||
// Simulates how message placeholders would be compiled
|
||||
// during execution (e.g., in playground or experiments)
|
||||
|
||||
const promptTemplate = [
|
||||
{ role: "system", content: "You are a helpful assistant." },
|
||||
{
|
||||
type: ChatMessageType.Placeholder,
|
||||
name: "conversation_history"
|
||||
},
|
||||
{ role: "user", content: "{{user_question}}" }
|
||||
];
|
||||
|
||||
const placeholderValues = {
|
||||
conversation_history: [
|
||||
{ role: "user", content: "Hello!" },
|
||||
{ role: "assistant", content: "Hi there! How can I help you?" },
|
||||
{ role: "user", content: "What's the weather like?" }
|
||||
]
|
||||
};
|
||||
|
||||
const textVariables = {
|
||||
user_question: "Can you continue our conversation?"
|
||||
};
|
||||
|
||||
// Simulate compilation logic that would happen in playground/experiments
|
||||
const compiledMessages = compileChatMessages(
|
||||
promptTemplate,
|
||||
placeholderValues,
|
||||
textVariables
|
||||
);
|
||||
|
||||
expect(compiledMessages).toEqual([
|
||||
{ role: "system", content: "You are a helpful assistant." },
|
||||
{ role: "user", content: "Hello!" },
|
||||
{ role: "assistant", content: "Hi there! How can I help you?" },
|
||||
{ role: "user", content: "What's the weather like?" },
|
||||
{ role: "user", content: "Can you continue our conversation?" }
|
||||
]);
|
||||
});
|
||||
|
||||
it("should throw error when placeholder value is missing", () => {
|
||||
const promptTemplate = [
|
||||
{ role: "system", content: "You are a helpful assistant." },
|
||||
{
|
||||
type: ChatMessageType.Placeholder,
|
||||
name: "missing_placeholder"
|
||||
},
|
||||
{ role: "user", content: "Hello" }
|
||||
];
|
||||
|
||||
const placeholderValues = {};
|
||||
|
||||
expect(() => {
|
||||
compileChatMessages(promptTemplate, placeholderValues);
|
||||
}).toThrow("Missing value for message placeholder: missing_placeholder");
|
||||
});
|
||||
|
||||
it("should throw error when placeholder messages lack required properties", () => {
|
||||
const promptTemplate = [
|
||||
{
|
||||
type: ChatMessageType.Placeholder,
|
||||
name: "invalid_messages"
|
||||
}
|
||||
];
|
||||
|
||||
// Test missing role property
|
||||
const placeholderValuesNoRole = {
|
||||
invalid_messages: [
|
||||
{ content: "Hello" }
|
||||
]
|
||||
};
|
||||
|
||||
expect(() => {
|
||||
compileChatMessages(promptTemplate, placeholderValuesNoRole);
|
||||
}).toThrow("Invalid message format in placeholder 'invalid_messages': messages must have 'role' and 'content' properties");
|
||||
|
||||
// Test missing content property
|
||||
const placeholderValuesNoContent = {
|
||||
invalid_messages: [
|
||||
{ role: "user" }
|
||||
]
|
||||
};
|
||||
|
||||
expect(() => {
|
||||
compileChatMessages(promptTemplate, placeholderValuesNoContent);
|
||||
}).toThrow("Invalid message format in placeholder 'invalid_messages': messages must have 'role' and 'content' properties");
|
||||
});
|
||||
|
||||
it("should compile placeholders without applying text substitutions when no variables provided", () => {
|
||||
const promptTemplate = [
|
||||
{ role: "system", content: "You are a helpful assistant. {{system_var}}" },
|
||||
{
|
||||
type: ChatMessageType.Placeholder,
|
||||
name: "history"
|
||||
},
|
||||
{ role: "user", content: "{{user_var}}" }
|
||||
];
|
||||
|
||||
const placeholderValues = {
|
||||
history: [
|
||||
{ role: "user", content: "Previous message with {{var}}" },
|
||||
{ role: "assistant", content: "Response with {{another_var}}" }
|
||||
]
|
||||
};
|
||||
|
||||
// No text variables provided
|
||||
const compiledMessages = compileChatMessages(promptTemplate, placeholderValues);
|
||||
|
||||
expect(compiledMessages).toEqual([
|
||||
{ role: "system", content: "You are a helpful assistant. {{system_var}}" },
|
||||
{ role: "user", content: "Previous message with {{var}}" },
|
||||
{ role: "assistant", content: "Response with {{another_var}}" },
|
||||
{ role: "user", content: "{{user_var}}" }
|
||||
]);
|
||||
});
|
||||
|
||||
it("should extract all placeholder names from messages", () => {
|
||||
const promptTemplate = [
|
||||
{ role: "system", content: "System message" },
|
||||
{
|
||||
type: ChatMessageType.Placeholder,
|
||||
name: "history"
|
||||
},
|
||||
{ role: "user", content: "User message" },
|
||||
{
|
||||
type: ChatMessageType.Placeholder,
|
||||
name: "context"
|
||||
}
|
||||
];
|
||||
|
||||
const placeholderNames = extractPlaceholderNames(promptTemplate);
|
||||
|
||||
expect(placeholderNames).toEqual(["history", "context"]);
|
||||
});
|
||||
|
||||
it("should return empty array when no placeholders exist", () => {
|
||||
const promptTemplate = [
|
||||
{ role: "system", content: "System message" },
|
||||
{ role: "user", content: "User message" }
|
||||
];
|
||||
|
||||
const placeholderNames = extractPlaceholderNames(promptTemplate);
|
||||
|
||||
expect(placeholderNames).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,679 @@
|
||||
/**
|
||||
* @fileoverview Integration tests for Pivot Table widget functionality in dashboard router
|
||||
*
|
||||
* This test suite validates the complete data pipeline for pivot table widgets:
|
||||
* - Query generation and execution through executeQuery function
|
||||
* - SQL generation by QueryBuilder for various pivot table configurations
|
||||
* - Data transformation from raw query results to pivot table structure
|
||||
* - Integration with ClickHouse database and error handling
|
||||
*
|
||||
* Test Coverage:
|
||||
* - Zero dimension pivot tables (grand total only)
|
||||
* - Single dimension pivot tables with subtotals
|
||||
* - Two dimension pivot tables with nested structure
|
||||
* - Row limiting functionality
|
||||
* - Error handling for malformed queries
|
||||
* - Integration with existing dashboard query infrastructure
|
||||
*/
|
||||
|
||||
import { randomUUID } from "crypto";
|
||||
import {
|
||||
createTrace,
|
||||
createTracesCh,
|
||||
createObservation,
|
||||
createObservationsCh,
|
||||
} from "@langfuse/shared/src/server";
|
||||
import { type QueryType } from "@/src/features/query/types";
|
||||
import { executeQuery } from "@/src/features/dashboard/server/dashboard-router";
|
||||
import {
|
||||
transformToPivotTable,
|
||||
type DatabaseRow,
|
||||
} from "@/src/features/widgets/utils/pivot-table-utils";
|
||||
import { QueryBuilder } from "@/src/features/query/server/queryBuilder";
|
||||
|
||||
describe("Dashboard Router - Pivot Table Integration", () => {
|
||||
// Single project ID for all tests
|
||||
const projectId = randomUUID();
|
||||
|
||||
// Time references for test data
|
||||
const now = new Date();
|
||||
const oneHourAgo = new Date(now.getTime() - 3600000);
|
||||
const twoHoursAgo = new Date(now.getTime() - 7200000);
|
||||
const threeDaysAgo = new Date(now.getTime() - 3 * 24 * 3600000);
|
||||
|
||||
// Time ranges for queries - ISO format for query builder
|
||||
const defaultFromTime = threeDaysAgo.toISOString();
|
||||
const defaultToTime = new Date(now.getTime() + 3600000).toISOString(); // 1 hour in future
|
||||
|
||||
// Test data statistics for verification
|
||||
const testDataStats = {
|
||||
totalTraces: 0,
|
||||
environmentCounts: {} as Record<string, number>,
|
||||
modelCounts: {} as Record<string, number>,
|
||||
totalObservations: 0,
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
// Create diverse test data for pivot table testing
|
||||
const traces = [
|
||||
// Production environment traces
|
||||
...Array(6)
|
||||
.fill(0)
|
||||
.map((_, i) =>
|
||||
createTrace({
|
||||
project_id: projectId,
|
||||
name: "chat-completion",
|
||||
environment: "production",
|
||||
timestamp: now.getTime() - i * 10000,
|
||||
user_id: `user-prod-${i}`,
|
||||
}),
|
||||
),
|
||||
|
||||
// Development environment traces
|
||||
...Array(4)
|
||||
.fill(0)
|
||||
.map((_, i) =>
|
||||
createTrace({
|
||||
project_id: projectId,
|
||||
name: "embeddings",
|
||||
environment: "development",
|
||||
timestamp: oneHourAgo.getTime() - i * 15000,
|
||||
user_id: `user-dev-${i}`,
|
||||
}),
|
||||
),
|
||||
|
||||
// Staging environment traces
|
||||
...Array(3)
|
||||
.fill(0)
|
||||
.map((_, i) =>
|
||||
createTrace({
|
||||
project_id: projectId,
|
||||
name: "summarize",
|
||||
environment: "staging",
|
||||
timestamp: twoHoursAgo.getTime() - i * 20000,
|
||||
user_id: `user-staging-${i}`,
|
||||
}),
|
||||
),
|
||||
];
|
||||
|
||||
// Insert traces into ClickHouse
|
||||
await createTracesCh(traces);
|
||||
|
||||
// Create observations with different models for each trace
|
||||
const observations = [];
|
||||
|
||||
// Production observations - GPT models
|
||||
for (let i = 0; i < 6; i++) {
|
||||
const traceId = traces[i].id;
|
||||
observations.push(
|
||||
createObservation({
|
||||
project_id: projectId,
|
||||
trace_id: traceId,
|
||||
name: "gpt-generation",
|
||||
type: "generation",
|
||||
environment: "production",
|
||||
start_time: now.getTime() - i * 10000,
|
||||
completion_start_time: now.getTime() - i * 10000 + 500,
|
||||
end_time: now.getTime() - i * 10000 + 2000,
|
||||
provided_model_name: i < 3 ? "gpt-4-turbo" : "gpt-3.5-turbo",
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// Development observations - Claude models
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const traceId = traces[6 + i].id;
|
||||
observations.push(
|
||||
createObservation({
|
||||
project_id: projectId,
|
||||
trace_id: traceId,
|
||||
name: "claude-generation",
|
||||
type: "generation",
|
||||
environment: "development",
|
||||
start_time: oneHourAgo.getTime() - i * 15000,
|
||||
completion_start_time: oneHourAgo.getTime() - i * 15000 + 800,
|
||||
end_time: oneHourAgo.getTime() - i * 15000 + 3000,
|
||||
provided_model_name: i < 2 ? "claude-3-opus" : "claude-3-sonnet",
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// Staging observations - Mixed models
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const traceId = traces[10 + i].id;
|
||||
observations.push(
|
||||
createObservation({
|
||||
project_id: projectId,
|
||||
trace_id: traceId,
|
||||
name: "mixed-generation",
|
||||
type: "generation",
|
||||
environment: "staging",
|
||||
start_time: twoHoursAgo.getTime() - i * 20000,
|
||||
completion_start_time: twoHoursAgo.getTime() - i * 20000 + 600,
|
||||
end_time: twoHoursAgo.getTime() - i * 20000 + 2500,
|
||||
provided_model_name: "gpt-4-turbo",
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// Insert observations into ClickHouse
|
||||
await createObservationsCh(observations);
|
||||
|
||||
// Calculate test data statistics for verification
|
||||
testDataStats.totalTraces = traces.length;
|
||||
testDataStats.totalObservations = observations.length;
|
||||
|
||||
// Count by environment
|
||||
traces.forEach((trace) => {
|
||||
testDataStats.environmentCounts[trace.environment] =
|
||||
(testDataStats.environmentCounts[trace.environment] || 0) + 1;
|
||||
});
|
||||
|
||||
// Count by model
|
||||
observations.forEach((obs) => {
|
||||
if (obs.provided_model_name) {
|
||||
testDataStats.modelCounts[obs.provided_model_name] =
|
||||
(testDataStats.modelCounts[obs.provided_model_name] || 0) + 1;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("executeQuery function with pivot table configurations", () => {
|
||||
it("should execute zero-dimension pivot table query (grand total only)", async () => {
|
||||
const query: QueryType = {
|
||||
view: "traces",
|
||||
dimensions: [],
|
||||
metrics: [{ measure: "count", aggregation: "count" }],
|
||||
filters: [],
|
||||
timeDimension: {
|
||||
granularity: "day",
|
||||
},
|
||||
fromTimestamp: defaultFromTime,
|
||||
toTimestamp: defaultToTime,
|
||||
orderBy: null,
|
||||
chartConfig: {
|
||||
type: "PIVOT_TABLE",
|
||||
row_limit: 20,
|
||||
},
|
||||
};
|
||||
|
||||
const result = await executeQuery(projectId, query);
|
||||
|
||||
// Verify basic query execution
|
||||
expect(result).toBeDefined();
|
||||
expect(Array.isArray(result)).toBe(true);
|
||||
|
||||
// Results should be grouped by time dimension when timeDimension is present
|
||||
expect(result.length).toBeGreaterThan(0);
|
||||
|
||||
// Each row should have time_dimension and aggregated count
|
||||
result.forEach((row) => {
|
||||
expect(row).toHaveProperty("time_dimension");
|
||||
expect(row).toHaveProperty("count_count");
|
||||
expect(typeof row.count_count).toBe("string"); // ClickHouse returns numbers as strings
|
||||
});
|
||||
|
||||
// Sum all counts to verify total
|
||||
const totalCount = result.reduce(
|
||||
(sum, row) => sum + parseInt(row.count_count as string),
|
||||
0,
|
||||
);
|
||||
expect(totalCount).toBe(testDataStats.totalTraces);
|
||||
});
|
||||
|
||||
it("should execute single-dimension pivot table query with environment grouping", async () => {
|
||||
const query: QueryType = {
|
||||
view: "traces",
|
||||
dimensions: [{ field: "environment" }],
|
||||
metrics: [{ measure: "count", aggregation: "count" }],
|
||||
filters: [],
|
||||
timeDimension: {
|
||||
granularity: "day",
|
||||
},
|
||||
fromTimestamp: defaultFromTime,
|
||||
toTimestamp: defaultToTime,
|
||||
orderBy: [{ field: "environment", direction: "asc" }],
|
||||
chartConfig: {
|
||||
type: "PIVOT_TABLE",
|
||||
row_limit: 20,
|
||||
},
|
||||
};
|
||||
|
||||
const result = await executeQuery(projectId, query);
|
||||
|
||||
// Verify query execution
|
||||
expect(result).toBeDefined();
|
||||
expect(Array.isArray(result)).toBe(true);
|
||||
|
||||
// Should have one row per environment per time dimension
|
||||
expect(result.length).toBeGreaterThan(0);
|
||||
|
||||
// Verify structure of results
|
||||
result.forEach((row) => {
|
||||
expect(row).toHaveProperty("environment");
|
||||
expect(row).toHaveProperty("time_dimension");
|
||||
expect(row).toHaveProperty("count_count");
|
||||
expect(typeof row.environment).toBe("string");
|
||||
expect(typeof row.count_count).toBe("string"); // ClickHouse returns numbers as strings
|
||||
});
|
||||
|
||||
// Verify data accuracy by checking environment counts
|
||||
const environmentTotals = result.reduce(
|
||||
(acc, row) => {
|
||||
const env = row.environment as string;
|
||||
acc[env] =
|
||||
((acc[env] as number) || 0) + parseInt(row.count_count as string);
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, number>,
|
||||
);
|
||||
|
||||
expect(environmentTotals["production"]).toBe(
|
||||
testDataStats.environmentCounts["production"],
|
||||
);
|
||||
expect(environmentTotals["development"]).toBe(
|
||||
testDataStats.environmentCounts["development"],
|
||||
);
|
||||
expect(environmentTotals["staging"]).toBe(
|
||||
testDataStats.environmentCounts["staging"],
|
||||
);
|
||||
});
|
||||
|
||||
it("should execute two-dimension pivot table query with environment and model grouping", async () => {
|
||||
const query: QueryType = {
|
||||
view: "observations",
|
||||
dimensions: [{ field: "environment" }, { field: "providedModelName" }],
|
||||
metrics: [
|
||||
{ measure: "count", aggregation: "count" },
|
||||
{ measure: "totalTokens", aggregation: "sum" },
|
||||
],
|
||||
filters: [],
|
||||
timeDimension: {
|
||||
granularity: "day",
|
||||
},
|
||||
fromTimestamp: defaultFromTime,
|
||||
toTimestamp: defaultToTime,
|
||||
orderBy: [
|
||||
{ field: "environment", direction: "asc" },
|
||||
{ field: "providedModelName", direction: "asc" },
|
||||
],
|
||||
chartConfig: {
|
||||
type: "PIVOT_TABLE",
|
||||
row_limit: 20,
|
||||
},
|
||||
};
|
||||
|
||||
const result = await executeQuery(projectId, query);
|
||||
|
||||
// Verify query execution
|
||||
expect(result).toBeDefined();
|
||||
expect(Array.isArray(result)).toBe(true);
|
||||
expect(result.length).toBeGreaterThan(0);
|
||||
|
||||
// Should have combinations of environment and model
|
||||
result.forEach((row) => {
|
||||
expect(row).toHaveProperty("environment");
|
||||
expect(row).toHaveProperty("providedModelName");
|
||||
expect(row).toHaveProperty("time_dimension");
|
||||
expect(row).toHaveProperty("count_count");
|
||||
expect(row).toHaveProperty("sum_totalTokens");
|
||||
|
||||
expect(typeof row.environment).toBe("string");
|
||||
expect(typeof row.providedModelName).toBe("string");
|
||||
expect(typeof row.count_count).toBe("string");
|
||||
expect(typeof row.sum_totalTokens).toBe("string");
|
||||
});
|
||||
|
||||
// Verify total observation count matches test data
|
||||
const totalObservations = result.reduce(
|
||||
(sum, row) => sum + parseInt(row.count_count as string),
|
||||
0,
|
||||
);
|
||||
expect(totalObservations).toBe(testDataStats.totalObservations);
|
||||
});
|
||||
|
||||
it("should handle empty results gracefully", async () => {
|
||||
const futureTime = new Date(now.getTime() + 86400000).toISOString(); // 1 day in future
|
||||
const farFutureTime = new Date(now.getTime() + 172800000).toISOString(); // 2 days in future
|
||||
|
||||
const query: QueryType = {
|
||||
view: "traces",
|
||||
dimensions: [{ field: "environment" }],
|
||||
metrics: [{ measure: "count", aggregation: "count" }],
|
||||
filters: [],
|
||||
timeDimension: {
|
||||
granularity: "day",
|
||||
},
|
||||
fromTimestamp: futureTime,
|
||||
toTimestamp: farFutureTime,
|
||||
orderBy: null,
|
||||
chartConfig: {
|
||||
type: "PIVOT_TABLE",
|
||||
row_limit: 20,
|
||||
},
|
||||
};
|
||||
|
||||
const result = await executeQuery(projectId, query);
|
||||
|
||||
// Should handle empty results without errors
|
||||
expect(result).toBeDefined();
|
||||
expect(Array.isArray(result)).toBe(true);
|
||||
// Time dimension queries can return rows with 0 counts for time filling
|
||||
expect(result.length).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Query Builder integration with pivot table configurations", () => {
|
||||
it("should generate correct SQL for zero-dimension pivot table", () => {
|
||||
const query: QueryType = {
|
||||
view: "traces",
|
||||
dimensions: [],
|
||||
metrics: [{ measure: "count", aggregation: "count" }],
|
||||
filters: [],
|
||||
timeDimension: {
|
||||
granularity: "day",
|
||||
},
|
||||
fromTimestamp: defaultFromTime,
|
||||
toTimestamp: defaultToTime,
|
||||
orderBy: null,
|
||||
chartConfig: {
|
||||
type: "PIVOT_TABLE",
|
||||
row_limit: 20,
|
||||
},
|
||||
};
|
||||
|
||||
const queryBuilder = new QueryBuilder(query.chartConfig);
|
||||
const { query: sql, parameters } = queryBuilder.build(query, projectId);
|
||||
|
||||
// Verify SQL generation
|
||||
expect(sql).toBeDefined();
|
||||
expect(typeof sql).toBe("string");
|
||||
expect(parameters).toBeDefined();
|
||||
expect(typeof parameters).toBe("object");
|
||||
|
||||
// SQL should contain GROUP BY for time dimension when timeDimension is present
|
||||
expect(sql.toLowerCase()).toContain("group by");
|
||||
|
||||
// Should contain aggregation
|
||||
expect(sql.toLowerCase()).toContain("count(");
|
||||
});
|
||||
|
||||
it("should generate correct SQL for single-dimension pivot table", () => {
|
||||
const query: QueryType = {
|
||||
view: "traces",
|
||||
dimensions: [{ field: "environment" }],
|
||||
metrics: [{ measure: "count", aggregation: "count" }],
|
||||
filters: [],
|
||||
timeDimension: {
|
||||
granularity: "day",
|
||||
},
|
||||
fromTimestamp: defaultFromTime,
|
||||
toTimestamp: defaultToTime,
|
||||
orderBy: [{ field: "environment", direction: "asc" }],
|
||||
chartConfig: {
|
||||
type: "PIVOT_TABLE",
|
||||
row_limit: 20,
|
||||
},
|
||||
};
|
||||
|
||||
const queryBuilder = new QueryBuilder(query.chartConfig);
|
||||
const { query: sql, parameters } = queryBuilder.build(query, projectId);
|
||||
|
||||
// Verify SQL generation
|
||||
expect(sql).toBeDefined();
|
||||
expect(typeof sql).toBe("string");
|
||||
expect(parameters).toBeDefined();
|
||||
|
||||
// SQL should contain GROUP BY for dimension
|
||||
expect(sql.toLowerCase()).toContain("group by");
|
||||
expect(sql.toLowerCase()).toContain("environment");
|
||||
|
||||
// Should contain ORDER BY
|
||||
expect(sql.toLowerCase()).toContain("order by");
|
||||
});
|
||||
|
||||
it("should generate correct SQL for two-dimension pivot table", () => {
|
||||
const query: QueryType = {
|
||||
view: "observations",
|
||||
dimensions: [{ field: "environment" }, { field: "providedModelName" }],
|
||||
metrics: [
|
||||
{ measure: "count", aggregation: "count" },
|
||||
{ measure: "totalTokens", aggregation: "sum" },
|
||||
],
|
||||
filters: [],
|
||||
timeDimension: {
|
||||
granularity: "day",
|
||||
},
|
||||
fromTimestamp: defaultFromTime,
|
||||
toTimestamp: defaultToTime,
|
||||
orderBy: [
|
||||
{ field: "environment", direction: "asc" },
|
||||
{ field: "providedModelName", direction: "asc" },
|
||||
],
|
||||
chartConfig: {
|
||||
type: "PIVOT_TABLE",
|
||||
row_limit: 20,
|
||||
},
|
||||
};
|
||||
|
||||
const queryBuilder = new QueryBuilder(query.chartConfig);
|
||||
const { query: sql, parameters } = queryBuilder.build(query, projectId);
|
||||
|
||||
// Verify SQL generation
|
||||
expect(sql).toBeDefined();
|
||||
expect(typeof sql).toBe("string");
|
||||
expect(parameters).toBeDefined();
|
||||
|
||||
// SQL should contain GROUP BY for both dimensions
|
||||
expect(sql.toLowerCase()).toContain("group by");
|
||||
expect(sql.toLowerCase()).toContain("environment");
|
||||
expect(sql.toLowerCase()).toContain("providedmodelname");
|
||||
|
||||
// Should contain multiple aggregations
|
||||
expect(sql.toLowerCase()).toContain("count(");
|
||||
expect(sql.toLowerCase()).toContain("sum(");
|
||||
|
||||
// Should contain ORDER BY for both dimensions
|
||||
expect(sql.toLowerCase()).toContain("order by");
|
||||
});
|
||||
});
|
||||
|
||||
describe("End-to-end pivot table data transformation", () => {
|
||||
it("should transform query results to pivot table structure for zero dimensions", async () => {
|
||||
const query: QueryType = {
|
||||
view: "traces",
|
||||
dimensions: [],
|
||||
metrics: [{ measure: "count", aggregation: "count" }],
|
||||
filters: [],
|
||||
timeDimension: null, // No time dimension for simpler test
|
||||
fromTimestamp: defaultFromTime,
|
||||
toTimestamp: defaultToTime,
|
||||
orderBy: null,
|
||||
chartConfig: {
|
||||
type: "PIVOT_TABLE",
|
||||
row_limit: 20,
|
||||
},
|
||||
};
|
||||
|
||||
// Execute query to get raw data
|
||||
const rawData = await executeQuery(projectId, query);
|
||||
|
||||
// Transform to pivot table structure
|
||||
const pivotTableData = transformToPivotTable(rawData as DatabaseRow[], {
|
||||
dimensions: [],
|
||||
metrics: ["count_count"],
|
||||
rowLimit: 20,
|
||||
});
|
||||
|
||||
// Verify transformation
|
||||
expect(pivotTableData).toBeDefined();
|
||||
expect(Array.isArray(pivotTableData)).toBe(true);
|
||||
expect(pivotTableData.length).toBe(1); // Should have only grand total
|
||||
|
||||
const totalRow = pivotTableData[0];
|
||||
expect(totalRow.type).toBe("total");
|
||||
expect(totalRow.level).toBe(0);
|
||||
expect(totalRow.label).toBe("Total");
|
||||
expect(totalRow.isTotal).toBe(true);
|
||||
expect(totalRow.values).toHaveProperty("count_count");
|
||||
});
|
||||
|
||||
it("should transform query results to pivot table structure for single dimension", async () => {
|
||||
const query: QueryType = {
|
||||
view: "traces",
|
||||
dimensions: [{ field: "environment" }],
|
||||
metrics: [{ measure: "count", aggregation: "count" }],
|
||||
filters: [],
|
||||
timeDimension: null, // No time dimension for simpler test
|
||||
fromTimestamp: defaultFromTime,
|
||||
toTimestamp: defaultToTime,
|
||||
orderBy: [{ field: "environment", direction: "asc" }],
|
||||
chartConfig: {
|
||||
type: "PIVOT_TABLE",
|
||||
row_limit: 20,
|
||||
},
|
||||
};
|
||||
|
||||
// Execute query to get raw data
|
||||
const rawData = await executeQuery(projectId, query);
|
||||
|
||||
// Transform to pivot table structure
|
||||
const pivotTableData = transformToPivotTable(rawData as DatabaseRow[], {
|
||||
dimensions: ["environment"],
|
||||
metrics: ["count_count"],
|
||||
rowLimit: 20,
|
||||
});
|
||||
|
||||
// Verify transformation
|
||||
expect(pivotTableData).toBeDefined();
|
||||
expect(Array.isArray(pivotTableData)).toBe(true);
|
||||
expect(pivotTableData.length).toBeGreaterThan(1); // Should have data rows + total
|
||||
|
||||
// Should have data rows for each environment
|
||||
const dataRows = pivotTableData.filter((row) => row.type === "data");
|
||||
const totalRow = pivotTableData.find((row) => row.type === "total");
|
||||
|
||||
expect(dataRows.length).toBeGreaterThan(0);
|
||||
expect(totalRow).toBeDefined();
|
||||
expect(totalRow!.isTotal).toBe(true);
|
||||
|
||||
// Verify data row structure
|
||||
dataRows.forEach((row) => {
|
||||
expect(row.type).toBe("data");
|
||||
expect(row.level).toBe(0);
|
||||
expect(row.values).toHaveProperty("count_count");
|
||||
expect(typeof row.values.count_count).toBe("number");
|
||||
});
|
||||
});
|
||||
|
||||
it("should transform query results to pivot table structure for two dimensions", async () => {
|
||||
const query: QueryType = {
|
||||
view: "observations",
|
||||
dimensions: [{ field: "environment" }, { field: "providedModelName" }],
|
||||
metrics: [{ measure: "count", aggregation: "count" }],
|
||||
filters: [],
|
||||
timeDimension: null, // No time dimension for simpler test
|
||||
fromTimestamp: defaultFromTime,
|
||||
toTimestamp: defaultToTime,
|
||||
orderBy: [
|
||||
{ field: "environment", direction: "asc" },
|
||||
{ field: "providedModelName", direction: "asc" },
|
||||
],
|
||||
chartConfig: {
|
||||
type: "PIVOT_TABLE",
|
||||
row_limit: 20,
|
||||
},
|
||||
};
|
||||
|
||||
// Execute query to get raw data
|
||||
const rawData = await executeQuery(projectId, query);
|
||||
|
||||
// Transform to pivot table structure
|
||||
const pivotTableData = transformToPivotTable(rawData as DatabaseRow[], {
|
||||
dimensions: ["environment", "providedModelName"],
|
||||
metrics: ["count_count"],
|
||||
rowLimit: 20,
|
||||
});
|
||||
|
||||
// Verify transformation
|
||||
expect(pivotTableData).toBeDefined();
|
||||
expect(Array.isArray(pivotTableData)).toBe(true);
|
||||
expect(pivotTableData.length).toBeGreaterThan(1);
|
||||
|
||||
// Should have nested structure with data rows, subtotals, and grand total
|
||||
const dataRows = pivotTableData.filter((row) => row.type === "data");
|
||||
const subtotalRows = pivotTableData.filter(
|
||||
(row) => row.type === "subtotal",
|
||||
);
|
||||
const totalRow = pivotTableData.find((row) => row.type === "total");
|
||||
|
||||
expect(dataRows.length).toBeGreaterThan(0);
|
||||
expect(totalRow).toBeDefined();
|
||||
|
||||
// Verify indentation levels
|
||||
dataRows.forEach((row) => {
|
||||
expect(row.level).toBe(1); // Second level for two dimensions
|
||||
expect(row.values).toHaveProperty("count_count");
|
||||
});
|
||||
|
||||
if (subtotalRows.length > 0) {
|
||||
subtotalRows.forEach((row) => {
|
||||
expect(row.level).toBe(0); // First level subtotals
|
||||
expect(row.isSubtotal).toBe(true);
|
||||
});
|
||||
}
|
||||
|
||||
expect(totalRow!.level).toBe(0);
|
||||
expect(totalRow!.isTotal).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Error handling and edge cases", () => {
|
||||
it("should handle empty results gracefully in transformation", async () => {
|
||||
// Transform empty data to pivot table structure
|
||||
const pivotTableData = transformToPivotTable([], {
|
||||
dimensions: ["environment"],
|
||||
metrics: ["count_count"],
|
||||
rowLimit: 20,
|
||||
});
|
||||
|
||||
// Should handle empty data gracefully
|
||||
expect(pivotTableData).toBeDefined();
|
||||
expect(Array.isArray(pivotTableData)).toBe(true);
|
||||
|
||||
// Should still have total row with zero values
|
||||
expect(pivotTableData.length).toBe(1);
|
||||
const totalRow = pivotTableData[0];
|
||||
expect(totalRow.type).toBe("total");
|
||||
expect(totalRow.values.count_count).toBe(0);
|
||||
});
|
||||
|
||||
it("should handle missing project data gracefully", async () => {
|
||||
const nonExistentProjectId = randomUUID();
|
||||
|
||||
const query: QueryType = {
|
||||
view: "traces",
|
||||
dimensions: [],
|
||||
metrics: [{ measure: "count", aggregation: "count" }],
|
||||
filters: [],
|
||||
timeDimension: {
|
||||
granularity: "day",
|
||||
},
|
||||
fromTimestamp: defaultFromTime,
|
||||
toTimestamp: defaultToTime,
|
||||
orderBy: null,
|
||||
chartConfig: {
|
||||
type: "PIVOT_TABLE",
|
||||
row_limit: 20,
|
||||
},
|
||||
};
|
||||
|
||||
const result = await executeQuery(nonExistentProjectId, query);
|
||||
|
||||
// Should return results even for non-existent project (time fill creates rows)
|
||||
expect(result).toBeDefined();
|
||||
expect(Array.isArray(result)).toBe(true);
|
||||
expect(result.length).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,383 @@
|
||||
/**
|
||||
* @fileoverview Unit Tests for PivotTable React Component
|
||||
*
|
||||
* Comprehensive test suite for the PivotTable component functionality including:
|
||||
* - Component rendering with various data scenarios
|
||||
* - Proper styling and CSS class application
|
||||
* - Indentation behavior for nested dimensions
|
||||
* - Empty data and error state handling
|
||||
* - Metric value formatting and display
|
||||
* - Column header formatting
|
||||
*
|
||||
* Uses Jest and React Testing Library for component testing.
|
||||
* This test focuses on component behavior rather than data transformation logic.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import "@testing-library/jest-dom";
|
||||
|
||||
import {
|
||||
PivotTable,
|
||||
type PivotTableProps,
|
||||
} from "@/src/features/widgets/chart-library/PivotTable";
|
||||
import { type DataPoint } from "@/src/features/widgets/chart-library/chart-props";
|
||||
|
||||
describe("PivotTable Component", () => {
|
||||
describe("Basic Rendering", () => {
|
||||
test("renders table with simple data", () => {
|
||||
const data: DataPoint[] = [
|
||||
{
|
||||
time_dimension: "2024-01-01",
|
||||
dimension: "gpt-4",
|
||||
metric: 100,
|
||||
},
|
||||
{
|
||||
time_dimension: "2024-01-01",
|
||||
dimension: "gpt-3.5",
|
||||
metric: 75,
|
||||
},
|
||||
];
|
||||
|
||||
const props: PivotTableProps = {
|
||||
data,
|
||||
config: {
|
||||
dimensions: ["model"],
|
||||
metrics: ["metric"],
|
||||
},
|
||||
};
|
||||
|
||||
render(<PivotTable {...props} />);
|
||||
|
||||
// Check table structure exists
|
||||
expect(screen.getByRole("table")).toBeInTheDocument();
|
||||
// Should have dimension column and metric column
|
||||
expect(screen.getAllByRole("columnheader")).toHaveLength(2);
|
||||
});
|
||||
|
||||
test("displays data when no configuration provided", () => {
|
||||
const data: DataPoint[] = [
|
||||
{
|
||||
time_dimension: "2024-01-01",
|
||||
dimension: "test",
|
||||
metric: 50,
|
||||
},
|
||||
];
|
||||
|
||||
const props: PivotTableProps = {
|
||||
data,
|
||||
};
|
||||
|
||||
render(<PivotTable {...props} />);
|
||||
|
||||
// Should still render a table
|
||||
expect(screen.getByRole("table")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Column Header Formatting", () => {
|
||||
test("formats single dimension header correctly", () => {
|
||||
const data: DataPoint[] = [
|
||||
{
|
||||
time_dimension: "2024-01-01",
|
||||
dimension: "test",
|
||||
metric: 100,
|
||||
},
|
||||
];
|
||||
|
||||
const props: PivotTableProps = {
|
||||
data,
|
||||
config: {
|
||||
dimensions: ["model_name"],
|
||||
metrics: ["request_count"],
|
||||
},
|
||||
};
|
||||
|
||||
render(<PivotTable {...props} />);
|
||||
|
||||
expect(
|
||||
screen.getByRole("columnheader", { name: "Model Name" }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("columnheader", { name: "Request Count" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("formats multiple dimension headers correctly", () => {
|
||||
const data: DataPoint[] = [
|
||||
{
|
||||
time_dimension: "2024-01-01",
|
||||
dimension: "test",
|
||||
metric: 100,
|
||||
},
|
||||
];
|
||||
|
||||
const props: PivotTableProps = {
|
||||
data,
|
||||
config: {
|
||||
dimensions: ["model_name", "time_period"],
|
||||
metrics: ["request_count", "avg_duration"],
|
||||
},
|
||||
};
|
||||
|
||||
render(<PivotTable {...props} />);
|
||||
|
||||
expect(
|
||||
screen.getByRole("columnheader", { name: "Model Name / Time Period" }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("columnheader", { name: "Request Count" }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("columnheader", { name: "Avg Duration" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("defaults to 'Dimension' when no dimensions configured", () => {
|
||||
const data: DataPoint[] = [
|
||||
{
|
||||
time_dimension: "2024-01-01",
|
||||
dimension: "test",
|
||||
metric: 100,
|
||||
},
|
||||
];
|
||||
|
||||
const props: PivotTableProps = {
|
||||
data,
|
||||
config: {
|
||||
dimensions: [],
|
||||
metrics: ["metric"],
|
||||
},
|
||||
};
|
||||
|
||||
render(<PivotTable {...props} />);
|
||||
|
||||
expect(
|
||||
screen.getByRole("columnheader", { name: "Dimension" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Empty Data Handling", () => {
|
||||
test("displays 'No data available' for empty data array", () => {
|
||||
const props: PivotTableProps = {
|
||||
data: [],
|
||||
config: {
|
||||
dimensions: ["model"],
|
||||
metrics: ["metric"],
|
||||
},
|
||||
};
|
||||
|
||||
render(<PivotTable {...props} />);
|
||||
|
||||
expect(screen.getByText("No data available")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("table")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("displays 'No data available' for null data", () => {
|
||||
const props: PivotTableProps = {
|
||||
data: null as any,
|
||||
config: {
|
||||
dimensions: ["model"],
|
||||
metrics: ["metric"],
|
||||
},
|
||||
};
|
||||
|
||||
render(<PivotTable {...props} />);
|
||||
|
||||
expect(screen.getByText("No data available")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("table")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("displays 'No data available' for undefined data", () => {
|
||||
const props: PivotTableProps = {
|
||||
data: undefined as any,
|
||||
config: {
|
||||
dimensions: ["model"],
|
||||
metrics: ["metric"],
|
||||
},
|
||||
};
|
||||
|
||||
render(<PivotTable {...props} />);
|
||||
|
||||
expect(screen.getByText("No data available")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("table")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Responsive Design", () => {
|
||||
test("includes overflow-auto class for responsive scrolling", () => {
|
||||
const data: DataPoint[] = [
|
||||
{
|
||||
time_dimension: "2024-01-01",
|
||||
dimension: "test",
|
||||
metric: 100,
|
||||
},
|
||||
];
|
||||
|
||||
const props: PivotTableProps = {
|
||||
data,
|
||||
config: {
|
||||
dimensions: ["model"],
|
||||
metrics: ["metric"],
|
||||
},
|
||||
};
|
||||
|
||||
const { container } = render(<PivotTable {...props} />);
|
||||
|
||||
const pivotTableContainer = container.firstChild as HTMLElement;
|
||||
expect(pivotTableContainer).toHaveClass("h-full", "overflow-auto");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Accessibility", () => {
|
||||
test("provides proper table structure for screen readers", () => {
|
||||
const data: DataPoint[] = [
|
||||
{
|
||||
time_dimension: "2024-01-01",
|
||||
dimension: "gpt-4",
|
||||
metric: 100,
|
||||
},
|
||||
];
|
||||
|
||||
const props: PivotTableProps = {
|
||||
data,
|
||||
config: {
|
||||
dimensions: ["model"],
|
||||
metrics: ["metric"],
|
||||
},
|
||||
};
|
||||
|
||||
render(<PivotTable {...props} />);
|
||||
|
||||
// Check table has proper semantic structure
|
||||
expect(screen.getByRole("table")).toBeInTheDocument();
|
||||
expect(screen.getAllByRole("columnheader")).toHaveLength(2);
|
||||
// Should have at least header row
|
||||
expect(screen.getAllByRole("row").length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
test("provides proper cell associations", () => {
|
||||
const data: DataPoint[] = [
|
||||
{
|
||||
time_dimension: "2024-01-01",
|
||||
dimension: "test",
|
||||
metric: 100,
|
||||
},
|
||||
];
|
||||
|
||||
const props: PivotTableProps = {
|
||||
data,
|
||||
config: {
|
||||
dimensions: ["model"],
|
||||
metrics: ["metric"],
|
||||
},
|
||||
};
|
||||
|
||||
render(<PivotTable {...props} />);
|
||||
|
||||
// Header cells should be properly associated
|
||||
const headers = screen.getAllByRole("columnheader");
|
||||
expect(headers.length).toBeGreaterThan(0);
|
||||
|
||||
// Each header should be part of a row
|
||||
headers.forEach((header) => {
|
||||
expect(header.closest("tr")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Component Props", () => {
|
||||
test("handles missing config gracefully", () => {
|
||||
const data: DataPoint[] = [
|
||||
{
|
||||
time_dimension: "2024-01-01",
|
||||
dimension: "test",
|
||||
metric: 100,
|
||||
},
|
||||
];
|
||||
|
||||
const props: PivotTableProps = {
|
||||
data,
|
||||
// No config provided
|
||||
};
|
||||
|
||||
render(<PivotTable {...props} />);
|
||||
|
||||
// Should still render a table
|
||||
expect(screen.getByRole("table")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("passes accessibilityLayer prop correctly", () => {
|
||||
const data: DataPoint[] = [
|
||||
{
|
||||
time_dimension: "2024-01-01",
|
||||
dimension: "test",
|
||||
metric: 100,
|
||||
},
|
||||
];
|
||||
|
||||
const props: PivotTableProps = {
|
||||
data,
|
||||
accessibilityLayer: true,
|
||||
};
|
||||
|
||||
// Should render without error
|
||||
expect(() => render(<PivotTable {...props} />)).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Data Processing Integration", () => {
|
||||
test("handles various metric data types", () => {
|
||||
const data: DataPoint[] = [
|
||||
{
|
||||
time_dimension: "2024-01-01",
|
||||
dimension: "test1",
|
||||
metric: 100, // number
|
||||
},
|
||||
{
|
||||
time_dimension: "2024-01-01",
|
||||
dimension: "test2",
|
||||
metric: [
|
||||
[10, 20],
|
||||
[30, 40],
|
||||
], // nested array
|
||||
},
|
||||
];
|
||||
|
||||
const props: PivotTableProps = {
|
||||
data,
|
||||
config: {
|
||||
dimensions: ["model"],
|
||||
metrics: ["metric"],
|
||||
},
|
||||
};
|
||||
|
||||
// Should render without error
|
||||
expect(() => render(<PivotTable {...props} />)).not.toThrow();
|
||||
expect(screen.getByRole("table")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("handles missing dimension data", () => {
|
||||
const data: DataPoint[] = [
|
||||
{
|
||||
time_dimension: "2024-01-01",
|
||||
dimension: undefined, // missing dimension
|
||||
metric: 100,
|
||||
},
|
||||
];
|
||||
|
||||
const props: PivotTableProps = {
|
||||
data,
|
||||
config: {
|
||||
dimensions: ["model"],
|
||||
metrics: ["metric"],
|
||||
},
|
||||
};
|
||||
|
||||
// Should render without error
|
||||
expect(() => render(<PivotTable {...props} />)).not.toThrow();
|
||||
expect(screen.getByRole("table")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
LegacyPromptSchema,
|
||||
PromptType,
|
||||
type LegacyValidatedPrompt,
|
||||
} from "@/src/features/prompts/server/utils/validation";
|
||||
} from "@langfuse/shared";
|
||||
import { getObservationById } from "@langfuse/shared/src/server";
|
||||
|
||||
describe("/api/public/prompts API Endpoint", () => {
|
||||
|
||||
@@ -3,20 +3,22 @@
|
||||
import { prisma } from "@langfuse/shared/src/db";
|
||||
import { makeAPICall, pruneDatabase } from "@/src/__tests__/test-utils";
|
||||
import { v4 as uuidv4, v4 } from "uuid";
|
||||
import { type Prompt } from "@langfuse/shared";
|
||||
import {
|
||||
PromptSchema,
|
||||
PromptType,
|
||||
type ValidatedPrompt,
|
||||
} from "@/src/features/prompts/server/utils/validation";
|
||||
type ChatMessage,
|
||||
type Prompt,
|
||||
} from "@langfuse/shared";
|
||||
import { parsePromptDependencyTags } from "@langfuse/shared";
|
||||
import { nanoid } from "ai";
|
||||
import { generateId, nanoid } from "ai";
|
||||
|
||||
import { type PromptsMetaResponse } from "@/src/features/prompts/server/actions/getPromptsMeta";
|
||||
import {
|
||||
createOrgProjectAndApiKey,
|
||||
getObservationById,
|
||||
MAX_PROMPT_NESTING_DEPTH,
|
||||
ChatMessageType,
|
||||
} from "@langfuse/shared/src/server";
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
@@ -452,6 +454,52 @@ describe("/api/public/v2/prompts API Endpoint", () => {
|
||||
expect(validatedPrompt.commitMessage).toBe("chore: setup initial prompt");
|
||||
});
|
||||
|
||||
it("should create and fetch a chat prompt with message placeholders", async () => {
|
||||
const promptName = `prompt-name-message-placeholders${generateId()}`;
|
||||
const commitMessage = "feat: add message placeholders support";
|
||||
const chatMessages = [
|
||||
{ role: "system", content: "You are a helpful assistant with conversation context." },
|
||||
{
|
||||
type: ChatMessageType.Placeholder,
|
||||
name: "conversation_history"
|
||||
},
|
||||
{ role: "user", content: "{{user_question}}" }
|
||||
];
|
||||
|
||||
const response = await makeAPICall("POST", baseURI, {
|
||||
name: promptName,
|
||||
prompt: chatMessages,
|
||||
type: "chat",
|
||||
labels: ["production"],
|
||||
commitMessage: commitMessage
|
||||
});
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
|
||||
const { body: fetchedPrompt } = await makeAPICall(
|
||||
"GET",
|
||||
`${baseURI}/${promptName}`,
|
||||
undefined,
|
||||
);
|
||||
|
||||
const validatedPrompt = validatePrompt(fetchedPrompt);
|
||||
|
||||
expect(validatedPrompt.name).toBe(promptName);
|
||||
expect(validatedPrompt.prompt).toEqual(chatMessages);
|
||||
expect(validatedPrompt.type).toBe("chat");
|
||||
expect(validatedPrompt.version).toBe(1);
|
||||
expect(validatedPrompt.labels).toEqual(["production", "latest"]);
|
||||
expect(validatedPrompt.createdBy).toBe("API");
|
||||
expect(validatedPrompt.config).toEqual({});
|
||||
expect(validatedPrompt.commitMessage).toBe(commitMessage);
|
||||
|
||||
// Verify the placeholder message structure is preserved
|
||||
const messages = validatedPrompt.prompt as ChatMessage[];
|
||||
const placeholderMessage = messages[1] as { type: ChatMessageType.Placeholder; name: string };
|
||||
expect(placeholderMessage.type).toBe(ChatMessageType.Placeholder);
|
||||
expect(placeholderMessage.name).toBe("conversation_history");
|
||||
});
|
||||
|
||||
it("should fail if chat prompt has string prompt", async () => {
|
||||
const promptName = "prompt-name";
|
||||
const response = await makeAPICall("POST", baseURI, {
|
||||
@@ -705,7 +753,9 @@ describe("/api/public/v2/prompts API Endpoint", () => {
|
||||
});
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.message).toBe("Invalid request data");
|
||||
const hasExpectedMessage = JSON.stringify(response.body.error).includes(`"message":"${expectedError}"`);
|
||||
const hasExpectedMessage = JSON.stringify(response.body.error).includes(
|
||||
`"message":"${expectedError}"`,
|
||||
);
|
||||
expect(hasExpectedMessage).toBe(true);
|
||||
};
|
||||
|
||||
@@ -723,10 +773,19 @@ describe("/api/public/v2/prompts API Endpoint", () => {
|
||||
|
||||
it("should reject invalid prompt names", async () => {
|
||||
// Test invalid patterns
|
||||
await testInvalidName("/invalid-name", "Name cannot start with a slash");
|
||||
await testInvalidName(
|
||||
"/invalid-name",
|
||||
"Name cannot start with a slash",
|
||||
);
|
||||
await testInvalidName("invalid-name/", "Name cannot end with a slash");
|
||||
await testInvalidName("invalid//name", "Name cannot contain consecutive slashes");
|
||||
await testInvalidName("invalid|name", "Prompt name cannot contain '|' character");
|
||||
await testInvalidName(
|
||||
"invalid//name",
|
||||
"Name cannot contain consecutive slashes",
|
||||
);
|
||||
await testInvalidName(
|
||||
"invalid|name",
|
||||
"Prompt name cannot contain '|' character",
|
||||
);
|
||||
await testInvalidName("new", "Prompt name cannot be 'new'");
|
||||
await testInvalidName("", "Enter a name");
|
||||
});
|
||||
@@ -848,7 +907,68 @@ describe("/api/public/v2/prompts API Endpoint", () => {
|
||||
// Verify the name with slashes is preserved
|
||||
expect(validatedPrompt.name).toBe(promptName);
|
||||
expect(validatedPrompt.name).toContain("/");
|
||||
expect(validatedPrompt.prompt).toBe("This is a prompt in a folder structure");
|
||||
expect(validatedPrompt.prompt).toBe(
|
||||
"This is a prompt in a folder structure",
|
||||
);
|
||||
});
|
||||
|
||||
it("should prevent creating a prompt with both a variable and placeholder with the same name", async () => {
|
||||
const promptName = "prompt-same-name-conflict-" + nanoid();
|
||||
|
||||
// Try to create a prompt where the same name is used as both a variable and a placeholder
|
||||
const response = await makeAPICall("POST", baseURI, {
|
||||
name: promptName,
|
||||
prompt: [
|
||||
{ role: "system", content: "Hello {{userName}}" },
|
||||
{ type: ChatMessageType.Placeholder, name: "userName" },
|
||||
{ role: "user", content: "How are you?" }
|
||||
],
|
||||
type: "chat",
|
||||
});
|
||||
|
||||
// This should fail with a 400 error
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body).toHaveProperty("error");
|
||||
expect(response.body).toHaveProperty("message");
|
||||
// @ts-expect-error
|
||||
expect(response.body.message).toContain("variables and placeholders must be unique");
|
||||
// @ts-expect-error
|
||||
expect(response.body.message).toContain("userName");
|
||||
});
|
||||
|
||||
it("should allow creating a new version of a prompt with placeholder names that conflict a variable name in a previous version", async () => {
|
||||
const promptName = "prompt-with-variable-conflict-" + nanoid();
|
||||
|
||||
// First, create a chat prompt with a message variable
|
||||
const v1Response = await makeAPICall("POST", baseURI, {
|
||||
name: promptName,
|
||||
prompt: [
|
||||
{ role: "system", content: "You are a helpful {{conversationHistory}}" },
|
||||
{ role: "user", content: "Continue our conversation" }
|
||||
],
|
||||
type: "chat",
|
||||
labels: ["production"],
|
||||
});
|
||||
|
||||
expect(v1Response.status).toBe(201);
|
||||
|
||||
// Try to create a new version with a text variable that has the same name as the placeholder
|
||||
const v2Response = await makeAPICall("POST", baseURI, {
|
||||
name: promptName,
|
||||
prompt: [
|
||||
{ role: "system", content: "You are a helpful assistant with context: {{newHistory}}" },
|
||||
{ type: "placeholder", name: "conversationHistory" },
|
||||
{ role: "user", content: "Continue our conversation" }
|
||||
],
|
||||
type: "chat"
|
||||
});
|
||||
|
||||
// This should succeed, we allow cross-version name reuse
|
||||
expect(v2Response.status).toBe(201);
|
||||
expect(v2Response.body).toHaveProperty("id");
|
||||
expect(v2Response.body).toHaveProperty("version");
|
||||
// @ts-expect-error - Response body type is flexible for testing
|
||||
expect(v2Response.body.version).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -3427,7 +3427,9 @@ describe("queryBuilder", () => {
|
||||
};
|
||||
|
||||
// Execute histogram query with custom bins
|
||||
const queryBuilder = new QueryBuilder(customBinHistogramQuery.chartConfig);
|
||||
const queryBuilder = new QueryBuilder(
|
||||
customBinHistogramQuery.chartConfig,
|
||||
);
|
||||
const { query: compiledQuery, parameters } = queryBuilder.build(
|
||||
customBinHistogramQuery,
|
||||
projectId,
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
SYSTEM_ROLES,
|
||||
type ChatMessageWithId,
|
||||
type LLMToolCall,
|
||||
type PlaceholderMessage,
|
||||
} from "@langfuse/shared";
|
||||
import { Button } from "@/src/components/ui/button";
|
||||
import { Card, CardContent } from "@/src/components/ui/card";
|
||||
@@ -45,15 +46,17 @@ const ROLES: ChatMessageRole[] = [
|
||||
const getRoleNamePlaceholder = (role: string) => {
|
||||
switch (role) {
|
||||
case ChatMessageRole.System:
|
||||
return "a system";
|
||||
return "a system message";
|
||||
case ChatMessageRole.Developer:
|
||||
return "a developer";
|
||||
return "a developer message";
|
||||
case ChatMessageRole.Assistant:
|
||||
return "an assistant";
|
||||
return "an assistant message";
|
||||
case ChatMessageRole.User:
|
||||
return "a user";
|
||||
return "a user message";
|
||||
case ChatMessageRole.Tool:
|
||||
return "a tool response";
|
||||
return "a tool response message";
|
||||
case "placeholder":
|
||||
return "placeholder name (e.g. msg_history)";
|
||||
default:
|
||||
return `a ${role}`;
|
||||
}
|
||||
@@ -92,6 +95,9 @@ export const ChatMessageComponent: React.FC<ChatMessageProps> = ({
|
||||
} = useSortable({ id: message.id });
|
||||
|
||||
const toggleRole = () => {
|
||||
// Only allow role toggling for messages that have a role property (not placeholder messages)
|
||||
if (!('role' in message)) return;
|
||||
|
||||
// if user has set custom roles, available roles will be non-empty and we toggle through custom and default roles (assistant, user)
|
||||
if (!!availableRoles && Boolean(availableRoles.length)) {
|
||||
let randomRole = availableRoles[roleIndex % availableRoles.length];
|
||||
@@ -113,7 +119,7 @@ export const ChatMessageComponent: React.FC<ChatMessageProps> = ({
|
||||
(toolCallIds && toolCallIds.length > 0),
|
||||
);
|
||||
const currentIndex = eligibleRoles.indexOf(
|
||||
message.role as ChatMessageRole,
|
||||
('role' in message ? message.role : ChatMessageRole.User) as ChatMessageRole,
|
||||
);
|
||||
const nextRole =
|
||||
eligibleRoles[(currentIndex + 1) % eligibleRoles.length];
|
||||
@@ -196,13 +202,28 @@ export const ChatMessageComponent: React.FC<ChatMessageProps> = ({
|
||||
};
|
||||
|
||||
const onValueChange = useCallback(
|
||||
(value: string) =>
|
||||
updateMessage(message.type, message.id, "content", value),
|
||||
(value: string) => {
|
||||
if (message.type === ChatMessageType.Placeholder) {
|
||||
updateMessage(message.type, message.id, "name", value);
|
||||
} else {
|
||||
updateMessage(message.type, message.id, "content", value);
|
||||
}
|
||||
},
|
||||
[message.id, message.type, updateMessage],
|
||||
);
|
||||
|
||||
const showDragHandle = !SYSTEM_ROLES.includes(message.role);
|
||||
const onPlaceholderNameChange = useCallback(
|
||||
(value: string) => {
|
||||
if (message.type === ChatMessageType.Placeholder) {
|
||||
updateMessage(message.type, message.id, "name", value);
|
||||
}
|
||||
},
|
||||
[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;
|
||||
|
||||
return (
|
||||
<Card
|
||||
@@ -233,14 +254,20 @@ export const ChatMessageComponent: React.FC<ChatMessageProps> = ({
|
||||
)}
|
||||
>
|
||||
<div className="flex w-[4rem] flex-shrink-0 flex-col gap-1">
|
||||
<Button
|
||||
onClick={toggleRole}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className="h-6 w-full px-1 py-0 text-[10px] font-semibold text-muted-foreground hover:bg-accent hover:text-accent-foreground"
|
||||
>
|
||||
{capitalize(message.role)}
|
||||
</Button>
|
||||
{isPlaceholder ? (
|
||||
<span className="inline-flex items-center justify-center font-mono h-6 w-full text-[9px] rounded-md bg-accent px-4 text-muted-foreground">
|
||||
placeholder
|
||||
</span>
|
||||
) : (
|
||||
<Button
|
||||
onClick={toggleRole}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className="h-6 w-full px-1 py-0 text-[10px] font-semibold text-muted-foreground hover:bg-accent hover:text-accent-foreground"
|
||||
>
|
||||
{capitalize(message.role)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col gap-1">
|
||||
<div className="flex gap-2">
|
||||
@@ -271,11 +298,19 @@ export const ChatMessageComponent: React.FC<ChatMessageProps> = ({
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
<MemoizedEditor
|
||||
value={message.content}
|
||||
onChange={onValueChange}
|
||||
role={message.role}
|
||||
/>
|
||||
{isPlaceholder ? (
|
||||
<MemoizedEditor
|
||||
value={(message as PlaceholderMessage).name || ""}
|
||||
onChange={onPlaceholderNameChange}
|
||||
role={message.type}
|
||||
/>
|
||||
) : (
|
||||
<MemoizedEditor
|
||||
value={message.content}
|
||||
onChange={onValueChange}
|
||||
role={message.role}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{message.type === ChatMessageType.AssistantToolCall && (
|
||||
<ToolCalls toolCalls={message.toolCalls as LLMToolCall[]} />
|
||||
@@ -303,7 +338,7 @@ const MemoizedEditor = memo(function MemoizedEditor(props: {
|
||||
onChange: (value: string) => void;
|
||||
}) {
|
||||
const { value, role, onChange } = props;
|
||||
const placeholder = `Enter ${getRoleNamePlaceholder(role)} message here.`;
|
||||
const placeholder = `Enter ${getRoleNamePlaceholder(role)} here.`;
|
||||
|
||||
return (
|
||||
<CodeMirrorEditor
|
||||
|
||||
@@ -2,9 +2,16 @@ import { PlusCircleIcon } from "lucide-react";
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
import { Button } from "@/src/components/ui/button";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/src/components/ui/tooltip";
|
||||
import {
|
||||
ChatMessageRole,
|
||||
ChatMessageType,
|
||||
type ChatMessageWithId,
|
||||
SYSTEM_ROLES,
|
||||
} from "@langfuse/shared";
|
||||
|
||||
@@ -60,8 +67,9 @@ export const ChatMessages: React.FC<ChatMessagesProps> = (props) => {
|
||||
if (newIndex < 0 || oldIndex < 0) {
|
||||
return;
|
||||
}
|
||||
// prevent reordering system messages
|
||||
if (SYSTEM_ROLES.includes(messages[newIndex].role)) {
|
||||
// prevent reordering system messages, placeholders can be reordered
|
||||
const targetMessage = messages[newIndex];
|
||||
if (targetMessage.type !== ChatMessageType.Placeholder && SYSTEM_ROLES.includes(targetMessage.role)) {
|
||||
return;
|
||||
}
|
||||
const newMessages = arrayMove(messages, oldIndex, newIndex);
|
||||
@@ -114,35 +122,66 @@ const AddMessageButton: React.FC<AddMessageButtonProps> = ({
|
||||
messages,
|
||||
addMessage,
|
||||
}) => {
|
||||
const lastMessageRole = messages[messages.length - 1]?.role;
|
||||
// Skip placeholder messages when determining last roles
|
||||
const lastMessageWithRole = messages.slice().reverse().find((msg): msg is ChatMessageWithId & { role: string } => msg.type !== ChatMessageType.Placeholder);
|
||||
const lastMessageRole = lastMessageWithRole?.role;
|
||||
const nextMessageRole =
|
||||
lastMessageRole === ChatMessageRole.User
|
||||
? ChatMessageRole.Assistant
|
||||
: ChatMessageRole.User;
|
||||
|
||||
const addRegularMessage = () => {
|
||||
if (nextMessageRole === ChatMessageRole.User) {
|
||||
addMessage({
|
||||
role: nextMessageRole,
|
||||
content: "",
|
||||
type: ChatMessageType.User,
|
||||
});
|
||||
} else {
|
||||
addMessage({
|
||||
role: nextMessageRole,
|
||||
content: "",
|
||||
type: ChatMessageType.AssistantText,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const addPlaceholderMessage = () => {
|
||||
addMessage({
|
||||
type: ChatMessageType.Placeholder,
|
||||
name: "",
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
type="button" // prevents submitting a form if this button is inside a form
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={() => {
|
||||
if (nextMessageRole === ChatMessageRole.User) {
|
||||
addMessage({
|
||||
role: nextMessageRole,
|
||||
content: "",
|
||||
type: ChatMessageType.User,
|
||||
});
|
||||
} else {
|
||||
addMessage({
|
||||
role: nextMessageRole,
|
||||
content: "",
|
||||
type: ChatMessageType.AssistantText,
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
<PlusCircleIcon size={14} className="mr-2" />
|
||||
<p>Add message</p>
|
||||
</Button>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="flex-1"
|
||||
onClick={addRegularMessage}
|
||||
>
|
||||
<PlusCircleIcon size={14} className="mr-2" />
|
||||
<p>Add message</p>
|
||||
</Button>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="flex-1"
|
||||
onClick={addPlaceholderMessage}
|
||||
>
|
||||
<PlusCircleIcon size={14} className="mr-2" />
|
||||
<p>Add message placeholder</p>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p className="text-xs">Adds a placeholder to inject message pairs, e.g. a message history (with "role", "content" pairs) when compiling the message in the SDK.</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import type { ChatMessage, ChatMessageWithId } from "@langfuse/shared";
|
||||
import type { ChatMessage, ChatMessageWithId, PlaceholderMessage } from "@langfuse/shared";
|
||||
|
||||
export type MessagesContext = {
|
||||
messages: ChatMessageWithId[];
|
||||
addMessage: (message: ChatMessage) => ChatMessageWithId;
|
||||
addMessage: (message: ChatMessage | PlaceholderMessage) => ChatMessageWithId;
|
||||
setMessages: (messages: ChatMessageWithId[]) => void;
|
||||
deleteMessage: (id: string) => void;
|
||||
updateMessage: <
|
||||
T extends ChatMessageWithId["type"],
|
||||
Key extends keyof Omit<
|
||||
Extract<ChatMessageWithId, { type: T }>,
|
||||
"id" | "type" | "role"
|
||||
"id" | "type"
|
||||
>,
|
||||
Value = Extract<ChatMessageWithId, { type: T }>[Key],
|
||||
>(
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { type ChatMessage, type ChatMessageWithId } from "@langfuse/shared";
|
||||
import { type ChatMessage, type ChatMessageWithIdNoPlaceholders } from "@langfuse/shared";
|
||||
|
||||
export function createEmptyMessage(message: ChatMessage): ChatMessageWithId {
|
||||
export function createEmptyMessage(message: ChatMessage): ChatMessageWithIdNoPlaceholders {
|
||||
return {
|
||||
...message,
|
||||
content: message.content ?? "",
|
||||
|
||||
@@ -338,6 +338,7 @@ export function DeleteEvalConfigButton(props: DeleteButtonProps) {
|
||||
source: isTableAction ? "table-single-row" : "eval config detail",
|
||||
})
|
||||
}
|
||||
customDeletePrompt="This action cannot be undone and removes all logs associated with this running evaluator. Scores produced by this evaluator will not be deleted."
|
||||
entityToDeleteName="running evaluator"
|
||||
executeDeleteMutation={executeDeleteMutation}
|
||||
isDeleteMutationLoading={evaluatorMutation.isLoading}
|
||||
|
||||
@@ -289,13 +289,13 @@ export default function Layout(props: PropsWithChildren) {
|
||||
rel="icon"
|
||||
type="image/png"
|
||||
sizes="32x32"
|
||||
href={`${env.NEXT_PUBLIC_BASE_PATH ?? ""}/favicon-32x32.png`}
|
||||
href={`${env.NEXT_PUBLIC_BASE_PATH ?? ""}/favicon-32x32${env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION === "DEV" ? "-dev" : ""}.png`}
|
||||
/>
|
||||
<link
|
||||
rel="icon"
|
||||
type="image/png"
|
||||
sizes="16x16"
|
||||
href={`${env.NEXT_PUBLIC_BASE_PATH ?? ""}/favicon-16x16.png`}
|
||||
href={`${env.NEXT_PUBLIC_BASE_PATH ?? ""}/favicon-16x16${env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION === "DEV" ? "-dev" : ""}.png`}
|
||||
/>
|
||||
</Head>
|
||||
<div>
|
||||
|
||||
@@ -29,6 +29,15 @@ type SidebarNotification = {
|
||||
};
|
||||
|
||||
const notifications: SidebarNotification[] = [
|
||||
{
|
||||
id: "python-sdk-v3",
|
||||
title: "New Python SDK v3",
|
||||
description:
|
||||
"Python SDK V3 offers significant improvements in developer experience, performance, and integrations.",
|
||||
link: "https://langfuse.com/docs/sdk/python/sdk-v3#upgrade-from-v2",
|
||||
linkTitle: "Upgrade to v3",
|
||||
createdAt: "2025-06-27",
|
||||
},
|
||||
{
|
||||
id: "lw3-5",
|
||||
title: "Launch Week #3: Day 5",
|
||||
@@ -102,7 +111,7 @@ const notifications: SidebarNotification[] = [
|
||||
linkContent: (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
alt="Langfuse Github stars"
|
||||
alt="Langfuse GitHub stars"
|
||||
src="https://img.shields.io/github/stars/langfuse/langfuse?label=langfuse&style=social"
|
||||
/>
|
||||
),
|
||||
|
||||
@@ -125,11 +125,12 @@ export const ChatMlMessageSchema = z
|
||||
...other,
|
||||
...additional_kwargs,
|
||||
}))
|
||||
.transform(({ role, name, content, audio, ...other }) => ({
|
||||
.transform(({ role, name, content, audio, type, ...other }) => ({
|
||||
role,
|
||||
name,
|
||||
content,
|
||||
audio,
|
||||
type,
|
||||
...(Object.keys(other).length === 0 ? {} : { json: other }),
|
||||
}));
|
||||
export type ChatMlMessageSchema = z.infer<typeof ChatMlMessageSchema>;
|
||||
|
||||
@@ -234,10 +234,14 @@ export const OpenAiMessageView: React.FC<{
|
||||
return !!message.json;
|
||||
};
|
||||
|
||||
const isPlaceholderMessage = (message: ChatMlMessageSchema) => {
|
||||
return message.type === 'placeholder';
|
||||
};
|
||||
|
||||
const messagesToRender = useMemo(
|
||||
() =>
|
||||
messages.filter(
|
||||
(message) => shouldRenderContent(message) || shouldRenderJson(message),
|
||||
(message) => shouldRenderContent(message) || shouldRenderJson(message) || isPlaceholderMessage(message),
|
||||
),
|
||||
[messages],
|
||||
);
|
||||
@@ -257,39 +261,57 @@ export const OpenAiMessageView: React.FC<{
|
||||
)
|
||||
.map((message, index) => (
|
||||
<Fragment key={index}>
|
||||
{shouldRenderContent(message) &&
|
||||
(shouldRenderMarkdown ? (
|
||||
{isPlaceholderMessage(message) ? (
|
||||
shouldRenderMarkdown ? (
|
||||
<MarkdownJsonView
|
||||
title={message.name ?? message.role}
|
||||
content={message.content || '""'}
|
||||
className={cn(!!message.json && "rounded-b-none")}
|
||||
customCodeHeaderClassName={cn(
|
||||
message.role === "assistant" && "bg-secondary",
|
||||
message.role === "system" && "bg-primary-foreground",
|
||||
)}
|
||||
audio={message.audio}
|
||||
title="Placeholder"
|
||||
content={message.name || 'Unnamed placeholder'}
|
||||
customCodeHeaderClassName={cn("bg-primary-foreground")}
|
||||
/>
|
||||
) : (
|
||||
<JSONView
|
||||
title={message.name ?? message.role}
|
||||
json={message.content}
|
||||
title="Placeholder"
|
||||
json={message.name || 'Unnamed placeholder'}
|
||||
projectIdForPromptButtons={projectIdForPromptButtons}
|
||||
className={cn(!!message.json && "rounded-b-none")}
|
||||
/>
|
||||
))}
|
||||
{shouldRenderJson(message) && (
|
||||
<JSONView
|
||||
title={
|
||||
message.content
|
||||
? undefined
|
||||
: (message.name ?? message.role)
|
||||
}
|
||||
json={message.json}
|
||||
projectIdForPromptButtons={projectIdForPromptButtons}
|
||||
className={cn(
|
||||
!!message.content && "rounded-t-none border-t-0",
|
||||
)
|
||||
) : (
|
||||
<>
|
||||
{shouldRenderContent(message) &&
|
||||
(shouldRenderMarkdown ? (
|
||||
<MarkdownJsonView
|
||||
title={message.name ?? message.role}
|
||||
content={message.content || '""'}
|
||||
className={cn(!!message.json && !isPlaceholderMessage(message) && "rounded-b-none")}
|
||||
customCodeHeaderClassName={cn(
|
||||
message.role === "assistant" && "bg-secondary",
|
||||
message.role === "system" && "bg-primary-foreground",
|
||||
)}
|
||||
audio={message.audio}
|
||||
/>
|
||||
) : (
|
||||
<JSONView
|
||||
title={message.name ?? message.role}
|
||||
json={message.content}
|
||||
projectIdForPromptButtons={projectIdForPromptButtons}
|
||||
className={cn(!!message.json && !isPlaceholderMessage(message) && "rounded-b-none")}
|
||||
/>
|
||||
))}
|
||||
{shouldRenderJson(message) && !isPlaceholderMessage(message) && (
|
||||
<JSONView
|
||||
title={
|
||||
message.content
|
||||
? undefined
|
||||
: (message.name ?? message.role)
|
||||
}
|
||||
json={message.json}
|
||||
projectIdForPromptButtons={projectIdForPromptButtons}
|
||||
className={cn(
|
||||
!!message.content && "rounded-t-none border-t-0",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{isCollapsed !== null && index === 0 ? (
|
||||
<Button
|
||||
|
||||
@@ -390,17 +390,18 @@ const ObservationTreeNodeCard = ({
|
||||
)}
|
||||
ref={currentObservationRef}
|
||||
>
|
||||
{/* Type badge */}
|
||||
<ItemBadge
|
||||
type={observation.type}
|
||||
isSmall
|
||||
className="flex-shrink-0 scale-75"
|
||||
/>
|
||||
{/* Type badge and name */}
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<ItemBadge
|
||||
type={observation.type}
|
||||
isSmall
|
||||
className="flex-shrink-0 scale-75"
|
||||
/>
|
||||
<span className="text-sm font-medium whitespace-nowrap">{observation.name}</span>
|
||||
</div>
|
||||
|
||||
{/* Name and duration */}
|
||||
{/* Duration and Comments */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium">{observation.name}</span>
|
||||
|
||||
{comments && showComments ? (
|
||||
<CommentCountIcon count={comments.get(observation.id)} />
|
||||
) : null}
|
||||
|
||||
@@ -1 +1 @@
|
||||
export const VERSION = "v3.75.3";
|
||||
export const VERSION = "v3.78.1";
|
||||
|
||||
@@ -120,6 +120,7 @@ export const DatasetForm = (props: DatasetFormProps) => {
|
||||
allNames: allDatasetNames,
|
||||
form,
|
||||
errorMessage: "Dataset name already exists.",
|
||||
whitelistedName: props.mode === "update" ? props.datasetName : undefined,
|
||||
});
|
||||
|
||||
function onSubmit(values: z.infer<typeof formSchema>) {
|
||||
|
||||
@@ -110,7 +110,11 @@ export function DatasetsTable(props: { projectId: string }) {
|
||||
size: 200,
|
||||
cell: ({ row }) => {
|
||||
const description: RowData["description"] = row.getValue("description");
|
||||
return <div className="h-full overflow-y-auto flex items-center">{description}</div>;
|
||||
return (
|
||||
<div className="flex h-full items-center overflow-y-auto">
|
||||
{description}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -189,6 +193,7 @@ export function DatasetsTable(props: { projectId: string }) {
|
||||
datasetId={key.id}
|
||||
datasetName={key.name}
|
||||
datasetDescription={row.getValue("description") ?? undefined}
|
||||
datasetMetadata={row.getValue("metadata") ?? undefined}
|
||||
/>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
|
||||
@@ -6,7 +6,7 @@ import { cn } from "@/src/utils/tailwind";
|
||||
import { type RouterOutput } from "@/src/utils/types";
|
||||
import { type EvalTemplate } from "@langfuse/shared";
|
||||
import Link from "next/link";
|
||||
import { Fragment } from "react";
|
||||
import { Fragment, useMemo } from "react";
|
||||
|
||||
const VARIABLE_COLORS = [
|
||||
"text-primary-accent",
|
||||
@@ -128,9 +128,14 @@ export const EvaluationPromptPreview = ({
|
||||
className?: string;
|
||||
controlButtons?: React.ReactNode;
|
||||
}) => {
|
||||
const memoizedVariables = useMemo(
|
||||
() => variableMapping.map(({ templateVariable }) => templateVariable),
|
||||
[variableMapping],
|
||||
);
|
||||
|
||||
const { extractedVariables, isExtracting } = useExtractVariables({
|
||||
variables: variableMapping.map(({ templateVariable }) => templateVariable),
|
||||
variableMapping: variableMapping,
|
||||
variables: memoizedVariables,
|
||||
variableMapping,
|
||||
trace: trace,
|
||||
isLoading,
|
||||
});
|
||||
|
||||
@@ -396,21 +396,27 @@ export const InnerEvaluatorForm = (props: {
|
||||
<div className="flex items-center gap-2">
|
||||
{form.watch("target") === "trace" && !props.disabled && (
|
||||
<>
|
||||
<span className="text-xs text-muted-foreground">Show preview</span>
|
||||
<span className="text-xs text-muted-foreground">Show Preview</span>
|
||||
<Switch
|
||||
checked={showPreview}
|
||||
onCheckedChange={setShowPreview}
|
||||
disabled={props.disabled}
|
||||
/>
|
||||
{traceWithObservations && showPreview && (
|
||||
<DetailPageNav
|
||||
currentId={traceWithObservations.id}
|
||||
listKey="traces"
|
||||
path={(entry) =>
|
||||
`/project/${props.projectId}/evals/new?evaluator=${props.evalTemplate.id}&traceId=${entry.id}`
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{showPreview &&
|
||||
(traceWithObservations ? (
|
||||
<DetailPageNav
|
||||
currentId={traceWithObservations.id}
|
||||
listKey="traces"
|
||||
path={(entry) =>
|
||||
`/project/${props.projectId}/evals/new?evaluator=${props.evalTemplate.id}&traceId=${entry.id}`
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-row gap-1">
|
||||
<Skeleton className="h-8 w-[54px]" />
|
||||
<Skeleton className="h-8 w-[54px]" />
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -723,11 +729,12 @@ export const InnerEvaluatorForm = (props: {
|
||||
controlButtons={mappingControlButtons}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex max-h-full min-h-[200px] w-full flex-col gap-1 lg:w-2/3">
|
||||
<div className="flex flex-row items-end justify-between">
|
||||
<span className="h-fit px-1 text-start text-sm font-medium">
|
||||
Evaluation Prompt
|
||||
</span>
|
||||
<div className="flex max-h-full min-h-48 w-full flex-col gap-1 lg:w-2/3">
|
||||
<div className="flex flex-row items-center justify-between py-0 text-sm font-medium capitalize">
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
Evaluation Prompt Preview
|
||||
<Skeleton className="h-[25px] w-[63px]" />
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
{mappingControlButtons}
|
||||
</div>
|
||||
|
||||
@@ -44,7 +44,8 @@ export function useExtractVariables({
|
||||
const previousMappingRef = useRef<string>("");
|
||||
|
||||
// Create a stable string representation of the current mapping for comparison
|
||||
const currentMappingString = JSON.stringify(variableMapping);
|
||||
const currentMappingString =
|
||||
variables.length > 0 ? JSON.stringify(variableMapping) : "";
|
||||
|
||||
// Create a stable reference to the trace ID
|
||||
const traceId = trace?.id;
|
||||
@@ -59,10 +60,14 @@ export function useExtractVariables({
|
||||
|
||||
useEffect(() => {
|
||||
// Return early conditions
|
||||
if (isLoading || !variables.length) {
|
||||
setExtractedVariables(
|
||||
variables.map((variable) => ({ variable, value: "n/a" })),
|
||||
);
|
||||
if (isLoading) {
|
||||
setExtractedVariables([]);
|
||||
return;
|
||||
}
|
||||
|
||||
// If no variables, only update if current state is not empty
|
||||
if (!Boolean(variables.length)) {
|
||||
setExtractedVariables((prev) => (prev.length === 0 ? prev : []));
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -638,7 +638,7 @@ export const CreateExperimentsForm = ({
|
||||
<span>
|
||||
Given current prompt, dataset item input must
|
||||
contain at least one of these first-level JSON
|
||||
keys, mapped to a string value:
|
||||
keys:
|
||||
</span>
|
||||
<ul className="my-2 ml-2 list-inside list-disc">
|
||||
{expectedColumns.map((col) => (
|
||||
@@ -646,8 +646,9 @@ export const CreateExperimentsForm = ({
|
||||
))}
|
||||
</ul>
|
||||
<span>
|
||||
These will be used as the input to your
|
||||
prompt.
|
||||
Variables (like {"{{variable}}"}) should be mapped to string values.
|
||||
Placeholders should be mapped to arrays of message objects.
|
||||
These will be used as the input to your prompt.
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
@@ -721,7 +722,7 @@ export const CreateExperimentsForm = ({
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
</CardTitle>
|
||||
<CardDescription className="text-foreground">
|
||||
Checking dataset items against prompt variables
|
||||
Checking dataset items against prompt variables and placeholders
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
@@ -747,7 +748,7 @@ export const CreateExperimentsForm = ({
|
||||
<CircleCheck className="h-4 w-4" />
|
||||
</CardTitle>
|
||||
<div className="text-sm">
|
||||
Matches between dataset items and prompt variables
|
||||
Matches between dataset items and prompt variables/placeholders
|
||||
<ul className="my-2 ml-2 list-inside list-disc">
|
||||
{Object.entries(
|
||||
validationResult.data.variablesMap ?? {},
|
||||
@@ -760,7 +761,7 @@ export const CreateExperimentsForm = ({
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
Items missing all prompt variables will be excluded from
|
||||
Items missing all required variables and placeholders will be excluded from
|
||||
the experiment.
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { useMemo } from "react";
|
||||
import { api } from "@/src/utils/api";
|
||||
import { type UseFormReturn } from "react-hook-form";
|
||||
import { extractVariables } from "@langfuse/shared";
|
||||
import { PromptType } from "@/src/features/prompts/server/utils/validation";
|
||||
import { extractVariables, PromptType, extractPlaceholderNames, type PromptMessage } from "@langfuse/shared";
|
||||
|
||||
type ExperimentPromptDataProps = {
|
||||
projectId: string;
|
||||
@@ -23,11 +22,18 @@ export function useExperimentPromptData({
|
||||
const prompt = promptMeta.data?.find((p) => p.id === promptId);
|
||||
if (!prompt) return [];
|
||||
|
||||
return extractVariables(
|
||||
const extractedVariables = extractVariables(
|
||||
prompt.type === PromptType.Text
|
||||
? (prompt?.prompt?.toString() ?? "")
|
||||
: JSON.stringify(prompt?.prompt),
|
||||
);
|
||||
|
||||
const promptMessages = prompt?.type === PromptType.Chat && Array.isArray(prompt.prompt)
|
||||
? prompt.prompt
|
||||
: [];
|
||||
const placeholderNames = extractPlaceholderNames(promptMessages as PromptMessage[]);
|
||||
|
||||
return [...extractedVariables, ...placeholderNames];
|
||||
}, [promptId, promptMeta.data]);
|
||||
|
||||
const promptsByName = useMemo(
|
||||
|
||||
@@ -2,23 +2,25 @@ import { z } from "zod/v4";
|
||||
import { randomUUID } from "crypto";
|
||||
import {
|
||||
type ExperimentMetadata,
|
||||
ExperimentCreateQueue,
|
||||
QueueJobs,
|
||||
QueueName,
|
||||
redis,
|
||||
ZodModelConfig,
|
||||
ExperimentCreateQueue,
|
||||
} from "@langfuse/shared/src/server";
|
||||
import {
|
||||
createTRPCRouter,
|
||||
protectedProjectProcedure,
|
||||
} from "@/src/server/api/trpc";
|
||||
import { PromptType } from "@/src/features/prompts/server/utils/validation";
|
||||
import {
|
||||
type DatasetItem,
|
||||
DatasetStatus,
|
||||
extractVariables,
|
||||
datasetItemMatchesVariable,
|
||||
UnauthorizedError,
|
||||
PromptType,
|
||||
extractPlaceholderNames,
|
||||
type PromptMessage,
|
||||
} from "@langfuse/shared";
|
||||
import { throwIfNoProjectAccess } from "@/src/features/rbac/utils/checkProjectAccess";
|
||||
|
||||
@@ -97,10 +99,17 @@ export const experimentsRouter = createTRPCRouter({
|
||||
: JSON.stringify(prompt.prompt),
|
||||
);
|
||||
|
||||
if (!Boolean(extractedVariables.length)) {
|
||||
const promptMessages = prompt?.type === PromptType.Chat && Array.isArray(prompt.prompt)
|
||||
? prompt.prompt
|
||||
: [];
|
||||
const placeholderNames = extractPlaceholderNames(promptMessages as PromptMessage[]);
|
||||
|
||||
const allVariables = [...extractedVariables, ...placeholderNames];
|
||||
|
||||
if (!Boolean(allVariables.length)) {
|
||||
return {
|
||||
isValid: false,
|
||||
message: "Selected prompt has no variables.",
|
||||
message: "Selected prompt has no variables or placeholders.",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -121,7 +130,7 @@ export const experimentsRouter = createTRPCRouter({
|
||||
|
||||
const variablesMap = validateDatasetItems(
|
||||
datasetItems,
|
||||
extractedVariables,
|
||||
allVariables,
|
||||
);
|
||||
|
||||
if (!Boolean(Object.keys(variablesMap).length)) {
|
||||
|
||||
@@ -310,7 +310,11 @@ export class OtelIngestionProcessor {
|
||||
scopeSpan.scope?.name.startsWith("langfuse-sdk") ?? false;
|
||||
const scopeAttributes = this.extractScopeAttributes(scopeSpan);
|
||||
|
||||
this.validatePublicKey(isLangfuseSDKSpans, scopeAttributes);
|
||||
this.validatePublicKey(
|
||||
isLangfuseSDKSpans,
|
||||
scopeAttributes,
|
||||
resourceAttributes,
|
||||
);
|
||||
|
||||
if (isLangfuseSDKSpans) {
|
||||
recordIncrement("langfuse.otel.ingestion.langfuse_sdk_batch", 1);
|
||||
@@ -598,11 +602,15 @@ export class OtelIngestionProcessor {
|
||||
private validatePublicKey(
|
||||
isLangfuseSDKSpans: boolean,
|
||||
scopeAttributes: Record<string, unknown>,
|
||||
resourceAttributes: Record<string, unknown>,
|
||||
): void {
|
||||
if (
|
||||
isLangfuseSDKSpans &&
|
||||
(!this.publicKey ||
|
||||
(scopeAttributes["public_key"] as unknown as string) !== this.publicKey)
|
||||
(scopeAttributes["public_key"] as unknown as string) !==
|
||||
this.publicKey) &&
|
||||
(resourceAttributes["telemetry.sdk.language"] as unknown as string) ===
|
||||
"python" // Only Python has multi project setups. Node OTEL does not allow setting scope.attributes, thus skipping the check for node
|
||||
) {
|
||||
throw new ForbiddenError(
|
||||
`Langfuse OTEL SDK span has different public key '${scopeAttributes["public_key"]}' than used for authentication '${this.publicKey}'. Discarding span.`,
|
||||
@@ -620,6 +628,8 @@ export class OtelIngestionProcessor {
|
||||
LangfuseOtelSpanAttributes.TRACE_SESSION_ID,
|
||||
LangfuseOtelSpanAttributes.TRACE_PUBLIC,
|
||||
LangfuseOtelSpanAttributes.TRACE_TAGS,
|
||||
LangfuseOtelSpanAttributes.TRACE_COMPAT_USER_ID,
|
||||
LangfuseOtelSpanAttributes.TRACE_COMPAT_SESSION_ID,
|
||||
`${LangfuseOtelSpanAttributes.OBSERVATION_METADATA}.langfuse_user_id`,
|
||||
`${LangfuseOtelSpanAttributes.OBSERVATION_METADATA}.langfuse_session_id`,
|
||||
`${LangfuseOtelSpanAttributes.OBSERVATION_METADATA}.langfuse_tags`,
|
||||
|
||||
@@ -33,4 +33,9 @@ export enum LangfuseOtelSpanAttributes {
|
||||
|
||||
// Internal
|
||||
AS_ROOT = "langfuse.internal.as_root",
|
||||
|
||||
// Compatibility - Map properties that were documented in https://langfuse.com/docs/opentelemetry/get-started#property-mapping,
|
||||
// but have a new assignment
|
||||
TRACE_COMPAT_USER_ID = "langfuse.user.id",
|
||||
TRACE_COMPAT_SESSION_ID = "langfuse.session.id",
|
||||
}
|
||||
|
||||
@@ -7,12 +7,11 @@ import { createEmptyMessage } from "@/src/components/ChatMessages/utils/createEm
|
||||
import { Button } from "@/src/components/ui/button";
|
||||
import usePlaygroundCache from "@/src/features/playground/page/hooks/usePlaygroundCache";
|
||||
import {
|
||||
type PlaygroundTool,
|
||||
type PlaygroundCache,
|
||||
type PlaygroundSchema,
|
||||
type PlaygroundTool,
|
||||
} from "@/src/features/playground/page/types";
|
||||
import { usePostHogClientCapture } from "@/src/features/posthog-analytics/usePostHogClientCapture";
|
||||
import { PromptType } from "@/src/features/prompts/server/utils/validation";
|
||||
import useProjectIdFromURL from "@/src/hooks/useProjectIdFromURL";
|
||||
import {
|
||||
ChatMessageRole,
|
||||
@@ -28,6 +27,10 @@ import {
|
||||
type ChatMessage,
|
||||
OpenAIResponseFormatSchema,
|
||||
type Prisma,
|
||||
PlaceholderMessageSchema,
|
||||
type PlaceholderMessage,
|
||||
isPlaceholder,
|
||||
PromptType,
|
||||
} from "@langfuse/shared";
|
||||
import { api } from "@/src/utils/api";
|
||||
import { cn } from "@/src/utils/tailwind";
|
||||
@@ -119,7 +122,7 @@ export const JumpToPlaygroundButton: React.FC<JumpToPlaygroundButtonProps> = (
|
||||
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."
|
||||
: "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
|
||||
@@ -138,34 +141,38 @@ export const JumpToPlaygroundButton: React.FC<JumpToPlaygroundButtonProps> = (
|
||||
};
|
||||
|
||||
const ParsedChatMessageListSchema = z.array(
|
||||
z.object({
|
||||
role: z.enum(ChatMessageRole),
|
||||
content: z.union([
|
||||
z.string(),
|
||||
z
|
||||
.array(
|
||||
z
|
||||
.object({
|
||||
text: z.string(),
|
||||
})
|
||||
.transform((v) => v.text),
|
||||
)
|
||||
.transform((v) => v.join("")),
|
||||
z.union([z.null(), z.undefined()]).transform((_) => ""),
|
||||
z.any().transform((v) => JSON.stringify(v, null, 2)),
|
||||
]),
|
||||
tool_calls: z
|
||||
.union([z.array(LLMToolCallSchema), z.array(OpenAIToolCallSchema)])
|
||||
.optional(),
|
||||
tool_call_id: z.string().optional(),
|
||||
additional_kwargs: z
|
||||
.object({
|
||||
tool_calls: z
|
||||
.union([z.array(LLMToolCallSchema), z.array(OpenAIToolCallSchema)])
|
||||
.optional(),
|
||||
})
|
||||
.optional(),
|
||||
}),
|
||||
z.union([
|
||||
// Regular chat message
|
||||
z.object({
|
||||
role: z.enum(ChatMessageRole),
|
||||
content: z.union([
|
||||
z.string(),
|
||||
z
|
||||
.array(
|
||||
z
|
||||
.object({
|
||||
text: z.string(),
|
||||
})
|
||||
.transform((v) => v.text),
|
||||
)
|
||||
.transform((v) => v.join("")),
|
||||
z.union([z.null(), z.undefined()]).transform((_) => ""),
|
||||
z.any().transform((v) => JSON.stringify(v, null, 2)),
|
||||
]),
|
||||
tool_calls: z
|
||||
.union([z.array(LLMToolCallSchema), z.array(OpenAIToolCallSchema)])
|
||||
.optional(),
|
||||
tool_call_id: z.string().optional(),
|
||||
additional_kwargs: z
|
||||
.object({
|
||||
tool_calls: z
|
||||
.union([z.array(LLMToolCallSchema), z.array(OpenAIToolCallSchema)])
|
||||
.optional(),
|
||||
})
|
||||
.optional(),
|
||||
}),
|
||||
PlaceholderMessageSchema,
|
||||
])
|
||||
);
|
||||
|
||||
// Langchain integration has the tool definition in a tool message
|
||||
@@ -173,6 +180,9 @@ const ParsedChatMessageListSchema = z.array(
|
||||
const isLangchainToolDefinitionMessage = (
|
||||
message: z.infer<typeof ParsedChatMessageListSchema>[0],
|
||||
): message is { content: string; role: ChatMessageRole } => {
|
||||
if (!("content" in message) || typeof message.content !== "string") {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
return OpenAIToolSchema.safeParse(JSON.parse(message.content)).success;
|
||||
} catch {
|
||||
@@ -182,15 +192,22 @@ const isLangchainToolDefinitionMessage = (
|
||||
|
||||
const transformToPlaygroundMessage = (
|
||||
message: z.infer<typeof ParsedChatMessageListSchema>[0],
|
||||
): ChatMessage => {
|
||||
const { role, content } = message;
|
||||
): ChatMessage | PlaceholderMessage => {
|
||||
// Return placeholder messages as-is
|
||||
if (isPlaceholder(message)) {
|
||||
return message;
|
||||
}
|
||||
|
||||
// Handle regular chat messages - remove the placeholder type
|
||||
const regularMessage = message as Exclude<typeof message, PlaceholderMessage>;
|
||||
const { role, content } = regularMessage;
|
||||
|
||||
if (
|
||||
message.role === "assistant" &&
|
||||
(message.tool_calls || message.additional_kwargs?.tool_calls)
|
||||
regularMessage.role === "assistant" &&
|
||||
(regularMessage.tool_calls || regularMessage.additional_kwargs?.tool_calls)
|
||||
) {
|
||||
const toolCalls =
|
||||
message.tool_calls ?? message.additional_kwargs?.tool_calls ?? [];
|
||||
regularMessage.tool_calls ?? regularMessage.additional_kwargs?.tool_calls ?? [];
|
||||
|
||||
const playgroundMessage: ChatMessage = {
|
||||
role: ChatMessageRole.Assistant,
|
||||
@@ -210,12 +227,12 @@ const transformToPlaygroundMessage = (
|
||||
};
|
||||
|
||||
return playgroundMessage;
|
||||
} else if (message.role === "tool") {
|
||||
} else if (regularMessage.role === "tool") {
|
||||
const playgroundMessage: ChatMessage = {
|
||||
role: ChatMessageRole.Tool,
|
||||
content,
|
||||
type: ChatMessageType.ToolResult,
|
||||
toolCallId: message.tool_call_id ?? "",
|
||||
toolCallId: regularMessage.tool_call_id ?? "",
|
||||
};
|
||||
|
||||
return playgroundMessage;
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { CheckCircle2, Circle, TrashIcon } from "lucide-react";
|
||||
import { Button } from "@/src/components/ui/button";
|
||||
import { CodeMirrorEditor } from "@/src/components/editor";
|
||||
import { useState, useCallback } from "react";
|
||||
import { type ChatMessage, PromptChatMessageListSchema } from "@langfuse/shared";
|
||||
|
||||
import { usePlaygroundContext } from "../context";
|
||||
import { type PlaceholderMessageFillIn } from "../types";
|
||||
import { useNamingConflicts } from "../hooks/useNamingConflicts";
|
||||
|
||||
export const MessagePlaceholderComponent: React.FC<{
|
||||
messagePlaceholder: PlaceholderMessageFillIn;
|
||||
}> = ({ messagePlaceholder }) => {
|
||||
const { updateMessagePlaceholderValue, deleteMessagePlaceholder, promptVariables, messagePlaceholders } =
|
||||
usePlaygroundContext();
|
||||
const { name, value, isUsed } = messagePlaceholder;
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const { isPlaceholderConflicting } = useNamingConflicts(promptVariables, messagePlaceholders);
|
||||
const hasConflict = isPlaceholderConflicting(name);
|
||||
|
||||
const handleInputChange = useCallback((jsonString: string) => {
|
||||
try {
|
||||
const parsed = jsonString.trim() === "" ? [] : JSON.parse(jsonString);
|
||||
const result = PromptChatMessageListSchema.safeParse(parsed);
|
||||
|
||||
if (result.success) {
|
||||
updateMessagePlaceholderValue(name, result.data as ChatMessage[]);
|
||||
setError(null);
|
||||
} else {
|
||||
setError(result.error.issues[0]?.message || "Invalid chat message format, ensure it has role and content keys.");
|
||||
}
|
||||
} catch {
|
||||
setError("Invalid JSON format");
|
||||
}
|
||||
}, [name, updateMessagePlaceholderValue]);
|
||||
|
||||
const UsedIcon = isUsed ? CheckCircle2 : Circle;
|
||||
const iconColor = isUsed ? "green" : "grey";
|
||||
|
||||
return (
|
||||
<div className="p-1">
|
||||
<div className="mb-1 flex flex-row items-center">
|
||||
<span className="flex flex-1 flex-row space-x-2 text-xs">
|
||||
<UsedIcon size={16} color={iconColor} />
|
||||
<p className={`min-w-[90px] truncate font-mono ${hasConflict ? 'text-red-500' : ''}`} title={name}>
|
||||
{name ? name : "Unnamed placeholder"}
|
||||
</p>
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
title="Delete placeholder"
|
||||
disabled={isUsed}
|
||||
onClick={() => deleteMessagePlaceholder(name)}
|
||||
className="p-0"
|
||||
>
|
||||
{!isUsed && <TrashIcon size={16} />}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<CodeMirrorEditor
|
||||
value={value.length === 0 ? `[\n {\n "role": "",\n "content": ""\n }\n]` : JSON.stringify(value, null, 2)}
|
||||
onChange={handleInputChange}
|
||||
mode="json"
|
||||
minHeight="none"
|
||||
className={`max-h-[15rem] w-full resize-y p-1 font-mono text-xs focus:outline-none ${hasConflict ? 'border border-red-500' : ''}`}
|
||||
editable={true}
|
||||
lineNumbers={false}
|
||||
placeholder={`[\n {\n "role": "user",\n "content": "Hello!"\n },\n {\n "role": "assistant",\n "content": "Hi there!"\n }\n]`}
|
||||
/>
|
||||
|
||||
{hasConflict && (
|
||||
<p className="mt-1 text-xs text-red-500">
|
||||
Placeholder name conflicts with variable. Names must be unique.
|
||||
</p>
|
||||
)}
|
||||
{error && (
|
||||
<p className="mt-1 text-xs text-red-500">{error}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,38 @@
|
||||
import { Divider } from "@tremor/react";
|
||||
|
||||
import { usePlaygroundContext } from "../context";
|
||||
import { MessagePlaceholderComponent } from "./MessagePlaceholderComponent";
|
||||
|
||||
export const MessagePlaceholders = () => {
|
||||
const { messagePlaceholders } = usePlaygroundContext();
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<p className="font-semibold">Message Placeholders</p>
|
||||
{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>
|
||||
) : (
|
||||
<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>
|
||||
);
|
||||
};
|
||||
@@ -4,13 +4,16 @@ import { type PromptVariable } from "@langfuse/shared";
|
||||
import { CodeMirrorEditor } from "@/src/components/editor";
|
||||
|
||||
import { usePlaygroundContext } from "../context";
|
||||
import { useNamingConflicts } from "../hooks/useNamingConflicts";
|
||||
|
||||
export const PromptVariableComponent: React.FC<{
|
||||
promptVariable: PromptVariable;
|
||||
}> = ({ promptVariable }) => {
|
||||
const { updatePromptVariableValue, deletePromptVariable } =
|
||||
const { updatePromptVariableValue, deletePromptVariable, promptVariables, messagePlaceholders } =
|
||||
usePlaygroundContext();
|
||||
const { name, value, isUsed } = promptVariable;
|
||||
const { isVariableConflicting } = useNamingConflicts(promptVariables, messagePlaceholders);
|
||||
const hasConflict = isVariableConflicting(name);
|
||||
|
||||
const handleInputChange = (value: string) => {
|
||||
updatePromptVariableValue(name, value);
|
||||
@@ -32,7 +35,7 @@ export const PromptVariableComponent: React.FC<{
|
||||
<div className="mb-1 flex flex-row items-center">
|
||||
<span className="flex flex-1 flex-row space-x-2 text-xs">
|
||||
<p title={isUsedTooltip}>{isUsedIcon}</p>
|
||||
<p className="min-w-[90px] truncate font-mono" title={name}>
|
||||
<p className={`min-w-[90px] truncate font-mono ${hasConflict ? 'text-red-500' : ''}`} title={name}>
|
||||
{name}
|
||||
</p>
|
||||
</span>
|
||||
@@ -53,11 +56,17 @@ export const PromptVariableComponent: React.FC<{
|
||||
onChange={(e) => handleInputChange(e)}
|
||||
mode="prompt"
|
||||
minHeight="none"
|
||||
className="max-h-[10rem] w-full resize-y p-1 font-mono text-xs focus:outline-none"
|
||||
className={`max-h-[10rem] w-full resize-y p-1 font-mono text-xs focus:outline-none ${hasConflict ? 'border border-red-500' : ''}`}
|
||||
editable={true}
|
||||
lineNumbers={false}
|
||||
placeholder={name}
|
||||
/>
|
||||
|
||||
{hasConflict && (
|
||||
<p className="mt-1 text-xs text-red-500">
|
||||
Variable name conflicts with placeholder. Names must be unique.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -20,11 +20,11 @@ import {
|
||||
import { usePlaygroundContext } from "@/src/features/playground/page/context";
|
||||
import usePlaygroundCache from "@/src/features/playground/page/hooks/usePlaygroundCache";
|
||||
import { usePostHogClientCapture } from "@/src/features/posthog-analytics/usePostHogClientCapture";
|
||||
import { PromptType } from "@/src/features/prompts/server/utils/validation";
|
||||
import useProjectIdFromURL from "@/src/hooks/useProjectIdFromURL";
|
||||
import { api } from "@/src/utils/api";
|
||||
import { cn } from "@/src/utils/tailwind";
|
||||
import DocPopup from "@/src/components/layouts/doc-popup";
|
||||
import { PromptType } from "@langfuse/shared";
|
||||
|
||||
export const SaveToPromptButton: React.FC = () => {
|
||||
const [selectedPromptId, setSelectedPromptId] = useState("");
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
ChatMessageRole,
|
||||
extractVariables,
|
||||
type ChatMessageWithId,
|
||||
type ChatMessageWithIdNoPlaceholders,
|
||||
type PromptVariable,
|
||||
ToolCallResponseSchema,
|
||||
type UIModelParams,
|
||||
@@ -25,6 +26,9 @@ import {
|
||||
type LLMToolDefinition,
|
||||
type LLMToolCall,
|
||||
ChatMessageType,
|
||||
type ChatMessage,
|
||||
compileChatMessagesWithIds,
|
||||
type MessagePlaceholderValues,
|
||||
} from "@langfuse/shared";
|
||||
|
||||
import type { MessagesContext } from "@/src/components/ChatMessages/types";
|
||||
@@ -33,14 +37,20 @@ import { env } from "@/src/env.mjs";
|
||||
import {
|
||||
type PlaygroundSchema,
|
||||
type PlaygroundTool,
|
||||
type PlaceholderMessageFillIn,
|
||||
} from "@/src/features/playground/page/types";
|
||||
import { getFinalModelParams } from "@/src/utils/getFinalModelParams";
|
||||
|
||||
|
||||
type PlaygroundContextType = {
|
||||
promptVariables: PromptVariable[];
|
||||
updatePromptVariableValue: (variable: string, value: string) => void;
|
||||
deletePromptVariable: (variable: string) => void;
|
||||
|
||||
messagePlaceholders: PlaceholderMessageFillIn[];
|
||||
updateMessagePlaceholderValue: (name: string, value: ChatMessage[]) => void;
|
||||
deleteMessagePlaceholder: (name: string) => void;
|
||||
|
||||
tools: PlaygroundTool[];
|
||||
setTools: React.Dispatch<React.SetStateAction<PlaygroundTool[]>>;
|
||||
|
||||
@@ -77,6 +87,7 @@ export const PlaygroundProvider: React.FC<PropsWithChildren> = ({
|
||||
const projectId = useProjectIdFromURL();
|
||||
const { playgroundCache, setPlaygroundCache } = usePlaygroundCache();
|
||||
const [promptVariables, setPromptVariables] = useState<PromptVariable[]>([]);
|
||||
const [messagePlaceholders, setMessagePlaceholders] = useState<PlaceholderMessageFillIn[]>([]);
|
||||
const [output, setOutput] = useState("");
|
||||
const [outputToolCalls, setOutputToolCalls] = useState<LLMToolCall[]>([]);
|
||||
const [outputJson, setOutputJson] = useState("");
|
||||
@@ -160,7 +171,7 @@ export const PlaygroundProvider: React.FC<PropsWithChildren> = ({
|
||||
}, [playgroundCache, setModelParams]);
|
||||
|
||||
const updatePromptVariables = useCallback(() => {
|
||||
const messageContents = messages.map((m) => m.content).join("\n");
|
||||
const messageContents = messages.map((m) => ('content' in m ? m.content : m.name)).join("\n");
|
||||
const variables = extractVariables(messageContents)
|
||||
.map((v) => v.trim())
|
||||
.filter(Boolean);
|
||||
@@ -219,6 +230,10 @@ export const PlaygroundProvider: React.FC<PropsWithChildren> = ({
|
||||
]);
|
||||
|
||||
return toolCallMessage;
|
||||
} else if (message.type === ChatMessageType.Placeholder) {
|
||||
const placeholderMessage = { ...message, id: uuidv4() } as ChatMessageWithId;
|
||||
setMessages((prev) => [...prev, placeholderMessage]);
|
||||
return placeholderMessage;
|
||||
} else {
|
||||
const newMessage = createEmptyMessage(message);
|
||||
setMessages((prev) => [...prev, newMessage]);
|
||||
@@ -264,7 +279,7 @@ export const PlaygroundProvider: React.FC<PropsWithChildren> = ({
|
||||
setOutputJson("");
|
||||
setOutputToolCalls([]);
|
||||
|
||||
const finalMessages = getFinalMessages(promptVariables, messages);
|
||||
const finalMessages = getFinalMessages(promptVariables, messages, messagePlaceholders);
|
||||
const leftOverVariables = extractVariables(
|
||||
finalMessages.map((m) => m.content).join("\n"),
|
||||
);
|
||||
@@ -372,6 +387,7 @@ export const PlaygroundProvider: React.FC<PropsWithChildren> = ({
|
||||
messages,
|
||||
modelParams,
|
||||
promptVariables,
|
||||
messagePlaceholders,
|
||||
tools,
|
||||
capture,
|
||||
setPlaygroundCache,
|
||||
@@ -395,12 +411,57 @@ export const PlaygroundProvider: React.FC<PropsWithChildren> = ({
|
||||
setPromptVariables((prev) => prev.filter((v) => v.name !== variable));
|
||||
}, []);
|
||||
|
||||
const updateMessagePlaceholderValue = useCallback((name: string, value: ChatMessage[]) => {
|
||||
setMessagePlaceholders((prev) =>
|
||||
prev.map((p) => (p.name === name ? { ...p, value } : p)),
|
||||
);
|
||||
}, []);
|
||||
|
||||
const deleteMessagePlaceholder = useCallback((name: string) => {
|
||||
setMessagePlaceholders((prev) => prev.filter((p) => p.name !== name));
|
||||
}, []);
|
||||
|
||||
const updateMessagePlaceholders = useCallback(() => {
|
||||
const placeholderNames = messages
|
||||
.filter((msg): msg is ChatMessageWithId & { type: ChatMessageType.Placeholder; name: string } =>
|
||||
msg.type === ChatMessageType.Placeholder
|
||||
)
|
||||
.map((msg) => msg.name);
|
||||
|
||||
setMessagePlaceholders((prev) => {
|
||||
// Set isUsed flag for existing placeholders and remove unused ones
|
||||
const next = prev.reduce<PlaceholderMessageFillIn[]>((updatedPlaceholders, p) => {
|
||||
const isUsed = placeholderNames.includes(p.name);
|
||||
// Remove unused placeholders
|
||||
if (!isUsed && p.value.length === 0) {
|
||||
return updatedPlaceholders;
|
||||
}
|
||||
updatedPlaceholders.push({ ...p, isUsed });
|
||||
return updatedPlaceholders;
|
||||
}, []);
|
||||
|
||||
// Add new placeholders
|
||||
for (const name of placeholderNames) {
|
||||
if (!next.some((p) => p.name === name)) {
|
||||
next.push({ name, value: [], isUsed: true });
|
||||
}
|
||||
}
|
||||
|
||||
return next;
|
||||
});
|
||||
}, [messages]);
|
||||
|
||||
useEffect(updateMessagePlaceholders, [messages, updateMessagePlaceholders]);
|
||||
|
||||
return (
|
||||
<PlaygroundContext.Provider
|
||||
value={{
|
||||
promptVariables,
|
||||
updatePromptVariableValue,
|
||||
deletePromptVariable,
|
||||
messagePlaceholders,
|
||||
updateMessagePlaceholderValue,
|
||||
deleteMessagePlaceholder,
|
||||
|
||||
tools,
|
||||
setTools,
|
||||
@@ -437,7 +498,7 @@ export const PlaygroundProvider: React.FC<PropsWithChildren> = ({
|
||||
|
||||
async function getChatCompletionWithTools(
|
||||
projectId: string | undefined,
|
||||
messages: ChatMessageWithId[],
|
||||
messages: ChatMessageWithIdNoPlaceholders[],
|
||||
modelParams: UIModelParams,
|
||||
tools: unknown[],
|
||||
streaming: boolean = false,
|
||||
@@ -607,7 +668,8 @@ async function getChatCompletionNonStreaming(
|
||||
function getFinalMessages(
|
||||
promptVariables: PromptVariable[],
|
||||
messages: ChatMessageWithId[],
|
||||
) {
|
||||
messagePlaceholders: PlaceholderMessageFillIn[],
|
||||
): ChatMessageWithIdNoPlaceholders[] {
|
||||
const missingVariables = promptVariables.filter((v) => !v.value && v.isUsed);
|
||||
if (missingVariables.length > 0) {
|
||||
throw new Error(
|
||||
@@ -617,24 +679,44 @@ function getFinalMessages(
|
||||
);
|
||||
}
|
||||
|
||||
// Dynamically replace variables in the prompt
|
||||
const finalMessages = messages
|
||||
.filter(
|
||||
(m) =>
|
||||
m.content.length > 0 || ("toolCalls" in m && m.toolCalls.length > 0),
|
||||
)
|
||||
.map((m) => {
|
||||
let content = m.content;
|
||||
for (const variable of promptVariables) {
|
||||
content = content.replace(
|
||||
new RegExp(`{{\\s*${variable.name}\\s*}}`, "g"),
|
||||
variable.value,
|
||||
);
|
||||
}
|
||||
const missingPlaceholders = messagePlaceholders.filter(
|
||||
(p) => p.value.length === 0 && p.isUsed,
|
||||
);
|
||||
if (missingPlaceholders.length > 0) {
|
||||
throw new Error(
|
||||
`Please set values for the following message placeholders: ${missingPlaceholders
|
||||
.map((p) => p.name)
|
||||
.join(", ")}`,
|
||||
);
|
||||
}
|
||||
|
||||
return { ...m, content };
|
||||
});
|
||||
return finalMessages;
|
||||
const placeholderValues: MessagePlaceholderValues = messagePlaceholders.reduce(
|
||||
(placeholderMap, p) => {
|
||||
placeholderMap[p.name] = p.value;
|
||||
return placeholderMap;
|
||||
},
|
||||
{} as MessagePlaceholderValues,
|
||||
);
|
||||
|
||||
const textVariables = promptVariables.reduce(
|
||||
(variableMap, v) => {
|
||||
variableMap[v.name] = v.value;
|
||||
return variableMap;
|
||||
},
|
||||
{} as Record<string, string>,
|
||||
);
|
||||
|
||||
const compiledMessages = compileChatMessagesWithIds(
|
||||
messages,
|
||||
placeholderValues,
|
||||
textVariables,
|
||||
);
|
||||
|
||||
// Filter empty messages (except tool calls), e.g. if placeholder value was empty
|
||||
return compiledMessages.filter(
|
||||
(m) =>
|
||||
m.content.length > 0 || ("toolCalls" in m && m.toolCalls && m.toolCalls.length > 0),
|
||||
);
|
||||
}
|
||||
|
||||
function getOutputJson(
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { useMemo } from "react";
|
||||
import { type PromptVariable } from "@langfuse/shared";
|
||||
import { type PlaceholderMessageFillIn } from "../types";
|
||||
|
||||
export interface NamingConflictInfo {
|
||||
hasConflicts: boolean;
|
||||
conflictingNames: string[];
|
||||
isVariableConflicting: (variableName: string) => boolean;
|
||||
isPlaceholderConflicting: (placeholderName: string) => boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects naming conflicts between variables and placeholders in the playground.
|
||||
* Returns names of conflicting variables and placeholders.
|
||||
*/
|
||||
export const useNamingConflicts = (
|
||||
variables: PromptVariable[],
|
||||
placeholders: PlaceholderMessageFillIn[]
|
||||
): NamingConflictInfo => {
|
||||
return useMemo(() => {
|
||||
const variableNames = variables.map(v => v.name);
|
||||
const placeholderNames = placeholders.map(p => p.name);
|
||||
|
||||
const conflictingNames = variableNames.filter(name =>
|
||||
placeholderNames.includes(name)
|
||||
);
|
||||
|
||||
const hasConflicts = conflictingNames.length > 0;
|
||||
|
||||
const isVariableConflicting = (variableName: string) =>
|
||||
conflictingNames.includes(variableName);
|
||||
|
||||
const isPlaceholderConflicting = (placeholderName: string) =>
|
||||
conflictingNames.includes(placeholderName);
|
||||
|
||||
return {
|
||||
hasConflicts,
|
||||
conflictingNames,
|
||||
isVariableConflicting,
|
||||
isPlaceholderConflicting,
|
||||
};
|
||||
}, [variables, placeholders]);
|
||||
};
|
||||
@@ -1,6 +1,7 @@
|
||||
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";
|
||||
@@ -25,7 +26,10 @@ export default function Playground() {
|
||||
<StructuredOutputSchemaSection />
|
||||
</div>
|
||||
<div className="flex-grow overflow-y-auto">
|
||||
<Variables />
|
||||
<div className="space-y-6">
|
||||
<Variables />
|
||||
<MessagePlaceholders />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import {
|
||||
type PromptVariable,
|
||||
type ChatMessage,
|
||||
type UIModelParams,
|
||||
type LlmSchema,
|
||||
type LLMToolDefinition,
|
||||
type LlmTool,
|
||||
type LLMJSONSchema,
|
||||
type LlmSchema,
|
||||
type LlmTool,
|
||||
type LLMToolDefinition,
|
||||
type PlaceholderMessage,
|
||||
type PromptVariable,
|
||||
type UIModelParams,
|
||||
} from "@langfuse/shared";
|
||||
|
||||
export type PlaygroundTool = LLMToolDefinition & {
|
||||
@@ -21,12 +22,19 @@ export type PlaygroundSchema = {
|
||||
existingLlmSchema?: LlmSchema;
|
||||
};
|
||||
|
||||
export type PlaceholderMessageFillIn = {
|
||||
name: string;
|
||||
value: ChatMessage[];
|
||||
isUsed: boolean;
|
||||
};
|
||||
|
||||
export type PlaygroundCache = {
|
||||
messages: ChatMessage[];
|
||||
messages: (ChatMessage | PlaceholderMessage)[];
|
||||
modelParams?: Partial<UIModelParams> &
|
||||
Pick<UIModelParams, "provider" | "model">;
|
||||
output?: string | null;
|
||||
promptVariables?: PromptVariable[];
|
||||
// TODO: also cache placeholders?
|
||||
tools?: PlaygroundTool[];
|
||||
structuredOutputSchema?: PlaygroundSchema | null;
|
||||
} | null;
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
import { decrypt, encrypt } from "@langfuse/shared/encryption";
|
||||
import { posthogIntegrationFormSchema } from "@/src/features/posthog-integration/types";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { env } from "@/src/env.mjs";
|
||||
|
||||
export const posthogIntegrationRouter = createTRPCRouter({
|
||||
get: protectedProjectProcedure
|
||||
@@ -47,44 +48,51 @@ export const posthogIntegrationRouter = createTRPCRouter({
|
||||
update: protectedProjectProcedure
|
||||
.input(posthogIntegrationFormSchema.extend({ projectId: z.string() }))
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
try {
|
||||
throwIfNoProjectAccess({
|
||||
session: ctx.session,
|
||||
projectId: input.projectId,
|
||||
scope: "integrations:CRUD",
|
||||
});
|
||||
await auditLog({
|
||||
session: ctx.session,
|
||||
action: "update",
|
||||
resourceType: "posthogIntegration",
|
||||
resourceId: input.projectId,
|
||||
});
|
||||
const { posthogProjectApiKey, ...config } = input;
|
||||
|
||||
const encryptedPosthogApiKey = encrypt(posthogProjectApiKey);
|
||||
|
||||
await ctx.prisma.posthogIntegration.upsert({
|
||||
where: {
|
||||
projectId: input.projectId,
|
||||
},
|
||||
create: {
|
||||
projectId: input.projectId,
|
||||
posthogHostName: config.posthogHostname,
|
||||
encryptedPosthogApiKey,
|
||||
enabled: config.enabled,
|
||||
},
|
||||
update: {
|
||||
encryptedPosthogApiKey,
|
||||
posthogHostName: config.posthogHostname,
|
||||
enabled: config.enabled,
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
console.log("posthog integration update", e);
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
});
|
||||
throwIfNoProjectAccess({
|
||||
session: ctx.session,
|
||||
projectId: input.projectId,
|
||||
scope: "integrations:CRUD",
|
||||
});
|
||||
if (!env.ENCRYPTION_KEY) {
|
||||
if (env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION) {
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Internal server error",
|
||||
});
|
||||
} else {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message:
|
||||
"Missing environment variable: `ENCRYPTION_KEY`. Please consult our docs: https://langfuse.com/self-hosting",
|
||||
});
|
||||
}
|
||||
}
|
||||
await auditLog({
|
||||
session: ctx.session,
|
||||
action: "update",
|
||||
resourceType: "posthogIntegration",
|
||||
resourceId: input.projectId,
|
||||
});
|
||||
const { posthogProjectApiKey, ...config } = input;
|
||||
|
||||
const encryptedPosthogApiKey = encrypt(posthogProjectApiKey);
|
||||
|
||||
await ctx.prisma.posthogIntegration.upsert({
|
||||
where: {
|
||||
projectId: input.projectId,
|
||||
},
|
||||
create: {
|
||||
projectId: input.projectId,
|
||||
posthogHostName: config.posthogHostname,
|
||||
encryptedPosthogApiKey,
|
||||
enabled: config.enabled,
|
||||
},
|
||||
update: {
|
||||
encryptedPosthogApiKey,
|
||||
posthogHostName: config.posthogHostname,
|
||||
enabled: config.enabled,
|
||||
},
|
||||
});
|
||||
}),
|
||||
delete: protectedProjectProcedure
|
||||
.input(z.object({ projectId: z.string() }))
|
||||
|
||||
@@ -50,17 +50,31 @@ export const PromptChatMessages: React.FC<PromptChatMessagesProps> = ({
|
||||
}
|
||||
|
||||
setMessages(
|
||||
parsedMessages.data.map((message) => ({
|
||||
...message,
|
||||
id: uuidv4(),
|
||||
type: ChatMessageType.PublicAPICreated,
|
||||
})),
|
||||
parsedMessages.data.map((message) => {
|
||||
const id = uuidv4();
|
||||
// TODO: clean up - Placeholder messages could also be API created..
|
||||
if ('type' in message && message.type === ChatMessageType.Placeholder) {
|
||||
return {
|
||||
...message,
|
||||
id,
|
||||
type: ChatMessageType.Placeholder,
|
||||
} as ChatMessageWithId;
|
||||
} else {
|
||||
return {
|
||||
...message,
|
||||
id,
|
||||
type: ChatMessageType.PublicAPICreated,
|
||||
} as ChatMessageWithId;
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
const customRoles = parsedMessages.data.reduce((acc, message) => {
|
||||
const { role } = message;
|
||||
if (ChatMessageDefaultRoleSchema.safeParse(role).error) {
|
||||
acc.add(role);
|
||||
if ('role' in message) {
|
||||
const { role } = message;
|
||||
if (ChatMessageDefaultRoleSchema.safeParse(role).error) {
|
||||
acc.add(role);
|
||||
}
|
||||
}
|
||||
return acc;
|
||||
}, new Set<string>());
|
||||
|
||||
@@ -20,15 +20,14 @@ import {
|
||||
TabsTrigger,
|
||||
} from "@/src/components/ui/tabs";
|
||||
import { Textarea } from "@/src/components/ui/textarea";
|
||||
import {
|
||||
type CreatePromptTRPCType,
|
||||
PromptType,
|
||||
} from "@/src/features/prompts/server/utils/validation";
|
||||
import useProjectIdFromURL from "@/src/hooks/useProjectIdFromURL";
|
||||
import { api } from "@/src/utils/api";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import {
|
||||
type CreatePromptTRPCType,
|
||||
PRODUCTION_LABEL,
|
||||
type Prompt,
|
||||
PromptType,
|
||||
extractVariables,
|
||||
getIsCharOrUnderscore,
|
||||
} from "@langfuse/shared";
|
||||
@@ -46,7 +45,6 @@ import { ArrowTopRightIcon } from "@radix-ui/react-icons";
|
||||
import { PromptVariableListPreview } from "@/src/features/prompts/components/PromptVariableListPreview";
|
||||
import { CodeMirrorEditor } from "@/src/components/editor/CodeMirrorEditor";
|
||||
import { PromptLinkingEditor } from "@/src/components/editor/PromptLinkingEditor";
|
||||
import { PRODUCTION_LABEL } from "@/src/features/prompts/constants";
|
||||
import { usePostHogClientCapture } from "@/src/features/posthog-analytics/usePostHogClientCapture";
|
||||
import usePlaygroundCache from "@/src/features/playground/page/hooks/usePlaygroundCache";
|
||||
import { useQueryParam } from "use-query-params";
|
||||
@@ -215,7 +213,16 @@ export const NewPromptForm: React.FC<NewPromptFormProps> = (props) => {
|
||||
<FormItem>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormDescription>
|
||||
Use slashes '/' in prompt names to organize them into <a target="_blank" rel="noopener noreferrer" href="https://langfuse.com/docs/prompts/get-started#prompt-folders-for-organization"><i>folders</i></a>.
|
||||
Use slashes '/' in prompt names to organize them
|
||||
into{" "}
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
href="https://langfuse.com/docs/prompts/get-started#prompt-folders-for-organization"
|
||||
>
|
||||
<i>folders</i>
|
||||
</a>
|
||||
.
|
||||
</FormDescription>
|
||||
<FormControl>
|
||||
<Input placeholder="Name your prompt" {...field} />
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { z } from "zod/v4";
|
||||
import { PromptType } from "@/src/features/prompts/server/utils/validation";
|
||||
import {
|
||||
ChatMessageType,
|
||||
PlaceholderMessageSchema,
|
||||
PromptChatMessageListSchema,
|
||||
TextPromptSchema,
|
||||
PromptNameSchema,
|
||||
TextPromptContentSchema,
|
||||
COMMIT_MESSAGE_MAX_LENGTH,
|
||||
PromptType,
|
||||
} from "@langfuse/shared";
|
||||
import { COMMIT_MESSAGE_MAX_LENGTH } from "@/src/features/prompts/constants";
|
||||
|
||||
const NewPromptBaseSchema = z.object({
|
||||
name: PromptNameSchema,
|
||||
@@ -23,17 +25,30 @@ const NewPromptBaseSchema = z.object({
|
||||
|
||||
const NewChatPromptSchema = NewPromptBaseSchema.extend({
|
||||
type: z.literal(PromptType.Chat),
|
||||
chatPrompt: PromptChatMessageListSchema.refine(
|
||||
(messages) => messages.every((message) => message.content.length > 0),
|
||||
"Enter a chat message or remove the empty message",
|
||||
),
|
||||
chatPrompt: z.array(z.any())
|
||||
.refine(
|
||||
(messages: Array<{ type?: ChatMessageType; content?: string }>) =>
|
||||
messages.every((message) => {
|
||||
const isPlaceholder = message?.type === ChatMessageType.Placeholder;
|
||||
return !isPlaceholder || PlaceholderMessageSchema.safeParse(message).success;
|
||||
}),
|
||||
"Placeholder name must start with a letter and contain only alphanumeric characters and underscores"
|
||||
)
|
||||
.refine(
|
||||
(messages: Array<{ type?: ChatMessageType; content?: string }>) =>
|
||||
messages.every((message) => {
|
||||
const isPlaceholder = message?.type === ChatMessageType.Placeholder;
|
||||
return isPlaceholder || Boolean(message?.content?.trim()?.length);
|
||||
}),
|
||||
"Enter a chat message or remove the empty message"
|
||||
),
|
||||
textPrompt: z.string(),
|
||||
});
|
||||
|
||||
const NewTextPromptSchema = NewPromptBaseSchema.extend({
|
||||
type: z.literal(PromptType.Text),
|
||||
chatPrompt: z.array(z.any()),
|
||||
textPrompt: TextPromptSchema,
|
||||
textPrompt: TextPromptContentSchema,
|
||||
});
|
||||
|
||||
export const NewPromptFormSchema = z.discriminatedUnion("type", [
|
||||
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
import Header from "@/src/components/layouts/header";
|
||||
import { useHasProjectAccess } from "@/src/features/rbac/utils/checkProjectAccess";
|
||||
import { useHasEntitlement } from "@/src/features/entitlements/hooks";
|
||||
import { PromptLabelSchema } from "@/src/features/prompts/server/utils/validation";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod/v4";
|
||||
import { XIcon, Check, ChevronsUpDown } from "lucide-react";
|
||||
@@ -31,11 +30,13 @@ import {
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/src/components/ui/popover";
|
||||
|
||||
import { StatusBadge } from "@/src/components/layouts/status-badge";
|
||||
import {
|
||||
LATEST_PROMPT_LABEL,
|
||||
PRODUCTION_LABEL,
|
||||
} from "@/src/features/prompts/constants";
|
||||
import { StatusBadge } from "@/src/components/layouts/status-badge";
|
||||
PromptLabelSchema,
|
||||
} from "@langfuse/shared";
|
||||
|
||||
const AddLabelFormSchema = z.object({
|
||||
label: PromptLabelSchema,
|
||||
|
||||
@@ -12,9 +12,9 @@ import {
|
||||
import { Button } from "@/src/components/ui/button";
|
||||
import { Input } from "@/src/components/ui/input";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { PromptLabelSchema } from "@/src/features/prompts/server/utils/validation";
|
||||
import { usePostHogClientCapture } from "@/src/features/posthog-analytics/usePostHogClientCapture";
|
||||
import { isReservedPromptLabel } from "@/src/features/prompts/utils";
|
||||
import { PromptLabelSchema } from "@langfuse/shared";
|
||||
|
||||
const AddLabelFormSchema = z.object({
|
||||
newLabel: PromptLabelSchema.refine(
|
||||
|
||||
@@ -15,10 +15,9 @@ import {
|
||||
import { useHasProjectAccess } from "@/src/features/rbac/utils/checkProjectAccess";
|
||||
import useProjectIdFromURL from "@/src/hooks/useProjectIdFromURL";
|
||||
import { api } from "@/src/utils/api";
|
||||
import { type Prompt } from "@langfuse/shared";
|
||||
import { PRODUCTION_LABEL, type Prompt } from "@langfuse/shared";
|
||||
import { AddLabelForm } from "./AddLabelForm";
|
||||
import { LabelCommandItem } from "./LabelCommandItem";
|
||||
import { PRODUCTION_LABEL } from "@/src/features/prompts/constants";
|
||||
import { usePostHogClientCapture } from "@/src/features/posthog-analytics/usePostHogClientCapture";
|
||||
import { isReservedPromptLabel } from "@/src/features/prompts/utils";
|
||||
import { StatusBadge } from "@/src/components/layouts/status-badge";
|
||||
@@ -47,6 +46,9 @@ export function SetPromptVersionLabels({
|
||||
const [selectedLabels, setSelectedLabels] = useState<string[]>([]);
|
||||
const [labels, setLabels] = useState<string[]>([]);
|
||||
const [isAddingLabel, setIsAddingLabel] = useState(false);
|
||||
const labelsChanged =
|
||||
JSON.stringify([...selectedLabels].sort()) !==
|
||||
JSON.stringify([...prompt.labels].sort());
|
||||
const customLabelScrollRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const usedLabelsInProject = api.prompts.allLabels.useQuery(
|
||||
@@ -116,7 +118,7 @@ export function SetPromptVersionLabels({
|
||||
<PopoverTrigger asChild data-version-trigger="true">
|
||||
<div
|
||||
className={cn(
|
||||
"flex min-w-0 max-w-full cursor-pointer flex-wrap gap-1",
|
||||
"flex w-fit min-w-0 max-w-full cursor-pointer flex-wrap gap-1",
|
||||
!hasAccess && "cursor-not-allowed",
|
||||
)}
|
||||
>
|
||||
@@ -143,7 +145,7 @@ export function SetPromptVersionLabels({
|
||||
</div>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="max-h-[50vh] overflow-y-auto"
|
||||
className="max-w-[90vw] sm:max-w-md"
|
||||
align="start"
|
||||
side="bottom"
|
||||
sideOffset={5}
|
||||
@@ -225,6 +227,7 @@ export function SetPromptVersionLabels({
|
||||
: "default"
|
||||
}
|
||||
loading={mutatePromptVersionLabels.isLoading}
|
||||
disabled={!labelsChanged}
|
||||
className="w-full"
|
||||
onClick={handleSubmitLabels}
|
||||
>
|
||||
|
||||
@@ -18,10 +18,13 @@ import { Tabs, TabsList, TabsTrigger } from "@/src/components/ui/tabs";
|
||||
import { Badge } from "@/src/components/ui/badge";
|
||||
import { CodeView, JSONView } from "@/src/components/ui/CodeJsonViewer";
|
||||
import { DetailPageNav } from "@/src/features/navigate-detail-pages/DetailPageNav";
|
||||
import { PromptType } from "@/src/features/prompts/server/utils/validation";
|
||||
import useProjectIdFromURL from "@/src/hooks/useProjectIdFromURL";
|
||||
import { api } from "@/src/utils/api";
|
||||
import { extractVariables } from "@langfuse/shared";
|
||||
import {
|
||||
extractVariables,
|
||||
PRODUCTION_LABEL,
|
||||
PromptType,
|
||||
} from "@langfuse/shared";
|
||||
import { PromptHistoryNode } from "./prompt-history";
|
||||
import { JumpToPlaygroundButton } from "@/src/features/playground/page/components/JumpToPlaygroundButton";
|
||||
import { ChatMlArraySchema } from "@/src/components/schemas/ChatMlSchema";
|
||||
@@ -51,7 +54,6 @@ import { TagPromptDetailsPopover } from "@/src/features/tag/components/TagPrompt
|
||||
import { SetPromptVersionLabels } from "@/src/features/prompts/components/SetPromptVersionLabels";
|
||||
import { CommentDrawerButton } from "@/src/features/comments/CommentDrawerButton";
|
||||
import { Command, CommandInput } from "@/src/components/ui/command";
|
||||
import { PRODUCTION_LABEL } from "@/src/features/prompts/constants";
|
||||
import { renderContentWithPromptButtons } from "@/src/features/prompts/components/renderContentWithPromptButtons";
|
||||
import { PromptVariableListPreview } from "@/src/features/prompts/components/PromptVariableListPreview";
|
||||
|
||||
@@ -95,16 +97,18 @@ ${labels.length > 0 ? labels.map((label) => `const prompt = await langfuse.getPr
|
||||
langfuse.getPrompt("${name}", ${version})
|
||||
`;
|
||||
|
||||
export const PromptDetail = ({ promptName: promptNameProp }: { promptName?: string } = {}) => {
|
||||
export const PromptDetail = ({
|
||||
promptName: promptNameProp,
|
||||
}: { promptName?: string } = {}) => {
|
||||
const projectId = useProjectIdFromURL();
|
||||
const capture = usePostHogClientCapture();
|
||||
const router = useRouter();
|
||||
|
||||
const promptName = promptNameProp || (
|
||||
router.query.promptName
|
||||
const promptName =
|
||||
promptNameProp ||
|
||||
(router.query.promptName
|
||||
? decodeURIComponent(router.query.promptName as string)
|
||||
: ''
|
||||
);
|
||||
: "");
|
||||
const [currentPromptVersion, setCurrentPromptVersion] = useQueryParam(
|
||||
"version",
|
||||
NumberParam,
|
||||
|
||||
@@ -12,7 +12,13 @@ import { DataTableToolbar } from "@/src/components/table/data-table-toolbar";
|
||||
import { useQueryFilterState } from "@/src/features/filters/hooks/useFilterState";
|
||||
import { useOrderByState } from "@/src/features/orderBy/hooks/useOrderByState";
|
||||
import { promptsTableColsWithOptions } from "@/src/server/api/definitions/promptsTable";
|
||||
import { NumberParam, StringParam, useQueryParams, withDefault } from "use-query-params";
|
||||
import {
|
||||
NumberParam,
|
||||
StringParam,
|
||||
useQueryParams,
|
||||
withDefault,
|
||||
useQueryParam,
|
||||
} from "use-query-params";
|
||||
import { createColumnHelper } from "@tanstack/react-table";
|
||||
import { joinTableCoreAndMetrics } from "@/src/components/table/utils/joinTableCoreAndMetrics";
|
||||
import { Skeleton } from "@/src/components/ui/skeleton";
|
||||
@@ -40,7 +46,11 @@ type PromptTableRow = {
|
||||
};
|
||||
|
||||
function createRow(
|
||||
data: Partial<PromptTableRow> & { id: string; name: string; type: "folder" | "text" | "chat" }
|
||||
data: Partial<PromptTableRow> & {
|
||||
id: string;
|
||||
name: string;
|
||||
type: "folder" | "text" | "chat";
|
||||
},
|
||||
): PromptTableRow {
|
||||
return {
|
||||
version: undefined,
|
||||
@@ -52,12 +62,14 @@ function createRow(
|
||||
};
|
||||
}
|
||||
|
||||
function isFolder(row: PromptTableRow): row is PromptTableRow & { type: "folder" } {
|
||||
function isFolder(
|
||||
row: PromptTableRow,
|
||||
): row is PromptTableRow & { type: "folder" } {
|
||||
return row.type === "folder";
|
||||
}
|
||||
|
||||
function getDisplayName(fullPath: string, currentFolderPath: string): string {
|
||||
return currentFolderPath === ''
|
||||
return currentFolderPath === ""
|
||||
? fullPath
|
||||
: fullPath.substring(currentFolderPath.length + 1);
|
||||
}
|
||||
@@ -65,9 +77,9 @@ function getDisplayName(fullPath: string, currentFolderPath: string): string {
|
||||
function createBreadcrumbItems(currentFolderPath: string) {
|
||||
if (!currentFolderPath) return [];
|
||||
|
||||
const segments = currentFolderPath.split('/');
|
||||
const segments = currentFolderPath.split("/");
|
||||
return segments.map((name, i) => {
|
||||
const folderPath = segments.slice(0, i + 1).join('/');
|
||||
const folderPath = segments.slice(0, i + 1).join("/");
|
||||
return {
|
||||
name,
|
||||
folderPath,
|
||||
@@ -95,12 +107,27 @@ export function PromptTable() {
|
||||
folder: StringParam,
|
||||
});
|
||||
|
||||
const [searchQuery, setSearchQuery] = useQueryParam(
|
||||
"search",
|
||||
withDefault(StringParam, null),
|
||||
);
|
||||
|
||||
// Reset pagination when search query changes
|
||||
useEffect(() => {
|
||||
setQueryParams({
|
||||
pageIndex: 0,
|
||||
pageSize: queryParams.pageSize,
|
||||
folder: queryParams.folder,
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [searchQuery]);
|
||||
|
||||
const paginationState = {
|
||||
pageIndex: queryParams.pageIndex,
|
||||
pageSize: queryParams.pageSize,
|
||||
};
|
||||
|
||||
const currentFolderPath = queryParams.folder || '';
|
||||
const currentFolderPath = queryParams.folder || "";
|
||||
|
||||
const prompts = api.prompts.all.useQuery(
|
||||
{
|
||||
@@ -110,6 +137,7 @@ export function PromptTable() {
|
||||
filter: filterState,
|
||||
orderBy: orderByState,
|
||||
pathPrefix: currentFolderPath,
|
||||
searchQuery: searchQuery || undefined,
|
||||
},
|
||||
{
|
||||
enabled: Boolean(projectId),
|
||||
@@ -167,7 +195,7 @@ export function PromptTable() {
|
||||
const prefix = `${currentFolderPath}/`;
|
||||
if (promptName.startsWith(prefix)) {
|
||||
const remainingPath = promptName.substring(prefix.length);
|
||||
const slashIndex = remainingPath.indexOf('/');
|
||||
const slashIndex = remainingPath.indexOf("/");
|
||||
|
||||
if (slashIndex > 0) {
|
||||
// Subfolder
|
||||
@@ -181,7 +209,7 @@ export function PromptTable() {
|
||||
}
|
||||
} else {
|
||||
// Root level
|
||||
const slashIndex = promptName.indexOf('/');
|
||||
const slashIndex = promptName.indexOf("/");
|
||||
if (slashIndex > 0) {
|
||||
const folderName = promptName.substring(0, slashIndex);
|
||||
uniqueFolders.add(folderName);
|
||||
@@ -197,11 +225,13 @@ export function PromptTable() {
|
||||
// Add folder rows
|
||||
for (const folderPath of uniqueFolders) {
|
||||
const folderName = getDisplayName(folderPath, currentFolderPath);
|
||||
combinedRows.push(createRow({
|
||||
id: folderPath,
|
||||
name: folderName,
|
||||
type: "folder",
|
||||
}));
|
||||
combinedRows.push(
|
||||
createRow({
|
||||
id: folderPath,
|
||||
name: folderName,
|
||||
type: "folder",
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// Add matching prompts
|
||||
@@ -216,7 +246,7 @@ export function PromptTable() {
|
||||
labels: prompt.labels,
|
||||
tags: prompt.tags,
|
||||
numberOfObservations: Number(prompt.observationCount ?? 0),
|
||||
})
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -284,7 +314,7 @@ export function PromptTable() {
|
||||
setQueryParams({
|
||||
folder: rowData.id,
|
||||
pageIndex: 0,
|
||||
pageSize: queryParams.pageSize
|
||||
pageSize: queryParams.pageSize,
|
||||
});
|
||||
}}
|
||||
title={displayName || ""}
|
||||
@@ -397,7 +427,7 @@ export function PromptTable() {
|
||||
return (
|
||||
<>
|
||||
{currentFolderPath && (
|
||||
<div className="pt-2 ml-2">
|
||||
<div className="ml-2 pt-2">
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList>
|
||||
<BreadcrumbItem>
|
||||
@@ -407,38 +437,40 @@ export function PromptTable() {
|
||||
setQueryParams({
|
||||
folder: undefined,
|
||||
pageIndex: 0,
|
||||
pageSize: queryParams.pageSize
|
||||
pageSize: queryParams.pageSize,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Home className="h-4 w-4" />
|
||||
</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
{createBreadcrumbItems(currentFolderPath).flatMap((item, index, array) => [
|
||||
index > 0 && (
|
||||
<BreadcrumbSeparator key={`sep-${item.folderPath}`}>
|
||||
<Slash />
|
||||
</BreadcrumbSeparator>
|
||||
),
|
||||
<BreadcrumbItem key={item.folderPath}>
|
||||
{index === array.length - 1 ? (
|
||||
<BreadcrumbPage>{item.name}</BreadcrumbPage>
|
||||
) : (
|
||||
<BreadcrumbLink
|
||||
className="cursor-pointer hover:underline"
|
||||
onClick={() => {
|
||||
setQueryParams({
|
||||
folder: item.folderPath,
|
||||
pageIndex: 0,
|
||||
pageSize: queryParams.pageSize
|
||||
});
|
||||
}}
|
||||
>
|
||||
{item.name}
|
||||
</BreadcrumbLink>
|
||||
)}
|
||||
</BreadcrumbItem>
|
||||
])}
|
||||
{createBreadcrumbItems(currentFolderPath).flatMap(
|
||||
(item, index, array) => [
|
||||
index > 0 && (
|
||||
<BreadcrumbSeparator key={`sep-${item.folderPath}`}>
|
||||
<Slash />
|
||||
</BreadcrumbSeparator>
|
||||
),
|
||||
<BreadcrumbItem key={item.folderPath}>
|
||||
{index === array.length - 1 ? (
|
||||
<BreadcrumbPage>{item.name}</BreadcrumbPage>
|
||||
) : (
|
||||
<BreadcrumbLink
|
||||
className="cursor-pointer hover:underline"
|
||||
onClick={() => {
|
||||
setQueryParams({
|
||||
folder: item.folderPath,
|
||||
pageIndex: 0,
|
||||
pageSize: queryParams.pageSize,
|
||||
});
|
||||
}}
|
||||
>
|
||||
{item.name}
|
||||
</BreadcrumbLink>
|
||||
)}
|
||||
</BreadcrumbItem>,
|
||||
],
|
||||
)}
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
</div>
|
||||
@@ -451,6 +483,14 @@ export function PromptTable() {
|
||||
filterState={filterState}
|
||||
setFilterState={useDebounce(setFilterState)}
|
||||
columnsWithCustomSelect={["labels", "tags"]}
|
||||
searchConfig={{
|
||||
metadataSearchFields: ["Name", "Tags"],
|
||||
updateQuery: useDebounce(setSearchQuery, 300),
|
||||
currentQuery: searchQuery ?? undefined,
|
||||
tableAllowsFullTextSearch: false,
|
||||
setSearchType: undefined,
|
||||
searchType: undefined,
|
||||
}}
|
||||
/>
|
||||
<DataTable
|
||||
columns={promptColumns}
|
||||
@@ -468,11 +508,12 @@ export function PromptTable() {
|
||||
isError: false,
|
||||
data: processedRowData.rows?.map((item) => ({
|
||||
id: item.id,
|
||||
name: item.type === 'folder'
|
||||
? item.name
|
||||
: currentFolderPath
|
||||
? item.name.substring(currentFolderPath.length + 1)
|
||||
: item.name,
|
||||
name:
|
||||
item.type === "folder"
|
||||
? item.name
|
||||
: currentFolderPath
|
||||
? item.name.substring(currentFolderPath.length + 1)
|
||||
: item.name,
|
||||
version: item.version,
|
||||
createdAt: item.createdAt,
|
||||
type: item.type,
|
||||
|
||||
@@ -1,23 +1,23 @@
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import {
|
||||
type CreatePromptTRPCType,
|
||||
PromptType,
|
||||
} from "@/src/features/prompts/server/utils/validation";
|
||||
import {
|
||||
InvalidRequestError,
|
||||
parsePromptDependencyTags,
|
||||
jsonSchema,
|
||||
type PromptDependency,
|
||||
type Prompt,
|
||||
type CreatePromptTRPCType,
|
||||
LATEST_PROMPT_LABEL,
|
||||
PromptType,
|
||||
extractVariables,
|
||||
} from "@langfuse/shared";
|
||||
import { type PrismaClient } from "@langfuse/shared/src/db";
|
||||
import { LATEST_PROMPT_LABEL } from "@/src/features/prompts/constants";
|
||||
import { removeLabelsFromPreviousPromptVersions } from "@/src/features/prompts/server/utils/updatePromptLabels";
|
||||
import { updatePromptTagsOnAllVersions } from "@/src/features/prompts/server/utils/updatePromptTags";
|
||||
import {
|
||||
PromptContentSchema,
|
||||
PromptService,
|
||||
redis,
|
||||
extractPlaceholderNames,
|
||||
} from "@langfuse/shared/src/server";
|
||||
|
||||
export type CreatePromptParams = CreatePromptTRPCType & {
|
||||
@@ -34,6 +34,22 @@ type DuplicatePromptParams = {
|
||||
prisma: PrismaClient;
|
||||
};
|
||||
|
||||
const extractChatVariableAndPlaceholderNames = (
|
||||
chatPrompt: Array<any>,
|
||||
): { variables: string[], placeholders: string[] } => {
|
||||
const placeholders = extractPlaceholderNames(chatPrompt);
|
||||
|
||||
const variables: string[] = [];
|
||||
for (const message of chatPrompt) {
|
||||
if (message && 'content' in message && typeof message.content === 'string') {
|
||||
variables.push(...extractVariables(message.content));
|
||||
}
|
||||
}
|
||||
|
||||
return { variables: [...new Set(variables)], placeholders: [...new Set(placeholders)] };
|
||||
};
|
||||
|
||||
|
||||
export const createPrompt = async ({
|
||||
projectId,
|
||||
name,
|
||||
@@ -57,6 +73,17 @@ export const createPrompt = async ({
|
||||
);
|
||||
}
|
||||
|
||||
// Prevent naming collisions between variables and placeholders
|
||||
if (type === PromptType.Chat && Array.isArray(prompt)) {
|
||||
const { variables, placeholders } = extractChatVariableAndPlaceholderNames(prompt);
|
||||
const conflictingNames = variables.filter(v => placeholders.includes(v));
|
||||
if (conflictingNames.length > 0) {
|
||||
throw new InvalidRequestError(
|
||||
`Cannot create prompt, variables and placeholders must be unique, the following are not: ${conflictingNames.join(", ")}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const finalLabels = [...labels, LATEST_PROMPT_LABEL]; // Newly created prompts are always labeled as 'latest'
|
||||
|
||||
// If tags are undefined, use the tags from the latest prompt version
|
||||
@@ -187,7 +214,7 @@ export const duplicatePrompt = async ({
|
||||
});
|
||||
|
||||
if (!existingPrompt) {
|
||||
throw new Error(`Existing prompt not found: ${promptId}`);
|
||||
throw new InvalidRequestError(`Existing prompt not found: ${promptId}`);
|
||||
}
|
||||
|
||||
// if defined as single version, duplicate current prompt as new prompt v1
|
||||
@@ -255,7 +282,7 @@ export const duplicatePrompt = async ({
|
||||
return promptResult;
|
||||
});
|
||||
|
||||
// If you need the created prompt, fetch it separately since createMany doesn't return created records
|
||||
// Fetch the created prompt to return
|
||||
const createdPrompt = await prisma.prompt.findFirst({
|
||||
where: {
|
||||
name,
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { PRODUCTION_LABEL } from "@/src/features/prompts/constants";
|
||||
import { InvalidRequestError, type Prompt } from "@langfuse/shared";
|
||||
import {
|
||||
InvalidRequestError,
|
||||
PRODUCTION_LABEL,
|
||||
type Prompt,
|
||||
} from "@langfuse/shared";
|
||||
import {
|
||||
PromptService,
|
||||
redis,
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { type GetPromptsMetaType } from "@/src/features/prompts/server/utils/validation";
|
||||
import { promptsTableCols } from "@/src/server/api/definitions/promptsTable";
|
||||
import { type FilterState } from "@langfuse/shared";
|
||||
import { type GetPromptsMetaType, type FilterState } from "@langfuse/shared";
|
||||
import { prisma } from "@langfuse/shared/src/db";
|
||||
import { tableColumnsToSqlFilterAndPrefix } from "@langfuse/shared/src/server";
|
||||
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
import { getPromptByName } from "@/src/features/prompts/server/actions/getPromptByName";
|
||||
import { GetPromptByNameSchema } from "@/src/features/prompts/server/utils/validation";
|
||||
import { withMiddlewares } from "@/src/features/public-api/server/withMiddlewares";
|
||||
import { authorizePromptRequestOrThrow } from "../utils/authorizePromptRequest";
|
||||
import { LangfuseNotFoundError } from "@langfuse/shared";
|
||||
import { PRODUCTION_LABEL } from "@/src/features/prompts/constants";
|
||||
import {
|
||||
GetPromptByNameSchema,
|
||||
LangfuseNotFoundError,
|
||||
PRODUCTION_LABEL,
|
||||
} from "@langfuse/shared";
|
||||
import { RateLimitService } from "@/src/features/public-api/server/RateLimitService";
|
||||
|
||||
const getPromptNameHandler = async (
|
||||
|
||||
@@ -2,15 +2,15 @@ import { type NextApiRequest, type NextApiResponse } from "next";
|
||||
|
||||
import { createPrompt } from "@/src/features/prompts/server/actions/createPrompt";
|
||||
import { getPromptsMeta } from "@/src/features/prompts/server/actions/getPromptsMeta";
|
||||
import {
|
||||
CreatePromptSchema,
|
||||
GetPromptsMetaSchema,
|
||||
} from "@/src/features/prompts/server/utils/validation";
|
||||
import { withMiddlewares } from "@/src/features/public-api/server/withMiddlewares";
|
||||
import { prisma } from "@langfuse/shared/src/db";
|
||||
import { authorizePromptRequestOrThrow } from "../utils/authorizePromptRequest";
|
||||
import { RateLimitService } from "@/src/features/public-api/server/RateLimitService";
|
||||
import { InvalidRequestError } from "@langfuse/shared";
|
||||
import {
|
||||
CreatePromptSchema,
|
||||
GetPromptsMetaSchema,
|
||||
InvalidRequestError,
|
||||
} from "@langfuse/shared";
|
||||
import { auditLog } from "@/src/features/audit-logs/auditLog";
|
||||
|
||||
const getPromptsHandler = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { auditLog } from "@/src/features/audit-logs/auditLog";
|
||||
import {
|
||||
CreatePromptTRPCSchema,
|
||||
PromptLabelSchema,
|
||||
PromptType,
|
||||
} from "@/src/features/prompts/server/utils/validation";
|
||||
import { throwIfNoProjectAccess } from "@/src/features/rbac/utils/checkProjectAccess";
|
||||
import { throwIfNoEntitlement } from "@/src/features/entitlements/server/hasEntitlement";
|
||||
import {
|
||||
@@ -17,12 +12,15 @@ import { createPrompt, duplicatePrompt } from "../actions/createPrompt";
|
||||
import { checkHasProtectedLabels } from "../utils/checkHasProtectedLabels";
|
||||
import { promptsTableCols } from "@/src/server/api/definitions/promptsTable";
|
||||
import {
|
||||
CreatePromptTRPCSchema,
|
||||
InvalidRequestError,
|
||||
LATEST_PROMPT_LABEL,
|
||||
optionalPaginationZod,
|
||||
paginationZod,
|
||||
PromptLabelSchema,
|
||||
PromptType,
|
||||
} from "@langfuse/shared";
|
||||
import { orderBy, singleFilter } from "@langfuse/shared";
|
||||
import { LATEST_PROMPT_LABEL } from "@/src/features/prompts/constants";
|
||||
import {
|
||||
orderByToPrismaSql,
|
||||
PromptService,
|
||||
@@ -42,6 +40,7 @@ const PromptFilterOptions = z.object({
|
||||
orderBy: orderBy,
|
||||
...paginationZod,
|
||||
pathPrefix: z.string().optional(),
|
||||
searchQuery: z.string().optional(),
|
||||
});
|
||||
|
||||
export const promptRouter = createTRPCRouter({
|
||||
@@ -89,7 +88,11 @@ export const promptRouter = createTRPCRouter({
|
||||
);
|
||||
|
||||
const pathFilter = input.pathPrefix
|
||||
? Prisma.sql` AND (p.name LIKE ${input.pathPrefix + '/%'} OR p.name = ${input.pathPrefix})`
|
||||
? Prisma.sql` AND (p.name LIKE ${input.pathPrefix + "/%"} OR p.name = ${input.pathPrefix})`
|
||||
: Prisma.empty;
|
||||
|
||||
const searchFilter = input.searchQuery
|
||||
? Prisma.sql` AND (p.name ILIKE ${`%${input.searchQuery}%`} OR EXISTS (SELECT 1 FROM UNNEST(p.tags) AS tag WHERE tag ILIKE ${`%${input.searchQuery}%`}))`
|
||||
: Prisma.empty;
|
||||
|
||||
const [prompts, promptCount] = await Promise.all([
|
||||
@@ -113,6 +116,7 @@ export const promptRouter = createTRPCRouter({
|
||||
input.limit,
|
||||
input.page,
|
||||
pathFilter,
|
||||
searchFilter,
|
||||
),
|
||||
),
|
||||
// promptCount
|
||||
@@ -125,6 +129,7 @@ export const promptRouter = createTRPCRouter({
|
||||
1, // limit
|
||||
0, // page,
|
||||
pathFilter,
|
||||
searchFilter,
|
||||
),
|
||||
),
|
||||
]);
|
||||
@@ -1235,6 +1240,7 @@ const generatePromptQuery = (
|
||||
limit: number,
|
||||
page: number,
|
||||
pathFilter: Prisma.Sql = Prisma.empty,
|
||||
searchFilter: Prisma.Sql = Prisma.empty,
|
||||
) => {
|
||||
return Prisma.sql`
|
||||
SELECT
|
||||
@@ -1246,11 +1252,13 @@ const generatePromptQuery = (
|
||||
WHERE "project_id" = ${projectId}
|
||||
${filterCondition}
|
||||
${pathFilter}
|
||||
${searchFilter}
|
||||
GROUP BY name
|
||||
)
|
||||
AND "project_id" = ${projectId}
|
||||
${filterCondition}
|
||||
${pathFilter}
|
||||
${searchFilter}
|
||||
${orderCondition}
|
||||
LIMIT ${limit} OFFSET ${page * limit};
|
||||
`;
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
import {
|
||||
LATEST_PROMPT_LABEL,
|
||||
PRODUCTION_LABEL,
|
||||
} from "@/src/features/prompts/constants";
|
||||
import { LATEST_PROMPT_LABEL, PRODUCTION_LABEL } from "@langfuse/shared";
|
||||
|
||||
export const isReservedPromptLabel = (label: string) => {
|
||||
return [PRODUCTION_LABEL, LATEST_PROMPT_LABEL].includes(label);
|
||||
|
||||
@@ -106,6 +106,10 @@ const viewMappings: Record<z.infer<typeof views>, Record<string, string>[]> = {
|
||||
uiTableName: "Score Source",
|
||||
viewName: "source",
|
||||
},
|
||||
{
|
||||
uiTableName: "Score Value",
|
||||
viewName: "value",
|
||||
},
|
||||
{
|
||||
uiTableName: "Scores Data Type",
|
||||
viewName: "dataType",
|
||||
@@ -156,6 +160,10 @@ const viewMappings: Record<z.infer<typeof views>, Record<string, string>[]> = {
|
||||
uiTableName: "Score Source",
|
||||
viewName: "source",
|
||||
},
|
||||
{
|
||||
uiTableName: "Score String Value",
|
||||
viewName: "stringValue",
|
||||
},
|
||||
{
|
||||
uiTableName: "Scores Data Type",
|
||||
viewName: "dataType",
|
||||
@@ -222,6 +230,18 @@ const isLegacyUiTableFilter = (
|
||||
clickhouseTableName: "traces",
|
||||
clickhouseSelect: 't."metadata"',
|
||||
},
|
||||
{
|
||||
uiTableName: "Score Value",
|
||||
uiTableId: "value",
|
||||
clickhouseTableName: "scores",
|
||||
clickhouseSelect: 's."value"',
|
||||
},
|
||||
{
|
||||
uiTableName: "Score String Value",
|
||||
uiTableId: "stringValue",
|
||||
clickhouseTableName: "scores",
|
||||
clickhouseSelect: 's."string_value"',
|
||||
},
|
||||
])
|
||||
.some((columnDef) => columnDef.uiTableName === filter.column);
|
||||
};
|
||||
|
||||
@@ -501,6 +501,11 @@ export const scoresNumericView: ViewDeclarationType = {
|
||||
"Scores are flexible objects that are used for evaluations. This view contains numeric scores.",
|
||||
dimensions: {
|
||||
...scoreBaseDimensions,
|
||||
value: {
|
||||
sql: "value",
|
||||
type: "number",
|
||||
description: "Value of the score.",
|
||||
},
|
||||
},
|
||||
measures: {
|
||||
count: {
|
||||
|
||||
@@ -114,7 +114,7 @@ export const query = z
|
||||
}),
|
||||
)
|
||||
.nullable(),
|
||||
// Chart configuration for chart-specific settings like histogram bins
|
||||
// Chart configuration for chart-specific settings like histogram bins and pivot table dimensions
|
||||
chartConfig: z
|
||||
.object({
|
||||
type: z.string(),
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user