diff --git a/src/pages/tenant/PromptExecute.tsx b/src/pages/tenant/PromptExecute.tsx index b769145..9a7fc27 100644 --- a/src/pages/tenant/PromptExecute.tsx +++ b/src/pages/tenant/PromptExecute.tsx @@ -48,6 +48,7 @@ const PromptExecute = (): ReactElement => { // Execution result const [result, setResult] = useState(null); const [error, setError] = useState(null); + const [activeRequestId, setActiveRequestId] = useState(null); // State & Refs for smooth typing effect const [displayedContent, setDisplayedContent] = useState(""); @@ -56,6 +57,7 @@ const PromptExecute = (): ReactElement => { const displayedContentRef = useRef(""); const finalResultRef = useRef(null); const isStreamDoneRef = useRef(false); + const isCancelledRef = useRef(false); const scrollContainerRef = useRef(null); const loadData = async (): Promise => { @@ -173,6 +175,63 @@ const PromptExecute = (): ReactElement => { typingTimerRef.current = setInterval(tick, 15); }; + const handleCancel = async (): Promise => { + 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 => { 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: (
- navigate("/tenant/ai/prompts")}> + navigate("/tenant/ai/prompts")} disabled={isExecuting}> Back to Prompts - - {isExecuting ? "Executing..." : "Execute Prompt"} - + {isExecuting ? ( + + Cancel + + ) : ( + + Execute Prompt + + )}
), }} @@ -352,10 +426,19 @@ const PromptExecute = (): ReactElement => { {result.provider} • {result.model} {result.id === "streaming" ? ( - - - Streaming response... - +
+ + + Streaming response... + + +
) : ( <> @@ -370,6 +453,12 @@ const PromptExecute = (): ReactElement => { {result.usage?.total_tokens ?? 0} tokens + {(result.requestId || result.request_id) && ( + + + ID: {result.requestId || result.request_id} + + )} )} diff --git a/src/services/ai-service.ts b/src/services/ai-service.ts index 5cc8169..19c998c 100644 --- a/src/services/ai-service.ts +++ b/src/services/ai-service.ts @@ -253,10 +253,11 @@ class AIService { async executePromptStream( id: string, - payload: { variables?: Record; provider?: string; model?: string; temperature?: number; max_tokens?: number }, + payload: { variables?: Record; 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 { 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; provider?: string; model?: string; temperature?: number; max_tokens?: number }, diff --git a/src/types/ai.ts b/src/types/ai.ts index f91f85a..6cada75 100644 --- a/src/types/ai.ts +++ b/src/types/ai.ts @@ -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 {