Skip to content

Commit

Permalink
Support Chat Streaming behavior (#12)
Browse files Browse the repository at this point in the history
update Chat Headless to expose a `streamNextMessage` function that internally will invoke Chat Core `streamNextMessage`. 

`ConversationState.Messages` state will get updated by appending the token to `text` field from `streamToken` event every time there's a new `streamToken` event. Notes are updated at the start of the stream from `startTokenStream` event. And finally, conversationId and message (for the timestamp) is updated from `endStream` event.

Updated test app to use both non-stream and stream method. Added more jest tests.

J=CLIP-152
TEST=manual&auto

see that jest tests passed
see that test app stream response to page as expected
  • Loading branch information
yen-tt authored May 24, 2023
1 parent 8f3e20d commit 342c361
Show file tree
Hide file tree
Showing 15 changed files with 451 additions and 130 deletions.
1 change: 1 addition & 0 deletions .github/CODEOWNERS
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
* @yext/clippy
1 change: 1 addition & 0 deletions apps/test-site/.sample.env
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
REACT_APP_BOT_API_KEY=
14 changes: 10 additions & 4 deletions apps/test-site/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,25 +8,25 @@ import { useCallback, useEffect, useState } from "react";

const config: ChatConfig = {
botId: "red-dog-bot",
apiKey: "API_KEY_HERE",
apiKey: process.env.REACT_APP_BOT_API_KEY || "BOT_KEY_HERE",
apiDomain: "liveapi-dev.yext.com",
};

function App() {
return (
<div className="App">
<ChatHeadlessProvider config={config}>
<MyComponent />
<ChatComponent />
</ChatHeadlessProvider>
</div>
);
}

function MyComponent(): JSX.Element {
function ChatComponent() {
const isLoading = useChatState((s) => s.conversation.isLoading);
const messages = useChatState((s) => s.conversation.messages);
const actions = useChatActions();
const [input, setInput] = useState("");
const actions = useChatActions();

useEffect(() => {
if (messages.length === 0) {
Expand All @@ -39,6 +39,11 @@ function MyComponent(): JSX.Element {
setInput("");
}, [actions, input]);

const onClickStream = useCallback(() => {
actions.streamNextMessage(input);
setInput("");
}, [actions, input]);

const onInputChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
setInput(e.target.value);
Expand All @@ -54,6 +59,7 @@ function MyComponent(): JSX.Element {
{isLoading && <p>loading...</p>}
<input type="text" value={input} onChange={onInputChange} />
<button onClick={onClick}>Send</button>
<button onClick={onClickStream}>Send (Stream)</button>
</div>
);
}
Expand Down
10 changes: 9 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion packages/chat-headless/THIRD-PARTY-NOTICES
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ SOFTWARE.

The following npm package may be included in this product:

- @yext/chat-core@0.2.0
- @yext/chat-core@0.3.0

This package contains the following license and notice below:

Expand Down
1 change: 1 addition & 0 deletions packages/chat-headless/docs/chat-headless.chatheadless.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,4 +36,5 @@ export declare class ChatHeadless
| [setMessageNotes(notes)](./chat-headless.chatheadless.setmessagenotes.md) | | Sets [ConversationState.notes](./chat-headless.conversationstate.notes.md) to the specified notes |
| [setMessages(messages)](./chat-headless.chatheadless.setmessages.md) | | Sets [ConversationState.messages](./chat-headless.conversationstate.messages.md) to the specified messages |
| [setState(state)](./chat-headless.chatheadless.setstate.md) | | Sets the [State](./chat-headless.state.md) to the specified state. |
| [streamNextMessage(text, source)](./chat-headless.chatheadless.streamnextmessage.md) | | Performs a Chat Stream API request for the next message generated by chat bot using the conversation state (e.g. message history and notes). The new message's "text" field is continously updated as tokens from the stream are consumed. Remaining conversation state are updated once the final event from the stream is recieved. |

Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->

[Home](./index.md) &gt; [@yext/chat-headless](./chat-headless.md) &gt; [ChatHeadless](./chat-headless.chatheadless.md) &gt; [streamNextMessage](./chat-headless.chatheadless.streamnextmessage.md)

## ChatHeadless.streamNextMessage() method

Performs a Chat Stream API request for the next message generated by chat bot using the conversation state (e.g. message history and notes). The new message's "text" field is continously updated as tokens from the stream are consumed. Remaining conversation state are updated once the final event from the stream is recieved.

**Signature:**

```typescript
streamNextMessage(text?: string, source?: MessageSource): Promise<MessageResponse>;
```

## Parameters

| Parameter | Type | Description |
| --- | --- | --- |
| text | string | _(Optional)_ the text of the next message |
| source | MessageSource | _(Optional)_ the source of the message |

**Returns:**

Promise&lt;MessageResponse&gt;

a Promise of the full response from the Chat Stream API

## Remarks

If rejected, an Error is returned.

25 changes: 25 additions & 0 deletions packages/chat-headless/etc/chat-headless.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,20 @@
```ts

import { ChatConfig } from '@yext/chat-core';
import { EndEvent } from '@yext/chat-core';
import { Message } from '@yext/chat-core';
import { MessageNotes } from '@yext/chat-core';
import { MessageRequest } from '@yext/chat-core';
import { MessageResponse } from '@yext/chat-core';
import { MessageSource } from '@yext/chat-core';
import { RawResponse } from '@yext/chat-core';
import { StartEvent } from '@yext/chat-core';
import { Store } from '@reduxjs/toolkit';
import { StreamEvent } from '@yext/chat-core';
import { StreamEventCallback } from '@yext/chat-core';
import { StreamEventName } from '@yext/chat-core';
import { StreamResponse } from '@yext/chat-core';
import { TokenStreamEvent } from '@yext/chat-core';
import { Unsubscribe } from '@reduxjs/toolkit';

export { ChatConfig }
Expand All @@ -29,6 +37,7 @@ export class ChatHeadless {
get state(): State;
// @internal
get store(): Store;
streamNextMessage(text?: string, source?: MessageSource): Promise<MessageResponse>;
}

// @public
Expand All @@ -39,6 +48,8 @@ export interface ConversationState {
notes?: MessageNotes;
}

export { EndEvent }

export { Message }

export { MessageNotes }
Expand All @@ -54,6 +65,10 @@ export interface MetaState {
context?: any;
}

export { RawResponse }

export { StartEvent }

// @public
export interface State {
conversation: ConversationState;
Expand All @@ -66,6 +81,16 @@ export interface StateListener<T> {
valueAccessor(state: State): T;
}

export { StreamEvent }

export { StreamEventCallback }

export { StreamEventName }

export { StreamResponse }

export { TokenStreamEvent }

// (No @packageDocumentation comment for this package)

```
5 changes: 3 additions & 2 deletions packages/chat-headless/jest.config.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,10 @@
"moduleFileExtensions": ["js", "ts"],
"moduleDirectories": ["node_modules", "<rootDir>"],
"testEnvironment": "jsdom",
"testPathIgnorePatterns": ["./tests/mocks/*"],
"testPathIgnorePatterns": ["./tests/mocks/*", "./tests/jest-setup.js"],
"resetMocks": true,
"restoreMocks": true,
"clearMocks": true,
"testMatch": ["<rootDir>/tests/**/*.[jt]s"]
"testMatch": ["<rootDir>/tests/**/*.[jt]s"],
"setupFiles": ["<rootDir>/tests/jest-setup.js"]
}
3 changes: 2 additions & 1 deletion packages/chat-headless/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
"main": "./dist/commonjs/index.js",
"module": "./dist/esm/index.js",
"types": "./dist/esm/index.d.ts",
"sideEffects": false,
"keywords": [
"chat",
"yext",
Expand Down Expand Up @@ -38,7 +39,7 @@
"homepage": "https://github.com/yext/chat-headless#readme",
"dependencies": {
"@reduxjs/toolkit": "^1.9.5",
"@yext/chat-core": "^0.2.0"
"@yext/chat-core": "^0.3.0"
},
"devDependencies": {
"@babel/preset-env": "^7.21.5",
Expand Down
114 changes: 103 additions & 11 deletions packages/chat-headless/src/ChatHeadless.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
MessageNotes,
MessageResponse,
MessageSource,
StreamEventName,
} from "@yext/chat-core";
import { State } from "./models/state";
import { ReduxStateManager } from "./ReduxStateManager";
Expand Down Expand Up @@ -189,6 +190,105 @@ export class ChatHeadless {
async getNextMessage(
text?: string,
source: MessageSource = MessageSource.USER
): Promise<MessageResponse> {
return this.nextMessageHandler(
async () => {
const { messages, conversationId, notes } = this.state.conversation;
const nextMessage = await this.chatCore.getNextMessage({
conversationId,
messages,
notes,
context: this.state.meta.context,
});
this.setConversationId(nextMessage.conversationId);
this.setMessages([...messages, nextMessage.message]);
this.setMessageNotes(nextMessage.notes);
return nextMessage;
},
text,
source
);
}

/**
* Performs a Chat Stream API request for the next message generated
* by chat bot using the conversation state (e.g. message history and notes).
* The new message's "text" field is continously updated as tokens from the
* stream are consumed. Remaining conversation state are updated once the
* final event from the stream is recieved.
*
* @public
*
* @remarks
* If rejected, an Error is returned.
*
* @param text - the text of the next message
* @param source - the source of the message
* @returns a Promise of the full response from the Chat Stream API
*/
async streamNextMessage(
text?: string,
source: MessageSource = MessageSource.USER
): Promise<MessageResponse> {
return this.nextMessageHandler(
async () => {
let messageResponse: MessageResponse | undefined = undefined;
let nextMessage: Message = {
source: MessageSource.BOT,
text: "",
};
const { messages, conversationId, notes } = this.state.conversation;
const stream = await this.chatCore.streamNextMessage({
conversationId,
messages,
notes,
context: this.state.meta.context,
});
stream.addEventListener(StreamEventName.StartEvent, ({ data }) => {
this.setMessageNotes(data);
});
stream.addEventListener(
StreamEventName.TokenStreamEvent,
({ data }) => {
nextMessage = {
...nextMessage,
text: nextMessage.text + data.token,
};
this.setMessages([...messages, nextMessage]);
}
);
stream.addEventListener(StreamEventName.EndEvent, ({ data }) => {
this.setConversationId(data.conversationId);
this.setMessages([...messages, data.message]);
messageResponse = data;
});
await stream.consume();
if (!messageResponse) {
return Promise.reject(
"Stream Error: Missing full message response at the end of stream."
);
}
return messageResponse;
},
text,
source
);
}

/**
* Setup relevant state before hitting Chat API endpoint for next message, such as
* setting loading status and appending new user's message in conversation state.
* Also update loading state when the next message is received or an error occurred.
*
* @param nextMessageFn - function to invoke to get next message
* @param text - the text of the next message
* @param source - the source of the message
* @returns a Promise of a response from the Chat API
*/
private async nextMessageHandler(
nextMessageFn: () => Promise<MessageResponse>,
text?: string,
source: MessageSource = MessageSource.USER
): Promise<MessageResponse> {
this.setChatLoadingStatus(true);
let messages: Message[] = this.state.conversation.messages;
Expand All @@ -203,22 +303,14 @@ export class ChatHeadless {
];
this.setMessages(messages);
}
let nextMessage: MessageResponse;
let messageResponse;
try {
nextMessage = await this.chatCore.getNextMessage({
conversationId: this.state.conversation.conversationId,
messages,
notes: this.state.conversation.notes,
context: this.state.meta.context,
});
messageResponse = await nextMessageFn();
} catch (e) {
this.setChatLoadingStatus(false);
return Promise.reject(e as Error);
}
this.setConversationId(nextMessage.conversationId);
this.setChatLoadingStatus(false);
this.setMessages([...messages, nextMessage.message]);
this.setMessageNotes(nextMessage.notes);
return nextMessage;
return messageResponse;
}
}
8 changes: 8 additions & 0 deletions packages/chat-headless/src/corereexports.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,12 @@ export {
MessageSource,
MessageRequest,
MessageResponse,
StreamEventName,
StreamEvent,
StartEvent,
TokenStreamEvent,
EndEvent,
StreamResponse,
RawResponse,
StreamEventCallback,
} from "@yext/chat-core";
Loading

0 comments on commit 342c361

Please sign in to comment.