graphlit-client
Version:
Graphlit API Client for TypeScript
503 lines (502 loc) • 20.2 kB
JavaScript
import { ConversationRoleTypes, } from "../generated/graphql-types.js";
import { ChunkBuffer } from "./chunk-buffer.js";
/**
* Adapter that transforms low-level streaming events into high-level UI events
* using GraphQL types for type safety
*/
export class UIEventAdapter {
onEvent;
conversationId;
model;
modelService;
tokenCount = 0;
currentMessage = "";
isStreaming = false;
conversationStartTime = 0; // When user sent the message
streamStartTime = 0; // When streaming actually began
firstTokenTime = 0;
lastTokenTime = 0;
tokenDelays = [];
activeToolCalls = new Map();
lastUpdateTime = 0;
updateTimer;
chunkBuffer;
smoothingDelay = 30;
chunkQueue = []; // Queue of chunks waiting to be emitted
contextWindowUsage;
constructor(onEvent, conversationId, options = {}) {
this.onEvent = onEvent;
this.conversationId = conversationId;
this.smoothingDelay = options.smoothingDelay ?? 30;
this.model = options.model;
this.modelService = options.modelService;
this.conversationStartTime = Date.now(); // Capture when conversation began
if (options.smoothingEnabled) {
this.chunkBuffer = new ChunkBuffer(options.chunkingStrategy || "word");
}
}
/**
* Process a raw streaming event and emit appropriate UI events
*/
handleEvent(event) {
switch (event.type) {
case "start":
this.handleStart(event.conversationId);
break;
case "token":
this.handleToken(event.token);
break;
case "message":
this.handleMessage(event.message);
break;
case "tool_call_start":
this.handleToolCallStart(event.toolCall);
break;
case "tool_call_delta":
this.handleToolCallDelta(event.toolCallId, event.argumentDelta);
break;
case "tool_call_parsed":
this.handleToolCallParsed(event.toolCall);
break;
case "tool_call_complete":
this.handleToolCallComplete(event.toolCall, event.result, event.error);
break;
case "complete":
this.handleComplete(event.tokens);
break;
case "error":
this.handleError(event.error);
break;
case "context_window":
this.handleContextWindow(event.usage);
break;
}
}
handleStart(conversationId) {
if (process.env.DEBUG_GRAPHLIT_SDK_STREAMING) {
console.log(`🚀 [UIEventAdapter] Handle start - Conversation ID: ${conversationId}`);
console.log(`🚀 [UIEventAdapter] Active tool calls at start: ${this.activeToolCalls.size}`);
}
this.conversationId = conversationId;
this.isStreaming = true;
this.streamStartTime = Date.now();
this.firstTokenTime = 0;
this.lastTokenTime = 0;
this.tokenCount = 0;
this.tokenDelays = [];
// Note: We only clear tool calls here if this is truly a new conversation start
// For multi-round tool calling, handleStart is only called once at the beginning
if (this.activeToolCalls.size > 0) {
console.log(`🚀 [UIEventAdapter] Warning: ${this.activeToolCalls.size} tool calls still active at start`);
}
this.activeToolCalls.clear();
this.emitUIEvent({
type: "conversation_started",
conversationId,
timestamp: new Date(),
model: this.model,
});
}
handleToken(token) {
// Track timing for first token
const now = Date.now();
if (this.firstTokenTime === 0) {
this.firstTokenTime = now;
}
// Track inter-token delays
if (this.lastTokenTime > 0) {
this.tokenDelays.push(now - this.lastTokenTime);
}
this.lastTokenTime = now;
this.tokenCount++;
if (this.chunkBuffer) {
const chunks = this.chunkBuffer.addToken(token);
// Add chunks to queue for all chunking modes (character, word, sentence)
this.chunkQueue.push(...chunks);
this.scheduleChunkEmission();
}
else {
// No chunking - emit tokens directly
this.currentMessage += token;
this.scheduleMessageUpdate();
}
}
handleMessage(message) {
this.currentMessage = message;
this.emitMessageUpdate(false);
}
handleToolCallStart(toolCall) {
if (process.env.DEBUG_GRAPHLIT_SDK_STREAMING) {
console.log(`🔧 [UIEventAdapter] Tool call start - ID: ${toolCall.id}, Name: ${toolCall.name}`);
console.log(`🔧 [UIEventAdapter] Active tool calls before: ${this.activeToolCalls.size}`);
}
const conversationToolCall = {
__typename: "ConversationToolCall",
id: toolCall.id,
name: toolCall.name,
arguments: "",
};
this.activeToolCalls.set(toolCall.id, {
toolCall: conversationToolCall,
status: "preparing",
});
if (process.env.DEBUG_GRAPHLIT_SDK_STREAMING) {
console.log(`🔧 [UIEventAdapter] Active tool calls after: ${this.activeToolCalls.size}`);
}
this.emitUIEvent({
type: "tool_update",
toolCall: conversationToolCall,
status: "preparing",
});
}
handleToolCallDelta(toolCallId, argumentDelta) {
if (process.env.DEBUG_GRAPHLIT_SDK_STREAMING) {
console.log(`🔧 [UIEventAdapter] Tool call delta - ID: ${toolCallId}, Delta length: ${argumentDelta.length}`);
console.log(`🔧 [UIEventAdapter] Delta content: ${argumentDelta.substring(0, 100)}...`);
}
const toolData = this.activeToolCalls.get(toolCallId);
if (toolData) {
toolData.toolCall.arguments += argumentDelta;
if (process.env.DEBUG_GRAPHLIT_SDK_STREAMING) {
console.log(`🔧 [UIEventAdapter] Tool ${toolCallId} accumulated args length: ${toolData.toolCall.arguments.length}`);
}
if (toolData.status === "preparing") {
toolData.status = "executing";
}
this.emitUIEvent({
type: "tool_update",
toolCall: toolData.toolCall,
status: "executing",
});
}
else {
console.warn(`🔧 [UIEventAdapter] WARNING: Tool call delta for unknown tool ID: ${toolCallId}`);
}
}
handleToolCallParsed(toolCall) {
if (process.env.DEBUG_GRAPHLIT_SDK_STREAMING) {
console.log(`🔧 [UIEventAdapter] Tool call parsed - ID: ${toolCall.id}, Name: ${toolCall.name}`);
console.log(`🔧 [UIEventAdapter] Final arguments length: ${toolCall.arguments.length}`);
console.log(`🔧 [UIEventAdapter] Final arguments: ${toolCall.arguments.substring(0, 200)}...`);
}
const toolData = this.activeToolCalls.get(toolCall.id);
if (toolData) {
// Update the arguments with the final complete version
toolData.toolCall.arguments = toolCall.arguments;
// Mark as ready for execution, not completed
toolData.status = "ready";
this.emitUIEvent({
type: "tool_update",
toolCall: toolData.toolCall,
status: "ready",
});
}
else {
// If we don't have this tool call tracked, create it now
console.warn(`🔧 [UIEventAdapter] Tool call parsed for untracked tool ID: ${toolCall.id}, creating entry`);
const conversationToolCall = {
__typename: "ConversationToolCall",
id: toolCall.id,
name: toolCall.name,
arguments: toolCall.arguments,
};
this.activeToolCalls.set(toolCall.id, {
toolCall: conversationToolCall,
status: "ready",
});
this.emitUIEvent({
type: "tool_update",
toolCall: conversationToolCall,
status: "ready",
});
}
}
handleToolCallComplete(toolCall, result, error) {
if (process.env.DEBUG_GRAPHLIT_SDK_STREAMING) {
console.log(`🔧 [UIEventAdapter] Tool call complete - ID: ${toolCall.id}, Name: ${toolCall.name}`);
console.log(`🔧 [UIEventAdapter] Has result: ${!!result}, Has error: ${!!error}`);
}
const toolData = this.activeToolCalls.get(toolCall.id);
if (toolData) {
// Update with execution result
toolData.status = error ? "failed" : "completed";
this.emitUIEvent({
type: "tool_update",
toolCall: toolData.toolCall,
status: toolData.status,
result: result,
error: error,
});
}
else {
console.warn(`🔧 [UIEventAdapter] Tool call complete for unknown tool ID: ${toolCall.id}`);
}
}
handleComplete(tokens) {
if (process.env.DEBUG_GRAPHLIT_SDK_STREAMING) {
console.log(`🔚 [UIEventAdapter] Handle complete - Active tool calls: ${this.activeToolCalls.size}`);
this.activeToolCalls.forEach((toolData, id) => {
console.log(`🔚 [UIEventAdapter] Tool ${id}: ${toolData.toolCall.name}, Status: ${toolData.status}, Args length: ${toolData.toolCall.arguments.length}`);
});
}
// Clear any pending updates
if (this.updateTimer) {
globalThis.clearTimeout(this.updateTimer);
this.updateTimer = undefined;
}
// Process any remaining chunks before completing
if (this.chunkQueue.length > 0) {
// Add all remaining chunks to current message
const remainingChunks = this.chunkQueue.join("");
const chunkCount = this.chunkQueue.length;
this.currentMessage += remainingChunks;
this.chunkQueue.length = 0; // Clear the queue after processing
if (process.env.DEBUG_GRAPHLIT_SDK_STREAMING) {
console.log(`🔚 [UIEventAdapter] Processed ${chunkCount} remaining chunks: "${remainingChunks}"`);
}
}
// Flush any remaining content from the buffer
if (this.chunkBuffer) {
const finalChunks = this.chunkBuffer.flush();
if (finalChunks.length > 0) {
const finalContent = finalChunks.join("");
this.currentMessage += finalContent;
if (process.env.DEBUG_GRAPHLIT_SDK_STREAMING) {
console.log(`🔚 [UIEventAdapter] Flushed buffer with ${finalChunks.length} chunks: "${finalContent}"`);
}
}
}
this.isStreaming = false;
// Create final message with metadata
const finalMessage = {
__typename: "ConversationMessage",
role: ConversationRoleTypes.Assistant,
message: this.currentMessage,
timestamp: new Date().toISOString(),
tokens: tokens, // Now we have the actual LLM token count!
toolCalls: Array.from(this.activeToolCalls.values()).map((t) => t.toolCall),
model: this.model,
modelService: this.modelService,
};
// Add final timing metadata
if (this.streamStartTime > 0) {
const totalTime = Date.now() - this.streamStartTime;
// Final throughput (chars/second) - includes entire duration
finalMessage.throughput =
totalTime > 0
? Math.round((this.currentMessage.length / totalTime) * 1000)
: 0;
// Total completion time in seconds
finalMessage.completionTime = totalTime / 1000;
// Add time to first token if we have it (useful metric)
if (this.firstTokenTime > 0) {
const ttft = this.firstTokenTime - this.streamStartTime;
if (process.env.DEBUG_GRAPHLIT_SDK_STREAMING) {
console.log(`⏱️ [UIEventAdapter] TTFT: ${ttft}ms | Total: ${totalTime}ms | Throughput: ${finalMessage.throughput} chars/s`);
}
}
}
// Build final metrics
const completionTime = Date.now();
const finalMetrics = {
totalTime: this.streamStartTime > 0 ? completionTime - this.streamStartTime : 0,
conversationDuration: this.conversationStartTime > 0
? completionTime - this.conversationStartTime
: 0,
};
// Add TTFT if we have it
if (this.firstTokenTime > 0 && this.streamStartTime > 0) {
finalMetrics.ttft = this.firstTokenTime - this.streamStartTime;
}
// Add token counts
if (this.tokenCount > 0) {
finalMetrics.tokenCount = this.tokenCount; // Streaming chunks
}
if (tokens) {
finalMetrics.llmTokens = tokens; // Actual LLM tokens used
}
// Calculate average token delay
if (this.tokenDelays.length > 0) {
const avgDelay = this.tokenDelays.reduce((a, b) => a + b, 0) / this.tokenDelays.length;
finalMetrics.avgTokenDelay = Math.round(avgDelay);
}
// Calculate streaming throughput (excludes TTFT)
if (this.firstTokenTime > 0 && this.streamStartTime > 0) {
const streamingTime = completionTime - this.firstTokenTime;
if (streamingTime > 0) {
finalMetrics.streamingThroughput = Math.round((this.currentMessage.length / streamingTime) * 1000);
}
}
// Include context window usage if available
const event = {
type: "conversation_completed",
message: finalMessage,
metrics: finalMetrics,
};
if (this.contextWindowUsage) {
event.contextWindow = this.contextWindowUsage;
}
this.emitUIEvent(event);
}
handleError(error) {
this.isStreaming = false;
this.emitUIEvent({
type: "error",
error: {
message: error,
recoverable: false,
},
conversationId: this.conversationId,
timestamp: new Date(),
});
}
scheduleMessageUpdate() {
const now = Date.now();
const timeSinceLastUpdate = now - this.lastUpdateTime;
// If enough time has passed, update immediately
if (timeSinceLastUpdate >= this.smoothingDelay) {
this.emitMessageUpdate(true);
return;
}
// Otherwise, schedule an update
if (!this.updateTimer) {
const delay = this.smoothingDelay - timeSinceLastUpdate;
this.updateTimer = globalThis.setTimeout(() => {
this.emitMessageUpdate(true);
}, delay);
}
}
scheduleChunkEmission() {
// If timer is already running, let it handle the queue
if (this.updateTimer) {
return;
}
// If queue is empty, nothing to do
if (this.chunkQueue.length === 0) {
return;
}
const now = Date.now();
const timeSinceLastUpdate = now - this.lastUpdateTime;
// If enough time has passed, emit a chunk immediately
if (timeSinceLastUpdate >= this.smoothingDelay) {
this.emitNextChunk();
return;
}
// Otherwise, schedule the next chunk emission
const delay = this.smoothingDelay - timeSinceLastUpdate;
this.updateTimer = globalThis.setTimeout(() => {
this.emitNextChunk();
}, delay);
}
emitNextChunk() {
if (this.chunkQueue.length === 0) {
this.updateTimer = undefined;
return;
}
// Take one chunk from the queue
const chunk = this.chunkQueue.shift();
this.currentMessage += chunk;
// Emit the update
this.emitMessageUpdate(true);
// Schedule next chunk if queue is not empty
if (this.chunkQueue.length > 0) {
this.updateTimer = globalThis.setTimeout(() => {
this.emitNextChunk();
}, this.smoothingDelay);
}
else {
this.updateTimer = undefined;
}
}
emitMessageUpdate(isStreaming) {
this.lastUpdateTime = Date.now();
if (this.updateTimer) {
globalThis.clearTimeout(this.updateTimer);
this.updateTimer = undefined;
}
const message = {
__typename: "ConversationMessage",
role: ConversationRoleTypes.Assistant,
message: this.currentMessage,
timestamp: new Date().toISOString(),
};
// Add model metadata if available
if (this.model) {
message.model = this.model;
}
if (this.modelService) {
message.modelService = this.modelService;
}
// Add timing metadata if streaming has started
if (this.streamStartTime > 0) {
const now = Date.now();
const elapsedTime = now - this.streamStartTime;
// Calculate throughput (chars/second)
const throughput = elapsedTime > 0
? Math.round((this.currentMessage.length / elapsedTime) * 1000)
: 0;
message.throughput = throughput;
// Add completion time if we have it (in seconds to match API)
if (elapsedTime > 0) {
message.completionTime = elapsedTime / 1000;
}
}
// Build metrics object
const now = Date.now();
const metrics = {
elapsedTime: this.streamStartTime > 0 ? now - this.streamStartTime : 0,
conversationDuration: this.conversationStartTime > 0 ? now - this.conversationStartTime : 0,
};
// Add TTFT if we have it
if (this.firstTokenTime > 0 && this.streamStartTime > 0) {
metrics.ttft = this.firstTokenTime - this.streamStartTime;
}
// Add token count if available
if (this.tokenCount > 0) {
metrics.tokenCount = this.tokenCount;
}
// Calculate average token delay
if (this.tokenDelays.length > 0) {
const avgDelay = this.tokenDelays.reduce((a, b) => a + b, 0) / this.tokenDelays.length;
metrics.avgTokenDelay = Math.round(avgDelay);
}
// Calculate streaming throughput (excludes TTFT)
if (this.firstTokenTime > 0 && this.streamStartTime > 0) {
const streamingTime = now - this.firstTokenTime;
if (streamingTime > 0) {
metrics.streamingThroughput = Math.round((this.currentMessage.length / streamingTime) * 1000);
}
}
this.emitUIEvent({
type: "message_update",
message,
isStreaming,
metrics,
});
}
emitUIEvent(event) {
this.onEvent(event);
}
handleContextWindow(usage) {
// Store for later inclusion in completion event
this.contextWindowUsage = usage;
if (process.env.DEBUG_GRAPHLIT_SDK_STREAMING) {
console.log(`📊 [UIEventAdapter] Context window: ${usage.usedTokens}/${usage.maxTokens} (${usage.percentage}%)`);
}
this.emitUIEvent({
type: "context_window",
usage,
timestamp: new Date(),
});
}
/**
* Clean up any pending timers
*/
dispose() {
if (this.updateTimer) {
globalThis.clearTimeout(this.updateTimer);
this.updateTimer = undefined;
}
this.activeToolCalls.clear();
}
}