first commit
This commit is contained in:
71
ui/components/prompts/components/alerts.tsx
Normal file
71
ui/components/prompts/components/alerts.tsx
Normal 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 "{deleteFolderDialog.folder?.name}"? 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 "{deletePromptDialog.prompt?.name}"? 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>
|
||||
);
|
||||
}
|
||||
92
ui/components/prompts/components/apiKeySelectorView.tsx
Normal file
92
ui/components/prompts/components/apiKeySelectorView.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
73
ui/components/prompts/components/emptyState.tsx
Normal file
73
ui/components/prompts/components/emptyState.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
183
ui/components/prompts/components/messagesView/toolCallView.tsx
Normal file
183
ui/components/prompts/components/messagesView/toolCallView.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
270
ui/components/prompts/components/newMessageInputView.tsx
Normal file
270
ui/components/prompts/components/newMessageInputView.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
369
ui/components/prompts/components/promptsViewHeader.tsx
Normal file
369
ui/components/prompts/components/promptsViewHeader.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
42
ui/components/prompts/components/sheets.tsx
Normal file
42
ui/components/prompts/components/sheets.tsx
Normal 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 });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
58
ui/components/prompts/components/variablesTableView.tsx
Normal file
58
ui/components/prompts/components/variablesTableView.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
623
ui/components/prompts/context.tsx
Normal file
623
ui/components/prompts/context.tsx
Normal 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>;
|
||||
}
|
||||
14
ui/components/prompts/fragments/playgroundPanel.tsx
Normal file
14
ui/components/prompts/fragments/playgroundPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
232
ui/components/prompts/fragments/settingsPanel.tsx
Normal file
232
ui/components/prompts/fragments/settingsPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
584
ui/components/prompts/fragments/sidebar.tsx
Normal file
584
ui/components/prompts/fragments/sidebar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
83
ui/components/prompts/promptsView.tsx
Normal file
83
ui/components/prompts/promptsView.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
219
ui/components/prompts/sheets/commitVersionSheet.tsx
Normal file
219
ui/components/prompts/sheets/commitVersionSheet.tsx
Normal 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., "Added error handling instructions")
|
||||
</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>
|
||||
);
|
||||
}
|
||||
131
ui/components/prompts/sheets/folderSheet.tsx
Normal file
131
ui/components/prompts/sheets/folderSheet.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
117
ui/components/prompts/sheets/promptSheet.tsx
Normal file
117
ui/components/prompts/sheets/promptSheet.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
42
ui/components/prompts/utils/attachment.ts
Normal file
42
ui/components/prompts/utils/attachment.ts
Normal 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",
|
||||
},
|
||||
};
|
||||
}
|
||||
183
ui/components/prompts/utils/executor.ts
Normal file
183
ui/components/prompts/utils/executor.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user