From 12261befe5f464ba84391ecc2fcac5173cee78e5 Mon Sep 17 00:00:00 2001 From: zahlekhan Date: Sat, 6 Dec 2025 07:26:17 +0530 Subject: [PATCH 01/12] Implement LangGraph integration for chat storage and response handling - Added LangGraphStorageAdapter to manage threads and messages via LangGraph API. - Updated createChat function to support LangGraph configuration alongside n8n. - Refactored storage adapter creation to auto-select LangGraph when applicable. - Enhanced call handling to differentiate between n8n and LangGraph responses. - Updated types to include LangGraph configuration options. --- index.html | 12 +- src/index.ts | 168 ++++++++++++++++-- src/storage/LangGraphStorageAdapter.ts | 231 +++++++++++++++++++++++++ src/storage/index.ts | 14 +- src/types.ts | 23 ++- 5 files changed, 426 insertions(+), 22 deletions(-) create mode 100644 src/storage/LangGraphStorageAdapter.ts diff --git a/index.html b/index.html index 2c2631d..7c0b008 100644 --- a/index.html +++ b/index.html @@ -12,17 +12,15 @@ /> + + From d5d60ad6541fe116ff1d03882b5762e2e1b7258f Mon Sep 17 00:00:00 2001 From: zahlekhan Date: Fri, 19 Dec 2025 12:55:15 +0530 Subject: [PATCH 03/12] Refactor chat provider architecture and enhance error handling - Introduced ChatProvider interface to standardize communication with various chat backends (LangGraph, webhooks). - Implemented LangGraphProvider and WebhookProvider classes for handling specific backend interactions. - Updated createChat function to initialize the appropriate provider based on configuration. - Enhanced error handling in message processing, including logging and callback notifications for errors. - Added onError callback in ChatConfig for improved error management during message processing. --- src/index.ts | 427 ++++++++--------------------- src/providers/ChatProvider.ts | 20 ++ src/providers/LangGraphProvider.ts | 170 ++++++++++++ src/providers/WebhookProvider.ts | 191 +++++++++++++ src/providers/index.ts | 30 ++ src/types.ts | 7 + src/utils/logger.ts | 14 +- 7 files changed, 536 insertions(+), 323 deletions(-) create mode 100644 src/providers/ChatProvider.ts create mode 100644 src/providers/LangGraphProvider.ts create mode 100644 src/providers/WebhookProvider.ts create mode 100644 src/providers/index.ts diff --git a/src/index.ts b/src/index.ts index d90828b..862f64f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,15 +8,10 @@ import { } from "@thesysai/genui-sdk"; import type { Thread, UserMessage } from "@crayonai/react-core"; import "@crayonai/react-ui/styles/index.css"; -import type { - ChatConfig, - ChatInstance, - LangGraphConfig, - N8NConfig, - WebhookMessage, -} from "./types"; +import type { ChatConfig, ChatInstance } from "./types"; import { createStorageAdapter, LangGraphStorageAdapter } from "./storage"; import type { StorageAdapter } from "./storage"; +import { createChatProvider, type ChatProvider } from "./providers"; import { log, logError } from "./utils/logger"; import "./styles/widget.css"; @@ -32,323 +27,89 @@ function generateThreadTitle(message: string): string { return cleaned.substring(0, maxLength) + "..."; } -async function callLanggraph( - langgraphConfig: LangGraphConfig, - sessionId: string, - prompt: string -): Promise { - console.log("callLanggraph", langgraphConfig, sessionId, prompt); - const response = await fetch( - `${langgraphConfig.deploymentUrl}/threads/${sessionId}/runs/stream`, - { - method: "POST", - body: JSON.stringify({ - assistant_id: langgraphConfig.assistantId, - input: { - messages: [ - { - role: "human", - content: prompt, - }, - ], - }, - stream_mode: ["values", "messages-tuple", "custom"], - stream_subgraphs: true, - stream_resumable: true, - }), - } - ); - - if (!response.body) { - throw new Error("Response body is null"); - } - - const reader = response.body.getReader(); - const decoder = new TextDecoder(); - - const stream = new ReadableStream({ - async start(controller) { - let buffer = ""; - let hasStreamedContent = false; - - // Helper to extract content from various JSON formats - const extractContent = (data: { content?: string }[]): string | null => { - try { - return data[0].content || null; - } catch { - return null; - } - }; - - try { - while (true) { - const { done, value } = await reader.read(); - if (done) break; - - const chunk = decoder.decode(value, { stream: true }); - buffer += chunk; - const lines = buffer.split("\n"); - - buffer = lines.pop() || ""; - - for (const line of lines) { - if (line.trim()) { - try { - console.log("line", line); - const data = JSON.parse(line.slice(6)); - console.log("datap", data); - const content = extractContent(data); - if (content) { - controller.enqueue(new TextEncoder().encode(content)); - hasStreamedContent = true; - } - } catch (e) { - logError("Failed to parse streaming line:", line, e); - } - } - } - } - - // Process remaining buffer - if (buffer.trim()) { - try { - const data = JSON.parse(buffer); - const content = extractContent(data); - if (content) { - controller.enqueue(new TextEncoder().encode(content)); - hasStreamedContent = true; - } - } catch (e) { - // Buffer might not be valid JSON - could be plain text - if (!hasStreamedContent) { - // If we haven't streamed anything, send buffer as-is - controller.enqueue(new TextEncoder().encode(buffer)); - } else { - logError("Failed to parse final streaming data:", buffer, e); - } - } - } - } catch (error) { - logError("Streaming error:", error); - controller.error(error); - } finally { - controller.close(); - reader.releaseLock(); - } - }, - }); - - return new Response(stream, { - headers: { - "Content-Type": "text/plain", - }, - }); -} - -/** - * Helper function to call webhook endpoint - * Supports n8n, Make.com, Zapier, and custom webhook providers - */ -async function callWebhook( - n8nConfig: N8NConfig, - sessionId: string, - prompt: string -): Promise { - const message: WebhookMessage = { - chatInput: prompt, - sessionId: sessionId, - }; - - const webhookMethod = n8nConfig.webhookConfig?.method || "POST"; - const customHeaders = n8nConfig.webhookConfig?.headers || {}; - - const headers = { - "Content-Type": "application/json", - ...customHeaders, - }; - - const response = await fetch(n8nConfig.webhookUrl, { - method: webhookMethod, - headers: headers, - body: JSON.stringify(message), - }); - - if (!response.ok) { - throw new Error(`Webhook error: ${response.status} ${response.statusText}`); - } - - // Check Content-Type to help determine how to handle the response - const contentType = response.headers.get("Content-Type") || ""; - const isStreamContentType = - contentType.includes("text/event-stream") || - contentType.includes("application/x-ndjson"); - - // Determine streaming behavior: - // - If user explicitly enabled streaming, trust that config (backend may send wrong Content-Type) - // - If Content-Type indicates streaming, handle as stream - const shouldStream = n8nConfig.enableStreaming || isStreamContentType; - - // If NOT streaming, parse as JSON - if (!shouldStream) { - const clonedResponse = response.clone(); - try { - const data = await clonedResponse.json(); - // Successfully parsed JSON - return it - return new Response(data.output || data.message || JSON.stringify(data)); - } catch (error) { - // JSON parsing failed - try to handle as stream as fallback - if (!response.body) { - throw new Error( - `Failed to parse response as JSON and no body available: ${error}` - ); - } - } - } - - // For streaming, transform line-delimited JSON format to plain text stream - if (!response.body) { - throw new Error("Response body is null"); - } - - const reader = response.body.getReader(); - const decoder = new TextDecoder(); - - const stream = new ReadableStream({ - async start(controller) { - let buffer = ""; - let hasStreamedContent = false; - - // Helper to extract content from various JSON formats - const extractContent = (data: Record): string | null => { - // NDJSON streaming format: {"type":"item","content":"..."} - if (data.type === "item" && typeof data.content === "string") { - return data.content; - } - // Regular JSON response format: {"output":"..."} or {"message":"..."} - if (typeof data.output === "string") return data.output; - if (typeof data.message === "string") return data.message; - return null; - }; - - try { - while (true) { - const { done, value } = await reader.read(); - if (done) break; - - const chunk = decoder.decode(value, { stream: true }); - buffer += chunk; - const lines = buffer.split("\n"); - - buffer = lines.pop() || ""; - - for (const line of lines) { - if (line.trim()) { - try { - const data = JSON.parse(line); - const content = extractContent(data); - if (content) { - controller.enqueue(new TextEncoder().encode(content)); - hasStreamedContent = true; - } - } catch (e) { - logError("Failed to parse streaming line:", line, e); - } - } - } - } - - // Process remaining buffer - if (buffer.trim()) { - try { - const data = JSON.parse(buffer); - const content = extractContent(data); - if (content) { - controller.enqueue(new TextEncoder().encode(content)); - hasStreamedContent = true; - } - } catch (e) { - // Buffer might not be valid JSON - could be plain text - if (!hasStreamedContent) { - // If we haven't streamed anything, send buffer as-is - controller.enqueue(new TextEncoder().encode(buffer)); - } else { - logError("Failed to parse final streaming data:", buffer, e); - } - } - } - } catch (error) { - logError("Streaming error:", error); - controller.error(error); - } finally { - controller.close(); - reader.releaseLock(); - } - }, - }); - - return new Response(stream, { - headers: { - "Content-Type": "text/plain", - }, - }); -} - /** * React component wrapper that manages thread persistence */ function ChatWithPersistence({ config, storage, + provider, onSessionIdChange, }: { config: ChatConfig; storage: StorageAdapter; + provider: ChatProvider; onSessionIdChange: (sessionId: string | null) => void; }) { const formFactor = config.mode === "sidepanel" ? "side-panel" : "full-page"; + // Helper to handle storage errors + const handleStorageError = (error: unknown, operation: string): Error => { + const err = error instanceof Error ? error : new Error(String(error)); + logError(`[Storage] ${operation} failed:`, err.message); + config.onError?.(err); + return err; + }; + // Initialize thread list manager const threadListManager = useThreadListManager({ fetchThreadList: async () => { - return await storage.getThreadList(); + try { + return await storage.getThreadList(); + } catch (error) { + handleStorageError(error, "fetchThreadList"); + return []; // Return empty list on error so UI still works + } }, createThread: async (firstMessage: UserMessage) => { const title = generateThreadTitle(firstMessage.message || "New Chat"); - // Use LangGraph API to create thread if using LangGraph storage - if (storage instanceof LangGraphStorageAdapter) { - const thread = await storage.createThread(title); - // Note: First message will be sent via processMessage, not saved here + try { + // Use LangGraph API to create thread if using LangGraph storage + if (storage instanceof LangGraphStorageAdapter) { + const thread = await storage.createThread(title); + // Note: First message will be sent via processMessage, not saved here + return thread; + } + + // Default: create thread locally + const threadId = crypto.randomUUID(); + const thread: Thread = { + threadId, + title, + createdAt: new Date(), + isRunning: false, + }; + + await storage.updateThread(thread); + + // Convert UserMessage to Message format (react-core -> genui-sdk) + const message: Message = { + id: crypto.randomUUID(), + role: "user", + content: firstMessage.message || "", + }; + await storage.saveThread(threadId, [message]); + return thread; + } catch (error) { + throw handleStorageError(error, "createThread"); } - - // Default: create thread locally - const threadId = crypto.randomUUID(); - const thread: Thread = { - threadId, - title, - createdAt: new Date(), - isRunning: false, - }; - - await storage.updateThread(thread); - - // Convert UserMessage to Message format (react-core -> genui-sdk) - const message: Message = { - id: crypto.randomUUID(), - role: "user", - content: firstMessage.message || "", - }; - await storage.saveThread(threadId, [message]); - - return thread; }, deleteThread: async (threadId: string) => { - await storage.deleteThread(threadId); + try { + await storage.deleteThread(threadId); + } catch (error) { + throw handleStorageError(error, "deleteThread"); + } }, updateThread: async (thread: Thread) => { - await storage.updateThread(thread); - return thread; + try { + await storage.updateThread(thread); + return thread; + } catch (error) { + throw handleStorageError(error, "updateThread"); + } }, onSwitchToNew: () => { // Called when user switches to new thread @@ -363,10 +124,15 @@ function ChatWithPersistence({ const threadManager = useThreadManager({ threadListManager, loadThread: async (threadId: string) => { - log("[Storage] loadThread:", threadId); - const messages = await storage.getThread(threadId); - log("[Storage] Loaded", messages?.length || 0, "messages"); - return messages || []; + try { + log("[Storage] loadThread:", threadId); + const messages = await storage.getThread(threadId); + log("[Storage] Loaded", messages?.length || 0, "messages"); + return messages || []; + } catch (error) { + handleStorageError(error, "loadThread"); + return []; // Return empty array so UI still works + } }, processMessage: async ({ threadId, @@ -391,23 +157,30 @@ function ChatWithPersistence({ // Save user messages (skip for LangGraph - messages are persisted via runs) if (!isLangGraph) { - await storage.saveThread(threadId, messages); - log("[Storage] Saved user messages"); + try { + await storage.saveThread(threadId, messages); + log("[Storage] Saved user messages"); + } catch (error) { + // Log but don't fail - message can still be sent even if save fails + handleStorageError(error, "saveThread (user messages)"); + } } // Get prompt const lastMessage = messages[messages.length - 1]; const prompt = lastMessage?.content || ""; - // Call webhook or LangGraph + // Send message via provider let response: Response; - if (config.n8n?.webhookUrl) { - response = await callWebhook(config.n8n, threadId, prompt); - } else if (config.langgraph?.deploymentUrl) { - response = await callLanggraph(config.langgraph, threadId, prompt); - } else { - console.log("No webhook or langgraph configuration provided"); - throw new Error("No webhook or langgraph configuration provided"); + try { + response = await provider.sendMessage(threadId, prompt); + } catch (error) { + // Notify consumer via callback + const err = error instanceof Error ? error : new Error(String(error)); + logError("[processMessage] Error:", err.message); + config.onError?.(err); + // Re-throw so the SDK can display error state in UI + throw err; } // For LangGraph, messages are automatically persisted by the run @@ -440,14 +213,19 @@ function ChatWithPersistence({ role: "assistant", content: fullContent, }; - await storage.saveThread(threadId, [ - ...messages, - assistantMessage, - ]); - log( - "[Storage] Saved assistant message, total:", - messages.length + 1 - ); + try { + await storage.saveThread(threadId, [ + ...messages, + assistantMessage, + ]); + log( + "[Storage] Saved assistant message, total:", + messages.length + 1 + ); + } catch (error) { + // Log but don't fail - response was already streamed successfully + handleStorageError(error, "saveThread (assistant message)"); + } controller.close(); } catch (error) { @@ -499,7 +277,11 @@ export function createChat(config: ChatConfig): ChatInstance { throw new Error("n8n or langgraph configuration is required"); } - console.log("config", config); + log("[createChat] Initializing with config:", { + hasLanggraph: !!config.langgraph, + hasN8n: !!config.n8n, + storageType: config.storageType, + }); // Set debug logging flag at window level if (!window.__THESYS_CHAT__) { @@ -508,6 +290,10 @@ export function createChat(config: ChatConfig): ChatInstance { window.__THESYS_CHAT__.enableDebugLogging = config.enableDebugLogging || false; + // Create chat provider + const provider = createChatProvider(config); + log("[createChat] Created provider:", provider.name); + // Create storage adapter // Auto-select "langgraph" storage when langgraph config is present (unless explicitly set) const storageType = @@ -530,6 +316,7 @@ export function createChat(config: ChatConfig): ChatInstance { createElement(ChatWithPersistence, { config, storage, + provider, onSessionIdChange: (sessionId: string | null) => { currentSessionId = sessionId; }, diff --git a/src/providers/ChatProvider.ts b/src/providers/ChatProvider.ts new file mode 100644 index 0000000..d80f88c --- /dev/null +++ b/src/providers/ChatProvider.ts @@ -0,0 +1,20 @@ +/** + * Interface for chat backend providers + * Implementations handle communication with different chat backends + * (LangGraph, n8n webhooks, Make.com, Zapier, etc.) + */ +export interface ChatProvider { + /** + * Unique identifier for this provider type + */ + readonly name: string; + + /** + * Send a message to the chat backend and get a streaming response + * + * @param sessionId - The thread/session ID for the conversation + * @param prompt - The user's message + * @returns A Response object with a readable stream body + */ + sendMessage(sessionId: string, prompt: string): Promise; +} diff --git a/src/providers/LangGraphProvider.ts b/src/providers/LangGraphProvider.ts new file mode 100644 index 0000000..9be801b --- /dev/null +++ b/src/providers/LangGraphProvider.ts @@ -0,0 +1,170 @@ +import type { LangGraphConfig } from "../types"; +import type { ChatProvider } from "./ChatProvider"; +import { log, logError } from "../utils/logger"; + +/** + * Chat provider for LangGraph deployments + * Handles streaming communication with LangGraph cloud or self-hosted deployments + */ +export class LangGraphProvider implements ChatProvider { + readonly name = "langgraph"; + + constructor(private readonly config: LangGraphConfig) {} + + async sendMessage(sessionId: string, prompt: string): Promise { + const url = `${this.config.deploymentUrl}/threads/${sessionId}/runs/stream`; + log("[LangGraph] Sending request:", { + url, + assistantId: this.config.assistantId, + sessionId, + }); + + let response: Response; + try { + response = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + assistant_id: this.config.assistantId, + input: { + messages: [ + { + role: "human", + content: prompt, + }, + ], + }, + stream_mode: ["values", "messages-tuple", "custom"], + stream_subgraphs: true, + stream_resumable: true, + }), + }); + } catch (error) { + // Network error (no internet, DNS failure, CORS, etc.) + const message = error instanceof Error ? error.message : "Network error"; + logError("[LangGraph] Network error:", message); + throw new Error(`Failed to connect to LangGraph: ${message}`); + } + + log("[LangGraph] Response status:", response.status, response.statusText); + + if (!response.ok) { + // Try to get error details from response body + let errorMessage = `LangGraph error: ${response.status} ${response.statusText}`; + try { + const errorBody = await response.json(); + if (errorBody.detail) { + errorMessage = `LangGraph error: ${errorBody.detail}`; + } else if (errorBody.message) { + errorMessage = `LangGraph error: ${errorBody.message}`; + } + } catch { + // Ignore JSON parsing errors, use default message + } + logError("[LangGraph] API error:", errorMessage); + throw new Error(errorMessage); + } + + if (!response.body) { + throw new Error("Response body is null"); + } + + return this.transformStream(response); + } + + /** + * Transform LangGraph streaming format to plain text stream + */ + private transformStream(response: Response): Response { + const reader = response.body!.getReader(); + const decoder = new TextDecoder(); + + const stream = new ReadableStream({ + async start(controller) { + let buffer = ""; + let hasStreamedContent = false; + + // Helper to extract content from LangGraph JSON format + const extractContent = ( + data: { content?: string }[] + ): string | null => { + try { + return data[0].content || null; + } catch { + return null; + } + }; + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + const chunk = decoder.decode(value, { stream: true }); + buffer += chunk; + const lines = buffer.split("\n"); + + buffer = lines.pop() || ""; + + for (const line of lines) { + if (line.trim()) { + try { + // LangGraph format: "data: {...}" + const data = JSON.parse(line.slice(6)); + const content = extractContent(data); + if (content) { + controller.enqueue(new TextEncoder().encode(content)); + hasStreamedContent = true; + } + } catch (e) { + logError( + "[LangGraph] Failed to parse streaming line:", + line, + e + ); + } + } + } + } + + // Process remaining buffer + if (buffer.trim()) { + try { + const data = JSON.parse(buffer); + const content = extractContent(data); + if (content) { + controller.enqueue(new TextEncoder().encode(content)); + hasStreamedContent = true; + } + } catch (e) { + // Buffer might not be valid JSON - could be plain text + if (!hasStreamedContent) { + controller.enqueue(new TextEncoder().encode(buffer)); + } else { + logError( + "[LangGraph] Failed to parse final streaming data:", + buffer, + e + ); + } + } + } + } catch (error) { + logError("[LangGraph] Streaming error:", error); + controller.error(error); + } finally { + controller.close(); + reader.releaseLock(); + } + }, + }); + + return new Response(stream, { + headers: { + "Content-Type": "text/plain", + }, + }); + } +} diff --git a/src/providers/WebhookProvider.ts b/src/providers/WebhookProvider.ts new file mode 100644 index 0000000..60314e2 --- /dev/null +++ b/src/providers/WebhookProvider.ts @@ -0,0 +1,191 @@ +import type { N8NConfig, WebhookMessage } from "../types"; +import type { ChatProvider } from "./ChatProvider"; +import { log, logError } from "../utils/logger"; + +/** + * Chat provider for webhook-based backends + * Supports n8n, Make.com, Zapier, and custom webhook endpoints + */ +export class WebhookProvider implements ChatProvider { + readonly name = "webhook"; + + constructor(private readonly config: N8NConfig) {} + + async sendMessage(sessionId: string, prompt: string): Promise { + const message: WebhookMessage = { + chatInput: prompt, + sessionId: sessionId, + }; + + const webhookMethod = this.config.webhookConfig?.method || "POST"; + const customHeaders = this.config.webhookConfig?.headers || {}; + + const headers = { + "Content-Type": "application/json", + ...customHeaders, + }; + + log("[Webhook] Sending request:", { + url: this.config.webhookUrl, + method: webhookMethod, + sessionId, + }); + + let response: Response; + try { + response = await fetch(this.config.webhookUrl, { + method: webhookMethod, + headers: headers, + body: JSON.stringify(message), + }); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : "Network error"; + logError("[Webhook] Network error:", errorMessage); + throw new Error(`Failed to connect to webhook: ${errorMessage}`); + } + + log("[Webhook] Response status:", response.status, response.statusText); + + if (!response.ok) { + const errorMessage = `Webhook error: ${response.status} ${response.statusText}`; + logError("[Webhook] API error:", errorMessage); + throw new Error(errorMessage); + } + + // Check Content-Type to help determine how to handle the response + const contentType = response.headers.get("Content-Type") || ""; + const isStreamContentType = + contentType.includes("text/event-stream") || + contentType.includes("application/x-ndjson"); + + // Determine streaming behavior: + // - If user explicitly enabled streaming, trust that config (backend may send wrong Content-Type) + // - If Content-Type indicates streaming, handle as stream + const shouldStream = this.config.enableStreaming || isStreamContentType; + + // If NOT streaming, parse as JSON + if (!shouldStream) { + const clonedResponse = response.clone(); + try { + const data = await clonedResponse.json(); + log("[Webhook] Parsed JSON response"); + // Successfully parsed JSON - return it + return new Response( + data.output || data.message || JSON.stringify(data) + ); + } catch (error) { + // JSON parsing failed - try to handle as stream as fallback + if (!response.body) { + throw new Error( + `Failed to parse response as JSON and no body available: ${error}` + ); + } + log("[Webhook] JSON parse failed, falling back to stream"); + } + } + + // For streaming, transform line-delimited JSON format to plain text stream + if (!response.body) { + throw new Error("Response body is null"); + } + + return this.transformStream(response); + } + + /** + * Transform webhook streaming format (NDJSON) to plain text stream + */ + private transformStream(response: Response): Response { + const reader = response.body!.getReader(); + const decoder = new TextDecoder(); + + const stream = new ReadableStream({ + async start(controller) { + let buffer = ""; + let hasStreamedContent = false; + + // Helper to extract content from various JSON formats + const extractContent = ( + data: Record + ): string | null => { + // NDJSON streaming format: {"type":"item","content":"..."} + if (data.type === "item" && typeof data.content === "string") { + return data.content; + } + // Regular JSON response format: {"output":"..."} or {"message":"..."} + if (typeof data.output === "string") return data.output; + if (typeof data.message === "string") return data.message; + return null; + }; + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + const chunk = decoder.decode(value, { stream: true }); + buffer += chunk; + const lines = buffer.split("\n"); + + buffer = lines.pop() || ""; + + for (const line of lines) { + if (line.trim()) { + try { + const data = JSON.parse(line); + const content = extractContent(data); + if (content) { + controller.enqueue(new TextEncoder().encode(content)); + hasStreamedContent = true; + } + } catch (e) { + logError( + "[Webhook] Failed to parse streaming line:", + line, + e + ); + } + } + } + } + + // Process remaining buffer + if (buffer.trim()) { + try { + const data = JSON.parse(buffer); + const content = extractContent(data); + if (content) { + controller.enqueue(new TextEncoder().encode(content)); + hasStreamedContent = true; + } + } catch (e) { + // Buffer might not be valid JSON - could be plain text + if (!hasStreamedContent) { + controller.enqueue(new TextEncoder().encode(buffer)); + } else { + logError( + "[Webhook] Failed to parse final streaming data:", + buffer, + e + ); + } + } + } + } catch (error) { + logError("[Webhook] Streaming error:", error); + controller.error(error); + } finally { + controller.close(); + reader.releaseLock(); + } + }, + }); + + return new Response(stream, { + headers: { + "Content-Type": "text/plain", + }, + }); + } +} diff --git a/src/providers/index.ts b/src/providers/index.ts new file mode 100644 index 0000000..e796ef5 --- /dev/null +++ b/src/providers/index.ts @@ -0,0 +1,30 @@ +import type { ChatConfig } from "../types"; +import type { ChatProvider } from "./ChatProvider"; +import { LangGraphProvider } from "./LangGraphProvider"; +import { WebhookProvider } from "./WebhookProvider"; + +export type { ChatProvider } from "./ChatProvider"; +export { LangGraphProvider } from "./LangGraphProvider"; +export { WebhookProvider } from "./WebhookProvider"; + +/** + * Create a chat provider based on the configuration + * Automatically selects LangGraph or Webhook provider based on config + * + * @param config - The chat configuration + * @returns A ChatProvider instance + * @throws Error if no valid provider configuration is found + */ +export function createChatProvider(config: ChatConfig): ChatProvider { + if (config.langgraph?.deploymentUrl) { + return new LangGraphProvider(config.langgraph); + } + + if (config.n8n?.webhookUrl) { + return new WebhookProvider(config.n8n); + } + + throw new Error( + "No valid provider configuration found. Provide either langgraph or n8n config." + ); +} diff --git a/src/types.ts b/src/types.ts index 0eaac73..0a6a983 100644 --- a/src/types.ts +++ b/src/types.ts @@ -63,6 +63,13 @@ export interface ChatConfig { */ onSessionStart?: (sessionId: string) => void; + /** + * Callback fired when an error occurs during message processing + * Useful for logging, analytics, or custom error UI + * Note: The SDK will still display error states in the chat UI + */ + onError?: (error: Error) => void; + /** * Theme configuration */ diff --git a/src/utils/logger.ts b/src/utils/logger.ts index b307e82..5342ace 100644 --- a/src/utils/logger.ts +++ b/src/utils/logger.ts @@ -21,15 +21,23 @@ function isDebugEnabled(): boolean { */ export function log(...args: unknown[]): void { if (isDebugEnabled()) { - console.log(...args); + console.log("[ThesysChat]", ...args); } } /** - * Log an error to console if debug logging is enabled + * Log an error to console (always shown, not gated by debug flag) + * Errors are important enough to always be visible */ export function logError(...args: unknown[]): void { + console.error("[ThesysChat]", ...args); +} + +/** + * Log a warning to console if debug logging is enabled + */ +export function logWarn(...args: unknown[]): void { if (isDebugEnabled()) { - console.error(...args); + console.warn("[ThesysChat]", ...args); } } From 207c3ae02d804f33912c705b7b4e794cf8fdf3c1 Mon Sep 17 00:00:00 2001 From: zahlekhan Date: Fri, 19 Dec 2025 13:56:46 +0530 Subject: [PATCH 04/12] self reivew --- src/index.ts | 70 ++++++----- src/providers/N8NProvider.ts | 191 +++++++++++++++++++++++++++++++ src/providers/WebhookProvider.ts | 8 +- src/providers/index.ts | 6 +- 4 files changed, 239 insertions(+), 36 deletions(-) create mode 100644 src/providers/N8NProvider.ts diff --git a/src/index.ts b/src/index.ts index 862f64f..e2ec0ff 100644 --- a/src/index.ts +++ b/src/index.ts @@ -272,41 +272,53 @@ function ChatWithPersistence({ * ``` */ export function createChat(config: ChatConfig): ChatInstance { - // Validate required config - if (!config.n8n && !config.langgraph) { - throw new Error("n8n or langgraph configuration is required"); - } - - log("[createChat] Initializing with config:", { - hasLanggraph: !!config.langgraph, - hasN8n: !!config.n8n, - storageType: config.storageType, - }); - - // Set debug logging flag at window level + // Set debug logging flag at window level (do this early so logging works) if (!window.__THESYS_CHAT__) { window.__THESYS_CHAT__ = {}; } window.__THESYS_CHAT__.enableDebugLogging = config.enableDebugLogging || false; - // Create chat provider - const provider = createChatProvider(config); - log("[createChat] Created provider:", provider.name); - - // Create storage adapter - // Auto-select "langgraph" storage when langgraph config is present (unless explicitly set) - const storageType = - config.storageType || (config.langgraph ? "langgraph" : "none"); - const storage = createStorageAdapter(storageType, config.langgraph); - - // Create container element - const container = document.createElement("div"); - container.id = "thesys-chat-root"; - document.body.appendChild(container); - - // Create React root - const root = createRoot(container); + let provider: ChatProvider; + let storage: StorageAdapter; + let container: HTMLDivElement; + let root: ReturnType; + + try { + // Validate required config + if (!config.n8n && !config.langgraph) { + throw new Error("n8n or langgraph configuration is required"); + } + + log("[createChat] Initializing with config:", { + hasLanggraph: !!config.langgraph, + hasN8n: !!config.n8n, + storageType: config.storageType, + }); + + // Create chat provider + provider = createChatProvider(config); + log("[createChat] Created provider:", provider.name); + + // Create storage adapter + // Auto-select "langgraph" storage when langgraph config is present (unless explicitly set) + const storageType = + config.storageType || (config.langgraph ? "langgraph" : "none"); + storage = createStorageAdapter(storageType, config.langgraph); + + // Create container element + container = document.createElement("div"); + container.id = "thesys-chat-root"; + document.body.appendChild(container); + + // Create React root + root = createRoot(container); + } catch (error) { + const err = error instanceof Error ? error : new Error(String(error)); + logError("[createChat] Initialization failed:", err.message); + config.onError?.(err); + throw err; + } // Track current session ID let currentSessionId: string | null = null; diff --git a/src/providers/N8NProvider.ts b/src/providers/N8NProvider.ts new file mode 100644 index 0000000..f92b24e --- /dev/null +++ b/src/providers/N8NProvider.ts @@ -0,0 +1,191 @@ +import type { N8NConfig, WebhookMessage } from "../types"; +import type { ChatProvider } from "./ChatProvider"; +import { log, logError } from "../utils/logger"; + +/** + * Chat provider for n8n webhook-based backends + * Supports n8n workflows with webhook triggers + */ +export class N8NProvider implements ChatProvider { + readonly name = "n8n"; + + constructor(private readonly config: N8NConfig) {} + + async sendMessage(sessionId: string, prompt: string): Promise { + const message: WebhookMessage = { + chatInput: prompt, + sessionId: sessionId, + }; + + const webhookMethod = this.config.webhookConfig?.method || "POST"; + const customHeaders = this.config.webhookConfig?.headers || {}; + + const headers = { + "Content-Type": "application/json", + ...customHeaders, + }; + + log("[Webhook] Sending request:", { + url: this.config.webhookUrl, + method: webhookMethod, + sessionId, + }); + + let response: Response; + try { + response = await fetch(this.config.webhookUrl, { + method: webhookMethod, + headers: headers, + body: JSON.stringify(message), + }); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : "Network error"; + logError("[Webhook] Network error:", errorMessage); + throw new Error(`Failed to connect to webhook: ${errorMessage}`); + } + + log("[Webhook] Response status:", response.status, response.statusText); + + if (!response.ok) { + const errorMessage = `Webhook error: ${response.status} ${response.statusText}`; + logError("[Webhook] API error:", errorMessage); + throw new Error(errorMessage); + } + + // Check Content-Type to help determine how to handle the response + const contentType = response.headers.get("Content-Type") || ""; + const isStreamContentType = + contentType.includes("text/event-stream") || + contentType.includes("application/x-ndjson"); + + // Determine streaming behavior: + // - If user explicitly enabled streaming, trust that config (backend may send wrong Content-Type) + // - If Content-Type indicates streaming, handle as stream + const shouldStream = this.config.enableStreaming || isStreamContentType; + + // If NOT streaming, parse as JSON + if (!shouldStream) { + const clonedResponse = response.clone(); + try { + const data = await clonedResponse.json(); + log("[Webhook] Parsed JSON response"); + // Successfully parsed JSON - return it + return new Response( + data.output || data.message || JSON.stringify(data) + ); + } catch (error) { + // JSON parsing failed - try to handle as stream as fallback + if (!response.body) { + throw new Error( + `Failed to parse response as JSON and no body available: ${error}` + ); + } + log("[Webhook] JSON parse failed, falling back to stream"); + } + } + + // For streaming, transform line-delimited JSON format to plain text stream + if (!response.body) { + throw new Error("Response body is null"); + } + + return this.transformStream(response); + } + + /** + * Transform webhook streaming format (NDJSON) to plain text stream + */ + private transformStream(response: Response): Response { + const reader = response.body!.getReader(); + const decoder = new TextDecoder(); + + const stream = new ReadableStream({ + async start(controller) { + let buffer = ""; + let hasStreamedContent = false; + + // Helper to extract content from various JSON formats + const extractContent = ( + data: Record + ): string | null => { + // NDJSON streaming format: {"type":"item","content":"..."} + if (data.type === "item" && typeof data.content === "string") { + return data.content; + } + // Regular JSON response format: {"output":"..."} or {"message":"..."} + if (typeof data.output === "string") return data.output; + if (typeof data.message === "string") return data.message; + return null; + }; + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + const chunk = decoder.decode(value, { stream: true }); + buffer += chunk; + const lines = buffer.split("\n"); + + buffer = lines.pop() || ""; + + for (const line of lines) { + if (line.trim()) { + try { + const data = JSON.parse(line); + const content = extractContent(data); + if (content) { + controller.enqueue(new TextEncoder().encode(content)); + hasStreamedContent = true; + } + } catch (e) { + logError( + "[Webhook] Failed to parse streaming line:", + line, + e + ); + } + } + } + } + + // Process remaining buffer + if (buffer.trim()) { + try { + const data = JSON.parse(buffer); + const content = extractContent(data); + if (content) { + controller.enqueue(new TextEncoder().encode(content)); + hasStreamedContent = true; + } + } catch (e) { + // Buffer might not be valid JSON - could be plain text + if (!hasStreamedContent) { + controller.enqueue(new TextEncoder().encode(buffer)); + } else { + logError( + "[Webhook] Failed to parse final streaming data:", + buffer, + e + ); + } + } + } + } catch (error) { + logError("[Webhook] Streaming error:", error); + controller.error(error); + } finally { + controller.close(); + reader.releaseLock(); + } + }, + }); + + return new Response(stream, { + headers: { + "Content-Type": "text/plain", + }, + }); + } +} diff --git a/src/providers/WebhookProvider.ts b/src/providers/WebhookProvider.ts index 60314e2..f92b24e 100644 --- a/src/providers/WebhookProvider.ts +++ b/src/providers/WebhookProvider.ts @@ -3,11 +3,11 @@ import type { ChatProvider } from "./ChatProvider"; import { log, logError } from "../utils/logger"; /** - * Chat provider for webhook-based backends - * Supports n8n, Make.com, Zapier, and custom webhook endpoints + * Chat provider for n8n webhook-based backends + * Supports n8n workflows with webhook triggers */ -export class WebhookProvider implements ChatProvider { - readonly name = "webhook"; +export class N8NProvider implements ChatProvider { + readonly name = "n8n"; constructor(private readonly config: N8NConfig) {} diff --git a/src/providers/index.ts b/src/providers/index.ts index e796ef5..d432027 100644 --- a/src/providers/index.ts +++ b/src/providers/index.ts @@ -1,11 +1,11 @@ import type { ChatConfig } from "../types"; import type { ChatProvider } from "./ChatProvider"; import { LangGraphProvider } from "./LangGraphProvider"; -import { WebhookProvider } from "./WebhookProvider"; +import { N8NProvider } from "./N8NProvider"; export type { ChatProvider } from "./ChatProvider"; export { LangGraphProvider } from "./LangGraphProvider"; -export { WebhookProvider } from "./WebhookProvider"; +export { N8NProvider } from "./N8NProvider"; /** * Create a chat provider based on the configuration @@ -21,7 +21,7 @@ export function createChatProvider(config: ChatConfig): ChatProvider { } if (config.n8n?.webhookUrl) { - return new WebhookProvider(config.n8n); + return new N8NProvider(config.n8n); } throw new Error( From 00694458d5116a4ceece7043a5d04365ebe95d83 Mon Sep 17 00:00:00 2001 From: zahlekhan Date: Fri, 19 Dec 2025 14:17:19 +0530 Subject: [PATCH 05/12] Refactor chat configuration and enhance error handling - Updated index.html to replace langgraph configuration with n8n settings, including webhook URL and streaming options. - Removed index2.html as it was no longer needed. - Enhanced error handling in src/index.ts by introducing handleError and normalizeError functions for better error management and logging. - Updated logger utility to support new error handling functions, improving overall error reporting. --- index.html | 8 +- index2.html | 1054 ------------------------------------------- src/index.ts | 62 +-- src/utils/logger.ts | 34 ++ 4 files changed, 73 insertions(+), 1085 deletions(-) delete mode 100644 index2.html diff --git a/index.html b/index.html index a1cb218..18e0d67 100644 --- a/index.html +++ b/index.html @@ -15,11 +15,11 @@ import { createChat } from "./src/index.ts"; const chat = createChat({ - langgraph: { - assistantId: "agent", - deploymentUrl: "http://localhost:2024", + n8n: { + webhookUrl: "", + enableStreaming: true, }, - n8n: {}, + langgraph: {}, agentName: "Test Bot", theme: { mode: "light" }, mode: "fullscreen", // Options: "fullscreen" (default) | "sidepanel" diff --git a/index2.html b/index2.html deleted file mode 100644 index 771f5a5..0000000 --- a/index2.html +++ /dev/null @@ -1,1054 +0,0 @@ - - - - - - Thesys Chat — Intelligent Conversations - - - - - - -
-
- - - - - -
- Now with LangGraph support -

Conversations that understand

-

- Build intelligent chat experiences powered by your n8n workflows or - LangGraph agents. Deploy in minutes, not months. -

- -
- - - -
-
- - -
- 👋 Try our chat assistant! -
- - -
- -

Everything you need to build conversational AI

-
-
-
- - - -
-

Multi-backend support

-

- Connect to n8n webhooks, LangGraph deployments, or any custom - backend with a unified interface. -

-
-
-
- - - - -
-

Beautiful by default

-

- Thoughtfully designed UI components that look great out of the box. - Customize themes to match your brand. -

-
-
-
- - - -
-

Stream responses

-

- Real-time streaming support for both n8n and LangGraph backends. - Keep users engaged with instant feedback. -

-
-
-
- - - - -
-

Persistent threads

-

- Automatic conversation history with local storage or LangGraph - persistence. Pick up where you left off. -

-
-
-
- - - - -
-

Webhook flexibility

-

- Works with n8n, Make.com, Zapier, or any HTTP endpoint. Send custom - headers and configure methods. -

-
-
-
- - - -
-

Easy integration

-

- Drop-in widget with zero dependencies. Embed via CDN or install from - npm. Configure with a single function call. -

-
-
-
- - -
-
-
-

100ms

-

Average first token latency

-
-
-

99.9%

-

Uptime SLA

-
-
-

50+

-

Integrations supported

-
-
-

-

Conversations scaled

-
-
-
- - -
- -

Ready to build something great?

-

- Click the chat button in the corner to try our live demo, or check out - our documentation to integrate Thesys Chat into your project. -

- - View documentation - - - - -
- - - - - -
-

© 2025 Thesys. Built for developers who ship.

-
- - - - - - diff --git a/src/index.ts b/src/index.ts index e2ec0ff..3f18a64 100644 --- a/src/index.ts +++ b/src/index.ts @@ -12,7 +12,7 @@ import type { ChatConfig, ChatInstance } from "./types"; import { createStorageAdapter, LangGraphStorageAdapter } from "./storage"; import type { StorageAdapter } from "./storage"; import { createChatProvider, type ChatProvider } from "./providers"; -import { log, logError } from "./utils/logger"; +import { log, handleError, normalizeError } from "./utils/logger"; import "./styles/widget.css"; /** @@ -43,21 +43,13 @@ function ChatWithPersistence({ }) { const formFactor = config.mode === "sidepanel" ? "side-panel" : "full-page"; - // Helper to handle storage errors - const handleStorageError = (error: unknown, operation: string): Error => { - const err = error instanceof Error ? error : new Error(String(error)); - logError(`[Storage] ${operation} failed:`, err.message); - config.onError?.(err); - return err; - }; - // Initialize thread list manager const threadListManager = useThreadListManager({ fetchThreadList: async () => { try { return await storage.getThreadList(); } catch (error) { - handleStorageError(error, "fetchThreadList"); + handleError(error, "[Storage] fetchThreadList failed", config.onError); return []; // Return empty list on error so UI still works } }, @@ -93,14 +85,22 @@ function ChatWithPersistence({ return thread; } catch (error) { - throw handleStorageError(error, "createThread"); + throw handleError( + error, + "[Storage] createThread failed", + config.onError + ); } }, deleteThread: async (threadId: string) => { try { await storage.deleteThread(threadId); } catch (error) { - throw handleStorageError(error, "deleteThread"); + throw handleError( + error, + "[Storage] deleteThread failed", + config.onError + ); } }, updateThread: async (thread: Thread) => { @@ -108,7 +108,11 @@ function ChatWithPersistence({ await storage.updateThread(thread); return thread; } catch (error) { - throw handleStorageError(error, "updateThread"); + throw handleError( + error, + "[Storage] updateThread failed", + config.onError + ); } }, onSwitchToNew: () => { @@ -130,7 +134,7 @@ function ChatWithPersistence({ log("[Storage] Loaded", messages?.length || 0, "messages"); return messages || []; } catch (error) { - handleStorageError(error, "loadThread"); + handleError(error, "[Storage] loadThread failed", config.onError); return []; // Return empty array so UI still works } }, @@ -161,8 +165,8 @@ function ChatWithPersistence({ await storage.saveThread(threadId, messages); log("[Storage] Saved user messages"); } catch (error) { - // Log but don't fail - message can still be sent even if save fails - handleStorageError(error, "saveThread (user messages)"); + // Log but don't notify - message can still be sent even if save fails + normalizeError(error, "[Storage] saveThread (user messages) failed"); } } @@ -175,12 +179,12 @@ function ChatWithPersistence({ try { response = await provider.sendMessage(threadId, prompt); } catch (error) { - // Notify consumer via callback - const err = error instanceof Error ? error : new Error(String(error)); - logError("[processMessage] Error:", err.message); - config.onError?.(err); // Re-throw so the SDK can display error state in UI - throw err; + throw handleError( + error, + "[Provider] sendMessage failed", + config.onError + ); } // For LangGraph, messages are automatically persisted by the run @@ -223,8 +227,11 @@ function ChatWithPersistence({ messages.length + 1 ); } catch (error) { - // Log but don't fail - response was already streamed successfully - handleStorageError(error, "saveThread (assistant message)"); + // Log but don't notify - response was already streamed successfully + normalizeError( + error, + "[Storage] saveThread (assistant message) failed" + ); } controller.close(); @@ -314,10 +321,11 @@ export function createChat(config: ChatConfig): ChatInstance { // Create React root root = createRoot(container); } catch (error) { - const err = error instanceof Error ? error : new Error(String(error)); - logError("[createChat] Initialization failed:", err.message); - config.onError?.(err); - throw err; + throw handleError( + error, + "[createChat] Initialization failed", + config.onError + ); } // Track current session ID diff --git a/src/utils/logger.ts b/src/utils/logger.ts index 5342ace..69fb984 100644 --- a/src/utils/logger.ts +++ b/src/utils/logger.ts @@ -41,3 +41,37 @@ export function logWarn(...args: unknown[]): void { console.warn("[ThesysChat]", ...args); } } + +/** + * Normalize an error to an Error instance and log it + * Use this for internal logging without notifying consumers + * + * @param error - The caught error (unknown type) + * @param context - Description of where the error occurred + * @returns The normalized Error object + */ +export function normalizeError(error: unknown, context: string): Error { + const err = error instanceof Error ? error : new Error(String(error)); + logError(`${context}:`, err.message); + return err; +} + +/** + * Handle an error at a boundary: normalize, log, and notify consumer + * Only call this at top-level boundaries (SDK callbacks, initialization) + * Inner code should just throw - don't call this in nested handlers + * + * @param error - The caught error (unknown type) + * @param context - Description of where the error occurred + * @param onError - Callback to notify consumers + * @returns The normalized Error object for re-throwing + */ +export function handleError( + error: unknown, + context: string, + onError?: (error: Error) => void +): Error { + const err = normalizeError(error, context); + onError?.(err); + return err; +} From fb25891fad225aac5993c17fb4ee30d5d91a3510 Mon Sep 17 00:00:00 2001 From: zahlekhan Date: Fri, 19 Dec 2025 14:25:19 +0530 Subject: [PATCH 06/12] improve error boundary --- index.html | 2 +- src/index.ts | 157 ++++++++++++++++++++++++--------------------------- 2 files changed, 74 insertions(+), 85 deletions(-) diff --git a/index.html b/index.html index 18e0d67..eb09b7f 100644 --- a/index.html +++ b/index.html @@ -23,7 +23,7 @@ agentName: "Test Bot", theme: { mode: "light" }, mode: "fullscreen", // Options: "fullscreen" (default) | "sidepanel" - // storageType: "localstorage", // Options: "none" (default) | "localstorage" + storageType: "localstorage", // Options: "none" (default) | "localstorage" onSessionStart: (sessionId) => { console.log("Chat session started:", sessionId); }, diff --git a/src/index.ts b/src/index.ts index 3f18a64..00b74ae 100644 --- a/src/index.ts +++ b/src/index.ts @@ -43,78 +43,74 @@ function ChatWithPersistence({ }) { const formFactor = config.mode === "sidepanel" ? "side-panel" : "full-page"; - // Initialize thread list manager - const threadListManager = useThreadListManager({ - fetchThreadList: async () => { + /** + * Wrap an async function with error boundary handling + * Errors are logged and sent to onError callback in one place + */ + function withErrorBoundary( + fn: (...args: Args) => Promise, + context: string, + options?: { fallback?: T } + ): (...args: Args) => Promise { + return async (...args: Args): Promise => { try { - return await storage.getThreadList(); + return await fn(...args); } catch (error) { - handleError(error, "[Storage] fetchThreadList failed", config.onError); - return []; // Return empty list on error so UI still works - } - }, - createThread: async (firstMessage: UserMessage) => { - const title = generateThreadTitle(firstMessage.message || "New Chat"); - - try { - // Use LangGraph API to create thread if using LangGraph storage - if (storage instanceof LangGraphStorageAdapter) { - const thread = await storage.createThread(title); - // Note: First message will be sent via processMessage, not saved here - return thread; + const err = handleError(error, context, config.onError); + if (options?.fallback !== undefined) { + return options.fallback; } + throw err; + } + }; + } - // Default: create thread locally - const threadId = crypto.randomUUID(); - const thread: Thread = { - threadId, - title, - createdAt: new Date(), - isRunning: false, - }; - - await storage.updateThread(thread); - - // Convert UserMessage to Message format (react-core -> genui-sdk) - const message: Message = { - id: crypto.randomUUID(), - role: "user", - content: firstMessage.message || "", - }; - await storage.saveThread(threadId, [message]); + // Initialize thread list manager + const threadListManager = useThreadListManager({ + fetchThreadList: withErrorBoundary( + () => storage.getThreadList(), + "[Storage] fetchThreadList failed", + { fallback: [] } + ), + createThread: withErrorBoundary(async (firstMessage: UserMessage) => { + const title = generateThreadTitle(firstMessage.message || "New Chat"); + // Use LangGraph API to create thread if using LangGraph storage + if (storage instanceof LangGraphStorageAdapter) { + const thread = await storage.createThread(title); + // Note: First message will be sent via processMessage, not saved here return thread; - } catch (error) { - throw handleError( - error, - "[Storage] createThread failed", - config.onError - ); - } - }, - deleteThread: async (threadId: string) => { - try { - await storage.deleteThread(threadId); - } catch (error) { - throw handleError( - error, - "[Storage] deleteThread failed", - config.onError - ); } - }, - updateThread: async (thread: Thread) => { - try { - await storage.updateThread(thread); - return thread; - } catch (error) { - throw handleError( - error, - "[Storage] updateThread failed", - config.onError - ); - } - }, + + // Default: create thread locally + const threadId = crypto.randomUUID(); + const thread: Thread = { + threadId, + title, + createdAt: new Date(), + isRunning: false, + }; + + await storage.updateThread(thread); + + // Convert UserMessage to Message format (react-core -> genui-sdk) + const message: Message = { + id: crypto.randomUUID(), + role: "user", + content: firstMessage.message || "", + }; + await storage.saveThread(threadId, [message]); + + return thread; + }, "[Storage] createThread failed"), + deleteThread: withErrorBoundary( + (threadId: string) => storage.deleteThread(threadId), + "[Storage] deleteThread failed" + ), + updateThread: withErrorBoundary(async (thread: Thread) => { + await storage.updateThread(thread); + return thread; + }, "[Storage] updateThread failed"), onSwitchToNew: () => { // Called when user switches to new thread }, @@ -127,17 +123,16 @@ function ChatWithPersistence({ // Initialize thread manager const threadManager = useThreadManager({ threadListManager, - loadThread: async (threadId: string) => { - try { + loadThread: withErrorBoundary( + async (threadId: string) => { log("[Storage] loadThread:", threadId); const messages = await storage.getThread(threadId); log("[Storage] Loaded", messages?.length || 0, "messages"); return messages || []; - } catch (error) { - handleError(error, "[Storage] loadThread failed", config.onError); - return []; // Return empty array so UI still works - } - }, + }, + "[Storage] loadThread failed", + { fallback: [] } + ), processMessage: async ({ threadId, messages, @@ -174,18 +169,12 @@ function ChatWithPersistence({ const lastMessage = messages[messages.length - 1]; const prompt = lastMessage?.content || ""; - // Send message via provider - let response: Response; - try { - response = await provider.sendMessage(threadId, prompt); - } catch (error) { - // Re-throw so the SDK can display error state in UI - throw handleError( - error, - "[Provider] sendMessage failed", - config.onError - ); - } + // Send message via provider (wrapped with error boundary) + const sendMessage = withErrorBoundary( + () => provider.sendMessage(threadId, prompt), + "[Provider] sendMessage failed" + ); + const response = await sendMessage(); // For LangGraph, messages are automatically persisted by the run // Just return the response directly From 62d1fcd78d8131bfec3536b34dfb3cc6230d3438 Mon Sep 17 00:00:00 2001 From: zahlekhan Date: Fri, 19 Dec 2025 15:45:58 +0530 Subject: [PATCH 07/12] fix langgraph parsing --- src/providers/LangGraphProvider.ts | 119 ++++++++++++++++++----------- 1 file changed, 73 insertions(+), 46 deletions(-) diff --git a/src/providers/LangGraphProvider.ts b/src/providers/LangGraphProvider.ts index 9be801b..17d7112 100644 --- a/src/providers/LangGraphProvider.ts +++ b/src/providers/LangGraphProvider.ts @@ -76,6 +76,16 @@ export class LangGraphProvider implements ChatProvider { /** * Transform LangGraph streaming format to plain text stream + * + * LangGraph uses Server-Sent Events (SSE) format: + * - event: + * - data: + * - data: + * - ... + * - id: + * - (blank line separates events) + * + * We extract content from "messages|*" events where data[0].content contains the text. */ private transformStream(response: Response): Response { const reader = response.body!.getReader(); @@ -84,16 +94,49 @@ export class LangGraphProvider implements ChatProvider { const stream = new ReadableStream({ async start(controller) { let buffer = ""; - let hasStreamedContent = false; + let currentEvent = ""; + let dataLines: string[] = []; + let detectedVersion: string | null = null; + + // Process a complete SSE event + const processEvent = (eventType: string, dataLines: string[]) => { + // Only process messages events (streaming content) + if (!eventType.startsWith("messages|")) { + return; + } - // Helper to extract content from LangGraph JSON format - const extractContent = ( - data: { content?: string }[] - ): string | null => { try { - return data[0].content || null; - } catch { - return null; + // Join all data lines (remove "data: " prefix from each) + const jsonStr = dataLines + .map((line) => { + if (line.startsWith("data: ")) return line.slice(6); + if (line.startsWith("data:")) return line.slice(5); + return line; + }) + .join("\n"); + + const data = JSON.parse(jsonStr); + + // Detect and log LangGraph version from metadata (data[1]) + if ( + !detectedVersion && + Array.isArray(data) && + data[1]?.langgraph_version + ) { + detectedVersion = data[1].langgraph_version; + log("[LangGraph] Detected version:", detectedVersion); + } + + // data is an array: [aiMessage, metadata] + // aiMessage.content contains the streaming text + if (Array.isArray(data) && data[0]?.content) { + const content = data[0].content; + if (content) { + controller.enqueue(new TextEncoder().encode(content)); + } + } + } catch (e) { + log("[LangGraph] Failed to parse event:", eventType, e); } }; @@ -104,52 +147,36 @@ export class LangGraphProvider implements ChatProvider { const chunk = decoder.decode(value, { stream: true }); buffer += chunk; - const lines = buffer.split("\n"); - buffer = lines.pop() || ""; + // Process complete lines + const lines = buffer.split("\n"); + buffer = lines.pop() || ""; // Keep incomplete line in buffer for (const line of lines) { - if (line.trim()) { - try { - // LangGraph format: "data: {...}" - const data = JSON.parse(line.slice(6)); - const content = extractContent(data); - if (content) { - controller.enqueue(new TextEncoder().encode(content)); - hasStreamedContent = true; - } - } catch (e) { - logError( - "[LangGraph] Failed to parse streaming line:", - line, - e - ); + if (line.startsWith("event: ")) { + // New event starting - process previous event if we have data + if (currentEvent && dataLines.length > 0) { + processEvent(currentEvent, dataLines); + } + currentEvent = line.slice(7).trim(); + dataLines = []; + } else if (line.startsWith("data:")) { + dataLines.push(line); + } else if (line.startsWith("id:")) { + // End of event block - process it + if (currentEvent && dataLines.length > 0) { + processEvent(currentEvent, dataLines); } + currentEvent = ""; + dataLines = []; } + // Ignore blank lines and other content } } - // Process remaining buffer - if (buffer.trim()) { - try { - const data = JSON.parse(buffer); - const content = extractContent(data); - if (content) { - controller.enqueue(new TextEncoder().encode(content)); - hasStreamedContent = true; - } - } catch (e) { - // Buffer might not be valid JSON - could be plain text - if (!hasStreamedContent) { - controller.enqueue(new TextEncoder().encode(buffer)); - } else { - logError( - "[LangGraph] Failed to parse final streaming data:", - buffer, - e - ); - } - } + // Process any remaining event + if (currentEvent && dataLines.length > 0) { + processEvent(currentEvent, dataLines); } } catch (error) { logError("[LangGraph] Streaming error:", error); From 2e44c0993f4033f2794b5493c846a21654597baa Mon Sep 17 00:00:00 2001 From: zahlekhan Date: Fri, 19 Dec 2025 15:57:11 +0530 Subject: [PATCH 08/12] add log error --- index.html | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/index.html b/index.html index eb09b7f..eb4dd81 100644 --- a/index.html +++ b/index.html @@ -5,7 +5,7 @@ Thesys Chat Client Test - + { console.log("Chat session started:", sessionId); }, + onError: (error) => { + alert(error.message); + }, }); // Expose to window for testing From 5f8c6bfa2a3ffdd1ba52592f9c2f6a08ea37ef19 Mon Sep 17 00:00:00 2001 From: zahlekhan Date: Fri, 19 Dec 2025 16:04:50 +0530 Subject: [PATCH 09/12] fix readme --- README.md | 182 +++++++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 161 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 325e49c..40a314c 100644 --- a/README.md +++ b/README.md @@ -1,21 +1,44 @@ # GenUI Widget -An embeddable chat widget that connects to webhook endpoints. Create beautiful chat interfaces powered by your custom workflows from n8n, Make.com, or any custom webhook provider. +An embeddable chat widget that connects to LangGraph deployments or webhook endpoints. Create beautiful chat interfaces powered by LangGraph agents, n8n workflows, Make.com, or any custom webhook provider. ## Features - 🎨 **Beautiful UI** - Clean, fullscreen chat interface - 🚀 **Easy Integration** - Single script tag or npm package -- 💬 **Session Management** - Automatic session handling -- 💾 **Persistence** - Optional localStorage support for chat history +- 💬 **Session Management** - Automatic session handling with persistent threads +- 💾 **Flexible Storage** - localStorage, LangGraph-managed, or in-memory - 🗂️ **Thread Management** - Create, switch, and delete conversation threads - 🌓 **Theme Support** - Light and dark mode - 📱 **Responsive** - Works perfectly on mobile and desktop -- 🔌 **Provider Agnostic** - Works with n8n, Make.com, or custom webhooks +- 🔌 **Multi-Provider** - LangGraph, n8n, Make.com, or custom webhooks +- 🌊 **Streaming Support** - Real-time streaming responses from your backend ## Quick Start -Add this script to your HTML: +### Using LangGraph + +```html + + + +``` + +### Using n8n or Custom Webhooks ```html @@ -47,19 +72,27 @@ See Quick Start above. npm install genui-widget ``` -**Note:** This package requires React 18 or 19 as peer dependencies. Make sure you have React installed in your project: -```bash -npm install react react-dom -``` ```javascript import { createChat } from "genui-widget"; +// With LangGraph +const chat = createChat({ + langgraph: { + deploymentUrl: "https://your-deployment.langraph.app", + assistantId: "your-assistant-id", + }, + storageType: "langgraph", +}); + +// OR with n8n/webhooks const chat = createChat({ n8n: { webhookUrl: "YOUR_WEBHOOK_URL", + enableStreaming: true, }, + storageType: "localstorage", }); ``` @@ -67,24 +100,32 @@ const chat = createChat({ ```javascript const chat = createChat({ - // Required: Webhook configuration + // Provider configuration (choose one) + langgraph: { + deploymentUrl: "https://your-deployment.langraph.app", + assistantId: "your-assistant-id", + }, + // OR n8n: { webhookUrl: "https://your-webhook-endpoint.com/chat", - enableStreaming: false, // Optional: Enable streaming responses + enableStreaming: true, // Optional: Enable streaming responses }, // Optional settings agentName: "Assistant", // Bot/agent name logoUrl: "https://example.com/logo.png", // Logo image URL theme: { mode: "light" }, // 'light' or 'dark' - storageType: "localstorage", // 'none' or 'localstorage' + storageType: "langgraph", // 'none', 'localstorage', or 'langgraph' mode: "fullscreen", // 'fullscreen' or 'sidepanel' enableDebugLogging: false, // Enable console debug logging - // Optional: Callback when session starts + // Optional: Callbacks onSessionStart: (sessionId) => { console.log("Session started:", sessionId); }, + onError: (error) => { + console.error("Chat error:", error); + }, }); ``` @@ -94,12 +135,22 @@ const chat = createChat({ - Messages work normally during the session - All data is lost on page refresh +- Best for: Simple use cases, demos, or privacy-focused applications **`storageType: "localstorage"`:** - Chat conversations persist across page refreshes - Users can create and manage multiple threads - Thread history is saved to browser localStorage +- Best for: n8n/webhook integrations without built-in persistence + +**`storageType: "langgraph"`:** + +- Leverages LangGraph's built-in thread management +- Conversations persist server-side across devices +- Requires `langgraph` provider configuration +- Thread operations (create, delete, update) sync with LangGraph API +- Best for: LangGraph deployments requiring cross-device sync ### Programmatic Control @@ -117,6 +168,41 @@ chat.close(); chat.destroy(); ``` +## Provider Integration + +### LangGraph + +The widget integrates seamlessly with [LangGraph](https://langchain-ai.github.io/langgraph/) deployments (Cloud or self-hosted). + +**Configuration:** + +```javascript +createChat({ + langgraph: { + deploymentUrl: "https://your-deployment.langraph.app", + assistantId: "your-assistant-id", + }, + storageType: "langgraph", // Recommended for LangGraph +}); +``` + +**Features:** + +- ✅ **Automatic Thread Management** - Creates and manages threads via LangGraph API +- ✅ **Server-Side Persistence** - Conversations persist across devices +- ✅ **Streaming Support** - Real-time streaming via Server-Sent Events (SSE) +- ✅ **Message History** - Fetches and displays conversation history +- ✅ **Thread Operations** - Create, update, delete threads with metadata + +**How it works:** + +1. Widget calls `POST /threads` to create new conversation threads +2. Messages sent via `POST /threads/{thread_id}/runs/stream` with streaming enabled +3. Thread history retrieved via `GET /threads/{thread_id}/history` +4. Thread list fetched via `POST /threads/search` + +The LangGraph provider automatically handles the streaming response format and extracts message content from the SSE events. + ## Webhook Integration ### Request Format @@ -198,10 +284,28 @@ For streaming, return line-delimited JSON chunks. Complete list of all available options: -### n8n (required) +### Provider Configuration + +**You must configure either `langgraph` OR `n8n` (not both):** + +#### langgraph (optional) + +```typescript +langgraph?: { + // Required: Your LangGraph deployment URL + deploymentUrl: string; + + // Required: The assistant ID to use + assistantId: string; +} +``` + +Use this for LangGraph Cloud or self-hosted deployments. When using LangGraph, set `storageType: "langgraph"` to leverage server-side thread management. + +#### n8n (optional) ```typescript -n8n: { +n8n?: { // Required: Your webhook URL webhookUrl: string; @@ -216,6 +320,8 @@ n8n: { } ``` +Use this for n8n, Make.com, or any custom webhook endpoint. + ### agentName (optional) ```typescript @@ -253,13 +359,14 @@ Sets the color scheme for the chat interface. ### storageType (optional) ```typescript -storageType?: 'none' | 'localstorage'; // Default: 'none' +storageType?: 'none' | 'localstorage' | 'langgraph'; // Default: 'none' ``` Controls chat history persistence: - `'none'` - Messages are kept in memory only, lost on page refresh - `'localstorage'` - Messages are saved to browser localStorage, persist across sessions +- `'langgraph'` - Uses LangGraph's server-side thread management (requires `langgraph` provider) ### mode (optional) @@ -280,25 +387,58 @@ onSessionStart?: (sessionId: string) => void; Callback function that fires when a new chat session is created. Receives the session ID as a parameter. Useful for analytics or tracking. +### onError (optional) + +```typescript +onError?: (error: Error) => void; +``` + +Callback function that fires when an error occurs during message processing. Useful for logging, analytics, or custom error handling. Note that the widget will still display error states in the chat UI automatically. + ## Troubleshooting ### Chat doesn't load -- Check browser console for errors -- Verify webhook URL is correct -- Ensure webhook endpoint is active and accessible +- Check browser console for errors (enable `enableDebugLogging: true`) +- Verify provider configuration is correct (LangGraph URL/assistant ID or webhook URL) +- Ensure endpoint is active and accessible - Check CORS settings -### "Unable to reach the webhook" error +### Connection errors + +**For LangGraph:** + +- Verify `deploymentUrl` and `assistantId` are correct +- Check that the LangGraph deployment is running +- Ensure your assistant is deployed and accessible + +**For n8n/webhooks:** - Verify webhook URL is correct - Check CORS configuration - Ensure your domain is allowlisted (for n8n) +- Test webhook endpoint independently + +### Messages not sending or displaying -### Messages not sending +**For LangGraph:** + +- Check that streaming is working (SSE connection) +- Verify assistant is responding correctly +- Check thread creation/access permissions + +**For n8n/webhooks:** - Verify response format: `{ "output": "message" }` +- For streaming, ensure line-delimited JSON format - Check webhook execution logs +- Enable `enableStreaming` if using streaming responses + +### Storage issues + +- If using `storageType: "langgraph"`, ensure LangGraph provider is configured +- For localStorage, check browser storage isn't full +- Clear localStorage if you encounter corrupted state: `localStorage.clear()` ## Requirements From b2ae1d17169af0a85d2e97760aedb1725d51390a Mon Sep 17 00:00:00 2001 From: zahlekhan Date: Fri, 19 Dec 2025 16:13:18 +0530 Subject: [PATCH 10/12] Update README to reflect project name change and enhance description of features --- README.md | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 40a314c..5cd49ef 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,9 @@ -# GenUI Widget +# Generative UI Widget -An embeddable chat widget that connects to LangGraph deployments or webhook endpoints. Create beautiful chat interfaces powered by LangGraph agents, n8n workflows, Make.com, or any custom webhook provider. +An embeddable chat widget for building Generative UI applications. +Add buttons, forms, and charts to your existing LangGraph agents and n8n workflows. + +[![Built with Thesys](https://thesys.dev/built-with-thesys-badge.svg)](https://thesys.dev) ## Features @@ -72,8 +75,6 @@ See Quick Start above. npm install genui-widget ``` - - ```javascript import { createChat } from "genui-widget"; From b4413e1e1dfbfaca430a550fd169321244c78c98 Mon Sep 17 00:00:00 2001 From: zahlekhan Date: Fri, 19 Dec 2025 16:15:55 +0530 Subject: [PATCH 11/12] fix typo --- README.md | 4 ++-- src/types.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 5cd49ef..fdedff4 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # Generative UI Widget An embeddable chat widget for building Generative UI applications. -Add buttons, forms, and charts to your existing LangGraph agents and n8n workflows. +Add buttons, forms, and charts to your existing LangGraph(/LangChain) agents and n8n workflows. [![Built with Thesys](https://thesys.dev/built-with-thesys-badge.svg)](https://thesys.dev) @@ -19,7 +19,7 @@ Add buttons, forms, and charts to your existing LangGraph agents and n8n workflo ## Quick Start -### Using LangGraph +### Using LangGraph(/LangChain) ```html Date: Fri, 19 Dec 2025 16:19:13 +0530 Subject: [PATCH 12/12] add more instructions --- README.md | 40 +--------------------------------------- 1 file changed, 1 insertion(+), 39 deletions(-) diff --git a/README.md b/README.md index fdedff4..67c7990 100644 --- a/README.md +++ b/README.md @@ -241,45 +241,7 @@ Return line-delimited JSON chunks: ### n8n -1. **Create a Webhook Trigger** - - - Add a Webhook node to your workflow - - Set it to accept POST requests - - Note your webhook URL - -2. **Access the Data** - - - Message: `{{ $json.chatInput }}` - - Session ID: `{{ $json.sessionId }}` - -3. **Return a Response** - - - Use "Respond to Webhook" node - - Return: `{ "output": "Your response" }` - -4. **Configure CORS** - - Enable Domain Allowlist in webhook settings - - Add your domain(s): `example.com`, `www.example.com` - - For local dev: `localhost`, `127.0.0.1` - -**Security Note:** The webhook URL is visible in the browser. Use n8n's Domain Allowlist to restrict access. - -### Make.com - -1. Create a scenario with a **Webhook** module -2. Process `chatInput` and `sessionId` -3. Add your logic (AI, database, etc.) -4. Use **Webhook Response** to return `{ "output": "Your response" }` - -### Custom Webhook - -Your endpoint should: - -1. Accept POST requests with JSON body -2. Parse `chatInput` and `sessionId` -3. Return JSON: `{ "output": "Your response" }` - -For streaming, return line-delimited JSON chunks. +Follow the instructions at [thesys.dev/n8n](https://thesys.dev/n8n) to quickly set up your n8n workflow. ## Configuration Reference