[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:
Ishaan Jaff 2025-11-21 08:53:18 -08:00 committed by GitHub
parent b96179a07a
commit 41566722af
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 978 additions and 25 deletions

View File

@ -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"],

View 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

View 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

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;
}

View File

@ -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,
};
};

View File

@ -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>