Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #1238 from botpress/sp_emulator
feat(studio): New chat Emulator
- Loading branch information
Showing
13 changed files
with
1,296 additions
and
532 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,44 @@ | ||
import React from 'react' | ||
import Dock from 'react-dock' | ||
import { HotKeys } from 'react-hotkeys' | ||
import Emulator from './Emulator' | ||
import { keyMap } from '~/keyboardShortcuts' | ||
|
||
import style from './Dock.styl' | ||
|
||
export default class EmulatorDock extends React.Component { | ||
state = { | ||
size: 500 | ||
} | ||
|
||
handleSizeChange = e => this.setState({ size: Math.min(Math.max(100, e), 1000) }) // [100, 1000] px | ||
|
||
keyHandlers = { | ||
cancel: () => this.props.onToggle() | ||
} | ||
|
||
render() { | ||
return ( | ||
<Dock | ||
position="right" | ||
isVisible={this.props.isOpen} | ||
fluid={false} | ||
zIndex={5} | ||
dimMode="none" | ||
duration={0} | ||
size={this.state.size} | ||
dockStyle={{ transition: 'none' }} | ||
onSizeChange={this.handleSizeChange} | ||
> | ||
<HotKeys keyMap={keyMap} handlers={this.keyHandlers}> | ||
<div className={style.container} tabIndex={-1}> | ||
<div className={style.titleBar} onClick={this.props.onToggle}> | ||
Chat Emulator | ||
</div> | ||
<Emulator isDockOpen={this.props.isOpen} /> | ||
</div> | ||
</HotKeys> | ||
</Dock> | ||
) | ||
} | ||
} |
33 changes: 33 additions & 0 deletions
33
src/bp/ui-studio/src/web/components/ChatEmulator/Dock.styl
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,33 @@ | ||
.container { | ||
padding: 50px 0 24px 0; | ||
height: 100%; | ||
position: absolute; | ||
width: 100%; | ||
|
||
&:focus-within { | ||
.titleBar { | ||
background-color: var(--c-brand); | ||
|
||
&:hover { | ||
background-color: var(--c-brand--dark-1); | ||
} | ||
} | ||
} | ||
|
||
.titleBar { | ||
height: 24px; | ||
background-color: var(--c-background--dark-1); | ||
width: 100%; | ||
color: var(--c-text--light); | ||
line-height: 24px; | ||
font-size: 14px; | ||
font-weight: bold; | ||
padding-left: 10px; | ||
user-select: none; | ||
cursor: pointer; | ||
|
||
&:hover { | ||
background-color: var(--c-background--dark-2); | ||
} | ||
} | ||
} |
226 changes: 226 additions & 0 deletions
226
src/bp/ui-studio/src/web/components/ChatEmulator/Emulator.jsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,226 @@ | ||
import React from 'react' | ||
import axios from 'axios' | ||
import Promise from 'bluebird' | ||
import SplitPane from 'react-split-pane' | ||
import JSONTree from 'react-json-tree' | ||
import _ from 'lodash' | ||
import nanoid from 'nanoid' | ||
import { Button } from 'react-bootstrap' | ||
|
||
import { Glyphicon } from 'react-bootstrap' | ||
|
||
import classnames from 'classnames' | ||
|
||
import inspectorTheme from './inspectorTheme' | ||
import Message from './Message' | ||
|
||
import style from './Emulator.styl' | ||
|
||
const USER_ID_KEY = `bp::${window.BOT_ID}::emulator::userId` | ||
const SENT_HISTORY_KEY = `bp::${window.BOT_ID}::emulator::sentHistory` | ||
const SENT_HISTORY_SIZE = 20 | ||
|
||
export default class EmulatorChat extends React.Component { | ||
constructor(props) { | ||
super(props) | ||
this.textInputRef = React.createRef() | ||
this.endOfMessagesRef = React.createRef() | ||
} | ||
|
||
state = { | ||
textInputValue: '', | ||
sending: false, | ||
messages: [], | ||
userId: this.getOrCreateUserId(), | ||
sentHistory: JSON.parse(localStorage.getItem(SENT_HISTORY_KEY) || '[]'), | ||
sentHistoryIndex: 0 | ||
} | ||
|
||
getOrCreateUserId(forceNew = false) { | ||
if (!forceNew && localStorage.getItem(USER_ID_KEY)) { | ||
return localStorage.getItem(USER_ID_KEY) | ||
} | ||
|
||
const userId = 'emulator_' + nanoid(7) | ||
localStorage.setItem(USER_ID_KEY, userId) | ||
return userId | ||
} | ||
|
||
componentDidUpdate(prevProps) { | ||
if (!prevProps.isDockOpen && this.props.isDockOpen) { | ||
this.textInputRef.current.focus() | ||
} | ||
} | ||
|
||
navigateSentHistory(step) { | ||
if (!this.state.sentHistory.length) { | ||
return | ||
} | ||
|
||
let newIndex = this.state.sentHistoryIndex + step | ||
|
||
if (newIndex < 0) { | ||
newIndex = this.state.sentHistory.length - 1 | ||
} else if (newIndex >= this.state.sentHistory.length) { | ||
newIndex = 0 | ||
} | ||
|
||
this.setState({ | ||
textInputValue: this.state.sentHistory[newIndex], | ||
sentHistoryIndex: newIndex | ||
}) | ||
} | ||
|
||
sendText = async () => { | ||
if (!this.state.textInputValue.length) { | ||
return | ||
} | ||
|
||
const text = this.state.textInputValue | ||
|
||
// Wait for state to be set fully to prevent race conditions | ||
await Promise.fromCallback(cb => this.setState({ textInputValue: '', sending: true }, cb)) | ||
|
||
const sentAt = Date.now() | ||
const res = await axios.post( | ||
`${window.BOT_API_PATH}/converse/${this.state.userId}`, | ||
{ text }, | ||
{ params: { include: 'nlu,state' } } | ||
) | ||
const duration = Date.now() - sentAt | ||
|
||
const msg = { duration, sent: text, result: res.data } | ||
|
||
// Only append to history if it's not the same as last one | ||
let newSentHistory = this.state.newSentHistory | ||
if (_.last(newSentHistory) !== text) { | ||
newSentHistory = [...this.state.sentHistory, text] | ||
} | ||
|
||
this.setState( | ||
{ | ||
messages: [...this.state.messages, msg], | ||
sending: false, | ||
selectedIndex: this.state.messages.length, | ||
sentHistory: newSentHistory, | ||
sentHistoryIndex: 0 | ||
}, | ||
() => localStorage.setItem(SENT_HISTORY_KEY, JSON.stringify(_.take(this.state.sentHistory, SENT_HISTORY_SIZE))) | ||
) | ||
|
||
this.endOfMessagesRef.current.scrollIntoView(false) | ||
} | ||
|
||
handleKeyPress = e => { | ||
if (!e.shiftKey && e.key === 'Enter') { | ||
this.sendText() | ||
e.preventDefault() | ||
} | ||
} | ||
|
||
handleKeyDown = e => { | ||
const maps = { ArrowUp: -1, ArrowDown: 1 } | ||
if (!e.shiftKey && e.key in maps) { | ||
e.preventDefault() | ||
this.navigateSentHistory(maps[e.key]) | ||
} | ||
} | ||
|
||
handleMsgChange = e => !this.state.sending && this.setState({ textInputValue: e.target.value }) | ||
|
||
get inspectorData() { | ||
return this.state.selectedIndex >= 0 ? this.state.messages[this.state.selectedIndex].result : {} | ||
} | ||
|
||
handleInspectorShouldExpand(key, data, level) { | ||
return level <= 1 | ||
} | ||
|
||
handleChangeUserId = () => { | ||
this.setState( | ||
{ | ||
messages: [], | ||
selectedIndex: -1, | ||
userId: this.getOrCreateUserId(true) | ||
}, | ||
() => this.textInputRef.current.focus() | ||
) | ||
} | ||
|
||
renderHistory() { | ||
return ( | ||
<div className={style.history}> | ||
{this.state.messages.map((msg, idx) => ( | ||
<Message | ||
tabIndex={this.state.messages.length - idx + 1} | ||
key={`msg-${idx}`} | ||
onFocus={() => this.setState({ selectedIndex: idx })} | ||
selected={this.state.selectedIndex === idx} | ||
message={msg} | ||
/> | ||
))} | ||
{/* This is used to loop using Tab, we're going back to text input */} | ||
{/* Also used to scroll to the end of the messages */} | ||
<div | ||
ref={this.endOfMessagesRef} | ||
tabIndex={this.state.messages.length + 1} | ||
onFocus={() => this.textInputRef.current.focus()} | ||
/> | ||
</div> | ||
) | ||
} | ||
|
||
renderInspector() { | ||
return ( | ||
<div className={style.inspector}> | ||
<JSONTree | ||
data={this.inspectorData} | ||
theme={inspectorTheme} | ||
invertTheme={false} | ||
hideRoot={true} | ||
shouldExpandNode={this.handleInspectorShouldExpand} | ||
/> | ||
</div> | ||
) | ||
} | ||
|
||
renderMessageInput() { | ||
return ( | ||
<textarea | ||
tabIndex={1} | ||
ref={this.textInputRef} | ||
className={classnames(style.msgInput, { [style.disabled]: this.state.sending })} | ||
type="text" | ||
onKeyPress={this.handleKeyPress} | ||
onKeyDown={this.handleKeyDown} | ||
value={this.state.textInputValue} | ||
placeholder="Type a message here" | ||
onChange={this.handleMsgChange} | ||
/> | ||
) | ||
} | ||
|
||
render() { | ||
return ( | ||
<div className={style.container}> | ||
<div className={style.toolbar}> | ||
<Button onClick={this.handleChangeUserId}> | ||
<Glyphicon glyph="refresh" /> New session | ||
</Button> | ||
</div> | ||
<div className={style.panes}> | ||
<SplitPane | ||
split="horizontal" | ||
minSize={50} | ||
defaultSize={'75%'} | ||
pane2Style={{ 'overflow-y': 'auto', backgroundColor: 'var(--c-background--dark-1)' }} | ||
> | ||
{this.renderHistory()} | ||
{this.renderInspector()} | ||
</SplitPane> | ||
</div> | ||
{this.renderMessageInput()} | ||
</div> | ||
) | ||
} | ||
} |
Oops, something went wrong.