2237 lines
82 KiB
TypeScript
2237 lines
82 KiB
TypeScript
import * as Ariakit from "@ariakit/react";
|
|
import { ArrowPathIcon, ChevronUpDownIcon } from "@heroicons/react/20/solid";
|
|
import { DialogClose } from "@radix-ui/react-dialog";
|
|
import { type MetaFunction, useFetcher } from "@remix-run/react";
|
|
import { json, type LoaderFunctionArgs, redirect } from "@remix-run/server-runtime";
|
|
|
|
import { AnimatePresence, motion } from "framer-motion";
|
|
import { ClipboardCheckIcon, ClipboardIcon, GitBranchPlusIcon } from "lucide-react";
|
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
import { typedjson, useTypedLoaderData } from "remix-typedjson";
|
|
import { CodeBlock } from "~/components/code/CodeBlock";
|
|
import { TextEditor } from "~/components/code/TextEditor";
|
|
import { PageBody, PageContainer } from "~/components/layout/AppLayout";
|
|
import { ModelsFilter } from "~/components/metrics/ModelsFilter";
|
|
import { OperationsFilter } from "~/components/metrics/OperationsFilter";
|
|
import { ProvidersFilter } from "~/components/metrics/ProvidersFilter";
|
|
import { AppliedFilter } from "~/components/primitives/AppliedFilter";
|
|
import { Badge } from "~/components/primitives/Badge";
|
|
import { Button, LinkButton } from "~/components/primitives/Buttons";
|
|
import { PermissionButton } from "~/components/primitives/PermissionButton";
|
|
import { DateTime } from "~/components/primitives/DateTime";
|
|
import { Dialog, DialogContent, DialogHeader } from "~/components/primitives/Dialog";
|
|
import { Header3 } from "~/components/primitives/Headers";
|
|
import { Hint } from "~/components/primitives/Hint";
|
|
import { Input } from "~/components/primitives/Input";
|
|
import { InputGroup } from "~/components/primitives/InputGroup";
|
|
import { Label } from "~/components/primitives/Label";
|
|
import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader";
|
|
import { Paragraph } from "~/components/primitives/Paragraph";
|
|
import { Popover, PopoverContent, PopoverTrigger } from "~/components/primitives/Popover";
|
|
import * as Property from "~/components/primitives/PropertyTable";
|
|
import {
|
|
Table,
|
|
TableBody,
|
|
TableCell,
|
|
TableCellMenu,
|
|
TableHeader,
|
|
TableHeaderCell,
|
|
TableRow,
|
|
} from "~/components/primitives/Table";
|
|
import { RadioButtonCircle } from "~/components/primitives/RadioButton";
|
|
import {
|
|
ResizableHandle,
|
|
ResizablePanel,
|
|
ResizablePanelGroup,
|
|
type ResizableSnapshot,
|
|
} from "~/components/primitives/Resizable";
|
|
import {
|
|
SelectItem,
|
|
SelectList,
|
|
SelectPopover,
|
|
SelectProvider,
|
|
SelectTrigger,
|
|
} from "~/components/primitives/Select";
|
|
import { Spinner } from "~/components/primitives/Spinner";
|
|
import { TabButton, TabContainer } from "~/components/primitives/Tabs";
|
|
import { TextArea } from "~/components/primitives/TextArea";
|
|
import { TimeFilter } from "~/components/runs/v3/SharedFilters";
|
|
import { prisma } from "~/db.server";
|
|
import { useEnvironment } from "~/hooks/useEnvironment";
|
|
import { useInterval } from "~/hooks/useInterval";
|
|
import { useOrganization } from "~/hooks/useOrganizations";
|
|
import { useProject } from "~/hooks/useProject";
|
|
import { useSearchParams } from "~/hooks/useSearchParam";
|
|
import { resolveOrgIdFromSlug } from "~/models/organization.server";
|
|
import { findProjectBySlug } from "~/models/project.server";
|
|
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
|
|
import { type GenerationRow, PromptPresenter } from "~/presenters/v3/PromptPresenter.server";
|
|
import { SpanView } from "~/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam.spans.$spanParam/route";
|
|
import { clickhouseFactory } from "~/services/clickhouse/clickhouseFactoryInstance.server";
|
|
import { getResizableSnapshot } from "~/services/resizablePanel.server";
|
|
import { requireUserId } from "~/services/session.server";
|
|
import { rbac } from "~/services/rbac.server";
|
|
import { dashboardAction } from "~/services/routeBuilders/dashboardBuilder";
|
|
import { checkPermissions } from "~/services/routeBuilders/permissions.server";
|
|
import { PromptService } from "~/v3/services/promptService.server";
|
|
|
|
import { z } from "zod";
|
|
import { AIChatIcon } from "~/assets/icons/AIChatIcon";
|
|
import { RunsIcon } from "~/assets/icons/RunsIcon";
|
|
import { InlineCode } from "~/components/code/InlineCode";
|
|
import { InfoPanel } from "~/components/primitives/InfoPanel";
|
|
import { SimpleTooltip } from "~/components/primitives/Tooltip";
|
|
import { MetricWidget } from "~/routes/resources.metric";
|
|
import { cn } from "~/utils/cn";
|
|
import { EnvironmentParamSchema, v3PromptsPath, v3RunSpanPath } from "~/utils/pathBuilder";
|
|
import { parsePeriodToMs } from "~/utils/periods";
|
|
|
|
const ParamSchema = EnvironmentParamSchema.extend({
|
|
promptSlug: z.string(),
|
|
});
|
|
|
|
export const meta: MetaFunction<typeof loader> = ({ data }) => {
|
|
return [{ title: `${(data as any)?.prompt.slug ?? "Prompt"} | Trigger.dev` }];
|
|
};
|
|
|
|
// ─── Action ──────────────────────────────────────────────
|
|
|
|
const ActionSchema = z.discriminatedUnion("intent", [
|
|
z.object({
|
|
intent: z.literal("promote"),
|
|
versionId: z.string(),
|
|
}),
|
|
z.object({
|
|
intent: z.literal("saveVersion"),
|
|
textContent: z.string().optional(),
|
|
model: z.string().optional(),
|
|
commitMessage: z.string().optional(),
|
|
}),
|
|
z.object({
|
|
intent: z.literal("updateOverride"),
|
|
textContent: z.string().optional(),
|
|
model: z.string().optional(),
|
|
commitMessage: z.string().optional(),
|
|
}),
|
|
z.object({
|
|
intent: z.literal("removeOverride"),
|
|
}),
|
|
z.object({
|
|
intent: z.literal("reactivateOverride"),
|
|
versionId: z.string(),
|
|
}),
|
|
]);
|
|
|
|
export const action = dashboardAction(
|
|
{
|
|
params: ParamSchema,
|
|
context: async (params) => {
|
|
const organizationId = await resolveOrgIdFromSlug(params.organizationSlug);
|
|
return organizationId ? { organizationId } : {};
|
|
},
|
|
},
|
|
async ({ request, params, user, ability, context }) => {
|
|
const { organizationSlug, projectParam, envParam, promptSlug } = params;
|
|
|
|
// This action checks permissions per intent inline (below) rather than via
|
|
// a top-level authorization block, so the builder's fail-closed scope guard
|
|
// doesn't run. Enforce it here: without a resolved org the inline
|
|
// ability.can checks would evaluate an unscoped ability.
|
|
if (!context.organizationId) {
|
|
return json({ error: "Unauthorized" }, { status: 403 });
|
|
}
|
|
|
|
const project = await findProjectBySlug(organizationSlug, projectParam, user.id);
|
|
if (!project) return json({ error: "Project not found" }, { status: 404 });
|
|
|
|
const environment = await findEnvironmentBySlug(project.id, envParam, user.id);
|
|
if (!environment) return json({ error: "Environment not found" }, { status: 404 });
|
|
|
|
const formData = Object.fromEntries(await request.formData());
|
|
const parsed = ActionSchema.safeParse(formData);
|
|
if (!parsed.success) return json({ error: "Invalid action" }, { status: 400 });
|
|
|
|
const prompt = await prisma.prompt.findUnique({
|
|
where: {
|
|
projectId_runtimeEnvironmentId_slug: {
|
|
projectId: project.id,
|
|
runtimeEnvironmentId: environment.id,
|
|
slug: promptSlug,
|
|
},
|
|
},
|
|
});
|
|
|
|
if (!prompt) return json({ error: "Prompt not found" }, { status: 404 });
|
|
|
|
const data = parsed.data;
|
|
|
|
// Promoting a version to production is `update:prompts`; creating or
|
|
// editing override versions is `write:prompts`. Check the right one per
|
|
// intent — a single authorization block can't express both.
|
|
const requiredAction = data.intent === "promote" ? "update" : "write";
|
|
if (!ability.can(requiredAction, { type: "prompts" })) {
|
|
return json({ error: "Unauthorized" }, { status: 403 });
|
|
}
|
|
|
|
const service = new PromptService();
|
|
|
|
if (data.intent === "promote") {
|
|
await service.promoteVersion(prompt.id, data.versionId);
|
|
return json({ ok: true });
|
|
}
|
|
|
|
const url = new URL(request.url);
|
|
|
|
if (data.intent === "saveVersion") {
|
|
const result = await service.createOverride(prompt.id, {
|
|
textContent: data.textContent ?? "",
|
|
model: data.model,
|
|
commitMessage: data.commitMessage,
|
|
source: "dashboard",
|
|
createdBy: user.id,
|
|
});
|
|
url.searchParams.set("version", String(result.version));
|
|
return redirect(url.pathname + url.search);
|
|
}
|
|
|
|
if (data.intent === "updateOverride") {
|
|
await service.updateOverride(prompt.id, {
|
|
textContent: data.textContent,
|
|
model: data.model,
|
|
commitMessage: data.commitMessage,
|
|
});
|
|
return json({ ok: true });
|
|
}
|
|
|
|
if (data.intent === "removeOverride") {
|
|
await service.removeOverride(prompt.id);
|
|
// Navigate back to current version
|
|
const currentVersion = await prisma.promptVersion.findFirst({
|
|
where: { promptId: prompt.id, labels: { has: "current" } },
|
|
select: { version: true },
|
|
});
|
|
if (currentVersion) {
|
|
url.searchParams.set("version", String(currentVersion.version));
|
|
} else {
|
|
url.searchParams.delete("version");
|
|
}
|
|
return redirect(url.pathname + url.search);
|
|
}
|
|
|
|
if (data.intent === "reactivateOverride") {
|
|
await service.reactivateOverride(prompt.id, data.versionId);
|
|
return json({ ok: true });
|
|
}
|
|
|
|
return json({ error: "Unknown intent" }, { status: 400 });
|
|
}
|
|
);
|
|
|
|
// ─── Loader ──────────────────────────────────────────────
|
|
|
|
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
|
const userId = await requireUserId(request);
|
|
const { organizationSlug, projectParam, envParam, promptSlug } = ParamSchema.parse(params);
|
|
|
|
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
|
|
if (!project) throw new Response("Project not found", { status: 404 });
|
|
|
|
const environment = await findEnvironmentBySlug(project.id, envParam, userId);
|
|
if (!environment) throw new Response("Environment not found", { status: 404 });
|
|
|
|
const prompt = await prisma.prompt.findUnique({
|
|
where: {
|
|
projectId_runtimeEnvironmentId_slug: {
|
|
projectId: project.id,
|
|
runtimeEnvironmentId: environment.id,
|
|
slug: promptSlug,
|
|
},
|
|
},
|
|
include: {
|
|
versions: { orderBy: { version: "desc" }, take: 50 },
|
|
},
|
|
});
|
|
|
|
if (!prompt) throw new Response("Prompt not found", { status: 404 });
|
|
|
|
const currentVersion = prompt.versions.find((v) => v.labels.includes("current"));
|
|
const overrideVersion = prompt.versions.find((v) => v.labels.includes("override"));
|
|
|
|
// Query ClickHouse for recent generations (for the Generations tab span list)
|
|
const url = new URL(request.url);
|
|
const period = url.searchParams.get("period") ?? "7d";
|
|
const periodMs = parsePeriodToMs(period);
|
|
const fromTime = url.searchParams.get("from");
|
|
const toTime = url.searchParams.get("to");
|
|
|
|
const startTime = fromTime ? new Date(fromTime) : new Date(Date.now() - periodMs);
|
|
const endTime = toTime ? new Date(toTime) : new Date();
|
|
|
|
const clickhouse = await clickhouseFactory.getClickhouseForOrganization(
|
|
project.organizationId,
|
|
"standard"
|
|
);
|
|
const presenter = new PromptPresenter(clickhouse);
|
|
let generations: Awaited<ReturnType<typeof presenter.listGenerations>>["generations"] = [];
|
|
let generationsPagination: { next?: string } = {};
|
|
try {
|
|
const urlVersions = url.searchParams
|
|
.getAll("versions")
|
|
.filter(Boolean)
|
|
.map(Number)
|
|
.filter((n) => !isNaN(n));
|
|
const urlModels = url.searchParams.getAll("models").filter(Boolean);
|
|
const urlOperations = url.searchParams.getAll("operations").filter(Boolean);
|
|
const urlProviders = url.searchParams.getAll("providers").filter(Boolean);
|
|
|
|
const result = await presenter.listGenerations({
|
|
environmentId: environment.id,
|
|
promptSlug: prompt.slug,
|
|
promptVersions: urlVersions.length > 0 ? urlVersions : undefined,
|
|
startTime,
|
|
endTime,
|
|
responseModels: urlModels.length > 0 ? urlModels : undefined,
|
|
operations: urlOperations.length > 0 ? urlOperations : undefined,
|
|
providers: urlProviders.length > 0 ? urlProviders : undefined,
|
|
});
|
|
generations = result.generations;
|
|
generationsPagination = result.pagination;
|
|
} catch (e) {
|
|
console.error("Prompt generations query exception:", e);
|
|
}
|
|
|
|
// Load distinct filter values and resizable snapshots in parallel
|
|
const distinctQuery = (col: string, name: string) =>
|
|
clickhouse.reader.query({
|
|
name,
|
|
query: `SELECT DISTINCT ${col} AS val FROM trigger_dev.llm_metrics_v1 WHERE environment_id = {environmentId: String} AND prompt_slug = {promptSlug: String} AND ${col} != '' ORDER BY val`,
|
|
params: z.object({ environmentId: z.string(), promptSlug: z.string() }),
|
|
schema: z.object({ val: z.string() }),
|
|
})({ environmentId: environment.id, promptSlug: prompt.slug });
|
|
|
|
const [
|
|
resizableOuter,
|
|
resizableVertical,
|
|
resizableGenerations,
|
|
[modelsErr, modelsRows],
|
|
[opsErr, opsRows],
|
|
[provsErr, provsRows],
|
|
] = await Promise.all([
|
|
getResizableSnapshot(request, "prompt-detail"),
|
|
getResizableSnapshot(request, "prompt-vertical"),
|
|
getResizableSnapshot(request, "prompt-generations"),
|
|
distinctQuery("response_model", "promptDistinctModels"),
|
|
distinctQuery("operation_id", "promptDistinctOperations"),
|
|
distinctQuery("gen_ai_system", "promptDistinctProviders"),
|
|
]);
|
|
|
|
const possibleModels = modelsErr ? [] : modelsRows.map((r) => r.val);
|
|
const possibleOperations = opsErr ? [] : opsRows.map((r) => r.val);
|
|
const possibleProviders = provsErr ? [] : provsRows.map((r) => r.val);
|
|
|
|
// Display flags for the promote / override controls — the action enforces
|
|
// update:prompts and write:prompts independently. Permissive in OSS.
|
|
const promptAuth = await rbac.authenticateSession(request, {
|
|
userId,
|
|
organizationId: project.organizationId,
|
|
});
|
|
const promptPermissions = promptAuth.ok
|
|
? checkPermissions(promptAuth.ability, {
|
|
canWritePrompts: { action: "write", resource: { type: "prompts" } },
|
|
canPromote: { action: "update", resource: { type: "prompts" } },
|
|
})
|
|
: { canWritePrompts: true, canPromote: true };
|
|
|
|
return typedjson({
|
|
resizable: {
|
|
outer: resizableOuter,
|
|
vertical: resizableVertical,
|
|
generations: resizableGenerations,
|
|
},
|
|
prompt: {
|
|
id: prompt.id,
|
|
friendlyId: prompt.friendlyId,
|
|
slug: prompt.slug,
|
|
description: prompt.description,
|
|
tags: prompt.tags,
|
|
defaultModel: prompt.defaultModel,
|
|
defaultConfig: prompt.defaultConfig,
|
|
variableSchema: prompt.variableSchema,
|
|
filePath: prompt.filePath,
|
|
exportName: prompt.exportName,
|
|
},
|
|
currentVersion: currentVersion
|
|
? {
|
|
id: currentVersion.id,
|
|
version: currentVersion.version,
|
|
textContent: currentVersion.textContent,
|
|
model: currentVersion.model,
|
|
source: currentVersion.source,
|
|
labels: currentVersion.labels,
|
|
}
|
|
: null,
|
|
versions: prompt.versions.map((v) => ({
|
|
id: v.id,
|
|
version: v.version,
|
|
labels: v.labels,
|
|
source: v.source,
|
|
commitMessage: v.commitMessage,
|
|
textContent: v.textContent,
|
|
model: v.model,
|
|
createdAt: v.createdAt,
|
|
})),
|
|
overrideVersion: overrideVersion
|
|
? {
|
|
id: overrideVersion.id,
|
|
version: overrideVersion.version,
|
|
}
|
|
: null,
|
|
generations,
|
|
generationsPagination,
|
|
organizationId: project.organizationId,
|
|
projectId: project.id,
|
|
environmentId: environment.id,
|
|
possibleModels,
|
|
possibleOperations,
|
|
possibleProviders,
|
|
...promptPermissions,
|
|
});
|
|
};
|
|
|
|
// ─── Helpers ─────────────────────────────────────────────
|
|
|
|
type VersionData = {
|
|
id: string;
|
|
version: number;
|
|
labels: string[];
|
|
source: string;
|
|
commitMessage: string | null;
|
|
textContent: string | null;
|
|
model: string | null;
|
|
createdAt: Date;
|
|
};
|
|
|
|
type VariableField = {
|
|
name: string;
|
|
type: string;
|
|
required: boolean;
|
|
description?: string;
|
|
placeholder?: string;
|
|
enumValues?: string[];
|
|
isLongText: boolean;
|
|
};
|
|
|
|
function extractVariableFields(schema: unknown): VariableField[] {
|
|
const jsonSchema = schema as {
|
|
properties?: Record<string, { type?: string; description?: string; enum?: string[] }>;
|
|
required?: string[];
|
|
} | null;
|
|
if (!jsonSchema?.properties) return [];
|
|
const requiredSet = new Set(jsonSchema.required ?? []);
|
|
return Object.entries(jsonSchema.properties).map(([name, prop]) => ({
|
|
name,
|
|
type: prop.type ?? "string",
|
|
required: requiredSet.has(name),
|
|
description: prop.description,
|
|
placeholder: prop.description ?? name,
|
|
enumValues: prop.enum,
|
|
isLongText:
|
|
prop.type === "string" &&
|
|
!prop.enum &&
|
|
(name.toLowerCase().includes("text") ||
|
|
name.toLowerCase().includes("content") ||
|
|
name.toLowerCase().includes("message") ||
|
|
name.toLowerCase().includes("body")),
|
|
}));
|
|
}
|
|
|
|
function compileTemplatePreview(template: string, variables: Record<string, string>): string {
|
|
let result = template.replace(/\{\{#(\w+)\}\}([\s\S]*?)\{\{\/\1\}\}/g, (_match, key, content) => {
|
|
const value = variables[key];
|
|
return value
|
|
? content.replace(/\{\{(\w+)\}\}/g, (_m: string, k: string) => variables[k] ?? "")
|
|
: "";
|
|
});
|
|
result = result.replace(/\{\{\s*(\w+)\s*\}\}/g, (_match, key) => {
|
|
return variables[key] ?? `{{${key}}}`;
|
|
});
|
|
return result;
|
|
}
|
|
|
|
function getVersionContent(version: { textContent?: string | null }): string {
|
|
return version.textContent ?? "";
|
|
}
|
|
|
|
// ─── Component ───────────────────────────────────────────
|
|
|
|
export default function PromptDetailPage() {
|
|
const {
|
|
prompt,
|
|
currentVersion,
|
|
versions,
|
|
overrideVersion,
|
|
generations,
|
|
generationsPagination,
|
|
organizationId,
|
|
projectId,
|
|
environmentId,
|
|
resizable,
|
|
possibleModels,
|
|
possibleOperations,
|
|
possibleProviders,
|
|
canWritePrompts,
|
|
canPromote,
|
|
} = useTypedLoaderData<typeof loader>();
|
|
const organization = useOrganization();
|
|
const project = useProject();
|
|
const environment = useEnvironment();
|
|
const fetcher = useFetcher();
|
|
const { value: searchValue, replace: replaceSearch } = useSearchParams();
|
|
|
|
const tab = searchValue("tab") ?? "details";
|
|
const contentTab = searchValue("contentTab") ?? "generations";
|
|
const period = searchValue("period") ?? "7d";
|
|
const from = searchValue("from") ?? null;
|
|
const to = searchValue("to") ?? null;
|
|
|
|
// Selected span for the generations inspector — auto-select first generation
|
|
const [selectedSpan, setSelectedSpan] = useState<{ runId: string; spanId: string } | null>(
|
|
generations[0] ? { runId: generations[0].run_id, spanId: generations[0].span_id } : null
|
|
);
|
|
|
|
// Selected version from URL or default to current
|
|
const versionParam = searchValue("version");
|
|
const selectedVersion = versionParam
|
|
? (versions.find((v) => v.version === Number(versionParam)) ?? versions[0])
|
|
: overrideVersion
|
|
? (versions.find((v) => v.id === overrideVersion.id) ?? versions[0])
|
|
: currentVersion
|
|
? (versions.find((v) => v.id === currentVersion.id) ?? versions[0])
|
|
: versions[0];
|
|
|
|
const content = selectedVersion ? getVersionContent(selectedVersion) : "";
|
|
const isCurrent = selectedVersion?.labels.includes("current") ?? false;
|
|
|
|
const [overrideDialogOpen, setOverrideDialogOpen] = useState(false);
|
|
|
|
const handlePromote = (versionId: string) => {
|
|
fetcher.submit({ intent: "promote", versionId }, { method: "POST" });
|
|
};
|
|
|
|
const switchVersion = (versionNumber: number) => {
|
|
replaceSearch({
|
|
version: String(versionNumber),
|
|
tab,
|
|
});
|
|
};
|
|
|
|
return (
|
|
<PageContainer className="grid-rows-[auto_auto_1fr]">
|
|
<NavBar>
|
|
<PageTitle
|
|
title={
|
|
<PromptCopyPopover
|
|
slug={prompt.slug}
|
|
friendlyId={prompt.friendlyId}
|
|
description={prompt.description}
|
|
/>
|
|
}
|
|
backButton={{ to: v3PromptsPath(organization, project, environment), text: "Prompts" }}
|
|
/>
|
|
<PageAccessories>
|
|
<div className="flex items-center gap-2">
|
|
{selectedVersion && (
|
|
<div className="flex items-center gap-1.5">
|
|
<div
|
|
className={cn(
|
|
"size-1.5 shrink-0 rounded-full",
|
|
selectedVersion.labels.includes("override")
|
|
? "bg-amber-400"
|
|
: isCurrent
|
|
? "bg-green-500"
|
|
: "bg-surface-control-hover"
|
|
)}
|
|
/>
|
|
<span className="text-xs text-text-dimmed">v{selectedVersion.version}</span>
|
|
{isCurrent && <Badge variant="extra-small">current</Badge>}
|
|
{selectedVersion.labels.includes("override") && (
|
|
<Badge variant="extra-small" className="border-amber-500/30 text-amber-400">
|
|
override
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
)}
|
|
{selectedVersion && !isCurrent && selectedVersion.source === "code" && (
|
|
<PermissionButton
|
|
hasPermission={canPromote}
|
|
noPermissionTooltip="You don't have permission to promote prompt versions"
|
|
variant="secondary/small"
|
|
onClick={() => handlePromote(selectedVersion.id)}
|
|
disabled={fetcher.state !== "idle"}
|
|
>
|
|
Promote to current
|
|
</PermissionButton>
|
|
)}
|
|
{selectedVersion &&
|
|
selectedVersion.source !== "code" &&
|
|
!selectedVersion.labels.includes("override") && (
|
|
<PermissionButton
|
|
hasPermission={canWritePrompts}
|
|
noPermissionTooltip="You don't have permission to edit prompt overrides"
|
|
variant="secondary/small"
|
|
onClick={() =>
|
|
fetcher.submit(
|
|
{ intent: "reactivateOverride", versionId: selectedVersion.id },
|
|
{ method: "POST" }
|
|
)
|
|
}
|
|
disabled={fetcher.state !== "idle"}
|
|
>
|
|
Reactivate as override
|
|
</PermissionButton>
|
|
)}
|
|
{!overrideVersion && (
|
|
<PermissionButton
|
|
hasPermission={canWritePrompts}
|
|
noPermissionTooltip="You don't have permission to edit prompt overrides"
|
|
variant="secondary/small"
|
|
onClick={() => setOverrideDialogOpen(true)}
|
|
>
|
|
Create override
|
|
</PermissionButton>
|
|
)}
|
|
</div>
|
|
</PageAccessories>
|
|
</NavBar>
|
|
<div>
|
|
<AnimatePresence initial={false}>
|
|
{overrideVersion && (
|
|
<motion.div
|
|
className="flex flex-wrap items-center justify-between gap-2 overflow-hidden border-b border-amber-500/10 bg-amber-500/10 pl-4 pr-2"
|
|
initial={{ opacity: 0, height: 0 }}
|
|
animate={{ opacity: 1, height: "auto" }}
|
|
exit={{ opacity: 0, height: 0 }}
|
|
transition={{ duration: 0.2, ease: "easeInOut" }}
|
|
>
|
|
<span className="py-1.5 text-xs text-amber-300">
|
|
Override v{overrideVersion.version} is active. API calls resolve to this version
|
|
instead of the deployed prompt.
|
|
</span>
|
|
<div className="flex items-center gap-2 py-1.5">
|
|
<PermissionButton
|
|
hasPermission={canWritePrompts}
|
|
noPermissionTooltip="You don't have permission to edit prompt overrides"
|
|
variant="tertiary/small"
|
|
className="border-amber-300/50 bg-amber-400/10 text-amber-300 group-hover/button:border-amber-400/60 group-hover/button:bg-amber-500/25 group-hover/button:text-amber-200"
|
|
onClick={() => setOverrideDialogOpen(true)}
|
|
>
|
|
Edit
|
|
</PermissionButton>
|
|
<PermissionButton
|
|
hasPermission={canWritePrompts}
|
|
noPermissionTooltip="You don't have permission to edit prompt overrides"
|
|
variant="tertiary/small"
|
|
className="border-amber-300/50 bg-amber-400/10 text-amber-300 group-hover/button:border-amber-400/60 group-hover/button:bg-amber-500/25 group-hover/button:text-amber-200"
|
|
onClick={() => fetcher.submit({ intent: "removeOverride" }, { method: "POST" })}
|
|
disabled={fetcher.state !== "idle"}
|
|
>
|
|
Remove
|
|
</PermissionButton>
|
|
</div>
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
</div>
|
|
<PageBody scrollable={false}>
|
|
<ResizablePanelGroup
|
|
autosaveId="prompt-detail"
|
|
snapshot={resizable?.outer}
|
|
className="h-full max-h-full"
|
|
>
|
|
{/* Main content */}
|
|
<ResizablePanel id="prompt-main" min="400px">
|
|
<ResizablePanelGroup
|
|
autosaveId="prompt-vertical"
|
|
snapshot={resizable?.vertical}
|
|
orientation="vertical"
|
|
className="h-full max-h-full"
|
|
>
|
|
{/* Template panel */}
|
|
<ResizablePanel id="prompt-template" default="250px" min="80px">
|
|
{content ? (
|
|
<CodeBlock
|
|
code={content}
|
|
language="markdown"
|
|
showLineNumbers={false}
|
|
showCopyButton={true}
|
|
showTextWrapping={false}
|
|
showOpenInModal={true}
|
|
className="h-full rounded-none border-none [&_pre]:px-3 [&_pre]:py-2 [&_pre]:text-sm"
|
|
maxLines={undefined}
|
|
/>
|
|
) : (
|
|
<div className="p-3">
|
|
<Paragraph variant="small" className="text-text-dimmed">
|
|
No content
|
|
</Paragraph>
|
|
</div>
|
|
)}
|
|
</ResizablePanel>
|
|
|
|
<ResizableHandle id="prompt-vertical-handle" />
|
|
|
|
{/* Tabs panel */}
|
|
<ResizablePanel id="prompt-tabs" min="100px">
|
|
<div className="grid h-full max-h-full grid-rows-[2.25rem_1fr] overflow-hidden">
|
|
{/* Tab bar */}
|
|
<div className="flex items-center justify-between border-b border-grid-dimmed bg-background-bright pl-3 pr-1.5">
|
|
<TabContainer className="-mb-1">
|
|
<TabButton
|
|
isActive={contentTab === "generations"}
|
|
layoutId="prompt-content"
|
|
onClick={() =>
|
|
replaceSearch({
|
|
contentTab: "generations",
|
|
tab,
|
|
version: searchValue("version"),
|
|
period: searchValue("period"),
|
|
from: searchValue("from"),
|
|
to: searchValue("to"),
|
|
})
|
|
}
|
|
>
|
|
Generations
|
|
</TabButton>
|
|
<TabButton
|
|
isActive={contentTab === "metrics"}
|
|
layoutId="prompt-content"
|
|
onClick={() =>
|
|
replaceSearch({
|
|
contentTab: "metrics",
|
|
tab,
|
|
version: searchValue("version"),
|
|
period: searchValue("period"),
|
|
from: searchValue("from"),
|
|
to: searchValue("to"),
|
|
})
|
|
}
|
|
>
|
|
Metrics
|
|
</TabButton>
|
|
</TabContainer>
|
|
<div className="flex items-center gap-1.5">
|
|
<PromptVersionsFilter versions={versions} />
|
|
<ModelsFilter
|
|
possibleModels={possibleModels.map((m) => ({ model: m, system: "" }))}
|
|
/>
|
|
<OperationsFilter possibleOperations={possibleOperations} />
|
|
<ProvidersFilter possibleProviders={possibleProviders} />
|
|
<TimeFilter
|
|
defaultPeriod="7d"
|
|
labelName="Period"
|
|
hideLabel
|
|
valueClassName="text-text-bright"
|
|
shortcut={{ key: "w" }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Tab content */}
|
|
<div
|
|
className={cn(
|
|
"min-h-0 overflow-hidden",
|
|
contentTab === "generations" && "bg-background-bright"
|
|
)}
|
|
>
|
|
{contentTab === "generations" && (
|
|
<GenerationsTab
|
|
promptSlug={prompt.slug}
|
|
initialGenerations={generations}
|
|
initialPagination={generationsPagination}
|
|
selectedSpan={selectedSpan}
|
|
onSelectSpan={setSelectedSpan}
|
|
generationsSnapshot={resizable?.generations}
|
|
/>
|
|
)}
|
|
|
|
{contentTab === "metrics" && (
|
|
<div className="h-full overflow-y-auto p-3 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control">
|
|
<MetricsTab
|
|
prompt={prompt}
|
|
organizationId={organizationId}
|
|
projectId={projectId}
|
|
environmentId={environmentId}
|
|
period={period}
|
|
from={from}
|
|
to={to}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</ResizablePanel>
|
|
</ResizablePanelGroup>
|
|
</ResizablePanel>
|
|
|
|
<ResizableHandle id="prompt-sidebar-handle" />
|
|
|
|
{/* Sidebar */}
|
|
<ResizablePanel
|
|
id="prompt-sidebar"
|
|
default="380px"
|
|
min="280px"
|
|
max="500px"
|
|
isStaticAtRest
|
|
>
|
|
<div className="grid h-full max-h-full grid-rows-[2rem_1fr] overflow-hidden bg-background-bright">
|
|
{/* Tabs */}
|
|
<div className="overflow-x-auto px-3 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control">
|
|
<TabContainer>
|
|
<TabButton
|
|
isActive={tab === "details"}
|
|
layoutId="prompt-sidebar"
|
|
onClick={() => replaceSearch({ tab: "details", version: versionParam })}
|
|
shortcut={{ key: "d" }}
|
|
>
|
|
Details
|
|
</TabButton>
|
|
<TabButton
|
|
isActive={tab === "preview"}
|
|
layoutId="prompt-sidebar"
|
|
onClick={() => replaceSearch({ tab: "preview", version: versionParam })}
|
|
shortcut={{ key: "i" }}
|
|
>
|
|
Preview
|
|
</TabButton>
|
|
<TabButton
|
|
isActive={tab === "versions"}
|
|
layoutId="prompt-sidebar"
|
|
onClick={() => replaceSearch({ tab: "versions", version: versionParam })}
|
|
shortcut={{ key: "v" }}
|
|
>
|
|
Versions
|
|
</TabButton>
|
|
</TabContainer>
|
|
</div>
|
|
|
|
{/* Tab content */}
|
|
<div
|
|
className={cn(
|
|
"overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control",
|
|
tab === "versions" ? "py-0" : "px-3 py-3"
|
|
)}
|
|
>
|
|
{tab === "details" && (
|
|
<DetailsTab prompt={prompt} selectedVersion={selectedVersion} />
|
|
)}
|
|
{tab === "preview" && <PreviewTab prompt={prompt} content={content} />}
|
|
{tab === "versions" && (
|
|
<VersionsTab
|
|
versions={versions}
|
|
selectedVersion={selectedVersion}
|
|
onSelectVersion={switchVersion}
|
|
/>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</ResizablePanel>
|
|
</ResizablePanelGroup>
|
|
</PageBody>
|
|
<OverrideDialog
|
|
open={overrideDialogOpen}
|
|
onOpenChange={setOverrideDialogOpen}
|
|
prompt={prompt}
|
|
content={
|
|
overrideVersion
|
|
? getVersionContent(
|
|
versions.find((v) => v.id === overrideVersion.id) ?? { textContent: null }
|
|
)
|
|
: content
|
|
}
|
|
isEditingOverride={!!overrideVersion}
|
|
currentOverrideModel={
|
|
overrideVersion
|
|
? (versions.find((v) => v.id === overrideVersion.id)?.model ?? null)
|
|
: null
|
|
}
|
|
onSave={(textContent, commitMessage, model) => {
|
|
const intent = overrideVersion ? "updateOverride" : "saveVersion";
|
|
fetcher.submit({ intent, textContent, commitMessage, model }, { method: "POST" });
|
|
setOverrideDialogOpen(false);
|
|
}}
|
|
/>
|
|
</PageContainer>
|
|
);
|
|
}
|
|
|
|
// ─── Override Dialog ─────────────────────────────────────
|
|
|
|
function OverrideDialog({
|
|
open,
|
|
onOpenChange,
|
|
prompt,
|
|
content,
|
|
isEditingOverride,
|
|
currentOverrideModel,
|
|
onSave,
|
|
}: {
|
|
open: boolean;
|
|
onOpenChange: (open: boolean) => void;
|
|
prompt: {
|
|
slug: string;
|
|
defaultModel: string | null;
|
|
defaultConfig: unknown;
|
|
variableSchema: unknown;
|
|
};
|
|
content: string;
|
|
isEditingOverride: boolean;
|
|
currentOverrideModel?: string | null;
|
|
onSave: (textContent: string, commitMessage: string, model: string) => void;
|
|
}) {
|
|
const effectiveModel = currentOverrideModel ?? prompt.defaultModel ?? "";
|
|
const [editedContent, setEditedContent] = useState(content);
|
|
const [commitMessage, setCommitMessage] = useState("");
|
|
const [model, setModel] = useState(effectiveModel);
|
|
|
|
// Reset when dialog opens
|
|
useEffect(() => {
|
|
if (open) {
|
|
setEditedContent(content);
|
|
setCommitMessage("");
|
|
setModel(currentOverrideModel ?? prompt.defaultModel ?? "");
|
|
}
|
|
}, [open, content, currentOverrideModel, prompt.defaultModel]);
|
|
|
|
const variableFields = prompt.variableSchema ? extractVariableFields(prompt.variableSchema) : [];
|
|
|
|
// Extract variables used in the template
|
|
const templateVars = new Set<string>();
|
|
editedContent.replace(/\{\{\s*(\w+)\s*\}\}/g, (_m, key) => {
|
|
templateVars.add(key);
|
|
return "";
|
|
});
|
|
editedContent.replace(/\{\{#(\w+)\}\}/g, (_m, key) => {
|
|
templateVars.add(key);
|
|
return "";
|
|
});
|
|
|
|
// Check for schema mismatches
|
|
const schemaVarNames = new Set(variableFields.map((f) => f.name));
|
|
const undefinedVars = [...templateVars].filter((v) => !schemaVarNames.has(v));
|
|
const unusedVars = variableFields.filter((f) => f.required && !templateVars.has(f.name));
|
|
|
|
const hasChanges = editedContent !== content || model !== effectiveModel;
|
|
|
|
return (
|
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
<DialogContent className="flex h-[85vh] max-h-[85vh] flex-col gap-0! overflow-hidden pl-0 pr-3 pt-0 md:max-w-4xl lg:max-w-6xl">
|
|
<DialogHeader className="px-4 py-2.5">
|
|
{isEditingOverride ? "Edit override" : "Create override"}
|
|
</DialogHeader>
|
|
|
|
<ResizablePanelGroup
|
|
orientation="horizontal"
|
|
className="-mx-3 w-auto flex-1 border-b border-t border-grid-dimmed"
|
|
>
|
|
{/* Editor */}
|
|
<ResizablePanel id="override-editor" min="300px" className="bg-[#121317]">
|
|
<TextEditor
|
|
className="h-full"
|
|
autoFocus
|
|
defaultValue={editedContent}
|
|
onChange={setEditedContent}
|
|
showCopyButton
|
|
/>
|
|
</ResizablePanel>
|
|
|
|
<ResizableHandle id="override-handle" />
|
|
|
|
{/* Right panel: properties */}
|
|
<ResizablePanel id="override-sidebar" min="220px" default="280px" max="360px">
|
|
<div className="h-full overflow-y-auto px-3 py-3 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control">
|
|
<div className="space-y-4">
|
|
<div className="space-y-2">
|
|
<Header3>Override settings</Header3>
|
|
<InputGroup>
|
|
<Label variant="small">Commit message</Label>
|
|
<Input
|
|
variant="small"
|
|
placeholder="What changed? (optional)"
|
|
value={commitMessage}
|
|
onChange={(e) => setCommitMessage(e.target.value)}
|
|
/>
|
|
</InputGroup>
|
|
<InputGroup>
|
|
<Label variant="small">Model</Label>
|
|
<Input
|
|
variant="small"
|
|
placeholder={prompt.defaultModel ?? "No default"}
|
|
value={model}
|
|
onChange={(e) => setModel(e.target.value)}
|
|
/>
|
|
<Hint>Override the default model for this version</Hint>
|
|
</InputGroup>
|
|
</div>
|
|
|
|
{/* Variable schema (read-only) */}
|
|
{variableFields.length > 0 && (
|
|
<div className="space-y-1.5 border-t border-grid-dimmed pt-3">
|
|
<Label variant="small">Variables (from code)</Label>
|
|
<div className="space-y-1">
|
|
{variableFields.map((f) => (
|
|
<div key={f.name} className="flex items-center gap-1.5 text-xs">
|
|
<code className="rounded bg-background-hover px-1 py-0.5 text-text-bright">
|
|
{f.name}
|
|
</code>
|
|
<span className="text-text-dimmed">{f.type}</span>
|
|
{f.required && (
|
|
<Badge
|
|
variant="extra-small"
|
|
className="border-amber-500/30 text-amber-400"
|
|
>
|
|
required
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Validation warnings */}
|
|
{(undefinedVars.length > 0 || unusedVars.length > 0) && (
|
|
<div className="space-y-2 border-t border-grid-dimmed pt-3">
|
|
{undefinedVars.length > 0 && (
|
|
<div className="rounded border border-amber-500/30 bg-amber-500/5 px-2.5 py-2 text-xs text-amber-300">
|
|
<span className="font-medium">Undefined variables:</span>{" "}
|
|
{undefinedVars.map((v) => (
|
|
<code
|
|
key={v}
|
|
className="mx-0.5 rounded bg-amber-500/10 px-1"
|
|
>{`{{${v}}}`}</code>
|
|
))}
|
|
</div>
|
|
)}
|
|
{unusedVars.length > 0 && (
|
|
<div className="rounded border border-amber-500/30 bg-amber-500/5 px-2.5 py-2 text-xs text-amber-300">
|
|
<span className="font-medium">Required but missing:</span>{" "}
|
|
{unusedVars.map((v) => (
|
|
<code key={v.name} className="mx-0.5 rounded bg-amber-500/10 px-1">
|
|
{v.name}
|
|
</code>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</ResizablePanel>
|
|
</ResizablePanelGroup>
|
|
|
|
{/* Footer */}
|
|
<div className="flex items-center justify-between gap-2 pl-4 pr-1 pt-3">
|
|
<DialogClose asChild>
|
|
<Button variant="secondary/medium">Cancel</Button>
|
|
</DialogClose>
|
|
<Button
|
|
variant="primary/medium"
|
|
disabled={!hasChanges}
|
|
onClick={() => onSave(editedContent, commitMessage, model)}
|
|
>
|
|
{isEditingOverride ? "Save override" : "Create override"}
|
|
</Button>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|
|
|
|
// ─── Details Tab ─────────────────────────────────────────
|
|
|
|
function DetailsTab({
|
|
prompt,
|
|
selectedVersion,
|
|
}: {
|
|
prompt: ReturnType<typeof useTypedLoaderData<typeof loader>>["prompt"];
|
|
selectedVersion?: VersionData;
|
|
}) {
|
|
const effectiveModel = selectedVersion?.model ?? prompt.defaultModel;
|
|
return (
|
|
<div className="space-y-4">
|
|
<Property.Table>
|
|
<Property.Item>
|
|
<Property.Label>Slug</Property.Label>
|
|
<Property.Value>
|
|
<code className="text-sm">{prompt.slug}</code>
|
|
</Property.Value>
|
|
</Property.Item>
|
|
{prompt.description && (
|
|
<Property.Item>
|
|
<Property.Label>Description</Property.Label>
|
|
<Property.Value>{prompt.description}</Property.Value>
|
|
</Property.Item>
|
|
)}
|
|
{effectiveModel && (
|
|
<Property.Item>
|
|
<Property.Label>Model</Property.Label>
|
|
<Property.Value>{effectiveModel}</Property.Value>
|
|
</Property.Item>
|
|
)}
|
|
{prompt.defaultConfig && (
|
|
<Property.Item>
|
|
<Property.Label className="mb-1.5">Config</Property.Label>
|
|
<Property.Value>
|
|
<CodeBlock
|
|
code={JSON.stringify(prompt.defaultConfig, null, 2)}
|
|
maxLines={10}
|
|
showLineNumbers={false}
|
|
/>
|
|
</Property.Value>
|
|
</Property.Item>
|
|
)}
|
|
{prompt.filePath && (
|
|
<Property.Item>
|
|
<Property.Label>Source</Property.Label>
|
|
<Property.Value>
|
|
<code className="text-sm">
|
|
{prompt.filePath}
|
|
{prompt.exportName ? ` (${prompt.exportName})` : ""}
|
|
</code>
|
|
</Property.Value>
|
|
</Property.Item>
|
|
)}
|
|
{prompt.tags.length > 0 && (
|
|
<Property.Item>
|
|
<Property.Label>Tags</Property.Label>
|
|
<Property.Value>
|
|
<div className="flex flex-wrap gap-1">
|
|
{prompt.tags.map((tag) => (
|
|
<Badge key={tag} variant="extra-small">
|
|
{tag}
|
|
</Badge>
|
|
))}
|
|
</div>
|
|
</Property.Value>
|
|
</Property.Item>
|
|
)}
|
|
</Property.Table>
|
|
|
|
{prompt.variableSchema && (
|
|
<div className="space-y-2">
|
|
<Header3>Variable schema</Header3>
|
|
<CodeBlock
|
|
code={JSON.stringify(prompt.variableSchema, null, 2)}
|
|
maxLines={20}
|
|
showLineNumbers={false}
|
|
showCopyButton
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── Preview Tab ─────────────────────────────────────────
|
|
|
|
function PreviewTab({
|
|
prompt,
|
|
content,
|
|
}: {
|
|
prompt: ReturnType<typeof useTypedLoaderData<typeof loader>>["prompt"];
|
|
content: string;
|
|
}) {
|
|
const variableFields = prompt.variableSchema ? extractVariableFields(prompt.variableSchema) : [];
|
|
const [testVariables, setTestVariables] = useState<Record<string, string>>(() =>
|
|
Object.fromEntries(variableFields.map((f) => [f.name, ""]))
|
|
);
|
|
const hasTestValues = Object.values(testVariables).some((v) => v.length > 0);
|
|
const previewText = hasTestValues ? compileTemplatePreview(content, testVariables) : null;
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
{variableFields.length > 0 ? (
|
|
<>
|
|
<div className="space-y-3">
|
|
<div>
|
|
<Header3 className="mb-1">Variables</Header3>
|
|
<Paragraph variant="small">
|
|
Fill in values to preview your resolved prompt template.
|
|
</Paragraph>
|
|
</div>
|
|
{variableFields.map((field, index) => (
|
|
<InputGroup className="max-w-full" key={field.name}>
|
|
<Label variant="small" required={field.required}>
|
|
{field.name}
|
|
</Label>
|
|
{field.enumValues ? (
|
|
<select
|
|
autoFocus={index === 0}
|
|
className="h-6 w-full rounded border border-border-bright bg-background-bright px-1 text-xs text-text-bright focus:border-indigo-500 focus:outline-hidden"
|
|
value={testVariables[field.name] ?? ""}
|
|
onChange={(e) =>
|
|
setTestVariables((prev) => ({
|
|
...prev,
|
|
[field.name]: e.target.value,
|
|
}))
|
|
}
|
|
>
|
|
<option value="">Select…</option>
|
|
{field.enumValues.map((v) => (
|
|
<option key={v} value={v}>
|
|
{v}
|
|
</option>
|
|
))}
|
|
</select>
|
|
) : field.isLongText ? (
|
|
<TextArea
|
|
autoFocus={index === 0}
|
|
rows={3}
|
|
className="w-full text-sm"
|
|
placeholder={field.placeholder}
|
|
value={testVariables[field.name] ?? ""}
|
|
onChange={(e) =>
|
|
setTestVariables((prev) => ({
|
|
...prev,
|
|
[field.name]: e.target.value,
|
|
}))
|
|
}
|
|
/>
|
|
) : (
|
|
<Input
|
|
autoFocus={index === 0}
|
|
variant="small"
|
|
placeholder={field.placeholder}
|
|
value={testVariables[field.name] ?? ""}
|
|
onChange={(e) =>
|
|
setTestVariables((prev) => ({
|
|
...prev,
|
|
[field.name]: e.target.value,
|
|
}))
|
|
}
|
|
/>
|
|
)}
|
|
{field.description && <Hint>{field.description}</Hint>}
|
|
</InputGroup>
|
|
))}
|
|
</div>
|
|
|
|
{previewText && (
|
|
<div className="space-y-1.5">
|
|
<Header3>Resolved output</Header3>
|
|
<div className="overflow-auto rounded border border-grid-bright bg-background-dimmed p-3">
|
|
<pre className="whitespace-pre-wrap font-mono text-sm leading-relaxed text-text-bright">
|
|
{previewText}
|
|
</pre>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</>
|
|
) : (
|
|
content && (
|
|
<>
|
|
<Header3>Resolved output</Header3>
|
|
<div className="overflow-auto rounded border border-grid-bright bg-background-dimmed p-3">
|
|
<pre className="whitespace-pre-wrap font-mono text-sm leading-relaxed text-text-bright">
|
|
{content}
|
|
</pre>
|
|
</div>
|
|
</>
|
|
)
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── Generations Tab ─────────────────────────────────────
|
|
|
|
function GenerationsTab({
|
|
promptSlug,
|
|
initialGenerations,
|
|
initialPagination,
|
|
selectedSpan,
|
|
onSelectSpan,
|
|
generationsSnapshot,
|
|
}: {
|
|
promptSlug: string;
|
|
initialGenerations: GenerationRow[];
|
|
initialPagination: { next?: string };
|
|
selectedSpan: { runId: string; spanId: string } | null;
|
|
onSelectSpan: (span: { runId: string; spanId: string } | null) => void;
|
|
generationsSnapshot?: ResizableSnapshot;
|
|
}) {
|
|
const organization = useOrganization();
|
|
const project = useProject();
|
|
const environment = useEnvironment();
|
|
const { value: searchValue } = useSearchParams();
|
|
const listRef = useRef<HTMLDivElement>(null);
|
|
const loadMoreRef = useRef<HTMLDivElement>(null);
|
|
const fetcher = useFetcher<{ generations: GenerationRow[]; pagination: { next?: string } }>();
|
|
const pollFetcher = useFetcher<{ generations: GenerationRow[]; pagination: { next?: string } }>();
|
|
|
|
// Accumulated generations state
|
|
const [generations, setGenerations] = useState<GenerationRow[]>(initialGenerations);
|
|
const [nextCursor, setNextCursor] = useState<string | undefined>(initialPagination.next);
|
|
const [newGenerationCount, setNewGenerationCount] = useState(0);
|
|
|
|
// Build the resource URL for fetches
|
|
const resourcePath = `/resources/orgs/${organization.slug}/projects/${project.slug}/env/${
|
|
environment.slug
|
|
}/prompts/${encodeURIComponent(promptSlug)}/generations`;
|
|
|
|
// Track filter state to reset on change
|
|
const { values: filterValues } = useSearchParams();
|
|
const models = filterValues("models").filter((v) => v !== "");
|
|
const operations = filterValues("operations").filter((v) => v !== "");
|
|
const providers = filterValues("providers").filter((v) => v !== "");
|
|
|
|
const versionFilters = filterValues("versions").filter((v) => v !== "");
|
|
const filterKey = `${versionFilters.join(",")}-${searchValue("period") ?? "7d"}-${
|
|
searchValue("from") ?? ""
|
|
}-${searchValue("to") ?? ""}-${models.join(",")}-${operations.join(",")}-${providers.join(",")}`;
|
|
const prevFilterKeyRef = useRef(filterKey);
|
|
|
|
// Reset when filters change (loader re-runs with new initial data)
|
|
useEffect(() => {
|
|
if (prevFilterKeyRef.current !== filterKey) {
|
|
prevFilterKeyRef.current = filterKey;
|
|
setGenerations(initialGenerations);
|
|
setNextCursor(initialPagination.next);
|
|
setNewGenerationCount(0);
|
|
}
|
|
}, [filterKey, initialGenerations, initialPagination.next]);
|
|
|
|
// Also reset if initialGenerations change identity (page navigation)
|
|
const initialGenRef = useRef(initialGenerations);
|
|
useEffect(() => {
|
|
if (initialGenRef.current !== initialGenerations) {
|
|
initialGenRef.current = initialGenerations;
|
|
setGenerations(initialGenerations);
|
|
setNextCursor(initialPagination.next);
|
|
setNewGenerationCount(0);
|
|
}
|
|
}, [initialGenerations, initialPagination.next]);
|
|
|
|
// Append fetched rows when fetcher completes
|
|
useEffect(() => {
|
|
if (fetcher.data && fetcher.state === "idle") {
|
|
setGenerations((prev) => {
|
|
const existingIds = new Set(prev.map((g) => g.span_id));
|
|
const newRows = fetcher.data!.generations.filter((g) => !existingIds.has(g.span_id));
|
|
return newRows.length > 0 ? [...prev, ...newRows] : prev;
|
|
});
|
|
setNextCursor(fetcher.data.pagination.next);
|
|
}
|
|
}, [fetcher.data, fetcher.state]);
|
|
|
|
// Poll for new generations periodically (also on focus/visibility)
|
|
useInterval({
|
|
interval: 10_000,
|
|
onLoad: false,
|
|
callback: () => {
|
|
if (pollFetcher.state !== "idle") return;
|
|
const params = new URLSearchParams();
|
|
for (const v of versionFilters) params.append("versions", v);
|
|
params.set("period", searchValue("period") ?? "7d");
|
|
const from = searchValue("from");
|
|
const to = searchValue("to");
|
|
if (from) params.set("from", from);
|
|
if (to) params.set("to", to);
|
|
for (const m of models) params.append("models", m);
|
|
for (const o of operations) params.append("operations", o);
|
|
for (const p of providers) params.append("providers", p);
|
|
pollFetcher.load(`${resourcePath}?${params.toString()}`);
|
|
},
|
|
});
|
|
|
|
// Check poll results for new generations — only react to new poll data, not generation list changes
|
|
const lastPollDataRef = useRef(pollFetcher.data);
|
|
useEffect(() => {
|
|
if (
|
|
pollFetcher.data &&
|
|
pollFetcher.state === "idle" &&
|
|
pollFetcher.data !== lastPollDataRef.current
|
|
) {
|
|
lastPollDataRef.current = pollFetcher.data;
|
|
const existingIds = new Set(generations.map((g) => g.span_id));
|
|
const newCount = pollFetcher.data.generations.filter(
|
|
(g) => !existingIds.has(g.span_id)
|
|
).length;
|
|
setNewGenerationCount(newCount);
|
|
}
|
|
}, [pollFetcher.data, pollFetcher.state, generations]);
|
|
|
|
const handleRefreshGenerations = useCallback(() => {
|
|
// Prepend new generations from the poll data
|
|
if (pollFetcher.data) {
|
|
setGenerations((prev) => {
|
|
const existingIds = new Set(prev.map((g) => g.span_id));
|
|
const newRows = pollFetcher.data!.generations.filter((g) => !existingIds.has(g.span_id));
|
|
return newRows.length > 0 ? [...newRows, ...prev] : prev;
|
|
});
|
|
}
|
|
setNewGenerationCount(0);
|
|
listRef.current?.scrollTo({ top: 0, behavior: "smooth" });
|
|
}, [pollFetcher.data]);
|
|
|
|
const handleLoadMore = useCallback(() => {
|
|
if (!nextCursor || fetcher.state !== "idle") return;
|
|
|
|
const params = new URLSearchParams();
|
|
for (const v of versionFilters) params.append("versions", v);
|
|
params.set("period", searchValue("period") ?? "7d");
|
|
const from = searchValue("from");
|
|
const to = searchValue("to");
|
|
if (from) params.set("from", from);
|
|
if (to) params.set("to", to);
|
|
for (const m of models) params.append("models", m);
|
|
for (const o of operations) params.append("operations", o);
|
|
for (const p of providers) params.append("providers", p);
|
|
params.set("cursor", nextCursor);
|
|
fetcher.load(`${resourcePath}?${params.toString()}`);
|
|
}, [
|
|
nextCursor,
|
|
fetcher,
|
|
searchValue,
|
|
resourcePath,
|
|
versionFilters,
|
|
models,
|
|
operations,
|
|
providers,
|
|
]);
|
|
|
|
// Debounced loading spinner
|
|
const isLoadingMore = fetcher.state === "loading";
|
|
const [showSpinner, setShowSpinner] = useState(false);
|
|
useEffect(() => {
|
|
if (!isLoadingMore) {
|
|
setShowSpinner(false);
|
|
return;
|
|
}
|
|
const timer = setTimeout(() => setShowSpinner(true), 200);
|
|
return () => clearTimeout(timer);
|
|
}, [isLoadingMore]);
|
|
|
|
// IntersectionObserver for infinite scroll
|
|
useEffect(() => {
|
|
if (!nextCursor || isLoadingMore) return;
|
|
|
|
const observer = new IntersectionObserver(
|
|
(entries) => {
|
|
if (entries[0].isIntersecting) {
|
|
handleLoadMore();
|
|
}
|
|
},
|
|
{ threshold: 0.1 }
|
|
);
|
|
|
|
const currentRef = loadMoreRef.current;
|
|
if (currentRef) {
|
|
observer.observe(currentRef);
|
|
}
|
|
|
|
return () => {
|
|
if (currentRef) {
|
|
observer.unobserve(currentRef);
|
|
}
|
|
};
|
|
}, [nextCursor, isLoadingMore, handleLoadMore]);
|
|
|
|
// Keyboard navigation
|
|
const selectedIndex = selectedSpan
|
|
? generations.findIndex((g) => g.span_id === selectedSpan.spanId)
|
|
: -1;
|
|
|
|
const selectByIndex = useCallback(
|
|
(index: number) => {
|
|
const gen = generations[index];
|
|
if (gen) {
|
|
onSelectSpan({ runId: gen.run_id, spanId: gen.span_id });
|
|
const items = listRef.current?.querySelectorAll("[data-generation-item]");
|
|
items?.[index]?.scrollIntoView({ block: "nearest" });
|
|
}
|
|
},
|
|
[generations, onSelectSpan]
|
|
);
|
|
|
|
useEffect(() => {
|
|
const handleKeyDown = (e: KeyboardEvent) => {
|
|
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return;
|
|
|
|
if (e.key === "ArrowDown" || e.key === "j") {
|
|
e.preventDefault();
|
|
const next = selectedIndex < generations.length - 1 ? selectedIndex + 1 : 0;
|
|
selectByIndex(next);
|
|
} else if (e.key === "ArrowUp" || e.key === "k") {
|
|
e.preventDefault();
|
|
const prev = selectedIndex > 0 ? selectedIndex - 1 : generations.length - 1;
|
|
selectByIndex(prev);
|
|
}
|
|
};
|
|
|
|
window.addEventListener("keydown", handleKeyDown);
|
|
return () => window.removeEventListener("keydown", handleKeyDown);
|
|
}, [selectedIndex, generations.length, selectByIndex]);
|
|
|
|
if (generations.length === 0) {
|
|
return (
|
|
<div className="flex h-full items-center justify-center">
|
|
<InfoPanel
|
|
title="No generations yet"
|
|
icon={AIChatIcon}
|
|
iconClassName="text-aiPrompts"
|
|
panelClassName="max-w-md"
|
|
>
|
|
<Paragraph variant="small">
|
|
Generations appear here when this prompt version is resolved inside a task using{" "}
|
|
<InlineCode variant="small">prompt.resolve()</InlineCode> with an AI SDK call.
|
|
</Paragraph>
|
|
<Paragraph variant="small">
|
|
Try adjusting the time period filter or switching to a different version.
|
|
</Paragraph>
|
|
</InfoPanel>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<ResizablePanelGroup
|
|
autosaveId="prompt-generations"
|
|
snapshot={generationsSnapshot}
|
|
className="h-full"
|
|
>
|
|
{/* Span list */}
|
|
<ResizablePanel id="prompt-gen-list" min="200px">
|
|
<div
|
|
ref={listRef}
|
|
className="h-full overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control"
|
|
>
|
|
{newGenerationCount > 0 && (
|
|
<div className="sticky top-0 z-20 flex items-center justify-center gap-2 border-b border-grid-dimmed bg-background-bright px-3 py-1.5">
|
|
<span className="text-xs text-text-dimmed">
|
|
{newGenerationCount} new {newGenerationCount === 1 ? "generation" : "generations"}
|
|
</span>
|
|
<Button
|
|
variant="minimal/small"
|
|
onClick={handleRefreshGenerations}
|
|
LeadingIcon={ArrowPathIcon}
|
|
>
|
|
Refresh
|
|
</Button>
|
|
</div>
|
|
)}
|
|
<Table variant="bright" fullWidth showTopBorder={false}>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHeaderCell className="w-8" />
|
|
<TableHeaderCell>Operation</TableHeaderCell>
|
|
<TableHeaderCell>Version</TableHeaderCell>
|
|
<TableHeaderCell>Model</TableHeaderCell>
|
|
<TableHeaderCell>Tokens</TableHeaderCell>
|
|
<TableHeaderCell>Cost</TableHeaderCell>
|
|
<TableHeaderCell>Duration</TableHeaderCell>
|
|
<TableHeaderCell alignment="right">Time</TableHeaderCell>
|
|
<TableHeaderCell className="w-12" hiddenLabel>
|
|
Actions
|
|
</TableHeaderCell>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{generations.map((gen, i) => {
|
|
const isSelected = selectedSpan?.spanId === gen.span_id;
|
|
const runPath = v3RunSpanPath(
|
|
organization,
|
|
project,
|
|
environment,
|
|
{ friendlyId: gen.run_id },
|
|
{ spanId: gen.span_id }
|
|
);
|
|
return (
|
|
<TableRow
|
|
key={`${gen.run_id}-${gen.span_id}-${i}`}
|
|
data-generation-item
|
|
isSelected={isSelected}
|
|
className="cursor-pointer [&_td]:cursor-pointer"
|
|
onClick={() => onSelectSpan({ runId: gen.run_id, spanId: gen.span_id })}
|
|
>
|
|
<TableCell>
|
|
<RadioButtonCircle checked={isSelected} />
|
|
</TableCell>
|
|
<TableCell className={cn("font-medium", isSelected && "text-text-bright")}>
|
|
{gen.operation_id || gen.task_identifier}
|
|
</TableCell>
|
|
<TableCell
|
|
className={cn(
|
|
"tabular-nums",
|
|
isSelected ? "text-text-bright" : "text-text-dimmed"
|
|
)}
|
|
>
|
|
v{gen.prompt_version}
|
|
</TableCell>
|
|
<TableCell className={cn(isSelected && "text-text-bright")}>
|
|
{gen.response_model}
|
|
</TableCell>
|
|
<TableCell className={cn("tabular-nums", isSelected && "text-text-bright")}>
|
|
{gen.input_tokens + gen.output_tokens}
|
|
</TableCell>
|
|
<TableCell className={cn("tabular-nums", isSelected && "text-text-bright")}>
|
|
{formatCost(gen.total_cost)}
|
|
</TableCell>
|
|
<TableCell className={cn("tabular-nums", isSelected && "text-text-bright")}>
|
|
{Math.round(gen.duration_ms)}ms
|
|
</TableCell>
|
|
<TableCell
|
|
alignment="right"
|
|
className={cn("tabular-nums", isSelected && "text-text-bright")}
|
|
>
|
|
{gen.start_time}
|
|
</TableCell>
|
|
<TableCellMenu
|
|
isSticky
|
|
isSelected={isSelected}
|
|
hiddenButtons={
|
|
<LinkButton
|
|
to={runPath}
|
|
onClick={(e) => e.stopPropagation()}
|
|
variant="minimal/small"
|
|
TrailingIcon={RunsIcon}
|
|
trailingIconClassName="text-text-bright"
|
|
className="h-5.5 pl-1.5 pr-2"
|
|
>
|
|
<span className="text-[0.6875rem] text-text-bright">View run</span>
|
|
</LinkButton>
|
|
}
|
|
/>
|
|
</TableRow>
|
|
);
|
|
})}
|
|
</TableBody>
|
|
</Table>
|
|
|
|
{/* Infinite scroll sentinel */}
|
|
<div ref={loadMoreRef} className="h-px" />
|
|
{showSpinner && (
|
|
<div className="flex items-center justify-center py-3">
|
|
<Spinner className="size-4" />
|
|
</div>
|
|
)}
|
|
</div>
|
|
</ResizablePanel>
|
|
|
|
<ResizableHandle id="prompt-gen-handle" />
|
|
|
|
{/* Span inspector */}
|
|
<ResizablePanel id="prompt-gen-inspector" default="430px" min="320px" isStaticAtRest>
|
|
{selectedSpan ? (
|
|
<SpanView runParam={selectedSpan.runId} spanId={selectedSpan.spanId} />
|
|
) : (
|
|
<div className="flex h-full items-center justify-center bg-background-bright">
|
|
<Paragraph variant="small" className="text-text-dimmed">
|
|
Select a generation to view details
|
|
</Paragraph>
|
|
</div>
|
|
)}
|
|
</ResizablePanel>
|
|
</ResizablePanelGroup>
|
|
);
|
|
}
|
|
|
|
function formatCost(cost: number): string {
|
|
if (cost >= 1) return `$${cost.toFixed(2)}`;
|
|
if (cost >= 0.01) return `$${cost.toFixed(4)}`;
|
|
if (cost === 0) return "$0";
|
|
return `$${cost.toFixed(6)}`;
|
|
}
|
|
|
|
// ─── Metrics Tab ─────────────────────────────────────────
|
|
|
|
function MetricsTab({
|
|
prompt,
|
|
organizationId,
|
|
projectId,
|
|
environmentId,
|
|
period,
|
|
from,
|
|
to,
|
|
}: {
|
|
prompt: { slug: string };
|
|
organizationId: string;
|
|
projectId: string;
|
|
environmentId: string;
|
|
period: string;
|
|
from: string | null;
|
|
to: string | null;
|
|
}) {
|
|
const { values: filterValues } = useSearchParams();
|
|
const versionFilters = filterValues("versions")
|
|
.map(Number)
|
|
.filter((n) => Number.isInteger(n) && n > 0);
|
|
const models = filterValues("models").filter((v) => v !== "");
|
|
const operations = filterValues("operations").filter((v) => v !== "");
|
|
const providers = filterValues("providers").filter((v) => v !== "");
|
|
|
|
const widgetProps = {
|
|
organizationId,
|
|
projectId,
|
|
environmentId,
|
|
scope: "environment" as const,
|
|
period,
|
|
from,
|
|
to,
|
|
promptSlugs: [prompt.slug],
|
|
promptVersions: versionFilters.length > 0 ? versionFilters : undefined,
|
|
responseModels: models.length > 0 ? models : undefined,
|
|
operations: operations.length > 0 ? operations : undefined,
|
|
providers: providers.length > 0 ? providers : undefined,
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-3">
|
|
{/* Summary big numbers */}
|
|
<div className="grid grid-cols-5 gap-3">
|
|
<div className="h-44">
|
|
<MetricWidget
|
|
widgetKey={`prompt-${prompt.slug}-generations`}
|
|
title="Total"
|
|
query={`SELECT count() AS generations FROM llm_metrics WHERE 1=1`}
|
|
config={{
|
|
type: "bignumber",
|
|
column: "generations",
|
|
aggregation: "sum",
|
|
abbreviate: true,
|
|
}}
|
|
{...widgetProps}
|
|
/>
|
|
</div>
|
|
<div className="h-44">
|
|
<MetricWidget
|
|
widgetKey={`prompt-${prompt.slug}-tokens`}
|
|
title="Avg input tokens"
|
|
query={`SELECT round(avg(input_tokens)) AS avg_input FROM llm_metrics WHERE 1=1`}
|
|
config={{
|
|
type: "bignumber",
|
|
column: "avg_input",
|
|
aggregation: "avg",
|
|
abbreviate: true,
|
|
}}
|
|
{...widgetProps}
|
|
/>
|
|
</div>
|
|
<div className="h-44">
|
|
<MetricWidget
|
|
widgetKey={`prompt-${prompt.slug}-cost`}
|
|
title="Avg input cost"
|
|
query={`SELECT avg(input_cost + cached_read_cost + cache_creation_cost) AS avg_cost FROM llm_metrics WHERE 1=1`}
|
|
config={{
|
|
type: "bignumber",
|
|
column: "avg_cost",
|
|
aggregation: "avg",
|
|
abbreviate: false,
|
|
}}
|
|
{...widgetProps}
|
|
/>
|
|
</div>
|
|
<div className="h-44">
|
|
<MetricWidget
|
|
widgetKey={`prompt-${prompt.slug}-latency`}
|
|
title="Avg latency"
|
|
query={`SELECT round(avg(duration) / 1000000, 1) AS avg_ms FROM llm_metrics WHERE 1=1`}
|
|
config={{
|
|
type: "bignumber",
|
|
column: "avg_ms",
|
|
aggregation: "avg",
|
|
abbreviate: false,
|
|
suffix: "ms",
|
|
}}
|
|
{...widgetProps}
|
|
/>
|
|
</div>
|
|
<div className="h-44">
|
|
<MetricWidget
|
|
widgetKey={`prompt-${prompt.slug}-cached-tokens`}
|
|
title="Cached tokens"
|
|
query={`SELECT sum(cached_read_tokens) AS cached_tokens FROM llm_metrics WHERE 1=1`}
|
|
config={{
|
|
type: "bignumber",
|
|
column: "cached_tokens",
|
|
aggregation: "sum",
|
|
abbreviate: true,
|
|
}}
|
|
{...widgetProps}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Version performance */}
|
|
<VersionPerformanceSection
|
|
promptSlug={prompt.slug}
|
|
organizationId={organizationId}
|
|
projectId={projectId}
|
|
environmentId={environmentId}
|
|
period={period}
|
|
from={from}
|
|
to={to}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── Version Performance Section ─────────────────────────
|
|
|
|
function VersionPerformanceSection({
|
|
promptSlug,
|
|
organizationId,
|
|
projectId,
|
|
environmentId,
|
|
period,
|
|
from,
|
|
to,
|
|
}: {
|
|
promptSlug: string;
|
|
organizationId: string;
|
|
projectId: string;
|
|
environmentId: string;
|
|
period: string;
|
|
from: string | null;
|
|
to: string | null;
|
|
}) {
|
|
const { values: filterValues } = useSearchParams();
|
|
const versionFilters = filterValues("versions")
|
|
.map(Number)
|
|
.filter((n) => Number.isInteger(n) && n > 0);
|
|
const models = filterValues("models").filter((v) => v !== "");
|
|
const operations = filterValues("operations").filter((v) => v !== "");
|
|
const providers = filterValues("providers").filter((v) => v !== "");
|
|
|
|
const widgetProps = {
|
|
organizationId,
|
|
projectId,
|
|
environmentId,
|
|
scope: "environment" as const,
|
|
period,
|
|
from,
|
|
to,
|
|
promptSlugs: [promptSlug],
|
|
promptVersions: versionFilters.length > 0 ? versionFilters : undefined,
|
|
responseModels: models.length > 0 ? models : undefined,
|
|
operations: operations.length > 0 ? operations : undefined,
|
|
providers: providers.length > 0 ? providers : undefined,
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-3">
|
|
<div className="grid grid-cols-2 gap-3">
|
|
{/* Row 1: Latency + TTFC */}
|
|
<div className="h-96">
|
|
<MetricWidget
|
|
widgetKey={`prompt-${promptSlug}-perf-latency`}
|
|
title="Latency p50 / p95"
|
|
query={`SELECT timeBucket(), round(quantile(0.5)(duration) / 1000000, 1) AS p50, round(quantile(0.95)(duration) / 1000000, 1) AS p95 FROM llm_metrics WHERE 1=1 GROUP BY timeBucket ORDER BY timeBucket`}
|
|
config={{
|
|
type: "chart",
|
|
chartType: "line",
|
|
xAxisColumn: "timebucket",
|
|
yAxisColumns: ["p50", "p95"],
|
|
groupByColumn: null,
|
|
stacked: false,
|
|
sortByColumn: null,
|
|
sortDirection: "asc",
|
|
aggregation: "avg",
|
|
}}
|
|
{...widgetProps}
|
|
/>
|
|
</div>
|
|
<div className="h-96">
|
|
<MetricWidget
|
|
widgetKey={`prompt-${promptSlug}-perf-ttfc`}
|
|
title="TTFC p50 / p95"
|
|
query={`SELECT timeBucket(), round(quantile(0.5)(ms_to_first_chunk), 1) AS p50, round(quantile(0.95)(ms_to_first_chunk), 1) AS p95 FROM llm_metrics WHERE ms_to_first_chunk > 0 GROUP BY timeBucket ORDER BY timeBucket`}
|
|
config={{
|
|
type: "chart",
|
|
chartType: "line",
|
|
xAxisColumn: "timebucket",
|
|
yAxisColumns: ["p50", "p95"],
|
|
groupByColumn: null,
|
|
stacked: false,
|
|
sortByColumn: null,
|
|
sortDirection: "asc",
|
|
aggregation: "avg",
|
|
}}
|
|
{...widgetProps}
|
|
/>
|
|
</div>
|
|
{/* Row 2: Input tokens + Input cost */}
|
|
<div className="h-96">
|
|
<MetricWidget
|
|
widgetKey={`prompt-${promptSlug}-perf-input-tokens`}
|
|
title="Input tokens p50 / p95"
|
|
query={`SELECT timeBucket(), round(quantile(0.5)(input_tokens)) AS p50, round(quantile(0.95)(input_tokens)) AS p95 FROM llm_metrics WHERE 1=1 GROUP BY timeBucket ORDER BY timeBucket`}
|
|
config={{
|
|
type: "chart",
|
|
chartType: "line",
|
|
xAxisColumn: "timebucket",
|
|
yAxisColumns: ["p50", "p95"],
|
|
groupByColumn: null,
|
|
stacked: false,
|
|
sortByColumn: null,
|
|
sortDirection: "asc",
|
|
aggregation: "avg",
|
|
}}
|
|
{...widgetProps}
|
|
/>
|
|
</div>
|
|
<div className="h-96">
|
|
<MetricWidget
|
|
widgetKey={`prompt-${promptSlug}-perf-input-cost`}
|
|
title="Input cost per 1k tokens (p50 / p95)"
|
|
query={`SELECT timeBucket(), prettyFormat(quantile(0.5)((input_cost + cached_read_cost + cache_creation_cost) / input_tokens * 1000), 'costInDollars') AS p50, prettyFormat(quantile(0.95)((input_cost + cached_read_cost + cache_creation_cost) / input_tokens * 1000), 'costInDollars') AS p95 FROM llm_metrics WHERE input_tokens > 0 GROUP BY timeBucket ORDER BY timeBucket`}
|
|
config={{
|
|
type: "chart",
|
|
chartType: "line",
|
|
xAxisColumn: "timebucket",
|
|
yAxisColumns: ["p50", "p95"],
|
|
groupByColumn: null,
|
|
stacked: false,
|
|
sortByColumn: null,
|
|
sortDirection: "asc",
|
|
aggregation: "avg",
|
|
}}
|
|
{...widgetProps}
|
|
/>
|
|
</div>
|
|
{/* Row 3: Output tokens + Output cost */}
|
|
<div className="h-96">
|
|
<MetricWidget
|
|
widgetKey={`prompt-${promptSlug}-perf-output-tokens`}
|
|
title="Output tokens p50 / p95"
|
|
query={`SELECT timeBucket(), round(quantile(0.5)(output_tokens)) AS p50, round(quantile(0.95)(output_tokens)) AS p95 FROM llm_metrics WHERE 1=1 GROUP BY timeBucket ORDER BY timeBucket`}
|
|
config={{
|
|
type: "chart",
|
|
chartType: "line",
|
|
xAxisColumn: "timebucket",
|
|
yAxisColumns: ["p50", "p95"],
|
|
groupByColumn: null,
|
|
stacked: false,
|
|
sortByColumn: null,
|
|
sortDirection: "asc",
|
|
aggregation: "avg",
|
|
}}
|
|
{...widgetProps}
|
|
/>
|
|
</div>
|
|
<div className="h-96">
|
|
<MetricWidget
|
|
widgetKey={`prompt-${promptSlug}-perf-output-cost`}
|
|
title="Output cost per 1k tokens (p50 / p95)"
|
|
query={`SELECT timeBucket(), prettyFormat(quantile(0.5)(output_cost / output_tokens * 1000), 'costInDollars') AS p50, prettyFormat(quantile(0.95)(output_cost / output_tokens * 1000), 'costInDollars') AS p95 FROM llm_metrics WHERE output_tokens > 0 GROUP BY timeBucket ORDER BY timeBucket`}
|
|
config={{
|
|
type: "chart",
|
|
chartType: "line",
|
|
xAxisColumn: "timebucket",
|
|
yAxisColumns: ["p50", "p95"],
|
|
groupByColumn: null,
|
|
stacked: false,
|
|
sortByColumn: null,
|
|
sortDirection: "asc",
|
|
aggregation: "avg",
|
|
}}
|
|
{...widgetProps}
|
|
/>
|
|
</div>
|
|
{/* Row 4: Caching */}
|
|
<div className="h-96">
|
|
<MetricWidget
|
|
widgetKey={`prompt-${promptSlug}-perf-cache-hit`}
|
|
title="Cache hit rate over time"
|
|
query={`SELECT timeBucket(), round(ifNull(sum(cached_read_tokens) * 100.0 / nullIf(sum(input_tokens), 0), 0), 1) AS cache_hit_pct FROM llm_metrics WHERE 1=1 GROUP BY timeBucket ORDER BY timeBucket`}
|
|
config={{
|
|
type: "chart",
|
|
chartType: "line",
|
|
xAxisColumn: "timebucket",
|
|
yAxisColumns: ["cache_hit_pct"],
|
|
groupByColumn: null,
|
|
stacked: false,
|
|
sortByColumn: null,
|
|
sortDirection: "asc",
|
|
aggregation: "avg",
|
|
}}
|
|
{...widgetProps}
|
|
/>
|
|
</div>
|
|
<div className="h-96">
|
|
<MetricWidget
|
|
widgetKey={`prompt-${promptSlug}-perf-cached-tokens`}
|
|
title="Cached tokens over time"
|
|
query={`SELECT timeBucket(), sum(cached_read_tokens) AS cache_reads, sum(cache_creation_tokens) AS cache_writes FROM llm_metrics WHERE 1=1 GROUP BY timeBucket ORDER BY timeBucket`}
|
|
config={{
|
|
type: "chart",
|
|
chartType: "bar",
|
|
xAxisColumn: "timebucket",
|
|
yAxisColumns: ["cache_reads", "cache_writes"],
|
|
groupByColumn: null,
|
|
stacked: true,
|
|
sortByColumn: null,
|
|
sortDirection: "asc",
|
|
aggregation: "sum",
|
|
}}
|
|
{...widgetProps}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="h-48">
|
|
<MetricWidget
|
|
widgetKey={`prompt-${promptSlug}-perf-versions-table`}
|
|
title="Version summary"
|
|
query={`SELECT prompt_version, count() AS calls, round(avg(input_tokens)) AS avg_input_tokens, round(avg(output_tokens)) AS avg_output_tokens, prettyFormat(avg(total_cost), 'costInDollars') AS avg_total_cost, round(quantile(0.5)(duration) / 1000000, 1) AS p50_latency_ms, round(quantile(0.95)(duration) / 1000000, 1) AS p95_latency_ms FROM llm_metrics WHERE 1=1 GROUP BY prompt_version ORDER BY prompt_version DESC`}
|
|
config={{ type: "table", prettyFormatting: true, sorting: [] }}
|
|
{...widgetProps}
|
|
/>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── Prompt Versions Filter ──────────────────────────────
|
|
|
|
function PromptVersionsFilter({ versions }: { versions: VersionData[] }) {
|
|
const { values, replace, del } = useSearchParams();
|
|
const selected = values("versions");
|
|
|
|
const handleChange = (newValues: string[]) => {
|
|
replace({ versions: newValues });
|
|
};
|
|
|
|
if (selected.length === 0 || selected.every((v) => v === "")) {
|
|
return (
|
|
<SelectProvider value={[]} setValue={handleChange} virtualFocus={true}>
|
|
<SelectTrigger
|
|
icon={<GitBranchPlusIcon className="size-4" />}
|
|
variant="secondary/small"
|
|
tooltipTitle="Filter by version"
|
|
shortcut={{ key: "e" }}
|
|
>
|
|
<span className="ml-0.5">Versions</span>
|
|
</SelectTrigger>
|
|
<SelectPopover className="min-w-0 max-w-[min(240px,var(--popover-available-width))]">
|
|
<SelectList>
|
|
{versions.map((v) => (
|
|
<SelectItem key={v.version} value={String(v.version)}>
|
|
v{v.version}
|
|
{v.labels.includes("current") ? " (current)" : ""}
|
|
{v.labels.includes("override") ? " (override)" : ""}
|
|
</SelectItem>
|
|
))}
|
|
</SelectList>
|
|
</SelectPopover>
|
|
</SelectProvider>
|
|
);
|
|
}
|
|
|
|
const summary = selected.length === 1 ? `v${selected[0]}` : `${selected.length} versions`;
|
|
|
|
return (
|
|
<SelectProvider value={selected} setValue={handleChange} virtualFocus={true}>
|
|
<Ariakit.Select render={<div className="group cursor-pointer focus-custom" />}>
|
|
<AppliedFilter
|
|
label="Version"
|
|
icon={<GitBranchPlusIcon className="size-4" />}
|
|
value={summary}
|
|
onRemove={() => del(["versions"])}
|
|
variant="secondary/small"
|
|
/>
|
|
</Ariakit.Select>
|
|
<SelectPopover className="min-w-0 max-w-[min(240px,var(--popover-available-width))]">
|
|
<SelectList>
|
|
{versions.map((v) => (
|
|
<SelectItem key={v.version} value={String(v.version)}>
|
|
v{v.version}
|
|
{v.labels.includes("current") ? " (current)" : ""}
|
|
{v.labels.includes("override") ? " (override)" : ""}
|
|
</SelectItem>
|
|
))}
|
|
</SelectList>
|
|
</SelectPopover>
|
|
</SelectProvider>
|
|
);
|
|
}
|
|
|
|
// ─── Versions Tab ────────────────────────────────────────
|
|
|
|
function VersionsTab({
|
|
versions,
|
|
selectedVersion,
|
|
onSelectVersion,
|
|
}: {
|
|
versions: VersionData[];
|
|
selectedVersion: VersionData | undefined;
|
|
onSelectVersion: (version: number) => void;
|
|
}) {
|
|
return (
|
|
<div className="divide-y divide-grid-dimmed border-b border-grid-dimmed">
|
|
{versions.map((v) => {
|
|
const isSelected = selectedVersion?.id === v.id;
|
|
const isCurrent = v.labels.includes("current");
|
|
const isLatest = v.labels.includes("latest");
|
|
const isOverride = v.labels.includes("override");
|
|
|
|
return (
|
|
<div
|
|
key={v.id}
|
|
onClick={() => onSelectVersion(v.version)}
|
|
className={cn(
|
|
"flex cursor-pointer items-center gap-3 px-3 py-3 text-sm transition",
|
|
isSelected
|
|
? "bg-indigo-500/10 hover:bg-indigo-500/[0.07]"
|
|
: "hover:bg-background-hover"
|
|
)}
|
|
>
|
|
<RadioButtonCircle checked={isSelected} />
|
|
<div className="min-w-0 flex-1">
|
|
<div className="flex items-center gap-1.5">
|
|
<div
|
|
className={cn(
|
|
"size-2 shrink-0 rounded-full",
|
|
isOverride ? "bg-amber-400" : isCurrent ? "bg-green-500" : "bg-surface-control"
|
|
)}
|
|
/>
|
|
<span className="font-medium text-text-bright">v{v.version}</span>
|
|
{isOverride && (
|
|
<Badge variant="extra-small" className="border-amber-500/30 text-amber-400">
|
|
override
|
|
</Badge>
|
|
)}
|
|
{isCurrent && <Badge variant="extra-small">current</Badge>}
|
|
{isLatest && !isCurrent && <Badge variant="extra-small">latest</Badge>}
|
|
<span
|
|
className={cn(
|
|
"text-xs",
|
|
v.source !== "code" ? "text-amber-400" : "text-text-dimmed"
|
|
)}
|
|
>
|
|
{v.source}
|
|
</span>
|
|
</div>
|
|
{(v.model || v.commitMessage) && (
|
|
<div className="flex items-center gap-1.5 truncate text-xs text-text-dimmed">
|
|
{v.model && <span>{v.model}</span>}
|
|
{v.model && v.commitMessage && <span className="text-charcoal-600">/</span>}
|
|
{v.commitMessage && <span className="truncate">{v.commitMessage}</span>}
|
|
</div>
|
|
)}
|
|
</div>
|
|
<span className="shrink-0 text-xs text-text-dimmed">
|
|
<DateTime date={v.createdAt} />
|
|
</span>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const MAX_DESCRIPTION_PREVIEW = 80;
|
|
|
|
function PromptCopyPopover({
|
|
slug,
|
|
friendlyId,
|
|
description,
|
|
}: {
|
|
slug: string;
|
|
friendlyId: string;
|
|
description: string | null;
|
|
}) {
|
|
const [open, setOpen] = useState(false);
|
|
|
|
return (
|
|
<Popover open={open} onOpenChange={setOpen}>
|
|
<PopoverTrigger className="-ml-1.5 flex items-center gap-1 rounded py-1.5 pl-2 pr-1.5 font-mono text-xs text-text-dimmed transition focus-custom hover:bg-background-hover hover:text-text-bright">
|
|
{slug}
|
|
<ChevronUpDownIcon className="size-4 text-text-faint" />
|
|
</PopoverTrigger>
|
|
<PopoverContent
|
|
align="start"
|
|
className="flex min-w-0 flex-col p-1"
|
|
onOpenAutoFocus={(e) => {
|
|
e.preventDefault();
|
|
const el = e.currentTarget as HTMLElement;
|
|
el.style.pointerEvents = "none";
|
|
requestAnimationFrame(() => {
|
|
el.style.pointerEvents = "";
|
|
});
|
|
}}
|
|
>
|
|
<CopyPopoverItem label="Copy slug" value={slug} onCopied={() => setOpen(false)} />
|
|
<CopyPopoverItem
|
|
label="Copy friendly ID"
|
|
value={friendlyId}
|
|
onCopied={() => setOpen(false)}
|
|
/>
|
|
{description && (
|
|
<CopyPopoverItem
|
|
label="Copy description"
|
|
value={description}
|
|
preview={
|
|
description.length > MAX_DESCRIPTION_PREVIEW
|
|
? description.slice(0, MAX_DESCRIPTION_PREVIEW) + "…"
|
|
: description
|
|
}
|
|
onCopied={() => setOpen(false)}
|
|
/>
|
|
)}
|
|
</PopoverContent>
|
|
</Popover>
|
|
);
|
|
}
|
|
|
|
function CopyPopoverItem({
|
|
label,
|
|
value,
|
|
preview,
|
|
onCopied,
|
|
}: {
|
|
label: string;
|
|
value: string;
|
|
preview?: string;
|
|
onCopied?: () => void;
|
|
}) {
|
|
const [copied, setCopied] = useState(false);
|
|
|
|
const handleCopy = () => {
|
|
navigator.clipboard.writeText(value);
|
|
setCopied(true);
|
|
setTimeout(() => {
|
|
setCopied(false);
|
|
onCopied?.();
|
|
}, 600);
|
|
};
|
|
|
|
return (
|
|
<SimpleTooltip
|
|
button={
|
|
<button
|
|
type="button"
|
|
onClick={handleCopy}
|
|
className={cn(
|
|
"flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs transition",
|
|
copied
|
|
? "text-green-500"
|
|
: "text-text-dimmed hover:bg-background-raised hover:text-text-bright"
|
|
)}
|
|
>
|
|
{copied ? (
|
|
<ClipboardCheckIcon className="size-3.5 shrink-0" />
|
|
) : (
|
|
<ClipboardIcon className="size-3.5 shrink-0" />
|
|
)}
|
|
{label}
|
|
</button>
|
|
}
|
|
content={<span className="max-w-64 break-all font-mono text-xs">{preview ?? value}</span>}
|
|
side="right"
|
|
disableHoverableContent
|
|
asChild
|
|
/>
|
|
);
|
|
}
|