Merge pull request #4695 from pnp/react-azure-openai-api-stream_updates

This commit is contained in:
Hugo Bernier 2024-02-10 13:24:12 -05:00 committed by GitHub
commit e550779692
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 4771 additions and 167 deletions

View File

@ -1,10 +1,10 @@
# Calling Azure OpenAI API in Stream mode # Calling Azure OpenAI API in Streaming Mode
## Summary ## Summary
This web part shows how you can call Azure OpenAI API in Streaming mode, so the web part shows the data coming from the API in chunks, giving a much better user experience, so you are not waiting for the entire response. It also shows how you can cancel the streaming response at any point, which is useful to safe some tokens (hence money), if the generating response does not look good to you (like when getting AI hallucinations). This web part shows how you can call Azure OpenAI API in Streaming mode. The web part will show the data coming from the API in chunks, giving a much better user experience, so you are not waiting for the entire response. It also shows how you can cancel the streaming response at any point, which is useful to save some tokens (hence money), if the generating response does not look good to you (like when getting AI hallucinations). AI responses render Markdown but there is a toggle to disable so you can compare Markdown rendering of responses with Streaming and without.
![./assets/react-azure-openai-api-stream.gif](./assets/react-azure-openai-api-stream.gif) ![Sample in action](./assets/screenshot.gif)
## Compatibility ## Compatibility
@ -44,13 +44,15 @@ This sample is optimally compatible with the following environment configuration
## Contributors ## Contributors
- [Luis Mañez](https://github.com/luismanez) | - [Luis Mañez](https://github.com/luismanez)
- [Chris Kent](https://twitter.com/thechriskent)
## Version history ## Version history
| Version | Date | Comments | | Version | Date | Comments |
| ------- | ---------------- | --------------- | | ------- | ---------------- | --------------- |
| 1.0 | January 2, 2024 | Initial release | | 1.0 | January 2, 2024 | Initial release |
| 1.1 | February 8, 2024 | Theme enhancements & markdown support |
## Minimal Path to Awesome ## Minimal Path to Awesome
@ -73,10 +75,7 @@ This sample illustrates the following concepts:
- How to cancel an streaming request - How to cancel an streaming request
- Using MS Graph toolkit with the Person component - Using MS Graph toolkit with the Person component
- Multiple FluentUI components - Multiple FluentUI components
- Markdown rendering of AI responses
> Notice that better pictures and documentation will increase the sample usage and the value you are providing for others. Thanks for your submissions advance.
> Share your web part with others through Microsoft 365 Patterns and Practices program to get visibility and exposure. More details on the community, open-source projects and other activities from http://aka.ms/m365pnp.
## References ## References
@ -85,3 +84,26 @@ This sample illustrates the following concepts:
- [Use Microsoft Graph in your solution](https://docs.microsoft.com/en-us/sharepoint/dev/spfx/web-parts/get-started/using-microsoft-graph-apis) - [Use Microsoft Graph in your solution](https://docs.microsoft.com/en-us/sharepoint/dev/spfx/web-parts/get-started/using-microsoft-graph-apis)
- [Publish SharePoint Framework applications to the Marketplace](https://docs.microsoft.com/en-us/sharepoint/dev/spfx/publish-to-marketplace-overview) - [Publish SharePoint Framework applications to the Marketplace](https://docs.microsoft.com/en-us/sharepoint/dev/spfx/publish-to-marketplace-overview)
- [Microsoft 365 Patterns and Practices](https://aka.ms/m365pnp) - Guidance, tooling, samples and open-source controls for your Microsoft 365 development - [Microsoft 365 Patterns and Practices](https://aka.ms/m365pnp) - Guidance, tooling, samples and open-source controls for your Microsoft 365 development
## Help
We do not support samples, but this community is always willing to help, and we want to improve these samples. We use GitHub to track issues, which makes it easy for community members to volunteer their time and help resolve issues.
If you're having issues building the solution, please run [spfx doctor](https://pnp.github.io/cli-microsoft365/cmd/spfx/spfx-doctor/) from within the solution folder to diagnose incompatibility issues with your environment.
You can try looking at [issues related to this sample](https://github.com/pnp/sp-dev-fx-webparts/issues?q=label%3A%22sample%3A%20react-azure-openai-api-stream%22) to see if anybody else is having the same issues.
You can also try looking at [discussions related to this sample](https://github.com/pnp/sp-dev-fx-webparts/discussions?discussions_q=react-azure-openai-api-stream) and see what the community is saying.
If you encounter any issues using this sample, [create a new issue](https://github.com/pnp/sp-dev-fx-webparts/issues/new?assignees=&labels=Needs%3A+Triage+%3Amag%3A%2Ctype%3Abug-suspected%2Csample%3A%20react-azure-openai-api-stream&template=bug-report.yml&sample=react-azure-openai-api-stream&authors=@luismanez%20@thechriskent&title=react-azure-openai-api-stream%20-%20).
For questions regarding this sample, [create a new question](https://github.com/pnp/sp-dev-fx-webparts/issues/new?assignees=&labels=Needs%3A+Triage+%3Amag%3A%2Ctype%3Aquestion%2Csample%3A%20react-azure-openai-api-stream&template=question.yml&sample=react-azure-openai-api-stream&authors=@luismanez%20@thechriskent&title=react-azure-openai-api-stream%20-%20).
Finally, if you have an idea for improvement, [make a suggestion](https://github.com/pnp/sp-dev-fx-webparts/issues/new?assignees=&labels=Needs%3A+Triage+%3Amag%3A%2Ctype%3Aenhancement%2Csample%3A%20react-azure-openai-api-stream&template=suggestion.yml&sample=react-azure-openai-api-stream&authors=@luismanez%20@thechriskent&title=react-azure-openai-api-stream%20-%20).
## Disclaimer
**THIS CODE IS PROVIDED _AS IS_ WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESS OR IMPLIED, INCLUDING ANY IMPLIED WARRANTIES OF FITNESS FOR A PARTICULAR PURPOSE, MERCHANTABILITY, OR NON-INFRINGEMENT.**
<img src="https://m365-visitor-stats.azurewebsites.net/sp-dev-fx-webparts/samples/react-azure-openai-api-stream" />

View File

@ -2,15 +2,15 @@
{ {
"name": "pnp-sp-dev-spfx-web-parts-react-azure-openai-api-stream", "name": "pnp-sp-dev-spfx-web-parts-react-azure-openai-api-stream",
"source": "pnp", "source": "pnp",
"title": "Calling Azure OpenAI API in stream mode", "title": "Calling Azure OpenAI API in streaming mode",
"shortDescription": "This web part shows how you can call Azure OpenAI API in Streaming mode", "shortDescription": "This web part shows how you can call Azure OpenAI API in Streaming mode",
"url": "https://github.com/pnp/sp-dev-fx-webparts/tree/main/samples/react-azure-openai-api-stream", "url": "https://github.com/pnp/sp-dev-fx-webparts/tree/main/samples/react-azure-openai-api-stream",
"downloadUrl": "https://pnp.github.io/download-partial/?url=https://github.com/pnp/sp-dev-fx-webparts/tree/main/samples/react-azure-openai-api-stream", "downloadUrl": "https://pnp.github.io/download-partial/?url=https://github.com/pnp/sp-dev-fx-webparts/tree/main/samples/react-azure-openai-api-stream",
"longDescription": [ "longDescription": [
"This web part shows how you can call Azure OpenAI API in Streaming mode, so the web part shows the data coming from the API in chunks, giving a much better user experience, so you are not waiting for the entire response. It also shows how you can cancel the streaming response at any point, which is useful to safe some tokens (hence money), if the generating response does not look good to you (like when getting AI hallucinations)." "This web part shows how you can call Azure OpenAI API in Streaming mode. The web part will show the data coming from the API in chunks, giving a much better user experience, so you are not waiting for the entire response. It also shows how you can cancel the streaming response at any point, which is useful to save some tokens (hence money), if the generating response does not look good to you (like when getting AI hallucinations). AI responses render Markdown but there is a toggle to disable so you can compare Markdown rendering of responses with Streaming and without."
], ],
"creationDateTime": "2024-01-02", "creationDateTime": "2024-01-02",
"updateDateTime": "2024-01-02", "updateDateTime": "2024-02-08",
"products": [ "products": [
"SharePoint" "SharePoint"
], ],
@ -28,8 +28,14 @@
{ {
"type": "image", "type": "image",
"order": 100, "order": 100,
"url": "https://github.com/pnp/sp-dev-fx-webparts/blob/1c6a0a60236cd8637c880406b8a30fec66b798ba/samples/react-azure-openai-api-stream/assets/react-azure-openai-api-stream.gif", "url": "https://github.com/pnp/sp-dev-fx-webparts/blob/main/samples/react-azure-openai-api-stream/assets/screenshot.gif",
"alt": "Web Part Preview" "alt": "Sample in action"
},
{
"type": "image",
"order": 101,
"url": "https://github.com/pnp/sp-dev-fx-webparts/blob/main/samples/react-azure-openai-api-stream/assets/screenshot.png",
"alt": "Screenshot"
} }
], ],
"authors": [ "authors": [
@ -37,6 +43,11 @@
"gitHubAccount": "luismanez", "gitHubAccount": "luismanez",
"pictureUrl": "https://github.com/luismanez.png", "pictureUrl": "https://github.com/luismanez.png",
"name": "Luis Mañez" "name": "Luis Mañez"
},
{
"gitHubAccount": "thechriskent",
"pictureUrl": "https://github.com/thechriskent.png",
"name": "Chris Kent"
} }
], ],
"references": [ "references": [

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

View File

@ -13,4 +13,10 @@ build.rig.getTasks = function () {
return result; return result;
}; };
/* fast-serve */
const { addFastServe } = require("spfx-fast-serve-helpers");
addFastServe(build);
/* end of fast-serve */
build.initialize(require('gulp')); build.initialize(require('gulp'));

File diff suppressed because it is too large Load Diff

View File

@ -9,7 +9,8 @@
"scripts": { "scripts": {
"build": "gulp bundle", "build": "gulp bundle",
"clean": "gulp clean", "clean": "gulp clean",
"test": "gulp test" "test": "gulp test",
"serve": "fast-serve"
}, },
"dependencies": { "dependencies": {
"@fluentui/react": "^8.106.4", "@fluentui/react": "^8.106.4",
@ -25,6 +26,8 @@
"@pnp/spfx-property-controls": "3.15.1", "@pnp/spfx-property-controls": "3.15.1",
"react": "17.0.1", "react": "17.0.1",
"react-dom": "17.0.1", "react-dom": "17.0.1",
"react-remark": "^2.1.0",
"remark-gfm": "1.0.0",
"tslib": "2.3.1" "tslib": "2.3.1"
}, },
"devDependencies": { "devDependencies": {
@ -41,6 +44,7 @@
"eslint": "8.7.0", "eslint": "8.7.0",
"eslint-plugin-react-hooks": "4.3.0", "eslint-plugin-react-hooks": "4.3.0",
"gulp": "4.0.2", "gulp": "4.0.2",
"spfx-fast-serve-helpers": "~1.18.0",
"typescript": "4.7.4" "typescript": "4.7.4"
} }
} }

View File

@ -16,7 +16,7 @@
"group": { "default": "Advanced" }, "group": { "default": "Advanced" },
"title": { "default": "OpenAi Chat in streaming" }, "title": { "default": "OpenAi Chat in streaming" },
"description": { "default": "This webpart provides an OpenAI chat in streaming mode." }, "description": { "default": "This webpart provides an OpenAI chat in streaming mode." },
"officeFabricIconFontName": "Page", "officeFabricIconFontName": "OfficeChat",
"properties": { "properties": {
"openAiApiKey": "", "openAiApiKey": "",
"openAiApiEndpoint": "", "openAiApiEndpoint": "",

View File

@ -1,9 +1,14 @@
import { Stack } from "@fluentui/react"; import { Stack } from "@fluentui/react";
import { Icon } from '@fluentui/react'; import { Icon } from '@fluentui/react';
import * as React from "react"; import * as React from "react";
import styles from "./ChatStreaming.module.scss";
import MarkdownContent from "./MarkdownContent";
import ThinkingIndicator from "./ThinkingIndicator";
export interface IAssistantResponseProps { export interface IAssistantResponseProps {
message: string; message: string;
disableMarkdown?: boolean;
thinking?: boolean;
} }
export default class AssistantResponse extends React.Component< export default class AssistantResponse extends React.Component<
@ -12,46 +17,22 @@ export default class AssistantResponse extends React.Component<
> { > {
public render(): React.ReactElement<IAssistantResponseProps> { public render(): React.ReactElement<IAssistantResponseProps> {
return ( return (
<Stack horizontal tokens={{ childrenGap: 30, padding: 10 }}> <Stack horizontal className={styles.assistantResponse}>
<Stack.Item> <div className={styles.avatar}>
<Icon iconName="Robot" styles={{ root: { fontSize: '22px' } }} /> <Icon iconName="Robot" />
</Stack.Item> </div>
<Stack.Item <div className={styles.messageBox}>
grow {this.props.thinking && this.props.message.length === 0 &&
styles={{ root: { display: "flex", justifyContent: "flex-start" } }} <ThinkingIndicator />
> }
<div {this.props.disableMarkdown && this.props.message.length > 0 &&
style={{ <p className={styles.message}>{this.props.message}</p>
position: "relative", }
borderRadius: "5px", {!this.props.disableMarkdown && this.props.message.length > 0 &&
padding: "5px", <MarkdownContent className={styles.message}>{this.props.message}</MarkdownContent>
backgroundColor: "white", }
fontFamily: <div className={styles.beak}/>
'"Segoe UI", "Segoe UI Web (West European)", "Segoe UI", -apple-system, BlinkMacSystemFont, Roboto, "Helvetica Neue", sans-serif;', </div>
fontSize: "14px",
fontWeight: "400",
boxShadow: "0px 4px 8px rgba(0, 0, 0, 0.1)",
maxWidth: "85%",
minWidth: "350px"
}}
>
<p style={{ minWidth: '100px' }}>{this.props.message}</p>
<div
style={{
content: '""',
position: "absolute",
left: "-10px",
top: "10px", // changed right to left
width: "0",
height: "0",
borderTop: "10px solid transparent",
borderBottom: "10px solid transparent",
borderRight: "10px solid white", // changed borderLeft to borderRight
}}
/>
</div>
</Stack.Item>
</Stack> </Stack>
); );
} }

View File

@ -4,30 +4,220 @@
overflow: hidden; overflow: hidden;
color: "[theme:bodyText, default: #323130]"; color: "[theme:bodyText, default: #323130]";
color: var(--bodyText); color: var(--bodyText);
min-height: 100%;
gap: 20px;
&.teams { &.teams {
font-family: $ms-font-family-fallbacks; font-family: $ms-font-family-fallbacks;
} }
}
.welcome { .messagesContainer {
text-align: left; min-height: 200px;
} height: 100%;
position: relative;
.welcomeImage {
width: 100%;
max-width: 420px;
}
.links {
a {
text-decoration: none;
color: "[theme:link, default:#03787c]";
color: var(--link); // note: CSS Custom Properties support is limited to modern browsers only
&:hover {
text-decoration: underline;
color: "[theme:linkHovered, default: #014446]";
color: var(--linkHovered); // note: CSS Custom Properties support is limited to modern browsers only
}
} }
}
.messagesList {
background-color: "[theme:neutralLighter, default: #f3f2f1]";
min-height: 400px;
border-radius: 8px;
.scrollablePane {
:global {
.ms-ScrollablePane--contentContainer {
padding: 8px;
}
}
}
}
.userQuestion {
padding-bottom: 8px;
gap: 12px;
.avatar {
--person-avatar-size: 32px;
margin-top: 8px;
}
.beak {
right: -15px;
border-left: 15px solid "[theme:white, default: #ffffff]";
margin-right: 5px;
}
}
.assistantResponse {
padding-bottom: 8px;
gap: 12px;
.avatar {
margin-top: 8px;
background-color: "[theme:white, default: #ffffff]";
color: "[theme:neutralPrimary, default: #323130]";
width: 32px;
height: 32px;
border-radius: 50%;
font-size: 22px;
display: flex;
align-items: center;
justify-content: center;
}
.message {
margin: 14px 0;
}
.beak {
left: -15px;
border-right: 15px solid "[theme:white, default: #ffffff]";
margin-left: 5px;
}
}
.messageBox {
position: relative;
border-radius: 8px;
padding: 0 12px;
background-color: "[theme:white, default: #ffffff]";
font-family: "Segoe UI", "Segoe UI Web (West European)", "Segoe UI", -apple-system, BlinkMacSystemFont, Roboto, "Helvetica Neue", sans-serif;
font-size: 14px;
font-weight: 400;
box-shadow: 0px 4px 8px rgba(0, 0, 0, 0.1);
max-width: 85%;
min-width: 350px;
.message {
min-width: 100px;
}
.beak {
content: "";
position: absolute;
top: 9px;
width: 0;
height: 0;
border-top: 15px solid transparent;
border-bottom: 15px solid transparent;
}
}
.userMessage {
gap: 5px;
}
.markdownContent {
table {
border-spacing: 0;
border-collapse: collapse;
display: block;
margin: 14px 0;
width: max-content;
max-width: fit-content;
overflow: auto;
}
tr {
background-color: "[theme: white, default: #ffffff]";
border-top: 1px solid "[theme: neutralTertiaryAlt, default: #c8c6c4]";
}
tr:nth-child(2n) {
background-color: "[theme: neutralLight, default: #edebe9]";
}
td,
th {
padding: 6px 13px;
border: 1px solid "[theme: neutralTertiaryAlt, default: #c8c6c4]";
}
th {
font-weight: 600;
}
table img {
background-color: transparent;
}
pre {
background-color: "[theme: neutralLight, default: #edebe9]";
padding: 8px;
border-radius: 4px;
}
}
$dotSize: 8px;
$jumpHeight: 14px;
.thinkingIndicator {
padding-top: 6px;
.allDots {
margin-top: $jumpHeight;
display: flex;
gap: calc($dotSize / 2);
.dot {
position: relative;
width: $dotSize;
height: $dotSize;
border-radius: 50%;
display: inline-block;
animation: bouncedelay 2.0s infinite cubic-bezier(.62, .28, .23, .99) both;
}
.dot1 {
animation-delay: -.16s;
}
.dot2 {
animation-delay: -.08s;
}
.dot3 {
color: inherit;
}
}
}
@keyframes bouncedelay {
0% {
bottom: 0;
background-color: "[theme: themePrimary, default: #0078d4]";
}
16.66% {
bottom: $jumpHeight;
background-color: "[theme: themeDark, default: #005a9e]";
}
33.33% {
bottom: 0px;
background-color: "[theme: themeDark, default: #005a9e]";
}
50% {
bottom: $jumpHeight;
background-color: "[theme: themeLight, default: #c7e0f4]";
}
66.66% {
bottom: 0px;
background-color: "[theme: themeLight, default: #c7e0f4]";
}
83.33% {
bottom: $jumpHeight;
background-color: "[theme: themePrimary, default: #0078d4]";
}
100% {
bottom: 0;
background-color: "[theme: themePrimary, default: #0078d4]";
}
} }

View File

@ -1,11 +1,11 @@
import * as React from 'react'; import * as React from 'react';
//import styles from './ChatStreaming.module.scss'; import styles from './ChatStreaming.module.scss';
import type { IChatStreamingProps } from './IChatStreamingProps'; import type { IChatStreamingProps } from './IChatStreamingProps';
import { IChatStreamingState } from './IChatStreamingState'; import { IChatStreamingState } from './IChatStreamingState';
import { cloneDeep } from '@microsoft/sp-lodash-subset'; import { cloneDeep } from '@microsoft/sp-lodash-subset';
import { fetchEventSource } from '@microsoft/fetch-event-source'; import { fetchEventSource } from '@microsoft/fetch-event-source';
import CompletionsRequestBuilder from '../models/CompletionsRequestBuilder'; import CompletionsRequestBuilder from '../models/CompletionsRequestBuilder';
import { Spinner, SpinnerSize, Stack } from '@fluentui/react'; import { Stack, css } from '@fluentui/react';
import MessagesList from './MessagesList'; import MessagesList from './MessagesList';
import UserMessage from './UserMessage'; import UserMessage from './UserMessage';
import { IChatMessage } from '../models/IChatMessage'; import { IChatMessage } from '../models/IChatMessage';
@ -21,8 +21,12 @@ export default class ChatStreaming extends React.Component<IChatStreamingProps,
this.state = { this.state = {
userQuery: '', userQuery: '',
sessionMessages: [], sessionMessages: [{
thinking: false role: 'assistant',
text: 'Hello! I am your AI assistant. How can I help you today?'
}],
thinking: false,
disableMarkdown: false,
} }
this._controller = new AbortController(); this._controller = new AbortController();
@ -32,31 +36,18 @@ export default class ChatStreaming extends React.Component<IChatStreamingProps,
public render(): React.ReactElement<IChatStreamingProps> { public render(): React.ReactElement<IChatStreamingProps> {
const content = this._validateWebPartProperties() ? ( const content = this._validateWebPartProperties() ? (
<Stack tokens={{ childrenGap: 20 }} style={{ minHeight: "100%" }}> <Stack className={css(styles.chatStreaming, this.props.hasTeamsContext && styles.teams)}>
<Stack.Item <Stack.Item grow className={styles.messagesContainer}>
grow={1} <MessagesList messages={this.state.sessionMessages} disableMarkdown={this.state.disableMarkdown} thinking={this.state.thinking} />
styles={{
root: { minHeight: "200px", height: "100%", position: "relative" },
}}
>
<MessagesList messages={this.state.sessionMessages} />
</Stack.Item> </Stack.Item>
{this.state.thinking && (
<Stack.Item>
<Spinner
size={SpinnerSize.large}
label="Wait till our super cool AI system answers your question..."
ariaLive="assertive"
labelPosition="right"
/>
</Stack.Item>
)}
<Stack.Item> <Stack.Item>
<UserMessage <UserMessage
textFieldValue={this.state.userQuery} textFieldValue={this.state.userQuery}
onMessageChange={this._onUserQueryChange} onMessageChange={this._onUserQueryChange.bind(this)}
sendQuery={this._onQuerySent} sendQuery={this._onQuerySent.bind(this)}
controller={this._controller} controller={this._controller}
disableMarkdown={this.state.disableMarkdown}
toggleMarkdown={this._toggleMarkdown.bind(this)}
/> />
</Stack.Item> </Stack.Item>
</Stack> </Stack>
@ -78,6 +69,10 @@ export default class ChatStreaming extends React.Component<IChatStreamingProps,
role: 'user', text: this.state.userQuery role: 'user', text: this.state.userQuery
}); });
this.state.sessionMessages.push({
role: 'assistant', text: ''
});
await this._chatAsStream(); await this._chatAsStream();
this.setState({ this.setState({
@ -177,4 +172,10 @@ export default class ChatStreaming extends React.Component<IChatStreamingProps,
return true; return true;
} }
private _toggleMarkdown(): void {
this.setState({
disableMarkdown: !this.state.disableMarkdown
});
}
} }

View File

@ -4,4 +4,5 @@ export interface IChatStreamingState {
userQuery: string; userQuery: string;
sessionMessages: IChatMessage[]; sessionMessages: IChatMessage[];
thinking: boolean; thinking: boolean;
disableMarkdown: boolean;
} }

View File

@ -0,0 +1,23 @@
import { css } from "@fluentui/react";
import * as React from "react";
import styles from "./ChatStreaming.module.scss";
import { Remark } from "react-remark";
import remarkGfm from "remark-gfm";
export interface IMarkdownContentProps {
children: string;
className?: string;
}
export default class MarkdownContent extends React.Component<
IMarkdownContentProps,
{}
> {
public render(): React.ReactElement<IMarkdownContentProps> {
return (
<div className={css(styles.markdownContent, this.props.className)}>
<Remark remarkPlugins={[remarkGfm]}>{this.props.children}</Remark>
</div>
);
}
}

View File

@ -2,11 +2,14 @@ import * as React from "react";
import UserQuestion from "./UserQuestion"; import UserQuestion from "./UserQuestion";
import AssistantResponse from "./AssistantResponse"; import AssistantResponse from "./AssistantResponse";
import { IChatMessage } from "../models/IChatMessage"; import { IChatMessage } from "../models/IChatMessage";
import styles from "./ChatStreaming.module.scss";
import { ScrollablePane, ScrollbarVisibility } from '@fluentui/react'; import { ScrollablePane, ScrollbarVisibility } from '@fluentui/react';
export interface IMessagesListProps { export interface IMessagesListProps {
messages: IChatMessage[]; messages: IChatMessage[];
disableMarkdown?: boolean;
thinking?: boolean;
} }
export default class MessagesList extends React.Component<IMessagesListProps, {}> { export default class MessagesList extends React.Component<IMessagesListProps, {}> {
@ -26,12 +29,12 @@ export default class MessagesList extends React.Component<IMessagesListProps, {}
if (m.role === 'user') { if (m.role === 'user') {
return <UserQuestion key={i} message={m.text} /> return <UserQuestion key={i} message={m.text} />
} }
return <AssistantResponse key={i} message={m.text} /> return <AssistantResponse key={i} message={m.text} disableMarkdown={this.props.disableMarkdown} thinking={this.props.thinking} />
}); });
return ( return (
<div style= {{ backgroundColor: '#f3f3f3', minHeight: '200px' }}> <div className={styles.messagesList}>
<ScrollablePane scrollbarVisibility={ScrollbarVisibility.auto} style={{ padding: '20px' }}> <ScrollablePane scrollbarVisibility={ScrollbarVisibility.auto} className={styles.scrollablePane}>
{output} {output}
</ScrollablePane> </ScrollablePane>
</div> </div>

View File

@ -0,0 +1,22 @@
import * as React from "react";
import styles from "./ChatStreaming.module.scss";
import { css } from "@fluentui/react";
export interface IThinkingIndicatorProps {}
export default class ThinkingIndicator extends React.Component<
IThinkingIndicatorProps,
{}
> {
public render(): React.ReactElement<IThinkingIndicatorProps> {
return (
<div className={styles.thinkingIndicator}>
<div className={styles.allDots}>
<div className={css(styles.dot, styles.dot1)} />
<div className={css(styles.dot, styles.dot2)} />
<div className={css(styles.dot, styles.dot3)} />
</div>
</div>
);
}
}

View File

@ -1,11 +1,14 @@
import { IconButton, Stack, TextField } from '@fluentui/react'; import { IconButton, Stack, TextField } from '@fluentui/react';
import * as React from 'react'; import * as React from 'react';
import styles from './ChatStreaming.module.scss';
export interface IUserMessageProps { export interface IUserMessageProps {
onMessageChange: (query: string) => void; onMessageChange: (query: string) => void;
sendQuery: () => Promise<void>; sendQuery: () => Promise<void>;
controller: AbortController; controller: AbortController;
textFieldValue: string; textFieldValue: string;
disableMarkdown?: boolean;
toggleMarkdown: () => void;
} }
export default class UserMessage extends React.Component<IUserMessageProps, {}> { export default class UserMessage extends React.Component<IUserMessageProps, {}> {
@ -20,34 +23,30 @@ export default class UserMessage extends React.Component<IUserMessageProps, {}>
await this.props.sendQuery(); await this.props.sendQuery();
}; };
private _keyDownHandler = async (e: KeyboardEvent): Promise<void> => {
if (e.ctrlKey && e.code === "Enter") { private _keyDownHandler = async (event: React.KeyboardEvent<HTMLInputElement | HTMLTextAreaElement>): Promise<void> => {
await this._handleClick(); if(!event.shiftKey && event.key === "Enter") {
//Submits message when a user hits return (but still allows newlines for shift+enter)
event.preventDefault();
return this._handleClick();
} }
}; };
public componentDidMount(): void {
window.addEventListener("keydown", this._keyDownHandler);
}
public componentWillUnmount(): void {
window.removeEventListener("keydown", this._keyDownHandler);
}
public render(): React.ReactElement<IUserMessageProps> { public render(): React.ReactElement<IUserMessageProps> {
return ( return (
<Stack horizontal tokens={{ childrenGap: 5 }}> <Stack horizontal className={styles.userMessage}>
<Stack.Item grow={1}> <Stack.Item grow>
<TextField <TextField
multiline multiline
autoAdjustHeight autoAdjustHeight
rows={5}
value={this.props.textFieldValue} value={this.props.textFieldValue}
onChange={this._onChange} onChange={this._onChange}
label="User message" onKeyDown={this._keyDownHandler}
placeholder="Type user query here." placeholder={'Talk to our super cool AI system!\n(Shift + Enter for new line)'}
/> />
</Stack.Item> </Stack.Item>
<Stack.Item align="end"> <Stack verticalAlign='end'>
<IconButton <IconButton
iconProps={{ iconName: "Send" }} iconProps={{ iconName: "Send" }}
title="Send" title="Send"
@ -60,7 +59,14 @@ export default class UserMessage extends React.Component<IUserMessageProps, {}>
ariaLabel="Stop" ariaLabel="Stop"
onClick={() => this.props.controller.abort()} onClick={() => this.props.controller.abort()}
/> />
</Stack.Item> <IconButton
toggle
checked={!this.props.disableMarkdown}
onClick={this.props.toggleMarkdown}
title="Toggle Markdown"
ariaLabel="Markdown"
iconProps={{iconName:'MarkDownLanguage'}}/>
</Stack>
</Stack> </Stack>
); );
} }

View File

@ -2,6 +2,7 @@ import { Stack } from "@fluentui/react";
import { Person } from "@microsoft/mgt-react/dist/es6/spfx"; import { Person } from "@microsoft/mgt-react/dist/es6/spfx";
import { ViewType } from "@microsoft/mgt-spfx"; import { ViewType } from "@microsoft/mgt-spfx";
import * as React from "react"; import * as React from "react";
import styles from "./ChatStreaming.module.scss";
export interface IUserQuestionProps { export interface IUserQuestionProps {
message: string; message: string;
@ -13,45 +14,13 @@ export default class UserQuestion extends React.Component<
> { > {
public render(): React.ReactElement<IUserQuestionProps> { public render(): React.ReactElement<IUserQuestionProps> {
return ( return (
<Stack horizontal horizontalAlign="end" tokens={{ childrenGap: 30, padding: 5 }}> <Stack horizontal horizontalAlign="end" className={styles.userQuestion}>
<Stack.Item <div className={styles.messageBox}>
styles={{ root: { display: "flex", justifyContent: 'flex-end', alignItems: 'flex-end' } }} <p className={styles.message}>{this.props.message}</p>
> <div className={styles.beak} />
<div </div>
style={{ <Person className={styles.avatar} personQuery="me" view={ViewType.image} avatarSize="auto" />
position: "relative",
borderRadius: "5px",
padding: "5px",
backgroundColor: "white",
fontFamily:
'"Segoe UI", "Segoe UI Web (West European)", "Segoe UI", -apple-system, BlinkMacSystemFont, Roboto, "Helvetica Neue", sans-serif;',
fontSize: "14px",
fontWeight: "400",
boxShadow: "0px 4px 8px rgba(0, 0, 0, 0.1)",
maxWidth: "85%",
minWidth: "350px"
}}
>
<p style={{ minWidth: '100px' }}>{this.props.message}</p>
<div
style={{
content: '""',
position: "absolute",
right: "-10px",
top: "10px",
width: "0",
height: "0",
borderTop: "10px solid transparent",
borderBottom: "10px solid transparent",
borderLeft: "10px solid white",
}}
/>
</div>
</Stack.Item>
<Stack.Item>
<Person personQuery="me" view={ViewType.image} avatarSize="auto" />
</Stack.Item>
</Stack> </Stack>
); );
} }
} }

View File

@ -13,6 +13,7 @@
"outDir": "lib", "outDir": "lib",
"inlineSources": false, "inlineSources": false,
"noImplicitAny": true, "noImplicitAny": true,
"allowSyntheticDefaultImports": true,
"typeRoots": [ "typeRoots": [
"./node_modules/@types", "./node_modules/@types",