first commit

This commit is contained in:
Beyhan Oğur
2026-04-26 21:52:23 +03:00
commit 880f412e2c
2662 changed files with 866266 additions and 0 deletions

View File

@@ -0,0 +1,71 @@
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alertDialog";
import { usePromptContext } from "../context";
export function DeleteFolderDialog() {
const { deleteFolderDialog, setDeleteFolderDialog, isDeletingFolder, handleDeleteFolder } = usePromptContext();
return (
<AlertDialog open={deleteFolderDialog.open}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Folder</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete &quot;{deleteFolderDialog.folder?.name}&quot;? This will also delete all prompts, versions, and
sessions in this folder. This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel
data-testid="delete-folder-cancel"
onClick={() => setDeleteFolderDialog({ open: false })}
disabled={isDeletingFolder}
>
Cancel
</AlertDialogCancel>
<AlertDialogAction data-testid="delete-folder-confirm" onClick={handleDeleteFolder} disabled={isDeletingFolder}>
{isDeletingFolder ? "Deleting..." : "Delete"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}
export function DeletePromptDialog() {
const { deletePromptDialog, setDeletePromptDialog, isDeletingPrompt, handleDeletePrompt } = usePromptContext();
return (
<AlertDialog open={deletePromptDialog.open}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Prompt</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete &quot;{deletePromptDialog.prompt?.name}&quot;? This will also delete all versions and sessions.
This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel
data-testid="delete-prompt-cancel"
onClick={() => setDeletePromptDialog({ open: false })}
disabled={isDeletingPrompt}
>
Cancel
</AlertDialogCancel>
<AlertDialogAction data-testid="delete-prompt-confirm" onClick={handleDeletePrompt} disabled={isDeletingPrompt}>
{isDeletingPrompt ? "Deleting..." : "Delete"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}

View File

@@ -0,0 +1,92 @@
import {
Combobox,
ComboboxContent,
ComboboxGroup,
ComboboxInput,
ComboboxItem,
ComboboxLabel,
ComboboxList,
ComboboxSeparator,
} from "@/components/ui/combobox";
import { Label } from "@/components/ui/label";
import type { DBKey, VirtualKey } from "@/lib/types/governance";
import { useCallback, useMemo, useState } from "react";
export function ApiKeySelectorView({
providerKeys,
virtualKeys,
value,
onValueChange,
disabled,
placeholder,
}: {
providerKeys: DBKey[];
virtualKeys: VirtualKey[];
value: string;
onValueChange: (v: string | null) => void;
disabled?: boolean;
placeholder?: string;
}) {
const [query, setQuery] = useState("");
const allOptions = useMemo(() => {
const apiKeyOpts = providerKeys.map((k) => ({ label: k.name, value: k.key_id, group: "api" as const }));
const vkOpts = virtualKeys.map((vk) => ({ label: vk.name, value: vk.id, group: "virtual" as const }));
return [{ label: "Auto (default)", value: "__auto__", group: "api" as const }, ...apiKeyOpts, ...vkOpts];
}, [providerKeys, virtualKeys]);
const filtered = useMemo(() => {
if (!query) return allOptions;
const q = query.toLowerCase();
return allOptions.filter((o) => o.label.toLowerCase().includes(q));
}, [allOptions, query]);
const filteredApiKeys = useMemo(() => filtered.filter((o) => o.group === "api"), [filtered]);
const filteredVirtualKeys = useMemo(() => filtered.filter((o) => o.group === "virtual"), [filtered]);
const getLabel = useCallback((val: string | null) => allOptions.find((o) => o.value === val)?.label ?? val ?? "", [allOptions]);
return (
<div className="flex flex-col gap-2">
<Label className="text-muted-foreground text-xs font-medium uppercase">Virtual key/ API Key</Label>
<Combobox
value={value}
onValueChange={(v) => onValueChange(v)}
onOpenChange={(open) => {
if (open) setQuery("");
}}
onInputValueChange={(v) => setQuery(v)}
filter={null}
itemToStringLabel={getLabel}
>
<ComboboxInput placeholder={placeholder ?? "Select API key"} showClear={value !== "__auto__"} showTrigger disabled={disabled} />
<ComboboxContent>
<ComboboxList>
{filteredApiKeys.length > 0 && (
<ComboboxGroup>
<ComboboxLabel>API Keys</ComboboxLabel>
{filteredApiKeys.map((o) => (
<ComboboxItem key={o.value} value={o.value}>
{o.label}
</ComboboxItem>
))}
</ComboboxGroup>
)}
{filteredApiKeys.length > 0 && filteredVirtualKeys.length > 0 && <ComboboxSeparator />}
{filteredVirtualKeys.length > 0 && (
<ComboboxGroup>
<ComboboxLabel>Virtual Keys</ComboboxLabel>
{filteredVirtualKeys.map((o) => (
<ComboboxItem key={o.value} value={o.value}>
{o.label}
</ComboboxItem>
))}
</ComboboxGroup>
)}
{filtered.length === 0 && <div className="text-muted-foreground py-6 text-center text-sm">No results found.</div>}
</ComboboxList>
</ComboboxContent>
</Combobox>
</div>
);
}

View File

@@ -0,0 +1,73 @@
import { Button } from "@/components/ui/button";
import { ArrowUpRight, SquareTerminal } from "lucide-react";
import { usePromptContext } from "../context";
export function EmptyState() {
const { setPromptSheet, canCreate } = usePromptContext();
return (
<div className="text-muted-foreground flex h-full items-center justify-center">
<div className="text-center">
<p className="text-lg font-medium">No prompt selected</p>
<p className="text-sm">
{canCreate ? (
<>
Select a prompt from the sidebar or{" "}
<Button
variant="link"
className="h-auto p-0 text-sm"
data-testid="empty-state-create-prompt-link"
onClick={() => setPromptSheet({ open: true })}
>
create a new one
</Button>
</>
) : (
"Select a prompt from the sidebar"
)}
</p>
</div>
</div>
);
}
export function PromptsEmptyState() {
const { setPromptSheet, canCreate } = usePromptContext();
return (
<div className="flex min-h-[80vh] w-full flex-col items-center justify-center gap-4 py-16 text-center">
<div className="text-muted-foreground">
<SquareTerminal className="h-[5.5rem] w-[5.5rem]" strokeWidth={1} />
</div>
<div className="flex flex-col gap-1">
<h1 className="text-muted-foreground text-xl font-medium">Build, test, and version your prompts</h1>
<div className="text-muted-foreground mx-auto mt-2 max-w-[600px] text-sm font-normal">
{canCreate
? "Create prompts, test them with different models and parameters in the playground, and version your changes for deployment."
: "View prompts and test them with different models and parameters in the playground."}
</div>
<div className="mx-auto mt-6 flex flex-row flex-wrap items-center justify-center gap-2">
<Button
variant="outline"
aria-label="Read more about prompt repository (opens in new tab)"
data-testid="empty-state-read-more"
onClick={() => {
window.open(`https://docs.getbifrost.ai/features/prompt-repository?utm_source=bfd`, "_blank", "noopener,noreferrer");
}}
>
Read more <ArrowUpRight className="text-muted-foreground h-3 w-3" />
</Button>
{canCreate && (
<Button
aria-label="Create your first prompt"
data-testid="empty-state-create-prompt"
onClick={() => setPromptSheet({ open: true })}
>
Create Prompt
</Button>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,192 @@
import { Textarea } from "@/components/ui/textarea";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { Message, SerializedMessage } from "@/lib/message";
import { InfoIcon, PencilIcon, XIcon } from "lucide-react";
import { Markdown } from "@/components/ui/markdown";
import { useEffect, useMemo, useRef, useState } from "react";
import MessageRoleSwitcher from "./messageRoleSwitcher";
import { isJson } from "@/lib/utils/validation";
import { CodeEditor } from "@/components/ui/codeEditor";
/**
* Renders the assistant message UI including role switcher, usage tooltip, edit/delete controls, and editable or view-only content.
*
* The component allows inline editing of plain text or JSON content (JSON edits are buffered and committed on blur), toggling role, and removing the message. Clicking outside the component exits edit mode.
*
* @param message - The message model to display and edit; updates are emitted via `onChange` as the message's serialized form.
* @param disabled - When true, disables editing, role changes, and delete action.
* @param isStreaming - When true, shows streaming state (loading indicator) and prevents entering edit mode.
* @param onChange - Called when the message is modified; receives the message's serialized representation.
* @param onRemove - Optional callback invoked when the delete action is triggered.
* @returns The rendered assistant message element.
*/
export function AssistantMessageView({
message,
disabled,
isStreaming,
onChange,
onRemove,
}: {
message: Message;
disabled?: boolean;
isStreaming?: boolean;
onChange: (serialized: SerializedMessage) => void;
onRemove?: () => void;
}) {
const [editMode, setEditMode] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const content = message.content;
const isEmpty = !content;
const usage = message.usage;
const jsonBufferRef = useRef<string | null>(null);
const contentIsJson = useMemo(() => !isEmpty && !isStreaming && isJson(content), [content, isEmpty, isStreaming]);
const formattedJson = useMemo(() => {
if (!contentIsJson) return "";
try {
return JSON.stringify(JSON.parse(content), null, 2);
} catch {
return content;
}
}, [content, contentIsJson]);
const flushJsonBuffer = () => {
if (jsonBufferRef.current !== null) {
const clone = message.clone();
clone.content = jsonBufferRef.current;
onChange(clone.serialized);
jsonBufferRef.current = null;
}
};
useEffect(() => {
const handleClick = (e: MouseEvent) => {
if (!containerRef.current?.contains(e.target as Node)) {
setEditMode(false);
}
};
document.addEventListener("mousedown", handleClick);
return () => document.removeEventListener("mousedown", handleClick);
}, []);
const handleRoleChange = (role: string) => {
const clone = message.clone();
clone.role = role as any;
onChange(clone.serialized);
};
return (
<div
className="group hover:border-border focus-within:border-border rounded-sm border border-transparent px-3 py-2 transition-colors"
ref={containerRef}
>
<div className="mb-1 flex items-center">
<MessageRoleSwitcher role={message.role ?? ""} disabled={disabled} onRoleChange={handleRoleChange} />
<div className="ml-auto flex h-5 items-center gap-0.5">
{usage && (
<Tooltip>
<TooltipTrigger className="hover:bg-muted focus:bg-muted rounded-sm p-1 focus:opacity-100">
<InfoIcon className="text-muted-foreground hover:text-foreground size-3 shrink-0 cursor-pointer opacity-0 transition-opacity group-focus-within:opacity-100 group-hover:opacity-100" />
</TooltipTrigger>
<TooltipContent side="bottom">
<div className="flex flex-col gap-0.5 text-xs tabular-nums">
<span>
<span className="inline-block w-12">Input:</span> {usage.prompt_tokens} tokens
</span>
<span>
<span className="inline-block w-12">Output:</span> {usage.completion_tokens} tokens
</span>
<span>
<span className="inline-block w-12">Total:</span> {usage.total_tokens} tokens
</span>
</div>
</TooltipContent>
</Tooltip>
)}
{!disabled && !isStreaming && (
<button
type="button"
aria-label="Edit message"
data-testid="assistant-msg-edit"
onClick={() => setEditMode(true)}
className="hover:bg-muted focus:bg-muted rounded-sm p-1 opacity-0 transition-opacity group-focus-within:opacity-100 group-hover:opacity-100 focus:opacity-100"
>
<PencilIcon className="text-muted-foreground hover:text-foreground size-3 shrink-0 cursor-pointer" />
</button>
)}
{!disabled && onRemove && (
<button
type="button"
aria-label="Delete message"
data-testid="assistant-msg-delete"
onClick={onRemove}
className="hover:bg-muted focus:bg-muted rounded-sm p-1 opacity-0 transition-opacity group-focus-within:opacity-100 group-hover:opacity-100 focus:opacity-100"
>
<XIcon className="text-muted-foreground hover:text-foreground size-3 shrink-0 cursor-pointer" />
</button>
)}
</div>
</div>
<div>
{isStreaming && isEmpty ? (
<div className="flex items-center gap-1 py-1">
<span className="bg-muted-foreground h-1.5 w-1.5 animate-bounce rounded-full opacity-60" style={{ animationDelay: "0ms" }} />
<span className="bg-muted-foreground h-1.5 w-1.5 animate-bounce rounded-full opacity-60" style={{ animationDelay: "150ms" }} />
<span className="bg-muted-foreground h-1.5 w-1.5 animate-bounce rounded-full opacity-60" style={{ animationDelay: "300ms" }} />
</div>
) : editMode ? (
<Textarea
autoFocus
value={content}
className="text-muted-foreground min-h-[20px] resize-none rounded-none border-0 bg-transparent p-0 text-sm shadow-none focus-visible:ring-0 focus-visible:ring-offset-0 dark:bg-transparent"
disabled={disabled}
onChange={(e) => {
const clone = message.clone();
clone.content = e.target.value;
onChange(clone.serialized);
}}
onFocus={(e) => {
const target = e.target;
requestAnimationFrame(() => {
target.selectionStart = target.value.length;
target.selectionEnd = target.value.length;
});
}}
onBlur={() => {
if (content.trim().length > 0) setEditMode(false);
}}
/>
) : isEmpty ? (
<div className="text-muted-foreground min-h-[20px] text-sm italic">Enter assistant message...</div>
) : contentIsJson ? (
<CodeEditor
wrap
code={formattedJson}
lang="json"
readonly={disabled}
autoResize
onChange={(value) => {
jsonBufferRef.current = value ?? "";
}}
onBlur={flushJsonBuffer}
options={{
showIndentLines: false,
disableHover: true,
}}
/>
) : (
<div
className={!disabled && !isStreaming ? "cursor-text" : undefined}
onClick={(e) => {
if (disabled || isStreaming || editMode) return;
if ((e.target as HTMLElement).closest("button, a, [role='button']")) return;
setEditMode(true);
}}
>
<Markdown content={content} isStreaming={isStreaming} className="text-muted-foreground" />
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,144 @@
import { MessageContent } from "@/lib/message";
import { Mic, FileIcon, XIcon } from "lucide-react";
/**
* Renders a compact badge for a single attachment with an inline remove control.
*
* Displays different visuals based on the attachment type:
* - image_url with a valid URL: a thumbnail and the label "Image"
* - input_audio: a microphone icon and the audio format in uppercase or "Audio"
* - other: a file icon and the filename or "File"
*
* @param attachment - The attachment to display; its `type` determines the badge content.
* @param onRemove - Callback invoked when the badge's remove button is clicked.
* @returns The rendered attachment badge element.
*/
export function AttachmentBadge({ attachment, onRemove }: { attachment: MessageContent; onRemove: () => void }) {
const isImage = attachment.type === "image_url";
const isAudio = attachment.type === "input_audio";
return (
<div className="group/att bg-muted/50 relative flex items-center gap-1.5 rounded-sm border px-2 py-1 text-xs">
{isImage && attachment.image_url?.url ? (
<>
<img src={attachment.image_url.url} alt="attachment" className="h-8 w-8 rounded object-cover" />
<span className="text-muted-foreground max-w-[100px] truncate">Image</span>
</>
) : isAudio ? (
<>
<Mic className="text-muted-foreground size-3" />
<span className="text-muted-foreground max-w-[100px] truncate">{attachment.input_audio?.format?.toUpperCase() || "Audio"}</span>
</>
) : (
<>
<FileIcon className="text-muted-foreground size-3" />
<span className="text-muted-foreground max-w-[120px] truncate">{attachment.file?.filename || "File"}</span>
</>
)}
<button
onClick={onRemove}
className="text-muted-foreground hover:bg-card hover:text-destructive ml-0.5 cursor-pointer rounded-full p-0.5"
type="button"
>
<XIcon className="size-3" />
</button>
</div>
);
}
/**
* Renders a compact list of attachment previews (images, audio controls, or file rows).
*
* Renders each attachment according to its type:
* - image_url with a URL: an image thumbnail
* - input_audio: an HTML audio control built from base64 data
* - file: a row with a file icon, filename, and optional file type
*
* When `editable` is true and `onRemoveAttachment` is provided, a remove button is shown for each attachment and invokes the callback with the attachment's index when clicked.
*
* @param attachments - The attachments to render.
* @param editable - If true, show per-attachment remove controls.
* @param onRemoveAttachment - Callback invoked with the attachment index when a remove control is clicked.
* @returns A JSX element containing the rendered attachments, or `null` when `attachments` is empty.
*/
export function AttachmentDisplay({
attachments,
editable,
onRemoveAttachment,
}: {
attachments: MessageContent[];
editable?: boolean;
onRemoveAttachment?: (index: number) => void;
}) {
if (attachments.length === 0) return null;
return (
<div className="mt-2 flex flex-wrap gap-2">
{attachments.map((att, i) => {
if (att.type === "image_url" && att.image_url?.url) {
return (
<div key={i} className="group/att relative max-w-full">
{/* eslint-disable-next-line jsx-a11y/alt-text */}
<img
src={att.image_url.url}
alt="attached image"
className="max-h-48 max-w-full rounded-sm border object-contain sm:max-w-xs"
/>
{editable && onRemoveAttachment && (
<button
onClick={() => onRemoveAttachment(i)}
className="bg-background/80 text-muted-foreground hover:bg-card hover:text-destructive absolute -top-1.5 -right-1.5 cursor-pointer rounded-full border p-0.5 opacity-0 transition-opacity group-hover/att:opacity-100"
>
<XIcon className="size-3" />
</button>
)}
</div>
);
}
if (att.type === "input_audio") {
const format = att.input_audio?.format || "wav";
const dataUrl = `data:audio/${format};base64,${att.input_audio?.data || ""}`;
return (
<div key={i} className="group/att bg-muted/30 relative flex w-full items-center gap-2 rounded-sm border px-3 py-2">
<audio controls className="h-8 w-full min-w-0 grow">
<source src={dataUrl} type={`audio/${format}`} />
</audio>
{editable && onRemoveAttachment && (
<button
onClick={() => onRemoveAttachment(i)}
className="bg-background/80 text-muted-foreground hover:bg-card hover:text-destructive absolute -top-1.5 -right-1.5 cursor-pointer rounded-full border p-0.5 opacity-0 transition-opacity group-hover/att:opacity-100"
>
<XIcon className="size-3" />
</button>
)}
</div>
);
}
if (att.type === "file") {
return (
<div
key={i}
className="group/att bg-muted/30 text-muted-foreground relative flex max-w-full items-center gap-2 rounded-sm border px-3 py-1.5 text-sm"
>
<FileIcon className="size-3 shrink-0" />
<span className="min-w-0 truncate">{att.file?.filename || "File"}</span>
{att.file?.file_type && <span className="shrink-0 text-xs opacity-60">{att.file.file_type}</span>}
{editable && onRemoveAttachment && (
<button
onClick={() => onRemoveAttachment(i)}
className="bg-background/80 text-muted-foreground hover:bg-card hover:text-destructive absolute -top-1.5 -right-1.5 rounded-full border p-0.5 opacity-0 transition-opacity group-hover/att:opacity-100"
>
<XIcon className="size-3" />
</button>
)}
</div>
);
}
return null;
})}
</div>
);
}

View File

@@ -0,0 +1,39 @@
import { Message } from "@/lib/message";
import { AlertCircle, XIcon } from "lucide-react";
/**
* Render a styled error message block with an optional delete control.
*
* @param message - The message object whose `content` is displayed inside the error block.
* @param disabled - When true, the remove button is not rendered.
* @param onRemove - Callback invoked when the delete button is clicked.
* @returns The React element that displays the error message view.
*/
export default function ErrorMessageView({ message, disabled, onRemove }: { message: Message; disabled?: boolean; onRemove?: () => void }) {
return (
<div className="group hover:border-destructive/30 focus-within:border-destructive/30 rounded-sm border border-transparent px-3 py-2 transition-colors">
<div className="mb-1 flex h-5 items-center">
<span className="text-destructive flex items-center gap-1 py-0.5 text-xs font-medium uppercase">
<AlertCircle className="size-3" />
Error
</span>
<div className="ml-auto">
{!disabled && onRemove && (
<button
type="button"
aria-label="Delete message"
data-testid="error-msg-delete"
onClick={onRemove}
className="hover:bg-muted focus:bg-muted rounded-sm p-1 opacity-0 transition-opacity group-focus-within:opacity-100 group-hover:opacity-100 focus:opacity-100"
>
<XIcon className="text-muted-foreground hover:text-foreground size-3 shrink-0 cursor-pointer" />
</button>
)}
</div>
</div>
<div className="bg-destructive/10 rounded-sm px-2.5 py-1.5">
<p className="text-muted-foreground text-sm whitespace-pre-wrap">{message.content}</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,54 @@
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdownMenu";
import { cn } from "@/lib/utils";
import { ChevronDown } from "lucide-react";
const AVAILABLE_ROLES = [
{ value: "system", label: "System" },
{ value: "user", label: "User" },
{ value: "assistant", label: "Assistant" },
{ value: "tool", label: "Tool" },
] as const;
/**
* Render a dropdown that lets the user switch the current message role.
*
* @param role - The currently selected role value shown in the trigger
* @param disabled - If true, disables interaction with the trigger
* @param onRoleChange - Callback invoked with the newly selected role value
* @param restrictedRoles - Optional list of role values that should be excluded from the menu
* @returns A JSX element rendering the role selection dropdown
*/
export default function MessageRoleSwitcher({
role,
disabled,
onRoleChange,
restrictedRoles,
}: {
role: string;
disabled?: boolean;
onRoleChange: (role: string) => void;
restrictedRoles?: (typeof AVAILABLE_ROLES)[number]["value"][];
}) {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild disabled={disabled}>
<button
className={cn(
"-ml-1.5 flex items-center gap-1 rounded-sm px-1.5 py-0.5 text-xs font-medium uppercase",
!disabled && "hover:bg-muted cursor-pointer",
)}
>
{role}
<ChevronDown className="size-3 opacity-0 transition-opacity group-focus-within:opacity-100 group-hover:opacity-100" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
{AVAILABLE_ROLES.filter((r) => r.value !== role && (!restrictedRoles || !restrictedRoles.includes(r.value))).map((option) => (
<DropdownMenuItem key={option.value} onSelect={() => onRoleChange(option.value)}>
{option.label.toUpperCase()}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@@ -0,0 +1,165 @@
import { Message, MessageType, SerializedMessage, extractVariablesFromMessages, mergeVariables } from "@/lib/message";
import { useCallback, useEffect, useRef } from "react";
import { usePromptContext } from "../../context";
import { SystemMessageView } from "./systemMessageView";
import { UserMessageView } from "./userMessageView";
import { AssistantMessageView } from "./assistantMessageView";
import ToolResultMessageView from "./toolCallResultView";
import ToolCallMessageView from "./toolCallView";
import ErrorMessageView from "./errorMessageView";
/**
* Render and manage the chat messages list, mapping each message to its appropriate view, handling edits, removals, variable recomputation, and automatic scrolling during streaming.
*
* @returns A React element that renders the messages list and provides handlers for message changes, removals, tool submissions, and variable updates.
*/
export function MessagesView() {
const { messages, setMessages: onUpdateMessages, setVariables, isStreaming, supportsVision, handleSubmitToolResult } = usePromptContext();
const messagesEndRef = useRef<HTMLDivElement>(null);
const prevLengthRef = useRef(messages.length);
const prevLastIdRef = useRef(messages[messages.length - 1]?.id);
useEffect(() => {
const lastId = messages[messages.length - 1]?.id;
const grew = messages.length > prevLengthRef.current;
const lastChanged = lastId !== prevLastIdRef.current;
const shouldScroll = grew || (lastChanged && messages.length >= prevLengthRef.current);
prevLengthRef.current = messages.length;
prevLastIdRef.current = lastId;
if (shouldScroll) {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
}
}, [messages, isStreaming]);
const recomputeVariables = useCallback(
(msgs: Message[]) => {
const varNames = extractVariablesFromMessages(msgs);
setVariables((prev) => mergeVariables(prev, varNames));
},
[setVariables],
);
const handleMessageChange = useCallback(
(index: number, serialized: SerializedMessage) => {
const newMessages = [...messages];
newMessages[index] = Message.deserialize(serialized);
onUpdateMessages(newMessages);
recomputeVariables(newMessages);
},
[messages, onUpdateMessages, recomputeVariables],
);
const handleRemoveMessage = useCallback(
(index: number) => {
const newMessages = messages.filter((_, i) => i !== index);
const result = newMessages.length > 0 ? newMessages : [Message.system("")];
onUpdateMessages(result);
recomputeVariables(result);
},
[messages, onUpdateMessages, recomputeVariables],
);
const lastMessage = messages[messages.length - 1];
const isLastMessageStreaming = isStreaming && lastMessage?.type === MessageType.CompletionResult;
return (
<div className="space-y-1 px-1 py-4">
{messages.map((msg, index) => {
const isStreamingMsg = isLastMessageStreaming && index === messages.length - 1;
const canRemove = index > 0;
switch (msg.type) {
case MessageType.CompletionError:
return (
<ErrorMessageView
key={msg.id}
message={msg}
disabled={isStreaming}
onRemove={canRemove ? () => handleRemoveMessage(index) : undefined}
/>
);
case MessageType.ToolResult:
return (
<ToolResultMessageView
key={msg.id}
message={msg}
disabled={isStreaming}
onChange={(s) => handleMessageChange(index, s)}
onRemove={canRemove ? () => handleRemoveMessage(index) : undefined}
/>
);
case MessageType.CompletionResult:
if (msg.toolCalls) {
const respondedIds = new Set<string>();
for (let i = index + 1; i < messages.length; i++) {
const m = messages[i];
if (m.type === MessageType.ToolResult && m.toolCallId) {
respondedIds.add(m.toolCallId);
} else {
break;
}
}
return (
<ToolCallMessageView
key={msg.id}
message={msg}
disabled={isStreaming}
onChange={(s) => handleMessageChange(index, s)}
onRemove={canRemove ? () => handleRemoveMessage(index) : undefined}
onSubmitToolResult={(toolCallId, content) => handleSubmitToolResult(index, toolCallId, content)}
respondedToolCallIds={respondedIds}
/>
);
}
return (
<AssistantMessageView
key={msg.id}
message={msg}
disabled={isStreaming}
isStreaming={isStreamingMsg}
onChange={(s) => handleMessageChange(index, s)}
onRemove={canRemove ? () => handleRemoveMessage(index) : undefined}
/>
);
default: {
const role = msg.role;
if (role === "system") {
return (
<SystemMessageView
key={msg.id}
message={msg}
disabled={isStreaming}
onChange={(s) => handleMessageChange(index, s)}
onRemove={canRemove ? () => handleRemoveMessage(index) : undefined}
/>
);
}
if (role === "user") {
return (
<UserMessageView
key={msg.id}
message={msg}
disabled={isStreaming}
supportsVision={supportsVision}
onChange={(s) => handleMessageChange(index, s)}
onRemove={canRemove ? () => handleRemoveMessage(index) : undefined}
/>
);
}
return (
<AssistantMessageView
key={msg.id}
message={msg}
disabled={isStreaming}
isStreaming={isStreamingMsg}
onChange={(s) => handleMessageChange(index, s)}
onRemove={canRemove ? () => handleRemoveMessage(index) : undefined}
/>
);
}
}
})}
<div ref={messagesEndRef} />
</div>
);
}

View File

@@ -0,0 +1,202 @@
import { CodeEditor } from "@/components/ui/codeEditor";
import { RichTextarea } from "@/components/ui/custom/richTextarea";
import { Markdown } from "@/components/ui/markdown";
import { Message, SerializedMessage } from "@/lib/message";
import { JINJA_VAR_HIGHLIGHT_PATTERNS, JINJA_VAR_REGEX } from "@/lib/message/constant";
import { isJson } from "@/lib/utils/validation";
import { PencilIcon, XIcon } from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
import MessageRoleSwitcher from "./messageRoleSwitcher";
/**
* Renders an editable system message block that supports role switching, rich-text editing, JSON editing with buffered changes, Jinja variable highlighting, and optional removal.
*
* @param message - The message model to display and edit.
* @param disabled - When true, disables interactions and makes the view read-only.
* @param onChange - Called with the message's serialized representation when the message is modified (role or content).
* @param onRemove - Optional callback invoked when the message should be removed.
* @returns The rendered system message JSX element.
*/
export function SystemMessageView({
message,
disabled,
onChange,
onRemove,
}: {
message: Message;
disabled?: boolean;
onChange: (serialized: SerializedMessage) => void;
onRemove?: () => void;
}) {
const [editMode, setEditMode] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const messageRef = useRef(message);
messageRef.current = message;
const pendingCursorRef = useRef<number | null>(null);
const content = message.content;
const isEmpty = !content;
const hasVariables = JINJA_VAR_REGEX.test(content);
JINJA_VAR_REGEX.lastIndex = 0;
const jsonBufferRef = useRef<string | null>(null);
const contentIsJson = useMemo(() => !isEmpty && isJson(content), [content, isEmpty]);
const formattedJson = useMemo(() => {
if (!contentIsJson) return "";
try {
return JSON.stringify(JSON.parse(content), null, 2);
} catch {
return content;
}
}, [content, contentIsJson]);
const applyPendingJsonBuffer = (msg: Message): Message => {
if (jsonBufferRef.current !== null) {
const clone = msg.clone();
clone.content = jsonBufferRef.current;
jsonBufferRef.current = null;
return clone;
}
return msg;
};
const flushJsonBuffer = () => {
const updated = applyPendingJsonBuffer(messageRef.current);
if (updated !== messageRef.current) {
onChange(updated.serialized);
}
};
useEffect(() => {
const handleClick = (e: MouseEvent) => {
if (!containerRef.current?.contains(e.target as Node)) {
setEditMode(false);
}
};
document.addEventListener("mousedown", handleClick);
return () => document.removeEventListener("mousedown", handleClick);
}, []);
const handleRoleChange = (role: string) => {
const latest = applyPendingJsonBuffer(messageRef.current);
const clone = latest.clone();
clone.role = role as any;
onChange(clone.serialized);
};
const handleReadOnlyClick = (e: React.MouseEvent<HTMLTextAreaElement>) => {
if (disabled) return;
const target = e.target as HTMLTextAreaElement;
pendingCursorRef.current = target.selectionStart ?? 0;
setEditMode(true);
};
const handleEditFocus = (e: React.FocusEvent<HTMLTextAreaElement>) => {
const pos = pendingCursorRef.current;
pendingCursorRef.current = null;
const target = e.target;
requestAnimationFrame(() => {
const cursorPos = pos ?? target.value.length;
target.selectionStart = cursorPos;
target.selectionEnd = cursorPos;
});
};
return (
<div
className="group hover:border-border focus-within:border-border rounded-sm border border-transparent px-3 py-2 transition-colors"
ref={containerRef}
>
<div className="mb-1 flex items-center">
<MessageRoleSwitcher role={message.role ?? ""} disabled={disabled} onRoleChange={handleRoleChange} />
<div className="ml-auto flex h-5 items-center gap-0.5">
{!disabled && (
<button
type="button"
aria-label="Edit message"
data-testid="system-msg-edit"
onClick={() => setEditMode(true)}
className="hover:bg-muted focus:bg-muted rounded-sm p-1 opacity-0 transition-opacity group-focus-within:opacity-100 group-hover:opacity-100 focus:opacity-100"
>
<PencilIcon className="text-muted-foreground hover:text-foreground size-3 shrink-0 cursor-pointer" />
</button>
)}
{!disabled && onRemove && (
<button
type="button"
aria-label="Delete message"
data-testid="system-msg-delete"
onClick={onRemove}
className="hover:bg-muted focus:bg-muted rounded-sm p-1 opacity-0 transition-opacity group-focus-within:opacity-100 group-hover:opacity-100 focus:opacity-100"
>
<XIcon className="text-muted-foreground hover:text-foreground size-3 shrink-0 cursor-pointer" />
</button>
)}
</div>
</div>
<div
onClick={(e) => {
if (!disabled && !editMode && !(e.target as HTMLElement).closest("button, a, [role='button']")) setEditMode(true);
}}
className={!disabled && !editMode ? "cursor-text" : ""}
>
{editMode ? (
<RichTextarea
autoFocus
value={content}
className="text-muted-foreground min-h-[20px] resize-none rounded-none border-0 bg-transparent p-0 text-sm shadow-none focus-visible:ring-0 focus-visible:ring-offset-0 dark:bg-transparent"
textAreaClassName="rounded-none p-0 border-none"
disabled={disabled}
onChange={(e) => {
const clone = message.clone();
clone.content = e.target.value;
onChange(clone.serialized);
}}
onFocus={handleEditFocus}
onBlur={() => {
if (content.trim().length > 0) setEditMode(false);
}}
highlightPatterns={JINJA_VAR_HIGHLIGHT_PATTERNS}
/>
) : isEmpty ? (
<div className="text-muted-foreground min-h-[20px] text-sm italic">Enter system message...</div>
) : contentIsJson ? (
<CodeEditor
wrap
code={formattedJson}
lang="json"
readonly={disabled}
autoResize
onChange={(value) => {
jsonBufferRef.current = value ?? "";
}}
options={{
showIndentLines: false,
disableHover: true,
}}
onBlur={flushJsonBuffer}
/>
) : hasVariables ? (
<RichTextarea
readOnly
value={content}
className="text-muted-foreground min-h-[20px] resize-none rounded-none border-0 bg-transparent p-0 text-sm shadow-none focus-visible:ring-0 focus-visible:ring-offset-0 dark:bg-transparent"
textAreaClassName="rounded-none p-0 border-none cursor-text"
onClick={handleReadOnlyClick}
highlightPatterns={JINJA_VAR_HIGHLIGHT_PATTERNS}
/>
) : (
<div
className={!disabled ? "cursor-text" : undefined}
onClick={(e) => {
if (disabled || editMode) return;
if ((e.target as HTMLElement).closest("button, a, [role='button']")) return;
setEditMode(true);
}}
>
<Markdown content={content} className="text-muted-foreground" />
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,165 @@
import { Textarea } from "@/components/ui/textarea";
import { Message, MessageRole, SerializedMessage } from "@/lib/message";
import { isJson } from "@/lib/utils/validation";
import { CodeEditor } from "@/components/ui/codeEditor";
import { PencilIcon, XIcon } from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
import MessageRoleSwitcher from "./messageRoleSwitcher";
/**
* Renders an editable view for a tool result message that supports role switching, inline text editing, JSON-aware editing, and removal.
*
* The component presents the message role selector, optional tool call id, edit/delete actions, and a content area that:
* - shows a textarea for freeform editing when in edit mode,
* - shows a JSON-aware code editor (with edits buffered and flushed on blur) when the content is valid JSON,
* - shows a read-only monospaced display when content is plain text,
* - shows a placeholder when content is empty.
*
* @param message - The Message instance to display and edit; updates emitted via `onChange` are serialized from clones of this message.
* @param disabled - When true, disables interactive controls and makes editors read-only.
* @param onChange - Called with the message's serialized form whenever the message is modified (role, content edits, or flushed JSON buffer).
* @param onRemove - Optional callback invoked when the user requests deletion of the message.
*/
export default function ToolResultMessageView({
message,
disabled,
onChange,
onRemove,
}: {
message: Message;
disabled?: boolean;
onChange: (serialized: SerializedMessage) => void;
onRemove?: () => void;
}) {
const [editMode, setEditMode] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const messageRef = useRef(message);
messageRef.current = message;
const content = message.content;
const isEmpty = !content;
const jsonBufferRef = useRef<string | null>(null);
const contentIsJson = useMemo(() => !isEmpty && isJson(content), [content, isEmpty]);
const formattedJson = useMemo(() => {
if (!contentIsJson) return "";
try {
return JSON.stringify(JSON.parse(content), null, 2);
} catch {
return content;
}
}, [content, contentIsJson]);
const applyPendingJsonBuffer = (msg: Message): Message => {
if (jsonBufferRef.current !== null) {
const clone = msg.clone();
clone.content = jsonBufferRef.current;
jsonBufferRef.current = null;
return clone;
}
return msg;
};
const flushJsonBuffer = () => {
const updated = applyPendingJsonBuffer(messageRef.current);
if (updated !== messageRef.current) {
onChange(updated.serialized);
}
};
useEffect(() => {
const handleClick = (e: MouseEvent) => {
if (!containerRef.current?.contains(e.target as Node)) {
setEditMode(false);
}
};
document.addEventListener("mousedown", handleClick);
return () => document.removeEventListener("mousedown", handleClick);
}, []);
const handleRoleChange = (role: string) => {
const latest = applyPendingJsonBuffer(messageRef.current);
const clone = latest.clone();
clone.role = role as any;
onChange(clone.serialized);
};
return (
<div
className="group hover:border-border focus-within:border-border rounded-sm border border-transparent px-3 py-2 transition-colors"
ref={containerRef}
>
<div className="mb-1 flex items-center">
<MessageRoleSwitcher role={message.role ?? MessageRole.ASSISTANT} disabled={disabled} onRoleChange={handleRoleChange} />
<div className="ml-auto flex h-5 items-center gap-0.5">
{message.toolCallId && (
<span className="text-muted-foreground ml-4 max-w-[200px] truncate font-mono text-xs">{message.toolCallId}</span>
)}
{!disabled && (
<button
type="button"
aria-label="Edit message"
data-testid="tool-result-msg-edit"
onClick={() => setEditMode(true)}
className="hover:bg-muted focus:bg-muted rounded-sm p-1 opacity-0 transition-opacity group-focus-within:opacity-100 group-hover:opacity-100 focus:opacity-100"
>
<PencilIcon className="text-muted-foreground hover:text-foreground size-3 shrink-0 cursor-pointer" />
</button>
)}
{!disabled && onRemove && (
<button
type="button"
aria-label="Delete message"
data-testid="tool-result-msg-delete"
onClick={onRemove}
className="hover:bg-muted focus:bg-muted rounded-sm p-1 opacity-0 transition-opacity group-focus-within:opacity-100 group-hover:opacity-100 focus:opacity-100"
>
<XIcon className="text-muted-foreground hover:text-foreground size-3 shrink-0 cursor-pointer" />
</button>
)}
</div>
</div>
<div>
{editMode ? (
<Textarea
autoFocus
value={content}
className="text-muted-foreground min-h-[20px] resize-none rounded-none border-0 bg-transparent p-0 font-mono text-sm shadow-none focus-visible:ring-0 focus-visible:ring-offset-0 dark:bg-transparent"
disabled={disabled}
onChange={(e) => {
const clone = message.clone();
clone.content = e.target.value;
onChange(clone.serialized);
}}
onBlur={() => setEditMode(false)}
/>
) : isEmpty ? (
<div className="text-muted-foreground min-h-[20px] font-mono text-sm italic">Enter tool result...</div>
) : contentIsJson ? (
<CodeEditor
wrap
code={formattedJson}
lang="json"
readonly={disabled}
autoResize
onChange={(value) => {
jsonBufferRef.current = value ?? "";
}}
options={{
showIndentLines: false,
disableHover: true,
}}
onBlur={flushJsonBuffer}
/>
) : (
<div
className={!disabled ? "cursor-text" : undefined}
onClick={() => {
if (!disabled) setEditMode(true);
}}
>
<div className="text-muted-foreground min-h-[20px] font-mono text-sm whitespace-pre-wrap">{content}</div>
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,183 @@
import { Textarea } from "@/components/ui/textarea";
import { Button } from "@/components/ui/button";
import { Message, SerializedMessage } from "@/lib/message";
import { isJson } from "@/lib/utils/validation";
import { CodeEditor } from "@/components/ui/codeEditor";
import { Wrench, XIcon } from "lucide-react";
import { useRef, useState } from "react";
import MessageRoleSwitcher from "./messageRoleSwitcher";
/**
* Renders a UI for viewing and editing tool-call entries on a message, including optional argument editing and submitting tool responses.
*
* The component displays each tool call's name, id, and arguments (JSON arguments open in an editable code editor). JSON edits are buffered locally and only committed to `onChange` when the editor loses focus or when the message role changes. The component also exposes controls for switching the message role, deleting the message, and entering/submitting a response for individual tool calls.
*
* @param message - Message instance containing zero or more toolCalls to render; edits are serialized via `onChange`.
* @param disabled - When true, disables interactive controls and makes editors read-only.
* @param onChange - Called with the message's serialized form after committed edits (e.g., buffered JSON arguments flushed or role changed).
* @param onRemove - If provided, called when the delete button is clicked.
* @param onSubmitToolResult - If provided, called with (toolCallId, content) when a user submits a response for a tool call.
* @param respondedToolCallIds - Optional set of toolCall ids that have already received responses; tool calls in this set hide the response UI.
*
* @returns The rendered React element for the tool-call message view.
*/
export default function ToolCallMessageView({
message,
disabled,
onChange,
onRemove,
onSubmitToolResult,
respondedToolCallIds,
}: {
message: Message;
disabled?: boolean;
onChange: (serialized: SerializedMessage) => void;
onRemove?: () => void;
onSubmitToolResult?: (toolCallId: string, content: string) => void;
respondedToolCallIds?: Set<string>;
}) {
const toolCalls = message.toolCalls ?? [];
const [responses, setResponses] = useState<Record<string, string>>({});
const messageRef = useRef(message);
messageRef.current = message;
const jsonBufferRef = useRef<Record<string, string>>({});
const applyPendingJsonBuffers = (msg: Message): Message => {
const keys = Object.keys(jsonBufferRef.current);
if (keys.length === 0) return msg;
const clone = msg.clone();
for (const toolCallId of keys) {
const tc = clone.toolCalls?.find((t) => t.id === toolCallId);
if (tc) {
tc.function.arguments = jsonBufferRef.current[toolCallId];
}
}
jsonBufferRef.current = {};
return clone;
};
const flushJsonBuffer = (toolCallId: string) => {
if (jsonBufferRef.current[toolCallId] !== undefined) {
const clone = messageRef.current.clone();
const tc = clone.toolCalls?.find((t) => t.id === toolCallId);
if (tc) {
tc.function.arguments = jsonBufferRef.current[toolCallId];
onChange(clone.serialized);
}
delete jsonBufferRef.current[toolCallId];
}
};
const handleRoleChange = (role: string) => {
const latest = applyPendingJsonBuffers(messageRef.current);
const clone = latest.clone();
clone.role = role as any;
onChange(clone.serialized);
};
const handleResponseChange = (toolCallId: string, value: string) => {
setResponses((prev) => ({ ...prev, [toolCallId]: value }));
};
const handleSubmitResponse = (toolCallId: string) => {
const content = responses[toolCallId]?.trim();
if (!content || !onSubmitToolResult) return;
onSubmitToolResult(toolCallId, content);
setResponses((prev) => {
const next = { ...prev };
delete next[toolCallId];
return next;
});
};
return (
<div className="group hover:border-border focus-within:border-border rounded-sm border border-transparent px-3 py-2 transition-colors">
<div className="mb-1 flex items-center">
<MessageRoleSwitcher role={message.role ?? ""} disabled={disabled} onRoleChange={handleRoleChange} />
<div className="ml-auto h-5">
{!disabled && onRemove && (
<button
type="button"
aria-label="Delete message"
data-testid="tool-call-msg-delete"
onClick={onRemove}
className="hover:bg-muted focus:bg-muted rounded-sm p-1 opacity-0 transition-opacity group-focus-within:opacity-100 group-hover:opacity-100 focus:opacity-100"
>
<XIcon className="text-muted-foreground hover:text-foreground size-3 shrink-0 cursor-pointer" />
</button>
)}
</div>
</div>
<div className="space-y-2">
{toolCalls.map((tc) => {
const argsIsJson = isJson(tc.function.arguments);
let formattedArgs = tc.function.arguments;
if (argsIsJson) {
try {
formattedArgs = JSON.stringify(JSON.parse(tc.function.arguments), null, 2);
} catch {
// keep raw string
}
}
return (
<div key={tc.id} className="bg-muted/50 mt-2 rounded-sm border px-3 py-2">
<div className="flex items-center gap-2">
<Wrench className="text-muted-foreground size-3 shrink-0" />
<span className="mr-4 shrink-0 font-mono text-xs font-medium">{tc.function.name}</span>
<span className="text-muted-foreground ml-auto truncate font-mono text-[10px]">{tc.id}</span>
</div>
{formattedArgs &&
(argsIsJson ? (
<div className="mt-2">
<CodeEditor
wrap
code={formattedArgs}
lang="json"
readonly={disabled}
autoResize
onChange={(value) => {
jsonBufferRef.current[tc.id] = value ?? "";
}}
options={{
showIndentLines: false,
disableHover: true,
}}
onBlur={() => flushJsonBuffer(tc.id)}
/>
</div>
) : (
<pre className="text-muted-foreground bg-card mt-2 overflow-x-auto rounded p-2 text-xs leading-relaxed">
{formattedArgs}
</pre>
))}
{!disabled && onSubmitToolResult && !respondedToolCallIds?.has(tc.id) && (
<div className="mt-2 border-t pt-2">
<div className="text-muted-foreground mb-1 text-[10px] font-semibold tracking-wide uppercase">Response</div>
<div className="flex items-end gap-2">
<Textarea
placeholder="Enter tool response..."
value={responses[tc.id] ?? ""}
onChange={(e) => handleResponseChange(tc.id, e.target.value)}
data-testid="tool-call-response-textarea"
className="min-h-[36px] resize-none font-mono text-xs"
rows={2}
/>
<Button
variant="secondary"
size="sm"
data-testid="tool-call-response-submit"
disabled={!responses[tc.id]?.trim()}
onClick={() => handleSubmitResponse(tc.id)}
>
Submit
</Button>
</div>
</div>
)}
</div>
);
})}
</div>
</div>
);
}

View File

@@ -0,0 +1,322 @@
import { CodeEditor } from "@/components/ui/codeEditor";
import { RichTextarea } from "@/components/ui/custom/richTextarea";
import { Markdown } from "@/components/ui/markdown";
import { Message, SerializedMessage, type MessageContent } from "@/lib/message";
import { JINJA_VAR_HIGHLIGHT_PATTERNS, JINJA_VAR_REGEX } from "@/lib/message/constant";
import { isJson } from "@/lib/utils/validation";
import { Paperclip, PencilIcon, XIcon } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { fileToAttachment } from "../../utils/attachment";
import { AttachmentDisplay } from "./attachmentViews";
import MessageRoleSwitcher from "./messageRoleSwitcher";
/**
* Render an interactive user message block that supports viewing and editing content, role switching, file attachments (via picker or drag-and-drop), and special handling for JSON and Jinja-variable content.
*
* @param message - The message model to render and edit; its updates are emitted via `onChange`.
* @param disabled - When true, disables editing and attachment interactions.
* @param supportsVision - When true, enables attaching files (images, audio, documents) and drag-and-drop attachments.
* @param onChange - Called with the message's serialized form whenever the message is modified (content, role, or attachments).
* @param onRemove - Optional callback invoked when the message's delete action is triggered.
* @returns The JSX element that renders the user message view and its interactive controls.
*/
export function UserMessageView({
message,
disabled,
supportsVision,
onChange,
onRemove,
}: {
message: Message;
disabled?: boolean;
supportsVision?: boolean;
onChange: (serialized: SerializedMessage) => void;
onRemove?: () => void;
}) {
const [editMode, setEditMode] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const messageRef = useRef(message);
messageRef.current = message;
const pendingCursorRef = useRef<number | null>(null);
const content = message.content;
const isEmpty = !content;
const messageAttachments = message.attachments;
const canAttach = supportsVision && !disabled;
const hasVariables = JINJA_VAR_REGEX.test(content);
JINJA_VAR_REGEX.lastIndex = 0;
const jsonBufferRef = useRef<string | null>(null);
const contentIsJson = useMemo(() => !isEmpty && isJson(content), [content, isEmpty]);
const formattedJson = useMemo(() => {
if (!contentIsJson) return "";
try {
return JSON.stringify(JSON.parse(content), null, 2);
} catch {
return content;
}
}, [content, contentIsJson]);
const applyPendingJsonBuffer = (msg: Message): Message => {
if (jsonBufferRef.current !== null) {
const clone = msg.clone();
clone.content = jsonBufferRef.current;
jsonBufferRef.current = null;
return clone;
}
return msg;
};
const flushJsonBuffer = () => {
const updated = applyPendingJsonBuffer(messageRef.current);
if (updated !== messageRef.current) {
onChange(updated.serialized);
}
};
useEffect(() => {
const handleClick = (e: MouseEvent) => {
if (!containerRef.current?.contains(e.target as Node)) {
setEditMode(false);
}
};
document.addEventListener("mousedown", handleClick);
return () => document.removeEventListener("mousedown", handleClick);
}, []);
const handleRoleChange = (role: string) => {
const latest = applyPendingJsonBuffer(messageRef.current);
const clone = latest.clone();
clone.role = role as any;
onChange(clone.serialized);
};
const addAttachments = useCallback(
(newAttachments: MessageContent[]) => {
const latest = applyPendingJsonBuffer(messageRef.current);
const clone = latest.clone();
clone.attachments = [...latest.attachments, ...newAttachments];
onChange(clone.serialized);
},
[onChange],
);
const handleRemoveAttachment = useCallback(
(index: number) => {
const latest = applyPendingJsonBuffer(messageRef.current);
const clone = latest.clone();
clone.attachments = latest.attachments.filter((_, i) => i !== index);
onChange(clone.serialized);
},
[message, onChange],
);
const handleFileSelect = useCallback(
async (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (!files) return;
const attachments: MessageContent[] = [];
for (const file of Array.from(files)) {
const att = await fileToAttachment(file);
if (att) attachments.push(att);
}
if (attachments.length > 0) addAttachments(attachments);
e.target.value = "";
},
[addAttachments],
);
// Drag & drop state
const [isDragging, setIsDragging] = useState(false);
const dragCounterRef = useRef(0);
const handleDragEnter = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
dragCounterRef.current++;
if (e.dataTransfer.types.includes("Files")) setIsDragging(true);
}, []);
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
dragCounterRef.current--;
if (dragCounterRef.current === 0) setIsDragging(false);
}, []);
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
}, []);
const handleDrop = useCallback(
async (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
dragCounterRef.current = 0;
setIsDragging(false);
const files = e.dataTransfer.files;
if (!files || files.length === 0) return;
const attachments: MessageContent[] = [];
for (const file of Array.from(files)) {
const att = await fileToAttachment(file);
if (att) attachments.push(att);
}
if (attachments.length > 0) addAttachments(attachments);
},
[addAttachments],
);
const handleReadOnlyClick = (e: React.MouseEvent<HTMLTextAreaElement>) => {
if (disabled) return;
const target = e.target as HTMLTextAreaElement;
pendingCursorRef.current = target.selectionStart ?? 0;
setEditMode(true);
};
const handleEditFocus = (e: React.FocusEvent<HTMLTextAreaElement>) => {
const pos = pendingCursorRef.current;
pendingCursorRef.current = null;
const target = e.target;
requestAnimationFrame(() => {
const cursorPos = pos ?? target.value.length;
target.selectionStart = cursorPos;
target.selectionEnd = cursorPos;
});
};
return (
<div
className="group hover:border-border focus-within:border-border relative rounded-sm border border-transparent px-3 py-2 transition-colors"
ref={containerRef}
{...(canAttach
? {
onDragEnter: handleDragEnter,
onDragLeave: handleDragLeave,
onDragOver: handleDragOver,
onDrop: handleDrop,
}
: {})}
>
{canAttach && isDragging && (
<div className="bg-background/80 border-primary absolute inset-0 z-50 flex items-center justify-center rounded-sm border-2 border-dashed backdrop-blur-sm">
<div className="text-primary flex flex-col items-center gap-1">
<Paperclip className="h-5 w-5" />
<span className="text-xs font-medium">Drop files to attach</span>
</div>
</div>
)}
<div className="mb-1 flex items-center">
<MessageRoleSwitcher role={message.role ?? ""} disabled={disabled} onRoleChange={handleRoleChange} />
<div className="ml-auto flex h-5 items-center gap-0.5">
{canAttach && (
<>
<input
ref={fileInputRef}
type="file"
multiple
accept="image/*,audio/*,.pdf,.txt,.csv,.json,.xml,.doc,.docx"
className="hidden"
onChange={handleFileSelect}
/>
<button
type="button"
aria-label="Attach file"
data-testid="user-msg-attach"
onClick={() => fileInputRef.current?.click()}
className="hover:bg-muted focus:bg-muted rounded-sm p-1 opacity-0 transition-opacity group-focus-within:opacity-100 group-hover:opacity-100 focus:opacity-100"
>
<Paperclip className="text-muted-foreground hover:text-foreground size-3 shrink-0 cursor-pointer" />
</button>
</>
)}
{!disabled && (
<button
type="button"
aria-label="Edit message"
data-testid="user-msg-edit"
onClick={() => setEditMode(true)}
className="hover:bg-muted focus:bg-muted rounded-sm p-1 opacity-0 transition-opacity group-focus-within:opacity-100 group-hover:opacity-100 focus:opacity-100"
>
<PencilIcon className="text-muted-foreground hover:text-foreground size-3 shrink-0 cursor-pointer" />
</button>
)}
{!disabled && onRemove && (
<button
type="button"
aria-label="Delete message"
data-testid="user-msg-delete"
onClick={onRemove}
className="hover:bg-muted focus:bg-muted rounded-sm p-1 opacity-0 transition-opacity group-focus-within:opacity-100 group-hover:opacity-100 focus:opacity-100"
>
<XIcon className="text-muted-foreground hover:text-foreground size-3 shrink-0 cursor-pointer" />
</button>
)}
</div>
</div>
<div>
{editMode ? (
<RichTextarea
autoFocus
value={content}
className="text-muted-foreground min-h-[20px] resize-none rounded-none border-0 bg-transparent p-0 text-sm shadow-none focus-visible:ring-0 focus-visible:ring-offset-0 dark:bg-transparent"
textAreaClassName="rounded-none p-0 border-none"
disabled={disabled}
onChange={(e) => {
const clone = message.clone();
clone.content = e.target.value;
onChange(clone.serialized);
}}
onFocus={handleEditFocus}
onBlur={() => {
if (content.trim().length > 0) setEditMode(false);
}}
highlightPatterns={JINJA_VAR_HIGHLIGHT_PATTERNS}
/>
) : isEmpty && messageAttachments.length === 0 ? (
<div className="text-muted-foreground min-h-[20px] text-sm italic">Enter user message...</div>
) : contentIsJson ? (
<CodeEditor
wrap
code={formattedJson}
lang="json"
readonly={disabled}
autoResize
onChange={(value) => {
jsonBufferRef.current = value ?? "";
}}
options={{
showIndentLines: false,
disableHover: true,
}}
onBlur={flushJsonBuffer}
/>
) : hasVariables ? (
<RichTextarea
readOnly
value={content}
className="text-muted-foreground min-h-[20px] resize-none rounded-none border-0 bg-transparent p-0 text-sm shadow-none focus-visible:ring-0 focus-visible:ring-offset-0 dark:bg-transparent"
textAreaClassName="rounded-none p-0 border-none cursor-text"
onClick={handleReadOnlyClick}
highlightPatterns={JINJA_VAR_HIGHLIGHT_PATTERNS}
/>
) : (
<div
className={!disabled ? "cursor-text" : undefined}
onClick={(e) => {
if (disabled || editMode) return;
if ((e.target as HTMLElement).closest("button, a, [role='button']")) return;
setEditMode(true);
}}
>
<Markdown content={content} className="text-muted-foreground" />
</div>
)}
</div>
{messageAttachments.length > 0 && (
<AttachmentDisplay attachments={messageAttachments} editable={canAttach} onRemoveAttachment={handleRemoveAttachment} />
)}
</div>
);
}

View File

@@ -0,0 +1,270 @@
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { Message, type MessageContent } from "@/lib/message";
import { Loader2, Paperclip, Play, Plus } from "lucide-react";
import { useCallback, useRef, useState } from "react";
import { usePromptContext } from "../context";
import { fileToAttachment } from "../utils/attachment";
import { AttachmentBadge } from "./messagesView/attachmentViews";
import MessageRoleSwitcher from "./messagesView/messageRoleSwitcher";
export function NewMessageInputView() {
const {
messages,
setMessages: onUpdateMessages,
handleSendMessage: onSendMessage,
isStreaming,
supportsVision,
provider,
model,
} = usePromptContext();
const [userInput, setUserInput] = useState("");
const [inputRole, setInputRole] = useState<string>("user");
const [attachments, setAttachments] = useState<MessageContent[]>([]);
const fileInputRef = useRef<HTMLInputElement>(null);
const userInputRef = useRef<HTMLTextAreaElement>(null);
const handleAddMessage = useCallback(() => {
if (isStreaming) return;
const input = userInput.trim();
const currentAttachments = attachments.length > 0 ? [...attachments] : undefined;
if (!input && !currentAttachments) return;
setUserInput("");
setAttachments([]);
let msg: Message;
if (inputRole === "user") {
msg = Message.request(input, 0, currentAttachments);
} else if (inputRole === "system") {
msg = Message.system(input);
} else {
msg = Message.response(input);
}
onUpdateMessages([...messages, msg]);
}, [userInput, attachments, isStreaming, inputRole, onUpdateMessages, messages]);
const canRun = !!(provider && model);
const handleRun = useCallback(async () => {
if (isStreaming || !provider || !model) return;
const input = userInput.trim();
const currentAttachments = attachments.length > 0 ? [...attachments] : undefined;
if (input || currentAttachments) {
setUserInput("");
setAttachments([]);
}
let pendingMessage: Message | undefined;
if (input || currentAttachments) {
if (inputRole === "system") {
pendingMessage = Message.system(input);
} else if (inputRole === "user") {
pendingMessage = Message.request(input, 0, currentAttachments);
} else {
pendingMessage = Message.response(input);
}
}
await onSendMessage(pendingMessage);
setTimeout(() => {
userInputRef.current?.focus();
}, 100);
}, [userInput, attachments, isStreaming, inputRole, onSendMessage, provider, model]);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleRun();
}
},
[handleAddMessage, handleRun],
);
const handleFileSelect = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (!files) return;
for (const file of Array.from(files)) {
const attachment = await fileToAttachment(file);
if (attachment) {
setAttachments((prev) => [...prev, attachment]);
}
}
// Reset input so re-selecting the same file triggers onChange
e.target.value = "";
}, []);
const handleRemoveAttachment = useCallback((index: number) => {
setAttachments((prev) => prev.filter((_, i) => i !== index));
}, []);
const handlePaste = useCallback(
async (e: React.ClipboardEvent) => {
if (!supportsVision) return;
const items = e.clipboardData?.items;
if (!items) return;
for (const item of Array.from(items)) {
if (item.type.startsWith("image/")) {
e.preventDefault();
const file = item.getAsFile();
if (file) {
const attachment = await fileToAttachment(file);
if (attachment) {
setAttachments((prev) => [...prev, attachment]);
}
}
}
}
},
[supportsVision],
);
const [isDragging, setIsDragging] = useState(false);
const dragCounterRef = useRef(0);
const handleDragEnter = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
dragCounterRef.current++;
if (e.dataTransfer.types.includes("Files")) {
setIsDragging(true);
}
}, []);
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
dragCounterRef.current--;
if (dragCounterRef.current === 0) {
setIsDragging(false);
}
}, []);
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
}, []);
const handleDrop = useCallback(async (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
dragCounterRef.current = 0;
setIsDragging(false);
const files = e.dataTransfer.files;
if (!files || files.length === 0) return;
for (const file of Array.from(files)) {
const attachment = await fileToAttachment(file);
if (attachment) {
setAttachments((prev) => [...prev, attachment]);
}
}
}, []);
return (
<div
className="group relative max-h-[500px] shrink-0 overflow-y-auto border-t px-4 py-2"
{...(supportsVision
? {
onDragEnter: handleDragEnter,
onDragLeave: handleDragLeave,
onDragOver: handleDragOver,
onDrop: handleDrop,
}
: {})}
>
{supportsVision && isDragging && (
<div className="bg-background/80 border-primary absolute inset-0 z-50 flex items-center justify-center rounded-sm border-2 border-dashed backdrop-blur-sm">
<div className="text-primary flex flex-col items-center gap-1">
<Paperclip className="h-5 w-5" />
<span className="text-xs font-medium">Drop files to attach</span>
</div>
</div>
)}
<div className="mb-1 flex items-center">
<MessageRoleSwitcher
role={inputRole}
disabled={isStreaming}
onRoleChange={(role) => {
setInputRole(role);
if (role !== "user") setAttachments([]);
}}
restrictedRoles={["system", "tool"]}
/>
{supportsVision && inputRole === "user" && (
<div className="ml-auto">
<input
ref={fileInputRef}
type="file"
multiple
accept="image/*,audio/*,.pdf,.txt,.csv,.json,.xml,.doc,.docx"
className="hidden"
onChange={handleFileSelect}
/>
<button
type="button"
aria-label="Attach file"
data-testid="new-message-attach-file"
onClick={() => fileInputRef.current?.click()}
className="hover:bg-muted focus:bg-muted rounded-sm p-1"
>
<Paperclip className="text-muted-foreground hover:text-foreground h-3.5 w-3.5 shrink-0 cursor-pointer" />
</button>
</div>
)}
</div>
{attachments.length > 0 && (
<div className="mb-2 flex flex-wrap gap-2">
{attachments.map((att, index) => (
<AttachmentBadge key={index} attachment={att} onRemove={() => handleRemoveAttachment(index)} />
))}
</div>
)}
<div className="relative">
<Textarea
placeholder="Type a message..."
value={userInput}
ref={userInputRef}
onChange={(e) => setUserInput(e.target.value)}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
data-testid="new-message-textarea"
className="text-muted-foreground min-h-[60px] resize-none rounded-none border-0 bg-transparent p-0 pr-16 text-sm shadow-none focus-visible:ring-0 focus-visible:ring-offset-0 dark:bg-transparent"
disabled={isStreaming}
/>
<div className="absolute right-0 bottom-0 flex items-center gap-1">
<Button
onClick={handleAddMessage}
disabled={isStreaming}
variant={"ghost"}
data-testid="new-message-add"
className="text-muted-foreground hover:text-foreground flex items-center gap-1 rounded px-1.5 py-1 text-xs disabled:pointer-events-none disabled:opacity-50"
>
<Plus className="h-3.5 w-3.5" />
Add
</Button>
<Tooltip>
<TooltipTrigger asChild>
<Button
onClick={handleRun}
disabled={isStreaming || !canRun}
variant={"ghost"}
data-testid="new-message-run"
className="text-muted-foreground hover:text-foreground flex items-center gap-1 rounded px-1.5 py-1 text-xs disabled:pointer-events-none disabled:opacity-50"
>
{isStreaming ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Play className="h-3.5 w-3.5" />}
Run
</Button>
</TooltipTrigger>
<TooltipContent side="top">
{!canRun ? <span>Select a provider and model to run</span> : <span>Run prompt</span>}
<kbd className="bg-primary-foreground/20 ml-1.5 rounded px-1 py-0.5 font-mono text-[10px]"></kbd>
</TooltipContent>
</Tooltip>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,369 @@
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
import { DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator } from "@/components/ui/dropdownMenu";
import { Input } from "@/components/ui/input";
import { SplitButton } from "@/components/ui/splitButton";
import { Message, MessageRole } from "@/lib/message";
import { getErrorMessage } from "@/lib/store";
import { useCreateSessionMutation, useGetSessionsQuery, useGetVersionsQuery, useRenameSessionMutation } from "@/lib/store/apis/promptsApi";
import { ModelParams, PromptSession } from "@/lib/types/prompts";
import { cn } from "@/lib/utils";
import { Check, GitCommit, PencilIcon, Save, Trash2 } from "lucide-react";
import { parseAsInteger, useQueryStates } from "nuqs";
import { useCallback, useRef, useState } from "react";
import { useHotkeys } from "react-hotkeys-hook";
import { toast } from "sonner";
import { usePromptContext } from "../context";
export default function PromptsViewHeader() {
const {
selectedPrompt,
messages,
setMessages: onMessagesChange,
setCommitSheet,
apiKeyId,
modelParams,
provider,
model,
variables,
hasChanges,
hasVersionChanges,
hasSessionChanges,
isStreaming,
canUpdate,
} = usePromptContext();
const [sessionsOpen, setSessionsOpen] = useState(false);
const onSessionSaved = useCallback(
(session: PromptSession) => {
setCommitSheet({ open: true, session });
},
[setCommitSheet],
);
// UI state — persisted in URL query params
const [{ sessionId: selectedSessionId, versionId: selectedVersionId }, setUrlState] = useQueryStates(
{
sessionId: parseAsInteger,
versionId: parseAsInteger,
},
{ history: "replace" },
);
// Fetch versions and sessions for selected prompt
const { data: versionsData } = useGetVersionsQuery(selectedPrompt?.id ?? "", { skip: !selectedPrompt?.id });
const { data: sessionsData } = useGetSessionsQuery(selectedPrompt?.id ?? "", { skip: !selectedPrompt?.id });
// Mutations
const [createSession, { isLoading: isCreatingSession }] = useCreateSessionMutation();
const [renameSession] = useRenameSessionMutation();
const versions = versionsData?.versions ?? [];
const sessions = sessionsData?.sessions ?? [];
const handleSelectVersion = useCallback(
(versionId: number) => {
setUrlState({ versionId, sessionId: null });
},
[setUrlState],
);
// Build model_params with api_key_id for persistence
const buildSaveParams = useCallback((): ModelParams => {
const params = { ...modelParams };
if (apiKeyId && apiKeyId !== "__auto__") {
params.api_key_id = apiKeyId;
}
return params;
}, [modelParams, apiKeyId]);
const handleSaveSession = useCallback(async () => {
if (!selectedPrompt || !hasChanges || isStreaming) return;
try {
const result = await createSession({
promptId: selectedPrompt.id,
data: {
messages: Message.serializeAll(messages),
model_params: buildSaveParams(),
provider,
model,
variables: Object.keys(variables).length > 0 ? variables : undefined,
},
}).unwrap();
setUrlState({ sessionId: result.session.id, versionId: null });
toast.success("Session saved");
} catch (err) {
toast.error("Failed to save session", { description: getErrorMessage(err) });
}
}, [selectedPrompt?.id, messages, buildSaveParams, provider, model, variables, createSession, setUrlState, hasChanges, isStreaming]);
// Cmd+S / Ctrl+S to save session
useHotkeys(
"mod+s",
() => handleSaveSession(),
{
preventDefault: true,
enableOnFormTags: ["input", "textarea", "select"],
enabled: !!selectedPrompt && !isCreatingSession && !isStreaming,
},
[handleSaveSession, selectedPrompt, isCreatingSession, isStreaming],
);
const handleCommitVersion = useCallback(async () => {
if (!selectedPrompt) return;
if (!hasChanges) {
const selectedSession = sessions.find((s) => s.id === selectedSessionId);
if (selectedSession) {
onSessionSaved(selectedSession);
}
return;
}
try {
// Always create a new session with current state before committing
const result = await createSession({
promptId: selectedPrompt.id,
data: {
messages: Message.serializeAll(messages),
model_params: buildSaveParams(),
provider,
model,
variables: Object.keys(variables).length > 0 ? variables : undefined,
},
}).unwrap();
setUrlState({ sessionId: result.session.id, versionId: null });
onSessionSaved(result.session);
} catch (err) {
toast.error("Failed to save session", { description: getErrorMessage(err) });
}
}, [selectedPrompt?.id, messages, buildSaveParams, provider, model, variables, createSession, setUrlState, onSessionSaved, hasChanges]);
const handleRenameSession = useCallback(
async (sessionId: number, name: string) => {
if (!selectedPrompt) return;
try {
await renameSession({ id: sessionId, promptId: selectedPrompt.id, data: { name } }).unwrap();
} catch (err) {
toast.error("Failed to rename session", { description: getErrorMessage(err) });
}
},
[selectedPrompt?.id, renameSession],
);
const handleClearConversation = useCallback(() => {
const firstMsg = messages[0];
if (firstMsg?.role === MessageRole.SYSTEM) {
onMessagesChange([firstMsg]);
} else {
onMessagesChange([Message.system("")]);
}
}, [messages]);
const selectedVersion = versions.find((v) => v.id === selectedVersionId);
const latestVersion = versions.find((v) => v.is_latest);
const displayVersion = selectedVersion ?? latestVersion;
return (
<div className="flex items-center justify-between border-b px-4 py-3">
<div className="flex min-w-0 items-center gap-2">
<h3 className="truncate font-semibold">
{selectedPrompt?.name || "Playground"}
{hasChanges && <span className="text-destructive ml-1">*</span>}
</h3>
{displayVersion && <Badge variant={"secondary"}>v{displayVersion.version_number}</Badge>}
{hasVersionChanges && versions.length > 0 && <Badge variant="outline">Unpublished Changes</Badge>}
</div>
<div className="flex shrink-0 items-center gap-4">
{messages.length > 1 && (
<Button variant="ghost" size="sm" data-testid="header-clear" onClick={handleClearConversation} disabled={isStreaming}>
<Trash2 className="h-4 w-4" />
Clear
</Button>
)}
<SplitButton
onClick={handleSaveSession}
disabled={isCreatingSession || isStreaming}
isLoading={isCreatingSession}
dropdownContent={{
className: "w-72 p-0",
open: sessionsOpen,
onOpenChange: setSessionsOpen,
children: (
<Command>
<CommandInput placeholder="Search sessions..." data-testid="header-sessions-search" />
<CommandList>
<CommandEmpty>No sessions found.</CommandEmpty>
<CommandGroup>
{sessions.map((session) => (
<SessionItem
key={session.id}
session={session}
isSelected={selectedSessionId === session.id}
onSelect={() => {
setUrlState({ sessionId: session.id, versionId: null });
setSessionsOpen(false);
}}
onRename={(name) => handleRenameSession(session.id, name)}
/>
))}
</CommandGroup>
</CommandList>
</Command>
),
}}
variant={"outline"}
dropdownTrigger={{
className: cn("bg-transparent"),
}}
button={{
dataTestId: "header-save-session",
className: "bg-transparent disabled:opacity-100 disabled:text-muted-foreground",
disabled: !hasChanges || !canUpdate,
}}
>
<Save className="h-4 w-4" />
Save Session
</SplitButton>
<SplitButton
onClick={handleCommitVersion}
disabled={isCreatingSession || isStreaming}
dropdownContent={{
className: "w-64 max-h-72 overflow-y-auto",
children: (
<>
<DropdownMenuLabel>Versions</DropdownMenuLabel>
<DropdownMenuSeparator />
{versions.length === 0 ? (
<div className="text-muted-foreground px-2 py-3 text-center text-sm">No versions yet</div>
) : (
versions.map((version) => (
<DropdownMenuItem
key={version.id}
onClick={() => handleSelectVersion(version.id)}
className="flex items-center justify-between gap-2"
>
<div className="flex min-w-0 flex-col">
<span className="truncate text-sm">
v{version.version_number}
{version.is_latest && <span className="text-primary ml-1.5 text-xs">(latest)</span>}
</span>
<span className="text-muted-foreground truncate text-xs">{version.commit_message || "No commit message"}</span>
<span className="text-muted-foreground text-xs">{formatSessionDate(version.created_at)}</span>
</div>
{selectedVersionId === version.id && <Check className="text-primary h-4 w-4 shrink-0" />}
</DropdownMenuItem>
))
)}
</>
),
}}
variant={"outline"}
dropdownTrigger={{
className: cn("bg-transparent"),
}}
button={{
dataTestId: "header-commit-version",
className: "bg-transparent disabled:opacity-100 disabled:text-muted-foreground",
disabled: !hasVersionChanges || !canUpdate,
}}
>
<GitCommit className="h-4 w-4" />
Commit Version
</SplitButton>
</div>
</div>
);
}
function formatSessionDate(dateStr: string): string {
const date = new Date(dateStr);
const month = date.toLocaleString("en-US", { month: "short" });
const day = date.getDate();
const hours = date.getHours();
const minutes = date.getMinutes().toString().padStart(2, "0");
const ampm = hours >= 12 ? "pm" : "am";
const displayHours = (hours % 12 || 12).toString().padStart(2, "0");
return `${month} ${day}, ${displayHours}:${minutes}${ampm}`;
}
function SessionItem({
session,
isSelected,
onSelect,
onRename,
}: {
session: PromptSession;
isSelected: boolean;
onSelect: () => void;
onRename: (name: string) => void;
}) {
const [isEditing, setIsEditing] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const handleRenameSubmit = () => {
const newName = inputRef.current?.value.trim() ?? "";
if (!newName || newName === session.name) {
setIsEditing(false);
return;
}
onRename(newName);
setIsEditing(false);
};
const dateLabel = formatSessionDate(session.created_at);
if (isEditing) {
return (
<div className="flex items-center gap-2 rounded-sm px-2 py-1.5" onKeyDown={(e) => e.stopPropagation()}>
<Input
ref={inputRef}
defaultValue={session.name}
placeholder="Session name"
className="h-auto border-none bg-transparent p-0 text-sm shadow-none focus-visible:border-none focus-visible:ring-0"
data-testid="session-rename-input"
autoFocus
onKeyDown={(e) => {
if (e.key === "Enter") handleRenameSubmit();
if (e.key === "Escape") setIsEditing(false);
}}
onBlur={handleRenameSubmit}
/>
</div>
);
}
return (
<CommandItem
value={`${session.id}-${dateLabel}-${session.name}`}
onSelect={onSelect}
className="group/item flex items-center justify-between gap-2 py-1"
>
<div className="flex min-w-0 flex-col">
<span className="truncate text-sm">
<span className="text-muted-foreground">{dateLabel}</span>
{session.name && <span className="ml-1.5">{session.name}</span>}
</span>
</div>
<div className="flex shrink-0 items-center gap-1">
<button
type="button"
aria-label="Rename session"
data-testid="session-rename"
onPointerDown={(e) => {
e.preventDefault();
e.stopPropagation();
}}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setIsEditing(true);
}}
className="hover:bg-muted focus:bg-muted rounded-sm p-1 opacity-0 transition-opacity group-hover/item:opacity-100 focus:opacity-100"
>
<PencilIcon className="text-muted-foreground hover:text-foreground h-3.5 w-3.5 cursor-pointer" />
</button>
{isSelected && <Check className="text-primary h-4 w-4" />}
</div>
</CommandItem>
);
}

View File

@@ -0,0 +1,42 @@
import { usePromptContext } from "../context";
import { FolderSheet } from "../sheets/folderSheet";
import { PromptSheet } from "../sheets/promptSheet";
import { CommitVersionSheet } from "../sheets/commitVersionSheet";
export function PromptSheets() {
const { folderSheet, setFolderSheet, promptSheet, setPromptSheet, commitSheet, setCommitSheet, setUrlState } = usePromptContext();
return (
<>
<FolderSheet
open={folderSheet.open}
onOpenChange={(open) => setFolderSheet({ ...folderSheet, open })}
folder={folderSheet.folder}
onSaved={() => {}}
/>
<PromptSheet
open={promptSheet.open}
onOpenChange={(open) => setPromptSheet({ ...promptSheet, open })}
prompt={promptSheet.prompt}
folderId={promptSheet.folderId}
onSaved={(newPromptId) => {
if (newPromptId) {
setUrlState({ promptId: newPromptId, sessionId: null, versionId: null });
}
}}
/>
{commitSheet.session && (
<CommitVersionSheet
open={commitSheet.open}
onOpenChange={(open) => setCommitSheet({ ...commitSheet, open })}
session={commitSheet.session}
onCommitted={(versionId) => {
setUrlState({ versionId, sessionId: null });
}}
/>
)}
</>
);
}

View File

@@ -0,0 +1,58 @@
import { AutoSizeTextarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import type { VariableMap } from "@/lib/message";
import { useCallback, useMemo } from "react";
export function VariablesTableView({
variables,
onChange,
}: {
variables: VariableMap;
onChange: React.Dispatch<React.SetStateAction<VariableMap>>;
}) {
const entries = useMemo(() => Object.entries(variables).sort(([a], [b]) => a.localeCompare(b)), [variables]);
const handleValueChange = useCallback(
(name: string, value: string) => {
onChange((prev) => ({ ...prev, [name]: value }));
},
[onChange],
);
return (
<div className="flex flex-col gap-3">
<Label className="text-muted-foreground text-xs font-medium uppercase">Variables</Label>
<p className="text-muted-foreground text-xs">
Detected from <code className="bg-muted rounded px-1">{"{{ }}"}</code> syntax in messages. Values are substituted at runtime.
</p>
<div className="border-border overflow-hidden rounded-sm border">
<table className="w-full table-fixed text-sm">
<thead>
<tr className="bg-muted/50 border-border border-b">
<th className="text-muted-foreground w-[40%] max-w-[40%] px-3 py-1.5 text-left text-xs font-medium">Variable</th>
<th className="text-muted-foreground px-3 py-1.5 text-left text-xs font-medium">Value</th>
</tr>
</thead>
<tbody>
{entries.map(([name, value]) => (
<tr key={name} className="border-border border-b last:border-b-0">
<td className="w-[40%] max-w-[40%] px-3 py-1.5 align-top">
<span className="block truncate pt-1 text-xs">{name}</span>
</td>
<td className="py-1">
<AutoSizeTextarea
value={value}
onChange={(e) => handleValueChange(name, e.target.value)}
placeholder={"value"}
minRows={1}
className="min-h-0 w-full resize-none border-none bg-transparent px-3 py-1 text-xs shadow-none outline-none focus-visible:ring-0"
/>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}

View File

@@ -0,0 +1,623 @@
import { extractVariablesFromMessages, mergeVariables, Message, MessageRole, MessageType, type VariableMap } from "@/lib/message";
import { getErrorMessage } from "@/lib/store";
import {
useDeleteFolderMutation,
useDeletePromptMutation,
useGetFoldersQuery,
useGetPromptsQuery,
useGetPromptVersionQuery,
useGetSessionsQuery,
useUpdatePromptMutation,
} from "@/lib/store/apis/promptsApi";
import { useGetModelParametersQuery } from "@/lib/store/apis/providersApi";
import { Folder, ModelParams, Prompt, PromptSession, PromptVersion } from "@/lib/types/prompts";
import { RbacOperation, RbacResource, useRbac } from "@enterprise/lib";
import { parseAsInteger, parseAsString, useQueryStates } from "nuqs";
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { toast } from "sonner";
import { executePrompt } from "./utils/executor";
interface PromptContextValue {
// Data
folders: Folder[];
prompts: Prompt[];
selectedPrompt?: Prompt;
sessions: PromptSession[];
selectedSession?: PromptSession;
selectedVersion?: PromptVersion;
// Loading states
foldersLoading: boolean;
promptsLoading: boolean;
foldersError: unknown;
promptsError: unknown;
isLoadingPlayground: boolean;
isStreaming: boolean;
// URL state
selectedPromptId: string | null;
selectedSessionId: number | null;
selectedVersionId: number | null;
setUrlState: (state: { promptId?: string | null; sessionId?: number | null; versionId?: number | null }) => void;
// Playground state
messages: Message[];
setMessages: React.Dispatch<React.SetStateAction<Message[]>>;
provider: string;
setProvider: React.Dispatch<React.SetStateAction<string>>;
model: string;
setModel: React.Dispatch<React.SetStateAction<string>>;
modelParams: ModelParams;
setModelParams: React.Dispatch<React.SetStateAction<ModelParams>>;
apiKeyId: string;
setApiKeyId: React.Dispatch<React.SetStateAction<string>>;
// Jinja2 variables
variables: VariableMap;
setVariables: React.Dispatch<React.SetStateAction<VariableMap>>;
// Sheet states
folderSheet: { open: boolean; folder?: Folder };
setFolderSheet: React.Dispatch<React.SetStateAction<{ open: boolean; folder?: Folder }>>;
promptSheet: { open: boolean; prompt?: Prompt; folderId?: string };
setPromptSheet: React.Dispatch<React.SetStateAction<{ open: boolean; prompt?: Prompt; folderId?: string }>>;
commitSheet: { open: boolean; session?: PromptSession };
setCommitSheet: React.Dispatch<React.SetStateAction<{ open: boolean; session?: PromptSession }>>;
// Delete dialog states
deleteFolderDialog: { open: boolean; folder?: Folder };
setDeleteFolderDialog: React.Dispatch<React.SetStateAction<{ open: boolean; folder?: Folder }>>;
deletePromptDialog: { open: boolean; prompt?: Prompt };
setDeletePromptDialog: React.Dispatch<React.SetStateAction<{ open: boolean; prompt?: Prompt }>>;
// Mutation loading states
isDeletingFolder: boolean;
isDeletingPrompt: boolean;
// Model capabilities
supportsVision: boolean;
// Diff detection
hasChanges: boolean;
hasVersionChanges: boolean;
hasSessionChanges: boolean;
// Handlers
handleSelectPrompt: (id: string) => void;
handleMovePrompt: (promptId: string, folderId: string | null) => Promise<void>;
handleDeleteFolder: () => Promise<void>;
handleDeletePrompt: () => Promise<void>;
handleSendMessage: (pendingMessage?: Message) => Promise<void>;
handleSubmitToolResult: (afterIndex: number, toolCallId: string, content: string) => Promise<void>;
// RBAC permissions
canCreate: boolean;
canUpdate: boolean;
canDelete: boolean;
}
const PromptContext = createContext<PromptContextValue | null>(null);
export function usePromptContext() {
const context = useContext(PromptContext);
if (!context) {
throw new Error("usePromptContext must be used within a PromptProvider");
}
return context;
}
export function PromptProvider({ children }: { children: ReactNode }) {
// RBAC permissions
const canCreate = useRbac(RbacResource.PromptRepository, RbacOperation.Create);
const canUpdate = useRbac(RbacResource.PromptRepository, RbacOperation.Update);
const canDelete = useRbac(RbacResource.PromptRepository, RbacOperation.Delete);
// API queries
const { data: foldersData, isLoading: foldersLoading, error: foldersError } = useGetFoldersQuery();
const { data: promptsData, isLoading: promptsLoading, error: promptsError } = useGetPromptsQuery();
// Mutations
const [deleteFolder, { isLoading: isDeletingFolder }] = useDeleteFolderMutation();
const [deletePrompt, { isLoading: isDeletingPrompt }] = useDeletePromptMutation();
const [updatePrompt] = useUpdatePromptMutation();
// UI state — persisted in URL query params
const [{ promptId: selectedPromptId, sessionId: selectedSessionId, versionId: selectedVersionId }, setUrlState] = useQueryStates(
{
promptId: parseAsString,
sessionId: parseAsInteger,
versionId: parseAsInteger,
},
{ history: "replace" },
);
// Sheet states
const [folderSheet, setFolderSheet] = useState<{ open: boolean; folder?: Folder }>({ open: false });
const [promptSheet, setPromptSheet] = useState<{ open: boolean; prompt?: Prompt; folderId?: string }>({ open: false });
const [commitSheet, setCommitSheet] = useState<{ open: boolean; session?: PromptSession }>({ open: false });
// Delete dialog states
const [deleteFolderDialog, setDeleteFolderDialog] = useState<{ open: boolean; folder?: Folder }>({ open: false });
const [deletePromptDialog, setDeletePromptDialog] = useState<{ open: boolean; prompt?: Prompt }>({ open: false });
// Playground state
const [messages, setMessagesRaw] = useState<Message[]>([Message.system("")]);
const setMessages = useCallback<React.Dispatch<React.SetStateAction<Message[]>>>((action) => {
setMessagesRaw((prev) => {
const next = typeof action === "function" ? action(prev) : action;
return next.map((msg, i) => msg.withIndex(i));
});
}, []);
const [provider, setProvider] = useState("");
const [model, setModel] = useState("");
const [modelParams, setModelParams] = useState<ModelParams>({ stream: true });
const [apiKeyId, setApiKeyId] = useState("__auto__");
const [isStreaming, setIsStreaming] = useState(false);
const activeRunRef = useRef<symbol | null>(null);
const [variables, setVariables] = useState<VariableMap>({});
// Fetch model datasheet for capabilities
const { data: datasheetData } = useGetModelParametersQuery(model, { skip: !model });
const supportsVision = datasheetData?.supports_vision ?? false;
// Derived data
const folders = useMemo(() => foldersData?.folders ?? [], [foldersData]);
const prompts = useMemo(() => promptsData?.prompts ?? [], [promptsData]);
const selectedPrompt = useMemo(() => prompts.find((p) => p.id === selectedPromptId), [prompts, selectedPromptId]);
// Fetch versions and sessions for selected prompt
const { data: sessionsData, isLoading: isSessionsLoading } = useGetSessionsQuery(selectedPromptId ?? "", { skip: !selectedPromptId });
// Filter sessions to current prompt — RTK Query may briefly return stale cached data from the previous prompt
const sessions = useMemo(() => {
const all = sessionsData?.sessions ?? [];
if (!selectedPromptId) return [];
return all.filter((s) => s.prompt_id === selectedPromptId);
}, [sessionsData, selectedPromptId]);
const selectedSession = useMemo(() => sessions.find((s) => s.id === selectedSessionId), [sessions, selectedSessionId]);
// Fetch full version data (with messages) when a version is selected
const {
currentData: selectedVersionData,
isLoading: isVersionLoading,
isFetching: isVersionFetching,
} = useGetPromptVersionQuery(selectedVersionId ?? 0, {
skip: !selectedVersionId,
});
const selectedVersion = selectedVersionData?.version;
// Show loader only on initial fetch, not on cache refetches (avoids flicker on save)
const isLoadingPlayground = !!(
selectedPromptId &&
(isSessionsLoading ||
(selectedVersionId && isVersionLoading) ||
// Sessions loaded but auto-select hasn't happened yet
(sessions.length > 0 && !selectedSessionId && !selectedVersionId))
);
// Load session or version data when selection changes
useEffect(() => {
// Don't reset state while waiting for data that hasn't arrived yet
if (selectedSessionId && !selectedSession) return;
if (selectedVersionId && !selectedVersion) return;
const loadFromParams = (params: ModelParams, prov: string, mod: string) => {
const { api_key_id, ...rest } = params || ({} as ModelParams);
setModelParams({ stream: true, ...rest });
setApiKeyId(api_key_id || "__auto__");
setProvider(prov || "");
setModel(mod || "");
};
const loadMessages = (msgs: Message[]) => {
setMessages(msgs);
const varNames = extractVariablesFromMessages(msgs);
setVariables((prev) => mergeVariables(prev, varNames));
};
if (selectedSession) {
const raw = (selectedSession.messages ?? []).map((m) => m.message);
const loaded = Message.fromLegacyAll(raw);
loadMessages(loaded.length > 0 ? loaded : [Message.system("")]);
loadFromParams(selectedSession.model_params, selectedSession.provider, selectedSession.model);
// Restore variables (key:value) from session
if (selectedSession.variables && Object.keys(selectedSession.variables).length > 0) {
setVariables(selectedSession.variables);
}
} else if (selectedVersion) {
// If sessions are still loading and no session is explicitly selected,
// wait — a session may auto-select and take priority
if (isSessionsLoading && !selectedSessionId) return;
const raw = (selectedVersion.messages ?? []).map((m) => m.message);
const loaded = Message.fromLegacyAll(raw);
loadMessages(loaded.length > 0 ? loaded : [Message.system("")]);
loadFromParams(selectedVersion.model_params, selectedVersion.provider, selectedVersion.model);
// Initialize variables from version (keys with empty values)
if (selectedVersion.variables && Object.keys(selectedVersion.variables).length > 0) {
setVariables((prev) => mergeVariables(prev, Object.keys(selectedVersion.variables!)));
}
} else if (selectedPrompt?.latest_version) {
// Only fall back to latest_version after sessions have settled
// to avoid racing with the session auto-select effect
if (isSessionsLoading) return;
const version = selectedPrompt.latest_version;
const raw = (version.messages ?? []).map((m) => m.message);
const loaded = Message.fromLegacyAll(raw);
loadMessages(loaded.length > 0 ? loaded : [Message.system("")]);
loadFromParams(version.model_params, version.provider, version.model);
// Initialize variables from version (keys with empty values)
if (version.variables && Object.keys(version.variables).length > 0) {
setVariables((prev) => mergeVariables(prev, Object.keys(version.variables!)));
}
if (sessions.length === 0) {
setUrlState({ versionId: version.id });
}
} else {
setMessages([Message.system("")]);
setProvider("");
setModel("");
setModelParams({ stream: true });
setApiKeyId("__auto__");
}
}, [
selectedSession,
selectedVersion,
selectedPrompt,
selectedSessionId,
selectedVersionId,
setUrlState,
isSessionsLoading,
sessions.length,
]);
// Auto-select the most recent session when sessions load and none is selected
// Sessions take priority over versions for initial loading
useEffect(() => {
if (sessions.length > 0 && !selectedSessionId && !selectedVersionId) {
setUrlState({ sessionId: sessions[0].id });
}
}, [selectedPromptId, sessions, selectedSessionId, selectedVersionId, setUrlState]);
// Diff detection helper — compares current playground state against a reference config
const diffAgainst = useCallback(
(ref: { messages?: any[]; model_params?: ModelParams; provider?: string; model?: string } | undefined) => {
if (!ref) return true; // No reference — treat as changed
const refMessages = ref.messages ?? [];
const refProvider = ref.provider;
const refModel = ref.model;
const refParams = ref.model_params;
if (provider !== refProvider) return true;
if (model !== refModel) return true;
const { api_key_id: refApiKeyId, ...refParamsRest } = refParams || ({} as ModelParams);
const currentApiKeyId = apiKeyId !== "__auto__" ? apiKeyId : undefined;
if (currentApiKeyId !== (refApiKeyId || undefined)) return true;
// Normalize: treat missing stream as stream: true so legacy params without stream don't appear changed
const normalizeParams = (p: ModelParams): ModelParams => {
const { stream = true, ...rest } = p;
return { stream, ...rest };
};
const normalizedCurrent = normalizeParams(modelParams);
const normalizedRef = normalizeParams(refParamsRest);
if (
JSON.stringify(normalizedCurrent, Object.keys(normalizedCurrent).sort()) !==
JSON.stringify(normalizedRef, Object.keys(normalizedRef).sort())
)
return true;
const currentSerialized = Message.serializeAll(messages);
if (JSON.stringify(currentSerialized) !== JSON.stringify(refMessages)) return true;
return false;
},
[provider, model, modelParams, apiKeyId, messages],
);
// Diff detection — compare current playground state against the loaded session/version
const hasChanges = useMemo(() => {
// Suppress diff while version data is in flight to avoid flicker
if (selectedVersionId && (isVersionFetching || selectedVersion?.id !== selectedVersionId)) return false;
if (selectedSession) {
return diffAgainst({
messages: selectedSession.messages?.map((m) => m.message) ?? [],
model_params: selectedSession.model_params,
provider: selectedSession.provider,
model: selectedSession.model,
});
}
if (selectedVersion) {
return diffAgainst({
messages: selectedVersion.messages?.map((m) => m.message) ?? [],
model_params: selectedVersion.model_params,
provider: selectedVersion.provider,
model: selectedVersion.model,
});
}
return true;
}, [selectedSession, selectedVersion, diffAgainst, selectedVersionId, isVersionFetching]);
// Diff against the active version — drives "unpublished changes" badge & commit button
// Uses the explicitly selected version if available, otherwise falls back to latest_version
const activeVersionRef = selectedVersion ?? selectedPrompt?.latest_version;
const hasVersionChanges = useMemo(() => {
// Suppress diff while version data is in flight or mismatched to avoid flash
if (selectedVersionId && (isVersionFetching || selectedVersion?.id !== selectedVersionId)) return false;
if (!activeVersionRef) return true; // No versions yet — always allow commit
return diffAgainst({
messages: activeVersionRef.messages?.map((m) => m.message) ?? [],
model_params: activeVersionRef.model_params,
provider: activeVersionRef.provider,
model: activeVersionRef.model,
});
}, [activeVersionRef, diffAgainst, selectedVersionId, isVersionFetching, selectedVersion?.id]);
// Diff against the selected session — drives red asterisk indicator
const hasSessionChanges = useMemo(() => {
if (!selectedSession) return false;
return diffAgainst({
messages: selectedSession.messages?.map((m) => m.message) ?? [],
model_params: selectedSession.model_params,
provider: selectedSession.provider,
model: selectedSession.model,
});
}, [selectedSession, diffAgainst]);
// Handlers
const handleSelectPrompt = useCallback(
(id: string) => {
setMessages([Message.system("")]);
setProvider("");
setModel("");
setModelParams({ stream: true });
setApiKeyId("__auto__");
setUrlState({ promptId: id, sessionId: null, versionId: null });
},
[setUrlState],
);
const handleMovePrompt = useCallback(
async (promptId: string, folderId: string | null) => {
try {
await updatePrompt({ id: promptId, data: { folder_id: folderId } }).unwrap();
toast.success("Prompt moved successfully");
} catch (err) {
toast.error(getErrorMessage(err) || "Failed to move prompt");
}
},
[updatePrompt],
);
const handleDeleteFolder = useCallback(async () => {
if (!deleteFolderDialog.folder) return;
try {
await deleteFolder(deleteFolderDialog.folder.id).unwrap();
toast.success("Folder deleted");
setDeleteFolderDialog({ open: false });
if (selectedPrompt?.folder_id === deleteFolderDialog.folder.id) {
setUrlState({ promptId: null, sessionId: null, versionId: null });
}
} catch (err) {
toast.error("Failed to delete folder", { description: getErrorMessage(err) });
}
}, [deleteFolderDialog.folder, deleteFolder, selectedPrompt, setUrlState]);
const handleDeletePrompt = useCallback(async () => {
if (!deletePromptDialog.prompt) return;
try {
await deletePrompt(deletePromptDialog.prompt.id).unwrap();
toast.success("Prompt deleted");
setDeletePromptDialog({ open: false });
if (selectedPromptId === deletePromptDialog.prompt.id) {
setUrlState({ promptId: null, sessionId: null, versionId: null });
}
} catch (err) {
toast.error("Failed to delete prompt", { description: getErrorMessage(err) });
}
}, [deletePromptDialog.prompt, deletePrompt, selectedPromptId, setUrlState]);
const handleSendMessage = useCallback(
async (pendingMessage?: Message) => {
const runToken = Symbol();
activeRunRef.current = runToken;
const isActive = () => activeRunRef.current === runToken;
setIsStreaming(true);
await executePrompt(
messages,
pendingMessage,
{ provider, model, modelParams, apiKeyId, variables },
{
onStreamingStart: (allMessages, placeholder) => {
if (!isActive()) return;
setMessages([...allMessages, placeholder]);
},
onStreamChunk: (content) => {
if (!isActive()) return;
setMessages((prev) => {
const updated = [...prev];
const last = updated[updated.length - 1];
const clone = last.clone();
clone.content = content;
updated[updated.length - 1] = clone;
return updated;
});
},
onComplete: (content, usage) => {
if (!isActive()) return;
setMessages((prev) => {
const updated = [...prev];
updated[updated.length - 1] = Message.response(content, 0, usage);
return updated;
});
},
onToolCallComplete: (content, toolCalls, usage) => {
if (!isActive()) return;
setMessages((prev) => {
const updated = [...prev];
updated[updated.length - 1] = Message.toolCallResponse(content, toolCalls, 0, usage);
return updated;
});
},
onEmptyResponse: () => {
if (!isActive()) return;
setMessages((prev) => prev.slice(0, -1));
},
onError: (error) => {
if (!isActive()) return;
setMessages((prev) => {
const withoutPlaceholder = prev.slice(0, -1);
return [...withoutPlaceholder, Message.error(error)];
});
},
onFinally: () => {
if (!isActive()) return;
setIsStreaming(false);
},
},
);
},
[messages, provider, model, modelParams, apiKeyId, variables],
);
const handleSubmitToolResult = useCallback(
async (afterIndex: number, toolCallId: string, content: string) => {
const runToken = Symbol();
activeRunRef.current = runToken;
const isActive = () => activeRunRef.current === runToken;
const toolResultMsg = new Message(crypto.randomUUID(), 0, MessageType.ToolResult, {
role: MessageRole.TOOL,
content,
tool_call_id: toolCallId,
});
const newMessages = [...messages];
// Insert after any existing tool results that follow the assistant message
let insertAt = afterIndex + 1;
while (insertAt < newMessages.length && newMessages[insertAt].type === MessageType.ToolResult) {
insertAt++;
}
newMessages.splice(insertAt, 0, toolResultMsg);
setMessages(newMessages);
// Execute with the updated messages
setIsStreaming(true);
await executePrompt(
newMessages,
undefined,
{ provider, model, modelParams, apiKeyId, variables },
{
onStreamingStart: (allMessages, placeholder) => {
if (!isActive()) return;
setMessages([...allMessages, placeholder]);
},
onStreamChunk: (content) => {
if (!isActive()) return;
setMessages((prev) => {
const updated = [...prev];
const last = updated[updated.length - 1];
const clone = last.clone();
clone.content = content;
updated[updated.length - 1] = clone;
return updated;
});
},
onComplete: (content, usage) => {
if (!isActive()) return;
setMessages((prev) => {
const updated = [...prev];
updated[updated.length - 1] = Message.response(content, 0, usage);
return updated;
});
},
onToolCallComplete: (content, toolCalls, usage) => {
if (!isActive()) return;
setMessages((prev) => {
const updated = [...prev];
updated[updated.length - 1] = Message.toolCallResponse(content, toolCalls, 0, usage);
return updated;
});
},
onEmptyResponse: () => {
if (!isActive()) return;
setMessages((prev) => prev.slice(0, -1));
},
onError: (error) => {
if (!isActive()) return;
setMessages((prev) => {
const withoutPlaceholder = prev.slice(0, -1);
return [...withoutPlaceholder, Message.error(error)];
});
},
onFinally: () => {
if (!isActive()) return;
setIsStreaming(false);
},
},
);
},
[messages, provider, model, modelParams, apiKeyId, variables],
);
const value: PromptContextValue = {
folders,
prompts,
selectedPrompt,
sessions,
selectedSession,
selectedVersion,
foldersLoading,
promptsLoading,
foldersError,
promptsError,
isLoadingPlayground,
isStreaming,
selectedPromptId,
selectedSessionId,
selectedVersionId,
setUrlState,
messages,
setMessages,
provider,
setProvider,
model,
setModel,
modelParams,
setModelParams,
apiKeyId,
setApiKeyId,
variables,
setVariables,
folderSheet,
setFolderSheet,
promptSheet,
setPromptSheet,
commitSheet,
setCommitSheet,
deleteFolderDialog,
setDeleteFolderDialog,
deletePromptDialog,
setDeletePromptDialog,
isDeletingFolder,
isDeletingPrompt,
supportsVision,
hasChanges,
hasVersionChanges,
hasSessionChanges,
handleSelectPrompt,
handleMovePrompt,
handleDeleteFolder,
handleDeletePrompt,
handleSendMessage,
handleSubmitToolResult,
canCreate,
canUpdate,
canDelete,
};
return <PromptContext.Provider value={value}>{children}</PromptContext.Provider>;
}

View File

@@ -0,0 +1,14 @@
import { ScrollArea } from "@/components/ui/scrollArea";
import { MessagesView } from "../components/messagesView/rootMessageView";
import { NewMessageInputView } from "../components/newMessageInputView";
export function PlaygroundPanel() {
return (
<div className="custom-scrollbar relative flex h-full flex-col overscroll-none">
<ScrollArea className="flex-1 scroll-mb-12 overflow-y-auto" viewportClassName="no-table">
<MessagesView />
</ScrollArea>
<NewMessageInputView />
</div>
);
}

View File

@@ -0,0 +1,232 @@
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion";
import { ComboboxSelect } from "@/components/ui/combobox";
import ModelParameters from "@/components/ui/custom/modelParameters";
import { Label } from "@/components/ui/label";
import { ModelMultiselect } from "@/components/ui/modelMultiselect";
import { Separator } from "@/components/ui/separator";
import { Skeleton } from "@/components/ui/skeleton";
import { getProviderLabel } from "@/lib/constants/logs";
import { useGetVirtualKeysQuery } from "@/lib/store";
import { useGetAllKeysQuery, useGetProvidersQuery } from "@/lib/store/apis/providersApi";
import { ModelProviderName } from "@/lib/types/config";
import { ModelParams } from "@/lib/types/prompts";
import { cn } from "@/lib/utils";
import { PromptDeploymentsAccordionItem } from "@enterprise/components/prompt-deployments/promptDeploymentsAccordionItem";
import { useCallback, useMemo, useState } from "react";
import { ApiKeySelectorView } from "../components/apiKeySelectorView";
import { VariablesTableView } from "../components/variablesTableView";
import { usePromptContext } from "../context";
export function SettingsPanel() {
const {
provider,
setProvider,
model,
setModel: onModelChange,
modelParams,
setModelParams: onModelParamsChange,
apiKeyId,
setApiKeyId,
variables,
setVariables,
selectedPromptId,
} = usePromptContext();
const onProviderChange = useCallback(
(p: string) => {
setProvider(p);
setApiKeyId("__auto__");
onModelChange("");
onModelParamsChange({} as ModelParams);
},
[setProvider, setApiKeyId, onModelChange, onModelParamsChange],
);
const onApiKeyIdChange = useCallback(
(id: string) => {
setApiKeyId(id);
},
[setApiKeyId],
);
// Dynamic providers
const { data: providers, isLoading: isLoadingProviders } = useGetProvidersQuery();
const { data: virtualKeysData } = useGetVirtualKeysQuery();
// Keys for the API Key selector (from /api/keys endpoint, provider-filtered)
const { data: allKeys, isSuccess: hasLoadedAllKeys } = useGetAllKeysQuery();
const isInitialLoading = isLoadingProviders;
const configuredProviders = useMemo(() => {
const activeVirtualKeys = virtualKeysData?.virtual_keys?.filter((vk) => vk.is_active) ?? [];
if (!hasLoadedAllKeys) {
return providers ?? [];
}
const keyedProviders = new Set((allKeys ?? []).map((k) => k.provider));
return (providers ?? []).filter((p) => {
if (keyedProviders.has(p.name)) return true;
// Include providers that have active virtual keys (wildcard or explicitly targeting this provider)
return activeVirtualKeys.some(
(vk) => !vk.provider_configs || vk.provider_configs.length === 0 || vk.provider_configs.some((pc) => pc.provider === p.name),
);
});
}, [providers, virtualKeysData, allKeys, hasLoadedAllKeys]);
// Ensure current provider always has a label-resolved option (even before providers query loads)
const providerOptions = useMemo(() => {
const opts = configuredProviders.map((p) => ({ label: getProviderLabel(p.name), value: p.name }));
if (provider && !opts.find((o) => o.value === provider)) {
opts.unshift({ label: getProviderLabel(provider), value: provider as ModelProviderName });
}
return opts;
}, [configuredProviders, provider]);
const providerKeys = useMemo(() => (allKeys ?? []).filter((k) => k.provider === provider), [allKeys, provider]);
// Virtual keys filtered by selected provider
const providerVirtualKeys = useMemo(() => {
const vks = virtualKeysData?.virtual_keys ?? [];
return vks.filter((vk) => {
if (!vk.is_active) return false;
// No provider configs means all providers are allowed (wildcard)
if (!vk.provider_configs || vk.provider_configs.length === 0) return true;
// Check if selected provider is in the configured providers
return vk.provider_configs.some((pc) => pc.provider === provider);
});
}, [virtualKeysData, provider]);
// Separate keys/vks to pass to model fetch for filtering.
const filterKeys = useMemo(() => {
const isProviderKey = providerKeys.some((k) => k.key_id === apiKeyId);
if (isProviderKey) return [apiKeyId];
const isVirtualKey = providerVirtualKeys.some((vk) => vk.id === apiKeyId);
if (isVirtualKey) return undefined;
// Auto: pass all provider key IDs
return providerKeys.map((k) => k.key_id);
}, [apiKeyId, providerKeys, providerVirtualKeys]);
const filterVks = useMemo(() => {
const isVirtualKey = providerVirtualKeys.some((vk) => vk.id === apiKeyId);
if (isVirtualKey) return [apiKeyId];
return undefined;
}, [apiKeyId, providerVirtualKeys]);
const handleModelParamsChange = useCallback(
(params: Record<string, any>) => {
onModelParamsChange(params as ModelParams);
},
[onModelParamsChange],
);
const hasModel = Boolean(model);
type SettingsSection = "parameters" | "deployments";
const [openSection, setOpenSection] = useState<SettingsSection | undefined>("parameters");
if (isInitialLoading) {
return (
<div className="flex h-full flex-col">
<div className="space-y-6 p-4">
<div className="flex flex-col gap-2">
<Skeleton className="h-4 w-16" />
<Skeleton className="h-9 w-full rounded-sm" />
</div>
<div className="flex flex-col gap-2">
<Skeleton className="h-4 w-12" />
<Skeleton className="h-9 w-full rounded-sm" />
</div>
</div>
</div>
);
}
return (
<div className="flex h-full min-h-0 flex-col">
<div className="flex min-h-0 flex-1 flex-col px-4 pt-2 pb-4">
<Accordion
type="single"
collapsible
value={openSection ?? ""}
onValueChange={(v) => {
if (v === "parameters" || v === "deployments") {
setOpenSection(v);
} else {
setOpenSection(undefined);
}
}}
className="flex min-h-0 flex-1 flex-col"
>
<AccordionItem
value="parameters"
className={cn("flex min-h-0 flex-col border-b-0", openSection === "parameters" ? "flex-1" : "shrink-0 overflow-hidden")}
>
<AccordionTrigger
data-testid="prompts-configuration-trigger"
className="text-muted-foreground shrink-0 py-3 pr-1 text-xs font-medium uppercase hover:no-underline"
>
<span className="min-w-0 flex-1 text-left font-semibold">Configuration</span>
</AccordionTrigger>
<AccordionContent
containerClassName="data-[state=open]:flex data-[state=open]:min-h-0 data-[state=open]:flex-1 data-[state=open]:flex-col"
className="min-h-0 flex-1 overflow-y-auto pt-0 pb-2"
>
<div className="space-y-6">
<div className="flex flex-col gap-2" data-testid="settings-provider">
<Label className="text-muted-foreground text-xs font-medium uppercase">Provider</Label>
<ComboboxSelect
options={providerOptions}
value={provider}
onValueChange={(v) => v && onProviderChange(v)}
placeholder="Select provider"
hideClear
/>
</div>
<div className="flex flex-col gap-2" data-testid="settings-model">
<Label className="text-muted-foreground text-xs font-medium uppercase">Model</Label>
<ModelMultiselect
provider={provider}
keys={filterKeys && filterKeys.length > 0 ? filterKeys : undefined}
vks={filterVks}
value={model}
onChange={(v) => onModelChange(v)}
isSingleSelect
placeholder={!provider ? "Select a provider first" : "Select model"}
disabled={!provider}
unfiltered={true}
/>
</div>
{(providerKeys.length > 0 || providerVirtualKeys.length > 0) && !!provider && (
<ApiKeySelectorView
providerKeys={providerKeys}
virtualKeys={providerVirtualKeys}
value={apiKeyId}
onValueChange={(v) => onApiKeyIdChange(v ?? "__auto__")}
disabled={!provider}
/>
)}
{Object.keys(variables).length > 0 && (
<>
<Separator />
<VariablesTableView variables={variables} onChange={setVariables} />
</>
)}
{hasModel && (
<>
<Separator />
<div className="flex flex-col gap-4">
<ModelParameters model={model} config={modelParams} onChange={handleModelParamsChange} hideFields={["promptTools"]} />
</div>
</>
)}
</div>
</AccordionContent>
</AccordionItem>
{selectedPromptId && <PromptDeploymentsAccordionItem activeSection={openSection} />}
</Accordion>
</div>
</div>
);
}

View File

@@ -0,0 +1,584 @@
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdownMenu";
import { Input } from "@/components/ui/input";
import { ScrollArea } from "@/components/ui/scrollArea";
import { Folder, Prompt } from "@/lib/types/prompts";
import { cn } from "@/lib/utils";
import { DragDropProvider, useDraggable, useDroppable } from "@dnd-kit/react";
import {
ChevronDown,
ChevronRight,
FileText,
Folder as FolderIcon,
FolderOpen,
MoreHorizontal,
Pencil,
Plus,
PlusIcon,
Search,
Trash2,
} from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { usePromptContext } from "../context";
/**
* Renders the prompt-manager sidebar including search, folder hierarchy, root prompts, and drag-and-drop reorganization.
*
* The sidebar supports creating, renaming, and deleting folders and prompts (when permitted), selecting prompts, auto-expanding the folder that contains the selected prompt, filtering by search query, and dragging prompts between folders or to the root. Visual drag-over feedback and permission gating for create/update/delete actions are applied.
*
* @returns The sidebar React element containing the search input, folder list, root prompt drop zone, and drag-and-drop provider.
*/
export function PromptSidebar() {
const {
folders,
prompts,
selectedPromptId,
handleSelectPrompt: onSelectPrompt,
setFolderSheet,
setDeleteFolderDialog,
setPromptSheet,
setDeletePromptDialog,
handleMovePrompt: onMovePrompt,
canCreate,
canUpdate,
canDelete,
} = usePromptContext();
const onCreateFolder = useCallback(() => setFolderSheet({ open: true }), [setFolderSheet]);
const onEditFolder = useCallback((folder: Folder) => setFolderSheet({ open: true, folder }), [setFolderSheet]);
const onDeleteFolder = useCallback((folder: Folder) => setDeleteFolderDialog({ open: true, folder }), [setDeleteFolderDialog]);
const onCreatePrompt = useCallback((folderId?: string) => setPromptSheet({ open: true, folderId }), [setPromptSheet]);
const onEditPrompt = useCallback((prompt: Prompt) => setPromptSheet({ open: true, prompt }), [setPromptSheet]);
const onDeletePrompt = useCallback((prompt: Prompt) => setDeletePromptDialog({ open: true, prompt }), [setDeletePromptDialog]);
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set());
const [searchQuery, setSearchQuery] = useState("");
const [dragOverTarget, setDragOverTarget] = useState<string | null>(null);
// Auto-expand the folder containing the selected prompt
useEffect(() => {
if (!selectedPromptId) return;
const prompt = prompts.find((p) => p.id === selectedPromptId);
if (prompt?.folder_id) {
setExpandedFolders((prev) => {
if (prev.has(prompt.folder_id!)) return prev;
const next = new Set(prev);
next.add(prompt.folder_id!);
return next;
});
}
}, [selectedPromptId, prompts]);
const toggleFolder = useCallback((folderId: string) => {
setExpandedFolders((prev) => {
const next = new Set(prev);
if (next.has(folderId)) {
next.delete(folderId);
} else {
next.add(folderId);
}
return next;
});
}, []);
// Group prompts by folder, root prompts have no folder_id
const { promptsByFolder, rootPrompts } = useMemo(() => {
const map = new Map<string, Prompt[]>();
const root: Prompt[] = [];
for (const prompt of prompts) {
if (!prompt.folder_id) {
root.push(prompt);
} else {
const list = map.get(prompt.folder_id) || [];
list.push(prompt);
map.set(prompt.folder_id, list);
}
}
return { promptsByFolder: map, rootPrompts: root };
}, [prompts]);
// Filter folders and prompts based on search
const filteredData = useMemo(() => {
if (!searchQuery.trim()) {
return { folders, promptsByFolder, rootPrompts };
}
const query = searchQuery.toLowerCase();
const matchedFolderIds = new Set<string>();
const filteredPromptsByFolder = new Map<string, Prompt[]>();
const filteredRootPrompts: Prompt[] = [];
for (const prompt of prompts) {
if (prompt.name.toLowerCase().includes(query)) {
if (!prompt.folder_id) {
filteredRootPrompts.push(prompt);
} else {
matchedFolderIds.add(prompt.folder_id);
const list = filteredPromptsByFolder.get(prompt.folder_id) || [];
list.push(prompt);
filteredPromptsByFolder.set(prompt.folder_id, list);
}
}
}
const filteredFolders = folders.filter((folder) => folder.name.toLowerCase().includes(query) || matchedFolderIds.has(folder.id));
return {
folders: filteredFolders,
promptsByFolder: filteredPromptsByFolder,
rootPrompts: filteredRootPrompts,
};
}, [folders, prompts, promptsByFolder, rootPrompts, searchQuery]);
// Prompt lookup for drag events
const promptMap = useMemo(() => {
const map = new Map<string, Prompt>();
for (const p of prompts) map.set(p.id, p);
return map;
}, [prompts]);
return (
<DragDropProvider
onDragOver={(event) => {
if (!canUpdate) return;
const targetId = event.operation.target?.id as string | undefined;
setDragOverTarget(targetId ?? null);
}}
onDragEnd={(event) => {
setDragOverTarget(null);
if (!canUpdate) return;
if (event.canceled || !onMovePrompt) return;
const sourceId = event.operation.source?.id as string | undefined;
const targetId = event.operation.target?.id as string | undefined;
if (!sourceId || !targetId) return;
const promptId = sourceId.startsWith("prompt-") ? sourceId.slice(7) : null;
if (!promptId) return;
const prompt = promptMap.get(promptId);
if (!prompt) return;
let targetFolderId: string | null = null;
if (targetId === "root-drop-zone") {
targetFolderId = null;
} else if (targetId.startsWith("folder-")) {
targetFolderId = targetId.slice(7);
} else {
return;
}
if ((prompt.folder_id ?? null) === targetFolderId) return;
onMovePrompt(promptId, targetFolderId);
}}
>
<div className="flex h-full flex-col">
{/* Search */}
<div className="flex items-center gap-2 border-b p-3">
<div className="relative grow">
<Search className="text-muted-foreground absolute top-1/2 left-2.5 h-4 w-4 -translate-y-1/2" />
<Input
placeholder="Search prompts..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
data-testid="sidebar-search"
className="h-8 pl-8"
/>
</div>
{canCreate && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
className="h-8 w-8 shrink-0 bg-transparent"
data-testid="sidebar-create-menu"
aria-label="Create prompt or folder"
>
<PlusIcon className="h-3.5 w-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
data-testid="sidebar-create-prompt"
onClick={(e) => {
e.stopPropagation();
onCreatePrompt();
}}
>
New Prompt
</DropdownMenuItem>
<DropdownMenuItem
data-testid="sidebar-create-folder"
onClick={(e) => {
e.stopPropagation();
onCreateFolder();
}}
>
New Folder
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
<ScrollArea className="grow overflow-y-auto" viewportClassName="no-table viewport-table-height-full">
<div className="flex flex-col p-2 px-3">
{filteredData.folders.length === 0 && filteredData.rootPrompts.length === 0 ? (
<div className="text-muted-foreground py-8 text-center text-sm">{searchQuery ? "No results found" : "No prompts yet"}</div>
) : (
<>
{filteredData.folders.map((folder) => (
<DroppableFolder
key={folder.id}
folder={folder}
prompts={filteredData.promptsByFolder.get(folder.id) || promptsByFolder.get(folder.id) || []}
isExpanded={expandedFolders.has(folder.id) || !!searchQuery}
isDragOver={dragOverTarget === `folder-${folder.id}`}
selectedPromptId={selectedPromptId}
onToggle={() => toggleFolder(folder.id)}
onSelectPrompt={onSelectPrompt}
onEdit={() => onEditFolder(folder)}
onDelete={() => onDeleteFolder(folder)}
onCreatePrompt={() => onCreatePrompt(folder.id)}
onEditPrompt={onEditPrompt}
onDeletePrompt={onDeletePrompt}
canCreate={canCreate}
canUpdate={canUpdate}
canDelete={canDelete}
/>
))}
<RootDropZone
isDragOver={dragOverTarget === "root-drop-zone"}
rootPrompts={filteredData.rootPrompts}
selectedPromptId={selectedPromptId}
onSelectPrompt={onSelectPrompt}
onEditPrompt={onEditPrompt}
onDeletePrompt={onDeletePrompt}
canUpdate={canUpdate}
canDelete={canDelete}
/>
</>
)}
</div>
</ScrollArea>
</div>
</DragDropProvider>
);
}
interface RootDropZoneProps {
isDragOver: boolean;
rootPrompts: Prompt[];
selectedPromptId?: string | null;
onSelectPrompt: (promptId: string) => void;
onEditPrompt: (prompt: Prompt) => void;
onDeletePrompt: (prompt: Prompt) => void;
canUpdate: boolean;
canDelete: boolean;
}
/**
* Renders the droppable root area that lists and hosts draggable root-level prompts.
*
* @param isDragOver - Whether a draggable item is currently over the root drop zone (applies drag-over styling).
* @param rootPrompts - Array of prompts that belong at the root (no folder).
* @param selectedPromptId - ID of the currently selected prompt, used to mark its item as selected.
* @param onSelectPrompt - Callback invoked with a prompt ID when a prompt is selected.
* @param onEditPrompt - Callback invoked with a prompt when the prompt's edit action is triggered.
* @param onDeletePrompt - Callback invoked with a prompt when the prompt's delete action is triggered.
* @param canUpdate - Whether prompts are movable/editable (enables dragging).
* @param canDelete - Whether prompts may be deleted (controls delete action visibility).
* @returns The JSX element for the root drop zone containing draggable prompt items.
*/
function RootDropZone({
isDragOver,
rootPrompts,
selectedPromptId,
onSelectPrompt,
onEditPrompt,
onDeletePrompt,
canUpdate,
canDelete,
}: RootDropZoneProps) {
const { ref } = useDroppable({ id: "root-drop-zone" });
return (
<div ref={ref} className={cn("min-h-[8px] grow rounded-sm transition-colors", isDragOver && "bg-primary/10 ring-primary/30 ring-1")}>
{rootPrompts.map((prompt) => (
<DraggablePromptItem
key={prompt.id}
prompt={prompt}
isSelected={selectedPromptId === prompt.id}
onSelect={() => onSelectPrompt(prompt.id)}
onEdit={() => onEditPrompt(prompt)}
onDelete={() => onDeletePrompt(prompt)}
canUpdate={canUpdate}
canDelete={canDelete}
/>
))}
</div>
);
}
interface DroppableFolderProps {
folder: Folder;
prompts: Prompt[];
isExpanded: boolean;
isDragOver: boolean;
selectedPromptId?: string | null;
onToggle: () => void;
onSelectPrompt: (promptId: string) => void;
onEdit: () => void;
onDelete: () => void;
onCreatePrompt: () => void;
onEditPrompt: (prompt: Prompt) => void;
onDeletePrompt: (prompt: Prompt) => void;
canCreate: boolean;
canUpdate: boolean;
canDelete: boolean;
}
/**
* Renders a droppable folder header with optional action menu and its list of prompts.
*
* @param folder - Folder metadata (id, name, etc.)
* @param prompts - Prompts that belong to this folder
* @param isExpanded - Whether the folder is expanded to show its prompts
* @param isDragOver - Whether a draggable item is currently over this folder (affects visual state)
* @param selectedPromptId - ID of the currently selected prompt, used to highlight an item
* @param onToggle - Callback invoked to toggle the folder's expanded state
* @param onSelectPrompt - Callback invoked with a prompt ID when a prompt is selected
* @param onEdit - Callback invoked to start editing the folder
* @param onDelete - Callback invoked to start deleting the folder
* @param onCreatePrompt - Callback invoked to create a new prompt inside this folder
* @param onEditPrompt - Callback invoked with a prompt to start editing that prompt
* @param onDeletePrompt - Callback invoked with a prompt to start deleting that prompt
* @param canCreate - Whether the current user may create prompts in this folder
* @param canUpdate - Whether the current user may move/rename prompts or edit the folder
* @param canDelete - Whether the current user may delete prompts or the folder
* @returns A JSX element containing the folder row and, when expanded, its nested prompt items
*/
function DroppableFolder({
folder,
prompts,
isExpanded,
isDragOver,
selectedPromptId,
onToggle,
onSelectPrompt,
onEdit,
onDelete,
onCreatePrompt,
onEditPrompt,
onDeletePrompt,
canCreate,
canUpdate,
canDelete,
}: DroppableFolderProps) {
const { ref } = useDroppable({ id: `folder-${folder.id}` });
const showActions = canCreate || canUpdate || canDelete;
return (
<div ref={ref} className="mb-1 last:mb-0">
<div
className={cn(
"hover:bg-muted/50 group relative flex h-[30px] cursor-pointer items-center gap-1 rounded-sm px-2 transition-colors",
isDragOver && "bg-primary/10 ring-primary/30 ring-1",
)}
onClick={onToggle}
data-testid={`sidebar-folder-${folder.id}`}
>
<button className="flex shrink-0 items-center" aria-label="Toggle folder">
{isExpanded ? (
<ChevronDown className="text-muted-foreground h-4 w-4" />
) : (
<ChevronRight className="text-muted-foreground h-4 w-4" />
)}
</button>
{isExpanded ? (
<FolderOpen className="text-muted-foreground h-4 w-4 shrink-0" />
) : (
<FolderIcon className="text-muted-foreground h-4 w-4 shrink-0" />
)}
<span className="flex-1 truncate text-sm font-medium">{folder.name}</span>
<span className="text-muted-foreground mr-1 shrink-0 text-xs">{prompts.length}</span>
{showActions && (
<DropdownMenu>
<DropdownMenuTrigger asChild onClick={(e) => e.stopPropagation()} className="bg-card absolute top-1/2 right-2 -translate-y-1/2">
<Button
variant="ghost"
size="icon"
className="h-6 w-6 shrink-0 opacity-0 group-focus-within:opacity-100 group-hover:opacity-100 focus-visible:opacity-100"
data-testid={`sidebar-folder-actions-${folder.id}`}
aria-label="Folder actions"
>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{canCreate && (
<DropdownMenuItem
data-testid="folder-create-prompt"
onClick={(e) => {
e.stopPropagation();
onCreatePrompt();
}}
>
<Plus className="mr-2 h-4 w-4" />
New Prompt
</DropdownMenuItem>
)}
{canCreate && (canUpdate || canDelete) && <DropdownMenuSeparator />}
{canUpdate && (
<DropdownMenuItem
data-testid="folder-action-edit"
onClick={(e) => {
e.stopPropagation();
onEdit();
}}
>
<Pencil className="h-4 w-4" />
Edit Folder
</DropdownMenuItem>
)}
{canDelete && (
<DropdownMenuItem
variant="destructive"
data-testid="folder-action-delete"
onClick={(e) => {
e.stopPropagation();
onDelete();
}}
>
<Trash2 className="h-4 w-4" />
Delete Folder
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
{isExpanded && (
<div className="ml-4 border-l pl-2">
{prompts.length === 0 ? (
<div className="text-muted-foreground py-2 pl-4 text-xs">{isDragOver ? "Drop here" : "No prompts"}</div>
) : (
prompts.map((prompt) => (
<DraggablePromptItem
key={prompt.id}
prompt={prompt}
isSelected={selectedPromptId === prompt.id}
onSelect={() => onSelectPrompt(prompt.id)}
onEdit={() => onEditPrompt(prompt)}
onDelete={() => onDeletePrompt(prompt)}
canUpdate={canUpdate}
canDelete={canDelete}
/>
))
)}
</div>
)}
</div>
);
}
interface DraggablePromptItemProps {
prompt: Prompt;
isSelected: boolean;
onSelect: () => void;
onEdit: () => void;
onDelete: () => void;
canUpdate: boolean;
canDelete: boolean;
}
/**
* Renders a draggable prompt list item that shows the prompt name, selection/drag states, and an actions menu when permitted.
*
* Displays a file icon and truncated prompt name, applies visual styles for selection and dragging, prevents selection while dragging, and exposes rename/delete actions via a dropdown when `canUpdate` or `canDelete` are true.
*
* @param prompt - The prompt object to render.
* @param isSelected - Whether this prompt is currently selected; used for styling.
* @param onSelect - Callback invoked when the item is clicked (not invoked if the item is being dragged).
* @param onEdit - Callback invoked to start editing/renaming the prompt.
* @param onDelete - Callback invoked to delete the prompt.
* @param canUpdate - When true, enables dragging and shows the rename action.
* @param canDelete - When true, shows the delete action.
* @returns The rendered prompt item JSX element.
*/
function DraggablePromptItem({ prompt, isSelected, onSelect, onEdit, onDelete, canUpdate, canDelete }: DraggablePromptItemProps) {
const { ref, isDragging } = useDraggable({
id: `prompt-${prompt.id}`,
disabled: !canUpdate,
});
const showActions = canUpdate || canDelete;
return (
<div
ref={ref}
data-testid={`sidebar-prompt-${prompt.id}`}
className={cn(
"group mb-1 flex h-[30px] cursor-pointer items-center gap-2 rounded-sm px-2 last:mb-0",
isSelected ? "bg-primary/10 text-primary" : "hover:bg-muted/50",
isDragging && "opacity-50",
)}
onClick={() => {
// Don't navigate if this was a drag
if (isDragging) return;
onSelect();
}}
>
<FileText className="h-4 w-4 shrink-0" />
<span className="flex-1 truncate text-sm">{prompt.name}</span>
{showActions && (
<DropdownMenu>
<DropdownMenuTrigger asChild onClick={(e) => e.stopPropagation()}>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 opacity-0 group-focus-within:opacity-100 group-hover:opacity-100 focus-visible:opacity-100"
data-testid={`sidebar-prompt-actions-${prompt.id}`}
aria-label="Prompt actions"
>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{canUpdate && (
<DropdownMenuItem
className="cursor-pointer"
data-testid="prompt-action-rename"
onClick={(e) => {
e.stopPropagation();
onEdit();
}}
>
<Pencil className="h-4 w-4" />
Rename
</DropdownMenuItem>
)}
{canDelete && (
<DropdownMenuItem
variant="destructive"
className="cursor-pointer"
data-testid="prompt-action-delete"
onClick={(e) => {
e.stopPropagation();
onDelete();
}}
>
<Trash2 className="h-4 w-4" />
Delete
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
);
}

View File

@@ -0,0 +1,83 @@
import FullPageLoader from "@/components/fullPageLoader";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable";
import { AlertCircle, Loader2 } from "lucide-react";
import { PromptSidebar } from "./fragments/sidebar";
import { PlaygroundPanel } from "./fragments/playgroundPanel";
import { SettingsPanel } from "./fragments/settingsPanel";
import { DeleteFolderDialog, DeletePromptDialog } from "./components/alerts";
import { PromptSheets } from "./components/sheets";
import { EmptyState, PromptsEmptyState } from "./components/emptyState";
import PromptsViewHeader from "./components/promptsViewHeader";
import { usePromptContext } from "./context";
export default function PromptsView() {
const { folders, prompts, foldersLoading, promptsLoading, foldersError, promptsError, isLoadingPlayground, selectedPromptId } =
usePromptContext();
if (foldersLoading || promptsLoading) {
return <FullPageLoader />;
}
if (foldersError || promptsError) {
return (
<div className="no-padding-parent no-border-parent p-4">
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>Failed to load prompt repository</AlertDescription>
</Alert>
</div>
);
}
if (folders.length === 0 && prompts.length === 0) {
return (
<div className="no-padding-parent no-border-parent flex h-[calc(100dvh_-_18px)] w-full items-center">
<PromptSheets />
<PromptsEmptyState />
</div>
);
}
return (
<div className="no-padding-parent no-border-parent bg-background h-[calc(100dvh_-_16px)] w-full">
<DeleteFolderDialog />
<DeletePromptDialog />
<PromptSheets />
<ResizablePanelGroup direction="horizontal" className="h-full">
<ResizablePanel defaultSize={20} className="bg-card mr-1 overflow-hidden rounded-r-md">
<PromptSidebar />
</ResizablePanel>
<ResizableHandle className="mr-1 bg-transparent" />
<ResizablePanel defaultSize={80} minSize={50} className="bg-card overflow-hidden rounded-md">
{selectedPromptId ? (
<div className="flex h-full flex-col">
<PromptsViewHeader />
{isLoadingPlayground ? (
<div className="flex flex-1 items-center justify-center">
<Loader2 className="text-muted-foreground h-5 w-5 animate-spin" />
</div>
) : (
<ResizablePanelGroup direction="horizontal" className="flex-1">
<ResizablePanel defaultSize={70} minSize={40}>
<PlaygroundPanel />
</ResizablePanel>
<ResizableHandle />
<ResizablePanel defaultSize={30} minSize={20}>
<SettingsPanel />
</ResizablePanel>
</ResizablePanelGroup>
)}
</div>
) : (
<EmptyState />
)}
</ResizablePanel>
</ResizablePanelGroup>
</div>
);
}

View File

@@ -0,0 +1,219 @@
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { ScrollArea } from "@/components/ui/scrollArea";
import { Sheet, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle } from "@/components/ui/sheet";
import { Message, MessageType } from "@/lib/message";
import { Markdown } from "@/components/ui/markdown";
import { getErrorMessage } from "@/lib/store";
import { useCommitSessionMutation } from "@/lib/store/apis/promptsApi";
import { PromptSession, PromptSessionMessage } from "@/lib/types/prompts";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { cn } from "@/lib/utils";
interface CommitVersionFormData {
commitMessage: string;
}
interface CommitVersionSheetProps {
open: boolean;
onOpenChange: (open: boolean) => void;
session: PromptSession;
onCommitted: (versionId: number) => void;
}
function MessagePreview({
sessionMessage,
selected,
onToggle,
}: {
sessionMessage: PromptSessionMessage;
selected: boolean;
onToggle: () => void;
}) {
const msg = useMemo(() => Message.deserialize(sessionMessage.message), [sessionMessage.message]);
const role = msg.role;
const content = msg.content;
const hasToolCalls = msg.type === MessageType.CompletionResult && msg.toolCalls && msg.toolCalls.length > 0;
return (
<label
className={cn(
"group flex items-start gap-3 rounded-md border px-3 py-2.5 cursor-pointer transition-colors",
selected ? "border-border" : "border-transparent",
)}
>
<Checkbox checked={selected} onCheckedChange={onToggle} className="mt-1 shrink-0" />
<div className="min-w-0 flex-1">
<span className="text-xs font-medium uppercase">{role}</span>
<div className="text-muted-foreground mt-1 line-clamp-3 text-sm">
{hasToolCalls && !content ? (
<span className="italic">Tool call: {msg.toolCalls!.map((tc) => tc.function.name).join(", ")}</span>
) : content ? (
<Markdown content={content} className="text-muted-foreground [&_*]:text-sm" />
) : (
<span className="italic">Empty message</span>
)}
</div>
</div>
</label>
);
}
export function CommitVersionSheet({ open, onOpenChange, session, onCommitted }: CommitVersionSheetProps) {
const [commitSession, { isLoading }] = useCommitSessionMutation();
const [selectedIndices, setSelectedIndices] = useState<Set<number>>(new Set());
const {
register,
handleSubmit,
reset,
formState: { errors },
} = useForm<CommitVersionFormData>({
defaultValues: { commitMessage: "" },
});
// Reset form and select only the first message when sheet opens
useEffect(() => {
if (open) {
reset({ commitMessage: "" });
setSelectedIndices(new Set(session.messages.length > 0 ? [0] : []));
}
}, [open, reset, session?.messages?.length]);
const toggleMessage = useCallback((index: number) => {
setSelectedIndices((prev) => {
const next = new Set(prev);
if (next.has(index)) {
next.delete(index);
} else {
next.add(index);
}
return next;
});
}, []);
const allSelected = selectedIndices.size === session.messages.length;
const toggleAll = useCallback(() => {
if (allSelected) {
setSelectedIndices(new Set());
} else {
setSelectedIndices(new Set(session.messages.map((_, i) => i)));
}
}, [allSelected, session.messages]);
async function onSubmit(data: CommitVersionFormData) {
if (selectedIndices.size === 0) {
toast.error("Please select at least one message to commit");
return;
}
try {
const sortedIndices = Array.from(selectedIndices).sort((a, b) => a - b);
const commitData: { commit_message: string; message_indices?: number[] } = {
commit_message: data.commitMessage.trim(),
};
// Only send message_indices if not all messages are selected
if (!allSelected) {
commitData.message_indices = sortedIndices;
}
const result = await commitSession({
id: session.id,
promptId: session.prompt_id,
data: commitData,
}).unwrap();
toast.success("Version committed");
reset();
onCommitted(result.version.id);
onOpenChange(false);
} catch (err) {
toast.error("Failed to commit version", {
description: getErrorMessage(err),
});
}
}
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent
className="flex h-full flex-col p-8"
onOpenAutoFocus={(e) => {
e.preventDefault();
document.getElementById("commitMessage")?.focus();
}}
>
<form onSubmit={handleSubmit(onSubmit)} className="flex flex-1 flex-col overflow-hidden">
<SheetHeader className="flex flex-col items-start">
<SheetTitle>Commit as Version</SheetTitle>
<SheetDescription>Select the messages to include in this version. Uncheck any messages you want to exclude.</SheetDescription>
</SheetHeader>
{/* Messages selection - scrollable */}
<div className="mt-4 flex flex-1 flex-col overflow-hidden">
<div className="mb-2 flex items-center justify-between">
<Label className="text-sm">
Messages ({selectedIndices.size}/{session.messages.length})
</Label>
<button type="button" onClick={toggleAll} className="text-muted-foreground hover:text-foreground text-xs transition-colors">
{allSelected ? "Deselect all" : "Select all"}
</button>
</div>
<ScrollArea className="flex-1 overflow-y-auto rounded-md border">
<div className="space-y-1 p-2">
{session.messages.map((sessionMsg, index) => (
<MessagePreview
key={sessionMsg.id}
sessionMessage={sessionMsg}
selected={selectedIndices.has(index)}
onToggle={() => toggleMessage(index)}
/>
))}
</div>
</ScrollArea>
</div>
{/* Commit message + CTAs - always visible at bottom */}
<div className="mt-4 shrink-0 space-y-4">
<div className="space-y-2">
<Label htmlFor="commitMessage">Commit Message</Label>
<Input
id="commitMessage"
data-testid="commit-version-message"
placeholder="Added system message for better context..."
{...register("commitMessage", {
required: "Commit message is required",
validate: (v) => v.trim().length > 0 || "Commit message cannot be blank",
})}
autoFocus
/>
{errors.commitMessage ? (
<p className="text-destructive text-xs">{errors.commitMessage.message}</p>
) : (
<p className="text-muted-foreground text-xs">
Describe what changed in this version (e.g., &quot;Added error handling instructions&quot;)
</p>
)}
</div>
<SheetFooter className="flex flex-row items-center justify-end gap-2 p-0">
<Button type="button" variant="outline" data-testid="commit-version-cancel" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button
type="submit"
data-testid="commit-version-submit"
disabled={isLoading || selectedIndices.size === 0}
className={selectedIndices.size === 0 ? "opacity-50" : ""}
>
{isLoading ? "Committing..." : "Commit Version"}
</Button>
</SheetFooter>
</div>
</form>
</SheetContent>
</Sheet>
);
}

View File

@@ -0,0 +1,131 @@
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Sheet, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle } from "@/components/ui/sheet";
import { Textarea } from "@/components/ui/textarea";
import { getErrorMessage } from "@/lib/store";
import { useCreateFolderMutation, useUpdateFolderMutation } from "@/lib/store/apis/promptsApi";
import { Folder } from "@/lib/types/prompts";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
interface FolderFormData {
name: string;
description: string;
}
interface FolderSheetProps {
open: boolean;
onOpenChange: (open: boolean) => void;
folder?: Folder;
onSaved: () => void;
}
export function FolderSheet({ open, onOpenChange, folder, onSaved }: FolderSheetProps) {
const [createFolder, { isLoading: isCreating }] = useCreateFolderMutation();
const [updateFolder, { isLoading: isUpdating }] = useUpdateFolderMutation();
const isLoading = isCreating || isUpdating;
const isEditing = !!folder;
const {
register,
handleSubmit,
reset,
formState: { errors },
} = useForm<FolderFormData>({
defaultValues: { name: "", description: "" },
});
useEffect(() => {
if (open) {
reset({
name: folder?.name ?? "",
description: folder?.description ?? "",
});
}
}, [open, folder, reset]);
async function onSubmit(data: FolderFormData) {
try {
if (isEditing) {
await updateFolder({
id: folder.id,
data: { name: data.name.trim(), description: data.description.trim() || undefined },
}).unwrap();
toast.success("Folder updated");
} else {
await createFolder({
name: data.name.trim(),
description: data.description.trim() || undefined,
}).unwrap();
toast.success("Folder created");
}
onSaved();
onOpenChange(false);
} catch (err) {
toast.error(`Failed to ${isEditing ? "update" : "create"} folder`, {
description: getErrorMessage(err),
});
}
}
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent
className="p-8"
onOpenAutoFocus={(e) => {
e.preventDefault();
document.getElementById("name")?.focus();
}}
>
<form onSubmit={handleSubmit(onSubmit)}>
<SheetHeader className="flex flex-col items-start">
<SheetTitle>{isEditing ? "Edit Folder" : "Create Folder"}</SheetTitle>
<SheetDescription>
{isEditing ? "Update the folder name and description." : "Create a new folder to organize your prompts."}
</SheetDescription>
</SheetHeader>
<div className="mt-6 space-y-4">
<div className="space-y-2">
<Label htmlFor="name">Name</Label>
<Input
id="name"
data-testid="folder-name-input"
placeholder="My Prompts"
{...register("name", {
required: "Folder name is required",
validate: (v) => v.trim().length > 0 || "Folder name cannot be blank",
})}
autoFocus
/>
{errors.name && <p className="text-destructive text-xs">{errors.name.message}</p>}
</div>
<div className="space-y-2">
<Label htmlFor="description">Description (optional)</Label>
<Textarea
id="description"
data-testid="folder-description-input"
placeholder="Prompts for customer support use cases..."
className="resize-none"
{...register("description")}
/>
</div>
</div>
<SheetFooter className="mt-6 flex flex-row items-center justify-end gap-2 p-0">
<Button type="button" variant="outline" data-testid="folder-cancel" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button type="submit" data-testid="folder-submit" disabled={isLoading}>
{isLoading ? "Saving..." : isEditing ? "Update" : "Create"}
</Button>
</SheetFooter>
</form>
</SheetContent>
</Sheet>
);
}

View File

@@ -0,0 +1,117 @@
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Sheet, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle } from "@/components/ui/sheet";
import { getErrorMessage } from "@/lib/store";
import { useCreatePromptMutation, useUpdatePromptMutation } from "@/lib/store/apis/promptsApi";
import { Prompt } from "@/lib/types/prompts";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
interface PromptFormData {
name: string;
}
interface PromptSheetProps {
open: boolean;
onOpenChange: (open: boolean) => void;
prompt?: Prompt;
folderId?: string;
onSaved: (promptId?: string) => void;
}
export function PromptSheet({ open, onOpenChange, prompt, folderId, onSaved }: PromptSheetProps) {
const [createPrompt, { isLoading: isCreating }] = useCreatePromptMutation();
const [updatePrompt, { isLoading: isUpdating }] = useUpdatePromptMutation();
const isLoading = isCreating || isUpdating;
const isEditing = !!prompt;
const {
register,
handleSubmit,
reset,
formState: { errors },
} = useForm<PromptFormData>({
defaultValues: { name: "" },
});
useEffect(() => {
if (open) {
reset({ name: prompt?.name ?? "" });
}
}, [open, prompt, reset]);
async function onSubmit(data: PromptFormData) {
try {
if (isEditing) {
await updatePrompt({
id: prompt.id,
data: { name: data.name.trim() },
}).unwrap();
toast.success("Prompt updated");
onSaved();
} else {
const result = await createPrompt({
name: data.name.trim(),
...(folderId ? { folder_id: folderId } : {}),
}).unwrap();
toast.success("Prompt created");
onSaved(result.prompt.id);
}
onOpenChange(false);
} catch (err) {
toast.error(`Failed to ${isEditing ? "update" : "create"} prompt`, {
description: getErrorMessage(err),
});
}
}
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent
className="p-8"
onOpenAutoFocus={(e) => {
e.preventDefault();
document.getElementById("name")?.focus();
}}
>
<form onSubmit={handleSubmit(onSubmit)}>
<SheetHeader className="flex flex-col items-start">
<SheetTitle>{isEditing ? "Rename Prompt" : "Create Prompt"}</SheetTitle>
<SheetDescription>
{isEditing ? "Update the prompt name." : folderId ? "Create a new prompt in this folder." : "Create a new prompt."}
</SheetDescription>
</SheetHeader>
<div className="mt-6 space-y-4">
<div className="space-y-2">
<Label htmlFor="name">Name</Label>
<Input
id="name"
data-testid="prompt-name-input"
placeholder="Customer Support Assistant"
{...register("name", {
required: "Prompt name is required",
validate: (v) => v.trim().length > 0 || "Prompt name cannot be blank",
})}
autoFocus
/>
{errors.name && <p className="text-destructive text-xs">{errors.name.message}</p>}
</div>
</div>
<SheetFooter className="mt-6 flex flex-row items-center justify-end gap-2 p-0">
<Button type="button" variant="outline" data-testid="prompt-cancel" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button type="submit" data-testid="prompt-submit" disabled={isLoading}>
{isLoading ? "Saving..." : isEditing ? "Update" : "Create"}
</Button>
</SheetFooter>
</form>
</SheetContent>
</Sheet>
);
}

View File

@@ -0,0 +1,42 @@
import { MessageContent } from "@/lib/message";
export function fileToBase64(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as string);
reader.onerror = reject;
reader.readAsDataURL(file);
});
}
export async function fileToAttachment(file: File): Promise<MessageContent | null> {
if (file.type.startsWith("image/")) {
const dataUrl = await fileToBase64(file);
return {
type: "image_url",
image_url: { url: dataUrl, detail: "auto" },
};
}
if (file.type.startsWith("audio/")) {
const dataUrl = await fileToBase64(file);
// Extract base64 data and format from data URL
const base64Data = dataUrl.split(",")[1] || "";
const format = file.name.split(".").pop() || file.type.split("/")[1] || "wav";
return {
type: "input_audio",
input_audio: { data: base64Data, format },
};
}
// Generic file — API expects full data URL with MIME prefix
const dataUrl = await fileToBase64(file);
return {
type: "file",
file: {
file_data: dataUrl,
filename: file.name,
file_type: file.type || "application/octet-stream",
},
};
}

View File

@@ -0,0 +1,183 @@
import { Message, type CompletionUsage, type ToolCall, type VariableMap, replaceVariablesInMessages } from "@/lib/message";
import { getErrorMessage } from "@/lib/store";
import type { ModelParams } from "@/lib/types/prompts";
export interface ExecutionConfig {
provider: string;
model: string;
modelParams: ModelParams;
apiKeyId: string;
variables?: VariableMap;
}
function getBaseUrl() {
if (process.env.NODE_ENV === "development") {
return "http://localhost:8080";
} else {
return "";
}
}
export interface ExecutionCallbacks {
onStreamingStart: (allMessages: Message[], placeholder: Message) => void;
onStreamChunk: (content: string) => void;
onComplete: (content: string, usage?: CompletionUsage) => void;
onToolCallComplete: (content: string, toolCalls: ToolCall[], usage?: CompletionUsage) => void;
onEmptyResponse: () => void;
onError: (error: string) => void;
onFinally: () => void;
}
export async function executePrompt(
currentMessages: Message[],
pendingMessage: Message | undefined,
config: ExecutionConfig,
callbacks: ExecutionCallbacks,
) {
let allMessages: Message[];
if (pendingMessage) {
allMessages = [...currentMessages, pendingMessage];
} else {
allMessages = [...currentMessages];
}
const placeholder = Message.response("");
callbacks.onStreamingStart(allMessages, placeholder);
// Replace Jinja2 variables before sending to the API
const resolvedMessages = config.variables ? replaceVariablesInMessages(allMessages, config.variables) : allMessages;
try {
const headers: Record<string, string> = { "Content-Type": "application/json" };
if (config.apiKeyId && config.apiKeyId !== "__auto__") {
if (config.apiKeyId.startsWith("sk-bf-")) {
headers["Authorization"] = `Bearer ${config.apiKeyId}`;
} else {
headers["x-bf-api-key-id"] = config.apiKeyId;
}
}
const { api_key_id: _, ...requestParams } = config.modelParams;
const response = await fetch(`${getBaseUrl()}/v1/chat/completions`, {
method: "POST",
headers,
body: JSON.stringify({
model: `${config.provider}/${config.model}`,
messages: Message.toAPIMessages(resolvedMessages),
...requestParams,
stream: requestParams.stream,
}),
});
if (!response.ok) {
let errorMessage = `HTTP error! status: ${response.status}`;
try {
const data = await response.json();
errorMessage = data.error?.error || data.error?.message || errorMessage;
} catch (error) {
console.error("Failed to parse error response:", error);
}
throw new Error(errorMessage);
}
const contentType = response.headers.get("content-type") || "";
const isStreamResponse = contentType.includes("text/event-stream");
if (!isStreamResponse) {
const data = await response.json();
const content = data.choices?.[0]?.message?.content ?? "";
const toolCalls = data.choices?.[0]?.message?.tool_calls as ToolCall[] | undefined;
const usage = data.usage as CompletionUsage | undefined;
if (toolCalls && toolCalls.length > 0) {
callbacks.onToolCallComplete(content, toolCalls, usage);
} else if (content) {
callbacks.onComplete(content, usage);
} else {
callbacks.onEmptyResponse();
}
} else {
const reader = response.body?.getReader();
if (!reader) throw new Error("No response body");
const decoder = new TextDecoder();
let assistantContent = "";
let streamUsage: CompletionUsage | undefined;
const toolCallsMap = new Map<number, ToolCall>();
let buffer = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
// Keep the last (potentially incomplete) line in the buffer
buffer = lines.pop() ?? "";
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed.startsWith("data: ")) continue;
const data = trimmed.slice(6);
if (data === "[DONE]") continue;
try {
const parsed = JSON.parse(data);
const delta = parsed.choices?.[0]?.delta;
if (parsed.usage) {
streamUsage = parsed.usage as CompletionUsage;
}
const content = delta?.content;
if (content) {
assistantContent += content;
callbacks.onStreamChunk(assistantContent);
}
const deltaToolCalls = delta?.tool_calls as Array<{
index: number;
id?: string;
type?: string;
function?: { name?: string; arguments?: string };
}>;
if (deltaToolCalls) {
for (const dtc of deltaToolCalls) {
const idx = dtc.index;
const existing = toolCallsMap.get(idx);
if (existing) {
if (dtc.function?.arguments) {
existing.function.arguments += dtc.function.arguments;
}
} else {
toolCallsMap.set(idx, {
type: "function",
id: dtc.id ?? "",
function: {
name: dtc.function?.name ?? "",
arguments: dtc.function?.arguments ?? "",
},
});
}
}
}
} catch {
// Ignore parse errors
}
}
}
const toolCalls = Array.from(toolCallsMap.values());
if (toolCalls.length > 0) {
callbacks.onToolCallComplete(assistantContent, toolCalls, streamUsage);
} else if (assistantContent) {
callbacks.onComplete(assistantContent, streamUsage);
} else {
callbacks.onEmptyResponse();
}
}
} catch (err) {
callbacks.onError(getErrorMessage(err));
} finally {
callbacks.onFinally();
}
}