From 5ecc18d73461abc644ee814c26b6d83fd8a595e0 Mon Sep 17 00:00:00 2001 From: gcalcedo Date: Thu, 21 Nov 2024 13:49:41 +0100 Subject: [PATCH 1/4] fix: use structured messages in stream with metadata --- src/hooks/useChatStream.ts | 55 +++++++++++++++++++++++++++++++------- src/utils/json.ts | 38 +++++++++++++++----------- 2 files changed, 68 insertions(+), 25 deletions(-) diff --git a/src/hooks/useChatStream.ts b/src/hooks/useChatStream.ts index 423c492..5f59f93 100644 --- a/src/hooks/useChatStream.ts +++ b/src/hooks/useChatStream.ts @@ -1,7 +1,7 @@ import { ChangeEvent, FormEvent, useState } from 'react'; import { decodeStreamToJson, getStream } from '../utils/streams'; import { UseChatStreamChatMessage, UseChatStreamInput } from '../types'; -import { extractJsonFromEnd } from '../utils/json'; +import { extractJsons } from '../utils/json'; const BOT_ERROR_MESSAGE = 'Something went wrong fetching AI response.'; @@ -50,13 +50,6 @@ const useChatStream = (input: UseChatStreamInput) => { continue; } - if (input.options.useMetadata) { - const metadata = extractJsonFromEnd(chunk); - if (metadata) { - return { ...initialMessage, content: response, metadata: metadata }; - } - } - // Stream characters one by one based on the characters per second that is set. for (const char of chunk) { appendMessageToChat(char); @@ -71,6 +64,43 @@ const useChatStream = (input: UseChatStreamInput) => { return { ...initialMessage, content: response }; }; + const fetchAndUpdateAIResponseWithMetadata = async (message: string) => { + const charactersPerSecond = input.options.fakeCharactersPerSecond; + const stream = await getStream(message, input.options, input.method); + const initialMessage = addMessage({ content: '', role: 'bot' }); + let response = ''; + let metadata = null; + + for await (const chunk of decodeStreamToJson(stream)) { + const jsonObjects = extractJsons(chunk); + + for (const parsedChunk of jsonObjects) { + if (parsedChunk.type === 'content') { + const content = parsedChunk.data; + + if (!charactersPerSecond) { + appendMessageToChat(content); + response += content; + continue; + } + + for (const char of content) { + appendMessageToChat(char); + response += char; + + if (charactersPerSecond > 0) { + await new Promise(resolve => setTimeout(resolve, 1000 / charactersPerSecond)); + } + } + } else if (parsedChunk.type === 'metadata') { + metadata = parsedChunk.data; + } + } + } + + return { ...initialMessage, content: response, metadata }; + }; + const submitMessage = async (message: string) => resetInputAndGetResponse(message); const resetInputAndGetResponse = async (message?: string) => { @@ -80,8 +110,13 @@ const useChatStream = (input: UseChatStreamInput) => { setFormInput(''); try { - const addedMessage = await fetchAndUpdateAIResponse(message ?? formInput); - await input.handlers.onMessageAdded?.(addedMessage); + if (input.options.useMetadata) { + const addedMessage = await fetchAndUpdateAIResponseWithMetadata(message ?? formInput); + await input.handlers.onMessageAdded?.(addedMessage); + } else { + const addedMessage = await fetchAndUpdateAIResponse(message ?? formInput); + await input.handlers.onMessageAdded?.(addedMessage); + } } catch { const addedMessage = addMessage({ content: BOT_ERROR_MESSAGE, role: 'bot' }); await input.handlers.onMessageAdded?.(addedMessage); diff --git a/src/utils/json.ts b/src/utils/json.ts index 215d0fd..a03d002 100644 --- a/src/utils/json.ts +++ b/src/utils/json.ts @@ -1,20 +1,28 @@ -export const extractJsonFromEnd = (chunk: string) => { - const chunkTrimmed = chunk.trim(); +export const extractJsons = (chunk: string) => { + const jsonObjects = []; + const braceStack = []; + let currentJsonStart = null; - const jsonObjectRegex = /({[^]*})\s*$/; - const match = chunkTrimmed.match(jsonObjectRegex); + for (let i = 0; i < chunk.length; i++) { + const char = chunk[i]; - if (!match) { - return null; - } - - const jsonStr = match[1]; - try { - const parsedData = JSON.parse(jsonStr); - if (typeof parsedData === 'object' && parsedData !== null && !Array.isArray(parsedData)) { - return parsedData; + if (char === '{') { + if (braceStack.length === 0) { + currentJsonStart = i; + } + braceStack.push('{'); + } else if (char === '}') { + braceStack.pop(); + if (braceStack.length === 0 && currentJsonStart !== null) { + const potentialJson = chunk.substring(currentJsonStart, i + 1); + try { + const parsedJson = JSON.parse(potentialJson); + jsonObjects.push(parsedJson); + } catch {} + currentJsonStart = null; + } } - } catch {} + } - return null; + return jsonObjects; }; From 10b3aabf1f7dfdbe76aae7eab5b7ad2cd1b250a4 Mon Sep 17 00:00:00 2001 From: gcalcedo Date: Thu, 21 Nov 2024 13:52:41 +0100 Subject: [PATCH 2/4] v0.5.2 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 331ad4d..5f205dc 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@magicul/react-chat-stream", "description": "A React hook that lets you easily integrate your custom ChatGPT-like chat in React.", - "version": "0.5.1", + "version": "0.5.2", "main": "dist/index.js", "types": "dist/index.d.ts", "homepage": "https://github.com/XD2Sketch/react-chat-stream#readme", From 921b81b15622c8d7ea9b39ec939187b30d37ef57 Mon Sep 17 00:00:00 2001 From: gcalcedo Date: Thu, 28 Nov 2024 14:37:53 +0100 Subject: [PATCH 3/4] chore: add docs to extract json chunks function --- src/utils/json.ts | 43 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 42 insertions(+), 1 deletion(-) diff --git a/src/utils/json.ts b/src/utils/json.ts index a03d002..81ec96b 100644 --- a/src/utils/json.ts +++ b/src/utils/json.ts @@ -1,4 +1,45 @@ -export const extractJsons = (chunk: string) => { +/** + * Extracts JSON objects from a string containing one or more dumps of JSON objects. + * + * This function is used to parse the stream only when the `useMetadata` option is enabled. + * Then, this hook expects to receive JSON dumps instead of plain text. These JSON dumps are of + * the following format: + * + * - For content to be used in the chat: + * ```json + * { + * "type": "content", + * "data": "Hello, world!" + * } + * ``` + * + * - For metadata: + * ```json + * { + * "type": "metadata", + * "data": { + * "key": "value", + * "key2": "value2", + * ... + * } + * } + * ``` + * + * @param chunk - The string containing one or more JSON object dumps. + * @returns An array of parsed JSON objects. + * + * @example + * ```typescript + * const chunk = '{"type": "content", "data": "Hello, world!"}{"type": "metadata", "data": {"key": "value"}}'; + * const jsonObjects = getJsonObjectsFromChunks(chunk); + * console.log(jsonObjects); + * // Output: [ + * // { type: 'content', data: 'Hello, world!' }, + * // { type: 'metadata', data: { key: 'value' } } + * // ] + * ``` + */ +export const getJsonObjectsFromChunks = (chunk: string) => { const jsonObjects = []; const braceStack = []; let currentJsonStart = null; From f140eda06635260887eb8bdc4a4cc80ac487cbf6 Mon Sep 17 00:00:00 2001 From: gcalcedo Date: Thu, 28 Nov 2024 14:38:21 +0100 Subject: [PATCH 4/4] chore: simplify fetch and update function --- src/hooks/useChatStream.ts | 79 ++++++++++++++------------------------ 1 file changed, 29 insertions(+), 50 deletions(-) diff --git a/src/hooks/useChatStream.ts b/src/hooks/useChatStream.ts index 5f59f93..65c83ed 100644 --- a/src/hooks/useChatStream.ts +++ b/src/hooks/useChatStream.ts @@ -1,7 +1,7 @@ import { ChangeEvent, FormEvent, useState } from 'react'; import { decodeStreamToJson, getStream } from '../utils/streams'; import { UseChatStreamChatMessage, UseChatStreamInput } from '../types'; -import { extractJsons } from '../utils/json'; +import { getJsonObjectsFromChunks } from '../utils/json'; const BOT_ERROR_MESSAGE = 'Something went wrong fetching AI response.'; @@ -10,7 +10,9 @@ const useChatStream = (input: UseChatStreamInput) => { const [formInput, setFormInput] = useState(''); const [isStreaming, setIsStreaming] = useState(false); - const handleInputChange = (e: ChangeEvent | ChangeEvent) => { + const handleInputChange = ( + e: ChangeEvent | ChangeEvent, + ) => { setFormInput(e.target.value); }; @@ -37,21 +39,21 @@ const useChatStream = (input: UseChatStreamInput) => { }); }; - const fetchAndUpdateAIResponse = async (message: string) => { + const fetchAndUpdateAIResponse = async (message: string, useMetadata: boolean) => { const charactersPerSecond = input.options.fakeCharactersPerSecond; const stream = await getStream(message, input.options, input.method); const initialMessage = addMessage({ content: '', role: 'bot' }); let response = ''; + let metadata = null; - for await (const chunk of decodeStreamToJson(stream)) { + const processContent = async (content: string) => { if (!charactersPerSecond) { - appendMessageToChat(chunk); - response += chunk; - continue; + appendMessageToChat(content); + response += content; + return; } - // Stream characters one by one based on the characters per second that is set. - for (const char of chunk) { + for (const char of content) { appendMessageToChat(char); response += char; @@ -59,46 +61,25 @@ const useChatStream = (input: UseChatStreamInput) => { await new Promise(resolve => setTimeout(resolve, 1000 / charactersPerSecond)); } } - } - - return { ...initialMessage, content: response }; - }; - - const fetchAndUpdateAIResponseWithMetadata = async (message: string) => { - const charactersPerSecond = input.options.fakeCharactersPerSecond; - const stream = await getStream(message, input.options, input.method); - const initialMessage = addMessage({ content: '', role: 'bot' }); - let response = ''; - let metadata = null; + }; for await (const chunk of decodeStreamToJson(stream)) { - const jsonObjects = extractJsons(chunk); - - for (const parsedChunk of jsonObjects) { - if (parsedChunk.type === 'content') { - const content = parsedChunk.data; - - if (!charactersPerSecond) { - appendMessageToChat(content); - response += content; - continue; - } - - for (const char of content) { - appendMessageToChat(char); - response += char; - - if (charactersPerSecond > 0) { - await new Promise(resolve => setTimeout(resolve, 1000 / charactersPerSecond)); - } + if (useMetadata) { + const jsonObjects = getJsonObjectsFromChunks(chunk); + + for (const parsedChunk of jsonObjects) { + if (parsedChunk.type === 'content') { + await processContent(parsedChunk.data); + } else if (parsedChunk.type === 'metadata') { + metadata = parsedChunk.data; } - } else if (parsedChunk.type === 'metadata') { - metadata = parsedChunk.data; } + } else { + await processContent(chunk); } } - return { ...initialMessage, content: response, metadata }; + return { ...initialMessage, content: response, ...(useMetadata && { metadata }) }; }; const submitMessage = async (message: string) => resetInputAndGetResponse(message); @@ -110,20 +91,18 @@ const useChatStream = (input: UseChatStreamInput) => { setFormInput(''); try { - if (input.options.useMetadata) { - const addedMessage = await fetchAndUpdateAIResponseWithMetadata(message ?? formInput); - await input.handlers.onMessageAdded?.(addedMessage); - } else { - const addedMessage = await fetchAndUpdateAIResponse(message ?? formInput); - await input.handlers.onMessageAdded?.(addedMessage); - } + const addedMessage = await fetchAndUpdateAIResponse( + message ?? formInput, + input.options.useMetadata ?? false, + ); + await input.handlers.onMessageAdded?.(addedMessage); } catch { const addedMessage = addMessage({ content: BOT_ERROR_MESSAGE, role: 'bot' }); await input.handlers.onMessageAdded?.(addedMessage); } finally { setIsStreaming(false); } - } + }; return { messages,