mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-09 03:18:44 +00:00
[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:
parent
3c789ac287
commit
97d9da93e0
10 changed files with 731 additions and 21 deletions
|
|
@ -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": (
|
||||
|
|
|
|||
222
tests/test_litellm/proxy/prompts/test_prompt_endpoints.py
Normal file
222
tests/test_litellm/proxy/prompts/test_prompt_endpoints.py
Normal 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
|
||||
|
||||
|
|
@ -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`;
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -26,5 +26,6 @@ export interface PromptEditorViewProps {
|
|||
onClose: () => void;
|
||||
onSuccess: () => void;
|
||||
accessToken: string | null;
|
||||
initialPromptData?: any;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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" }],
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue