chore: import upstream snapshot with attribution
dotnet-build-and-test / dotnet-test-functions (push) Has been cancelled
dotnet-build-and-test / paths-filter (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Debug, windows-latest, net9.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net8.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-foundry-hosted-it (push) Has been cancelled
dotnet-build-and-test / dotnet-build-and-test-check (push) Has been cancelled
dotnet-build-and-test / Integration Test Report (push) Has been cancelled
CodeQL / Analyze (csharp) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
dotnet-build-and-test / dotnet-test-functions (push) Has been cancelled
dotnet-build-and-test / paths-filter (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Debug, windows-latest, net9.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net8.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-foundry-hosted-it (push) Has been cancelled
dotnet-build-and-test / dotnet-build-and-test-check (push) Has been cancelled
dotnet-build-and-test / Integration Test Report (push) Has been cancelled
CodeQL / Analyze (csharp) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,239 @@
|
||||
/**
|
||||
* Streaming State Persistence
|
||||
*
|
||||
* Manages browser storage of streaming response state to enable:
|
||||
* - Resume interrupted streams after page refresh
|
||||
* - Graceful recovery from network disconnections
|
||||
*/
|
||||
|
||||
import type { ExtendedResponseStreamEvent } from "@/types/openai";
|
||||
|
||||
export interface StreamingState {
|
||||
conversationId: string;
|
||||
responseId: string;
|
||||
lastMessageId?: string;
|
||||
lastSequenceNumber: number;
|
||||
timestamp: number; // When this state was last updated
|
||||
completed: boolean; // Whether the stream completed successfully
|
||||
accumulatedText?: string; // Bounded tail preview for refresh restoration
|
||||
accumulatedTextIsPreview?: boolean;
|
||||
}
|
||||
|
||||
const STORAGE_KEY_PREFIX = "devui_streaming_state_";
|
||||
const STATE_EXPIRY_MS = 24 * 60 * 60 * 1000; // 24 hours
|
||||
const MAX_ACCUMULATED_TEXT_PREVIEW_CHARS = 16 * 1024;
|
||||
|
||||
interface CreateStreamingStateOptions {
|
||||
conversationId: string;
|
||||
responseId: string;
|
||||
lastMessageId?: string;
|
||||
lastSequenceNumber?: number;
|
||||
accumulatedText?: string;
|
||||
accumulatedTextIsPreview?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Storage key for a specific conversation
|
||||
*/
|
||||
function getStorageKey(conversationId: string): string {
|
||||
return `${STORAGE_KEY_PREFIX}${conversationId}`;
|
||||
}
|
||||
|
||||
function normalizeAccumulatedTextPreview(state: StreamingState): StreamingState {
|
||||
if (
|
||||
state.accumulatedText === undefined ||
|
||||
state.accumulatedText.length <= MAX_ACCUMULATED_TEXT_PREVIEW_CHARS
|
||||
) {
|
||||
return state;
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
accumulatedText: state.accumulatedText.slice(-MAX_ACCUMULATED_TEXT_PREVIEW_CHARS),
|
||||
accumulatedTextIsPreview: true,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Read raw streaming state from storage, including completed entries.
|
||||
*/
|
||||
function readStreamingState(conversationId: string): StreamingState | null {
|
||||
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;
|
||||
}
|
||||
|
||||
return normalizeAccumulatedTextPreview(state);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an initial streaming state snapshot.
|
||||
*/
|
||||
export function createStreamingState({
|
||||
conversationId,
|
||||
responseId,
|
||||
lastMessageId,
|
||||
lastSequenceNumber = -1,
|
||||
accumulatedText,
|
||||
accumulatedTextIsPreview = false,
|
||||
}: CreateStreamingStateOptions): StreamingState {
|
||||
return normalizeAccumulatedTextPreview({
|
||||
conversationId,
|
||||
responseId,
|
||||
lastMessageId,
|
||||
lastSequenceNumber,
|
||||
timestamp: Date.now(),
|
||||
completed: false,
|
||||
accumulatedText,
|
||||
accumulatedTextIsPreview,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply an incoming stream event to an in-memory streaming state snapshot.
|
||||
*/
|
||||
export function applyStreamingEventToState(
|
||||
state: StreamingState,
|
||||
event: ExtendedResponseStreamEvent,
|
||||
responseId: string,
|
||||
lastMessageId?: string
|
||||
): StreamingState {
|
||||
const sequenceNumber = "sequence_number" in event ? event.sequence_number : undefined;
|
||||
const nextState: StreamingState = {
|
||||
...state,
|
||||
responseId,
|
||||
lastMessageId,
|
||||
timestamp: Date.now(),
|
||||
completed: event.type === "response.completed" || event.type === "response.failed",
|
||||
};
|
||||
|
||||
if (sequenceNumber !== undefined) {
|
||||
nextState.lastSequenceNumber = sequenceNumber;
|
||||
}
|
||||
|
||||
if (
|
||||
event.type === "response.output_text.delta" &&
|
||||
"delta" in event &&
|
||||
typeof event.delta === "string" &&
|
||||
event.delta.length > 0
|
||||
) {
|
||||
const accumulatedText = `${state.accumulatedText ?? ""}${event.delta}`;
|
||||
const isPreview =
|
||||
state.accumulatedTextIsPreview ||
|
||||
accumulatedText.length > MAX_ACCUMULATED_TEXT_PREVIEW_CHARS;
|
||||
|
||||
nextState.accumulatedText = isPreview
|
||||
? accumulatedText.slice(-MAX_ACCUMULATED_TEXT_PREVIEW_CHARS)
|
||||
: accumulatedText;
|
||||
nextState.accumulatedTextIsPreview = isPreview;
|
||||
}
|
||||
|
||||
return nextState;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save streaming state to browser storage
|
||||
*/
|
||||
export function saveStreamingState(state: StreamingState): void {
|
||||
try {
|
||||
const key = getStorageKey(state.conversationId);
|
||||
const data = JSON.stringify(normalizeAccumulatedTextPreview(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(normalizeAccumulatedTextPreview(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 state = readStreamingState(conversationId);
|
||||
if (!state) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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();
|
||||
}
|
||||
Reference in New Issue
Block a user