Skip to content

Commit

Permalink
ratings and feedback are working from child to parent
Browse files Browse the repository at this point in the history
  • Loading branch information
petemill committed Dec 5, 2024
1 parent d0dca6d commit 8cf1be5
Show file tree
Hide file tree
Showing 18 changed files with 254 additions and 208 deletions.
2 changes: 1 addition & 1 deletion browser/brave_content_browser_client.cc
Original file line number Diff line number Diff line change
Expand Up @@ -48,9 +48,9 @@
#include "brave/components/ai_chat/core/browser/utils.h"
#include "brave/components/ai_chat/core/common/features.h"
#include "brave/components/ai_chat/core/common/mojom/ai_chat.mojom.h"
#include "brave/components/ai_chat/core/common/mojom/untrusted_frame.mojom.h"
#include "brave/components/ai_chat/core/common/mojom/page_content_extractor.mojom.h"
#include "brave/components/ai_chat/core/common/mojom/settings_helper.mojom.h"
#include "brave/components/ai_chat/core/common/mojom/untrusted_frame.mojom.h"
#include "brave/components/ai_rewriter/common/buildflags/buildflags.h"
#include "brave/components/body_sniffer/body_sniffer_throttle.h"
#include "brave/components/brave_federated/features.h"
Expand Down
53 changes: 28 additions & 25 deletions components/ai_chat/core/browser/conversation_handler.cc
Original file line number Diff line number Diff line change
Expand Up @@ -515,56 +515,59 @@ void ConversationHandler::GetState(GetStateCallback callback) {
}

void ConversationHandler::RateMessage(bool is_liked,
uint32_t turn_id,
const std::string& turn_uuid,
RateMessageCallback callback) {
DVLOG(2) << __func__ << ": " << is_liked << ", " << turn_uuid;
auto& model = GetCurrentModel();

// We only allow Leo models to be rated.
CHECK(model.options->is_leo_model_options());

const std::vector<mojom::ConversationTurnPtr>& history = chat_history_;

// TODO(petemill): Something more robust than relying on message index,
// and probably a message uuid.
uint32_t current_turn_id = turn_id + 1;

if (current_turn_id <= history.size()) {
base::span<const mojom::ConversationTurnPtr> history_slice =
base::make_span(history).first(current_turn_id);

feedback_api_->SendRating(
is_liked, ai_chat_service_->IsPremiumStatus(), history_slice,
model.options->get_leo_model_options()->name, selected_language_,
base::BindOnce(
[](RateMessageCallback callback, APIRequestResult result) {
if (result.Is2XXResponseCode() && result.value_body().is_dict()) {
std::string id =
*result.value_body().GetDict().FindString("id");
std::move(callback).Run(id);
return;
}
std::move(callback).Run(std::nullopt);
},
std::move(callback)));
auto entry_it =
base::ranges::find(history, turn_uuid, &mojom::ConversationTurn::uuid);

if (entry_it == history.end()) {
std::move(callback).Run(std::nullopt);
return;
}

std::move(callback).Run(std::nullopt);
const size_t count = std::distance(history.begin(), entry_it) + 1;

base::span<const mojom::ConversationTurnPtr> history_slice =
base::span(history).first(count);

feedback_api_->SendRating(
is_liked, ai_chat_service_->IsPremiumStatus(), history_slice,
model.options->get_leo_model_options()->name, selected_language_,
base::BindOnce(
[](RateMessageCallback callback, APIRequestResult result) {
if (result.Is2XXResponseCode() && result.value_body().is_dict()) {
std::string id = *result.value_body().GetDict().FindString("id");
std::move(callback).Run(id);
return;
}
DLOG(ERROR) << "Failed to send rating: " << result.response_code();
std::move(callback).Run(std::nullopt);
},
std::move(callback)));
}

void ConversationHandler::SendFeedback(const std::string& category,
const std::string& feedback,
const std::string& rating_id,
bool send_hostname,
SendFeedbackCallback callback) {
DVLOG(2) << __func__ << ": " << rating_id << ", " << send_hostname << ", "
<< category << ", " << feedback;
auto on_complete = base::BindOnce(
[](SendFeedbackCallback callback, APIRequestResult result) {
if (result.Is2XXResponseCode()) {
std::move(callback).Run(true);
return;
}

DLOG(ERROR) << "Failed to send feedback: " << result.response_code();
std::move(callback).Run(false);
},
std::move(callback));
Expand Down
2 changes: 1 addition & 1 deletion components/ai_chat/core/browser/conversation_handler.h
Original file line number Diff line number Diff line change
Expand Up @@ -232,7 +232,7 @@ class ConversationHandler : public mojom::ConversationHandler,
void GetState(GetStateCallback callback) override;
void GetConversationHistory(GetConversationHistoryCallback callback) override;
void RateMessage(bool is_liked,
uint32_t turn_id,
const std::string& turn_uuid,
RateMessageCallback callback) override;
void SendFeedback(const std::string& category,
const std::string& feedback,
Expand Down
2 changes: 1 addition & 1 deletion components/ai_chat/core/common/mojom/ai_chat.mojom
Original file line number Diff line number Diff line change
Expand Up @@ -468,7 +468,7 @@ interface ConversationHandler {
// Send a user-rating for a chat
// message. |turn_id| is the index of the message in the
// specified conversation.
RateMessage(bool is_liked, uint32 turn_id)
RateMessage(bool is_liked, string turn_uuid)
=> (string? rating_id);
SendFeedback(
string category,
Expand Down
14 changes: 13 additions & 1 deletion components/ai_chat/core/common/mojom/untrusted_frame.mojom
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,21 @@ module ai_chat.mojom;

// Trusted WebUI-side handler for messages from the untrusted child frame
interface ParentUIFrame {
// <iframe> cannot be sized to the natural height of its content, so the child
// must let the parent know whenever the body height changes so that the parent
// can manually set the same height on the <iframe> to avoid scrolling.
ChildHeightChanged(uint32 height);

// Separately, let the parent know when the height of the content changes
// as a result of active generation, so that the parent can automatically
// scroll to the most recent content as it's being generated.
GeneratedConversationEntryHeightChanged();
RateMessage(string turn_id, bool is_liked);

// This is sent to the UI rather than straight to the browser to allow
// for further rating feedback to be attached. The parent frame deals
// with it instead of the child frame, due to UI space as well as sensitive
// user information being captured.
RateMessage(string turn_uuid, bool is_liked);
};

// Browser-side handler for untrusted frame that handles rendering of
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,28 +20,22 @@ const CATEGORY_OPTIONS = new Map([
['other', getLocale('optionOther')]
])

interface FeedbackFormProps {
onCancel?: () => void
onSubmit?: (selectedCategory: string, feedbackText: string, shouldSendUrl: boolean) => void
isDisabled?: boolean
}

function FeedbackForm(props: FeedbackFormProps) {
function FeedbackForm() {
const ref = React.useRef<HTMLDivElement>(null)
const [category, setCategory] = React.useState('')
const [feedbackText, setFeedbackText] = React.useState('')
const aiChatContext = useAIChat()
const conversationContext = useConversation()
const [shouldSendUrl, setShouldSendUrl] = React.useState(true)

const canSubmit = !!category && !props.isDisabled
const canSubmit = !!category

const handleCancelClick = () => {
props.onCancel?.()
conversationContext.handleFeedbackFormCancel()
}

const handleSubmit = () => {
props.onSubmit?.(category, feedbackText, shouldSendUrl)
conversationContext.handleFeedbackFormSubmit(category, feedbackText, shouldSendUrl)
}

const handleSelectOnChange = ({ value }: { value: string }) => {
Expand Down
8 changes: 7 additions & 1 deletion components/ai_chat/resources/page/components/main/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,17 +21,18 @@ import LongConversationInfo from '../alerts/long_conversation_info'
import NoticeConversationStorage from '../notices/notice_conversation_storage'
import WarningPremiumDisconnected from '../alerts/warning_premium_disconnected'
import ConversationsList from '../conversations_list'
import FeedbackForm from '../feedback_form'
import { ConversationHeader } from '../header'
import InputBox from '../input_box'
import ModelIntro from '../model_intro'
import PageContextToggle from '../page_context_toggle'
import PremiumSuggestion from '../premium_suggestion'
import PrivacyMessage from '../privacy_message'
import SiteTitle from '../site_title'
import { SuggestedQuestion, SuggestionButton } from '../suggested_question'
import ToolsButtonMenu from '../tools_button_menu'
import WelcomeGuide from '../welcome_guide'
import styles from './style.module.scss'
import SiteTitle from '../site_title'

const SCROLL_BOTTOM_THRESHOLD = 10.0

Expand Down Expand Up @@ -226,6 +227,11 @@ function Main() {
onGeneratedConversationEntryHeightChanged={handleLastElementHeightChange}
/>
}
{conversationContext.isFeedbackFormVisible &&
<div className={styles.feedbackForm}>
<FeedbackForm />
</div>
}
{showSuggestions && (
<div className={styles.suggestedQuestionsBox}>
<div className={styles.questionsList}>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -167,3 +167,7 @@
gap: var(--leo-spacing-m);
border-bottom: 1px solid var(--leo-color-divider-subtle);
}

.feedbackForm {
padding: 0 16px;
}
10 changes: 7 additions & 3 deletions components/ai_chat/resources/page/state/conversation_context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,11 @@
import * as React from 'react'
import * as Mojom from '../../common/mojom'
import useIsConversationVisible from '../hooks/useIsConversationVisible'
import useSendFeedback, { defaultSendFeedbackState, SendFeedbackState } from './useSendFeedback'
import { isLeoModel } from '../model_utils'
import { tabAssociatedChatId, useActiveChat } from './active_chat_context'
import { useAIChat } from './ai_chat_context'
import getAPI from '../api'

const MAX_INPUT_CHAR = 2000
const CHAR_LIMIT_THRESHOLD = MAX_INPUT_CHAR * 0.8
Expand All @@ -19,7 +21,7 @@ export interface CharCountContext {
inputTextCharCountDisplay: string
}

export interface ConversationContext extends CharCountContext {
export type ConversationContext = SendFeedbackState & CharCountContext & {
conversationUuid?: string
conversationHistory: Mojom.ConversationTurn[]
associatedContentInfo?: Mojom.SiteInfo
Expand Down Expand Up @@ -93,6 +95,7 @@ const defaultContext: ConversationContext = {
resetSelectedActionType: () => { },
handleActionTypeClick: () => { },
setIsToolsMenuOpen: () => { },
...defaultSendFeedbackState,
...defaultCharCountContext
}

Expand Down Expand Up @@ -151,7 +154,9 @@ export function ConversationContextProvider(props: React.PropsWithChildren) {
const [context, setContext] =
React.useState<ConversationContext>(defaultContext)

const aiChatContext = useAIChat()
const { conversationHandler, callbackRouter, selectedConversationId, updateSelectedConversationId } = useActiveChat()
const sendFeedbackState = useSendFeedback(conversationHandler, getAPI().conversationEntriesFrameObserver)

const [
hasDismissedLongConversationInfo,
Expand Down Expand Up @@ -285,8 +290,6 @@ export function ConversationContextProvider(props: React.PropsWithChildren) {
}
}, [conversationHandler, callbackRouter])

const aiChatContext = useAIChat()

// Update favicon
React.useEffect(() => {
if (!context.conversationUuid || !aiChatContext.uiHandler) {
Expand Down Expand Up @@ -449,6 +452,7 @@ export function ConversationContextProvider(props: React.PropsWithChildren) {

const store: ConversationContext = {
...context,
...sendFeedbackState,
actionList,
apiHasError,
shouldDisableUserInput,
Expand Down
121 changes: 121 additions & 0 deletions components/ai_chat/resources/page/state/useSendFeedback.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
// Copyright (c) 2024 The Brave Authors. All rights reserved.
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this file,
// You can obtain one at https://mozilla.org/MPL/2.0/.

import * as React from 'react'
import { showAlert } from '@brave/leo/react/alertCenter'
import * as Mojom from '../../common/mojom'
import { getLocale } from '$web-common/locale'

/**
* State needed for UI to embed a feedback form
*/
export interface SendFeedbackState {
isFeedbackFormVisible: boolean
handleFeedbackFormCancel: () => void
handleFeedbackFormSubmit: (selectedCategory: string, feedbackText: string, shouldSendUrl: boolean) => void
}

export const defaultSendFeedbackState: SendFeedbackState = {
isFeedbackFormVisible: false,
handleFeedbackFormCancel: () => {},
handleFeedbackFormSubmit: () => {}
}

/**
* This hook handles calls from the child frame to rate a message and provides
* handling of the form to collect more user feedback, actioned via the alert
* center. This is self-contained apart from the form itself which should
* be implemented in the parent frame UI.
*/
export default function useSendFeedback(
conversationHandler: Mojom.ConversationHandlerRemote,
conversationEntriesFrameObserver: Mojom.ParentUIFrameCallbackRouter
): SendFeedbackState {
const feedbackId = React.useRef<string | null>(null)
const [isFeedbackFormVisible, setIsFeedbackFormVisible] = React.useState(false)

// Listen to ratings requests from the child frame
React.useEffect(() => {
async function handleRateMessage(turnUuid: string, isLiked: boolean) {
// Reset feedback form
feedbackId.current = null
setIsFeedbackFormVisible(false)
const response = await conversationHandler?.rateMessage(isLiked, turnUuid)
if (!response.ratingId) {
showAlert({
type: 'error',
content: getLocale('ratingError'),
actions: []
})
return
}
if (isLiked) {
showAlert({
type: 'info',
content: getLocale('answerLiked'),
actions: []
})
} else {
// Allow the alert to stay for longer so that the user has time
// to click the button to add feedback.
feedbackId.current = response.ratingId
showAlert({
type: 'info',
content: getLocale('answerDisliked'),
actions: [
{
text: getLocale('addFeedbackButtonLabel'),
kind: 'plain-faint',
action: () => setIsFeedbackFormVisible(true)
}
]
}, 5000)
}
}

const listenerId = conversationEntriesFrameObserver.rateMessage.addListener(
handleRateMessage
)

return () => {
conversationEntriesFrameObserver.removeListener(listenerId)
}
}, [conversationHandler, conversationEntriesFrameObserver])

function handleFeedbackFormCancel() {
setIsFeedbackFormVisible(false)
}

async function handleFeedbackFormSubmit(
selectedCategory: string, feedbackText: string, shouldSendUrl: boolean
) {
if (feedbackId.current) {
const response = await conversationHandler?.sendFeedback(
selectedCategory, feedbackText, feedbackId.current, shouldSendUrl
)
if (!response.isSuccess) {
showAlert({
type: 'error',
content: getLocale('feedbackError'),
actions: []
})
return
}

showAlert({
type: 'success',
content: getLocale('feedbackSent'),
actions: []
})
setIsFeedbackFormVisible(false)
}
}

return {
isFeedbackFormVisible,
handleFeedbackFormCancel,
handleFeedbackFormSubmit
}
}
Loading

0 comments on commit 8cf1be5

Please sign in to comment.