.NET: Add DevUI package for .NET (#1603)

* Implement DevUI

* Review feedback

* Fix build
This commit is contained in:
Reuben Bond
2025-11-05 12:02:48 -08:00
committed by GitHub
Unverified
parent 94a5ba3448
commit 8855bfb065
36 changed files with 2733 additions and 1176 deletions
+263 -144
View File
@@ -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();
}