426 lines
15 KiB
TypeScript
426 lines
15 KiB
TypeScript
import { CheckIcon, XMarkIcon } from "@heroicons/react/20/solid";
|
|
import { ClipboardIcon } from "@heroicons/react/24/outline";
|
|
import { AnimatePresence, motion } from "framer-motion";
|
|
import { Suspense, useCallback, useEffect, useRef, useState } from "react";
|
|
import { SparkleListIcon } from "~/assets/icons/SparkleListIcon";
|
|
import { Button } from "~/components/primitives/Buttons";
|
|
import { StreamdownRenderer } from "~/components/code/StreamdownRenderer";
|
|
import { Header3 } from "~/components/primitives/Headers";
|
|
import { Paragraph } from "~/components/primitives/Paragraph";
|
|
import { Spinner } from "~/components/primitives/Spinner";
|
|
import { SimpleTooltip } from "~/components/primitives/Tooltip";
|
|
import { useEnvironment } from "~/hooks/useEnvironment";
|
|
import { useOrganization } from "~/hooks/useOrganizations";
|
|
import { useProject } from "~/hooks/useProject";
|
|
import { cn } from "~/utils/cn";
|
|
|
|
type StreamEventType =
|
|
| { type: "thinking"; content: string }
|
|
| { type: "result"; success: true; payload: string }
|
|
| { type: "result"; success: false; error: string };
|
|
|
|
export function AIPayloadTabContent({
|
|
onPayloadGenerated,
|
|
payloadSchema,
|
|
taskIdentifier,
|
|
getCurrentPayload,
|
|
generateButtonLabel = "Generate payload",
|
|
placeholder,
|
|
examplePromptsOverride,
|
|
isAgent = false,
|
|
showExamplePromptsHeader = true,
|
|
}: {
|
|
onPayloadGenerated: (payload: string) => void;
|
|
payloadSchema?: unknown;
|
|
taskIdentifier: string;
|
|
getCurrentPayload?: () => string;
|
|
generateButtonLabel?: string;
|
|
placeholder?: string;
|
|
examplePromptsOverride?: string[];
|
|
isAgent?: boolean;
|
|
showExamplePromptsHeader?: boolean;
|
|
}) {
|
|
const [prompt, setPrompt] = useState("");
|
|
const [isLoading, setIsLoading] = useState(false);
|
|
const isLoadingRef = useRef(false);
|
|
const [thinking, setThinking] = useState("");
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [showThinking, setShowThinking] = useState(false);
|
|
const [lastResult, setLastResult] = useState<"success" | "error" | null>(null);
|
|
const [lastPayload, setLastPayload] = useState<string | null>(null);
|
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
|
const abortControllerRef = useRef<AbortController | null>(null);
|
|
|
|
const organization = useOrganization();
|
|
const project = useProject();
|
|
const environment = useEnvironment();
|
|
|
|
const resourcePath = `/resources/orgs/${organization.slug}/projects/${project.slug}/env/${environment.slug}/test/ai-generate-payload`;
|
|
|
|
const submitGeneration = useCallback(
|
|
async (queryPrompt: string) => {
|
|
if (!queryPrompt.trim() || isLoadingRef.current) return;
|
|
|
|
isLoadingRef.current = true;
|
|
setIsLoading(true);
|
|
setThinking("");
|
|
setError(null);
|
|
setShowThinking(true);
|
|
setLastResult(null);
|
|
setLastPayload(null);
|
|
|
|
if (abortControllerRef.current) {
|
|
abortControllerRef.current.abort();
|
|
}
|
|
abortControllerRef.current = new AbortController();
|
|
|
|
try {
|
|
const formData = new FormData();
|
|
formData.append("prompt", queryPrompt);
|
|
formData.append("taskIdentifier", taskIdentifier);
|
|
formData.append("isAgent", isAgent ? "true" : "false");
|
|
if (payloadSchema) {
|
|
formData.append("payloadSchema", JSON.stringify(payloadSchema));
|
|
}
|
|
const currentPayload = getCurrentPayload?.();
|
|
if (currentPayload) {
|
|
formData.append("currentPayload", currentPayload);
|
|
}
|
|
|
|
const response = await fetch(resourcePath, {
|
|
method: "POST",
|
|
body: formData,
|
|
signal: abortControllerRef.current.signal,
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const errorData = (await response.json()) as { error?: string };
|
|
setError(errorData.error || "Failed to generate payload");
|
|
setIsLoading(false);
|
|
setLastResult("error");
|
|
return;
|
|
}
|
|
|
|
const reader = response.body?.getReader();
|
|
if (!reader) {
|
|
setError("No response stream");
|
|
setIsLoading(false);
|
|
setLastResult("error");
|
|
return;
|
|
}
|
|
|
|
const decoder = new TextDecoder();
|
|
let buffer = "";
|
|
|
|
while (true) {
|
|
const { done, value } = await reader.read();
|
|
if (done) break;
|
|
|
|
buffer += decoder.decode(value, { stream: true });
|
|
|
|
const lines = buffer.split("\n\n");
|
|
buffer = lines.pop() || "";
|
|
|
|
for (const line of lines) {
|
|
if (line.startsWith("data: ")) {
|
|
try {
|
|
const event = JSON.parse(line.slice(6)) as StreamEventType;
|
|
processStreamEvent(event);
|
|
} catch {
|
|
// Ignore parse errors
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (buffer.startsWith("data: ")) {
|
|
try {
|
|
const event = JSON.parse(buffer.slice(6)) as StreamEventType;
|
|
processStreamEvent(event);
|
|
} catch {
|
|
// Ignore parse errors
|
|
}
|
|
}
|
|
} catch (err) {
|
|
if (err instanceof Error && err.name === "AbortError") return;
|
|
setError(err instanceof Error ? err.message : "An error occurred");
|
|
setLastResult("error");
|
|
} finally {
|
|
isLoadingRef.current = false;
|
|
setIsLoading(false);
|
|
}
|
|
},
|
|
[resourcePath, taskIdentifier, payloadSchema, getCurrentPayload, isAgent]
|
|
);
|
|
|
|
const processStreamEvent = useCallback(
|
|
(event: StreamEventType) => {
|
|
switch (event.type) {
|
|
case "thinking":
|
|
setThinking((prev) => prev + event.content);
|
|
break;
|
|
case "result":
|
|
if (event.success) {
|
|
onPayloadGenerated(event.payload);
|
|
setLastPayload(event.payload);
|
|
setPrompt("");
|
|
setLastResult("success");
|
|
} else {
|
|
setError(event.error);
|
|
setLastResult("error");
|
|
}
|
|
break;
|
|
}
|
|
},
|
|
[onPayloadGenerated]
|
|
);
|
|
|
|
const handleSubmit = useCallback(
|
|
(e?: React.FormEvent) => {
|
|
e?.preventDefault();
|
|
submitGeneration(prompt);
|
|
},
|
|
[prompt, submitGeneration]
|
|
);
|
|
|
|
useEffect(() => {
|
|
return () => {
|
|
if (abortControllerRef.current) {
|
|
abortControllerRef.current.abort();
|
|
}
|
|
};
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (error) {
|
|
const timer = setTimeout(() => setError(null), 15000);
|
|
return () => clearTimeout(timer);
|
|
}
|
|
}, [error]);
|
|
|
|
const examplePrompts =
|
|
examplePromptsOverride ??
|
|
(payloadSchema
|
|
? [
|
|
"Generate a valid payload",
|
|
"Generate a payload with edge cases",
|
|
"Generate a minimal payload with only required fields",
|
|
]
|
|
: [
|
|
"Generate a simple JSON payload",
|
|
"Generate a payload with nested objects",
|
|
"Generate a payload with an array of items",
|
|
]);
|
|
|
|
return (
|
|
<div className="space-y-0">
|
|
<div
|
|
className="overflow-hidden rounded-md p-px"
|
|
style={{ background: "linear-gradient(to bottom right, #E543FF, #286399)" }}
|
|
>
|
|
<div className="overflow-hidden rounded-md bg-background-bright">
|
|
<div>
|
|
<textarea
|
|
ref={textareaRef}
|
|
name="prompt"
|
|
placeholder={
|
|
placeholder ??
|
|
(payloadSchema
|
|
? "e.g. generate a payload for a new user signup"
|
|
: "e.g. generate a JSON payload with name, email, and age fields")
|
|
}
|
|
value={prompt}
|
|
onChange={(e) => setPrompt(e.target.value)}
|
|
disabled={isLoading}
|
|
rows={5}
|
|
className="focus-custom m-0 min-h-10 w-full resize-none border-0 bg-background-bright px-3 py-2.5 text-sm text-text-bright scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control placeholder:text-text-dimmed focus:border-0 focus:ring-0 disabled:cursor-not-allowed disabled:opacity-50"
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter" && !e.shiftKey && prompt.trim() && !isLoading) {
|
|
e.preventDefault();
|
|
handleSubmit();
|
|
}
|
|
}}
|
|
/>
|
|
<div className="flex justify-end gap-2 px-2 pb-2">
|
|
{isLoading ? (
|
|
<Button
|
|
type="button"
|
|
variant="tertiary/small"
|
|
disabled={true}
|
|
LeadingIcon={Spinner}
|
|
className="pl-2"
|
|
iconSpacing="gap-1.5"
|
|
>
|
|
Generating…
|
|
</Button>
|
|
) : (
|
|
<Button
|
|
type="button"
|
|
variant="tertiary/small"
|
|
disabled={!prompt.trim()}
|
|
className={cn(!prompt.trim() && "opacity-50")}
|
|
onClick={() => handleSubmit()}
|
|
>
|
|
{generateButtonLabel}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Error message */}
|
|
<AnimatePresence>
|
|
{error && (
|
|
<motion.div
|
|
initial={{ opacity: 0, height: 0 }}
|
|
animate={{ opacity: 1, height: "auto" }}
|
|
exit={{ opacity: 0, height: 0 }}
|
|
transition={{ duration: 0.2 }}
|
|
className="overflow-hidden"
|
|
>
|
|
<div className="rounded-md border border-error/30 bg-error/10 px-3 py-2 text-sm text-error">
|
|
{error}
|
|
</div>
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
|
|
{/* Thinking panel */}
|
|
<AnimatePresence>
|
|
{showThinking && thinking && (
|
|
<motion.div
|
|
initial={{ opacity: 0, height: 0 }}
|
|
animate={{ opacity: 1, height: "auto" }}
|
|
exit={{ opacity: 0, height: 0 }}
|
|
transition={{ duration: 0.2 }}
|
|
className="overflow-hidden rounded-b-md border-x border-b border-grid-bright bg-background-bright pb-3"
|
|
>
|
|
<div className="space-y-2 px-2 pt-2">
|
|
<div className="flex items-center justify-between gap-2">
|
|
<div className="flex items-center gap-1.5">
|
|
{isLoading ? (
|
|
<Spinner className="size-4" />
|
|
) : lastResult === "success" ? (
|
|
<CheckIcon className="size-4 text-success" />
|
|
) : lastResult === "error" ? (
|
|
<XMarkIcon className="size-4 text-error" />
|
|
) : null}
|
|
<span className="text-xs font-medium text-text-dimmed">
|
|
{isLoading
|
|
? "AI is thinking…"
|
|
: lastResult === "success"
|
|
? "Payload generated"
|
|
: lastResult === "error"
|
|
? "Generation failed"
|
|
: "AI response"}
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center gap-0.5">
|
|
{lastResult === "success" && lastPayload && (
|
|
<SimpleTooltip
|
|
asChild
|
|
side="top"
|
|
content="Copy"
|
|
disableHoverableContent
|
|
button={
|
|
<button
|
|
type="button"
|
|
aria-label="Copy"
|
|
onClick={() => {
|
|
if (lastPayload) {
|
|
void navigator.clipboard.writeText(lastPayload);
|
|
}
|
|
}}
|
|
className="rounded p-1 text-text-dimmed transition-colors hover:bg-background-raised hover:text-text-bright"
|
|
>
|
|
<ClipboardIcon className="size-4" />
|
|
</button>
|
|
}
|
|
/>
|
|
)}
|
|
<SimpleTooltip
|
|
asChild
|
|
side="top"
|
|
content="Dismiss"
|
|
disableHoverableContent
|
|
button={
|
|
<button
|
|
type="button"
|
|
aria-label="Dismiss"
|
|
onClick={() => {
|
|
if (abortControllerRef.current) {
|
|
abortControllerRef.current.abort();
|
|
}
|
|
setIsLoading(false);
|
|
setShowThinking(false);
|
|
setThinking("");
|
|
}}
|
|
className="rounded p-1 text-text-dimmed transition-colors hover:bg-background-raised hover:text-text-bright"
|
|
>
|
|
<XMarkIcon className="size-4" />
|
|
</button>
|
|
}
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div
|
|
className={cn(
|
|
"streamdown-container max-h-96 overflow-y-auto text-xs text-text-dimmed",
|
|
"scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control",
|
|
// Strip Streamdown's code-block chrome
|
|
"**:data-[streamdown=code-block-header]:hidden",
|
|
"**:data-[streamdown=code-block]:border-0!",
|
|
"**:data-[streamdown=code-block]:bg-transparent!",
|
|
"**:data-[streamdown=code-block]:p-0!",
|
|
"**:data-[streamdown=code-block]:my-0!",
|
|
"**:data-[streamdown=code-block]:gap-0!",
|
|
// Strip the inner code-block-body border (it draws a faint inset frame)
|
|
"**:data-[streamdown=code-block-body]:border-0!",
|
|
// Style Streamdown's inner code-block (where the horizontal scrollbar lives)
|
|
"**:data-[streamdown=code-block-body]:scrollbar-thin",
|
|
"**:data-[streamdown=code-block-body]:scrollbar-track-transparent",
|
|
"**:data-[streamdown=code-block-body]:scrollbar-thumb-surface-control"
|
|
)}
|
|
>
|
|
<Suspense fallback={<p className="whitespace-pre-wrap">{thinking}</p>}>
|
|
<StreamdownRenderer isAnimating={isLoading}>{thinking}</StreamdownRenderer>
|
|
</Suspense>
|
|
</div>
|
|
</div>
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
|
|
{/* Example prompts */}
|
|
<div className="pt-4">
|
|
{showExamplePromptsHeader && (
|
|
<Header3 className="mb-3 text-text-bright">Example prompts</Header3>
|
|
)}
|
|
<div className="flex flex-wrap gap-2">
|
|
{examplePrompts.map((example) => (
|
|
<button
|
|
key={example}
|
|
type="button"
|
|
disabled={isLoading}
|
|
onClick={() => {
|
|
setPrompt(example);
|
|
submitGeneration(example);
|
|
}}
|
|
className="group flex w-fit items-center gap-2 rounded-full border border-dashed border-border-bright px-4 py-2 transition-colors focus-custom hover:border-solid hover:border-indigo-500 focus-visible:rounded-full! disabled:cursor-not-allowed disabled:opacity-50"
|
|
>
|
|
<SparkleListIcon className="size-4 shrink-0 text-text-dimmed transition group-hover:text-indigo-500" />
|
|
<Paragraph
|
|
variant="small"
|
|
className="text-left transition group-hover:text-text-bright"
|
|
>
|
|
{example}
|
|
</Paragraph>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|