[Feat] UI - Prompt Management - Allow testing prompts with Chat UI (#16898)
* 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
This commit is contained in:
parent
b96179a07a
commit
41566722af
@ -6,7 +6,15 @@ import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, cast
|
||||
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
|
||||
from fastapi import (
|
||||
APIRouter,
|
||||
Depends,
|
||||
File,
|
||||
HTTPException,
|
||||
Request,
|
||||
Response,
|
||||
UploadFile,
|
||||
)
|
||||
from pydantic import BaseModel
|
||||
|
||||
from litellm._logging import verbose_proxy_logger
|
||||
@ -20,6 +28,7 @@ from litellm.types.prompts.init_prompts import (
|
||||
PromptSpec,
|
||||
PromptTemplateBase,
|
||||
)
|
||||
from litellm.types.proxy.prompt_endpoints import TestPromptRequest
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@ -667,6 +676,154 @@ async def patch_prompt(
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post(
|
||||
"/prompts/test",
|
||||
tags=["Prompt Management"],
|
||||
dependencies=[Depends(user_api_key_auth)],
|
||||
)
|
||||
async def test_prompt(
|
||||
request: TestPromptRequest,
|
||||
fastapi_request: Request,
|
||||
fastapi_response: Response,
|
||||
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
|
||||
):
|
||||
"""
|
||||
Test a prompt by rendering it with variables and executing an LLM call.
|
||||
|
||||
This endpoint allows testing prompts before saving them to the database.
|
||||
The response is always streamed.
|
||||
|
||||
👉 [Prompt docs](https://docs.litellm.ai/docs/proxy/prompt_management)
|
||||
|
||||
Example Request:
|
||||
```bash
|
||||
curl -X POST "http://localhost:4000/prompts/test" \\
|
||||
-H "Authorization: Bearer <your_api_key>" \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-d '{
|
||||
"dotprompt_content": "---\\nmodel: gpt-4o\\ntemperature: 0.7\\n---\\n\\nUser: Hello {{name}}",
|
||||
"prompt_variables": {
|
||||
"name": "World"
|
||||
}
|
||||
}'
|
||||
```
|
||||
"""
|
||||
from pydantic import BaseModel
|
||||
|
||||
from litellm.integrations.dotprompt.dotprompt_manager import DotpromptManager
|
||||
from litellm.integrations.dotprompt.prompt_manager import (
|
||||
PromptManager,
|
||||
PromptTemplate,
|
||||
)
|
||||
from litellm.proxy.common_request_processing import ProxyBaseLLMRequestProcessing
|
||||
from litellm.proxy.proxy_server import (
|
||||
general_settings,
|
||||
llm_router,
|
||||
proxy_config,
|
||||
proxy_logging_obj,
|
||||
select_data_generator,
|
||||
user_api_base,
|
||||
user_max_tokens,
|
||||
user_model,
|
||||
user_request_timeout,
|
||||
user_temperature,
|
||||
version,
|
||||
)
|
||||
|
||||
try:
|
||||
# Parse the dotprompt content and create PromptTemplate
|
||||
prompt_manager = PromptManager()
|
||||
frontmatter, template_content = prompt_manager._parse_frontmatter(
|
||||
content=request.dotprompt_content
|
||||
)
|
||||
|
||||
# Create PromptTemplate to leverage existing parameter extraction logic
|
||||
template = PromptTemplate(
|
||||
content=template_content,
|
||||
metadata=frontmatter,
|
||||
template_id="test_prompt"
|
||||
)
|
||||
|
||||
# Extract model from template
|
||||
if not template.model:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Model is required in dotprompt metadata"
|
||||
)
|
||||
|
||||
# Always render the template to extract system messages and other metadata
|
||||
variables = request.prompt_variables or {}
|
||||
rendered_content = prompt_manager.jinja_env.from_string(
|
||||
template_content
|
||||
).render(**variables)
|
||||
|
||||
# Convert rendered content to messages using DotpromptManager's method
|
||||
dotprompt_manager = DotpromptManager()
|
||||
rendered_messages = dotprompt_manager._convert_to_messages(
|
||||
rendered_content=rendered_content
|
||||
)
|
||||
|
||||
if not rendered_messages:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="No messages found in rendered prompt"
|
||||
)
|
||||
|
||||
# If conversation history is provided, use it but preserve system messages
|
||||
if request.conversation_history:
|
||||
# Extract system messages from rendered prompt
|
||||
system_messages = [msg for msg in rendered_messages if msg.get("role") == "system"]
|
||||
# Use conversation history for user/assistant messages
|
||||
messages = system_messages + request.conversation_history
|
||||
else:
|
||||
messages = rendered_messages
|
||||
|
||||
# Use PromptTemplate's optional_params which already extracts all parameters
|
||||
optional_params = template.optional_params.copy()
|
||||
|
||||
# Always stream the response
|
||||
optional_params["stream"] = True
|
||||
|
||||
# Build request data for chat completion
|
||||
data = {
|
||||
"model": template.model,
|
||||
"messages": messages,
|
||||
}
|
||||
data.update(optional_params)
|
||||
|
||||
# Use ProxyBaseLLMRequestProcessing to go through all proxy logic
|
||||
base_llm_response_processor = ProxyBaseLLMRequestProcessing(data=data)
|
||||
result = await base_llm_response_processor.base_process_llm_request(
|
||||
request=fastapi_request,
|
||||
fastapi_response=fastapi_response,
|
||||
user_api_key_dict=user_api_key_dict,
|
||||
route_type="acompletion",
|
||||
proxy_logging_obj=proxy_logging_obj,
|
||||
llm_router=llm_router,
|
||||
general_settings=general_settings,
|
||||
proxy_config=proxy_config,
|
||||
select_data_generator=select_data_generator,
|
||||
model=None,
|
||||
user_model=user_model,
|
||||
user_temperature=user_temperature,
|
||||
user_request_timeout=user_request_timeout,
|
||||
user_max_tokens=user_max_tokens,
|
||||
user_api_base=user_api_base,
|
||||
version=version,
|
||||
)
|
||||
|
||||
if isinstance(result, BaseModel):
|
||||
return result.model_dump(exclude_none=True, exclude_unset=True)
|
||||
else:
|
||||
return result
|
||||
|
||||
except HTTPException as e:
|
||||
raise e
|
||||
except Exception as e:
|
||||
verbose_proxy_logger.exception(f"Error testing prompt: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post(
|
||||
"/utils/dotprompt_json_converter",
|
||||
tags=["prompts", "utils"],
|
||||
|
||||
10
litellm/types/proxy/prompt_endpoints.py
Normal file
10
litellm/types/proxy/prompt_endpoints.py
Normal file
@ -0,0 +1,10 @@
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class TestPromptRequest(BaseModel):
|
||||
dotprompt_content: str
|
||||
prompt_variables: Optional[Dict[str, Any]] = None
|
||||
conversation_history: Optional[List[Dict[str, str]]] = None
|
||||
|
||||
134
tests/proxy_unit_tests/test_prompt_test_endpoint.py
Normal file
134
tests/proxy_unit_tests/test_prompt_test_endpoint.py
Normal file
@ -0,0 +1,134 @@
|
||||
"""
|
||||
Test /prompts/test endpoint for testing prompts before saving
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
from fastapi import HTTPException
|
||||
|
||||
|
||||
class TestPromptTestEndpoint:
|
||||
"""
|
||||
Tests the /prompts/test endpoint that allows testing prompts with variables
|
||||
"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_parse_dotprompt_with_variables(self):
|
||||
"""
|
||||
Test that dotprompt content is parsed and variables are rendered correctly
|
||||
"""
|
||||
from litellm.integrations.dotprompt.prompt_manager import PromptManager
|
||||
|
||||
dotprompt_content = """---
|
||||
model: gpt-4o
|
||||
temperature: 0.7
|
||||
max_tokens: 100
|
||||
---
|
||||
|
||||
User: Hello {{name}}, how are you?"""
|
||||
|
||||
# Parse the dotprompt
|
||||
prompt_manager = PromptManager()
|
||||
frontmatter, template_content = prompt_manager._parse_frontmatter(
|
||||
content=dotprompt_content
|
||||
)
|
||||
|
||||
assert frontmatter["model"] == "gpt-4o"
|
||||
assert frontmatter["temperature"] == 0.7
|
||||
assert frontmatter["max_tokens"] == 100
|
||||
assert "{{name}}" in template_content
|
||||
|
||||
# Render with variables
|
||||
from jinja2 import Environment
|
||||
|
||||
jinja_env = Environment(
|
||||
variable_start_string="{{",
|
||||
variable_end_string="}}",
|
||||
)
|
||||
jinja_template = jinja_env.from_string(template_content)
|
||||
rendered = jinja_template.render(name="World")
|
||||
|
||||
assert "Hello World" in rendered
|
||||
assert "{{name}}" not in rendered
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_convert_to_messages_format(self):
|
||||
"""
|
||||
Test that rendered prompt is converted to OpenAI messages format
|
||||
"""
|
||||
import re
|
||||
|
||||
rendered_content = """System: You are a helpful assistant.
|
||||
|
||||
User: Hello World, how are you?"""
|
||||
|
||||
messages = []
|
||||
role_pattern = r"^(System|User|Assistant|Developer):\s*(.*?)(?=\n(?:System|User|Assistant|Developer):|$)"
|
||||
matches = list(
|
||||
re.finditer(
|
||||
pattern=role_pattern,
|
||||
string=rendered_content.strip(),
|
||||
flags=re.MULTILINE | re.DOTALL,
|
||||
)
|
||||
)
|
||||
|
||||
for match in matches:
|
||||
role = match.group(1).lower()
|
||||
content = match.group(2).strip()
|
||||
|
||||
if role == "developer":
|
||||
role = "system"
|
||||
|
||||
if content:
|
||||
messages.append({"role": role, "content": content})
|
||||
|
||||
assert len(messages) == 2
|
||||
assert messages[0]["role"] == "system"
|
||||
assert "helpful assistant" in messages[0]["content"]
|
||||
assert messages[1]["role"] == "user"
|
||||
assert "Hello World" in messages[1]["content"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_single_message_without_role(self):
|
||||
"""
|
||||
Test that content without role markers is treated as a user message
|
||||
"""
|
||||
import re
|
||||
|
||||
rendered_content = "Just a plain message without any role markers"
|
||||
|
||||
messages = []
|
||||
role_pattern = r"^(System|User|Assistant|Developer):\s*(.*?)(?=\n(?:System|User|Assistant|Developer):|$)"
|
||||
matches = list(
|
||||
re.finditer(
|
||||
pattern=role_pattern,
|
||||
string=rendered_content.strip(),
|
||||
flags=re.MULTILINE | re.DOTALL,
|
||||
)
|
||||
)
|
||||
|
||||
if not matches:
|
||||
messages.append({"role": "user", "content": rendered_content.strip()})
|
||||
|
||||
assert len(messages) == 1
|
||||
assert messages[0]["role"] == "user"
|
||||
assert messages[0]["content"] == rendered_content
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_missing_model_raises_error(self):
|
||||
"""
|
||||
Test that missing model in frontmatter raises an error
|
||||
"""
|
||||
from litellm.integrations.dotprompt.prompt_manager import PromptManager
|
||||
|
||||
dotprompt_content = """---
|
||||
temperature: 0.7
|
||||
---
|
||||
|
||||
User: Hello"""
|
||||
|
||||
prompt_manager = PromptManager()
|
||||
frontmatter, _ = prompt_manager._parse_frontmatter(content=dotprompt_content)
|
||||
|
||||
model = frontmatter.get("model")
|
||||
assert model is None
|
||||
@ -1,21 +0,0 @@
|
||||
import React from "react";
|
||||
import { MessageSquareIcon } from "lucide-react";
|
||||
|
||||
const ConversationPanel: React.FC = () => {
|
||||
return (
|
||||
<div className="flex-1 bg-white flex flex-col">
|
||||
<div className="flex-1 flex items-center justify-center text-gray-400">
|
||||
<div className="text-center">
|
||||
<div className="w-12 h-12 mx-auto mb-3 bg-gray-100 rounded-full flex items-center justify-center">
|
||||
<MessageSquareIcon size={24} className="text-gray-400" />
|
||||
</div>
|
||||
<p className="text-sm">Your conversation will appear here</p>
|
||||
<p className="text-xs text-gray-500 mt-2">Save the prompt to test it</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConversationPanel;
|
||||
|
||||
@ -0,0 +1,22 @@
|
||||
import React from "react";
|
||||
import { RobotOutlined } from "@ant-design/icons";
|
||||
|
||||
interface EmptyStateProps {
|
||||
hasVariables: boolean;
|
||||
}
|
||||
|
||||
const EmptyState: React.FC<EmptyStateProps> = ({ hasVariables }) => {
|
||||
return (
|
||||
<div className="h-full flex flex-col items-center justify-center text-gray-400">
|
||||
<RobotOutlined style={{ fontSize: "48px", marginBottom: "16px" }} />
|
||||
<span className="text-base">
|
||||
{hasVariables
|
||||
? "Fill in the variables above, then type a message to start testing"
|
||||
: "Type a message below to start testing your prompt"}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EmptyState;
|
||||
|
||||
@ -0,0 +1,115 @@
|
||||
import React from "react";
|
||||
import { RobotOutlined, UserOutlined } from "@ant-design/icons";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
|
||||
import { coy } from "react-syntax-highlighter/dist/esm/styles/prism";
|
||||
import ResponseMetrics from "../../../playground/chat_ui/ResponseMetrics";
|
||||
import { Message } from "./types";
|
||||
|
||||
interface MessageBubbleProps {
|
||||
message: Message;
|
||||
}
|
||||
|
||||
const MessageBubble: React.FC<MessageBubbleProps> = ({ message }) => {
|
||||
return (
|
||||
<div className={`mb-4 flex ${message.role === "user" ? "justify-end" : "justify-start"}`}>
|
||||
<div
|
||||
className="max-w-[85%] rounded-lg shadow-sm p-3.5 px-4"
|
||||
style={{
|
||||
backgroundColor: message.role === "user" ? "#f0f8ff" : "#ffffff",
|
||||
border: message.role === "user" ? "1px solid #e6f0fa" : "1px solid #f0f0f0",
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-1.5">
|
||||
<div
|
||||
className="flex items-center justify-center w-6 h-6 rounded-full mr-1"
|
||||
style={{
|
||||
backgroundColor: message.role === "user" ? "#e6f0fa" : "#f5f5f5",
|
||||
}}
|
||||
>
|
||||
{message.role === "user" ? (
|
||||
<UserOutlined style={{ fontSize: "12px", color: "#2563eb" }} />
|
||||
) : (
|
||||
<RobotOutlined style={{ fontSize: "12px", color: "#4b5563" }} />
|
||||
)}
|
||||
</div>
|
||||
<strong className="text-sm capitalize">{message.role}</strong>
|
||||
{message.role === "assistant" && message.model && (
|
||||
<span className="text-xs px-2 py-0.5 rounded bg-gray-100 text-gray-600 font-normal">
|
||||
{message.model}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="whitespace-pre-wrap break-words max-w-full message-content"
|
||||
style={{
|
||||
wordWrap: "break-word",
|
||||
overflowWrap: "break-word",
|
||||
wordBreak: "break-word",
|
||||
hyphens: "auto",
|
||||
}}
|
||||
>
|
||||
{message.role === "assistant" ? (
|
||||
<ReactMarkdown
|
||||
components={{
|
||||
code({
|
||||
node,
|
||||
inline,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentPropsWithoutRef<"code"> & {
|
||||
inline?: boolean;
|
||||
node?: any;
|
||||
}) {
|
||||
const match = /language-(\w+)/.exec(className || "");
|
||||
return !inline && match ? (
|
||||
<SyntaxHighlighter
|
||||
style={coy as any}
|
||||
language={match[1]}
|
||||
PreTag="div"
|
||||
className="rounded-md my-2"
|
||||
wrapLines={true}
|
||||
wrapLongLines={true}
|
||||
{...props}
|
||||
>
|
||||
{String(children).replace(/\n$/, "")}
|
||||
</SyntaxHighlighter>
|
||||
) : (
|
||||
<code
|
||||
className={`${className} px-1.5 py-0.5 rounded bg-gray-100 text-sm font-mono`}
|
||||
style={{ wordBreak: "break-word" }}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
},
|
||||
pre: ({ node, ...props }) => (
|
||||
<pre style={{ overflowX: "auto", maxWidth: "100%" }} {...props} />
|
||||
),
|
||||
}}
|
||||
>
|
||||
{message.content}
|
||||
</ReactMarkdown>
|
||||
) : (
|
||||
<div className="whitespace-pre-wrap">{message.content}</div>
|
||||
)}
|
||||
|
||||
{message.role === "assistant" &&
|
||||
(message.timeToFirstToken || message.totalLatency || message.usage) && (
|
||||
<ResponseMetrics
|
||||
timeToFirstToken={message.timeToFirstToken}
|
||||
totalLatency={message.totalLatency}
|
||||
usage={message.usage}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MessageBubble;
|
||||
|
||||
@ -0,0 +1,71 @@
|
||||
import React from "react";
|
||||
import { ArrowUpOutlined } from "@ant-design/icons";
|
||||
import { Button as TremorButton } from "@tremor/react";
|
||||
import { Input } from "antd";
|
||||
|
||||
const { TextArea } = Input;
|
||||
|
||||
interface MessageInputProps {
|
||||
inputMessage: string;
|
||||
isLoading: boolean;
|
||||
isDisabled: boolean;
|
||||
onInputChange: (value: string) => void;
|
||||
onSend: () => void;
|
||||
onKeyDown: (event: React.KeyboardEvent<HTMLTextAreaElement>) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
const MessageInput: React.FC<MessageInputProps> = ({
|
||||
inputMessage,
|
||||
isLoading,
|
||||
isDisabled,
|
||||
onInputChange,
|
||||
onSend,
|
||||
onKeyDown,
|
||||
onCancel,
|
||||
}) => {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center flex-1 bg-white border border-gray-300 rounded-xl px-3 py-1 min-h-[44px]">
|
||||
<TextArea
|
||||
value={inputMessage}
|
||||
onChange={(e) => onInputChange(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
placeholder="Type your message... (Shift+Enter for new line)"
|
||||
disabled={isLoading}
|
||||
className="flex-1"
|
||||
autoSize={{ minRows: 1, maxRows: 4 }}
|
||||
style={{
|
||||
resize: "none",
|
||||
border: "none",
|
||||
boxShadow: "none",
|
||||
background: "transparent",
|
||||
padding: "4px 0",
|
||||
fontSize: "14px",
|
||||
lineHeight: "20px",
|
||||
}}
|
||||
/>
|
||||
|
||||
<TremorButton
|
||||
onClick={onSend}
|
||||
disabled={isDisabled}
|
||||
className="flex-shrink-0 ml-2 !w-8 !h-8 !min-w-8 !p-0 !rounded-full !bg-blue-600 hover:!bg-blue-700 disabled:!bg-gray-300 !border-none !text-white disabled:!text-gray-500 !flex !items-center !justify-center"
|
||||
>
|
||||
<ArrowUpOutlined style={{ fontSize: "14px" }} />
|
||||
</TremorButton>
|
||||
</div>
|
||||
|
||||
{isLoading && (
|
||||
<TremorButton
|
||||
onClick={onCancel}
|
||||
className="bg-red-50 hover:bg-red-100 text-red-600 border-red-200"
|
||||
>
|
||||
Cancel
|
||||
</TremorButton>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MessageInput;
|
||||
|
||||
@ -0,0 +1,42 @@
|
||||
import React from "react";
|
||||
import { LoadingOutlined } from "@ant-design/icons";
|
||||
import { Spin } from "antd";
|
||||
import EmptyState from "./EmptyState";
|
||||
import MessageBubble from "./MessageBubble";
|
||||
import { Message } from "./types";
|
||||
|
||||
interface MessageListProps {
|
||||
messages: Message[];
|
||||
isLoading: boolean;
|
||||
hasVariables: boolean;
|
||||
messagesEndRef: React.RefObject<HTMLDivElement>;
|
||||
}
|
||||
|
||||
const MessageList: React.FC<MessageListProps> = ({
|
||||
messages,
|
||||
isLoading,
|
||||
hasVariables,
|
||||
messagesEndRef,
|
||||
}) => {
|
||||
const antIcon = <LoadingOutlined style={{ fontSize: 24 }} spin />;
|
||||
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto p-4 pb-0">
|
||||
{messages.length === 0 && <EmptyState hasVariables={hasVariables} />}
|
||||
|
||||
{messages.map((message, index) => (
|
||||
<MessageBubble key={index} message={message} />
|
||||
))}
|
||||
|
||||
{isLoading && (
|
||||
<div className="flex justify-center items-center my-4">
|
||||
<Spin indicator={antIcon} />
|
||||
</div>
|
||||
)}
|
||||
<div ref={messagesEndRef} style={{ height: "1px" }} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MessageList;
|
||||
|
||||
@ -0,0 +1,44 @@
|
||||
import React from "react";
|
||||
import { Input } from "antd";
|
||||
|
||||
interface VariableInputProps {
|
||||
extractedVariables: string[];
|
||||
variables: Record<string, string>;
|
||||
onVariableChange: (varName: string, value: string) => void;
|
||||
}
|
||||
|
||||
const VariableInput: React.FC<VariableInputProps> = ({
|
||||
extractedVariables,
|
||||
variables,
|
||||
onVariableChange,
|
||||
}) => {
|
||||
if (extractedVariables.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-4 border-b border-gray-200 bg-blue-50">
|
||||
<h3 className="text-sm font-semibold text-gray-700 mb-3">
|
||||
Fill in template variables to start testing
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{extractedVariables.map((varName) => (
|
||||
<div key={varName}>
|
||||
<label className="block text-xs text-gray-600 mb-1 font-medium">
|
||||
{"{{"}{varName}{"}}"}
|
||||
</label>
|
||||
<Input
|
||||
value={variables[varName] || ""}
|
||||
onChange={(e) => onVariableChange(varName, e.target.value)}
|
||||
placeholder={`Enter value for ${varName}`}
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default VariableInput;
|
||||
|
||||
@ -0,0 +1,38 @@
|
||||
import React from "react";
|
||||
|
||||
interface VariableWarningProps {
|
||||
extractedVariables: string[];
|
||||
variables: Record<string, string>;
|
||||
}
|
||||
|
||||
const VariableWarning: React.FC<VariableWarningProps> = ({
|
||||
extractedVariables,
|
||||
variables,
|
||||
}) => {
|
||||
const missingVariables = extractedVariables.filter(
|
||||
(varName) => !variables[varName] || variables[varName].trim() === ""
|
||||
);
|
||||
|
||||
if (missingVariables.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mb-3 p-3 bg-yellow-50 border border-yellow-200 rounded-lg">
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="text-yellow-600 text-sm">⚠️</span>
|
||||
<div className="flex-1">
|
||||
<p className="text-sm text-yellow-800 font-medium mb-1">
|
||||
Please fill in all template variables above
|
||||
</p>
|
||||
<p className="text-xs text-yellow-700">
|
||||
Missing: {missingVariables.map((varName) => `{{${varName}}}`).join(", ")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default VariableWarning;
|
||||
|
||||
@ -0,0 +1,78 @@
|
||||
import React from "react";
|
||||
import { ClearOutlined } from "@ant-design/icons";
|
||||
import { Button as TremorButton } from "@tremor/react";
|
||||
import { ConversationPanelProps } from "./types";
|
||||
import { useConversation } from "./useConversation";
|
||||
import VariableInput from "./VariableInput";
|
||||
import MessageList from "./MessageList";
|
||||
import VariableWarning from "./VariableWarning";
|
||||
import MessageInput from "./MessageInput";
|
||||
|
||||
const ConversationPanel: React.FC<ConversationPanelProps> = ({ prompt, accessToken }) => {
|
||||
const {
|
||||
isLoading,
|
||||
messages,
|
||||
inputMessage,
|
||||
variables,
|
||||
variablesFilled,
|
||||
extractedVariables,
|
||||
allVariablesFilled,
|
||||
messagesEndRef,
|
||||
setInputMessage,
|
||||
handleSendMessage,
|
||||
handleCancelRequest,
|
||||
handleClearConversation,
|
||||
handleKeyDown,
|
||||
handleVariableChange,
|
||||
} = useConversation(prompt, accessToken);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-white">
|
||||
{!variablesFilled && (
|
||||
<VariableInput
|
||||
extractedVariables={extractedVariables}
|
||||
variables={variables}
|
||||
onVariableChange={handleVariableChange}
|
||||
/>
|
||||
)}
|
||||
|
||||
{messages.length > 0 && (
|
||||
<div className="p-3 border-b border-gray-200 bg-white flex justify-end">
|
||||
<TremorButton
|
||||
onClick={handleClearConversation}
|
||||
className="bg-gray-100 hover:bg-gray-200 text-gray-700 border-gray-300"
|
||||
icon={ClearOutlined}
|
||||
>
|
||||
Clear Chat
|
||||
</TremorButton>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<MessageList
|
||||
messages={messages}
|
||||
isLoading={isLoading}
|
||||
hasVariables={extractedVariables.length > 0}
|
||||
messagesEndRef={messagesEndRef}
|
||||
/>
|
||||
|
||||
<div className="p-4 border-t border-gray-200 bg-white">
|
||||
<VariableWarning extractedVariables={extractedVariables} variables={variables} />
|
||||
|
||||
<MessageInput
|
||||
inputMessage={inputMessage}
|
||||
isLoading={isLoading}
|
||||
isDisabled={
|
||||
isLoading || !inputMessage.trim() || (extractedVariables.length > 0 && !allVariablesFilled)
|
||||
}
|
||||
onInputChange={setInputMessage}
|
||||
onSend={handleSendMessage}
|
||||
onKeyDown={handleKeyDown}
|
||||
onCancel={handleCancelRequest}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConversationPanel;
|
||||
|
||||
@ -0,0 +1,16 @@
|
||||
import { TokenUsage } from "../../../playground/chat_ui/ResponseMetrics";
|
||||
|
||||
export interface Message {
|
||||
role: string;
|
||||
content: string;
|
||||
model?: string;
|
||||
timeToFirstToken?: number;
|
||||
totalLatency?: number;
|
||||
usage?: TokenUsage;
|
||||
}
|
||||
|
||||
export interface ConversationPanelProps {
|
||||
prompt: any;
|
||||
accessToken: string | null;
|
||||
}
|
||||
|
||||
@ -0,0 +1,245 @@
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import NotificationsManager from "../../../molecules/notifications_manager";
|
||||
import { TokenUsage } from "../../../playground/chat_ui/ResponseMetrics";
|
||||
import { Message } from "./types";
|
||||
import { convertToDotPrompt, extractVariables } from "../utils";
|
||||
import { getProxyBaseUrl } from "../../../networking";
|
||||
|
||||
export const useConversation = (prompt: any, accessToken: string | null) => {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
const [inputMessage, setInputMessage] = useState("");
|
||||
const [variables, setVariables] = useState<Record<string, string>>({});
|
||||
const [variablesFilled, setVariablesFilled] = useState(false);
|
||||
const [abortController, setAbortController] = useState<AbortController | null>(null);
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const extractedVariables = extractVariables(prompt);
|
||||
|
||||
const allVariablesFilled = extractedVariables.every(
|
||||
(varName) => variables[varName] && variables[varName].trim() !== ""
|
||||
);
|
||||
|
||||
const scrollToBottom = () => {
|
||||
if (messagesEndRef.current) {
|
||||
setTimeout(() => {
|
||||
messagesEndRef.current?.scrollIntoView({
|
||||
behavior: "smooth",
|
||||
block: "end",
|
||||
});
|
||||
}, 100);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
scrollToBottom();
|
||||
}, [messages]);
|
||||
|
||||
const handleSendMessage = async () => {
|
||||
if (!accessToken) {
|
||||
NotificationsManager.fromBackend("Access token is required");
|
||||
return;
|
||||
}
|
||||
|
||||
if (extractedVariables.length > 0 && !allVariablesFilled) {
|
||||
NotificationsManager.fromBackend("Please fill in all template variables");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!inputMessage.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!variablesFilled && extractedVariables.length > 0) {
|
||||
setVariablesFilled(true);
|
||||
}
|
||||
|
||||
const userMessage: Message = { role: "user", content: inputMessage };
|
||||
setMessages((prev) => [...prev, userMessage]);
|
||||
setInputMessage("");
|
||||
|
||||
const controller = new AbortController();
|
||||
setAbortController(controller);
|
||||
setIsLoading(true);
|
||||
|
||||
const startTime = Date.now();
|
||||
let timeToFirstToken: number | undefined;
|
||||
|
||||
try {
|
||||
const dotpromptContent = convertToDotPrompt(prompt);
|
||||
const proxyBaseUrl = getProxyBaseUrl();
|
||||
|
||||
const requestBody: any = {
|
||||
dotprompt_content: dotpromptContent,
|
||||
};
|
||||
|
||||
if (messages.length === 0) {
|
||||
requestBody.prompt_variables = variables;
|
||||
} else {
|
||||
requestBody.conversation_history = [
|
||||
...messages.map((msg) => ({
|
||||
role: msg.role,
|
||||
content: msg.content,
|
||||
})),
|
||||
{
|
||||
role: "user",
|
||||
content: inputMessage,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const response = await fetch(`${proxyBaseUrl}/prompts/test`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(requestBody),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`HTTP error! status: ${response.status}, ${errorText}`);
|
||||
}
|
||||
|
||||
if (!response.body) {
|
||||
throw new Error("No response body");
|
||||
}
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
|
||||
let assistantMessage = "";
|
||||
let model: string | undefined;
|
||||
let usage: TokenUsage | undefined;
|
||||
setMessages((prev) => [...prev, { role: "assistant", content: "" }]);
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
const chunk = decoder.decode(value);
|
||||
const lines = chunk.split("\n");
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith("data: ")) {
|
||||
const data = line.slice(6);
|
||||
if (data === "[DONE]") {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(data);
|
||||
|
||||
if (!model && parsed.model) {
|
||||
model = parsed.model;
|
||||
}
|
||||
|
||||
if (parsed.usage) {
|
||||
usage = parsed.usage;
|
||||
}
|
||||
|
||||
const content = parsed.choices?.[0]?.delta?.content;
|
||||
if (content) {
|
||||
if (!timeToFirstToken) {
|
||||
timeToFirstToken = Date.now() - startTime;
|
||||
}
|
||||
assistantMessage += content;
|
||||
setMessages((prev) => {
|
||||
const newMessages = [...prev];
|
||||
newMessages[newMessages.length - 1] = {
|
||||
role: "assistant",
|
||||
content: assistantMessage,
|
||||
model,
|
||||
timeToFirstToken,
|
||||
};
|
||||
return newMessages;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Error parsing chunk:", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const totalLatency = Date.now() - startTime;
|
||||
setMessages((prev) => {
|
||||
const newMessages = [...prev];
|
||||
newMessages[newMessages.length - 1] = {
|
||||
...newMessages[newMessages.length - 1],
|
||||
totalLatency,
|
||||
usage,
|
||||
};
|
||||
return newMessages;
|
||||
});
|
||||
} catch (error: any) {
|
||||
if (error.name === "AbortError") {
|
||||
console.log("Request was cancelled");
|
||||
} else {
|
||||
console.error("Error testing prompt:", error);
|
||||
setMessages((prev) => {
|
||||
const lastMsg = prev[prev.length - 1];
|
||||
if (lastMsg && lastMsg.role === "assistant" && lastMsg.content === "") {
|
||||
return [
|
||||
...prev.slice(0, -1),
|
||||
{ role: "assistant", content: `Error: ${error.message}` },
|
||||
];
|
||||
}
|
||||
return [...prev, { role: "assistant", content: `Error: ${error.message}` }];
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
setAbortController(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancelRequest = () => {
|
||||
if (abortController) {
|
||||
abortController.abort();
|
||||
setAbortController(null);
|
||||
setIsLoading(false);
|
||||
NotificationsManager.info("Request cancelled");
|
||||
}
|
||||
};
|
||||
|
||||
const handleClearConversation = () => {
|
||||
setMessages([]);
|
||||
setVariablesFilled(false);
|
||||
NotificationsManager.success("Chat history cleared.");
|
||||
};
|
||||
|
||||
const handleKeyDown = (event: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (event.key === "Enter" && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
handleSendMessage();
|
||||
}
|
||||
};
|
||||
|
||||
const handleVariableChange = (varName: string, value: string) => {
|
||||
setVariables({ ...variables, [varName]: value });
|
||||
};
|
||||
|
||||
return {
|
||||
// State
|
||||
isLoading,
|
||||
messages,
|
||||
inputMessage,
|
||||
variables,
|
||||
variablesFilled,
|
||||
extractedVariables,
|
||||
allVariablesFilled,
|
||||
messagesEndRef,
|
||||
|
||||
// Actions
|
||||
setInputMessage,
|
||||
handleSendMessage,
|
||||
handleCancelRequest,
|
||||
handleClearConversation,
|
||||
handleKeyDown,
|
||||
handleVariableChange,
|
||||
};
|
||||
};
|
||||
|
||||
@ -9,7 +9,7 @@ import ModelConfigCard from "./ModelConfigCard";
|
||||
import ToolsCard from "./ToolsCard";
|
||||
import DeveloperMessageCard from "./DeveloperMessageCard";
|
||||
import PromptMessagesCard from "./PromptMessagesCard";
|
||||
import ConversationPanel from "./ConversationPanel";
|
||||
import ConversationPanel from "./conversation_panel";
|
||||
import PublishModal from "./PublishModal";
|
||||
import DotpromptViewTab from "./DotpromptViewTab";
|
||||
|
||||
@ -185,7 +185,7 @@ const PromptEditorView: React.FC<PromptEditorViewProps> = ({ onClose, onSuccess,
|
||||
/>
|
||||
|
||||
<div className="flex-1 flex overflow-hidden">
|
||||
<div className="w-1/2 overflow-y-auto bg-white border-r border-gray-200">
|
||||
<div className="w-1/2 overflow-y-auto bg-white border-r border-gray-200 flex-shrink-0">
|
||||
<div className="border-b border-gray-200 bg-white px-6 py-4 flex items-center gap-3">
|
||||
<ModelConfigCard
|
||||
model={prompt.model}
|
||||
@ -258,7 +258,9 @@ const PromptEditorView: React.FC<PromptEditorViewProps> = ({ onClose, onSuccess,
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ConversationPanel />
|
||||
<div className="w-1/2 flex-shrink-0">
|
||||
<ConversationPanel prompt={prompt} accessToken={accessToken} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user