Skip to content

Commit

Permalink
feat: allow custom submit and adding event handlers (#23)
Browse files Browse the repository at this point in the history
  • Loading branch information
niels-bosman committed Feb 18, 2024
1 parent f66a1f7 commit 68555c3
Show file tree
Hide file tree
Showing 9 changed files with 1,284 additions and 509 deletions.
21 changes: 5 additions & 16 deletions .eslintrc → .eslintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,15 @@
"env": {
"browser": true
},
"plugins": ["react", "@typescript-eslint", "react-hooks", "prettier"],
"plugins": ["react", "@typescript-eslint", "prettier"],
"extends": [
"airbnb",
"plugin:react/recommended",
"plugin:@typescript-eslint/recommended",
"prettier",
"next/core-web-vitals"
"prettier"
],
"rules": {
"jsx-a11y/anchor-is-valid": 0,
"default-case": "off",
"no-irregular-whitespace": "off",
"jsx-a11y/label-has-associated-control": "off",
"jsx-a11y/control-has-associated-label": "off",
Expand All @@ -28,19 +27,9 @@
"@typescript-eslint/explicit-module-boundary-types": "off",
"@typescript-eslint/no-non-null-assertion": "off",
"@typescript-eslint/array-type": ["error", { "default": "array-simple" }],
"@typescript-eslint/naming-convention": [
"error",
{
"selector": "interface",
"format": ["PascalCase"],
"custom": {
"regex": "^I[A-Z]",
"match": true
}
}
],
"no-case-declarations": "off",
"@typescript-eslint/explicit-function-return-type": "off",
"@typescript-eslint/no-unused-vars": "off",
"@typescript-eslint/no-unused-vars": "error",
"@typescript-eslint/explicit-member-accessibility": "off",
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-use-before-define": [
Expand Down
2 changes: 1 addition & 1 deletion .npmignore
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
.github
.eslintrc
.eslintignore
.eslintignore.json
.prettierignore
.prettierrc
example
3 changes: 2 additions & 1 deletion .prettierrc
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,6 @@
"tabWidth": 2,
"semi": true,
"singleQuote": true,
"arrowParens": "avoid"
"arrowParens": "avoid",
"printWidth": 100,
}
34 changes: 16 additions & 18 deletions package.json
Original file line number Diff line number Diff line change
@@ -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.2.3",
"version": "0.3.0",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"homepage": "https://github.com/XD2Sketch/react-chat-stream#readme",
Expand Down Expand Up @@ -32,22 +32,20 @@
"react": ">=17.0.2"
},
"devDependencies": {
"@types/react": "^18.2.14",
"@typescript-eslint/eslint-plugin": "^5.59.11",
"@typescript-eslint/parser": "^5.61.0",
"eslint": "8.46.0",
"eslint-config-airbnb": "^19.0.4",
"eslint-plugin-jsx-a11y": "^6.7.1",
"eslint-plugin-prettier": "^5.0.0",
"eslint-plugin-react": "^7.32.2",
"eslint-plugin-react-hooks": "^4.6.0",
"prettier": "^3.0.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"typescript": "^5.1.6"
},
"dependencies": {
"uuid": "^9.0.0",
"@types/uuid": "^9.0.2"
"@types/react": "18.2.56",
"@typescript-eslint/eslint-plugin": "7.0.1",
"@typescript-eslint/parser": "7.0.1",
"eslint": "8.56.0",
"eslint-config-airbnb": "19.0.4",
"eslint-config-next": "14.1.0",
"eslint-config-prettier": "9.1.0",
"eslint-plugin-import": "2.29.1",
"eslint-plugin-jsx-a11y": "6.8.0",
"eslint-plugin-prettier": "5.1.3",
"eslint-plugin-react": "7.33.2",
"prettier": "3.2.5",
"react": "18.2.0",
"react-dom": "18.2.0",
"typescript": "5.3.3"
}
}
89 changes: 39 additions & 50 deletions src/hooks/useChatStream.ts
Original file line number Diff line number Diff line change
@@ -1,46 +1,28 @@
import { ChangeEvent, Dispatch, FormEvent, SetStateAction, useState } from 'react';
import { ChangeEvent, FormEvent, useState } from 'react';
import { decodeStreamToJson, getStream } from '../utils/streams';
import { v4 as uuidv4 } from 'uuid';
import { UseChatStreamChatMessage, UseChatStreamInput } from '../types';

const BOT_ERROR_MESSAGE = 'Something went wrong fetching AI response.';

type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';

type ChatMessage = {
role: 'bot' | 'user';
content: string;
id: string;
}

export type UseChatStreamOptions = {
url: string;
method: HttpMethod;
query?: Record<string, string>;
headers?: HeadersInit;
body?: Record<string, string>;
}

export type UseChatStreamInputMethod = {
type: 'body' | 'query',
key: string;
}

type UseChatStreamInput = {
options: UseChatStreamOptions,
method: UseChatStreamInputMethod,
};

const useChatStream = (input: UseChatStreamInput) => {
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [message, setMessage] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [messages, setMessages] = useState<UseChatStreamChatMessage[]>([]);
const [formInput, setFormInput] = useState('');
const [isStreaming, setIsStreaming] = useState(false);

const handleInputChange = (e: ChangeEvent<HTMLInputElement> | ChangeEvent<HTMLTextAreaElement>) => {
setMessage(e.target.value);
setFormInput(e.target.value);
};

const handleSubmit = async (e?: FormEvent<HTMLFormElement>) => {
e?.preventDefault();
await resetInputAndGetResponse();
};

const addMessageToChat = (message: string, role: ChatMessage['role'] = 'user') => {
setMessages(messages => [...messages, { role, content: message, id: uuidv4() }]);
const addMessage = (message: Omit<UseChatStreamChatMessage, 'id'>) => {
const messageWithId = { ...message, id: crypto.randomUUID() as string };
setMessages(messages => [...messages, messageWithId]);

return messageWithId;
};

const appendMessageToChat = (message: string) => {
Expand All @@ -56,38 +38,45 @@ const useChatStream = (input: UseChatStreamInput) => {

const fetchAndUpdateAIResponse = async (message: string) => {
const stream = await getStream(message, input.options, input.method);
if (!stream) throw new Error();

addMessageToChat('', 'bot');
const initialMessage = addMessage({ content: '', role: 'bot' });
let response = '';

for await (const message of decodeStreamToJson(stream)) {
appendMessageToChat(message);
response += message;
}

return { ...initialMessage, content: response };
};

const handleSubmit = async (e?: FormEvent<HTMLFormElement>, newMessage?: string) => {
setIsLoading(true);
e?.preventDefault();
addMessageToChat(newMessage ?? message);
setMessage('');
const submitMessage = async (message: string) => resetInputAndGetResponse(message);

const resetInputAndGetResponse = async (message?: string) => {
setIsStreaming(true);
const addedMessage = addMessage({ content: message ?? formInput, role: 'user' });
await input.handlers.onMessageAdded?.(addedMessage);
setFormInput('');

try {
await fetchAndUpdateAIResponse(newMessage ?? message);
const addedMessage = await fetchAndUpdateAIResponse(formInput);
await input.handlers.onMessageAdded?.(addedMessage);
} catch {
addMessageToChat(BOT_ERROR_MESSAGE, 'bot');
const addedMessage = addMessage({ content: BOT_ERROR_MESSAGE, role: 'bot' });
await input.handlers.onMessageAdded?.(addedMessage);
} finally {
setIsStreaming(false);
}

setIsLoading(false);
};
}

return {
messages,
setMessages,
input: message,
setInput: setMessage,
input: formInput,
setInput: setFormInput,
handleInputChange,
handleSubmit,
isLoading,
submitMessage,
isStreaming,
};
};

Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import useChatStream from './hooks/useChatStream';

export * from './types';
export default useChatStream;
36 changes: 36 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import useChatStream from './hooks/useChatStream';

type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';

export type UseChatStreamRole = 'bot' | 'user';

export type UseChatStreamChatMessage = {
role: UseChatStreamRole;
content: string;
id: string,
}

export type UseChatStreamHttpOptions = {
url: string;
method: HttpMethod;
query?: Record<string, string>;
headers?: HeadersInit;
body?: Record<string, string>;
}

export type UseChatStreamEventHandlers = {
onMessageAdded: (message: UseChatStreamChatMessage) => unknown | Promise<unknown>;
}

export type UseChatStreamInputMethod = {
type: 'body' | 'query',
key: string;
}

export type UseChatStreamInput = {
options: UseChatStreamHttpOptions,
method: UseChatStreamInputMethod,
handlers: UseChatStreamEventHandlers
};

export type UseChatStreamResult = ReturnType<typeof useChatStream>;
15 changes: 4 additions & 11 deletions src/utils/streams.ts
Original file line number Diff line number Diff line change
@@ -1,31 +1,24 @@
import type {
UseChatStreamInputMethod,
UseChatStreamOptions
} from '../hooks/useChatStream';
import { UseChatStreamHttpOptions, UseChatStreamInputMethod } from '../types';

const DEFAULT_HEADERS = {
'Content-Type': 'application/json',
};

const mergeInputInOptions = (input: string, options: UseChatStreamOptions, method: UseChatStreamInputMethod) => {
const mergeInputInOptions = (input: string, options: UseChatStreamHttpOptions, method: UseChatStreamInputMethod) => {
options.query = options.query ?? {};
(options[method.type] as Record<string, unknown>)[method.key] = input;

return options;
};

export const getStream = async (input: string, options: UseChatStreamOptions, method: UseChatStreamInputMethod) => {
export const getStream = async (input: string, options: UseChatStreamHttpOptions, method: UseChatStreamInputMethod) => {
options = mergeInputInOptions(input, options, method);

const params = '?' + new URLSearchParams(options.query).toString();
console.log(JSON.stringify(options.body, (_k, v) => v === null ? undefined : v));

const response = await fetch(options.url + params, {
method: options.method,
headers: {
...DEFAULT_HEADERS,
...options.headers
},
headers: { ...DEFAULT_HEADERS, ...options.headers },
body: JSON.stringify(options.body, (_k, v) => v === null ? undefined : v)
});

Expand Down

0 comments on commit 68555c3

Please sign in to comment.