Skip to content

Commit

Permalink
feat: Add feedback events on chatbot responses #2165 (#2178)
Browse files Browse the repository at this point in the history
  • Loading branch information
marek-mihok authored Nov 8, 2023
1 parent 94bf37f commit 4a7237b
Show file tree
Hide file tree
Showing 11 changed files with 188 additions and 11 deletions.
35 changes: 35 additions & 0 deletions py/examples/chatbot_events_feedback.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# Chatbot / Events/ Feedback
# Use thumbs up/down to provide feedback on the chatbot response.
# #chatbot #events #feedback
# ---
from h2o_wave import main, app, Q, ui, data

@app('/demo')
async def serve(q: Q):
if not q.client.initialized:
q.page['example'] = ui.chatbot_card(
box='1 1 5 5',
data=data(fields='content from_user', t='list'),
name='chatbot',
events=['feedback']
)
q.page['feedback'] = ui.form_card(
box='1 6 5 2',
items=[
ui.text_xl('Feedback'),
ui.text(name='text', content='No feedback yet.'),
]
)
q.client.initialized = True

if q.args.chatbot:
# Append user message.
q.page['example'].data += [q.args.chatbot, True]
# Append bot response.
q.page['example'].data += ['I am a fake chatbot. Sorry, I cannot help you.', False]
# Handle feedback event.
elif q.events.chatbot and q.events.chatbot.feedback:
# Process the feedback.
q.page['feedback'].text.content = f'{q.events.chatbot.feedback}'

await q.page.save()
1 change: 1 addition & 0 deletions py/examples/tour.conf
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ chatbot.py
chatbot_stream.py
chatbot_events_stop.py
chatbot_events_scroll.py
chatbot_events_feedback.py
form.py
form_visibility.py
text.py
Expand Down
2 changes: 1 addition & 1 deletion py/h2o_lightwave/h2o_lightwave/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -8234,7 +8234,7 @@ def __init__(
self.placeholder = placeholder
"""Chat input box placeholder. Use for prompt examples."""
self.events = events
"""The events to capture on this chatbot. One of 'stop'."""
"""The events to capture on this chatbot. One of 'stop' | 'scroll_up' | 'feedback'."""
self.generating = generating
"""True to show a button to stop the text generation. Defaults to False."""
self.commands = commands
Expand Down
2 changes: 1 addition & 1 deletion py/h2o_lightwave/h2o_lightwave/ui.py
Original file line number Diff line number Diff line change
Expand Up @@ -2885,7 +2885,7 @@ def chatbot_card(
name: An identifying name for this component.
data: Chat messages data. Requires cyclic buffer.
placeholder: Chat input box placeholder. Use for prompt examples.
events: The events to capture on this chatbot. One of 'stop'.
events: The events to capture on this chatbot. One of 'stop' | 'scroll_up' | 'feedback'.
generating: True to show a button to stop the text generation. Defaults to False.
commands: Contextual menu commands for this component.
Returns:
Expand Down
2 changes: 1 addition & 1 deletion py/h2o_wave/h2o_wave/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -8234,7 +8234,7 @@ def __init__(
self.placeholder = placeholder
"""Chat input box placeholder. Use for prompt examples."""
self.events = events
"""The events to capture on this chatbot. One of 'stop'."""
"""The events to capture on this chatbot. One of 'stop' | 'scroll_up' | 'feedback'."""
self.generating = generating
"""True to show a button to stop the text generation. Defaults to False."""
self.commands = commands
Expand Down
2 changes: 1 addition & 1 deletion py/h2o_wave/h2o_wave/ui.py
Original file line number Diff line number Diff line change
Expand Up @@ -2885,7 +2885,7 @@ def chatbot_card(
name: An identifying name for this component.
data: Chat messages data. Requires cyclic buffer.
placeholder: Chat input box placeholder. Use for prompt examples.
events: The events to capture on this chatbot. One of 'stop'.
events: The events to capture on this chatbot. One of 'stop' | 'scroll_up' | 'feedback'.
generating: True to show a button to stop the text generation. Defaults to False.
commands: Contextual menu commands for this component.
Returns:
Expand Down
2 changes: 1 addition & 1 deletion r/R/ui.R
Original file line number Diff line number Diff line change
Expand Up @@ -3332,7 +3332,7 @@ ui_chat_card <- function(
#' @param name An identifying name for this component.
#' @param data Chat messages data. Requires cyclic buffer.
#' @param placeholder Chat input box placeholder. Use for prompt examples.
#' @param events The events to capture on this chatbot. One of 'stop'.
#' @param events The events to capture on this chatbot. One of 'stop' | 'scroll_up' | 'feedback'.
#' @param generating True to show a button to stop the text generation. Defaults to False.
#' @param commands Contextual menu commands for this component.
#' @return A ChatbotCard instance.
Expand Down
96 changes: 96 additions & 0 deletions ui/src/chatbot.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -159,4 +159,100 @@ describe('XChatbot', () => {
expect(emitMock).toHaveBeenCalledTimes(1)
expect(emitMock).toHaveBeenCalledWith(model.name, 'scroll_up', true)
})

it('Renders thumbs up/down buttons', () => {
const { container } = render(<XChatbot {...{ ...model, data, events: ['feedback'] }} />)
const likeButton = container.querySelector("i[data-icon-name='Like']") as HTMLLIElement
const dislikeButton = container.querySelector("i[data-icon-name='Dislike']") as HTMLLIElement
expect(likeButton).toBeInTheDocument()
expect(dislikeButton).toBeInTheDocument()
})

it('Fires a like feedback event when clicked on the thumbs up button', () => {
const { container } = render(<XChatbot {...{ ...model, data, events: ['feedback'] }} />)
const likeButton = container.querySelector("i[data-icon-name='Like']") as HTMLLIElement

fireEvent.click(likeButton)
expect(emitMock).toHaveBeenCalledTimes(1)
expect(emitMock).toHaveBeenCalledWith(model.name, 'feedback', { message: data[1].content, positive: true })

const likeSolidButton = container.querySelector("i[data-icon-name='LikeSolid']") as HTMLLIElement
expect(likeSolidButton).toBeInTheDocument()
})

it('Fires a dislike feedback event when clicked on the thumbs down button', () => {
const { container } = render(<XChatbot {...{ ...model, data, events: ['feedback'] }} />)
const dislikeButton = container.querySelector("i[data-icon-name='Dislike']") as HTMLLIElement

fireEvent.click(dislikeButton)
expect(emitMock).toHaveBeenCalledTimes(1)
expect(emitMock).toHaveBeenCalledWith(model.name, 'feedback', { message: data[1].content, positive: false })

const dislikeSolidButton = container.querySelector("i[data-icon-name='DislikeSolid']") as HTMLLIElement
expect(dislikeSolidButton).toBeInTheDocument()
})

it('Fires a dislike feedback event when changing from thumbs up to thumbs down', () => {
const
{ container } = render(<XChatbot {...{ ...model, data, events: ['feedback'] }} />),
likeButton = container.querySelector("i[data-icon-name='Like']") as HTMLLIElement,
dislikeButton = container.querySelector("i[data-icon-name='Dislike']") as HTMLLIElement

fireEvent.click(likeButton)
expect(emitMock).toHaveBeenCalledTimes(1)
expect(emitMock).toHaveBeenCalledWith(model.name, 'feedback', { message: data[1].content, positive: true })

const likeSolidButton = container.querySelector("i[data-icon-name='LikeSolid']") as HTMLLIElement
expect(likeSolidButton).toBeInTheDocument()

fireEvent.click(dislikeButton)
expect(emitMock).toHaveBeenCalledTimes(2)
expect(emitMock).toHaveBeenCalledWith(model.name, 'feedback', { message: data[1].content, positive: false })
expect(container.querySelector("i[data-icon-name='DislikeSolid']") as HTMLLIElement).toBeInTheDocument()
expect(container.querySelector("i[data-icon-name='LikeSolid']") as HTMLLIElement).not.toBeInTheDocument()
})

it('Fires a like feedback event when changing from thumbs down to thumbs up', () => {
const
{ container } = render(<XChatbot {...{ ...model, data, events: ['feedback'] }} />),
dislikeButton = container.querySelector("i[data-icon-name='Dislike']") as HTMLLIElement,
likeButton = container.querySelector("i[data-icon-name='Like']") as HTMLLIElement

fireEvent.click(dislikeButton)
expect(emitMock).toHaveBeenCalledTimes(1)
expect(emitMock).toHaveBeenCalledWith(model.name, 'feedback', { message: data[1].content, positive: false })

const dislikeSolidButton = container.querySelector("i[data-icon-name='DislikeSolid']") as HTMLLIElement
expect(dislikeSolidButton).toBeInTheDocument()

fireEvent.click(likeButton)
expect(emitMock).toHaveBeenCalledTimes(2)
expect(emitMock).toHaveBeenCalledWith(model.name, 'feedback', { message: data[1].content, positive: true })
expect(container.querySelector("i[data-icon-name='LikeSolid']") as HTMLLIElement).toBeInTheDocument()
expect(container.querySelector("i[data-icon-name='DislikeSolid']") as HTMLLIElement).not.toBeInTheDocument()
})

it('Does not fire event when clicked on the thumbs up button twice', () => {
const { container } = render(<XChatbot {...{ ...model, data, events: ['feedback'] }} />)
const likeButton = container.querySelector("i[data-icon-name='Like']") as HTMLLIElement

fireEvent.click(likeButton)
expect(emitMock).toHaveBeenCalledTimes(1)
expect(emitMock).toHaveBeenCalledWith(model.name, 'feedback', { message: data[1].content, positive: true })

fireEvent.click(likeButton)
expect(emitMock).toHaveBeenCalledTimes(1)
})

it('Does not fire event when clicked on the thumbs down button twice', () => {
const { container } = render(<XChatbot {...{ ...model, data, events: ['feedback'] }} />)
const dislikeButton = container.querySelector("i[data-icon-name='Dislike']") as HTMLLIElement

fireEvent.click(dislikeButton)
expect(emitMock).toHaveBeenCalledTimes(1)
expect(emitMock).toHaveBeenCalledWith(model.name, 'feedback', { message: data[1].content, positive: false })

fireEvent.click(dislikeButton)
expect(emitMock).toHaveBeenCalledTimes(1)
})
})
39 changes: 33 additions & 6 deletions ui/src/chatbot.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
// limitations under the License.

import * as Fluent from '@fluentui/react'
import { B, Id, Model, Rec, S, isBuf, unpack, xid } from './core'
import { B, I, Id, Model, Rec, S, isBuf, unpack, xid } from './core'
import React from 'react'
import { cards } from './layout'
import { Markdown } from './markdown'
Expand Down Expand Up @@ -45,6 +45,7 @@ const
justifyContent: 'center',
},
msg: {
position: 'relative',
maxWidth: '65ch',
flexGrow: 1,
overflowWrap: 'break-word',
Expand All @@ -70,10 +71,14 @@ const
marginBottom: 7.5,
transform: 'translateX(-50%)',
width: 180
},
feedback: {
display: 'flex',
justifyContent: 'flex-end',
}
})

type Message = ChatbotMessage & { id?: S }
type Message = ChatbotMessage & { id?: S, positive?: B }

/* Chatbot message entity. */
interface ChatbotMessage {
Expand All @@ -91,7 +96,7 @@ export interface Chatbot {
data: Rec
/** Chat input box placeholder. Use for prompt examples. */
placeholder?: S
/** The events to capture on this chatbot. One of 'stop' | 'scroll_up'. */
/** The events to capture on this chatbot. One of 'stop' | 'scroll_up' | 'feedback'. */
events?: S[]
/** True to show a button to stop the text generation. Defaults to False. */
generating?: B
Expand Down Expand Up @@ -134,7 +139,23 @@ export const XChatbot = (props: Chatbot) => {
setIsInfiniteLoading(true)
}
}, [props.events, props.name]),
onDataChange = React.useCallback(() => { if (props.data) setMsgs(processData(props.data)) }, [props.data])
onDataChange = React.useCallback(() => { if (props.data) setMsgs(processData(props.data)) }, [props.data]),
handlePositive = (id: I) => {
setMsgs(messages => {
if (messages[id]?.positive) return messages
messages[id].positive = true
wave.emit(props.name, 'feedback', { message: messages[id].content, positive: true })
return [...messages]
})
},
handleNegative = (id: I) => {
setMsgs(messages => {
if (messages[id]?.positive !== undefined && !messages[id].positive) return messages
messages[id].positive = false
wave.emit(props.name, 'feedback', { message: messages[id].content, positive: false })
return [...messages]
})
}

React.useEffect(() => {
if (isBuf(props.data)) props.data.registerOnChange(onDataChange)
Expand Down Expand Up @@ -171,7 +192,7 @@ export const XChatbot = (props: Chatbot) => {
onInfiniteLoad={onLoad}
isInfiniteLoading={isInfiniteLoading}
>
{msgs.map(({ from_user, content, id }, idx) => (
{msgs.map(({ from_user, content, id, positive }, idx) => (
<div
key={id ?? idx}
className={clas(css.msgWrapper, from_user ? '' : css.botMsg)}
Expand All @@ -182,6 +203,12 @@ export const XChatbot = (props: Chatbot) => {
}} >
<span className={clas(css.msg, 'wave-s14')} style={{ padding: content?.includes('\n') ? 12 : 6 }}>
<Markdown source={content || ''} />
{props.events?.includes('feedback') && !from_user &&
<div className={css.feedback}>
<Fluent.IconButton onClick={() => handlePositive(idx)} iconProps={{ iconName: positive ? 'LikeSolid' : 'Like' }} />
<Fluent.IconButton onClick={() => handleNegative(idx)} iconProps={{ iconName: (positive !== undefined && !positive) ? 'DislikeSolid' : 'Dislike' }} />
</div>
}
</span>
</div>
))}
Expand Down Expand Up @@ -237,7 +264,7 @@ interface State {
data: Rec
/** Chat input box placeholder. Use for prompt examples. */
placeholder?: S
/** The events to capture on this chatbot. One of 'stop'. */
/** The events to capture on this chatbot. One of 'stop' | 'scroll_up' | 'feedback'. */
events?: S[]
/** True to show a button to stop the text generation. Defaults to False. */
generating?: B
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
18 changes: 18 additions & 0 deletions website/widgets/ai/chatbot.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,3 +63,21 @@ q.page['example'] = ui.chatbot_card(
events=['scroll']
)
```

## Collect feedback

Add the thumbs up and thumbs down buttons below the chatbot response to capture user feedback by configuring the `feedback` event. See [full example](/docs/examples/chatbot-events-feedback) to learn more.

```py {10}
from h2o_wave import data

q.page['example'] = ui.chatbot_card(
box='1 1 5 5',
name='chatbot',
data=data(fields='content from_user', t='list', rows=[
['Hello, buddy. Can you help me?', True],
['Sure, what you need?', False],
]),
events=['feedback']
)
```

0 comments on commit 4a7237b

Please sign in to comment.