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 { useNavigate, useParams } from "react-router-dom";
|
||||||
import { Layout } from "@/components/layout/Layout";
|
import { Layout } from "@/components/layout/Layout";
|
||||||
import {
|
import {
|
||||||
@ -49,6 +49,15 @@ const PromptExecute = (): ReactElement => {
|
|||||||
const [result, setResult] = useState<AICompletion | null>(null);
|
const [result, setResult] = useState<AICompletion | null>(null);
|
||||||
const [error, setError] = useState<string | 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> => {
|
const loadData = async (): Promise<void> => {
|
||||||
if (!id) return;
|
if (!id) return;
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
@ -108,6 +117,62 @@ const PromptExecute = (): ReactElement => {
|
|||||||
setVariables((prev) => ({ ...prev, [name]: value }));
|
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> => {
|
const handleExecute = async (): Promise<void> => {
|
||||||
if (!prompt || !id) return;
|
if (!prompt || !id) return;
|
||||||
|
|
||||||
@ -121,29 +186,59 @@ const PromptExecute = (): ReactElement => {
|
|||||||
return;
|
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);
|
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);
|
setError(null);
|
||||||
|
|
||||||
try {
|
const payload = {
|
||||||
const payload = {
|
variables: variables,
|
||||||
variables: variables,
|
provider: provider || undefined,
|
||||||
provider: provider || undefined,
|
model: model || undefined,
|
||||||
model: model || undefined,
|
temperature: temperature,
|
||||||
temperature: temperature,
|
max_tokens: maxTokens,
|
||||||
max_tokens: maxTokens,
|
};
|
||||||
};
|
|
||||||
|
|
||||||
const response = await aiService.executePrompt(id, payload);
|
await aiService.executePromptStream(
|
||||||
setResult(response);
|
id,
|
||||||
showToast.success("Prompt template executed successfully!");
|
payload,
|
||||||
} catch (err: any) {
|
(chunk) => {
|
||||||
const errMsg = err?.response?.data?.error?.message || err.message || "Failed to execute prompt";
|
targetContentRef.current += chunk;
|
||||||
setError(errMsg);
|
startTypingEffect();
|
||||||
showToast.error(errMsg);
|
},
|
||||||
} finally {
|
(finalData) => {
|
||||||
setIsExecuting(false);
|
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) {
|
if (isLoading) {
|
||||||
@ -256,22 +351,34 @@ const PromptExecute = (): ReactElement => {
|
|||||||
<Cpu className="w-3.5 h-3.5 mr-1.5 inline" />
|
<Cpu className="w-3.5 h-3.5 mr-1.5 inline" />
|
||||||
{result.provider} • {result.model}
|
{result.provider} • {result.model}
|
||||||
</StatusBadge>
|
</StatusBadge>
|
||||||
<StatusBadge variant="success">
|
{result.id === "streaming" ? (
|
||||||
<Clock className="w-3.5 h-3.5 mr-1.5 inline" />
|
<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">
|
||||||
{result.latency_ms} ms
|
<span className="w-2 h-2 rounded-full bg-[#13C2C2] mr-1.5 animate-ping inline-block" />
|
||||||
</StatusBadge>
|
Streaming response...
|
||||||
<StatusBadge variant="success">
|
</span>
|
||||||
<DollarSign className="w-3.5 h-3.5 mr-1.5 inline" />
|
) : (
|
||||||
USD {Number(result.cost ?? 0).toFixed(6)}
|
<>
|
||||||
</StatusBadge>
|
<StatusBadge variant="success">
|
||||||
<StatusBadge variant="process">
|
<Clock className="w-3.5 h-3.5 mr-1.5 inline" />
|
||||||
<Activity className="w-3.5 h-3.5 mr-1.5 inline" />
|
{result.latency_ms} ms
|
||||||
{result.usage?.total_tokens ?? 0} tokens
|
</StatusBadge>
|
||||||
</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>
|
||||||
|
|
||||||
<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">
|
<div
|
||||||
<MarkdownViewer content={result.content || ""} />
|
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>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -251,6 +251,79 @@ class AIService {
|
|||||||
return unwrap<AICompletion>(response);
|
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(
|
async testPrompt(
|
||||||
id: string,
|
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 },
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user