mirror of
https://github.com/meta-llama/llama-stack.git
synced 2025-12-11 19:56:03 +00:00
feat: Adding Prompts to admin UI
Signed-off-by: Francisco Javier Arceo <farceo@redhat.com>
This commit is contained in:
parent
b90c6a2c8b
commit
f1a00dd3ec
17 changed files with 1851 additions and 9 deletions
|
|
@ -51,10 +51,14 @@ async function proxyRequest(request: NextRequest, method: string) {
|
||||||
);
|
);
|
||||||
|
|
||||||
// Create response with same status and headers
|
// Create response with same status and headers
|
||||||
const proxyResponse = new NextResponse(responseText, {
|
// Handle 204 No Content responses specially
|
||||||
status: response.status,
|
const proxyResponse =
|
||||||
statusText: response.statusText,
|
response.status === 204
|
||||||
});
|
? new NextResponse(null, { status: 204 })
|
||||||
|
: new NextResponse(responseText, {
|
||||||
|
status: response.status,
|
||||||
|
statusText: response.statusText,
|
||||||
|
});
|
||||||
|
|
||||||
// Copy response headers (except problematic ones)
|
// Copy response headers (except problematic ones)
|
||||||
response.headers.forEach((value, key) => {
|
response.headers.forEach((value, key) => {
|
||||||
|
|
|
||||||
5
src/llama_stack/ui/app/prompts/page.tsx
Normal file
5
src/llama_stack/ui/app/prompts/page.tsx
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
import { PromptManagement } from "@/components/prompts";
|
||||||
|
|
||||||
|
export default function PromptsPage() {
|
||||||
|
return <PromptManagement />;
|
||||||
|
}
|
||||||
|
|
@ -8,6 +8,7 @@ import {
|
||||||
MessageCircle,
|
MessageCircle,
|
||||||
Settings2,
|
Settings2,
|
||||||
Compass,
|
Compass,
|
||||||
|
FileText,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
|
|
@ -50,6 +51,11 @@ const manageItems = [
|
||||||
url: "/logs/vector-stores",
|
url: "/logs/vector-stores",
|
||||||
icon: Database,
|
icon: Database,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: "Prompts",
|
||||||
|
url: "/prompts",
|
||||||
|
icon: FileText,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: "Documentation",
|
title: "Documentation",
|
||||||
url: "https://llama-stack.readthedocs.io/en/latest/references/api_reference/index.html",
|
url: "https://llama-stack.readthedocs.io/en/latest/references/api_reference/index.html",
|
||||||
|
|
|
||||||
4
src/llama_stack/ui/components/prompts/index.ts
Normal file
4
src/llama_stack/ui/components/prompts/index.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
export { PromptManagement } from "./prompt-management";
|
||||||
|
export { PromptList } from "./prompt-list";
|
||||||
|
export { PromptEditor } from "./prompt-editor";
|
||||||
|
export * from "./types";
|
||||||
309
src/llama_stack/ui/components/prompts/prompt-editor.test.tsx
Normal file
309
src/llama_stack/ui/components/prompts/prompt-editor.test.tsx
Normal file
|
|
@ -0,0 +1,309 @@
|
||||||
|
import React from "react";
|
||||||
|
import { render, screen, fireEvent } from "@testing-library/react";
|
||||||
|
import "@testing-library/jest-dom";
|
||||||
|
import { PromptEditor } from "./prompt-editor";
|
||||||
|
import type { Prompt, PromptFormData } from "./types";
|
||||||
|
|
||||||
|
describe("PromptEditor", () => {
|
||||||
|
const mockOnSave = jest.fn();
|
||||||
|
const mockOnCancel = jest.fn();
|
||||||
|
const mockOnDelete = jest.fn();
|
||||||
|
|
||||||
|
const defaultProps = {
|
||||||
|
onSave: mockOnSave,
|
||||||
|
onCancel: mockOnCancel,
|
||||||
|
onDelete: mockOnDelete,
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Create Mode", () => {
|
||||||
|
test("renders create form correctly", () => {
|
||||||
|
render(<PromptEditor {...defaultProps} />);
|
||||||
|
|
||||||
|
expect(screen.getByLabelText("Prompt Content *")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Variables")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Preview")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Create Prompt")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Cancel")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("shows preview placeholder when no content", () => {
|
||||||
|
render(<PromptEditor {...defaultProps} />);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.getByText("Enter content to preview the compiled prompt")
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("submits form with correct data", () => {
|
||||||
|
render(<PromptEditor {...defaultProps} />);
|
||||||
|
|
||||||
|
const promptInput = screen.getByLabelText("Prompt Content *");
|
||||||
|
fireEvent.change(promptInput, {
|
||||||
|
target: { value: "Hello {{name}}, welcome!" },
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText("Create Prompt"));
|
||||||
|
|
||||||
|
expect(mockOnSave).toHaveBeenCalledWith({
|
||||||
|
prompt: "Hello {{name}}, welcome!",
|
||||||
|
variables: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("prevents submission with empty prompt", () => {
|
||||||
|
render(<PromptEditor {...defaultProps} />);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText("Create Prompt"));
|
||||||
|
|
||||||
|
expect(mockOnSave).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Edit Mode", () => {
|
||||||
|
const mockPrompt: Prompt = {
|
||||||
|
prompt_id: "prompt_123",
|
||||||
|
prompt: "Hello {{name}}, how is {{weather}}?",
|
||||||
|
version: 1,
|
||||||
|
variables: ["name", "weather"],
|
||||||
|
is_default: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
test("renders edit form with existing data", () => {
|
||||||
|
render(<PromptEditor {...defaultProps} prompt={mockPrompt} />);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.getByDisplayValue("Hello {{name}}, how is {{weather}}?")
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(screen.getAllByText("name")).toHaveLength(2); // One in variables, one in preview
|
||||||
|
expect(screen.getAllByText("weather")).toHaveLength(2); // One in variables, one in preview
|
||||||
|
expect(screen.getByText("Update Prompt")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Delete Prompt")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("submits updated data correctly", () => {
|
||||||
|
render(<PromptEditor {...defaultProps} prompt={mockPrompt} />);
|
||||||
|
|
||||||
|
const promptInput = screen.getByLabelText("Prompt Content *");
|
||||||
|
fireEvent.change(promptInput, {
|
||||||
|
target: { value: "Updated: Hello {{name}}!" },
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText("Update Prompt"));
|
||||||
|
|
||||||
|
expect(mockOnSave).toHaveBeenCalledWith({
|
||||||
|
prompt: "Updated: Hello {{name}}!",
|
||||||
|
variables: ["name", "weather"],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Variables Management", () => {
|
||||||
|
test("adds new variable", () => {
|
||||||
|
render(<PromptEditor {...defaultProps} />);
|
||||||
|
|
||||||
|
const variableInput = screen.getByPlaceholderText(
|
||||||
|
"Add variable name (e.g. user_name, topic)"
|
||||||
|
);
|
||||||
|
fireEvent.change(variableInput, { target: { value: "testVar" } });
|
||||||
|
fireEvent.click(screen.getByText("Add"));
|
||||||
|
|
||||||
|
expect(screen.getByText("testVar")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("prevents adding duplicate variables", () => {
|
||||||
|
render(<PromptEditor {...defaultProps} />);
|
||||||
|
|
||||||
|
const variableInput = screen.getByPlaceholderText(
|
||||||
|
"Add variable name (e.g. user_name, topic)"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Add first variable
|
||||||
|
fireEvent.change(variableInput, { target: { value: "test" } });
|
||||||
|
fireEvent.click(screen.getByText("Add"));
|
||||||
|
|
||||||
|
// Try to add same variable again
|
||||||
|
fireEvent.change(variableInput, { target: { value: "test" } });
|
||||||
|
|
||||||
|
// Button should be disabled
|
||||||
|
expect(screen.getByText("Add")).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("removes variable", () => {
|
||||||
|
const mockPrompt: Prompt = {
|
||||||
|
prompt_id: "prompt_123",
|
||||||
|
prompt: "Hello {{name}}",
|
||||||
|
version: 1,
|
||||||
|
variables: ["name", "location"],
|
||||||
|
is_default: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
render(<PromptEditor {...defaultProps} prompt={mockPrompt} />);
|
||||||
|
|
||||||
|
// Check that both variables are present initially
|
||||||
|
expect(screen.getAllByText("name").length).toBeGreaterThan(0);
|
||||||
|
expect(screen.getAllByText("location").length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
// Remove the location variable by clicking the X button with the specific title
|
||||||
|
const removeLocationButton = screen.getByTitle(
|
||||||
|
"Remove location variable"
|
||||||
|
);
|
||||||
|
fireEvent.click(removeLocationButton);
|
||||||
|
|
||||||
|
// Name should still be there, location should be gone from the variables section
|
||||||
|
expect(screen.getAllByText("name").length).toBeGreaterThan(0);
|
||||||
|
expect(
|
||||||
|
screen.queryByTitle("Remove location variable")
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("adds variable on Enter key", () => {
|
||||||
|
render(<PromptEditor {...defaultProps} />);
|
||||||
|
|
||||||
|
const variableInput = screen.getByPlaceholderText(
|
||||||
|
"Add variable name (e.g. user_name, topic)"
|
||||||
|
);
|
||||||
|
fireEvent.change(variableInput, { target: { value: "enterVar" } });
|
||||||
|
|
||||||
|
// Simulate Enter key press
|
||||||
|
fireEvent.keyPress(variableInput, {
|
||||||
|
key: "Enter",
|
||||||
|
code: "Enter",
|
||||||
|
charCode: 13,
|
||||||
|
preventDefault: jest.fn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check if the variable was added by looking for the badge
|
||||||
|
expect(screen.getAllByText("enterVar").length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Preview Functionality", () => {
|
||||||
|
test("shows live preview with variables", () => {
|
||||||
|
render(<PromptEditor {...defaultProps} />);
|
||||||
|
|
||||||
|
// Add prompt content
|
||||||
|
const promptInput = screen.getByLabelText("Prompt Content *");
|
||||||
|
fireEvent.change(promptInput, {
|
||||||
|
target: { value: "Hello {{name}}, welcome to {{place}}!" },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add variables
|
||||||
|
const variableInput = screen.getByPlaceholderText(
|
||||||
|
"Add variable name (e.g. user_name, topic)"
|
||||||
|
);
|
||||||
|
fireEvent.change(variableInput, { target: { value: "name" } });
|
||||||
|
fireEvent.click(screen.getByText("Add"));
|
||||||
|
|
||||||
|
fireEvent.change(variableInput, { target: { value: "place" } });
|
||||||
|
fireEvent.click(screen.getByText("Add"));
|
||||||
|
|
||||||
|
// Check that preview area shows the content
|
||||||
|
expect(screen.getByText("Compiled Prompt")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("shows variable value inputs in preview", () => {
|
||||||
|
const mockPrompt: Prompt = {
|
||||||
|
prompt_id: "prompt_123",
|
||||||
|
prompt: "Hello {{name}}",
|
||||||
|
version: 1,
|
||||||
|
variables: ["name"],
|
||||||
|
is_default: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
render(<PromptEditor {...defaultProps} prompt={mockPrompt} />);
|
||||||
|
|
||||||
|
expect(screen.getByText("Variable Values")).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByPlaceholderText("Enter value for name")
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("shows color legend for variable states", () => {
|
||||||
|
render(<PromptEditor {...defaultProps} />);
|
||||||
|
|
||||||
|
// Add content to show preview
|
||||||
|
const promptInput = screen.getByLabelText("Prompt Content *");
|
||||||
|
fireEvent.change(promptInput, {
|
||||||
|
target: { value: "Hello {{name}}" },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.getByText("Used")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Unused")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Undefined")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Error Handling", () => {
|
||||||
|
test("displays error message", () => {
|
||||||
|
const errorMessage = "Prompt contains undeclared variables";
|
||||||
|
render(<PromptEditor {...defaultProps} error={errorMessage} />);
|
||||||
|
|
||||||
|
expect(screen.getByText(errorMessage)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Delete Functionality", () => {
|
||||||
|
const mockPrompt: Prompt = {
|
||||||
|
prompt_id: "prompt_123",
|
||||||
|
prompt: "Hello {{name}}",
|
||||||
|
version: 1,
|
||||||
|
variables: ["name"],
|
||||||
|
is_default: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
test("shows delete button in edit mode", () => {
|
||||||
|
render(<PromptEditor {...defaultProps} prompt={mockPrompt} />);
|
||||||
|
|
||||||
|
expect(screen.getByText("Delete Prompt")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("hides delete button in create mode", () => {
|
||||||
|
render(<PromptEditor {...defaultProps} />);
|
||||||
|
|
||||||
|
expect(screen.queryByText("Delete Prompt")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("calls onDelete with confirmation", () => {
|
||||||
|
const originalConfirm = window.confirm;
|
||||||
|
window.confirm = jest.fn(() => true);
|
||||||
|
|
||||||
|
render(<PromptEditor {...defaultProps} prompt={mockPrompt} />);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText("Delete Prompt"));
|
||||||
|
|
||||||
|
expect(window.confirm).toHaveBeenCalledWith(
|
||||||
|
"Are you sure you want to delete this prompt? This action cannot be undone."
|
||||||
|
);
|
||||||
|
expect(mockOnDelete).toHaveBeenCalledWith("prompt_123");
|
||||||
|
|
||||||
|
window.confirm = originalConfirm;
|
||||||
|
});
|
||||||
|
|
||||||
|
test("does not delete when confirmation is cancelled", () => {
|
||||||
|
const originalConfirm = window.confirm;
|
||||||
|
window.confirm = jest.fn(() => false);
|
||||||
|
|
||||||
|
render(<PromptEditor {...defaultProps} prompt={mockPrompt} />);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText("Delete Prompt"));
|
||||||
|
|
||||||
|
expect(mockOnDelete).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
window.confirm = originalConfirm;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Cancel Functionality", () => {
|
||||||
|
test("calls onCancel when cancel button is clicked", () => {
|
||||||
|
render(<PromptEditor {...defaultProps} />);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText("Cancel"));
|
||||||
|
|
||||||
|
expect(mockOnCancel).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
346
src/llama_stack/ui/components/prompts/prompt-editor.tsx
Normal file
346
src/llama_stack/ui/components/prompts/prompt-editor.tsx
Normal file
|
|
@ -0,0 +1,346 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
import { X, Plus, Save, Trash2 } from "lucide-react";
|
||||||
|
import { Prompt, PromptFormData } from "./types";
|
||||||
|
|
||||||
|
interface PromptEditorProps {
|
||||||
|
prompt?: Prompt;
|
||||||
|
onSave: (prompt: PromptFormData) => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
onDelete?: (promptId: string) => void;
|
||||||
|
error?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PromptEditor({
|
||||||
|
prompt,
|
||||||
|
onSave,
|
||||||
|
onCancel,
|
||||||
|
onDelete,
|
||||||
|
error,
|
||||||
|
}: PromptEditorProps) {
|
||||||
|
const [formData, setFormData] = useState<PromptFormData>({
|
||||||
|
prompt: "",
|
||||||
|
variables: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const [newVariable, setNewVariable] = useState("");
|
||||||
|
const [variableValues, setVariableValues] = useState<Record<string, string>>(
|
||||||
|
{}
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (prompt) {
|
||||||
|
setFormData({
|
||||||
|
prompt: prompt.prompt || "",
|
||||||
|
variables: prompt.variables || [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [prompt]);
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!formData.prompt.trim()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onSave(formData);
|
||||||
|
};
|
||||||
|
|
||||||
|
const addVariable = () => {
|
||||||
|
if (
|
||||||
|
newVariable.trim() &&
|
||||||
|
!formData.variables.includes(newVariable.trim())
|
||||||
|
) {
|
||||||
|
setFormData(prev => ({
|
||||||
|
...prev,
|
||||||
|
variables: [...prev.variables, newVariable.trim()],
|
||||||
|
}));
|
||||||
|
setNewVariable("");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeVariable = (variableToRemove: string) => {
|
||||||
|
setFormData(prev => ({
|
||||||
|
...prev,
|
||||||
|
variables: prev.variables.filter(
|
||||||
|
variable => variable !== variableToRemove
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderPreview = () => {
|
||||||
|
const text = formData.prompt;
|
||||||
|
if (!text) return text;
|
||||||
|
|
||||||
|
// Split text by variable patterns and process each part
|
||||||
|
const parts = text.split(/(\{\{\s*\w+\s*\}\})/g);
|
||||||
|
|
||||||
|
return parts.map((part, index) => {
|
||||||
|
const variableMatch = part.match(/\{\{\s*(\w+)\s*\}\}/);
|
||||||
|
if (variableMatch) {
|
||||||
|
const variableName = variableMatch[1];
|
||||||
|
const isDefined = formData.variables.includes(variableName);
|
||||||
|
const value = variableValues[variableName];
|
||||||
|
|
||||||
|
if (!isDefined) {
|
||||||
|
// Variable not in variables list - likely a typo/bug (RED)
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
key={index}
|
||||||
|
className="bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200 px-1 rounded font-medium"
|
||||||
|
>
|
||||||
|
{part}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
} else if (value && value.trim()) {
|
||||||
|
// Variable defined and has value - show the value (GREEN)
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
key={index}
|
||||||
|
className="bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200 px-1 rounded font-medium"
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Variable defined but empty (YELLOW)
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
key={index}
|
||||||
|
className="bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200 px-1 rounded font-medium"
|
||||||
|
>
|
||||||
|
{part}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return part;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateVariableValue = (variable: string, value: string) => {
|
||||||
|
setVariableValues(prev => ({
|
||||||
|
...prev,
|
||||||
|
[variable]: value,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
|
{error && (
|
||||||
|
<div className="p-4 bg-destructive/10 border border-destructive/20 rounded-md">
|
||||||
|
<p className="text-destructive text-sm">{error}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
{/* Form Section */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="prompt">Prompt Content *</Label>
|
||||||
|
<Textarea
|
||||||
|
id="prompt"
|
||||||
|
value={formData.prompt}
|
||||||
|
onChange={e =>
|
||||||
|
setFormData(prev => ({ ...prev, prompt: e.target.value }))
|
||||||
|
}
|
||||||
|
placeholder="Enter your prompt content here. Use {{variable_name}} for dynamic variables."
|
||||||
|
className="min-h-32 font-mono mt-2"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground mt-2">
|
||||||
|
Use double curly braces around variable names, e.g.,{" "}
|
||||||
|
{`{{user_name}}`} or {`{{topic}}`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Label className="text-sm font-medium">Variables</Label>
|
||||||
|
|
||||||
|
<div className="flex gap-2 mt-2">
|
||||||
|
<Input
|
||||||
|
value={newVariable}
|
||||||
|
onChange={e => setNewVariable(e.target.value)}
|
||||||
|
placeholder="Add variable name (e.g. user_name, topic)"
|
||||||
|
onKeyPress={e =>
|
||||||
|
e.key === "Enter" && (e.preventDefault(), addVariable())
|
||||||
|
}
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={addVariable}
|
||||||
|
size="sm"
|
||||||
|
disabled={
|
||||||
|
!newVariable.trim() ||
|
||||||
|
formData.variables.includes(newVariable.trim())
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
Add
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{formData.variables.length > 0 && (
|
||||||
|
<div className="border rounded-lg p-3 bg-muted/20">
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{formData.variables.map(variable => (
|
||||||
|
<Badge
|
||||||
|
key={variable}
|
||||||
|
variant="secondary"
|
||||||
|
className="text-sm px-2 py-1"
|
||||||
|
>
|
||||||
|
{variable}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => removeVariable(variable)}
|
||||||
|
className="ml-2 hover:text-destructive transition-colors"
|
||||||
|
title={`Remove ${variable} variable`}
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Variables that can be used in the prompt template. Each variable
|
||||||
|
should match a {`{{variable}}`} placeholder in the content above.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Preview Section */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-lg">Preview</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Live preview of compiled prompt and variable substitution.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{formData.prompt ? (
|
||||||
|
<>
|
||||||
|
{/* Variable Values */}
|
||||||
|
{formData.variables.length > 0 && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Label className="text-sm font-medium">
|
||||||
|
Variable Values
|
||||||
|
</Label>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{formData.variables.map(variable => (
|
||||||
|
<div
|
||||||
|
key={variable}
|
||||||
|
className="grid grid-cols-2 gap-3 items-center"
|
||||||
|
>
|
||||||
|
<div className="text-sm font-mono text-muted-foreground">
|
||||||
|
{variable}
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
id={`var-${variable}`}
|
||||||
|
value={variableValues[variable] || ""}
|
||||||
|
onChange={e =>
|
||||||
|
updateVariableValue(variable, e.target.value)
|
||||||
|
}
|
||||||
|
placeholder={`Enter value for ${variable}`}
|
||||||
|
className="text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<Separator />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Live Preview */}
|
||||||
|
<div>
|
||||||
|
<Label className="text-sm font-medium mb-2 block">
|
||||||
|
Compiled Prompt
|
||||||
|
</Label>
|
||||||
|
<div className="bg-muted/50 p-4 rounded-lg border">
|
||||||
|
<div className="text-sm leading-relaxed whitespace-pre-wrap">
|
||||||
|
{renderPreview()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-4 mt-2 text-xs">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<div className="w-3 h-3 bg-green-500 dark:bg-green-400 border rounded"></div>
|
||||||
|
<span className="text-muted-foreground">Used</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<div className="w-3 h-3 bg-yellow-500 dark:bg-yellow-400 border rounded"></div>
|
||||||
|
<span className="text-muted-foreground">Unused</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<div className="w-3 h-3 bg-red-500 dark:bg-red-400 border rounded"></div>
|
||||||
|
<span className="text-muted-foreground">Undefined</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="text-center py-8">
|
||||||
|
<div className="text-muted-foreground text-sm">
|
||||||
|
Enter content to preview the compiled prompt
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground mt-2">
|
||||||
|
Use {`{{variable_name}}`} to add dynamic variables
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<div>
|
||||||
|
{prompt && onDelete && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() => {
|
||||||
|
if (
|
||||||
|
confirm(
|
||||||
|
`Are you sure you want to delete this prompt? This action cannot be undone.`
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
onDelete(prompt.prompt_id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4 mr-2" />
|
||||||
|
Delete Prompt
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button type="button" variant="outline" onClick={onCancel}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit">
|
||||||
|
<Save className="h-4 w-4 mr-2" />
|
||||||
|
{prompt ? "Update" : "Create"} Prompt
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
259
src/llama_stack/ui/components/prompts/prompt-list.test.tsx
Normal file
259
src/llama_stack/ui/components/prompts/prompt-list.test.tsx
Normal file
|
|
@ -0,0 +1,259 @@
|
||||||
|
import React from "react";
|
||||||
|
import { render, screen, fireEvent } from "@testing-library/react";
|
||||||
|
import "@testing-library/jest-dom";
|
||||||
|
import { PromptList } from "./prompt-list";
|
||||||
|
import type { Prompt } from "./types";
|
||||||
|
|
||||||
|
describe("PromptList", () => {
|
||||||
|
const mockOnEdit = jest.fn();
|
||||||
|
const mockOnDelete = jest.fn();
|
||||||
|
|
||||||
|
const defaultProps = {
|
||||||
|
prompts: [],
|
||||||
|
onEdit: mockOnEdit,
|
||||||
|
onDelete: mockOnDelete,
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Empty State", () => {
|
||||||
|
test("renders empty message when no prompts", () => {
|
||||||
|
render(<PromptList {...defaultProps} />);
|
||||||
|
|
||||||
|
expect(screen.getByText("No prompts yet")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("shows filtered empty message when search has no results", () => {
|
||||||
|
const prompts: Prompt[] = [
|
||||||
|
{
|
||||||
|
prompt_id: "prompt_123",
|
||||||
|
prompt: "Hello world",
|
||||||
|
version: 1,
|
||||||
|
variables: [],
|
||||||
|
is_default: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
render(<PromptList {...defaultProps} prompts={prompts} />);
|
||||||
|
|
||||||
|
// Search for something that doesn't exist
|
||||||
|
const searchInput = screen.getByPlaceholderText("Search prompts...");
|
||||||
|
fireEvent.change(searchInput, { target: { value: "nonexistent" } });
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.getByText("No prompts match your filters")
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Prompts Display", () => {
|
||||||
|
const mockPrompts: Prompt[] = [
|
||||||
|
{
|
||||||
|
prompt_id: "prompt_123",
|
||||||
|
prompt: "Hello {{name}}, how are you?",
|
||||||
|
version: 1,
|
||||||
|
variables: ["name"],
|
||||||
|
is_default: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
prompt_id: "prompt_456",
|
||||||
|
prompt: "Summarize this {{text}} in {{length}} words",
|
||||||
|
version: 2,
|
||||||
|
variables: ["text", "length"],
|
||||||
|
is_default: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
prompt_id: "prompt_789",
|
||||||
|
prompt: "Simple prompt with no variables",
|
||||||
|
version: 1,
|
||||||
|
variables: [],
|
||||||
|
is_default: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
test("renders prompts table with correct headers", () => {
|
||||||
|
render(<PromptList {...defaultProps} prompts={mockPrompts} />);
|
||||||
|
|
||||||
|
expect(screen.getByText("ID")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Content")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Variables")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Version")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Actions")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("renders prompt data correctly", () => {
|
||||||
|
render(<PromptList {...defaultProps} prompts={mockPrompts} />);
|
||||||
|
|
||||||
|
// Check prompt IDs
|
||||||
|
expect(screen.getByText("prompt_123")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("prompt_456")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("prompt_789")).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Check content
|
||||||
|
expect(
|
||||||
|
screen.getByText("Hello {{name}}, how are you?")
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByText("Summarize this {{text}} in {{length}} words")
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByText("Simple prompt with no variables")
|
||||||
|
).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Check versions
|
||||||
|
expect(screen.getAllByText("1")).toHaveLength(2); // Two prompts with version 1
|
||||||
|
expect(screen.getByText("2")).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Check default badge
|
||||||
|
expect(screen.getByText("Default")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("renders variables correctly", () => {
|
||||||
|
render(<PromptList {...defaultProps} prompts={mockPrompts} />);
|
||||||
|
|
||||||
|
// Check variables display
|
||||||
|
expect(screen.getByText("name")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("text")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("length")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("None")).toBeInTheDocument(); // For prompt with no variables
|
||||||
|
});
|
||||||
|
|
||||||
|
test("prompt ID links are clickable and call onEdit", () => {
|
||||||
|
render(<PromptList {...defaultProps} prompts={mockPrompts} />);
|
||||||
|
|
||||||
|
// Click on the first prompt ID link
|
||||||
|
const promptLink = screen.getByRole("button", { name: "prompt_123" });
|
||||||
|
fireEvent.click(promptLink);
|
||||||
|
|
||||||
|
expect(mockOnEdit).toHaveBeenCalledWith(mockPrompts[0]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("edit buttons call onEdit", () => {
|
||||||
|
const { container } = render(
|
||||||
|
<PromptList {...defaultProps} prompts={mockPrompts} />
|
||||||
|
);
|
||||||
|
|
||||||
|
// Find the action buttons in the table - they should be in the last column
|
||||||
|
const actionCells = container.querySelectorAll("td:last-child");
|
||||||
|
const firstActionCell = actionCells[0];
|
||||||
|
const editButton = firstActionCell?.querySelector("button");
|
||||||
|
|
||||||
|
expect(editButton).toBeInTheDocument();
|
||||||
|
fireEvent.click(editButton!);
|
||||||
|
|
||||||
|
expect(mockOnEdit).toHaveBeenCalledWith(mockPrompts[0]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("delete buttons call onDelete with confirmation", () => {
|
||||||
|
const originalConfirm = window.confirm;
|
||||||
|
window.confirm = jest.fn(() => true);
|
||||||
|
|
||||||
|
const { container } = render(
|
||||||
|
<PromptList {...defaultProps} prompts={mockPrompts} />
|
||||||
|
);
|
||||||
|
|
||||||
|
// Find the delete button (second button in the first action cell)
|
||||||
|
const actionCells = container.querySelectorAll("td:last-child");
|
||||||
|
const firstActionCell = actionCells[0];
|
||||||
|
const buttons = firstActionCell?.querySelectorAll("button");
|
||||||
|
const deleteButton = buttons?.[1]; // Second button should be delete
|
||||||
|
|
||||||
|
expect(deleteButton).toBeInTheDocument();
|
||||||
|
fireEvent.click(deleteButton!);
|
||||||
|
|
||||||
|
expect(window.confirm).toHaveBeenCalledWith(
|
||||||
|
"Are you sure you want to delete this prompt? This action cannot be undone."
|
||||||
|
);
|
||||||
|
expect(mockOnDelete).toHaveBeenCalledWith("prompt_123");
|
||||||
|
|
||||||
|
window.confirm = originalConfirm;
|
||||||
|
});
|
||||||
|
|
||||||
|
test("delete does not execute when confirmation is cancelled", () => {
|
||||||
|
const originalConfirm = window.confirm;
|
||||||
|
window.confirm = jest.fn(() => false);
|
||||||
|
|
||||||
|
const { container } = render(
|
||||||
|
<PromptList {...defaultProps} prompts={mockPrompts} />
|
||||||
|
);
|
||||||
|
|
||||||
|
const actionCells = container.querySelectorAll("td:last-child");
|
||||||
|
const firstActionCell = actionCells[0];
|
||||||
|
const buttons = firstActionCell?.querySelectorAll("button");
|
||||||
|
const deleteButton = buttons?.[1]; // Second button should be delete
|
||||||
|
|
||||||
|
expect(deleteButton).toBeInTheDocument();
|
||||||
|
fireEvent.click(deleteButton!);
|
||||||
|
|
||||||
|
expect(mockOnDelete).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
window.confirm = originalConfirm;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Search Functionality", () => {
|
||||||
|
const mockPrompts: Prompt[] = [
|
||||||
|
{
|
||||||
|
prompt_id: "user_greeting",
|
||||||
|
prompt: "Hello {{name}}, welcome!",
|
||||||
|
version: 1,
|
||||||
|
variables: ["name"],
|
||||||
|
is_default: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
prompt_id: "system_summary",
|
||||||
|
prompt: "Summarize the following text",
|
||||||
|
version: 1,
|
||||||
|
variables: [],
|
||||||
|
is_default: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
test("filters prompts by prompt ID", () => {
|
||||||
|
render(<PromptList {...defaultProps} prompts={mockPrompts} />);
|
||||||
|
|
||||||
|
const searchInput = screen.getByPlaceholderText("Search prompts...");
|
||||||
|
fireEvent.change(searchInput, { target: { value: "user" } });
|
||||||
|
|
||||||
|
expect(screen.getByText("user_greeting")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText("system_summary")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("filters prompts by content", () => {
|
||||||
|
render(<PromptList {...defaultProps} prompts={mockPrompts} />);
|
||||||
|
|
||||||
|
const searchInput = screen.getByPlaceholderText("Search prompts...");
|
||||||
|
fireEvent.change(searchInput, { target: { value: "welcome" } });
|
||||||
|
|
||||||
|
expect(screen.getByText("user_greeting")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText("system_summary")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("search is case insensitive", () => {
|
||||||
|
render(<PromptList {...defaultProps} prompts={mockPrompts} />);
|
||||||
|
|
||||||
|
const searchInput = screen.getByPlaceholderText("Search prompts...");
|
||||||
|
fireEvent.change(searchInput, { target: { value: "HELLO" } });
|
||||||
|
|
||||||
|
expect(screen.getByText("user_greeting")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText("system_summary")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("clearing search shows all prompts", () => {
|
||||||
|
render(<PromptList {...defaultProps} prompts={mockPrompts} />);
|
||||||
|
|
||||||
|
const searchInput = screen.getByPlaceholderText("Search prompts...");
|
||||||
|
|
||||||
|
// Filter first
|
||||||
|
fireEvent.change(searchInput, { target: { value: "user" } });
|
||||||
|
expect(screen.queryByText("system_summary")).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
// Clear search
|
||||||
|
fireEvent.change(searchInput, { target: { value: "" } });
|
||||||
|
expect(screen.getByText("user_greeting")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("system_summary")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
164
src/llama_stack/ui/components/prompts/prompt-list.tsx
Normal file
164
src/llama_stack/ui/components/prompts/prompt-list.tsx
Normal file
|
|
@ -0,0 +1,164 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Edit, Search, Trash2 } from "lucide-react";
|
||||||
|
import { Prompt, PromptFilters } from "./types";
|
||||||
|
|
||||||
|
interface PromptListProps {
|
||||||
|
prompts: Prompt[];
|
||||||
|
onEdit: (prompt: Prompt) => void;
|
||||||
|
onDelete: (promptId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PromptList({ prompts, onEdit, onDelete }: PromptListProps) {
|
||||||
|
const [filters, setFilters] = useState<PromptFilters>({});
|
||||||
|
|
||||||
|
const filteredPrompts = prompts.filter(prompt => {
|
||||||
|
if (
|
||||||
|
filters.searchTerm &&
|
||||||
|
!(
|
||||||
|
prompt.prompt
|
||||||
|
?.toLowerCase()
|
||||||
|
.includes(filters.searchTerm.toLowerCase()) ||
|
||||||
|
prompt.prompt_id
|
||||||
|
.toLowerCase()
|
||||||
|
.includes(filters.searchTerm.toLowerCase())
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Filters */}
|
||||||
|
<div className="flex flex-col sm:flex-row gap-4">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-muted-foreground h-4 w-4" />
|
||||||
|
<Input
|
||||||
|
placeholder="Search prompts..."
|
||||||
|
value={filters.searchTerm || ""}
|
||||||
|
onChange={e =>
|
||||||
|
setFilters(prev => ({ ...prev, searchTerm: e.target.value }))
|
||||||
|
}
|
||||||
|
className="pl-10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Prompts Table */}
|
||||||
|
<div className="overflow-auto">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>ID</TableHead>
|
||||||
|
<TableHead>Content</TableHead>
|
||||||
|
<TableHead>Variables</TableHead>
|
||||||
|
<TableHead>Version</TableHead>
|
||||||
|
<TableHead>Actions</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{filteredPrompts.map(prompt => (
|
||||||
|
<TableRow key={prompt.prompt_id}>
|
||||||
|
<TableCell className="max-w-48">
|
||||||
|
<Button
|
||||||
|
variant="link"
|
||||||
|
className="p-0 h-auto font-mono text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300 max-w-full justify-start"
|
||||||
|
onClick={() => onEdit(prompt)}
|
||||||
|
title={prompt.prompt_id}
|
||||||
|
>
|
||||||
|
<div className="truncate">{prompt.prompt_id}</div>
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="max-w-64">
|
||||||
|
<div
|
||||||
|
className="font-mono text-xs text-muted-foreground truncate"
|
||||||
|
title={prompt.prompt || "No content"}
|
||||||
|
>
|
||||||
|
{prompt.prompt || "No content"}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{prompt.variables.length > 0 ? (
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{prompt.variables.map(variable => (
|
||||||
|
<Badge
|
||||||
|
key={variable}
|
||||||
|
variant="outline"
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
{variable}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground text-sm">None</span>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-sm">
|
||||||
|
{prompt.version}
|
||||||
|
{prompt.is_default && (
|
||||||
|
<Badge variant="secondary" className="text-xs ml-2">
|
||||||
|
Default
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => onEdit(prompt)}
|
||||||
|
className="h-8 w-8 p-0"
|
||||||
|
>
|
||||||
|
<Edit className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
if (
|
||||||
|
confirm(
|
||||||
|
`Are you sure you want to delete this prompt? This action cannot be undone.`
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
onDelete(prompt.prompt_id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="h-8 w-8 p-0 text-destructive hover:text-destructive"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{filteredPrompts.length === 0 && (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<div className="text-muted-foreground">
|
||||||
|
{prompts.length === 0
|
||||||
|
? "No prompts yet"
|
||||||
|
: "No prompts match your filters"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
304
src/llama_stack/ui/components/prompts/prompt-management.test.tsx
Normal file
304
src/llama_stack/ui/components/prompts/prompt-management.test.tsx
Normal file
|
|
@ -0,0 +1,304 @@
|
||||||
|
import React from "react";
|
||||||
|
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||||
|
import "@testing-library/jest-dom";
|
||||||
|
import { PromptManagement } from "./prompt-management";
|
||||||
|
import type { Prompt } from "./types";
|
||||||
|
|
||||||
|
// Mock the auth client
|
||||||
|
const mockPromptsClient = {
|
||||||
|
list: jest.fn(),
|
||||||
|
create: jest.fn(),
|
||||||
|
update: jest.fn(),
|
||||||
|
delete: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
jest.mock("@/hooks/use-auth-client", () => ({
|
||||||
|
useAuthClient: () => ({
|
||||||
|
prompts: mockPromptsClient,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("PromptManagement", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Loading State", () => {
|
||||||
|
test("renders loading state initially", () => {
|
||||||
|
mockPromptsClient.list.mockReturnValue(new Promise(() => {})); // Never resolves
|
||||||
|
render(<PromptManagement />);
|
||||||
|
|
||||||
|
expect(screen.getByText("Loading prompts...")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Prompts")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Empty State", () => {
|
||||||
|
test("renders empty state when no prompts", async () => {
|
||||||
|
mockPromptsClient.list.mockResolvedValue([]);
|
||||||
|
render(<PromptManagement />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("No prompts found.")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.getByText("Create Your First Prompt")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("opens modal when clicking 'Create Your First Prompt'", async () => {
|
||||||
|
mockPromptsClient.list.mockResolvedValue([]);
|
||||||
|
render(<PromptManagement />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(
|
||||||
|
screen.getByText("Create Your First Prompt")
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText("Create Your First Prompt"));
|
||||||
|
|
||||||
|
expect(screen.getByText("Create New Prompt")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Error State", () => {
|
||||||
|
test("renders error state when API fails", async () => {
|
||||||
|
const error = new Error("API not found");
|
||||||
|
mockPromptsClient.list.mockRejectedValue(error);
|
||||||
|
render(<PromptManagement />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText(/Error:/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("renders specific error for 404", async () => {
|
||||||
|
const error = new Error("404 Not found");
|
||||||
|
mockPromptsClient.list.mockRejectedValue(error);
|
||||||
|
render(<PromptManagement />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(
|
||||||
|
screen.getByText(/Prompts API endpoint not found/)
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Prompts List", () => {
|
||||||
|
const mockPrompts: Prompt[] = [
|
||||||
|
{
|
||||||
|
prompt_id: "prompt_123",
|
||||||
|
prompt: "Hello {{name}}, how are you?",
|
||||||
|
version: 1,
|
||||||
|
variables: ["name"],
|
||||||
|
is_default: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
prompt_id: "prompt_456",
|
||||||
|
prompt: "Summarize this {{text}}",
|
||||||
|
version: 2,
|
||||||
|
variables: ["text"],
|
||||||
|
is_default: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
test("renders prompts list correctly", async () => {
|
||||||
|
mockPromptsClient.list.mockResolvedValue(mockPrompts);
|
||||||
|
render(<PromptManagement />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("prompt_123")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.getByText("prompt_456")).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByText("Hello {{name}}, how are you?")
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Summarize this {{text}}")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("opens modal when clicking 'New Prompt' button", async () => {
|
||||||
|
mockPromptsClient.list.mockResolvedValue(mockPrompts);
|
||||||
|
render(<PromptManagement />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("prompt_123")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText("New Prompt"));
|
||||||
|
|
||||||
|
expect(screen.getByText("Create New Prompt")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Modal Operations", () => {
|
||||||
|
const mockPrompts: Prompt[] = [
|
||||||
|
{
|
||||||
|
prompt_id: "prompt_123",
|
||||||
|
prompt: "Hello {{name}}",
|
||||||
|
version: 1,
|
||||||
|
variables: ["name"],
|
||||||
|
is_default: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
test("closes modal when clicking cancel", async () => {
|
||||||
|
mockPromptsClient.list.mockResolvedValue(mockPrompts);
|
||||||
|
render(<PromptManagement />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("prompt_123")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Open modal
|
||||||
|
fireEvent.click(screen.getByText("New Prompt"));
|
||||||
|
expect(screen.getByText("Create New Prompt")).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Close modal
|
||||||
|
fireEvent.click(screen.getByText("Cancel"));
|
||||||
|
expect(screen.queryByText("Create New Prompt")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("creates new prompt successfully", async () => {
|
||||||
|
const newPrompt: Prompt = {
|
||||||
|
prompt_id: "prompt_new",
|
||||||
|
prompt: "New prompt content",
|
||||||
|
version: 1,
|
||||||
|
variables: [],
|
||||||
|
is_default: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
mockPromptsClient.list.mockResolvedValue(mockPrompts);
|
||||||
|
mockPromptsClient.create.mockResolvedValue(newPrompt);
|
||||||
|
render(<PromptManagement />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("prompt_123")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Open modal
|
||||||
|
fireEvent.click(screen.getByText("New Prompt"));
|
||||||
|
|
||||||
|
// Fill form
|
||||||
|
const promptInput = screen.getByLabelText("Prompt Content *");
|
||||||
|
fireEvent.change(promptInput, {
|
||||||
|
target: { value: "New prompt content" },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Submit form
|
||||||
|
fireEvent.click(screen.getByText("Create Prompt"));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockPromptsClient.create).toHaveBeenCalledWith({
|
||||||
|
prompt: "New prompt content",
|
||||||
|
variables: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("handles create error gracefully", async () => {
|
||||||
|
const error = {
|
||||||
|
detail: {
|
||||||
|
errors: [{ msg: "Prompt contains undeclared variables: ['test']" }],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
mockPromptsClient.list.mockResolvedValue(mockPrompts);
|
||||||
|
mockPromptsClient.create.mockRejectedValue(error);
|
||||||
|
render(<PromptManagement />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("prompt_123")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Open modal
|
||||||
|
fireEvent.click(screen.getByText("New Prompt"));
|
||||||
|
|
||||||
|
// Fill form
|
||||||
|
const promptInput = screen.getByLabelText("Prompt Content *");
|
||||||
|
fireEvent.change(promptInput, { target: { value: "Hello {{test}}" } });
|
||||||
|
|
||||||
|
// Submit form
|
||||||
|
fireEvent.click(screen.getByText("Create Prompt"));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(
|
||||||
|
screen.getByText("Prompt contains undeclared variables: ['test']")
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("updates existing prompt successfully", async () => {
|
||||||
|
const updatedPrompt: Prompt = {
|
||||||
|
...mockPrompts[0],
|
||||||
|
prompt: "Updated content",
|
||||||
|
};
|
||||||
|
|
||||||
|
mockPromptsClient.list.mockResolvedValue(mockPrompts);
|
||||||
|
mockPromptsClient.update.mockResolvedValue(updatedPrompt);
|
||||||
|
const { container } = render(<PromptManagement />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("prompt_123")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Click edit button (first button in the action cell of the first row)
|
||||||
|
const actionCells = container.querySelectorAll("td:last-child");
|
||||||
|
const firstActionCell = actionCells[0];
|
||||||
|
const editButton = firstActionCell?.querySelector("button");
|
||||||
|
|
||||||
|
expect(editButton).toBeInTheDocument();
|
||||||
|
fireEvent.click(editButton!);
|
||||||
|
|
||||||
|
expect(screen.getByText("Edit Prompt")).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Update content
|
||||||
|
const promptInput = screen.getByLabelText("Prompt Content *");
|
||||||
|
fireEvent.change(promptInput, { target: { value: "Updated content" } });
|
||||||
|
|
||||||
|
// Submit form
|
||||||
|
fireEvent.click(screen.getByText("Update Prompt"));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockPromptsClient.update).toHaveBeenCalledWith("prompt_123", {
|
||||||
|
prompt: "Updated content",
|
||||||
|
variables: ["name"],
|
||||||
|
version: 1,
|
||||||
|
set_as_default: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("deletes prompt successfully", async () => {
|
||||||
|
mockPromptsClient.list.mockResolvedValue(mockPrompts);
|
||||||
|
mockPromptsClient.delete.mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
// Mock window.confirm
|
||||||
|
const originalConfirm = window.confirm;
|
||||||
|
window.confirm = jest.fn(() => true);
|
||||||
|
|
||||||
|
const { container } = render(<PromptManagement />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("prompt_123")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Click delete button (second button in the action cell of the first row)
|
||||||
|
const actionCells = container.querySelectorAll("td:last-child");
|
||||||
|
const firstActionCell = actionCells[0];
|
||||||
|
const buttons = firstActionCell?.querySelectorAll("button");
|
||||||
|
const deleteButton = buttons?.[1]; // Second button should be delete
|
||||||
|
|
||||||
|
expect(deleteButton).toBeInTheDocument();
|
||||||
|
fireEvent.click(deleteButton!);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockPromptsClient.delete).toHaveBeenCalledWith("prompt_123");
|
||||||
|
});
|
||||||
|
|
||||||
|
// Restore window.confirm
|
||||||
|
window.confirm = originalConfirm;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
233
src/llama_stack/ui/components/prompts/prompt-management.tsx
Normal file
233
src/llama_stack/ui/components/prompts/prompt-management.tsx
Normal file
|
|
@ -0,0 +1,233 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Plus } from "lucide-react";
|
||||||
|
import { PromptList } from "./prompt-list";
|
||||||
|
import { PromptEditor } from "./prompt-editor";
|
||||||
|
import { Prompt, PromptFormData } from "./types";
|
||||||
|
import { useAuthClient } from "@/hooks/use-auth-client";
|
||||||
|
|
||||||
|
export function PromptManagement() {
|
||||||
|
const [prompts, setPrompts] = useState<Prompt[]>([]);
|
||||||
|
const [showPromptModal, setShowPromptModal] = useState(false);
|
||||||
|
const [editingPrompt, setEditingPrompt] = useState<Prompt | undefined>();
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null); // For main page errors (loading, etc.)
|
||||||
|
const [modalError, setModalError] = useState<string | null>(null); // For form submission errors
|
||||||
|
const client = useAuthClient();
|
||||||
|
|
||||||
|
// Load prompts from API on component mount
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchPrompts = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const response = await client.prompts.list();
|
||||||
|
setPrompts(response || []);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
console.error("Failed to load prompts:", err);
|
||||||
|
|
||||||
|
// Handle different types of errors
|
||||||
|
const error = err as Error & { status?: number };
|
||||||
|
if (error?.message?.includes("404") || error?.status === 404) {
|
||||||
|
setError(
|
||||||
|
"Prompts API endpoint not found. Please ensure your Llama Stack server supports the prompts API."
|
||||||
|
);
|
||||||
|
} else if (
|
||||||
|
error?.message?.includes("not implemented") ||
|
||||||
|
error?.message?.includes("not supported")
|
||||||
|
) {
|
||||||
|
setError(
|
||||||
|
"Prompts API is not yet implemented on this Llama Stack server."
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
setError(
|
||||||
|
`Failed to load prompts: ${error?.message || "Unknown error"}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchPrompts();
|
||||||
|
}, [client]);
|
||||||
|
|
||||||
|
const handleSavePrompt = async (formData: PromptFormData) => {
|
||||||
|
try {
|
||||||
|
setModalError(null);
|
||||||
|
|
||||||
|
if (editingPrompt) {
|
||||||
|
// Update existing prompt
|
||||||
|
const response = await client.prompts.update(editingPrompt.prompt_id, {
|
||||||
|
prompt: formData.prompt,
|
||||||
|
variables: formData.variables,
|
||||||
|
version: editingPrompt.version,
|
||||||
|
set_as_default: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update local state
|
||||||
|
setPrompts(prev =>
|
||||||
|
prev.map(p =>
|
||||||
|
p.prompt_id === editingPrompt.prompt_id ? response : p
|
||||||
|
)
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Create new prompt
|
||||||
|
const response = await client.prompts.create({
|
||||||
|
prompt: formData.prompt,
|
||||||
|
variables: formData.variables,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add to local state
|
||||||
|
setPrompts(prev => [response, ...prev]);
|
||||||
|
}
|
||||||
|
|
||||||
|
setShowPromptModal(false);
|
||||||
|
setEditingPrompt(undefined);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to save prompt:", err);
|
||||||
|
|
||||||
|
// Extract specific error message from API response
|
||||||
|
const error = err as Error & {
|
||||||
|
message?: string;
|
||||||
|
detail?: { errors?: Array<{ msg?: string }> };
|
||||||
|
};
|
||||||
|
|
||||||
|
// Try to parse JSON from error message if it's a string
|
||||||
|
let parsedError = error;
|
||||||
|
if (typeof error?.message === "string" && error.message.includes("{")) {
|
||||||
|
try {
|
||||||
|
const jsonMatch = error.message.match(/\d+\s+(.+)/);
|
||||||
|
if (jsonMatch) {
|
||||||
|
parsedError = JSON.parse(jsonMatch[1]);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// If parsing fails, use original error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to get the specific validation error message
|
||||||
|
const validationError = parsedError?.detail?.errors?.[0]?.msg;
|
||||||
|
if (validationError) {
|
||||||
|
// Clean up validation error messages (remove "Value error, " prefix if present)
|
||||||
|
const cleanMessage = validationError.replace(/^Value error,\s*/i, "");
|
||||||
|
setModalError(cleanMessage);
|
||||||
|
} else {
|
||||||
|
// For other errors, format them nicely with line breaks
|
||||||
|
const statusMatch = error?.message?.match(/(\d+)\s+(.+)/);
|
||||||
|
if (statusMatch) {
|
||||||
|
const statusCode = statusMatch[1];
|
||||||
|
const response = statusMatch[2];
|
||||||
|
setModalError(
|
||||||
|
`Failed to save prompt: Status Code ${statusCode}\n\nResponse: ${response}`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
const message = error?.message || error?.detail || "Unknown error";
|
||||||
|
setModalError(`Failed to save prompt: ${message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEditPrompt = (prompt: Prompt) => {
|
||||||
|
setEditingPrompt(prompt);
|
||||||
|
setShowPromptModal(true);
|
||||||
|
setModalError(null); // Clear any previous modal errors
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeletePrompt = async (promptId: string) => {
|
||||||
|
try {
|
||||||
|
setError(null);
|
||||||
|
await client.prompts.delete(promptId);
|
||||||
|
setPrompts(prev => prev.filter(p => p.prompt_id !== promptId));
|
||||||
|
|
||||||
|
// If we're deleting the currently editing prompt, close the modal
|
||||||
|
if (editingPrompt && editingPrompt.prompt_id === promptId) {
|
||||||
|
setShowPromptModal(false);
|
||||||
|
setEditingPrompt(undefined);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to delete prompt:", err);
|
||||||
|
setError("Failed to delete prompt");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreateNew = () => {
|
||||||
|
setEditingPrompt(undefined);
|
||||||
|
setShowPromptModal(true);
|
||||||
|
setModalError(null); // Clear any previous modal errors
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
setShowPromptModal(false);
|
||||||
|
setEditingPrompt(undefined);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderContent = () => {
|
||||||
|
if (loading) {
|
||||||
|
return <div className="text-muted-foreground">Loading prompts...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return <div className="text-destructive">Error: {error}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!prompts || prompts.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<p className="text-muted-foreground mb-4">No prompts found.</p>
|
||||||
|
<Button onClick={handleCreateNew}>
|
||||||
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
|
Create Your First Prompt
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PromptList
|
||||||
|
prompts={prompts}
|
||||||
|
onEdit={handleEditPrompt}
|
||||||
|
onDelete={handleDeletePrompt}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h1 className="text-2xl font-semibold">Prompts</h1>
|
||||||
|
<Button onClick={handleCreateNew} disabled={loading}>
|
||||||
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
|
New Prompt
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{renderContent()}
|
||||||
|
|
||||||
|
{/* Create/Edit Prompt Modal */}
|
||||||
|
{showPromptModal && (
|
||||||
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||||
|
<div className="bg-background border rounded-lg shadow-lg max-w-4xl w-full mx-4 max-h-[90vh] overflow-hidden">
|
||||||
|
<div className="p-6 border-b">
|
||||||
|
<h2 className="text-2xl font-bold">
|
||||||
|
{editingPrompt ? "Edit Prompt" : "Create New Prompt"}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<div className="p-6 overflow-y-auto max-h-[calc(90vh-120px)]">
|
||||||
|
<PromptEditor
|
||||||
|
prompt={editingPrompt}
|
||||||
|
onSave={handleSavePrompt}
|
||||||
|
onCancel={handleCancel}
|
||||||
|
onDelete={handleDeletePrompt}
|
||||||
|
error={modalError}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
16
src/llama_stack/ui/components/prompts/types.ts
Normal file
16
src/llama_stack/ui/components/prompts/types.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
export interface Prompt {
|
||||||
|
prompt_id: string;
|
||||||
|
prompt: string | null;
|
||||||
|
version: number;
|
||||||
|
variables: string[];
|
||||||
|
is_default: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PromptFormData {
|
||||||
|
prompt: string;
|
||||||
|
variables: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PromptFilters {
|
||||||
|
searchTerm?: string;
|
||||||
|
}
|
||||||
36
src/llama_stack/ui/components/ui/badge.tsx
Normal file
36
src/llama_stack/ui/components/ui/badge.tsx
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
import * as React from "react";
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const badgeVariants = cva(
|
||||||
|
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default:
|
||||||
|
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
|
||||||
|
secondary:
|
||||||
|
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||||
|
destructive:
|
||||||
|
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
|
||||||
|
outline: "text-foreground",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export interface BadgeProps
|
||||||
|
extends React.HTMLAttributes<HTMLDivElement>,
|
||||||
|
VariantProps<typeof badgeVariants> {}
|
||||||
|
|
||||||
|
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||||
|
return (
|
||||||
|
<div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Badge, badgeVariants };
|
||||||
24
src/llama_stack/ui/components/ui/label.tsx
Normal file
24
src/llama_stack/ui/components/ui/label.tsx
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
import * as React from "react";
|
||||||
|
import * as LabelPrimitive from "@radix-ui/react-label";
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const labelVariants = cva(
|
||||||
|
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||||
|
);
|
||||||
|
|
||||||
|
const Label = React.forwardRef<
|
||||||
|
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
|
||||||
|
VariantProps<typeof labelVariants>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<LabelPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn(labelVariants(), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
Label.displayName = LabelPrimitive.Root.displayName;
|
||||||
|
|
||||||
|
export { Label };
|
||||||
53
src/llama_stack/ui/components/ui/tabs.tsx
Normal file
53
src/llama_stack/ui/components/ui/tabs.tsx
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
import * as React from "react";
|
||||||
|
import * as TabsPrimitive from "@radix-ui/react-tabs";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const Tabs = TabsPrimitive.Root;
|
||||||
|
|
||||||
|
const TabsList = React.forwardRef<
|
||||||
|
React.ElementRef<typeof TabsPrimitive.List>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<TabsPrimitive.List
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
TabsList.displayName = TabsPrimitive.List.displayName;
|
||||||
|
|
||||||
|
const TabsTrigger = React.forwardRef<
|
||||||
|
React.ElementRef<typeof TabsPrimitive.Trigger>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<TabsPrimitive.Trigger
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
|
||||||
|
|
||||||
|
const TabsContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof TabsPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<TabsPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
TabsContent.displayName = TabsPrimitive.Content.displayName;
|
||||||
|
|
||||||
|
export { Tabs, TabsList, TabsTrigger, TabsContent };
|
||||||
23
src/llama_stack/ui/components/ui/textarea.tsx
Normal file
23
src/llama_stack/ui/components/ui/textarea.tsx
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
export type TextareaProps = React.TextareaHTMLAttributes<HTMLTextAreaElement>;
|
||||||
|
|
||||||
|
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||||
|
({ className, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<textarea
|
||||||
|
className={cn(
|
||||||
|
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
Textarea.displayName = "Textarea";
|
||||||
|
|
||||||
|
export { Textarea };
|
||||||
62
src/llama_stack/ui/package-lock.json
generated
62
src/llama_stack/ui/package-lock.json
generated
|
|
@ -11,14 +11,16 @@
|
||||||
"@radix-ui/react-collapsible": "^1.1.12",
|
"@radix-ui/react-collapsible": "^1.1.12",
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
|
"@radix-ui/react-label": "^2.1.7",
|
||||||
"@radix-ui/react-select": "^2.2.6",
|
"@radix-ui/react-select": "^2.2.6",
|
||||||
"@radix-ui/react-separator": "^1.1.7",
|
"@radix-ui/react-separator": "^1.1.7",
|
||||||
"@radix-ui/react-slot": "^1.2.3",
|
"@radix-ui/react-slot": "^1.2.3",
|
||||||
|
"@radix-ui/react-tabs": "^1.1.13",
|
||||||
"@radix-ui/react-tooltip": "^1.2.8",
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"framer-motion": "^12.23.24",
|
"framer-motion": "^12.23.24",
|
||||||
"llama-stack-client": "^0.3.0",
|
"llama-stack-client": "github:llamastack/llama-stack-client-typescript",
|
||||||
"lucide-react": "^0.545.0",
|
"lucide-react": "^0.545.0",
|
||||||
"next": "15.5.4",
|
"next": "15.5.4",
|
||||||
"next-auth": "^4.24.11",
|
"next-auth": "^4.24.11",
|
||||||
|
|
@ -2597,6 +2599,29 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@radix-ui/react-label": {
|
||||||
|
"version": "2.1.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.7.tgz",
|
||||||
|
"integrity": "sha512-YT1GqPSL8kJn20djelMX7/cTRp/Y9w5IZHvfxQTVHrOqa2yMl7i/UfMqKRU5V7mEyKTrUVgJXhNQPVCG8PBLoQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-primitive": "2.1.3"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-dom": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@radix-ui/react-menu": {
|
"node_modules/@radix-ui/react-menu": {
|
||||||
"version": "2.1.16",
|
"version": "2.1.16",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.16.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.16.tgz",
|
||||||
|
|
@ -2855,6 +2880,36 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@radix-ui/react-tabs": {
|
||||||
|
"version": "1.1.13",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.13.tgz",
|
||||||
|
"integrity": "sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/primitive": "1.1.3",
|
||||||
|
"@radix-ui/react-context": "1.1.2",
|
||||||
|
"@radix-ui/react-direction": "1.1.1",
|
||||||
|
"@radix-ui/react-id": "1.1.1",
|
||||||
|
"@radix-ui/react-presence": "1.1.5",
|
||||||
|
"@radix-ui/react-primitive": "2.1.3",
|
||||||
|
"@radix-ui/react-roving-focus": "1.1.11",
|
||||||
|
"@radix-ui/react-use-controllable-state": "1.2.2"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-dom": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@radix-ui/react-tooltip": {
|
"node_modules/@radix-ui/react-tooltip": {
|
||||||
"version": "1.2.8",
|
"version": "1.2.8",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.8.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.8.tgz",
|
||||||
|
|
@ -9629,9 +9684,8 @@
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/llama-stack-client": {
|
"node_modules/llama-stack-client": {
|
||||||
"version": "0.3.0",
|
"version": "0.4.0-alpha.1",
|
||||||
"resolved": "https://registry.npmjs.org/llama-stack-client/-/llama-stack-client-0.3.0.tgz",
|
"resolved": "git+ssh://git@github.com/llamastack/llama-stack-client-typescript.git#78de4862c4b7d77939ac210fa9f9bde77a2c5c5f",
|
||||||
"integrity": "sha512-76K/t1doaGmlBbDxCADaral9Vccvys9P8pqAMIhwBhMAqWudCEORrMMhUSg+pjhamWmEKj3wa++d4zeOGbfN/w==",
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/node": "^18.11.18",
|
"@types/node": "^18.11.18",
|
||||||
|
|
|
||||||
|
|
@ -16,14 +16,16 @@
|
||||||
"@radix-ui/react-collapsible": "^1.1.12",
|
"@radix-ui/react-collapsible": "^1.1.12",
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
|
"@radix-ui/react-label": "^2.1.7",
|
||||||
"@radix-ui/react-select": "^2.2.6",
|
"@radix-ui/react-select": "^2.2.6",
|
||||||
"@radix-ui/react-separator": "^1.1.7",
|
"@radix-ui/react-separator": "^1.1.7",
|
||||||
"@radix-ui/react-slot": "^1.2.3",
|
"@radix-ui/react-slot": "^1.2.3",
|
||||||
|
"@radix-ui/react-tabs": "^1.1.13",
|
||||||
"@radix-ui/react-tooltip": "^1.2.8",
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"framer-motion": "^12.23.24",
|
"framer-motion": "^12.23.24",
|
||||||
"llama-stack-client": "^0.3.0",
|
"llama-stack-client": "github:llamastack/llama-stack-client-typescript",
|
||||||
"lucide-react": "^0.545.0",
|
"lucide-react": "^0.545.0",
|
||||||
"next": "15.5.4",
|
"next": "15.5.4",
|
||||||
"next-auth": "^4.24.11",
|
"next-auth": "^4.24.11",
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue