feat: implement streaming response support for AI prompt execution with real-time UI updates

This commit is contained in:
Yashwin 2026-06-01 17:43:42 +05:30
parent 5455e9b94c
commit 7a598d01ae
2 changed files with 214 additions and 34 deletions

View File

@ -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<AICompletion | null>(null);
const [error, setError] = useState<string | null>(null);
// State & Refs for smooth typing effect
const [displayedContent, setDisplayedContent] = useState("");
const typingTimerRef = useRef<any>(null);
const targetContentRef = useRef("");
const displayedContentRef = useRef("");
const finalResultRef = useRef<AICompletion | null>(null);
const isStreamDoneRef = useRef(false);
const scrollContainerRef = useRef<HTMLDivElement | null>(null);
const loadData = async (): Promise<void> => {
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<void> => {
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 => {
<Cpu className="w-3.5 h-3.5 mr-1.5 inline" />
{result.provider} {result.model}
</StatusBadge>
<StatusBadge variant="success">
<Clock className="w-3.5 h-3.5 mr-1.5 inline" />
{result.latency_ms} ms
</StatusBadge>
<StatusBadge variant="success">
<DollarSign className="w-3.5 h-3.5 mr-1.5 inline" />
USD {Number(result.cost ?? 0).toFixed(6)}
</StatusBadge>
<StatusBadge variant="process">
<Activity className="w-3.5 h-3.5 mr-1.5 inline" />
{result.usage?.total_tokens ?? 0} tokens
</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>
) : (
<>
<StatusBadge variant="success">
<Clock className="w-3.5 h-3.5 mr-1.5 inline" />
{result.latency_ms} ms
</StatusBadge>
<StatusBadge variant="success">
<DollarSign className="w-3.5 h-3.5 mr-1.5 inline" />
USD {Number(result.cost ?? 0).toFixed(6)}
</StatusBadge>
<StatusBadge variant="process">
<Activity className="w-3.5 h-3.5 mr-1.5 inline" />
{result.usage?.total_tokens ?? 0} tokens
</StatusBadge>
</>
)}
</div>
<div className="bg-[#f8fafc] border border-gray-200 rounded-lg p-5 max-h-[480px] overflow-y-auto leading-relaxed shadow-inner font-sans text-gray-800">
<MarkdownViewer content={result.content || ""} />
<div
ref={scrollContainerRef}
className="bg-[#f8fafc] border border-gray-200 rounded-lg p-5 max-h-[480px] overflow-y-auto leading-relaxed shadow-inner font-sans text-gray-800"
>
<MarkdownViewer content={result.id === "streaming" ? displayedContent : (result.content || "")} />
</div>
</div>
)}

View File

@ -251,6 +251,79 @@ class AIService {
return unwrap<AICompletion>(response);
}
async executePromptStream(
id: string,
payload: { variables?: Record<string, unknown>; provider?: string; model?: string; temperature?: number; max_tokens?: number },
onChunk: (chunk: string) => void,
onDone: (result: AICompletion) => void,
onError: (error: string) => void
): Promise<void> {
try {
const store = (window as any).__REDUX_STORE__;
const token = store ? store.getState()?.auth?.accessToken : null;
const headers: Record<string, string> = {
'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<string, unknown>; provider?: string; model?: string; temperature?: number; max_tokens?: number },