Files
2026-07-13 13:32:57 +08:00

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