From c24b05a3b13dee14b3ce7d16f7bafbde1783bd0d Mon Sep 17 00:00:00 2001 From: Jocelyn Liu Date: Mon, 24 Jun 2024 09:30:50 -0700 Subject: [PATCH] feat(Leo): Support modifying user prompts --- browser/ai_chat/android/ai_chat_utils.cc | 5 +- .../webui/ai_chat/ai_chat_ui_page_handler.cc | 11 ++- .../webui/ai_chat/ai_chat_ui_page_handler.h | 2 + .../chrome_autocomplete_provider_client.cc | 13 +-- components/ai_chat/core/browser/constants.cc | 3 + .../core/browser/conversation_driver.cc | 88 ++++++++++++++---- .../core/browser/conversation_driver.h | 7 ++ .../browser/conversation_driver_unittest.cc | 89 +++++++++++++++++-- .../engine/engine_consumer_claude_unittest.cc | 6 +- ...gine_consumer_conversation_api_unittest.cc | 9 +- .../engine/engine_consumer_llama_unittest.cc | 7 +- .../engine/engine_consumer_oai_unittest.cc | 6 +- .../ai_chat/core/common/mojom/ai_chat.mojom | 10 +++ .../resources/page/api/mock_page_handler.ts | 1 + .../components/conversation_list/index.tsx | 70 +++++++++++---- .../page/components/edit_button/index.tsx | 29 ++++++ .../components/edit_button/style.module.scss | 9 ++ .../page/components/edit_indicator/index.tsx | 39 ++++++++ .../edit_indicator/style.module.scss | 21 +++++ .../page/components/edit_input/index.tsx | 71 +++++++++++++++ .../components/edit_input/style.module.scss | 73 +++++++++++++++ .../page/state/data-context-provider.tsx | 3 +- .../page/stories/components_panel.tsx | 37 ++++++++ .../ai_chat/resources/page/stories/locale.ts | 3 + components/resources/ai_chat_ui_strings.grdp | 9 ++ .../AIChat/Components/AIChatView.swift | 4 +- .../Messages/AIChatResponseMessageView.swift | 4 +- ios/browser/api/ai_chat/ai_chat.mm | 4 +- 28 files changed, 570 insertions(+), 63 deletions(-) create mode 100644 components/ai_chat/resources/page/components/edit_button/index.tsx create mode 100644 components/ai_chat/resources/page/components/edit_button/style.module.scss create mode 100644 components/ai_chat/resources/page/components/edit_indicator/index.tsx create mode 100644 components/ai_chat/resources/page/components/edit_indicator/style.module.scss create mode 100644 components/ai_chat/resources/page/components/edit_input/index.tsx create mode 100644 components/ai_chat/resources/page/components/edit_input/style.module.scss diff --git a/browser/ai_chat/android/ai_chat_utils.cc b/browser/ai_chat/android/ai_chat_utils.cc index 13ce0d8c8e1e..1444509fc1e1 100644 --- a/browser/ai_chat/android/ai_chat_utils.cc +++ b/browser/ai_chat/android/ai_chat_utils.cc @@ -5,6 +5,7 @@ #include "base/android/jni_android.h" #include "base/android/jni_string.h" +#include "base/time/time.h" #include "brave/build/android/jni_headers/BraveLeoUtils_jni.h" #include "brave/components/ai_chat/core/common/buildflags/buildflags.h" #include "brave/components/ai_chat/core/common/mojom/ai_chat.mojom.h" @@ -30,8 +31,8 @@ static void JNI_BraveLeoUtils_OpenLeoQuery( mojom::ConversationTurnPtr turn = mojom::ConversationTurn::New( mojom::CharacterType::HUMAN, mojom::ActionType::QUERY, mojom::ConversationTurnVisibility::VISIBLE, - base::android::ConvertJavaStringToUTF8(query), std::nullopt, - std::nullopt); + base::android::ConvertJavaStringToUTF8(query), std::nullopt, std::nullopt, + base::Time::Now(), std::nullopt); chat_tab_helper->SubmitHumanConversationEntry(std::move(turn)); #endif } diff --git a/browser/ui/webui/ai_chat/ai_chat_ui_page_handler.cc b/browser/ui/webui/ai_chat/ai_chat_ui_page_handler.cc index 82055cc07100..660404fe64fa 100644 --- a/browser/ui/webui/ai_chat/ai_chat_ui_page_handler.cc +++ b/browser/ui/webui/ai_chat/ai_chat_ui_page_handler.cc @@ -13,6 +13,7 @@ #include "base/notreached.h" #include "base/strings/utf_string_conversions.h" +#include "base/time/time.h" #include "brave/browser/ui/side_panel/ai_chat/ai_chat_side_panel_utils.h" #include "brave/components/ai_chat/core/browser/constants.h" #include "brave/components/ai_chat/core/common/mojom/ai_chat.mojom-shared.h" @@ -141,7 +142,8 @@ void AIChatUIPageHandler::SubmitHumanConversationEntry( mojom::ConversationTurnPtr turn = mojom::ConversationTurn::New( CharacterType::HUMAN, mojom::ActionType::UNSPECIFIED, - ConversationTurnVisibility::VISIBLE, input, std::nullopt, std::nullopt); + ConversationTurnVisibility::VISIBLE, input, std::nullopt, std::nullopt, + base::Time::Now(), std::nullopt); active_chat_tab_helper_->SubmitHumanConversationEntry(std::move(turn)); } @@ -516,4 +518,11 @@ void AIChatUIPageHandler::OnGetPremiumStatus( } } +void AIChatUIPageHandler::ModifyConversation(uint32_t turn_index, + const std::string& new_text) { + if (active_chat_tab_helper_) { + active_chat_tab_helper_->ModifyConversation(turn_index, new_text); + } +} + } // namespace ai_chat diff --git a/browser/ui/webui/ai_chat/ai_chat_ui_page_handler.h b/browser/ui/webui/ai_chat/ai_chat_ui_page_handler.h index a0902615a16d..60f960360eda 100644 --- a/browser/ui/webui/ai_chat/ai_chat_ui_page_handler.h +++ b/browser/ui/webui/ai_chat/ai_chat_ui_page_handler.h @@ -97,6 +97,8 @@ class AIChatUIPageHandler : public ai_chat::mojom::PageHandler, void ClosePanel() override; void GetActionMenuList(GetActionMenuListCallback callback) override; void OpenModelSupportUrl() override; + void ModifyConversation(uint32_t turn_index, + const std::string& new_text) override; // content::WebContentsObserver: void OnVisibilityChanged(content::Visibility visibility) override; diff --git a/chromium_src/chrome/browser/autocomplete/chrome_autocomplete_provider_client.cc b/chromium_src/chrome/browser/autocomplete/chrome_autocomplete_provider_client.cc index 19326e8fa408..4e6549209a04 100644 --- a/chromium_src/chrome/browser/autocomplete/chrome_autocomplete_provider_client.cc +++ b/chromium_src/chrome/browser/autocomplete/chrome_autocomplete_provider_client.cc @@ -72,12 +72,13 @@ void ChromeAutocompleteProviderClient::OpenLeo(const std::u16string& query) { // Send the query to the AIChat's backend. ai_chat::mojom::ConversationTurnPtr turn = - ai_chat::mojom::ConversationTurn::New(); - turn->character_type = ai_chat::mojom::CharacterType::HUMAN; - turn->action_type = ai_chat::mojom::ActionType::QUERY; - turn->visibility = ai_chat::mojom::ConversationTurnVisibility::VISIBLE; - turn->text = base::UTF16ToUTF8(query); - turn->selected_text = std::nullopt; + ai_chat::mojom::ConversationTurn::New( + ai_chat::mojom::CharacterType::HUMAN, + ai_chat::mojom::ActionType::QUERY, + ai_chat::mojom::ConversationTurnVisibility::VISIBLE, + base::UTF16ToUTF8(query) /* text */, std::nullopt /* selected_text */, + std::nullopt /* events */, base::Time::Now(), + std::nullopt /* edits */); chat_tab_helper->SubmitHumanConversationEntry(std::move(turn)); diff --git a/components/ai_chat/core/browser/constants.cc b/components/ai_chat/core/browser/constants.cc index 0da88c4a9941..4d0bd214e80d 100644 --- a/components/ai_chat/core/browser/constants.cc +++ b/components/ai_chat/core/browser/constants.cc @@ -81,6 +81,9 @@ base::span GetLocalizedStrings() { {"feedbackPremiumNote", IDS_CHAT_UI_FEEDBACK_PREMIUM_NOTE}, {"submitButtonLabel", IDS_CHAT_UI_SUBMIT_BUTTON_LABEL}, {"cancelButtonLabel", IDS_CHAT_UI_CANCEL_BUTTON_LABEL}, + {"saveButtonLabel", IDS_CHAT_UI_SAVE_BUTTON_LABEL}, + {"editedLabel", IDS_CHAT_UI_EDITED_LABEL}, + {"editButtonLabel", IDS_CHAT_UI_EDIT_BUTTON_LABEL}, {"optionNotHelpful", IDS_CHAT_UI_OPTION_NOT_HELPFUL}, {"optionIncorrect", IDS_CHAT_UI_OPTION_INCORRECT}, {"optionUnsafeHarmful", IDS_CHAT_UI_OPTION_UNSAFE_HARMFUL}, diff --git a/components/ai_chat/core/browser/conversation_driver.cc b/components/ai_chat/core/browser/conversation_driver.cc index d31fe744f485..98bde71fd682 100644 --- a/components/ai_chat/core/browser/conversation_driver.cc +++ b/components/ai_chat/core/browser/conversation_driver.cc @@ -22,6 +22,7 @@ #include "base/strings/strcat.h" #include "base/strings/string_util.h" #include "base/strings/utf_string_conversions.h" +#include "base/time/time.h" #include "base/values.h" #include "brave/components/ai_chat/core/browser/ai_chat_credential_manager.h" #include "brave/components/ai_chat/core/browser/ai_chat_metrics.h" @@ -416,7 +417,8 @@ void ConversationDriver::UpdateOrCreateLastAssistantEntry( mojom::ConversationTurnPtr entry = mojom::ConversationTurn::New( CharacterType::ASSISTANT, mojom::ActionType::RESPONSE, ConversationTurnVisibility::VISIBLE, "", std::nullopt, - std::vector{}); + std::vector{}, base::Time::Now(), + std::nullopt); chat_history_.push_back(std::move(entry)); } @@ -868,7 +870,7 @@ void ConversationDriver::AddSubmitSelectedTextError( const std::string& question = GetActionTypeQuestion(action_type); mojom::ConversationTurnPtr turn = mojom::ConversationTurn::New( CharacterType::HUMAN, action_type, ConversationTurnVisibility::VISIBLE, - question, selected_text, std::nullopt); + question, selected_text, std::nullopt, base::Time::Now(), std::nullopt); AddToConversationHistory(std::move(turn)); SetAPIError(error); } @@ -925,7 +927,7 @@ void ConversationDriver::SubmitSelectedTextWithQuestion( // Use sidebar. mojom::ConversationTurnPtr turn = mojom::ConversationTurn::New( CharacterType::HUMAN, action_type, ConversationTurnVisibility::VISIBLE, - question, selected_text, std::nullopt); + question, selected_text, std::nullopt, base::Time::Now(), std::nullopt); SubmitHumanConversationEntry(std::move(turn)); } else { @@ -937,6 +939,12 @@ void ConversationDriver::SubmitHumanConversationEntry( mojom::ConversationTurnPtr turn) { VLOG(1) << __func__; DVLOG(4) << __func__ << ": " << turn->text; + + // If there's edits, use the last one as the latest turn. + bool has_edits = turn->edits && !turn->edits->empty(); + mojom::ConversationTurnPtr& latest_turn = + has_edits ? turn->edits->back() : turn; + // Decide if this entry needs to wait for one of: // - user to be opted-in // - conversation to be active @@ -963,7 +971,7 @@ void ConversationDriver::SubmitHumanConversationEntry( return; } - DCHECK(turn->character_type == CharacterType::HUMAN); + DCHECK(latest_turn->character_type == CharacterType::HUMAN); is_request_in_progress_ = true; for (auto& obs : observers_) { @@ -971,16 +979,20 @@ void ConversationDriver::SubmitHumanConversationEntry( } // If it's a suggested question, remove it - auto found_question_iter = base::ranges::find(suggestions_, turn->text); + auto found_question_iter = + base::ranges::find(suggestions_, latest_turn->text); if (found_question_iter != suggestions_.end()) { suggestions_.erase(found_question_iter); OnSuggestedQuestionsChanged(); } // Directly modify Entry's text to remove engine-breaking substrings - engine_->SanitizeInput(turn->text); - if (turn->selected_text) { - engine_->SanitizeInput(*turn->selected_text); + if (!has_edits) { // Edits are already sanitized. + engine_->SanitizeInput(latest_turn->text); + } + + if (latest_turn->selected_text) { + engine_->SanitizeInput(*latest_turn->selected_text); } // TODO(petemill): Tokenize the summary question so that we @@ -988,19 +1000,20 @@ void ConversationDriver::SubmitHumanConversationEntry( // TODO(jocelyn): Assigning turn.type below is a workaround for now since // callers of SubmitHumanConversationEntry mojo API currently don't have // action_type specified. - std::string question_part = turn->text; - if (turn->action_type == mojom::ActionType::UNSPECIFIED) { - if (turn->text == l10n_util::GetStringUTF8(IDS_CHAT_UI_SUMMARIZE_PAGE)) { - turn->action_type = mojom::ActionType::SUMMARIZE_PAGE; + std::string question_part = latest_turn->text; + if (latest_turn->action_type == mojom::ActionType::UNSPECIFIED) { + if (latest_turn->text == + l10n_util::GetStringUTF8(IDS_CHAT_UI_SUMMARIZE_PAGE)) { + latest_turn->action_type = mojom::ActionType::SUMMARIZE_PAGE; question_part = l10n_util::GetStringUTF8(IDS_AI_CHAT_QUESTION_SUMMARIZE_PAGE); - } else if (turn->text == + } else if (latest_turn->text == l10n_util::GetStringUTF8(IDS_CHAT_UI_SUMMARIZE_VIDEO)) { - turn->action_type = mojom::ActionType::SUMMARIZE_VIDEO; + latest_turn->action_type = mojom::ActionType::SUMMARIZE_VIDEO; question_part = l10n_util::GetStringUTF8(IDS_AI_CHAT_QUESTION_SUMMARIZE_VIDEO); } else { - turn->action_type = mojom::ActionType::QUERY; + latest_turn->action_type = mojom::ActionType::QUERY; } } @@ -1165,7 +1178,7 @@ void ConversationDriver::SubmitSummarizationRequest() { CharacterType::HUMAN, mojom::ActionType::SUMMARIZE_PAGE, ConversationTurnVisibility::VISIBLE, l10n_util::GetStringUTF8(IDS_CHAT_UI_SUMMARIZE_PAGE), std::nullopt, - std::nullopt); + std::nullopt, base::Time::Now(), std::nullopt); SubmitHumanConversationEntry(std::move(turn)); } @@ -1329,4 +1342,47 @@ void ConversationDriver::SendFeedback( : std::nullopt, std::move(on_complete)); } + +void ConversationDriver::ModifyConversation(uint32_t turn_index, + const std::string& new_text) { + if (turn_index >= chat_history_.size()) { + return; + } + + auto& turn = chat_history_.at(turn_index); + if (turn->character_type == CharacterType::ASSISTANT) { // not supported yet + return; + } + + std::string sanitized_input = new_text; + engine_->SanitizeInput(sanitized_input); + const auto& current_text = turn->edits && !turn->edits->empty() + ? turn->edits->back()->text + : turn->text; + if (sanitized_input.empty() || sanitized_input == current_text) { + return; + } + + // turn->selected_text and turn->events are actually std::nullopt for + // editable human turns in our current implementation, just use std::nullopt + // here directly to be more explicit and avoid confusion. + auto edited_turn = mojom::ConversationTurn::New( + turn->character_type, turn->action_type, turn->visibility, + sanitized_input, std::nullopt /* selected_text */, + std::nullopt /* events */, base::Time::Now(), std::nullopt /* edits */); + if (!turn->edits) { + turn->edits.emplace(); + } + turn->edits->emplace_back(std::move(edited_turn)); + + // Modifying human turn, drop anything after this turn_index and resubmit. + auto new_turn = std::move(chat_history_.at(turn_index)); + chat_history_.erase(chat_history_.begin() + turn_index, chat_history_.end()); + for (auto& obs : observers_) { + obs.OnHistoryUpdate(); + } + + SubmitHumanConversationEntry(std::move(new_turn)); +} + } // namespace ai_chat diff --git a/components/ai_chat/core/browser/conversation_driver.h b/components/ai_chat/core/browser/conversation_driver.h index d83351a06aa6..c647053d2727 100644 --- a/components/ai_chat/core/browser/conversation_driver.h +++ b/components/ai_chat/core/browser/conversation_driver.h @@ -145,6 +145,8 @@ class ConversationDriver : public ModelService::Observer { GeneratedTextCallback received_callback, EngineConsumer::GenerationCompletedCallback completed_callback); + void ModifyConversation(uint32_t turn_index, const std::string& new_text); + void RateMessage(bool is_liked, uint32_t turn_id, mojom::PageHandler::RateMessageCallback callback); @@ -169,6 +171,11 @@ class ConversationDriver : public ModelService::Observer { } EngineConsumer* GetEngineForTesting() { return engine_.get(); } + void SetChatHistoryForTesting( + std::vector history) { + chat_history_ = std::move(history); + } + protected: virtual GURL GetPageURL() const = 0; virtual std::u16string GetPageTitle() const = 0; diff --git a/components/ai_chat/core/browser/conversation_driver_unittest.cc b/components/ai_chat/core/browser/conversation_driver_unittest.cc index 37edb981db0d..3ca675af5bef 100644 --- a/components/ai_chat/core/browser/conversation_driver_unittest.cc +++ b/components/ai_chat/core/browser/conversation_driver_unittest.cc @@ -22,6 +22,7 @@ #include "base/test/mock_callback.h" #include "base/test/scoped_feature_list.h" #include "base/test/task_environment.h" +#include "base/time/time.h" #include "brave/components/ai_chat/core/browser/ai_chat_credential_manager.h" #include "brave/components/ai_chat/core/common/features.h" #include "brave/components/ai_chat/core/common/mojom/ai_chat.mojom-forward.h" @@ -329,11 +330,11 @@ TEST_F(ConversationDriverUnitTest, SubmitSelectedText) { mojom::CharacterType::HUMAN, mojom::ActionType::SUMMARIZE_SELECTED_TEXT, mojom::ConversationTurnVisibility::VISIBLE, l10n_util::GetStringUTF8(IDS_AI_CHAT_QUESTION_SUMMARIZE_SELECTED_TEXT), - "I have spoken.", std::nullopt)); + "I have spoken.", std::nullopt, base::Time::Now(), std::nullopt)); expected_history.push_back(mojom::ConversationTurn::New( mojom::CharacterType::ASSISTANT, mojom::ActionType::RESPONSE, mojom::ConversationTurnVisibility::VISIBLE, "This is the way.", - std::nullopt, std::nullopt)); + std::nullopt, std::nullopt, base::Time::Now(), std::nullopt)); EXPECT_EQ(history.size(), expected_history.size()); for (size_t i = 0; i < history.size(); i++) { EXPECT_TRUE(CompareConversationTurn(history[i], expected_history[i])); @@ -372,20 +373,20 @@ TEST_F(ConversationDriverUnitTest, SubmitSelectedText) { mojom::CharacterType::HUMAN, mojom::ActionType::SUMMARIZE_SELECTED_TEXT, mojom::ConversationTurnVisibility::VISIBLE, l10n_util::GetStringUTF8(IDS_AI_CHAT_QUESTION_SUMMARIZE_SELECTED_TEXT), - "I have spoken.", std::nullopt)); + "I have spoken.", std::nullopt, base::Time::Now(), std::nullopt)); expected_history2.push_back(mojom::ConversationTurn::New( mojom::CharacterType::ASSISTANT, mojom::ActionType::RESPONSE, mojom::ConversationTurnVisibility::VISIBLE, "This is the way.", - std::nullopt, std::nullopt)); + std::nullopt, std::nullopt, base::Time::Now(), std::nullopt)); expected_history2.push_back(mojom::ConversationTurn::New( mojom::CharacterType::HUMAN, mojom::ActionType::SUMMARIZE_SELECTED_TEXT, mojom::ConversationTurnVisibility::VISIBLE, l10n_util::GetStringUTF8(IDS_AI_CHAT_QUESTION_SUMMARIZE_SELECTED_TEXT), - "I have spoken again.", std::nullopt)); + "I have spoken again.", std::nullopt, base::Time::Now(), std::nullopt)); expected_history2.push_back(mojom::ConversationTurn::New( mojom::CharacterType::ASSISTANT, mojom::ActionType::RESPONSE, mojom::ConversationTurnVisibility::VISIBLE, "This is the way.", - std::nullopt, std::nullopt)); + std::nullopt, std::nullopt, base::Time::Now(), std::nullopt)); EXPECT_EQ(history2.size(), expected_history2.size()); for (size_t i = 0; i < history2.size(); i++) { EXPECT_TRUE(CompareConversationTurn(history2[i], expected_history2[i])); @@ -705,4 +706,80 @@ TEST_F(ConversationDriverUnitTest, } } +TEST_F(ConversationDriverUnitTest, ModifyConversation) { + conversation_driver_->SetShouldSendPageContents(false); + EmulateUserOptedIn(); + + url_loader_factory_.SetInterceptor( + base::BindLambdaForTesting([&](const network::ResourceRequest& request) { + // Set header for enabling SSE. + auto head = network::mojom::URLResponseHead::New(); + head->mime_type = "text/event-stream"; + url_loader_factory_.ClearResponses(); + url_loader_factory_.AddResponse( + request.url, std::move(head), + R"(data: {"completion": "new answer", "stop": null})", + network::URLLoaderCompletionStatus()); + })); + + // Setup history for testing. + auto created_time1 = base::Time::Now(); + std::vector history; + history.push_back(mojom::ConversationTurn::New( + mojom::CharacterType::HUMAN, mojom::ActionType::QUERY, + mojom::ConversationTurnVisibility::VISIBLE, "prompt1", std::nullopt, + std::nullopt, created_time1, std::nullopt)); + history.push_back(mojom::ConversationTurn::New( + mojom::CharacterType::ASSISTANT, mojom::ActionType::RESPONSE, + mojom::ConversationTurnVisibility::VISIBLE, "answer1", std::nullopt, + std::nullopt, base::Time::Now(), std::nullopt)); + conversation_driver_->SetChatHistoryForTesting(std::move(history)); + + // Modify an entry for the first time. + conversation_driver_->ModifyConversation(0, "prompt2"); + const auto& conversation_history = + conversation_driver_->GetConversationHistory(); + ASSERT_EQ(conversation_history.size(), 1u); + EXPECT_EQ(conversation_history[0]->text, "prompt1"); + EXPECT_EQ(conversation_history[0]->created_time, created_time1); + + ASSERT_TRUE(conversation_history[0]->edits); + ASSERT_EQ(conversation_history[0]->edits->size(), 1u); + EXPECT_EQ(conversation_history[0]->edits->at(0)->text, "prompt2"); + EXPECT_NE(conversation_history[0]->edits->at(0)->created_time, created_time1); + EXPECT_FALSE(conversation_history[0]->edits->at(0)->edits); + + WaitForOnEngineCompletionComplete(); + ASSERT_EQ(conversation_history.size(), 2u); + EXPECT_EQ(conversation_history[1]->text, "new answer"); + + auto created_time2 = conversation_history[0]->edits->at(0)->created_time; + + // Modify the same entry again. + conversation_driver_->ModifyConversation(0, "prompt3"); + ASSERT_EQ(conversation_history.size(), 1u); + EXPECT_EQ(conversation_history[0]->text, "prompt1"); + EXPECT_EQ(conversation_history[0]->created_time, created_time1); + + ASSERT_TRUE(conversation_history[0]->edits); + ASSERT_EQ(conversation_history[0]->edits->size(), 2u); + EXPECT_EQ(conversation_history[0]->edits->at(0)->text, "prompt2"); + EXPECT_EQ(conversation_history[0]->edits->at(0)->created_time, created_time2); + EXPECT_FALSE(conversation_history[0]->edits->at(0)->edits); + + EXPECT_EQ(conversation_history[0]->edits->at(1)->text, "prompt3"); + EXPECT_NE(conversation_history[0]->edits->at(1)->created_time, created_time1); + EXPECT_NE(conversation_history[0]->edits->at(1)->created_time, created_time2); + EXPECT_FALSE(conversation_history[0]->edits->at(1)->edits); + + WaitForOnEngineCompletionComplete(); + ASSERT_EQ(conversation_history.size(), 2u); + EXPECT_EQ(conversation_history[1]->text, "new answer"); + + // Modify server response is not supported yet. + conversation_driver_->ModifyConversation(1, "answer2"); + ASSERT_EQ(conversation_history.size(), 2u); + EXPECT_EQ(conversation_history[1]->text, "new answer"); +} + } // namespace ai_chat diff --git a/components/ai_chat/core/browser/engine/engine_consumer_claude_unittest.cc b/components/ai_chat/core/browser/engine/engine_consumer_claude_unittest.cc index d1dd4ee1ec3d..d50b2affe54d 100644 --- a/components/ai_chat/core/browser/engine/engine_consumer_claude_unittest.cc +++ b/components/ai_chat/core/browser/engine/engine_consumer_claude_unittest.cc @@ -16,6 +16,7 @@ #include "base/strings/string_util.h" #include "base/test/bind.h" #include "base/test/task_environment.h" +#include "base/time/time.h" #include "brave/components/ai_chat/core/browser/engine/engine_consumer.h" #include "brave/components/ai_chat/core/browser/engine/mock_remote_completion_client.h" #include "brave/components/ai_chat/core/browser/model_service.h" @@ -71,11 +72,12 @@ TEST_F(EngineConsumerClaudeUnitTest, TestGenerateAssistantResponse) { history.push_back(mojom::ConversationTurn::New( mojom::CharacterType::HUMAN, mojom::ActionType::SUMMARIZE_SELECTED_TEXT, mojom::ConversationTurnVisibility::VISIBLE, - "Which show is this catchphrase from?", "I have spoken.", std::nullopt)); + "Which show is this catchphrase from?", "I have spoken.", std::nullopt, + base::Time::Now(), std::nullopt)); history.push_back(mojom::ConversationTurn::New( mojom::CharacterType::ASSISTANT, mojom::ActionType::RESPONSE, mojom::ConversationTurnVisibility::VISIBLE, "The Mandalorian.", - std::nullopt, std::nullopt)); + std::nullopt, std::nullopt, base::Time::Now(), std::nullopt)); auto* mock_remote_completion_client = GetMockRemoteCompletionClient(); std::string prompt_before_time_and_date = "\n\nHuman: Here is the text of a web page in tags:\n\nThis " diff --git a/components/ai_chat/core/browser/engine/engine_consumer_conversation_api_unittest.cc b/components/ai_chat/core/browser/engine/engine_consumer_conversation_api_unittest.cc index 23fa7a42fb7c..bdfe629412b0 100644 --- a/components/ai_chat/core/browser/engine/engine_consumer_conversation_api_unittest.cc +++ b/components/ai_chat/core/browser/engine/engine_consumer_conversation_api_unittest.cc @@ -19,6 +19,7 @@ #include "base/strings/string_util.h" #include "base/test/bind.h" #include "base/test/task_environment.h" +#include "base/time/time.h" #include "brave/components/ai_chat/core/browser/engine/conversation_api_client.h" #include "brave/components/ai_chat/core/browser/engine/engine_consumer.h" #include "brave/components/ai_chat/core/common/mojom/ai_chat.mojom-forward.h" @@ -213,15 +214,17 @@ TEST_F(EngineConsumerConversationAPIUnitTest, history.push_back(mojom::ConversationTurn::New( mojom::CharacterType::HUMAN, mojom::ActionType::QUERY, mojom::ConversationTurnVisibility::VISIBLE, - "Which show is this catchphrase from?", "I have spoken.", std::nullopt)); + "Which show is this catchphrase from?", "I have spoken.", std::nullopt, + base::Time::Now(), std::nullopt)); history.push_back(mojom::ConversationTurn::New( mojom::CharacterType::ASSISTANT, mojom::ActionType::RESPONSE, mojom::ConversationTurnVisibility::VISIBLE, "The Mandalorian.", - std::nullopt, std::nullopt)); + std::nullopt, std::nullopt, base::Time::Now(), std::nullopt)); history.push_back(mojom::ConversationTurn::New( mojom::CharacterType::HUMAN, mojom::ActionType::RESPONSE, mojom::ConversationTurnVisibility::VISIBLE, - "Is it related to a broader series?", std::nullopt, std::nullopt)); + "Is it related to a broader series?", std::nullopt, std::nullopt, + base::Time::Now(), std::nullopt)); std::string expected_events = R"([ {"role": "user", "type": "pageText", "content": "This is my page. I have spoken."}, {"role": "user", "type": "pageExcerpt", "content": "I have spoken."}, diff --git a/components/ai_chat/core/browser/engine/engine_consumer_llama_unittest.cc b/components/ai_chat/core/browser/engine/engine_consumer_llama_unittest.cc index 3ce74fe4d34e..e01839d71703 100644 --- a/components/ai_chat/core/browser/engine/engine_consumer_llama_unittest.cc +++ b/components/ai_chat/core/browser/engine/engine_consumer_llama_unittest.cc @@ -16,6 +16,7 @@ #include "base/strings/string_util.h" #include "base/test/bind.h" #include "base/test/task_environment.h" +#include "base/time/time.h" #include "brave/components/ai_chat/core/browser/engine/engine_consumer.h" #include "brave/components/ai_chat/core/browser/engine/mock_remote_completion_client.h" #include "brave/components/ai_chat/core/browser/model_service.h" @@ -69,12 +70,12 @@ TEST_F(EngineConsumerLlamaUnitTest, TestGenerateAssistantResponse) { history.push_back(mojom::ConversationTurn::New( mojom::CharacterType::HUMAN, mojom::ActionType::SUMMARIZE_SELECTED_TEXT, mojom::ConversationTurnVisibility::VISIBLE, - "Which show is this catchphrase from?", "This is the way.", - std::nullopt)); + "Which show is this catchphrase from?", "This is the way.", std::nullopt, + base::Time::Now(), std::nullopt)); history.push_back(mojom::ConversationTurn::New( mojom::CharacterType::ASSISTANT, mojom::ActionType::RESPONSE, mojom::ConversationTurnVisibility::VISIBLE, "The Mandalorian.", - std::nullopt, std::nullopt)); + std::nullopt, std::nullopt, base::Time::Now(), std::nullopt)); auto* mock_remote_completion_client = static_cast(engine_->GetAPIForTesting()); std::string prompt_before_time_and_date = diff --git a/components/ai_chat/core/browser/engine/engine_consumer_oai_unittest.cc b/components/ai_chat/core/browser/engine/engine_consumer_oai_unittest.cc index e58d6cc74eb0..1646e751981a 100644 --- a/components/ai_chat/core/browser/engine/engine_consumer_oai_unittest.cc +++ b/components/ai_chat/core/browser/engine/engine_consumer_oai_unittest.cc @@ -8,6 +8,7 @@ #include #include #include +#include #include "base/functional/callback_helpers.h" #include "base/i18n/time_formatting.h" @@ -15,6 +16,7 @@ #include "base/strings/string_util.h" #include "base/test/bind.h" #include "base/test/task_environment.h" +#include "base/time/time.h" #include "brave/components/ai_chat/core/browser/engine/engine_consumer.h" #include "brave/components/ai_chat/core/common/mojom/ai_chat.mojom.h" #include "components/grit/brave_components_strings.h" @@ -203,12 +205,12 @@ TEST_F(EngineConsumerOAIUnitTest, TestGenerateAssistantResponse) { history.push_back(mojom::ConversationTurn::New( mojom::CharacterType::HUMAN, mojom::ActionType::SUMMARIZE_SELECTED_TEXT, mojom::ConversationTurnVisibility::VISIBLE, human_input, selected_text, - std::nullopt)); + std::nullopt, base::Time::Now(), std::nullopt)); history.push_back(mojom::ConversationTurn::New( mojom::CharacterType::ASSISTANT, mojom::ActionType::RESPONSE, mojom::ConversationTurnVisibility::VISIBLE, assistant_input, std::nullopt, - std::nullopt)); + std::nullopt, base::Time::Now(), std::nullopt)); std::string date_and_time_string = base::UTF16ToUTF8(TimeFormatFriendlyDateAndTime(base::Time::Now())); diff --git a/components/ai_chat/core/common/mojom/ai_chat.mojom b/components/ai_chat/core/common/mojom/ai_chat.mojom index 33dbb6a1ee97..5c09e928020d 100644 --- a/components/ai_chat/core/common/mojom/ai_chat.mojom +++ b/components/ai_chat/core/common/mojom/ai_chat.mojom @@ -147,6 +147,15 @@ struct ConversationTurn { // to be an event - as the events become richer, the order around text could // be important. array? events; + + mojo_base.mojom.Time created_time; + + // Edits to this turn, sorted by time of creation, with the most recent edit + // at the end of the array. When this appears, the value of |text| field is + // the original text of the turn, the last entry of this array should be used + // instead of the original turn text when submitting the turn to the AI + // engine or displaying the most recent text to users. + array? edits; }; // Represents an AI engine model choice, usually for the user to choose for a @@ -264,6 +273,7 @@ interface PageHandler { ClosePanel(); GetActionMenuList() => (array action_list); OpenModelSupportUrl(); + ModifyConversation(uint32 turn_index, string new_text); }; interface ChatUIPage { diff --git a/components/ai_chat/resources/page/api/mock_page_handler.ts b/components/ai_chat/resources/page/api/mock_page_handler.ts index 0bd9fd21ed8c..79319f35d90b 100644 --- a/components/ai_chat/resources/page/api/mock_page_handler.ts +++ b/components/ai_chat/resources/page/api/mock_page_handler.ts @@ -136,6 +136,7 @@ export class MockPageHandlerRemote implements Public { dismissPremiumPrompt() {} closePanel() {} openModelSupportUrl() {} + modifyConversation() {} } const router = { diff --git a/components/ai_chat/resources/page/components/conversation_list/index.tsx b/components/ai_chat/resources/page/components/conversation_list/index.tsx index 7e0436f4bc15..6ef98df93db1 100644 --- a/components/ai_chat/resources/page/components/conversation_list/index.tsx +++ b/components/ai_chat/resources/page/components/conversation_list/index.tsx @@ -19,6 +19,9 @@ import LongPageInfo from '../alerts/long_page_info' import AssistantResponse from '../assistant_response' import styles from './style.module.scss' import CopyButton from '../copy_button' +import EditButton from '../edit_button' +import EditInput from '../edit_input' +import EditIndicator from '../edit_indicator' const SUGGESTION_STATUS_SHOW_BUTTON: mojom.SuggestionGenerationStatus[] = [ mojom.SuggestionGenerationStatus.CanGenerate, @@ -53,6 +56,12 @@ function ConversationList(props: ConversationListProps) { const lastEntryElementRef = React.useRef(null) const [activeMenuId, setActiveMenuId] = React.useState() + const [editInputId, setEditInputId] = React.useState() + + const handleEditSubmit = (index: number, text: string) => { + getPageHandlerInstance().pageHandler.modifyConversation(index, text) + setEditInputId(null) + } const showAssistantMenu = (id: number) => { setActiveMenuId(id) @@ -103,6 +112,11 @@ function ConversationList(props: ConversationListProps) { const showSiteTitle = id === 0 && isHuman && shouldSendPageContents const showLongPageContentInfo = id === 1 && isAIAssistant && context.shouldShowLongPageWarning + const showEditInput = editInputId === id + const showEditIndicator = !showEditInput && !!turn.edits?.length + const latestEdit = turn.edits?.at(-1); + const latestTurnText = latestEdit?.text ?? turn.text + const lastEditedTime = latestEdit?.createdTime ?? turn.createdTime const turnContainer = classnames({ [styles.turnContainerMobile]: context.isMobile, @@ -121,9 +135,13 @@ function ConversationList(props: ConversationListProps) { }) const handleCopyText = () => { - const event = turn.events?.find((event) => event.completionEvent) - if (!event?.completionEvent) return - navigator.clipboard.writeText(event.completionEvent.completion) + if (isAIAssistant) { + const event = turn.events?.find((event) => event.completionEvent) + if (!event?.completionEvent) return + navigator.clipboard.writeText(event.completionEvent.completion) + } else { + navigator.clipboard.writeText(latestTurnText) + } } return ( @@ -149,20 +167,24 @@ function ConversationList(props: ConversationListProps) { {isHuman ? 'You' : 'Leo'} - {isAIAssistant && ( -
- - {context.currentModel?.options.leoModelOptions && ( - showAssistantMenu(id)} - onClose={hideAssistantMenu} - /> - )} -
- )} + {!turn.selectedText && ( +
+ + {!isAIAssistant && ( + setEditInputId(id)} /> + )} + {isAIAssistant && + context.currentModel?.options.leoModelOptions && ( + showAssistantMenu(id)} + onClose={hideAssistantMenu} + /> + )} +
+ )}
{isAIAssistant && ( @@ -171,7 +193,19 @@ function ConversationList(props: ConversationListProps) { isEntryInProgress={isEntryInProgress} /> )} - {!isAIAssistant && !turn.selectedText && turn.text} + {!isAIAssistant && !turn.selectedText && !showEditInput && + latestTurnText} + {showEditIndicator && ( + + )} + {showEditInput && ( + handleEditSubmit(id, text)} + onCancel={() => setEditInputId(null)} + isSubmitDisabled={shouldDisableUserInput} + /> + )} {turn.selectedText && ( )} diff --git a/components/ai_chat/resources/page/components/edit_button/index.tsx b/components/ai_chat/resources/page/components/edit_button/index.tsx new file mode 100644 index 000000000000..d3ad939cb8c5 --- /dev/null +++ b/components/ai_chat/resources/page/components/edit_button/index.tsx @@ -0,0 +1,29 @@ +/* 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 { getLocale } from '$web-common/locale' +import Button from '@brave/leo/react/button' +import Icon from '@brave/leo/react/icon' +import styles from './style.module.scss' + +interface Props { + onClick: () => void +} + +function EditButton (props: Props) { + return ( + + ) +} + +export default EditButton diff --git a/components/ai_chat/resources/page/components/edit_button/style.module.scss b/components/ai_chat/resources/page/components/edit_button/style.module.scss new file mode 100644 index 000000000000..91c5792a6897 --- /dev/null +++ b/components/ai_chat/resources/page/components/edit_button/style.module.scss @@ -0,0 +1,9 @@ +// 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/. + +.editButton { + color: var(--leo-color-gray-30); +} + diff --git a/components/ai_chat/resources/page/components/edit_indicator/index.tsx b/components/ai_chat/resources/page/components/edit_indicator/index.tsx new file mode 100644 index 000000000000..0137b5e165f3 --- /dev/null +++ b/components/ai_chat/resources/page/components/edit_indicator/index.tsx @@ -0,0 +1,39 @@ +/* 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 { Time } from 'gen/mojo/public/mojom/base/time.mojom.m.js' +import { mojoTimeToJSDate } from '$web-common/mojomUtils' +import { getLocale } from '$web-common/locale' +import Icon from '@brave/leo/react/icon' + +import styles from './style.module.scss' + +interface Props { + time: Time; +} + +const dateTimeFormatter = new Intl.DateTimeFormat(undefined, { + month: 'short', + day: 'numeric', + year: 'numeric', + hour: 'numeric', + minute: 'numeric' +}); + +function EditIndicator(props: Props) { + return ( +
+ + {getLocale('editedLabel')} + + {dateTimeFormatter.format(mojoTimeToJSDate(props.time))} + +
+ ); +} + +export default EditIndicator; diff --git a/components/ai_chat/resources/page/components/edit_indicator/style.module.scss b/components/ai_chat/resources/page/components/edit_indicator/style.module.scss new file mode 100644 index 000000000000..9b09adffa8ea --- /dev/null +++ b/components/ai_chat/resources/page/components/edit_indicator/style.module.scss @@ -0,0 +1,21 @@ +// 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/. + +.editIndicator { + margin-top: var(--leo-spacing-xl); + display: flex; + align-items: flex-start; + gap: var(--leo-spacing-m); + align-self: stretch; + --leo-icon-size: 18px; +} + +.editedText { + color: var(--leo-color-text-secondary); +} + +.time { + color: var(--leo-color-text-tertiary); +} diff --git a/components/ai_chat/resources/page/components/edit_input/index.tsx b/components/ai_chat/resources/page/components/edit_input/index.tsx new file mode 100644 index 000000000000..4327607fb68f --- /dev/null +++ b/components/ai_chat/resources/page/components/edit_input/index.tsx @@ -0,0 +1,71 @@ +// 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 Button from '@brave/leo/react/button' +import { getLocale } from '$web-common/locale' + +import styles from './style.module.scss' + +interface Props { + text: string + onSubmit: (text: string) => void + onCancel: () => void + isSubmitDisabled: boolean +} + +function EditInput(props: Props) { + const [text, setText] = React.useState(props.text) + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing && + !props.isSubmitDisabled) { + if (!e.repeat) { + props.onSubmit(e.currentTarget.value) + } + e.preventDefault() + } else if (e.key === 'Escape') { + props.onCancel() + } + } + + return ( +
+
+