feat: add cancel functionality for prompt execution with request ID tracking

This commit is contained in:
Yashwin 2026-06-05 14:50:04 +05:30
parent 43abf370c7
commit e15111a8fe
3 changed files with 112 additions and 10 deletions

View File

@ -48,6 +48,7 @@ const PromptExecute = (): ReactElement => {
// Execution result
const [result, setResult] = useState<AICompletion | null>(null);
const [error, setError] = useState<string | null>(null);
const [activeRequestId, setActiveRequestId] = useState<string | null>(null);
// State & Refs for smooth typing effect
const [displayedContent, setDisplayedContent] = useState("");
@ -56,6 +57,7 @@ const PromptExecute = (): ReactElement => {
const displayedContentRef = useRef("");
const finalResultRef = useRef<AICompletion | null>(null);
const isStreamDoneRef = useRef(false);
const isCancelledRef = useRef(false);
const scrollContainerRef = useRef<HTMLDivElement | null>(null);
const loadData = async (): Promise<void> => {
@ -173,6 +175,63 @@ const PromptExecute = (): ReactElement => {
typingTimerRef.current = setInterval(tick, 15);
};
const handleCancel = async (): Promise<void> => {
if (!id || !activeRequestId) {
showToast.error("No active request found to cancel.");
return;
}
// If the stream is already finished and we are just catching up on typing,
// don't hit the backend cancel endpoint since it will return NOT_FOUND.
if (isStreamDoneRef.current) {
showToast.info("Execution has already completed.");
setIsExecuting(false);
return;
}
try {
isCancelledRef.current = true;
await aiService.cancelPromptExecution(id, activeRequestId);
showToast.info("Cancellation requested successfully.");
if (typingTimerRef.current) {
clearInterval(typingTimerRef.current);
typingTimerRef.current = null;
}
setIsExecuting(false);
const abortedText = displayedContentRef.current + "\n\n[Execution Cancelled by User]";
setDisplayedContent(abortedText);
displayedContentRef.current = abortedText;
setResult((prev) => {
if (!prev) return null;
return {
...prev,
id: "cancelled",
content: abortedText,
};
});
} catch (err: any) {
// If the error indicates not found, it means the request already finished or was cancelled.
const errStr = err.message || String(err);
if (
errStr.includes("No active execution") ||
err.code === "NOT_FOUND" ||
err.response?.status === 404 ||
errStr.includes("404")
) {
showToast.info("Execution has already completed.");
if (typingTimerRef.current) {
clearInterval(typingTimerRef.current);
typingTimerRef.current = null;
}
setIsExecuting(false);
} else {
showToast.error("Failed to cancel execution: " + errStr);
}
}
};
const handleExecute = async (): Promise<void> => {
if (!prompt || !id) return;
@ -196,6 +255,8 @@ const PromptExecute = (): ReactElement => {
targetContentRef.current = "";
finalResultRef.current = null;
isStreamDoneRef.current = false;
isCancelledRef.current = false;
setActiveRequestId(null);
setIsExecuting(true);
setResult({
@ -233,10 +294,17 @@ const PromptExecute = (): ReactElement => {
clearInterval(typingTimerRef.current);
typingTimerRef.current = null;
}
// If the user cancelled the execution, we ignore the subsequent network abort/error.
if (isCancelledRef.current) {
return;
}
setError(errMessage);
setResult(null);
setIsExecuting(false);
showToast.error(errMessage);
},
(requestId) => {
setActiveRequestId(requestId);
}
);
};
@ -280,12 +348,18 @@ const PromptExecute = (): ReactElement => {
description: "Compile template variables and execute the prompt.",
action: (
<div className="flex gap-2">
<SecondaryButton onClick={() => navigate("/tenant/ai/prompts")}>
<SecondaryButton onClick={() => navigate("/tenant/ai/prompts")} disabled={isExecuting}>
Back to Prompts
</SecondaryButton>
<PrimaryButton onClick={handleExecute} disabled={isExecuting}>
{isExecuting ? "Executing..." : "Execute Prompt"}
</PrimaryButton>
{isExecuting ? (
<PrimaryButton onClick={handleCancel} className="bg-red-600 hover:bg-red-700 border-red-600 text-white">
Cancel
</PrimaryButton>
) : (
<PrimaryButton onClick={handleExecute}>
Execute Prompt
</PrimaryButton>
)}
</div>
),
}}
@ -352,10 +426,19 @@ const PromptExecute = (): ReactElement => {
{result.provider} {result.model}
</StatusBadge>
{result.id === "streaming" ? (
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-semibold bg-[#E6FFFB] text-[#08979C] border border-[#B5F5EC] animate-pulse">
<span className="w-2 h-2 rounded-full bg-[#13C2C2] mr-1.5 animate-ping inline-block" />
Streaming response...
</span>
<div className="flex items-center gap-2">
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-semibold bg-[#E6FFFB] text-[#08979C] border border-[#B5F5EC] animate-pulse">
<span className="w-2 h-2 rounded-full bg-[#13C2C2] mr-1.5 animate-ping inline-block" />
Streaming response...
</span>
<button
type="button"
onClick={handleCancel}
className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-semibold bg-red-50 text-red-600 border border-red-200 hover:bg-red-100 transition-colors"
>
Cancel
</button>
</div>
) : (
<>
<StatusBadge variant="success">
@ -370,6 +453,12 @@ const PromptExecute = (): ReactElement => {
<Activity className="w-3.5 h-3.5 mr-1.5 inline" />
{result.usage?.total_tokens ?? 0} tokens
</StatusBadge>
{(result.requestId || result.request_id) && (
<StatusBadge variant="process">
<Tag className="w-3.5 h-3.5 mr-1.5 inline" />
ID: {result.requestId || result.request_id}
</StatusBadge>
)}
</>
)}
</div>

View File

@ -253,10 +253,11 @@ class AIService {
async executePromptStream(
id: string,
payload: { variables?: Record<string, unknown>; provider?: string; model?: string; temperature?: number; max_tokens?: number },
payload: { variables?: Record<string, unknown>; provider?: string; model?: string; temperature?: number; max_tokens?: number; clientRequestId?: string },
onChunk: (chunk: string) => void,
onDone: (result: AICompletion) => void,
onError: (error: string) => void
onError: (error: string) => void,
onRequestId?: (requestId: string) => void
): Promise<void> {
try {
const store = (window as any).__REDUX_STORE__;
@ -307,6 +308,10 @@ class AIService {
if (data.error) {
throw new Error(data.error);
}
if (data.clientRequestId && onRequestId) {
console.log("[aiService] Parsed clientRequestId from stream:", data.clientRequestId);
onRequestId(data.clientRequestId);
}
if (data.content) {
onChunk(data.content);
}
@ -324,6 +329,12 @@ class AIService {
}
}
async cancelPromptExecution(id: string, requestId: string): Promise<{ success: boolean; message?: string }> {
console.log("[aiService] cancelPromptExecution called for request ID:", requestId);
const response = await apiClient.post(`/ai/prompts/${id}/cancel`, { request_id: requestId });
return unwrap(response);
}
async testPrompt(
id: string,
payload: { variables?: Record<string, unknown>; provider?: string; model?: string; temperature?: number; max_tokens?: number },

View File

@ -63,6 +63,8 @@ export interface AICompletion {
completed_at?: string | null;
created_at?: string;
updated_at?: string;
requestId?: string;
request_id?: string;
}
export interface AICompletionListResponse {