feat: implement streaming response support for AI prompt execution with real-time UI updates
This commit is contained in:
parent
5455e9b94c
commit
7a598d01ae
@ -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>
|
||||
)}
|
||||
|
||||
@ -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 },
|
||||
|
||||
Loading…
Reference in New Issue
Block a user