feat(ui): migrate prompts subtree to shadcn

Migrates the add/info/code-snippet surfaces of the prompts subtree:

- add_prompt_form.tsx: antd Form.useForm -> react-hook-form (FormProvider
  + register with RHF validation rules). antd Select -> shadcn Select +
  Controller. antd Upload -> native <input type=file> button (retains
  .prompt validation + single-file behavior). antd Option import removed.
- prompt_editor_view/PromptCodeSnippets.tsx: antd Tabs -> shadcn
  Tabs/TabsList/TabsTrigger (preserves controlled activeKey pattern).
- prompt_info.tsx: tremor Card/Title/Text/Grid/Badge/TremorButton/Tab*/
  Table* -> shadcn Card/Badge/Button/Tabs*/Table*. antd Button/Modal ->
  shadcn Button/Dialog. @ant-design/icons already lucide. text-gray-*
  -> text-muted-foreground; bg-gray-* -> bg-muted. Environment status
  palette (red/amber/green for production/staging/development) and
  copy-success palette kept raw with eslint override.

prompt_info.tsx gets added to .eslintrc.json's no-raw-tailwind-colors
override list where it keeps the environment status palette.

Co-authored-by: yuneng-jiang <yuneng-berri@users.noreply.github.com>
This commit is contained in:
cursor 2026-04-24 11:03:35 +00:00
parent 6badcee6c2
commit 68bbcae478
No known key found for this signature in database
5 changed files with 485 additions and 426 deletions

View file

@ -160,7 +160,8 @@
"src/components/view_users/user_info_view.tsx",
"src/components/UsagePage/components/EntityUsage/EntityUsage.tsx",
"src/components/UsagePage/components/UsageAIChatPanel.tsx",
"src/components/UsagePage/components/UsagePageView.tsx"
"src/components/UsagePage/components/UsagePageView.tsx",
"src/components/prompts/prompt_info.tsx"
],
"rules": {
"litellm-ui/no-banned-ui-imports": "off",

View file

@ -1,4 +1,5 @@
import React, { useState } from "react";
import React, { useRef, useState } from "react";
import { Controller, FormProvider, useForm } from "react-hook-form";
import { Button } from "@/components/ui/button";
import {
Dialog,
@ -8,14 +9,18 @@ import {
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Form, Select, Upload } from "antd";
import { Upload as UploadIcon } from "lucide-react";
import type { UploadFile, UploadProps } from "antd";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Upload as UploadIcon, X } from "lucide-react";
import { convertPromptFileToJson, createPromptCall } from "../networking";
import NotificationsManager from "../molecules/notifications_manager";
const { Option } = Select;
interface AddPromptFormProps {
visible: boolean;
onClose: () => void;
@ -23,172 +28,212 @@ interface AddPromptFormProps {
onSuccess: () => void;
}
interface PromptFormData {
interface PromptFormValues {
prompt_id: string;
prompt_integration: string;
prompt_file?: File;
}
const PROMPT_ID_PATTERN = /^[a-zA-Z0-9_-]+$/;
const AddPromptForm: React.FC<AddPromptFormProps> = ({ visible, onClose, accessToken, onSuccess }) => {
const [form] = Form.useForm();
const [loading, setLoading] = useState(false);
const [fileList, setFileList] = useState<UploadFile[]>([]);
const [promptIntegration, setPromptIntegration] = useState<string>("dotprompt");
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const fileInputRef = useRef<HTMLInputElement | null>(null);
const form = useForm<PromptFormValues>({
defaultValues: {
prompt_id: "",
prompt_integration: "dotprompt",
},
});
const promptIntegration = form.watch("prompt_integration");
const handleCancel = () => {
form.resetFields();
setFileList([]);
setPromptIntegration("dotprompt");
form.reset({ prompt_id: "", prompt_integration: "dotprompt" });
setSelectedFile(null);
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
onClose();
};
const handleSubmit = async () => {
try {
const values = await form.validateFields();
const onSubmit = form.handleSubmit(async (values) => {
console.log("values: ", values);
if (!accessToken) {
NotificationsManager.fromBackend("Access token is required");
return;
}
console.log("values: ", values);
if (!accessToken) {
NotificationsManager.fromBackend("Access token is required");
return;
}
if (values.prompt_integration === "dotprompt" && !selectedFile) {
NotificationsManager.fromBackend("Please upload a .prompt file");
return;
}
if (promptIntegration === "dotprompt" && fileList.length === 0) {
NotificationsManager.fromBackend("Please upload a .prompt file");
return;
}
setLoading(true);
setLoading(true);
let promptData: any = {};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let promptData: any = {};
if (promptIntegration === "dotprompt" && fileList.length > 0) {
// Convert the uploaded file to JSON
const file = fileList[0].originFileObj as File;
try {
const conversionResult = await convertPromptFileToJson(accessToken, file);
console.log("Conversion result:", conversionResult);
// Prepare prompt data for creation
promptData = {
prompt_id: values.prompt_id,
litellm_params: {
prompt_integration: "dotprompt",
prompt_id: conversionResult.prompt_id,
prompt_data: conversionResult.json_data,
},
prompt_info: {
prompt_type: "db",
},
};
} catch (conversionError) {
console.error("Error converting prompt file:", conversionError);
NotificationsManager.fromBackend("Failed to convert prompt file to JSON");
setLoading(false);
return;
}
}
// Create the prompt
if (values.prompt_integration === "dotprompt" && selectedFile) {
try {
await createPromptCall(accessToken, promptData);
NotificationsManager.success("Prompt created successfully!");
handleCancel();
onSuccess();
} catch (createError) {
console.error("Error creating prompt:", createError);
NotificationsManager.fromBackend("Failed to create prompt");
const conversionResult = await convertPromptFileToJson(accessToken, selectedFile);
console.log("Conversion result:", conversionResult);
promptData = {
prompt_id: values.prompt_id,
litellm_params: {
prompt_integration: "dotprompt",
prompt_id: conversionResult.prompt_id,
prompt_data: conversionResult.json_data,
},
prompt_info: {
prompt_type: "db",
},
};
} catch (conversionError) {
console.error("Error converting prompt file:", conversionError);
NotificationsManager.fromBackend("Failed to convert prompt file to JSON");
setLoading(false);
return;
}
} catch (error) {
console.error("Form validation error:", error);
}
try {
await createPromptCall(accessToken, promptData);
NotificationsManager.success("Prompt created successfully!");
handleCancel();
onSuccess();
} catch (createError) {
console.error("Error creating prompt:", createError);
NotificationsManager.fromBackend("Failed to create prompt");
} finally {
setLoading(false);
}
});
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) {
setSelectedFile(null);
return;
}
if (!file.name.endsWith(".prompt")) {
NotificationsManager.fromBackend("Please upload a .prompt file");
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
setSelectedFile(null);
return;
}
setSelectedFile(file);
};
const uploadProps: UploadProps = {
beforeUpload: (file) => {
if (!file.name.endsWith(".prompt")) {
NotificationsManager.fromBackend("Please upload a .prompt file");
return false;
}
return false; // Prevent automatic upload
},
fileList,
onChange: ({ fileList: newFileList }) => {
setFileList(newFileList.slice(-1)); // Keep only the last file
},
onRemove: () => {
setFileList([]);
},
const handleRemoveFile = () => {
setSelectedFile(null);
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
};
return (
<Dialog
open={visible}
onOpenChange={(o) => (!o ? handleCancel() : undefined)}
>
<Dialog open={visible} onOpenChange={(o) => (!o ? handleCancel() : undefined)}>
<DialogContent className="max-w-[600px]">
<DialogHeader>
<DialogTitle>Add New Prompt</DialogTitle>
</DialogHeader>
<Form form={form} layout="vertical" requiredMark={false}>
<Form.Item
label="Prompt ID"
name="prompt_id"
rules={[
{ required: true, message: "Please enter a prompt ID" },
{
pattern: /^[a-zA-Z0-9_-]+$/,
message:
"Prompt ID can only contain letters, numbers, underscores, and hyphens",
},
]}
>
<Input placeholder="Enter unique prompt ID (e.g., my_prompt_id)" />
</Form.Item>
<FormProvider {...form}>
<form onSubmit={onSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="prompt_id">
Prompt ID <span className="text-destructive">*</span>
</Label>
<Input
id="prompt_id"
placeholder="Enter unique prompt ID (e.g., my_prompt_id)"
{...form.register("prompt_id", {
required: "Please enter a prompt ID",
pattern: {
value: PROMPT_ID_PATTERN,
message:
"Prompt ID can only contain letters, numbers, underscores, and hyphens",
},
})}
/>
{form.formState.errors.prompt_id && (
<p className="text-sm text-destructive">
{form.formState.errors.prompt_id.message as string}
</p>
)}
</div>
<Form.Item
label="Prompt Integration"
name="prompt_integration"
initialValue="dotprompt"
>
<Select value={promptIntegration} onChange={setPromptIntegration}>
<Option value="dotprompt">dotprompt</Option>
</Select>
</Form.Item>
<div className="space-y-2">
<Label htmlFor="prompt_integration">Prompt Integration</Label>
<Controller
control={form.control}
name="prompt_integration"
render={({ field }) => (
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger id="prompt_integration">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="dotprompt">dotprompt</SelectItem>
</SelectContent>
</Select>
)}
/>
</div>
{promptIntegration === "dotprompt" && (
<>
<hr className="my-4 border-border" />
<Form.Item
label="Prompt File"
extra="Upload a .prompt file that follows the Dotprompt specification"
>
<Upload {...uploadProps}>
<Button variant="outline" type="button">
{promptIntegration === "dotprompt" && (
<>
<hr className="my-4 border-border" />
<div className="space-y-2">
<Label>Prompt File</Label>
<input
ref={fileInputRef}
type="file"
accept=".prompt"
className="hidden"
onChange={handleFileChange}
/>
<Button
type="button"
variant="outline"
onClick={() => fileInputRef.current?.click()}
>
<UploadIcon className="h-4 w-4" />
Select .prompt File
</Button>
</Upload>
{fileList.length > 0 && (
<div className="mt-2 text-sm text-muted-foreground">
Selected: {fileList[0].name}
</div>
)}
</Form.Item>
</>
)}
</Form>
<DialogFooter>
<Button variant="outline" onClick={handleCancel}>
Cancel
</Button>
<Button onClick={handleSubmit} disabled={loading}>
{loading ? "Creating…" : "Create Prompt"}
</Button>
</DialogFooter>
<p className="text-xs text-muted-foreground">
Upload a .prompt file that follows the Dotprompt specification
</p>
{selectedFile && (
<div className="mt-2 flex items-center gap-2 text-sm text-muted-foreground">
<span>Selected: {selectedFile.name}</span>
<button
type="button"
onClick={handleRemoveFile}
className="text-muted-foreground hover:text-foreground"
aria-label="Remove file"
>
<X className="h-3 w-3" />
</button>
</div>
)}
</div>
</>
)}
<DialogFooter>
<Button type="button" variant="outline" onClick={handleCancel}>
Cancel
</Button>
<Button type="submit" disabled={loading}>
{loading ? "Creating…" : "Create Prompt"}
</Button>
</DialogFooter>
</form>
</FormProvider>
</DialogContent>
</Dialog>
);

View file

@ -1,5 +1,9 @@
import React, { useState } from "react";
import { Tabs } from "antd";
import {
Tabs,
TabsList,
TabsTrigger,
} from "@/components/ui/tabs";
import { Button } from "@/components/ui/button";
import {
Dialog,
@ -270,15 +274,13 @@ main();`;
</Button>
</div>
<Tabs
activeKey={selectedTab}
onChange={setSelectedTab}
items={[
{ label: "Basic", key: "basic" },
{ label: "With Messages", key: "messages" },
{ label: "With Version", key: "version" },
]}
/>
<Tabs value={selectedTab} onValueChange={setSelectedTab}>
<TabsList>
<TabsTrigger value="basic">Basic</TabsTrigger>
<TabsTrigger value="messages">With Messages</TabsTrigger>
<TabsTrigger value="version">With Version</TabsTrigger>
</TabsList>
</Tabs>
<SyntaxHighlighter
language={

View file

@ -1,36 +1,50 @@
import React, { useState, useEffect, useRef } from "react";
// eslint-disable-next-line litellm-ui/no-banned-ui-imports
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "@/components/ui/tabs";
import {
Card,
Title,
Text,
Grid,
Badge,
Button as TremorButton,
Tab,
TabGroup,
TabList,
TabPanel,
TabPanels,
Table,
TableBody,
TableCell,
TableHead,
TableHeaderCell,
TableHeader,
TableRow,
} from "@tremor/react";
import { Button, Modal } from "antd";
import { ArrowLeft as ArrowLeftIcon, Trash2 as TrashIcon, Pencil as PencilIcon } from "lucide-react";
import { getPromptInfo, getPromptVersions, PromptSpec, PromptTemplateBase, deletePromptCall } from "@/components/networking";
} from "@/components/ui/table";
import {
ArrowLeft as ArrowLeftIcon,
Trash2 as TrashIcon,
Pencil as PencilIcon,
Check as CheckIcon,
Copy as CopyIcon,
} from "lucide-react";
import {
getPromptInfo,
getPromptVersions,
PromptSpec,
PromptTemplateBase,
deletePromptCall,
} from "@/components/networking";
import { copyToClipboard as utilCopyToClipboard } from "@/utils/dataUtils";
import { CheckIcon, CopyIcon } from "lucide-react";
import NotificationsManager from "../molecules/notifications_manager";
import PromptCodeSnippets from "./prompt_editor_view/PromptCodeSnippets";
import {
extractModel,
extractTemplateVariables,
getBasePromptId,
getCurrentVersion
getCurrentVersion,
} from "./prompt_utils";
export interface PromptInfoProps {
@ -68,7 +82,6 @@ const PromptInfoView: React.FC<PromptInfoProps> = ({ promptId, onClose, accessTo
setPromptTemplate(response.raw_prompt_template);
setRawApiResponse(response);
// Set environments from response
if (response.environments && response.environments.length > 0) {
setEnvironments(response.environments);
if (!selectedEnv) {
@ -84,7 +97,6 @@ const PromptInfoView: React.FC<PromptInfoProps> = ({ promptId, onClose, accessTo
}
};
// Fetch version history for selected environment
const fetchVersionHistory = async (env: string) => {
if (!accessToken) return;
setLoadingVersions(true);
@ -107,11 +119,9 @@ const PromptInfoView: React.FC<PromptInfoProps> = ({ promptId, onClose, accessTo
fetchPromptInfo();
}, [promptId, accessToken]);
// When environment changes (user clicks tab), re-fetch — skip initial mount
useEffect(() => {
if (isInitialMount.current) {
isInitialMount.current = false;
// Still fetch version history on initial mount once selectedEnv is set
if (selectedEnv && accessToken) {
fetchVersionHistory(selectedEnv);
}
@ -174,7 +184,6 @@ const PromptInfoView: React.FC<PromptInfoProps> = ({ promptId, onClose, accessTo
const handleVersionClick = async (version: PromptSpec) => {
if (!accessToken || !selectedEnv) return;
// Fetch specific version's info
const versionNum = version.version || 1;
setSelectedVersion(versionNum);
try {
@ -191,31 +200,34 @@ const PromptInfoView: React.FC<PromptInfoProps> = ({ promptId, onClose, accessTo
const promptModel = promptData ? extractModel(promptData) || "gpt-4o" : "gpt-4o";
const basePromptId = getBasePromptId(promptData);
const currentVersion = getCurrentVersion(promptData);
const latestVersion = versionHistory.length > 0 ? Math.max(...versionHistory.map(v => v.version || 1)) : null;
const latestVersion = versionHistory.length > 0 ? Math.max(...versionHistory.map((v) => v.version || 1)) : null;
const isViewingOldVersion = latestVersion !== null && selectedVersion !== null && selectedVersion < latestVersion;
return (
<div className="p-4">
<div>
<TremorButton icon={ArrowLeftIcon} variant="light" onClick={onClose} className="mb-4">
<Button variant="ghost" onClick={onClose} className="mb-4">
<ArrowLeftIcon className="h-4 w-4" />
Back to Prompts
</TremorButton>
</Button>
<div className="flex justify-between items-start mb-4">
<div>
<Title>Prompt Details</Title>
<h2 className="text-2xl font-semibold m-0">Prompt Details</h2>
<div className="flex items-center cursor-pointer">
<Text className="text-gray-500 font-mono">{basePromptId}</Text>
<span className="text-muted-foreground font-mono">{basePromptId}</span>
<Button
type="text"
size="small"
icon={copiedStates["prompt-id"] ? <CheckIcon size={12} /> : <CopyIcon size={12} />}
variant="ghost"
size="sm"
onClick={() => copyToClipboard(basePromptId, "prompt-id")}
className={`left-2 z-10 transition-all duration-200 ${
copiedStates["prompt-id"]
? "text-green-600 bg-green-50 border-green-200"
: "text-gray-500 hover:text-gray-700 hover:bg-gray-100"
: "text-muted-foreground hover:text-foreground"
}`}
/>
aria-label="Copy prompt ID"
>
{copiedStates["prompt-id"] ? <CheckIcon size={12} /> : <CopyIcon size={12} />}
</Button>
</div>
</div>
<div className="flex gap-2">
@ -226,24 +238,16 @@ const PromptInfoView: React.FC<PromptInfoProps> = ({ promptId, onClose, accessTo
accessToken={accessToken}
version={currentVersion}
/>
<TremorButton
icon={PencilIcon}
variant="primary"
onClick={() => onEdit?.(rawApiResponse)}
className="flex items-center"
>
<Button onClick={() => onEdit?.(rawApiResponse)} className="flex items-center">
<PencilIcon className="h-4 w-4" />
Prompt Studio
</TremorButton>
{isAdmin && (
<TremorButton
icon={TrashIcon}
variant="secondary"
onClick={handleDeleteClick}
className="flex items-center"
>
Delete Prompt
</TremorButton>
)}
</Button>
{isAdmin && (
<Button variant="secondary" onClick={handleDeleteClick} className="flex items-center">
<TrashIcon className="h-4 w-4" />
Delete Prompt
</Button>
)}
</div>
</div>
</div>
@ -251,267 +255,274 @@ const PromptInfoView: React.FC<PromptInfoProps> = ({ promptId, onClose, accessTo
{/* Environment Tabs */}
{environments.length > 0 && (
<div className="flex gap-2 mb-4">
{[...environments].sort((a, b) => {
const order: Record<string, number> = { development: 0, staging: 1, production: 2 };
return (order[a] ?? 99) - (order[b] ?? 99);
}).map((env) => (
<button
key={env}
onClick={() => {
setSelectedEnv(env);
setSelectedVersion(null);
}}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-all ${
selectedEnv === env
? env === "production"
? "bg-red-100 text-red-800 border-2 border-red-300"
: env === "staging"
? "bg-yellow-100 text-yellow-800 border-2 border-yellow-300"
: "bg-green-100 text-green-800 border-2 border-green-300"
: "bg-gray-100 text-gray-600 border-2 border-transparent hover:bg-gray-200"
}`}
>
{env}
{versionHistory.length > 0 && selectedEnv === env && (
<span className="ml-1 text-xs opacity-75">
(v{latestVersion})
</span>
)}
</button>
))}
{[...environments]
.sort((a, b) => {
const order: Record<string, number> = { development: 0, staging: 1, production: 2 };
return (order[a] ?? 99) - (order[b] ?? 99);
})
.map((env) => (
<button
key={env}
onClick={() => {
setSelectedEnv(env);
setSelectedVersion(null);
}}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-all ${
selectedEnv === env
? env === "production"
? "bg-red-100 text-red-800 border-2 border-red-300 dark:bg-red-950 dark:text-red-300"
: env === "staging"
? "bg-yellow-100 text-yellow-800 border-2 border-yellow-300 dark:bg-yellow-950 dark:text-yellow-300"
: "bg-green-100 text-green-800 border-2 border-green-300 dark:bg-green-950 dark:text-green-300"
: "bg-muted text-muted-foreground border-2 border-transparent hover:bg-muted/80"
}`}
>
{env}
{versionHistory.length > 0 && selectedEnv === env && (
<span className="ml-1 text-xs opacity-75">(v{latestVersion})</span>
)}
</button>
))}
</div>
)}
{/* Old version banner */}
{isViewingOldVersion && (
<div className="mb-4 p-3 bg-amber-50 border border-amber-200 rounded-lg flex items-center justify-between">
<Text className="text-amber-800">
<div className="mb-4 p-3 bg-amber-50 border border-amber-200 rounded-lg flex items-center justify-between dark:bg-amber-950 dark:border-amber-900">
<span className="text-amber-800 dark:text-amber-200">
Viewing v{selectedVersion} — not the latest version (v{latestVersion})
</Text>
<TremorButton
variant="light"
size="xs"
</span>
<Button
variant="ghost"
size="sm"
onClick={() => {
const latest = versionHistory.find(v => v.version === latestVersion);
const latest = versionHistory.find((v) => v.version === latestVersion);
if (latest) handleVersionClick(latest);
}}
>
Go to latest
</TremorButton>
</Button>
</div>
)}
<TabGroup>
<TabList className="mb-4">
<Tab key="overview">Overview</Tab>
{promptTemplate ? <Tab key="prompt-template">Prompt Template</Tab> : <></>}
<Tab key="raw-json">Raw JSON</Tab>
</TabList>
<Tabs defaultValue="overview" className="mt-1">
<TabsList className="mb-4">
<TabsTrigger value="overview">Overview</TabsTrigger>
{promptTemplate && <TabsTrigger value="prompt-template">Prompt Template</TabsTrigger>}
<TabsTrigger value="raw-json">Raw JSON</TabsTrigger>
</TabsList>
<TabPanels>
{/* Overview Panel */}
<TabPanel>
<Grid numItems={1} numItemsSm={2} numItemsLg={4} className="gap-4">
<Card>
<Text>Version</Text>
<div className="mt-2">
<Title>{currentVersion}</Title>
<Badge color="blue" className="mt-1">v{currentVersion}</Badge>
</div>
</Card>
<Card>
<Text>Prompt Type</Text>
<div className="mt-2">
<Title>{promptData.prompt_info?.prompt_type || "-"}</Title>
</div>
</Card>
<Card>
<Text>Created By</Text>
<div className="mt-2">
<Title className="text-sm">{promptData.created_by || "-"}</Title>
</div>
</Card>
<Card>
<Text>Created At</Text>
<div className="mt-2">
<Title className="text-sm">{formatDate(promptData.created_at)}</Title>
<Text className="text-xs">Updated: {formatDate(promptData.updated_at)}</Text>
</div>
</Card>
</Grid>
{/* Version History Table */}
<Card className="mt-6">
<Title className="mb-3">Version History — {selectedEnv}</Title>
{loadingVersions ? (
<Text>Loading versions...</Text>
) : versionHistory.length > 0 ? (
<Table>
<TableHead>
<TableRow>
<TableHeaderCell>Version</TableHeaderCell>
<TableHeaderCell>Created By</TableHeaderCell>
<TableHeaderCell>Date</TableHeaderCell>
<TableHeaderCell>Actions</TableHeaderCell>
</TableRow>
</TableHead>
<TableBody>
{versionHistory.map((v) => {
const vNum = v.version || 1;
const isSelected = vNum === selectedVersion;
const isLatest = vNum === latestVersion;
return (
<TableRow
key={vNum}
className={`cursor-pointer hover:bg-blue-50 transition-colors ${
isSelected ? "bg-blue-50" : ""
}`}
onClick={() => handleVersionClick(v)}
>
<TableCell>
<span className={isSelected ? "font-bold" : ""}>
v{vNum}
</span>
{isLatest && (
<Badge color="blue" className="ml-2" size="xs">latest</Badge>
)}
</TableCell>
<TableCell>
<span className="text-sm">{v.created_by || "-"}</span>
</TableCell>
<TableCell>
<span className="text-sm">{formatDate(v.created_at)}</span>
</TableCell>
<TableCell>
<TremorButton
icon={PencilIcon}
variant="light"
size="xs"
onClick={(e) => {
e.stopPropagation();
// Build a response-like object for the editor
const editData = {
prompt_spec: {
...v,
prompt_id: basePromptId,
environment: selectedEnv,
},
raw_prompt_template: isSelected ? promptTemplate : null,
};
onEdit?.(editData);
}}
>
Edit
</TremorButton>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
) : (
<Text className="text-gray-400">No versions found in {selectedEnv}</Text>
)}
{/* Overview Panel */}
<TabsContent value="overview">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<Card className="p-6">
<p className="text-sm text-muted-foreground">Version</p>
<div className="mt-2">
<h3 className="text-lg font-semibold">{currentVersion}</h3>
<Badge variant="secondary" className="mt-1">
v{currentVersion}
</Badge>
</div>
</Card>
</TabPanel>
{/* Prompt Template Panel */}
{promptTemplate && (
<TabPanel>
<Card>
<div className="flex justify-between items-center mb-4">
<Title>Prompt Template</Title>
<Button
type="text"
size="small"
icon={copiedStates["prompt-content"] ? <CheckIcon size={16} /> : <CopyIcon size={16} />}
onClick={() => copyToClipboard(promptTemplate.content, "prompt-content")}
className={`transition-all duration-200 ${
copiedStates["prompt-content"]
? "text-green-600 bg-green-50 border-green-200"
: "text-gray-500 hover:text-gray-700 hover:bg-gray-100"
}`}
>
{copiedStates["prompt-content"] ? "Copied!" : "Copy Content"}
</Button>
</div>
<Card className="p-6">
<p className="text-sm text-muted-foreground">Prompt Type</p>
<div className="mt-2">
<h3 className="text-lg font-semibold">{promptData.prompt_info?.prompt_type || "-"}</h3>
</div>
</Card>
<div className="space-y-4">
<div>
<Text className="font-medium">Template ID</Text>
<div className="font-mono text-sm bg-gray-50 p-2 rounded">{promptTemplate.litellm_prompt_id}</div>
</div>
<Card className="p-6">
<p className="text-sm text-muted-foreground">Created By</p>
<div className="mt-2">
<h3 className="text-sm font-semibold">{promptData.created_by || "-"}</h3>
</div>
</Card>
<div>
<Text className="font-medium">Content</Text>
<div className="mt-2 p-4 bg-gray-50 rounded-md border overflow-auto max-h-96">
<pre className="text-sm text-gray-800 whitespace-pre-wrap">{promptTemplate.content}</pre>
</div>
</div>
<Card className="p-6">
<p className="text-sm text-muted-foreground">Created At</p>
<div className="mt-2">
<h3 className="text-sm font-semibold">{formatDate(promptData.created_at)}</h3>
<p className="text-xs text-muted-foreground">Updated: {formatDate(promptData.updated_at)}</p>
</div>
</Card>
</div>
{promptTemplate.metadata && Object.keys(promptTemplate.metadata).length > 0 && (
<div>
<Text className="font-medium">Template Metadata</Text>
<div className="mt-2 p-3 bg-gray-50 rounded-md border">
<pre className="text-xs text-gray-800 whitespace-pre-wrap overflow-auto max-h-64">
{JSON.stringify(promptTemplate.metadata, null, 2)}
</pre>
</div>
</div>
)}
</div>
</Card>
</TabPanel>
)}
{/* Version History Table */}
<Card className="mt-6 p-6">
<h3 className="text-lg font-semibold mb-3">Version History — {selectedEnv}</h3>
{loadingVersions ? (
<p className="text-sm text-muted-foreground">Loading versions...</p>
) : versionHistory.length > 0 ? (
<Table>
<TableHeader>
<TableRow>
<TableHead>Version</TableHead>
<TableHead>Created By</TableHead>
<TableHead>Date</TableHead>
<TableHead>Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{versionHistory.map((v) => {
const vNum = v.version || 1;
const isSelected = vNum === selectedVersion;
const isLatest = vNum === latestVersion;
return (
<TableRow
key={vNum}
className={`cursor-pointer hover:bg-muted transition-colors ${
isSelected ? "bg-muted" : ""
}`}
onClick={() => handleVersionClick(v)}
>
<TableCell>
<span className={isSelected ? "font-bold" : ""}>v{vNum}</span>
{isLatest && (
<Badge variant="secondary" className="ml-2 text-xs">
latest
</Badge>
)}
</TableCell>
<TableCell>
<span className="text-sm">{v.created_by || "-"}</span>
</TableCell>
<TableCell>
<span className="text-sm">{formatDate(v.created_at)}</span>
</TableCell>
<TableCell>
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation();
const editData = {
prompt_spec: {
...v,
prompt_id: basePromptId,
environment: selectedEnv,
},
raw_prompt_template: isSelected ? promptTemplate : null,
};
onEdit?.(editData);
}}
>
<PencilIcon className="h-4 w-4" />
Edit
</Button>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
) : (
<p className="text-muted-foreground">No versions found in {selectedEnv}</p>
)}
</Card>
</TabsContent>
{/* Raw JSON Panel */}
<TabPanel>
<Card>
{/* Prompt Template Panel */}
{promptTemplate && (
<TabsContent value="prompt-template">
<Card className="p-6">
<div className="flex justify-between items-center mb-4">
<Title>Raw API Response</Title>
<h3 className="text-lg font-semibold">Prompt Template</h3>
<Button
type="text"
size="small"
icon={copiedStates["raw-json"] ? <CheckIcon size={16} /> : <CopyIcon size={16} />}
onClick={() => copyToClipboard(JSON.stringify(rawApiResponse, null, 2), "raw-json")}
variant="ghost"
size="sm"
onClick={() => copyToClipboard(promptTemplate.content, "prompt-content")}
className={`transition-all duration-200 ${
copiedStates["raw-json"]
copiedStates["prompt-content"]
? "text-green-600 bg-green-50 border-green-200"
: "text-gray-500 hover:text-gray-700 hover:bg-gray-100"
: "text-muted-foreground hover:text-foreground"
}`}
>
{copiedStates["raw-json"] ? "Copied!" : "Copy JSON"}
{copiedStates["prompt-content"] ? <CheckIcon size={16} /> : <CopyIcon size={16} />}
{copiedStates["prompt-content"] ? "Copied!" : "Copy Content"}
</Button>
</div>
<div className="p-4 bg-gray-50 rounded-md border overflow-auto">
<pre className="text-xs text-gray-800 whitespace-pre-wrap">
{JSON.stringify(rawApiResponse, null, 2)}
</pre>
<div className="space-y-4">
<div>
<p className="font-medium">Template ID</p>
<div className="font-mono text-sm bg-muted p-2 rounded">{promptTemplate.litellm_prompt_id}</div>
</div>
<div>
<p className="font-medium">Content</p>
<div className="mt-2 p-4 bg-muted rounded-md border border-border overflow-auto max-h-96">
<pre className="text-sm text-foreground whitespace-pre-wrap">{promptTemplate.content}</pre>
</div>
</div>
{promptTemplate.metadata && Object.keys(promptTemplate.metadata).length > 0 && (
<div>
<p className="font-medium">Template Metadata</p>
<div className="mt-2 p-3 bg-muted rounded-md border border-border">
<pre className="text-xs text-foreground whitespace-pre-wrap overflow-auto max-h-64">
{JSON.stringify(promptTemplate.metadata, null, 2)}
</pre>
</div>
</div>
)}
</div>
</Card>
</TabPanel>
</TabPanels>
</TabGroup>
</TabsContent>
)}
{/* Raw JSON Panel */}
<TabsContent value="raw-json">
<Card className="p-6">
<div className="flex justify-between items-center mb-4">
<h3 className="text-lg font-semibold">Raw API Response</h3>
<Button
variant="ghost"
size="sm"
onClick={() => copyToClipboard(JSON.stringify(rawApiResponse, null, 2), "raw-json")}
className={`transition-all duration-200 ${
copiedStates["raw-json"]
? "text-green-600 bg-green-50 border-green-200"
: "text-muted-foreground hover:text-foreground"
}`}
>
{copiedStates["raw-json"] ? <CheckIcon size={16} /> : <CopyIcon size={16} />}
{copiedStates["raw-json"] ? "Copied!" : "Copy JSON"}
</Button>
</div>
<div className="p-4 bg-muted rounded-md border border-border overflow-auto">
<pre className="text-xs text-foreground whitespace-pre-wrap">
{JSON.stringify(rawApiResponse, null, 2)}
</pre>
</div>
</Card>
</TabsContent>
</Tabs>
{/* Delete Confirmation Modal */}
<Modal
title="Delete Prompt"
<Dialog
open={showDeleteConfirm}
onOk={handleDeleteConfirm}
onCancel={handleDeleteCancel}
confirmLoading={isDeleting}
okText="Delete"
okButtonProps={{ danger: true }}
onOpenChange={(o) => (!o ? handleDeleteCancel() : undefined)}
>
<p>
Are you sure you want to delete prompt: <strong>{basePromptId}</strong>?
</p>
<p>This action cannot be undone.</p>
</Modal>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete Prompt</DialogTitle>
</DialogHeader>
<p>
Are you sure you want to delete prompt: <strong>{basePromptId}</strong>?
</p>
<p>This action cannot be undone.</p>
<DialogFooter>
<Button variant="outline" onClick={handleDeleteCancel} disabled={isDeleting}>
Cancel
</Button>
<Button variant="destructive" onClick={handleDeleteConfirm} disabled={isDeleting}>
{isDeleting ? "Deleting…" : "Delete"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
};

File diff suppressed because one or more lines are too long