From 33a72219c040d6982e27812d2578507fc68132f2 Mon Sep 17 00:00:00 2001 From: Marshall Bowers Date: Tue, 7 May 2024 18:16:48 -0400 Subject: [PATCH] assistant2: Add new conversation button, that also saves the current conversation (#11522) This PR updates the new assistant with a button to start a new conversation. Clicking on it will reset the chat and put it into a fresh state. The current conversation will be serialized and written to `~/.config/zed/conversations`. Release Notes: - N/A --- Cargo.lock | 1 + crates/assistant/src/saved_conversation.rs | 5 + crates/assistant2/Cargo.toml | 1 + crates/assistant2/src/assistant2.rs | 105 ++++++++++++++++++-- crates/assistant2/src/saved_conversation.rs | 20 ++++ 5 files changed, 126 insertions(+), 6 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index a40047b4ffd7e28142420a0c390ee17711821878..e4efd88faf8866b93080d516b9f66964e3f30fd2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -382,6 +382,7 @@ dependencies = [ "editor", "env_logger", "feature_flags", + "fs", "futures 0.3.28", "fuzzy", "gpui", diff --git a/crates/assistant/src/saved_conversation.rs b/crates/assistant/src/saved_conversation.rs index 5e6ce613226ad6489340f66e5777def102cf218e..ac6c925a43e1220f94eab2d9f73cda04281531d5 100644 --- a/crates/assistant/src/saved_conversation.rs +++ b/crates/assistant/src/saved_conversation.rs @@ -106,6 +106,11 @@ impl SavedConversationMetadata { .and_then(|name| name.to_str()) .zip(metadata) { + // This is used to filter out conversations saved by the new assistant. + if !re.is_match(file_name) { + continue; + } + let title = re.replace(file_name, ""); conversations.push(Self { title: title.into_owned(), diff --git a/crates/assistant2/Cargo.toml b/crates/assistant2/Cargo.toml index b8982b4d5ef213495d276298f6148033343ee80b..51602c4162b43fa92de7578c1b5eab386a6bee7f 100644 --- a/crates/assistant2/Cargo.toml +++ b/crates/assistant2/Cargo.toml @@ -22,6 +22,7 @@ client.workspace = true collections.workspace = true editor.workspace = true feature_flags.workspace = true +fs.workspace = true futures.workspace = true fuzzy.workspace = true gpui.workspace = true diff --git a/crates/assistant2/src/assistant2.rs b/crates/assistant2/src/assistant2.rs index 5c2863f58c709cf45cdd02c60af18b08724eb040..6e4c8544a2f17062b9fe30ba57456009d0da61fe 100644 --- a/crates/assistant2/src/assistant2.rs +++ b/crates/assistant2/src/assistant2.rs @@ -6,13 +6,14 @@ mod saved_conversation_picker; mod tools; pub mod ui; +use crate::saved_conversation::{SavedConversation, SavedMessage, SavedMessageRole}; use crate::saved_conversation_picker::SavedConversationPicker; use crate::{ attachments::ActiveEditorAttachmentTool, tools::{CreateBufferTool, ProjectIndexTool}, ui::UserOrAssistant, }; -use ::ui::{div, prelude::*, Color, ViewContext}; +use ::ui::{div, prelude::*, Color, Tooltip, ViewContext}; use anyhow::{Context, Result}; use assistant_tooling::{ AttachmentRegistry, ProjectContext, ToolFunctionCall, ToolRegistry, UserAttachment, @@ -22,6 +23,7 @@ use collections::HashMap; use completion_provider::*; use editor::Editor; use feature_flags::FeatureFlagAppExt as _; +use fs::Fs; use futures::{future::join_all, StreamExt}; use gpui::{ list, AnyElement, AppContext, AsyncWindowContext, ClickEvent, EventEmitter, FocusHandle, @@ -31,11 +33,12 @@ use language::{language_settings::SoftWrap, LanguageRegistry}; use open_ai::{FunctionContent, ToolCall, ToolCallContent}; use rich_text::RichText; use semantic_index::{CloudEmbeddingProvider, ProjectIndex, ProjectIndexDebugView, SemanticIndex}; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use settings::Settings; use std::sync::Arc; use tools::OpenBufferTool; use ui::{ActiveFileButton, Composer, ProjectIndexButton}; +use util::paths::CONVERSATIONS_DIR; use util::{maybe, paths::EMBEDDINGS_DIR, ResultExt}; use workspace::{ dock::{DockPosition, Panel, PanelEvent}, @@ -155,6 +158,7 @@ impl AssistantPanel { .register(ActiveEditorAttachmentTool::new(workspace.clone(), cx)); Self::new( + project.read(cx).fs().clone(), app_state.languages.clone(), Arc::new(tool_registry), Arc::new(attachment_registry), @@ -167,7 +171,9 @@ impl AssistantPanel { }) } + #[allow(clippy::too_many_arguments)] pub fn new( + fs: Arc, language_registry: Arc, tool_registry: Arc, attachment_registry: Arc, @@ -178,6 +184,7 @@ impl AssistantPanel { ) -> Self { let chat = cx.new_view(|cx| { AssistantChat::new( + fs, language_registry, tool_registry.clone(), attachment_registry, @@ -254,6 +261,7 @@ pub struct AssistantChat { model: String, messages: Vec, list_state: ListState, + fs: Arc, language_registry: Arc, composer_editor: View, project_index_button: View, @@ -275,7 +283,9 @@ struct EditingMessage { } impl AssistantChat { + #[allow(clippy::too_many_arguments)] fn new( + fs: Arc, language_registry: Arc, tool_registry: Arc, attachment_registry: Arc, @@ -320,6 +330,7 @@ impl AssistantChat { }), list_state, user_store, + fs, language_registry, project_index_button, active_file_button, @@ -657,6 +668,69 @@ impl AssistantChat { *entry = !*entry; } + fn new_conversation(&mut self, cx: &mut ViewContext) { + let messages = self + .messages + .drain(..) + .map(|message| { + let text = match &message { + ChatMessage::User(message) => message.body.read(cx).text(cx), + ChatMessage::Assistant(message) => message + .messages + .iter() + .map(|message| message.body.text.to_string()) + .collect::>() + .join("\n\n"), + }; + + SavedMessage { + id: message.id(), + role: match message { + ChatMessage::User(_) => SavedMessageRole::User, + ChatMessage::Assistant(_) => SavedMessageRole::Assistant, + }, + text, + } + }) + .collect::>(); + + // Reset the chat for the new conversation. + self.list_state.reset(0); + self.editing_message.take(); + self.collapsed_messages.clear(); + + let title = messages + .first() + .map(|message| message.text.clone()) + .unwrap_or_else(|| "A conversation with the assistant.".to_string()); + + let saved_conversation = SavedConversation { + version: "0.3.0".to_string(), + title, + messages, + }; + + let discriminant = 1; + + let path = CONVERSATIONS_DIR.join(&format!( + "{title} - {discriminant}.zed.{version}.json", + title = saved_conversation.title, + version = saved_conversation.version + )); + + cx.spawn({ + let fs = self.fs.clone(); + |_this, _cx| async move { + fs.create_dir(CONVERSATIONS_DIR.as_ref()).await?; + fs.atomic_write(path, serde_json::to_string(&saved_conversation)?) + .await?; + + anyhow::Ok(()) + } + }) + .detach_and_log_err(cx); + } + fn render_error( &self, error: Option, @@ -684,7 +758,7 @@ impl AssistantChat { fn render_message(&self, ix: usize, cx: &mut ViewContext) -> AnyElement { let is_first = ix == 0; - let is_last = ix == self.messages.len() - 1; + let is_last = ix == self.messages.len().saturating_sub(1); let padding = Spacing::Large.rems(cx); @@ -905,8 +979,20 @@ impl Render for AssistantChat { .on_action(cx.listener(Self::cancel)) .text_color(Color::Default.color(cx)) .child( - Button::new("open-saved-conversations", "Saved Conversations") - .on_click(|_event, cx| cx.dispatch_action(Box::new(ToggleSavedConversations))), + h_flex() + .gap_2() + .child( + Button::new("open-saved-conversations", "Saved Conversations").on_click( + |_event, cx| cx.dispatch_action(Box::new(ToggleSavedConversations)), + ), + ) + .child( + IconButton::new("new-conversation", IconName::Plus) + .on_click(cx.listener(move |this, _event, cx| { + this.new_conversation(cx); + })) + .tooltip(move |cx| Tooltip::text("New Conversation", cx)), + ), ) .child(list(self.list_state.clone()).flex_1()) .child(Composer::new( @@ -919,7 +1005,7 @@ impl Render for AssistantChat { } } -#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy)] +#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy, Serialize, Deserialize)] pub struct MessageId(usize); impl MessageId { @@ -936,6 +1022,13 @@ enum ChatMessage { } impl ChatMessage { + pub fn id(&self) -> MessageId { + match self { + ChatMessage::User(message) => message.id, + ChatMessage::Assistant(message) => message.id, + } + } + fn focus_handle(&self, cx: &AppContext) -> Option { match self { ChatMessage::User(UserMessage { body, .. }) => Some(body.focus_handle(cx)), diff --git a/crates/assistant2/src/saved_conversation.rs b/crates/assistant2/src/saved_conversation.rs index ed0e6a3d4becfe5511ef85190a4ed42b14f95699..2eb6af0557af7118459c891469b507646253ca77 100644 --- a/crates/assistant2/src/saved_conversation.rs +++ b/crates/assistant2/src/saved_conversation.rs @@ -1,10 +1,27 @@ +use serde::{Deserialize, Serialize}; + +use crate::MessageId; + +#[derive(Serialize, Deserialize)] pub struct SavedConversation { + /// The schema version of the conversation. + pub version: String, /// The title of the conversation, generated by the Assistant. pub title: String, pub messages: Vec, } +#[derive(Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum SavedMessageRole { + User, + Assistant, +} + +#[derive(Serialize, Deserialize)] pub struct SavedMessage { + pub id: MessageId, + pub role: SavedMessageRole, pub text: String, } @@ -14,14 +31,17 @@ pub struct SavedMessage { pub fn placeholder_conversations() -> Vec { vec![ SavedConversation { + version: "0.3.0".to_string(), title: "How to get a list of exported functions in an Erlang module".to_string(), messages: vec![], }, SavedConversation { + version: "0.3.0".to_string(), title: "7 wonders of the ancient world".to_string(), messages: vec![], }, SavedConversation { + version: "0.3.0".to_string(), title: "Size difference between u8 and a reference to u8 in Rust".to_string(), messages: vec![], },