[Feat] Prompt Management - Allow viewing version history (#16901)

* TestPromptRequest

* add prompts/test endpoint for testing prompt

* TestPromptTestEndpoint

* feat: working v1 of this ui

* workig prompt endpoints

* add chat ui for prompts

* add conversation panel

* add init chat ui

* allow clicking edit prompt

* fix use get_base_prompt_id

* add endpoints for viewing prompt versions

* TestPromptVersioning

* add getPromptVersions

* add VersionHistorySidePanel

* allow viewing version history

* add version history
This commit is contained in:
Ishaan Jaff 2025-11-21 08:54:52 -08:00 • committed by GitHub
parent 3c789ac287
commit 97d9da93e0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 731 additions and 21 deletions

View file

@ -33,6 +33,77 @@ from litellm.types.proxy.prompt_endpoints import TestPromptRequest
router = APIRouter()
def get_base_prompt_id(prompt_id: str) -> str:
"""
Extract the base prompt ID by stripping the version suffix if present.
Args:
prompt_id: Prompt ID that may include version suffix (e.g., "jack_success.v1")
Returns:
Base prompt ID without version suffix (e.g., "jack_success")
Examples:
>>> get_base_prompt_id("jack_success.v1")
"jack_success"
>>> get_base_prompt_id("jack_success")
"jack_success"
"""
return prompt_id.split(".v")[0] if ".v" in prompt_id else prompt_id
def get_version_number(prompt_id: str) -> int:
"""
Extract the version number from a versioned prompt ID.
Args:
prompt_id: Prompt ID that may include version suffix (e.g., "jack_success.v2")
Returns:
Version number (defaults to 1 if no version suffix or invalid format)
Examples:
>>> get_version_number("jack_success.v2")
2
>>> get_version_number("jack_success")
1
"""
if ".v" in prompt_id:
version_str = prompt_id.split(".v")[1]
try:
return int(version_str)
except ValueError:
return 1
return 1
def get_latest_prompt_versions(prompts: List[PromptSpec]) -> List[PromptSpec]:
"""
Filter a list of prompts to return only the latest version of each unique prompt.
Args:
prompts: List of PromptSpec objects
Returns:
List of PromptSpec objects with only the latest version of each prompt
"""
latest_prompts: Dict[str, PromptSpec] = {}
for prompt in prompts:
base_id = get_base_prompt_id(prompt_id=prompt.prompt_id)
version = get_version_number(prompt_id=prompt.prompt_id)
# Keep the prompt with the highest version number
if base_id not in latest_prompts:
latest_prompts[base_id] = prompt
else:
existing_version = get_version_number(prompt_id=latest_prompts[base_id].prompt_id)
if version > existing_version:
latest_prompts[base_id] = prompt
return list(latest_prompts.values())
async def get_next_version_for_prompt(prisma_client, prompt_id: str) -> int:
"""
Get the next version number for a prompt.
@ -171,13 +242,89 @@ async def list_prompts(
user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN
or user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN.value
):
return ListPromptsResponse(
prompts=list(IN_MEMORY_PROMPT_REGISTRY.IN_MEMORY_PROMPTS.values())
)
# Get all prompts and filter to show only the latest version of each
all_prompts = list(IN_MEMORY_PROMPT_REGISTRY.IN_MEMORY_PROMPTS.values())
latest_prompts = get_latest_prompt_versions(prompts=all_prompts)
return ListPromptsResponse(prompts=latest_prompts)
else:
return ListPromptsResponse(prompts=[])
@router.get(
"/prompts/{prompt_id}/versions",
tags=["Prompt Management"],
dependencies=[Depends(user_api_key_auth)],
response_model=ListPromptsResponse,
)
async def get_prompt_versions(
prompt_id: str,
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
):
"""
Get all versions of a specific prompt by base prompt ID
👉 [Prompt docs](https://docs.litellm.ai/docs/proxy/prompt_management)
Example Request:
```bash
curl -X GET "http://localhost:4000/prompts/jack_success/versions" \\
-H "Authorization: Bearer <your_api_key>"
```
Example Response:
```json
{
"prompts": [
{
"prompt_id": "jack_success.v1",
"litellm_params": {...},
"prompt_info": {"prompt_type": "db"},
"created_at": "2023-11-09T12:34:56.789Z",
"updated_at": "2023-11-09T12:34:56.789Z"
},
{
"prompt_id": "jack_success.v2",
"litellm_params": {...},
"prompt_info": {"prompt_type": "db"},
"created_at": "2023-11-09T13:45:12.345Z",
"updated_at": "2023-11-09T13:45:12.345Z"
}
]
}
```
"""
from litellm.proxy.prompts.prompt_registry import IN_MEMORY_PROMPT_REGISTRY
# Only allow proxy admins to view version history
if user_api_key_dict.user_role is None or (
user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN
and user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN.value
):
raise HTTPException(
status_code=403, detail="Only proxy admins can view prompt versions"
)
# Strip version suffix if provided (e.g., "jack_success.v1" -> "jack_success")
base_prompt_id = get_base_prompt_id(prompt_id=prompt_id)
# Get all prompts and filter by base_prompt_id
all_prompts = list(IN_MEMORY_PROMPT_REGISTRY.IN_MEMORY_PROMPTS.values())
prompt_versions = [
prompt for prompt in all_prompts
if get_base_prompt_id(prompt_id=prompt.prompt_id) == base_prompt_id
]
if not prompt_versions:
raise HTTPException(
status_code=404, detail=f"No versions found for prompt ID {base_prompt_id}"
)
# Sort by version number (descending - newest first)
prompt_versions.sort(key=lambda p: get_version_number(prompt_id=p.prompt_id), reverse=True)
return ListPromptsResponse(prompts=prompt_versions)
@router.get(
"/prompts/{prompt_id}",
tags=["Prompt Management"],
@ -427,19 +574,21 @@ async def update_prompt(
)
try:
# Strip version suffix from prompt_id if present (e.g., "jack_success.v1" -> "jack_success")
base_prompt_id = get_base_prompt_id(prompt_id=prompt_id)
# Check if any version exists
existing_prompts = await prisma_client.db.litellm_prompttable.find_many(
where={"prompt_id": request.prompt_id}
where={"prompt_id": base_prompt_id}
)
if not existing_prompts:
raise HTTPException(
status_code=404, detail=f"Prompt with ID {request.prompt_id} not found"
status_code=404, detail=f"Prompt with ID {base_prompt_id} not found"
)
# Check if it's a config prompt
base_prompt_id = request.prompt_id
existing_in_memory = IN_MEMORY_PROMPT_REGISTRY.get_prompt_by_id(base_prompt_id)
existing_in_memory = IN_MEMORY_PROMPT_REGISTRY.get_prompt_by_id(prompt_id)
if existing_in_memory and existing_in_memory.prompt_info.prompt_type == "config":
raise HTTPException(
status_code=400,
@ -448,13 +597,13 @@ async def update_prompt(
# Get next version number (UPDATE creates a new version)
new_version = await get_next_version_for_prompt(
prisma_client=prisma_client, prompt_id=request.prompt_id
prisma_client=prisma_client, prompt_id=base_prompt_id
)
# Store new version in db
prompt_db_entry = await prisma_client.db.litellm_prompttable.create(
data={
"prompt_id": request.prompt_id,
"prompt_id": base_prompt_id,
"version": new_version,
"litellm_params": request.litellm_params.model_dump_json(),
"prompt_info": (

View file

@ -0,0 +1,222 @@
"""
Test prompt endpoints for version filtering and history
"""
from unittest.mock import MagicMock
import pytest
from litellm.types.prompts.init_prompts import (
PromptInfo,
PromptLiteLLMParams,
PromptSpec,
)
class TestPromptVersioning:
"""
Test prompt versioning functionality
"""
def test_get_latest_prompt_versions(self):
"""
Test that get_latest_prompt_versions returns only the latest version of each prompt
"""
from litellm.proxy.prompts.prompt_endpoints import get_latest_prompt_versions
# Create mock prompts with different versions
prompts = [
PromptSpec(
prompt_id="jack.v1",
litellm_params=PromptLiteLLMParams(
prompt_id="jack",
prompt_integration="dotprompt",
dotprompt_content="v1 content"
),
prompt_info=PromptInfo(prompt_type="db"),
),
PromptSpec(
prompt_id="jack.v2",
litellm_params=PromptLiteLLMParams(
prompt_id="jack",
prompt_integration="dotprompt",
dotprompt_content="v2 content"
),
prompt_info=PromptInfo(prompt_type="db"),
),
PromptSpec(
prompt_id="jane.v1",
litellm_params=PromptLiteLLMParams(
prompt_id="jane",
prompt_integration="dotprompt",
dotprompt_content="jane v1"
),
prompt_info=PromptInfo(prompt_type="db"),
),
PromptSpec(
prompt_id="jack.v3",
litellm_params=PromptLiteLLMParams(
prompt_id="jack",
prompt_integration="dotprompt",
dotprompt_content="v3 content"
),
prompt_info=PromptInfo(prompt_type="db"),
),
]
# Get latest versions
latest = get_latest_prompt_versions(prompts=prompts)
# Should return 2 prompts (jack.v3 and jane.v1)
assert len(latest) == 2
# Find jack and jane in results
jack_prompt = next((p for p in latest if "jack" in p.prompt_id), None)
jane_prompt = next((p for p in latest if "jane" in p.prompt_id), None)
assert jack_prompt is not None
assert jack_prompt.prompt_id == "jack.v3"
assert jack_prompt.litellm_params.dotprompt_content == "v3 content"
assert jane_prompt is not None
assert jane_prompt.prompt_id == "jane.v1"
def test_get_version_number(self):
"""
Test that get_version_number correctly extracts version numbers
"""
from litellm.proxy.prompts.prompt_endpoints import get_version_number
assert get_version_number(prompt_id="jack.v1") == 1
assert get_version_number(prompt_id="jack.v2") == 2
assert get_version_number(prompt_id="jack.v10") == 10
assert get_version_number(prompt_id="jack") == 1
assert get_version_number(prompt_id="jack.vinvalid") == 1
def test_get_base_prompt_id(self):
"""
Test that get_base_prompt_id correctly strips version suffixes
"""
from litellm.proxy.prompts.prompt_endpoints import get_base_prompt_id
assert get_base_prompt_id(prompt_id="jack.v1") == "jack"
assert get_base_prompt_id(prompt_id="jack.v2") == "jack"
assert get_base_prompt_id(prompt_id="jack") == "jack"
assert get_base_prompt_id(prompt_id="my_prompt.v10") == "my_prompt"
class TestPromptVersionsEndpoint:
"""
Test the /prompts/{prompt_id}/versions endpoint
"""
@pytest.mark.asyncio
async def test_get_prompt_versions_returns_all_versions(self):
"""
Test that get_prompt_versions returns all versions of a prompt sorted by version number
"""
from unittest.mock import MagicMock, patch
from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth
from litellm.proxy.prompts.prompt_endpoints import get_prompt_versions
# Mock user with admin role
mock_user = UserAPIKeyAuth(
api_key="test_key",
user_role=LitellmUserRoles.PROXY_ADMIN
)
# Create mock prompt registry with multiple versions
mock_prompts = {
"jack.v1": PromptSpec(
prompt_id="jack.v1",
litellm_params=PromptLiteLLMParams(
prompt_id="jack",
prompt_integration="dotprompt",
dotprompt_content="v1"
),
prompt_info=PromptInfo(prompt_type="db"),
),
"jack.v2": PromptSpec(
prompt_id="jack.v2",
litellm_params=PromptLiteLLMParams(
prompt_id="jack",
prompt_integration="dotprompt",
dotprompt_content="v2"
),
prompt_info=PromptInfo(prompt_type="db"),
),
"jack.v3": PromptSpec(
prompt_id="jack.v3",
litellm_params=PromptLiteLLMParams(
prompt_id="jack",
prompt_integration="dotprompt",
dotprompt_content="v3"
),
prompt_info=PromptInfo(prompt_type="db"),
),
"jane.v1": PromptSpec(
prompt_id="jane.v1",
litellm_params=PromptLiteLLMParams(
prompt_id="jane",
prompt_integration="dotprompt",
dotprompt_content="jane"
),
prompt_info=PromptInfo(prompt_type="db"),
),
}
# Mock the IN_MEMORY_PROMPT_REGISTRY at the import location
with patch("litellm.proxy.prompts.prompt_registry.IN_MEMORY_PROMPT_REGISTRY") as mock_registry:
mock_registry.IN_MEMORY_PROMPTS = mock_prompts
# Test with base prompt ID
response = await get_prompt_versions(
prompt_id="jack",
user_api_key_dict=mock_user
)
# Should return 3 versions of jack, sorted newest first
assert len(response.prompts) == 3
assert response.prompts[0].prompt_id == "jack.v3"
assert response.prompts[1].prompt_id == "jack.v2"
assert response.prompts[2].prompt_id == "jack.v1"
# Test with versioned prompt ID (should strip version)
response = await get_prompt_versions(
prompt_id="jack.v1",
user_api_key_dict=mock_user
)
assert len(response.prompts) == 3
assert response.prompts[0].prompt_id == "jack.v3"
@pytest.mark.asyncio
async def test_get_prompt_versions_not_found(self):
"""
Test that get_prompt_versions raises 404 when prompt doesn't exist
"""
from unittest.mock import patch
from fastapi import HTTPException
from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth
from litellm.proxy.prompts.prompt_endpoints import get_prompt_versions
mock_user = UserAPIKeyAuth(
api_key="test_key",
user_role=LitellmUserRoles.PROXY_ADMIN
)
with patch("litellm.proxy.prompts.prompt_registry.IN_MEMORY_PROMPT_REGISTRY") as mock_registry:
mock_registry.IN_MEMORY_PROMPTS = {}
with pytest.raises(HTTPException) as exc_info:
await get_prompt_versions(
prompt_id="nonexistent",
user_api_key_dict=mock_user
)
assert exc_info.value.status_code == 404
assert "No versions found" in exc_info.value.detail

View file

@ -5239,6 +5239,35 @@ export const getPromptInfo = async (accessToken: string, promptId: string): Prom
}
};
export const getPromptVersions = async (accessToken: string, promptId: string): Promise<ListPromptsResponse> => {
try {
const url = proxyBaseUrl ? `${proxyBaseUrl}/prompts/${promptId}/versions` : `/prompts/${promptId}/versions`;
const response = await fetch(url, {
method: "GET",
headers: {
[globalLitellmHeaderName]: `Bearer ${accessToken}`,
"Content-Type": "application/json",
},
});
if (!response.ok) {
const errorData = await response.json();
const errorMessage = deriveErrorMessage(errorData);
// Don't throw global error for 404 (no versions found) as we might want to handle it gracefully
if (response.status !== 404) {
handleError(errorMessage);
}
throw new Error(errorMessage);
}
const data = await response.json();
return data;
} catch (error) {
console.error("Failed to get prompt versions:", error);
throw error;
}
};
export const createPromptCall = async (accessToken: string, promptData: any) => {
try {
const url = proxyBaseUrl ? `${proxyBaseUrl}/prompts` : `/prompts`;

View file

@ -21,6 +21,7 @@ const PromptsPanel: React.FC<PromptsProps> = ({ accessToken, userRole }) => {
const [selectedPromptId, setSelectedPromptId] = useState<string | null>(null);
const [isAddModalVisible, setIsAddModalVisible] = useState(false);
const [showEditorView, setShowEditorView] = useState(false);
const [editPromptData, setEditPromptData] = useState<any>(null);
const [isDeleting, setIsDeleting] = useState(false);
const [promptToDelete, setPromptToDelete] = useState<{ id: string; name: string } | null>(null);
@ -55,6 +56,12 @@ const PromptsPanel: React.FC<PromptsProps> = ({ accessToken, userRole }) => {
if (selectedPromptId) {
setSelectedPromptId(null);
}
setEditPromptData(null);
setShowEditorView(true);
};
const handleEditPrompt = (promptData: any) => {
setEditPromptData(promptData);
setShowEditorView(true);
};
@ -71,10 +78,14 @@ const PromptsPanel: React.FC<PromptsProps> = ({ accessToken, userRole }) => {
const handleCloseEditor = () => {
setShowEditorView(false);
setEditPromptData(null);
};
const handleSuccess = () => {
fetchPrompts();
setShowEditorView(false);
setEditPromptData(null);
setSelectedPromptId(null);
};
const handleDeleteClick = (promptId: string, promptName: string) => {
@ -109,6 +120,7 @@ const PromptsPanel: React.FC<PromptsProps> = ({ accessToken, userRole }) => {
onClose={handleCloseEditor}
onSuccess={handleSuccess}
accessToken={accessToken}
initialPromptData={editPromptData}
/>
) : selectedPromptId ? (
<PromptInfoView
@ -117,6 +129,7 @@ const PromptsPanel: React.FC<PromptsProps> = ({ accessToken, userRole }) => {
accessToken={accessToken}
isAdmin={isAdmin}
onDelete={fetchPrompts}
onEdit={handleEditPrompt}
/>
) : (
<>

View file

@ -1,7 +1,7 @@
import React from "react";
import { Button as TremorButton } from "@tremor/react";
import { Input } from "antd";
import { ArrowLeftIcon, SaveIcon } from "lucide-react";
import { ArrowLeftIcon, SaveIcon, ClockIcon } from "lucide-react";
interface PromptEditorHeaderProps {
promptName: string;
@ -9,6 +9,9 @@ interface PromptEditorHeaderProps {
onBack: () => void;
onSave: () => void;
isSaving: boolean;
editMode?: boolean;
onShowHistory?: () => void;
version?: string | null;
}
const PromptEditorHeader: React.FC<PromptEditorHeaderProps> = ({
@ -17,6 +20,9 @@ const PromptEditorHeader: React.FC<PromptEditorHeaderProps> = ({
onBack,
onSave,
isSaving,
editMode = false,
onShowHistory,
version,
}) => {
return (
<div className="bg-white border-b border-gray-200 px-6 py-3 flex items-center justify-between">
@ -30,17 +36,31 @@ const PromptEditorHeader: React.FC<PromptEditorHeaderProps> = ({
className="text-base font-medium border-none shadow-none"
style={{ width: "200px" }}
/>
{version && (
<span className="px-2 py-0.5 text-xs bg-blue-100 text-blue-700 rounded font-medium">
{version}
</span>
)}
<span className="px-2 py-0.5 text-xs bg-gray-100 text-gray-600 rounded">Draft</span>
<span className="text-xs text-gray-400">Unsaved changes</span>
</div>
<div className="flex items-center space-x-2">
{editMode && onShowHistory && (
<TremorButton
icon={ClockIcon}
variant="secondary"
onClick={onShowHistory}
>
History
</TremorButton>
)}
<TremorButton
icon={SaveIcon}
onClick={onSave}
loading={isSaving}
disabled={isSaving}
>
Save
{editMode ? "Update" : "Save"}
</TremorButton>
</div>
</div>

View file

@ -0,0 +1,123 @@
import React, { useEffect, useState } from "react";
import { Drawer, List, Tag, Typography, Skeleton, Button } from "antd";
import { getPromptVersions, PromptSpec } from "../../networking";
import NotificationsManager from "../../molecules/notifications_manager";
const { Text } = Typography;
interface VersionHistorySidePanelProps {
isOpen: boolean;
onClose: () => void;
accessToken: string | null;
promptId: string;
activeVersionId?: string;
onSelectVersion?: (version: PromptSpec) => void;
}
const VersionHistorySidePanel: React.FC<VersionHistorySidePanelProps> = ({
isOpen,
onClose,
accessToken,
promptId,
activeVersionId,
onSelectVersion,
}) => {
const [versions, setVersions] = useState<PromptSpec[]>([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
if (isOpen && accessToken && promptId) {
fetchVersions();
}
}, [isOpen, accessToken, promptId]);
const fetchVersions = async () => {
setLoading(true);
try {
// Strip .v suffix if present to get base ID for querying all versions
const basePromptId = promptId.includes(".v") ? promptId.split(".v")[0] : promptId;
const response = await getPromptVersions(accessToken!, basePromptId);
setVersions(response.prompts);
} catch (error) {
console.error("Error fetching prompt versions:", error);
} finally {
setLoading(false);
}
};
const getVersionNumber = (pid: string) => {
if (pid.includes(".v")) {
return `v${pid.split(".v")[1]}`;
}
return "v1";
};
const formatDate = (dateString?: string) => {
if (!dateString) return "-";
return new Date(dateString).toLocaleString();
};
return (
<Drawer
title="Version History"
placement="right"
onClose={onClose}
open={isOpen}
width={400}
mask={false} // Allow interacting with the main editor while drawer is open
maskClosable={false}
>
{loading ? (
<Skeleton active paragraph={{ rows: 4 }} />
) : versions.length === 0 ? (
<div className="text-center py-8 text-gray-500">
No version history available.
</div>
) : (
<List
dataSource={versions}
renderItem={(item, index) => {
const isSelected = item.prompt_id === (activeVersionId || promptId);
return (
<div
key={item.prompt_id}
className={`mb-4 p-4 rounded-lg border cursor-pointer transition-all hover:shadow-md ${
isSelected
? "border-blue-500 bg-blue-50"
: "border-gray-200 bg-white hover:border-blue-300"
}`}
onClick={() => onSelectVersion?.(item)}
>
<div className="flex justify-between items-start mb-2">
<div className="flex items-center gap-2">
<Tag className="m-0">
{getVersionNumber(item.prompt_id)}
</Tag>
{index === 0 && <Tag color="blue" className="m-0">Latest</Tag>}
</div>
{isSelected && (
<Tag color="green" className="m-0">
Active
</Tag>
)}
</div>
<div className="flex flex-col gap-1">
<Text className="text-sm text-gray-600 font-medium">
{formatDate(item.created_at)}
</Text>
<Text type="secondary" className="text-xs">
{item.prompt_info?.prompt_type === "db" ? "Saved to Database" : "Config Prompt"}
</Text>
</div>
</div>
);
}}
/>
)}
</Drawer>
);
};
export default VersionHistorySidePanel;

View file

@ -1,9 +1,9 @@
import React, { useState } from "react";
import React, { useState, useEffect } from "react";
import ToolModal from "../tool_modal";
import NotificationsManager from "../../molecules/notifications_manager";
import { createPromptCall } from "../../networking";
import { createPromptCall, updatePromptCall } from "../../networking";
import { PromptType, PromptEditorViewProps, Tool } from "./types";
import { convertToDotPrompt } from "./utils";
import { convertToDotPrompt, parseExistingPrompt } from "./utils";
import PromptEditorHeader from "./PromptEditorHeader";
import ModelConfigCard from "./ModelConfigCard";
import ToolsCard from "./ToolsCard";
@ -12,9 +12,19 @@ import PromptMessagesCard from "./PromptMessagesCard";
import ConversationPanel from "./conversation_panel";
import PublishModal from "./PublishModal";
import DotpromptViewTab from "./DotpromptViewTab";
import VersionHistorySidePanel from "./VersionHistorySidePanel";
const PromptEditorView: React.FC<PromptEditorViewProps> = ({ onClose, onSuccess, accessToken }) => {
const [prompt, setPrompt] = useState<PromptType>({
const PromptEditorView: React.FC<PromptEditorViewProps> = ({ onClose, onSuccess, accessToken, initialPromptData }) => {
const getInitialPrompt = (): PromptType => {
if (initialPromptData) {
try {
return parseExistingPrompt(initialPromptData);
} catch (error) {
console.error("Error parsing existing prompt:", error);
NotificationsManager.fromBackend("Failed to parse prompt data");
}
}
return {
name: "New prompt",
model: "gpt-4o",
config: {
@ -29,7 +39,15 @@ const PromptEditorView: React.FC<PromptEditorViewProps> = ({ onClose, onSuccess,
content: "Enter task specifics. Use {{template_variables}} for dynamic inputs",
},
],
});
};
};
const [prompt, setPrompt] = useState<PromptType>(getInitialPrompt());
const [editMode, setEditMode] = useState<boolean>(!!initialPromptData);
const [showHistoryModal, setShowHistoryModal] = useState(false);
const [activeVersionId, setActiveVersionId] = useState<string | undefined>(
initialPromptData?.prompt_spec?.prompt_id
);
const [showToolModal, setShowToolModal] = useState(false);
const [showNameModal, setShowNameModal] = useState(false);
@ -124,6 +142,18 @@ const PromptEditorView: React.FC<PromptEditorViewProps> = ({ onClose, onSuccess,
setShowToolModal(true);
};
const handleLoadVersion = (versionData: any) => {
try {
const loadedPrompt = parseExistingPrompt({ prompt_spec: versionData });
setPrompt(loadedPrompt);
setActiveVersionId(versionData.prompt_id);
// NotificationsManager.success(`Loaded version ${versionData.prompt_id}`);
} catch (error) {
console.error("Error loading version:", error);
NotificationsManager.fromBackend("Failed to load prompt version");
}
};
const handleSaveClick = () => {
if (!prompt.name || prompt.name.trim() === "" || prompt.name === "New prompt") {
setShowNameModal(true);
@ -160,19 +190,34 @@ const PromptEditorView: React.FC<PromptEditorViewProps> = ({ onClose, onSuccess,
},
};
if (editMode && initialPromptData?.prompt_spec?.prompt_id) {
await updatePromptCall(accessToken, initialPromptData.prompt_spec.prompt_id, promptData);
NotificationsManager.success("Prompt updated successfully!");
} else {
await createPromptCall(accessToken, promptData);
NotificationsManager.success("Prompt created successfully!");
}
onSuccess();
onClose();
} catch (error) {
console.error("Error saving prompt:", error);
NotificationsManager.fromBackend("Failed to save prompt");
NotificationsManager.fromBackend(editMode ? "Failed to update prompt" : "Failed to save prompt");
} finally {
setIsSaving(false);
setShowNameModal(false);
}
};
const getVersionNumber = (pid?: string) => {
if (!pid) return null;
if (pid.includes(".v")) {
return `v${pid.split(".v")[1]}`;
}
return null;
};
const currentVersion = getVersionNumber(activeVersionId);
return (
<div className="flex h-full bg-white">
<div className="flex-1 flex flex-col">
@ -182,6 +227,9 @@ const PromptEditorView: React.FC<PromptEditorViewProps> = ({ onClose, onSuccess,
onBack={onClose}
onSave={handleSaveClick}
isSaving={isSaving}
editMode={editMode}
onShowHistory={() => setShowHistoryModal(true)}
version={currentVersion}
/>
<div className="flex-1 flex overflow-hidden">
@ -284,6 +332,15 @@ const PromptEditorView: React.FC<PromptEditorViewProps> = ({ onClose, onSuccess,
}}
/>
)}
<VersionHistorySidePanel
isOpen={showHistoryModal}
onClose={() => setShowHistoryModal(false)}
accessToken={accessToken}
promptId={initialPromptData?.prompt_spec?.prompt_id || prompt.name}
activeVersionId={activeVersionId}
onSelectVersion={handleLoadVersion}
/>
</div>
);
};

View file

@ -26,5 +26,6 @@ export interface PromptEditorViewProps {
onClose: () => void;
onSuccess: () => void;
accessToken: string | null;
initialPromptData?: any;
}

View file

@ -1,4 +1,4 @@
import { PromptType } from "./types";
import { PromptType, Message, Tool } from "./types";
export const extractVariables = (prompt: PromptType): string[] => {
const variableSet = new Set<string>();
@ -74,3 +74,88 @@ export const convertToDotPrompt = (prompt: PromptType): string => {
return result.trim();
};
export const parseExistingPrompt = (apiResponse: any): PromptType => {
// Extract dotprompt_content from litellm_params
const dotpromptContent = apiResponse?.prompt_spec?.litellm_params?.dotprompt_content || "";
if (!dotpromptContent) {
throw new Error("No dotprompt_content found in API response");
}
// Split into frontmatter and content
const parts = dotpromptContent.split("---");
if (parts.length < 3) {
throw new Error("Invalid dotprompt format");
}
// Parse YAML frontmatter (parts[1])
const frontmatter = parts[1];
const content = parts.slice(2).join("---").trim();
// Extract metadata from frontmatter
const metadata: any = {};
frontmatter.split("\n").forEach((line: string) => {
const trimmedLine = line.trim();
if (trimmedLine && !trimmedLine.startsWith("input:") && !trimmedLine.startsWith("output:") && !trimmedLine.startsWith("schema:") && !trimmedLine.startsWith("format:")) {
const colonIndex = trimmedLine.indexOf(":");
if (colonIndex > 0) {
const key = trimmedLine.substring(0, colonIndex).trim();
const value = trimmedLine.substring(colonIndex + 1).trim();
if (key === "temperature" || key === "max_tokens" || key === "top_p") {
metadata[key] = parseFloat(value);
} else if (key === "model") {
metadata[key] = value;
}
}
}
});
// Parse content to extract developer message and user messages
let developerMessage = "";
const messages: Message[] = [];
const lines = content.split("\n");
let currentRole: "user" | "assistant" | null = null;
let currentContent = "";
for (const line of lines) {
if (line.startsWith("Developer:")) {
developerMessage = line.substring("Developer:".length).trim();
} else if (line.startsWith("User:")) {
if (currentRole && currentContent) {
messages.push({ role: currentRole, content: currentContent.trim() });
}
currentRole = "user";
currentContent = line.substring("User:".length).trim();
} else if (line.startsWith("Assistant:")) {
if (currentRole && currentContent) {
messages.push({ role: currentRole, content: currentContent.trim() });
}
currentRole = "assistant";
currentContent = line.substring("Assistant:".length).trim();
} else if (line.trim() && currentRole) {
currentContent += "\n" + line.trim();
}
}
// Add the last message
if (currentRole && currentContent) {
messages.push({ role: currentRole, content: currentContent.trim() });
}
// Parse tools from frontmatter if present
const tools: Tool[] = [];
// TODO: Add tool parsing if needed
return {
name: apiResponse?.prompt_spec?.prompt_id || "Unnamed Prompt",
model: metadata.model || "gpt-4o",
config: {
temperature: metadata.temperature,
max_tokens: metadata.max_tokens,
top_p: metadata.top_p,
},
tools: tools,
developerMessage: developerMessage,
messages: messages.length > 0 ? messages : [{ role: "user", content: "Enter task specifics. Use {{template_variables}} for dynamic inputs" }],
};
};

View file

@ -13,7 +13,7 @@ import {
TabPanels,
} from "@tremor/react";
import { Button, Modal } from "antd";
import { ArrowLeftIcon, TrashIcon } from "@heroicons/react/outline";
import { ArrowLeftIcon, TrashIcon, PencilIcon } from "@heroicons/react/outline";
import { getPromptInfo, PromptSpec, PromptTemplateBase, deletePromptCall } from "@/components/networking";
import { copyToClipboard as utilCopyToClipboard } from "@/utils/dataUtils";
import { CheckIcon, CopyIcon } from "lucide-react";
@ -25,9 +25,10 @@ export interface PromptInfoProps {
accessToken: string | null;
isAdmin: boolean;
onDelete?: () => void;
onEdit?: (promptData: any) => void;
}
const PromptInfoView: React.FC<PromptInfoProps> = ({ promptId, onClose, accessToken, isAdmin, onDelete }) => {
const PromptInfoView: React.FC<PromptInfoProps> = ({ promptId, onClose, accessToken, isAdmin, onDelete, onEdit }) => {
const [promptData, setPromptData] = useState<PromptSpec | null>(null);
const [promptTemplate, setPromptTemplate] = useState<PromptTemplateBase | null>(null);
const [rawApiResponse, setRawApiResponse] = useState<any>(null);
@ -131,6 +132,15 @@ const PromptInfoView: React.FC<PromptInfoProps> = ({ promptId, onClose, accessTo
/>
</div>
</div>
<div className="flex gap-2">
<TremorButton
icon={PencilIcon}
variant="primary"
onClick={() => onEdit?.(rawApiResponse)}
className="flex items-center"
>
Edit Prompt
</TremorButton>
{isAdmin && (
<TremorButton
icon={TrashIcon}
@ -141,6 +151,7 @@ const PromptInfoView: React.FC<PromptInfoProps> = ({ promptId, onClose, accessTo
Delete Prompt
</TremorButton>
)}
</div>
</div>
</div>