mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
.NET: Add DevUI package for .NET (#1603)
* Implement DevUI * Review feedback * Fix build
This commit is contained in:
committed by
GitHub
Unverified
parent
94a5ba3448
commit
8855bfb065
@@ -45,6 +45,7 @@ import type {
|
||||
ExtendedResponseStreamEvent,
|
||||
} from "@/types";
|
||||
import { useDevUIStore } from "@/stores";
|
||||
import { loadStreamingState } from "@/services/streaming-state";
|
||||
|
||||
type DebugEventHandler = (event: ExtendedResponseStreamEvent | "clear") => void;
|
||||
|
||||
@@ -229,7 +230,6 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
|
||||
|
||||
const scrollAreaRef = useRef<HTMLDivElement>(null);
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const accumulatedText = useRef<string>("");
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const currentMessageUsage = useRef<{
|
||||
total_tokens: number;
|
||||
@@ -237,6 +237,7 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
|
||||
output_tokens: number;
|
||||
} | null>(null);
|
||||
const userJustSentMessage = useRef<boolean>(false);
|
||||
const accumulatedTextRef = useRef<string>("");
|
||||
|
||||
// Auto-scroll to bottom when new items arrive
|
||||
useEffect(() => {
|
||||
@@ -281,33 +282,270 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
|
||||
|
||||
// Load conversations when agent changes
|
||||
useEffect(() => {
|
||||
// Resume streaming after page refresh
|
||||
const resumeStreaming = async (
|
||||
assistantMessage: import("@/types/openai").ConversationMessage,
|
||||
conversation: Conversation,
|
||||
agent: AgentInfo
|
||||
) => {
|
||||
// Load the stored state to get the response ID
|
||||
const storedState = loadStreamingState(conversation.id);
|
||||
if (!storedState || !storedState.responseId) {
|
||||
setIsStreaming(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Use the stored responseId to resume the stream via GET /v1/responses/{responseId}
|
||||
const openAIRequest: import("@/types/agent-framework").AgentFrameworkRequest = {
|
||||
model: agent.id,
|
||||
input: [], // Not needed for resume (using GET)
|
||||
stream: true,
|
||||
conversation: conversation.id,
|
||||
};
|
||||
|
||||
// Pass the response ID explicitly to trigger GET request
|
||||
const streamGenerator = apiClient.streamAgentExecutionOpenAIDirect(
|
||||
agent.id,
|
||||
openAIRequest,
|
||||
conversation.id,
|
||||
storedState.responseId // Pass response ID for resume
|
||||
);
|
||||
|
||||
for await (const openAIEvent of streamGenerator) {
|
||||
// Pass all events to debug panel
|
||||
onDebugEvent(openAIEvent);
|
||||
|
||||
// Handle response.completed event
|
||||
if (openAIEvent.type === "response.completed") {
|
||||
const completedEvent = openAIEvent as import("@/types/openai").ResponseCompletedEvent;
|
||||
const usage = completedEvent.response?.usage;
|
||||
|
||||
if (usage) {
|
||||
currentMessageUsage.current = {
|
||||
input_tokens: usage.input_tokens,
|
||||
output_tokens: usage.output_tokens,
|
||||
total_tokens: usage.total_tokens,
|
||||
};
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Handle response.failed event
|
||||
if (openAIEvent.type === "response.failed") {
|
||||
const failedEvent = openAIEvent as import("@/types/openai").ResponseFailedEvent;
|
||||
const error = failedEvent.response?.error;
|
||||
const errorMessage = error
|
||||
? typeof error === "object" && "message" in error
|
||||
? (error as any).message
|
||||
: JSON.stringify(error)
|
||||
: "Request failed";
|
||||
|
||||
const currentItems = useDevUIStore.getState().chatItems;
|
||||
setChatItems(currentItems.map((item) =>
|
||||
item.id === assistantMessage.id && item.type === "message"
|
||||
? {
|
||||
...item,
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: accumulatedTextRef.current || errorMessage,
|
||||
} as import("@/types/openai").MessageTextContent,
|
||||
],
|
||||
status: "incomplete" as const,
|
||||
}
|
||||
: item
|
||||
));
|
||||
setIsStreaming(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle function approval request events
|
||||
if (openAIEvent.type === "response.function_approval.requested") {
|
||||
const approvalEvent = openAIEvent as import("@/types/openai").ResponseFunctionApprovalRequestedEvent;
|
||||
setPendingApprovals([
|
||||
...useDevUIStore.getState().pendingApprovals,
|
||||
{
|
||||
request_id: approvalEvent.request_id,
|
||||
function_call: approvalEvent.function_call,
|
||||
},
|
||||
]);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Handle function approval response events
|
||||
if (openAIEvent.type === "response.function_approval.responded") {
|
||||
const responseEvent = openAIEvent as import("@/types/openai").ResponseFunctionApprovalRespondedEvent;
|
||||
setPendingApprovals(
|
||||
useDevUIStore.getState().pendingApprovals.filter((a) => a.request_id !== responseEvent.request_id)
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Handle error events
|
||||
if (openAIEvent.type === "error") {
|
||||
const errorEvent = openAIEvent as ExtendedResponseStreamEvent & { message?: string };
|
||||
const errorMessage = errorEvent.message || "An error occurred";
|
||||
|
||||
const currentItems = useDevUIStore.getState().chatItems;
|
||||
setChatItems(currentItems.map((item) =>
|
||||
item.id === assistantMessage.id && item.type === "message"
|
||||
? {
|
||||
...item,
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: accumulatedTextRef.current || errorMessage,
|
||||
} as import("@/types/openai").MessageTextContent,
|
||||
],
|
||||
status: "incomplete" as const,
|
||||
}
|
||||
: item
|
||||
));
|
||||
setIsStreaming(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle text delta events
|
||||
if (
|
||||
openAIEvent.type === "response.output_text.delta" &&
|
||||
"delta" in openAIEvent &&
|
||||
openAIEvent.delta
|
||||
) {
|
||||
accumulatedTextRef.current += openAIEvent.delta;
|
||||
|
||||
const currentItems = useDevUIStore.getState().chatItems;
|
||||
setChatItems(currentItems.map((item) =>
|
||||
item.id === assistantMessage.id && item.type === "message"
|
||||
? {
|
||||
...item,
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: accumulatedTextRef.current,
|
||||
} as import("@/types/openai").MessageTextContent,
|
||||
],
|
||||
status: "in_progress" as const,
|
||||
}
|
||||
: item
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// Stream ended - mark as complete
|
||||
const finalUsage = currentMessageUsage.current;
|
||||
|
||||
const currentItems = useDevUIStore.getState().chatItems;
|
||||
setChatItems(currentItems.map((item) =>
|
||||
item.id === assistantMessage.id && item.type === "message"
|
||||
? {
|
||||
...item,
|
||||
status: "completed" as const,
|
||||
usage: finalUsage || undefined,
|
||||
}
|
||||
: item
|
||||
));
|
||||
setIsStreaming(false);
|
||||
|
||||
if (finalUsage) {
|
||||
updateConversationUsage(finalUsage.total_tokens);
|
||||
}
|
||||
|
||||
currentMessageUsage.current = null;
|
||||
} catch (error) {
|
||||
const currentItems = useDevUIStore.getState().chatItems;
|
||||
setChatItems(currentItems.map((item) =>
|
||||
item.id === assistantMessage.id && item.type === "message"
|
||||
? {
|
||||
...item,
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Error resuming stream: ${
|
||||
error instanceof Error ? error.message : "Unknown error"
|
||||
}`,
|
||||
} as import("@/types/openai").MessageTextContent,
|
||||
],
|
||||
status: "incomplete" as const,
|
||||
}
|
||||
: item
|
||||
));
|
||||
setIsStreaming(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadConversations = async () => {
|
||||
if (!selectedAgent) return;
|
||||
|
||||
setLoadingConversations(true);
|
||||
try {
|
||||
// Step 1: Try to list conversations from backend (DevUI extension)
|
||||
// This works with DevUI backend but fails with OpenAI/Azure (they don't have list endpoint)
|
||||
// Step 1: Always try to list conversations from backend first
|
||||
// This ensures we get the latest data from the server
|
||||
try {
|
||||
const { data: conversations } = await apiClient.listConversations(
|
||||
selectedAgent.id
|
||||
);
|
||||
|
||||
// Backend successfully returned conversations list
|
||||
setAvailableConversations(conversations);
|
||||
|
||||
if (conversations.length > 0) {
|
||||
// Found conversations on backend - use most recent
|
||||
const mostRecent = conversations[0];
|
||||
setAvailableConversations(conversations);
|
||||
setCurrentConversation(mostRecent);
|
||||
|
||||
// Load conversation items from backend
|
||||
try {
|
||||
const { data: items } = await apiClient.listConversationItems(
|
||||
mostRecent.id
|
||||
);
|
||||
// Load all conversation items with pagination
|
||||
let allItems: unknown[] = [];
|
||||
let hasMore = true;
|
||||
let after: string | undefined = undefined;
|
||||
|
||||
while (hasMore) {
|
||||
const result = await apiClient.listConversationItems(
|
||||
mostRecent.id,
|
||||
{ order: "asc", after } // Load in chronological order (oldest first)
|
||||
);
|
||||
allItems = allItems.concat(result.data);
|
||||
hasMore = result.has_more;
|
||||
|
||||
// Get the last item's ID for pagination
|
||||
if (hasMore && result.data.length > 0) {
|
||||
const lastItem = result.data[result.data.length - 1] as { id?: string };
|
||||
after = lastItem.id;
|
||||
}
|
||||
}
|
||||
|
||||
// Use OpenAI ConversationItems directly (no conversion!)
|
||||
setChatItems(items as import("@/types/openai").ConversationItem[]);
|
||||
setChatItems(allItems as import("@/types/openai").ConversationItem[]);
|
||||
setIsStreaming(false);
|
||||
|
||||
// Check for incomplete stream and resume if needed
|
||||
const state = loadStreamingState(mostRecent.id);
|
||||
|
||||
if (state && !state.completed) {
|
||||
accumulatedTextRef.current = state.accumulatedText || "";
|
||||
// Add assistant message with resumed text
|
||||
const assistantMsg: import("@/types/openai").ConversationMessage = {
|
||||
id: state.lastMessageId || `assistant-${Date.now()}`,
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
content: state.accumulatedText ? [{ type: "text", text: state.accumulatedText }] : [],
|
||||
status: "in_progress",
|
||||
};
|
||||
setChatItems([...allItems as import("@/types/openai").ConversationItem[], assistantMsg]);
|
||||
setIsStreaming(true);
|
||||
|
||||
// Resume streaming from where we left off
|
||||
setTimeout(() => {
|
||||
resumeStreaming(assistantMsg, mostRecent, selectedAgent);
|
||||
}, 100);
|
||||
}
|
||||
|
||||
// Scroll to bottom after loading conversation
|
||||
setTimeout(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
}, 100);
|
||||
} catch {
|
||||
// 404 means conversation exists but has no items yet (newly created)
|
||||
// This is normal - just start with empty chat
|
||||
@@ -316,11 +554,6 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
|
||||
setIsStreaming(false);
|
||||
}
|
||||
|
||||
// Cache to localStorage for faster future loads
|
||||
localStorage.setItem(
|
||||
`devui_convs_${selectedAgent.id}`,
|
||||
JSON.stringify(conversations)
|
||||
);
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
@@ -371,9 +604,6 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
|
||||
setAvailableConversations([newConversation]);
|
||||
setChatItems([]);
|
||||
setIsStreaming(false);
|
||||
|
||||
// Save to localStorage
|
||||
localStorage.setItem(cachedKey, JSON.stringify([newConversation]));
|
||||
} catch {
|
||||
setAvailableConversations([]);
|
||||
setChatItems([]);
|
||||
@@ -387,10 +617,12 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
|
||||
setChatItems([]);
|
||||
setIsStreaming(false);
|
||||
setCurrentConversation(undefined);
|
||||
accumulatedText.current = "";
|
||||
accumulatedTextRef.current = "";
|
||||
|
||||
loadConversations();
|
||||
}, [selectedAgent, setLoadingConversations, setAvailableConversations, setCurrentConversation, setChatItems, setIsStreaming]);
|
||||
// currentConversation is intentionally excluded - this effect should only run when agent changes
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selectedAgent, onDebugEvent, setChatItems, setIsStreaming, setLoadingConversations, setAvailableConversations, setCurrentConversation, setPendingApprovals, updateConversationUsage]);
|
||||
|
||||
// Handle file uploads
|
||||
const handleFilesSelected = async (files: File[]) => {
|
||||
@@ -626,16 +858,11 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
|
||||
setIsStreaming(false);
|
||||
// Reset conversation usage by setting it to initial state
|
||||
useDevUIStore.setState({ conversationUsage: { total_tokens: 0, message_count: 0 } });
|
||||
accumulatedText.current = "";
|
||||
|
||||
// Update localStorage cache with new conversation
|
||||
const cachedKey = `devui_convs_${selectedAgent.id}`;
|
||||
const updated = [newConversation, ...availableConversations];
|
||||
localStorage.setItem(cachedKey, JSON.stringify(updated));
|
||||
accumulatedTextRef.current = "";
|
||||
} catch {
|
||||
// Failed to create conversation
|
||||
}
|
||||
}, [selectedAgent, availableConversations, setCurrentConversation, setAvailableConversations, setChatItems, setIsStreaming]);
|
||||
}, [selectedAgent, setCurrentConversation, setAvailableConversations, setChatItems, setIsStreaming]);
|
||||
|
||||
// Handle conversation deletion
|
||||
const handleDeleteConversation = useCallback(
|
||||
@@ -660,15 +887,6 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
|
||||
);
|
||||
setAvailableConversations(updatedConversations);
|
||||
|
||||
// Update localStorage cache
|
||||
if (selectedAgent) {
|
||||
const cachedKey = `devui_convs_${selectedAgent.id}`;
|
||||
localStorage.setItem(
|
||||
cachedKey,
|
||||
JSON.stringify(updatedConversations)
|
||||
);
|
||||
}
|
||||
|
||||
// If deleted conversation was selected, switch to another conversation or clear chat
|
||||
if (currentConversation?.id === conversationId) {
|
||||
if (updatedConversations.length > 0) {
|
||||
@@ -683,7 +901,7 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
|
||||
setChatItems([]);
|
||||
setIsStreaming(false);
|
||||
useDevUIStore.setState({ conversationUsage: { total_tokens: 0, message_count: 0 } });
|
||||
accumulatedText.current = "";
|
||||
accumulatedTextRef.current = "";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -694,7 +912,7 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
|
||||
alert("Failed to delete conversation. Please try again.");
|
||||
}
|
||||
},
|
||||
[availableConversations, currentConversation, selectedAgent, onDebugEvent, setAvailableConversations, setCurrentConversation, setChatItems, setIsStreaming]
|
||||
[availableConversations, currentConversation, onDebugEvent, setAvailableConversations, setCurrentConversation, setChatItems, setIsStreaming]
|
||||
);
|
||||
|
||||
// Handle conversation selection
|
||||
@@ -711,11 +929,28 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
|
||||
onDebugEvent("clear");
|
||||
|
||||
try {
|
||||
// Load conversation history from backend
|
||||
const result = await apiClient.listConversationItems(conversationId);
|
||||
// Load conversation history from backend with pagination
|
||||
let allItems: unknown[] = [];
|
||||
let hasMore = true;
|
||||
let after: string | undefined = undefined;
|
||||
|
||||
while (hasMore) {
|
||||
const result = await apiClient.listConversationItems(conversationId, {
|
||||
order: "asc", // Load in chronological order (oldest first)
|
||||
after,
|
||||
});
|
||||
allItems = allItems.concat(result.data);
|
||||
hasMore = result.has_more;
|
||||
|
||||
// Get the last item's ID for pagination
|
||||
if (hasMore && result.data.length > 0) {
|
||||
const lastItem = result.data[result.data.length - 1] as { id?: string };
|
||||
after = lastItem.id;
|
||||
}
|
||||
}
|
||||
|
||||
// Use OpenAI ConversationItems directly (no conversion!)
|
||||
const items = result.data as import("@/types/openai").ConversationItem[];
|
||||
const items = allItems as import("@/types/openai").ConversationItem[];
|
||||
|
||||
setChatItems(items);
|
||||
setIsStreaming(false);
|
||||
@@ -727,6 +962,27 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
|
||||
message_count: items.length,
|
||||
}
|
||||
});
|
||||
|
||||
// Check for incomplete stream and restore accumulated text
|
||||
const state = loadStreamingState(conversationId);
|
||||
if (state?.accumulatedText) {
|
||||
accumulatedTextRef.current = state.accumulatedText;
|
||||
// Add assistant message with resumed text - streaming will continue automatically
|
||||
const assistantMsg: import("@/types/openai").ConversationMessage = {
|
||||
id: `assistant-${Date.now()}`,
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
content: [{ type: "output_text", text: state.accumulatedText }],
|
||||
status: "in_progress",
|
||||
};
|
||||
setChatItems([...items, assistantMsg]);
|
||||
setIsStreaming(true);
|
||||
}
|
||||
|
||||
// Scroll to bottom after loading conversation
|
||||
setTimeout(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
}, 100);
|
||||
} catch {
|
||||
// 404 means conversation doesn't exist or has no items yet
|
||||
// This can happen if server restarted (in-memory store cleared)
|
||||
@@ -736,7 +992,7 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
|
||||
useDevUIStore.setState({ conversationUsage: { total_tokens: 0, message_count: 0 } });
|
||||
}
|
||||
|
||||
accumulatedText.current = "";
|
||||
accumulatedTextRef.current = "";
|
||||
},
|
||||
[availableConversations, onDebugEvent, setCurrentConversation, setChatItems, setIsStreaming]
|
||||
);
|
||||
@@ -851,13 +1107,18 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
|
||||
}
|
||||
}
|
||||
|
||||
// Clear any previous streaming state for this conversation before starting new message
|
||||
if (conversationToUse?.id) {
|
||||
apiClient.clearStreamingState(conversationToUse.id);
|
||||
}
|
||||
|
||||
const apiRequest = {
|
||||
input: request.input,
|
||||
conversation_id: conversationToUse?.id,
|
||||
};
|
||||
|
||||
// Clear text accumulator for new response
|
||||
accumulatedText.current = "";
|
||||
accumulatedTextRef.current = "";
|
||||
|
||||
// Use OpenAI-compatible API streaming - direct event handling
|
||||
const streamGenerator = apiClient.streamAgentExecutionOpenAI(
|
||||
@@ -884,6 +1145,35 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
|
||||
continue; // Continue processing other events
|
||||
}
|
||||
|
||||
// Handle response.failed event
|
||||
if (openAIEvent.type === "response.failed") {
|
||||
const failedEvent = openAIEvent as import("@/types/openai").ResponseFailedEvent;
|
||||
const error = failedEvent.response?.error;
|
||||
const errorMessage = error
|
||||
? typeof error === "object" && "message" in error
|
||||
? (error as any).message
|
||||
: JSON.stringify(error)
|
||||
: "Request failed";
|
||||
|
||||
const currentItems = useDevUIStore.getState().chatItems;
|
||||
setChatItems(currentItems.map((item) =>
|
||||
item.id === assistantMessage.id && item.type === "message"
|
||||
? {
|
||||
...item,
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: accumulatedTextRef.current || errorMessage,
|
||||
} as import("@/types/openai").MessageTextContent,
|
||||
],
|
||||
status: "incomplete" as const,
|
||||
}
|
||||
: item
|
||||
));
|
||||
setIsStreaming(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle function approval request events
|
||||
if (openAIEvent.type === "response.function_approval.requested") {
|
||||
const approvalEvent = openAIEvent as import("@/types/openai").ResponseFunctionApprovalRequestedEvent;
|
||||
@@ -943,7 +1233,7 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
|
||||
"delta" in openAIEvent &&
|
||||
openAIEvent.delta
|
||||
) {
|
||||
accumulatedText.current += openAIEvent.delta;
|
||||
accumulatedTextRef.current += openAIEvent.delta;
|
||||
|
||||
// Update assistant message with accumulated content
|
||||
const currentItems = useDevUIStore.getState().chatItems;
|
||||
@@ -954,7 +1244,7 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: accumulatedText.current,
|
||||
text: accumulatedTextRef.current,
|
||||
} as import("@/types/openai").MessageTextContent,
|
||||
],
|
||||
status: "in_progress" as const,
|
||||
|
||||
+3
-1
@@ -23,7 +23,7 @@ interface ContentRendererProps {
|
||||
|
||||
// Text content renderer
|
||||
function TextContentRenderer({ content, className, isStreaming }: ContentRendererProps) {
|
||||
if (content.type !== "text") return null;
|
||||
if (content.type !== "text" && content.type !== "input_text" && content.type !== "output_text") return null;
|
||||
|
||||
const text = content.text;
|
||||
|
||||
@@ -160,6 +160,8 @@ function FileContentRenderer({ content, className }: ContentRendererProps) {
|
||||
export function OpenAIContentRenderer({ content, className, isStreaming }: ContentRendererProps) {
|
||||
switch (content.type) {
|
||||
case "text":
|
||||
case "input_text":
|
||||
case "output_text":
|
||||
return <TextContentRenderer content={content} className={className} isStreaming={isStreaming} />;
|
||||
case "input_image":
|
||||
return <ImageContentRenderer content={content} className={className} />;
|
||||
|
||||
@@ -554,6 +554,10 @@ export function WorkflowView({
|
||||
try {
|
||||
const request = { input_data: inputData };
|
||||
|
||||
// Clear any previous streaming state before starting new workflow execution
|
||||
// Note: Workflows don't use conversation IDs, so we use workflow ID as the key
|
||||
apiClient.clearStreamingState(selectedWorkflow.id);
|
||||
|
||||
// Use OpenAI-compatible API streaming - direct event handling
|
||||
const streamGenerator = apiClient.streamWorkflowExecutionOpenAI(
|
||||
selectedWorkflow.id,
|
||||
|
||||
@@ -3,6 +3,10 @@ import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
import { ThemeProvider } from "./components/theme-provider"
|
||||
import { initStreamingState } from "./services/api"
|
||||
|
||||
// Initialize streaming state management (clears expired states)
|
||||
initStreamingState();
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
|
||||
@@ -14,6 +14,12 @@ import type {
|
||||
} from "@/types";
|
||||
import type { AgentFrameworkRequest } from "@/types/agent-framework";
|
||||
import type { ExtendedResponseStreamEvent } from "@/types/openai";
|
||||
import {
|
||||
loadStreamingState,
|
||||
updateStreamingState,
|
||||
markStreamingCompleted,
|
||||
clearStreamingState,
|
||||
} from "./streaming-state";
|
||||
|
||||
// Backend API response type - polymorphic entity that can be agent or workflow
|
||||
// This matches the Python Pydantic EntityInfo model which has all fields optional
|
||||
@@ -57,9 +63,27 @@ const DEFAULT_API_BASE_URL =
|
||||
? import.meta.env.VITE_API_BASE_URL
|
||||
: "http://localhost:8080";
|
||||
|
||||
// Retry configuration for streaming
|
||||
const RETRY_INTERVAL_MS = 1000; // Retry every second
|
||||
const MAX_RETRY_ATTEMPTS = 600; // Max 600 retries (10 minutes total)
|
||||
|
||||
// Get backend URL from localStorage or default
|
||||
function getBackendUrl(): string {
|
||||
return localStorage.getItem("devui_backend_url") || DEFAULT_API_BASE_URL;
|
||||
const stored = localStorage.getItem("devui_backend_url");
|
||||
if (stored) return stored;
|
||||
|
||||
// If VITE_API_BASE_URL is explicitly set to empty string, use relative path
|
||||
// This allows the frontend to call the same host it's served from
|
||||
if (import.meta.env.VITE_API_BASE_URL === "") {
|
||||
return "";
|
||||
}
|
||||
|
||||
return DEFAULT_API_BASE_URL;
|
||||
}
|
||||
|
||||
// Helper to sleep for a given duration
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
class ApiClient {
|
||||
@@ -272,6 +296,8 @@ class ApiClient {
|
||||
await this.request(`/v1/conversations/${conversationId}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
// Clear streaming state when conversation is deleted
|
||||
clearStreamingState(conversationId);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
@@ -297,10 +323,230 @@ class ApiClient {
|
||||
|
||||
// OpenAI-compatible streaming methods using /v1/responses endpoint
|
||||
|
||||
// Private helper method that handles the actual streaming with retry logic
|
||||
private async *streamOpenAIResponse(
|
||||
openAIRequest: AgentFrameworkRequest,
|
||||
conversationId?: string,
|
||||
resumeResponseId?: string
|
||||
): AsyncGenerator<ExtendedResponseStreamEvent, void, unknown> {
|
||||
let lastSequenceNumber = -1;
|
||||
let retryCount = 0;
|
||||
let hasYieldedAnyEvent = false;
|
||||
let currentResponseId: string | undefined = resumeResponseId;
|
||||
let lastMessageId: string | undefined = undefined;
|
||||
|
||||
// Try to resume from stored state if conversation ID is provided
|
||||
if (conversationId) {
|
||||
const storedState = loadStreamingState(conversationId);
|
||||
if (storedState) {
|
||||
// Use stored response ID if no explicit one provided
|
||||
if (!resumeResponseId) {
|
||||
currentResponseId = storedState.responseId;
|
||||
}
|
||||
|
||||
lastSequenceNumber = storedState.lastSequenceNumber;
|
||||
lastMessageId = storedState.lastMessageId;
|
||||
|
||||
// Replay stored events only if we're not explicitly resuming
|
||||
// (explicit resume means the caller already has the events)
|
||||
if (!resumeResponseId) {
|
||||
for (const event of storedState.events) {
|
||||
hasYieldedAnyEvent = true;
|
||||
yield event;
|
||||
}
|
||||
} else {
|
||||
// Mark that we've already seen events up to this sequence number
|
||||
hasYieldedAnyEvent = storedState.events.length > 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
while (retryCount <= MAX_RETRY_ATTEMPTS) {
|
||||
try {
|
||||
// If we have a response_id from a previous attempt, use GET endpoint to resume
|
||||
// Otherwise, use POST to create a new response
|
||||
let response: Response;
|
||||
if (currentResponseId) {
|
||||
const params = new URLSearchParams();
|
||||
params.set("stream", "true");
|
||||
if (lastSequenceNumber >= 0) {
|
||||
params.set("starting_after", lastSequenceNumber.toString());
|
||||
}
|
||||
const url = `${this.baseUrl}/v1/responses/${currentResponseId}?${params.toString()}`;
|
||||
response = await fetch(url, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Accept: "text/event-stream",
|
||||
},
|
||||
});
|
||||
} else {
|
||||
const url = `${this.baseUrl}/v1/responses`;
|
||||
response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "text/event-stream",
|
||||
},
|
||||
body: JSON.stringify(openAIRequest),
|
||||
});
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
// Try to extract detailed error message from response body
|
||||
let errorMessage = `Request failed with status ${response.status}`;
|
||||
try {
|
||||
const errorBody = await response.json();
|
||||
if (errorBody.error && errorBody.error.message) {
|
||||
errorMessage = errorBody.error.message;
|
||||
} else if (errorBody.detail) {
|
||||
errorMessage = errorBody.detail;
|
||||
}
|
||||
} catch {
|
||||
// Fallback to generic message if parsing fails
|
||||
}
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) {
|
||||
throw new Error("Response body is not readable");
|
||||
}
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
|
||||
if (done) {
|
||||
// Stream completed successfully
|
||||
if (conversationId) {
|
||||
markStreamingCompleted(conversationId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
|
||||
// Parse SSE events
|
||||
const lines = buffer.split("\n");
|
||||
buffer = lines.pop() || ""; // Keep incomplete line in buffer
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith("data: ")) {
|
||||
const dataStr = line.slice(6);
|
||||
|
||||
// Handle [DONE] signal
|
||||
if (dataStr === "[DONE]") {
|
||||
if (conversationId) {
|
||||
markStreamingCompleted(conversationId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const openAIEvent: ExtendedResponseStreamEvent =
|
||||
JSON.parse(dataStr);
|
||||
|
||||
// Capture response_id if present in the event for use in retries
|
||||
if ("response" in openAIEvent && openAIEvent.response && typeof openAIEvent.response === "object" && "id" in openAIEvent.response) {
|
||||
const newResponseId = openAIEvent.response.id as string;
|
||||
if (!currentResponseId || currentResponseId !== newResponseId) {
|
||||
currentResponseId = newResponseId;
|
||||
}
|
||||
} else if ("id" in openAIEvent && typeof openAIEvent.id === "string" && openAIEvent.id.startsWith("resp_")) {
|
||||
const newResponseId = openAIEvent.id;
|
||||
if (!currentResponseId || currentResponseId !== newResponseId) {
|
||||
currentResponseId = newResponseId;
|
||||
}
|
||||
}
|
||||
|
||||
// Track last message ID if present (for user/assistant messages)
|
||||
if ("item_id" in openAIEvent && openAIEvent.item_id) {
|
||||
lastMessageId = openAIEvent.item_id;
|
||||
}
|
||||
|
||||
// Check for sequence number restart (server restarted response)
|
||||
const eventSeq = "sequence_number" in openAIEvent ? openAIEvent.sequence_number : undefined;
|
||||
if (eventSeq !== undefined) {
|
||||
// If we've received events before and sequence restarted from 0/1
|
||||
if (hasYieldedAnyEvent && eventSeq <= 1 && lastSequenceNumber > 1) {
|
||||
// Server restarted the response - clear old state and start fresh
|
||||
if (conversationId) {
|
||||
clearStreamingState(conversationId);
|
||||
}
|
||||
yield {
|
||||
type: "error",
|
||||
message: "Connection lost - previous response failed. Starting new response.",
|
||||
} as ExtendedResponseStreamEvent;
|
||||
lastSequenceNumber = eventSeq;
|
||||
hasYieldedAnyEvent = true;
|
||||
|
||||
// Save new event to storage
|
||||
if (conversationId && currentResponseId) {
|
||||
updateStreamingState(conversationId, openAIEvent, currentResponseId, lastMessageId);
|
||||
}
|
||||
|
||||
yield openAIEvent;
|
||||
}
|
||||
// Skip events we've already seen (resume from last position)
|
||||
else if (eventSeq <= lastSequenceNumber) {
|
||||
continue; // Skip duplicate event
|
||||
} else {
|
||||
lastSequenceNumber = eventSeq;
|
||||
hasYieldedAnyEvent = true;
|
||||
|
||||
// Save event to storage before yielding
|
||||
if (conversationId && currentResponseId) {
|
||||
updateStreamingState(conversationId, openAIEvent, currentResponseId, lastMessageId);
|
||||
}
|
||||
|
||||
yield openAIEvent;
|
||||
}
|
||||
} else {
|
||||
// No sequence number - just yield the event
|
||||
hasYieldedAnyEvent = true;
|
||||
|
||||
// Still save to storage if we have conversation context
|
||||
if (conversationId && currentResponseId) {
|
||||
updateStreamingState(conversationId, openAIEvent, currentResponseId, lastMessageId);
|
||||
}
|
||||
|
||||
yield openAIEvent;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to parse OpenAI SSE event:", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
} catch (error) {
|
||||
// Network error occurred - prepare to retry
|
||||
retryCount++;
|
||||
|
||||
if (retryCount > MAX_RETRY_ATTEMPTS) {
|
||||
// Max retries exceeded - give up
|
||||
throw new Error(
|
||||
`Connection failed after ${MAX_RETRY_ATTEMPTS} retry attempts: ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
}
|
||||
|
||||
// Wait before retrying
|
||||
await sleep(RETRY_INTERVAL_MS);
|
||||
// Loop will retry with GET if we have response_id, otherwise POST
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Stream agent execution using OpenAI format with simplified routing
|
||||
async *streamAgentExecutionOpenAI(
|
||||
agentId: string,
|
||||
request: RunAgentRequest
|
||||
request: RunAgentRequest,
|
||||
resumeResponseId?: string
|
||||
): AsyncGenerator<ExtendedResponseStreamEvent, void, unknown> {
|
||||
const openAIRequest: AgentFrameworkRequest = {
|
||||
model: agentId, // Model IS the entity_id (simplified routing!)
|
||||
@@ -309,87 +555,20 @@ class ApiClient {
|
||||
conversation: request.conversation_id, // OpenAI standard conversation param
|
||||
};
|
||||
|
||||
return yield* this.streamAgentExecutionOpenAIDirect(agentId, openAIRequest);
|
||||
return yield* this.streamAgentExecutionOpenAIDirect(agentId, openAIRequest, request.conversation_id, resumeResponseId);
|
||||
}
|
||||
|
||||
// Stream agent execution using direct OpenAI format
|
||||
async *streamAgentExecutionOpenAIDirect(
|
||||
_agentId: string,
|
||||
openAIRequest: AgentFrameworkRequest
|
||||
openAIRequest: AgentFrameworkRequest,
|
||||
conversationId?: string,
|
||||
resumeResponseId?: string
|
||||
): AsyncGenerator<ExtendedResponseStreamEvent, void, unknown> {
|
||||
|
||||
const response = await fetch(`${this.baseUrl}/v1/responses`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "text/event-stream",
|
||||
},
|
||||
body: JSON.stringify(openAIRequest),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
// Try to extract detailed error message from response body
|
||||
let errorMessage = `Request failed with status ${response.status}`;
|
||||
try {
|
||||
const errorBody = await response.json();
|
||||
if (errorBody.error && errorBody.error.message) {
|
||||
errorMessage = errorBody.error.message;
|
||||
} else if (errorBody.detail) {
|
||||
errorMessage = errorBody.detail;
|
||||
}
|
||||
} catch {
|
||||
// Fallback to generic message if parsing fails
|
||||
}
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) {
|
||||
throw new Error("Response body is not readable");
|
||||
}
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
|
||||
if (done) {
|
||||
break;
|
||||
}
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
|
||||
// Parse SSE events
|
||||
const lines = buffer.split("\n");
|
||||
buffer = lines.pop() || ""; // Keep incomplete line in buffer
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith("data: ")) {
|
||||
const dataStr = line.slice(6);
|
||||
|
||||
// Handle [DONE] signal
|
||||
if (dataStr === "[DONE]") {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const openAIEvent: ExtendedResponseStreamEvent =
|
||||
JSON.parse(dataStr);
|
||||
yield openAIEvent; // Direct pass-through - no conversion!
|
||||
} catch (e) {
|
||||
console.error("Failed to parse OpenAI SSE event:", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
yield* this.streamOpenAIResponse(openAIRequest, conversationId, resumeResponseId);
|
||||
}
|
||||
|
||||
// Stream workflow execution using OpenAI format - direct event pass-through
|
||||
// Stream workflow execution using OpenAI format
|
||||
async *streamWorkflowExecutionOpenAI(
|
||||
workflowId: string,
|
||||
request: RunWorkflowRequest
|
||||
@@ -402,75 +581,7 @@ class ApiClient {
|
||||
conversation: request.conversation_id, // Include conversation if present
|
||||
};
|
||||
|
||||
const response = await fetch(`${this.baseUrl}/v1/responses`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "text/event-stream",
|
||||
},
|
||||
body: JSON.stringify(openAIRequest),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
// Try to extract detailed error message from response body
|
||||
let errorMessage = `Request failed with status ${response.status}`;
|
||||
try {
|
||||
const errorBody = await response.json();
|
||||
if (errorBody.error && errorBody.error.message) {
|
||||
errorMessage = errorBody.error.message;
|
||||
} else if (errorBody.detail) {
|
||||
errorMessage = errorBody.detail;
|
||||
}
|
||||
} catch {
|
||||
// Fallback to generic message if parsing fails
|
||||
}
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) {
|
||||
throw new Error("Response body is not readable");
|
||||
}
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
|
||||
if (done) {
|
||||
break;
|
||||
}
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
|
||||
// Parse SSE events
|
||||
const lines = buffer.split("\n");
|
||||
buffer = lines.pop() || ""; // Keep incomplete line in buffer
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith("data: ")) {
|
||||
const dataStr = line.slice(6);
|
||||
|
||||
// Handle [DONE] signal
|
||||
if (dataStr === "[DONE]") {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const openAIEvent: ExtendedResponseStreamEvent =
|
||||
JSON.parse(dataStr);
|
||||
yield openAIEvent; // Direct pass-through - no conversion!
|
||||
} catch (e) {
|
||||
console.error("Failed to parse OpenAI SSE event:", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
yield* this.streamOpenAIResponse(openAIRequest, request.conversation_id);
|
||||
}
|
||||
|
||||
// REMOVED: Legacy streaming methods - use streamAgentExecutionOpenAI and streamWorkflowExecutionOpenAI instead
|
||||
@@ -503,8 +614,16 @@ class ApiClient {
|
||||
body: JSON.stringify(request),
|
||||
});
|
||||
}
|
||||
|
||||
// Clear streaming state for a conversation (e.g., when starting a new message)
|
||||
clearStreamingState(conversationId: string): void {
|
||||
clearStreamingState(conversationId);
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const apiClient = new ApiClient();
|
||||
export { ApiClient };
|
||||
|
||||
// Export streaming state init function
|
||||
export { initStreamingState } from "./streaming-state";
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
/**
|
||||
* Streaming State Persistence
|
||||
*
|
||||
* Manages browser storage of streaming response state to enable:
|
||||
* - Resume interrupted streams after page refresh
|
||||
* - Replay cached events before fetching new ones
|
||||
* - Graceful recovery from network disconnections
|
||||
*/
|
||||
|
||||
import type { ExtendedResponseStreamEvent } from "@/types/openai";
|
||||
|
||||
export interface StreamingState {
|
||||
conversationId: string;
|
||||
responseId: string;
|
||||
lastMessageId?: string;
|
||||
lastSequenceNumber: number;
|
||||
events: ExtendedResponseStreamEvent[];
|
||||
timestamp: number; // When this state was last updated
|
||||
completed: boolean; // Whether the stream completed successfully
|
||||
accumulatedText?: string; // Accumulated text content for quick restoration
|
||||
}
|
||||
|
||||
const STORAGE_KEY_PREFIX = "devui_streaming_state_";
|
||||
const STATE_EXPIRY_MS = 24 * 60 * 60 * 1000; // 24 hours
|
||||
|
||||
/**
|
||||
* Storage key for a specific conversation
|
||||
*/
|
||||
function getStorageKey(conversationId: string): string {
|
||||
return `${STORAGE_KEY_PREFIX}${conversationId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract accumulated text from events (for quick restoration)
|
||||
*/
|
||||
function extractAccumulatedText(events: ExtendedResponseStreamEvent[]): string {
|
||||
let text = "";
|
||||
for (const event of events) {
|
||||
if (event.type === "response.output_text.delta" && "delta" in event) {
|
||||
text += event.delta;
|
||||
}
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save streaming state to browser storage
|
||||
*/
|
||||
export function saveStreamingState(state: StreamingState): void {
|
||||
try {
|
||||
const key = getStorageKey(state.conversationId);
|
||||
const data = JSON.stringify(state);
|
||||
localStorage.setItem(key, data);
|
||||
} catch (error) {
|
||||
console.error("Failed to save streaming state:", error);
|
||||
// If storage is full, try to clear old states
|
||||
try {
|
||||
clearExpiredStreamingStates();
|
||||
// Try again
|
||||
const key = getStorageKey(state.conversationId);
|
||||
const data = JSON.stringify(state);
|
||||
localStorage.setItem(key, data);
|
||||
} catch {
|
||||
console.error("Failed to save streaming state even after cleanup");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load streaming state from browser storage
|
||||
*/
|
||||
export function loadStreamingState(conversationId: string): StreamingState | null {
|
||||
try {
|
||||
const key = getStorageKey(conversationId);
|
||||
const data = localStorage.getItem(key);
|
||||
|
||||
if (!data) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const state: StreamingState = JSON.parse(data);
|
||||
|
||||
// Check if state has expired
|
||||
const age = Date.now() - state.timestamp;
|
||||
if (age > STATE_EXPIRY_MS) {
|
||||
clearStreamingState(conversationId);
|
||||
return null;
|
||||
}
|
||||
|
||||
// If stream was completed, no need to resume
|
||||
if (state.completed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return state;
|
||||
} catch (error) {
|
||||
console.error("Failed to load streaming state:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update streaming state with a new event
|
||||
*/
|
||||
export function updateStreamingState(
|
||||
conversationId: string,
|
||||
event: ExtendedResponseStreamEvent,
|
||||
responseId: string,
|
||||
lastMessageId?: string
|
||||
): void {
|
||||
try {
|
||||
const existing = loadStreamingState(conversationId);
|
||||
const sequenceNumber = "sequence_number" in event ? event.sequence_number : undefined;
|
||||
|
||||
const newEvents = existing ? [...existing.events, event] : [event];
|
||||
|
||||
const state: StreamingState = {
|
||||
conversationId,
|
||||
responseId,
|
||||
lastMessageId,
|
||||
lastSequenceNumber: sequenceNumber ?? (existing?.lastSequenceNumber ?? -1),
|
||||
events: newEvents,
|
||||
timestamp: Date.now(),
|
||||
completed: event.type === "response.completed" || event.type === "response.failed",
|
||||
accumulatedText: extractAccumulatedText(newEvents),
|
||||
};
|
||||
|
||||
saveStreamingState(state);
|
||||
} catch (error) {
|
||||
console.error("Failed to update streaming state:", error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark streaming state as completed
|
||||
*/
|
||||
export function markStreamingCompleted(conversationId: string): void {
|
||||
try {
|
||||
const existing = loadStreamingState(conversationId);
|
||||
if (existing) {
|
||||
existing.completed = true;
|
||||
existing.timestamp = Date.now();
|
||||
saveStreamingState(existing);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to mark streaming as completed:", error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear streaming state for a conversation
|
||||
*/
|
||||
export function clearStreamingState(conversationId: string): void {
|
||||
try {
|
||||
const key = getStorageKey(conversationId);
|
||||
localStorage.removeItem(key);
|
||||
} catch (error) {
|
||||
console.error("Failed to clear streaming state:", error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all expired streaming states
|
||||
*/
|
||||
export function clearExpiredStreamingStates(): void {
|
||||
try {
|
||||
const keys = Object.keys(localStorage);
|
||||
const now = Date.now();
|
||||
|
||||
for (const key of keys) {
|
||||
if (key.startsWith(STORAGE_KEY_PREFIX)) {
|
||||
try {
|
||||
const data = localStorage.getItem(key);
|
||||
if (data) {
|
||||
const state: StreamingState = JSON.parse(data);
|
||||
const age = now - state.timestamp;
|
||||
|
||||
if (age > STATE_EXPIRY_MS || state.completed) {
|
||||
localStorage.removeItem(key);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Invalid state, remove it
|
||||
localStorage.removeItem(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to clear expired streaming states:", error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize streaming state management (call on app startup)
|
||||
*/
|
||||
export function initStreamingState(): void {
|
||||
// Clear expired states on startup
|
||||
clearExpiredStreamingStates();
|
||||
}
|
||||
@@ -347,6 +347,18 @@ export interface MessageTextContent {
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface MessageInputTextContent {
|
||||
type: "input_text";
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface MessageOutputTextContent {
|
||||
type: "output_text";
|
||||
text: string;
|
||||
annotations?: any[];
|
||||
logprobs?: any[];
|
||||
}
|
||||
|
||||
export interface MessageInputImage {
|
||||
type: "input_image";
|
||||
image_url: string;
|
||||
@@ -376,6 +388,8 @@ export interface MessageFunctionApprovalResponseContent {
|
||||
|
||||
export type MessageContent =
|
||||
| MessageTextContent
|
||||
| MessageInputTextContent
|
||||
| MessageOutputTextContent
|
||||
| MessageInputImage
|
||||
| MessageInputFile
|
||||
| MessageFunctionApprovalResponseContent;
|
||||
|
||||
Reference in New Issue
Block a user