diff --git a/src/pages/tenant/PromptExecute.tsx b/src/pages/tenant/PromptExecute.tsx index 45c3a10..b769145 100644 --- a/src/pages/tenant/PromptExecute.tsx +++ b/src/pages/tenant/PromptExecute.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState, type ReactElement } from "react"; +import { useEffect, useMemo, useState, useRef, type ReactElement } from "react"; import { useNavigate, useParams } from "react-router-dom"; import { Layout } from "@/components/layout/Layout"; import { @@ -49,6 +49,15 @@ const PromptExecute = (): ReactElement => { const [result, setResult] = useState(null); const [error, setError] = useState(null); + // State & Refs for smooth typing effect + const [displayedContent, setDisplayedContent] = useState(""); + const typingTimerRef = useRef(null); + const targetContentRef = useRef(""); + const displayedContentRef = useRef(""); + const finalResultRef = useRef(null); + const isStreamDoneRef = useRef(false); + const scrollContainerRef = useRef(null); + const loadData = async (): Promise => { if (!id) return; setIsLoading(true); @@ -108,6 +117,62 @@ const PromptExecute = (): ReactElement => { setVariables((prev) => ({ ...prev, [name]: value })); }; + // Clean up typing timers on unmount + useEffect(() => { + return () => { + if (typingTimerRef.current) { + clearInterval(typingTimerRef.current); + } + }; + }, []); + + // Auto scroll as text is typed in real-time + useEffect(() => { + if (scrollContainerRef.current) { + scrollContainerRef.current.scrollTop = scrollContainerRef.current.scrollHeight; + } + }, [displayedContent]); + + const startTypingEffect = () => { + if (typingTimerRef.current) return; + + const tick = () => { + const currentLen = displayedContentRef.current.length; + const target = targetContentRef.current; + const targetLen = target.length; + + if (currentLen >= targetLen) { + if (isStreamDoneRef.current) { + // Stream completed and we have caught up typing all text! + if (finalResultRef.current) { + setResult(finalResultRef.current); + } + setIsExecuting(false); + showToast.success("Prompt template executed successfully!"); + + if (typingTimerRef.current) { + clearInterval(typingTimerRef.current); + typingTimerRef.current = null; + } + } + return; + } + + // Smart speed: adjust speed based on how far back we are + const lag = targetLen - currentLen; + let charsToType = 1; + if (lag > 100) charsToType = 5; + else if (lag > 50) charsToType = 3; + else if (lag > 20) charsToType = 2; + + const nextText = target.slice(0, currentLen + charsToType); + displayedContentRef.current = nextText; + setDisplayedContent(nextText); + }; + + typingTimerRef.current = setInterval(tick, 15); + }; + const handleExecute = async (): Promise => { if (!prompt || !id) return; @@ -121,29 +186,59 @@ const PromptExecute = (): ReactElement => { return; } + // Reset typing loop variables & refs + if (typingTimerRef.current) { + clearInterval(typingTimerRef.current); + typingTimerRef.current = null; + } + setDisplayedContent(""); + displayedContentRef.current = ""; + targetContentRef.current = ""; + finalResultRef.current = null; + isStreamDoneRef.current = false; + setIsExecuting(true); - setResult(null); + setResult({ + id: "streaming", + provider: provider || prompt.defaultParameters?.provider || "AI", + model: model || prompt.defaultParameters?.model || "Model", + content: "", + created_at: new Date().toISOString(), + }); setError(null); - try { - const payload = { - variables: variables, - provider: provider || undefined, - model: model || undefined, - temperature: temperature, - max_tokens: maxTokens, - }; + const payload = { + variables: variables, + provider: provider || undefined, + model: model || undefined, + temperature: temperature, + max_tokens: maxTokens, + }; - const response = await aiService.executePrompt(id, payload); - setResult(response); - showToast.success("Prompt template executed successfully!"); - } catch (err: any) { - const errMsg = err?.response?.data?.error?.message || err.message || "Failed to execute prompt"; - setError(errMsg); - showToast.error(errMsg); - } finally { - setIsExecuting(false); - } + await aiService.executePromptStream( + id, + payload, + (chunk) => { + targetContentRef.current += chunk; + startTypingEffect(); + }, + (finalData) => { + finalResultRef.current = finalData; + isStreamDoneRef.current = true; + // Trigger catching up immediately + startTypingEffect(); + }, + (errMessage) => { + if (typingTimerRef.current) { + clearInterval(typingTimerRef.current); + typingTimerRef.current = null; + } + setError(errMessage); + setResult(null); + setIsExecuting(false); + showToast.error(errMessage); + } + ); }; if (isLoading) { @@ -256,22 +351,34 @@ const PromptExecute = (): ReactElement => { {result.provider} • {result.model} - - - {result.latency_ms} ms - - - - USD {Number(result.cost ?? 0).toFixed(6)} - - - - {result.usage?.total_tokens ?? 0} tokens - + {result.id === "streaming" ? ( + + + Streaming response... + + ) : ( + <> + + + {result.latency_ms} ms + + + + USD {Number(result.cost ?? 0).toFixed(6)} + + + + {result.usage?.total_tokens ?? 0} tokens + + + )} -
- +
+
)} diff --git a/src/services/ai-service.ts b/src/services/ai-service.ts index 56b8c86..5cc8169 100644 --- a/src/services/ai-service.ts +++ b/src/services/ai-service.ts @@ -251,6 +251,79 @@ class AIService { return unwrap(response); } + async executePromptStream( + id: string, + payload: { variables?: Record; provider?: string; model?: string; temperature?: number; max_tokens?: number }, + onChunk: (chunk: string) => void, + onDone: (result: AICompletion) => void, + onError: (error: string) => void + ): Promise { + try { + const store = (window as any).__REDUX_STORE__; + const token = store ? store.getState()?.auth?.accessToken : null; + + const headers: Record = { + 'Content-Type': 'application/json', + }; + if (token) { + headers['Authorization'] = `Bearer ${token}`; + } + + const baseURL = apiClient.defaults.baseURL || import.meta.env.VITE_API_BASE_URL || 'http://localhost:3000/api/v1'; + const response = await fetch(`${baseURL}/ai/prompts/${id}/execute`, { + method: 'POST', + headers, + body: JSON.stringify({ ...payload, stream: true }), + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error(errorData?.error?.message || `Execution failed with status ${response.status}`); + } + + if (!response.body) { + throw new Error("Response body is empty"); + } + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + + while (true) { + const { value, done } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split("\n"); + buffer = lines.pop() || ""; // Keep the last incomplete line in buffer + + for (const line of lines) { + const cleanLine = line.trim(); + if (!cleanLine.startsWith("data:")) continue; + + const jsonStr = cleanLine.replace(/^data:\s*/, ""); + try { + const data = JSON.parse(jsonStr); + if (data.error) { + throw new Error(data.error); + } + if (data.content) { + onChunk(data.content); + } + if (data.done) { + onDone(data.metadata); + } + } catch (e: any) { + console.error("Error parsing stream chunk:", e); + if (e.message) throw e; + } + } + } + } catch (err: any) { + onError(err.message || "Failed to execute prompt stream"); + } + } + async testPrompt( id: string, payload: { variables?: Record; provider?: string; model?: string; temperature?: number; max_tokens?: number },