diff --git a/crates/assistant/src/assistant.rs b/crates/assistant/src/assistant.rs index a7efd5c0fe..b3b6b8d4f8 100644 --- a/crates/assistant/src/assistant.rs +++ b/crates/assistant/src/assistant.rs @@ -1,11 +1,11 @@ pub mod assistant_panel; pub mod assistant_settings; mod completion_provider; +mod conversation_store; mod inline_assistant; mod model_selector; mod prompt_library; mod prompts; -mod saved_conversation; mod search; mod slash_command; mod streaming_diff; @@ -17,10 +17,10 @@ use assistant_slash_command::SlashCommandRegistry; use client::{proto, Client}; use command_palette_hooks::CommandPaletteFilter; pub(crate) use completion_provider::*; +pub(crate) use conversation_store::*; use gpui::{actions, AppContext, Global, SharedString, UpdateGlobal}; pub(crate) use inline_assistant::*; pub(crate) use model_selector::*; -pub(crate) use saved_conversation::*; use semantic_index::{CloudEmbeddingProvider, SemanticIndex}; use serde::{Deserialize, Serialize}; use settings::{Settings, SettingsStore}; diff --git a/crates/assistant/src/assistant_panel.rs b/crates/assistant/src/assistant_panel.rs index 223a8d0fe6..ad8312990e 100644 --- a/crates/assistant/src/assistant_panel.rs +++ b/crates/assistant/src/assistant_panel.rs @@ -6,10 +6,10 @@ use crate::{ default_command::DefaultSlashCommand, SlashCommandCompletionProvider, SlashCommandLine, SlashCommandRegistry, }, - ApplyEdit, Assist, CompletionProvider, ConfirmCommand, CycleMessageRole, InlineAssist, - InlineAssistant, LanguageModelRequest, LanguageModelRequestMessage, MessageId, MessageMetadata, - MessageStatus, ModelSelector, QuoteSelection, ResetKey, Role, SavedConversation, - SavedConversationMetadata, SavedMessage, Split, ToggleFocus, ToggleHistory, + ApplyEdit, Assist, CompletionProvider, ConfirmCommand, ConversationStore, CycleMessageRole, + InlineAssist, InlineAssistant, LanguageModelRequest, LanguageModelRequestMessage, MessageId, + MessageMetadata, MessageStatus, ModelSelector, QuoteSelection, ResetKey, Role, + SavedConversation, SavedConversationMetadata, SavedMessage, Split, ToggleFocus, ToggleHistory, ToggleModelSelector, }; use anyhow::{anyhow, Result}; @@ -29,17 +29,18 @@ use fs::Fs; use futures::future::Shared; use futures::{FutureExt, StreamExt}; use gpui::{ - div, point, rems, uniform_list, Action, AnyElement, AnyView, AppContext, AsyncAppContext, - AsyncWindowContext, ClipboardItem, Context, Empty, EventEmitter, FocusHandle, FocusableView, - InteractiveElement, IntoElement, Model, ModelContext, ParentElement, Pixels, Render, - SharedString, StatefulInteractiveElement, Styled, Subscription, Task, UniformListScrollHandle, - UpdateGlobal, View, ViewContext, VisualContext, WeakView, WindowContext, + div, point, rems, Action, AnyElement, AnyView, AppContext, AsyncAppContext, AsyncWindowContext, + ClipboardItem, Context, Empty, EventEmitter, FocusHandle, FocusableView, InteractiveElement, + IntoElement, Model, ModelContext, ParentElement, Pixels, Render, SharedString, + StatefulInteractiveElement, Styled, Subscription, Task, UpdateGlobal, View, ViewContext, + VisualContext, WeakView, WindowContext, }; use language::{ language_settings::SoftWrap, AnchorRangeExt, AutoindentMode, Buffer, LanguageRegistry, LspAdapterDelegate, OffsetRangeExt as _, Point, ToOffset as _, }; use multi_buffer::MultiBufferRow; +use picker::{Picker, PickerDelegate}; use project::{Project, ProjectLspAdapterDelegate, ProjectTransaction}; use search::{buffer_search::DivRegistrar, BufferSearchBar}; use settings::Settings; @@ -54,8 +55,8 @@ use std::{ }; use telemetry_events::AssistantKind; use ui::{ - popover_menu, prelude::*, ButtonLike, ContextMenu, ElevationIndex, KeyBinding, - PopoverMenuHandle, Tab, TabBar, Tooltip, + popover_menu, prelude::*, ButtonLike, ContextMenu, ElevationIndex, KeyBinding, ListItem, + ListItemSpacing, PopoverMenuHandle, Tab, TabBar, Tooltip, }; use util::{paths::CONVERSATIONS_DIR, post_inc, ResultExt, TryFutureExt}; use uuid::Uuid; @@ -93,8 +94,8 @@ pub struct AssistantPanel { height: Option, active_conversation_editor: Option, show_saved_conversations: bool, - saved_conversations: Vec, - saved_conversations_scroll_handle: UniformListScrollHandle, + conversation_store: Model, + saved_conversation_picker: View>, zoomed: bool, focus_handle: FocusHandle, toolbar: View, @@ -103,11 +104,102 @@ pub struct AssistantPanel { fs: Arc, telemetry: Arc, _subscriptions: Vec, - _watch_saved_conversations: Task>, authentication_prompt: Option, model_menu_handle: PopoverMenuHandle, } +struct SavedConversationPickerDelegate { + store: Model, + matches: Vec, + selected_index: usize, +} + +enum SavedConversationPickerEvent { + Confirmed { path: PathBuf }, +} + +impl EventEmitter for Picker {} + +impl SavedConversationPickerDelegate { + fn new(store: Model) -> Self { + Self { + store, + matches: Vec::new(), + selected_index: 0, + } + } +} + +impl PickerDelegate for SavedConversationPickerDelegate { + type ListItem = ListItem; + + fn match_count(&self) -> usize { + self.matches.len() + } + + fn selected_index(&self) -> usize { + self.selected_index + } + + fn set_selected_index(&mut self, ix: usize, _cx: &mut ViewContext>) { + self.selected_index = ix; + } + + fn placeholder_text(&self, _cx: &mut WindowContext) -> Arc { + "Search...".into() + } + + fn update_matches(&mut self, query: String, cx: &mut ViewContext>) -> Task<()> { + let search = self.store.read(cx).search(query, cx); + cx.spawn(|this, mut cx| async move { + let matches = search.await; + this.update(&mut cx, |this, cx| { + this.delegate.matches = matches; + this.delegate.selected_index = 0; + cx.notify(); + }) + .ok(); + }) + } + + fn confirm(&mut self, _secondary: bool, cx: &mut ViewContext>) { + if let Some(metadata) = self.matches.get(self.selected_index) { + cx.emit(SavedConversationPickerEvent::Confirmed { + path: metadata.path.clone(), + }) + } + } + + fn dismissed(&mut self, _cx: &mut ViewContext>) {} + + fn render_match( + &self, + ix: usize, + selected: bool, + _cx: &mut ViewContext>, + ) -> Option { + let conversation = self.matches.get(ix)?; + Some( + ListItem::new(ix) + .inset(true) + .spacing(ListItemSpacing::Sparse) + .selected(selected) + .child( + div() + .flex() + .w_full() + .gap_2() + .child( + Label::new(conversation.mtime.format("%F %I:%M%p").to_string()) + .color(Color::Muted) + .size(LabelSize::Small), + ) + .child(Label::new(conversation.title.clone()).size(LabelSize::Small)), + ), + ) + } +} + struct ActiveConversationEditor { editor: View, _subscriptions: Vec, @@ -120,35 +212,14 @@ impl AssistantPanel { ) -> Task>> { cx.spawn(|mut cx| async move { let fs = workspace.update(&mut cx, |workspace, _| workspace.app_state().fs.clone())?; - let saved_conversations = SavedConversationMetadata::list(fs.clone()) - .await - .log_err() - .unwrap_or_default(); + let conversation_store = cx + .update(|cx| ConversationStore::new(fs.clone(), cx))? + .await?; // TODO: deserialize state. let workspace_handle = workspace.clone(); workspace.update(&mut cx, |workspace, cx| { cx.new_view::(|cx| { - const CONVERSATION_WATCH_DURATION: Duration = Duration::from_millis(100); - let _watch_saved_conversations = cx.spawn(move |this, mut cx| async move { - let (mut events, _) = fs - .watch(&CONVERSATIONS_DIR, CONVERSATION_WATCH_DURATION) - .await; - while events.next().await.is_some() { - let saved_conversations = SavedConversationMetadata::list(fs.clone()) - .await - .log_err() - .unwrap_or_default(); - this.update(&mut cx, |this, cx| { - this.saved_conversations = saved_conversations; - cx.notify(); - }) - .ok(); - } - - anyhow::Ok(()) - }); - let toolbar = cx.new_view(|cx| { let mut toolbar = Toolbar::new(); toolbar.set_can_navigate(false, cx); @@ -156,6 +227,15 @@ impl AssistantPanel { toolbar }); + let saved_conversation_picker = cx.new_view(|cx| { + Picker::uniform_list( + SavedConversationPickerDelegate::new(conversation_store.clone()), + cx, + ) + .modal(false) + .max_height(None) + }); + let focus_handle = cx.focus_handle(); let subscriptions = vec![ cx.on_focus_in(&focus_handle, Self::focus_in), @@ -169,6 +249,14 @@ impl AssistantPanel { CompletionProvider::global(cx).settings_version(); } }), + cx.observe(&conversation_store, |this, _, cx| { + this.saved_conversation_picker + .update(cx, |picker, cx| picker.refresh(cx)); + }), + cx.subscribe( + &saved_conversation_picker, + Self::handle_saved_conversation_picker_event, + ), ]; cx.observe_global::(|_, cx| { @@ -180,8 +268,8 @@ impl AssistantPanel { workspace: workspace_handle, active_conversation_editor: None, show_saved_conversations: false, - saved_conversations, - saved_conversations_scroll_handle: Default::default(), + saved_conversation_picker, + conversation_store, zoomed: false, focus_handle, toolbar, @@ -192,7 +280,6 @@ impl AssistantPanel { width: None, height: None, _subscriptions: subscriptions, - _watch_saved_conversations, authentication_prompt: None, model_menu_handle: PopoverMenuHandle::default(), } @@ -206,8 +293,10 @@ impl AssistantPanel { .update(cx, |toolbar, cx| toolbar.focus_changed(true, cx)); cx.notify(); if self.focus_handle.is_focused(cx) { - if let Some(editor) = self.active_conversation_editor() { - cx.focus_view(editor); + if self.show_saved_conversations { + cx.focus_view(&self.saved_conversation_picker); + } else if let Some(conversation) = self.active_conversation_editor() { + cx.focus_view(conversation); } } } @@ -251,6 +340,20 @@ impl AssistantPanel { } } + fn handle_saved_conversation_picker_event( + &mut self, + _picker: View>, + event: &SavedConversationPickerEvent, + cx: &mut ViewContext, + ) { + match event { + SavedConversationPickerEvent::Confirmed { path } => { + self.open_conversation(path.clone(), cx) + .detach_and_log_err(cx); + } + } + } + pub fn inline_assist( workspace: &mut Workspace, _: &InlineAssist, @@ -409,17 +512,29 @@ impl AssistantPanel { } fn toggle_history(&mut self, _: &ToggleHistory, cx: &mut ViewContext) { - self.show_saved_conversations = !self.show_saved_conversations; - cx.notify(); + if self.show_saved_conversations { + self.hide_history(cx); + } else { + self.show_history(cx); + } } fn show_history(&mut self, cx: &mut ViewContext) { + cx.focus_view(&self.saved_conversation_picker); if !self.show_saved_conversations { self.show_saved_conversations = true; cx.notify(); } } + fn hide_history(&mut self, cx: &mut ViewContext) { + if let Some(editor) = self.active_conversation_editor() { + cx.focus_view(&editor); + self.show_saved_conversations = false; + cx.notify(); + } + } + fn deploy(&mut self, action: &search::buffer_search::Deploy, cx: &mut ViewContext) { let mut propagate = true; if let Some(search_bar) = self.toolbar.read(cx).item_of_type::() { @@ -613,37 +728,10 @@ impl AssistantPanel { }) } - fn render_saved_conversation( - &mut self, - index: usize, - cx: &mut ViewContext, - ) -> impl IntoElement { - let conversation = &self.saved_conversations[index]; - let path = conversation.path.clone(); - - ButtonLike::new(index) - .on_click(cx.listener(move |this, _, cx| { - this.open_conversation(path.clone(), cx) - .detach_and_log_err(cx) - })) - .full_width() - .child( - div() - .flex() - .w_full() - .gap_2() - .child( - Label::new(conversation.mtime.format("%F %I:%M%p").to_string()) - .color(Color::Muted) - .size(LabelSize::Small), - ) - .child(Label::new(conversation.title.clone()).size(LabelSize::Small)), - ) - } - fn open_conversation(&mut self, path: PathBuf, cx: &mut ViewContext) -> Task> { cx.focus(&self.focus_handle); + let saved_conversation = self.conversation_store.read(cx).load(path.clone(), cx); let fs = self.fs.clone(); let workspace = self.workspace.clone(); let slash_commands = self.slash_commands.clone(); @@ -658,7 +746,7 @@ impl AssistantPanel { .flatten(); cx.spawn(|this, mut cx| async move { - let saved_conversation = SavedConversation::load(&path, fs.as_ref()).await?; + let saved_conversation = saved_conversation.await?; let conversation = Conversation::deserialize( saved_conversation, path.clone(), @@ -705,7 +793,13 @@ impl AssistantPanel { .h(rems(Tab::CONTAINER_HEIGHT_IN_REMS)) .flex_1() .px_2() - .child(Label::new(editor.read(cx).title(cx)).into_element()) + .child( + div() + .id("title") + .cursor_pointer() + .on_click(cx.listener(|this, _, cx| this.hide_history(cx))) + .child(Label::new(editor.read(cx).title(cx))), + ) })) .end_child( h_flex() @@ -780,22 +874,10 @@ impl AssistantPanel { }) .child(contents.flex_1().child( if self.show_saved_conversations || self.active_conversation_editor().is_none() { - let view = cx.view().clone(); - let scroll_handle = self.saved_conversations_scroll_handle.clone(); - let conversation_count = self.saved_conversations.len(); - uniform_list( - view, - "saved_conversations", - conversation_count, - |this, range, cx| { - range - .map(|ix| this.render_saved_conversation(ix, cx)) - .collect() - }, - ) - .size_full() - .track_scroll(scroll_handle) - .into_any_element() + div() + .size_full() + .child(self.saved_conversation_picker.clone()) + .into_any_element() } else if let Some(editor) = self.active_conversation_editor() { let editor = editor.clone(); div() @@ -1809,11 +1891,10 @@ impl Conversation { let messages = self .messages(cx) - .take(2) .map(|message| message.to_request_message(self.buffer.read(cx))) .chain(Some(LanguageModelRequestMessage { role: Role::User, - content: "Summarize the conversation into a short title without punctuation" + content: "Summarize the conversation into a short title without punctuation." .into(), })); let request = LanguageModelRequest { @@ -1830,13 +1911,17 @@ impl Conversation { while let Some(message) = messages.next().await { let text = message?; + let mut lines = text.lines(); this.update(&mut cx, |this, cx| { - this.summary - .get_or_insert(Default::default()) - .text - .push_str(&text); + let summary = this.summary.get_or_insert(Default::default()); + summary.text.extend(lines.next()); cx.emit(ConversationEvent::SummaryChanged); })?; + + // Stop if the LLM generated multiple lines. + if lines.next().is_some() { + break; + } } this.update(&mut cx, |this, cx| { diff --git a/crates/assistant/src/conversation_store.rs b/crates/assistant/src/conversation_store.rs new file mode 100644 index 0000000000..4e683ff475 --- /dev/null +++ b/crates/assistant/src/conversation_store.rs @@ -0,0 +1,203 @@ +use crate::{assistant_settings::OpenAiModel, MessageId, MessageMetadata}; +use anyhow::{anyhow, Result}; +use collections::HashMap; +use fs::Fs; +use futures::StreamExt; +use fuzzy::StringMatchCandidate; +use gpui::{AppContext, Model, ModelContext, Task}; +use regex::Regex; +use serde::{Deserialize, Serialize}; +use std::{cmp::Reverse, ffi::OsStr, path::PathBuf, sync::Arc, time::Duration}; +use ui::Context; +use util::{paths::CONVERSATIONS_DIR, ResultExt, TryFutureExt}; + +#[derive(Serialize, Deserialize)] +pub struct SavedMessage { + pub id: MessageId, + pub start: usize, +} + +#[derive(Serialize, Deserialize)] +pub struct SavedConversation { + pub id: Option, + pub zed: String, + pub version: String, + pub text: String, + pub messages: Vec, + pub message_metadata: HashMap, + pub summary: String, +} + +impl SavedConversation { + pub const VERSION: &'static str = "0.2.0"; +} + +#[derive(Serialize, Deserialize)] +struct SavedConversationV0_1_0 { + id: Option, + zed: String, + version: String, + text: String, + messages: Vec, + message_metadata: HashMap, + summary: String, + api_url: Option, + model: OpenAiModel, +} + +#[derive(Clone)] +pub struct SavedConversationMetadata { + pub title: String, + pub path: PathBuf, + pub mtime: chrono::DateTime, +} + +pub struct ConversationStore { + conversations_metadata: Vec, + fs: Arc, + _watch_updates: Task>, +} + +impl ConversationStore { + pub fn new(fs: Arc, cx: &mut AppContext) -> Task>> { + cx.spawn(|mut cx| async move { + const CONVERSATION_WATCH_DURATION: Duration = Duration::from_millis(100); + let (mut events, _) = fs + .watch(&CONVERSATIONS_DIR, CONVERSATION_WATCH_DURATION) + .await; + + let this = cx.new_model(|cx: &mut ModelContext| Self { + conversations_metadata: Vec::new(), + fs, + _watch_updates: cx.spawn(|this, mut cx| { + async move { + while events.next().await.is_some() { + this.update(&mut cx, |this, cx| this.reload(cx))? + .await + .log_err(); + } + anyhow::Ok(()) + } + .log_err() + }), + })?; + this.update(&mut cx, |this, cx| this.reload(cx))? + .await + .log_err(); + Ok(this) + }) + } + + pub fn load(&self, path: PathBuf, cx: &AppContext) -> Task> { + let fs = self.fs.clone(); + cx.background_executor().spawn(async move { + let saved_conversation = fs.load(&path).await?; + let saved_conversation_json = + serde_json::from_str::(&saved_conversation)?; + match saved_conversation_json + .get("version") + .ok_or_else(|| anyhow!("version not found"))? + { + serde_json::Value::String(version) => match version.as_str() { + SavedConversation::VERSION => Ok(serde_json::from_value::( + saved_conversation_json, + )?), + "0.1.0" => { + let saved_conversation = serde_json::from_value::( + saved_conversation_json, + )?; + Ok(SavedConversation { + id: saved_conversation.id, + zed: saved_conversation.zed, + version: saved_conversation.version, + text: saved_conversation.text, + messages: saved_conversation.messages, + message_metadata: saved_conversation.message_metadata, + summary: saved_conversation.summary, + }) + } + _ => Err(anyhow!( + "unrecognized saved conversation version: {}", + version + )), + }, + _ => Err(anyhow!("version not found on saved conversation")), + } + }) + } + + pub fn search(&self, query: String, cx: &AppContext) -> Task> { + let metadata = self.conversations_metadata.clone(); + let executor = cx.background_executor().clone(); + cx.background_executor().spawn(async move { + if query.is_empty() { + metadata + } else { + let candidates = metadata + .iter() + .enumerate() + .map(|(id, metadata)| StringMatchCandidate::new(id, metadata.title.clone())) + .collect::>(); + let matches = fuzzy::match_strings( + &candidates, + &query, + false, + 100, + &Default::default(), + executor, + ) + .await; + + matches + .into_iter() + .map(|mat| metadata[mat.candidate_id].clone()) + .collect() + } + }) + } + + fn reload(&mut self, cx: &mut ModelContext) -> Task> { + let fs = self.fs.clone(); + cx.spawn(|this, mut cx| async move { + fs.create_dir(&CONVERSATIONS_DIR).await?; + + let mut paths = fs.read_dir(&CONVERSATIONS_DIR).await?; + let mut conversations = Vec::::new(); + while let Some(path) = paths.next().await { + let path = path?; + if path.extension() != Some(OsStr::new("json")) { + continue; + } + + let pattern = r" - \d+.zed.json$"; + let re = Regex::new(pattern).unwrap(); + + let metadata = fs.metadata(&path).await?; + if let Some((file_name, metadata)) = path + .file_name() + .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; + } + + if let Some(title) = re.replace(file_name, "").lines().next() { + conversations.push(SavedConversationMetadata { + title: title.to_string(), + path, + mtime: metadata.mtime.into(), + }); + } + } + } + conversations.sort_unstable_by_key(|conversation| Reverse(conversation.mtime)); + + this.update(&mut cx, |this, cx| { + this.conversations_metadata = conversations; + cx.notify(); + }) + }) + } +} diff --git a/crates/assistant/src/saved_conversation.rs b/crates/assistant/src/saved_conversation.rs deleted file mode 100644 index ac6c925a43..0000000000 --- a/crates/assistant/src/saved_conversation.rs +++ /dev/null @@ -1,126 +0,0 @@ -use crate::{assistant_settings::OpenAiModel, MessageId, MessageMetadata}; -use anyhow::{anyhow, Result}; -use collections::HashMap; -use fs::Fs; -use futures::StreamExt; -use regex::Regex; -use serde::{Deserialize, Serialize}; -use std::{ - cmp::Reverse, - ffi::OsStr, - path::{Path, PathBuf}, - sync::Arc, -}; -use util::paths::CONVERSATIONS_DIR; - -#[derive(Serialize, Deserialize)] -pub struct SavedMessage { - pub id: MessageId, - pub start: usize, -} - -#[derive(Serialize, Deserialize)] -pub struct SavedConversation { - pub id: Option, - pub zed: String, - pub version: String, - pub text: String, - pub messages: Vec, - pub message_metadata: HashMap, - pub summary: String, -} - -impl SavedConversation { - pub const VERSION: &'static str = "0.2.0"; - - pub async fn load(path: &Path, fs: &dyn Fs) -> Result { - let saved_conversation = fs.load(path).await?; - let saved_conversation_json = - serde_json::from_str::(&saved_conversation)?; - match saved_conversation_json - .get("version") - .ok_or_else(|| anyhow!("version not found"))? - { - serde_json::Value::String(version) => match version.as_str() { - Self::VERSION => Ok(serde_json::from_value::(saved_conversation_json)?), - "0.1.0" => { - let saved_conversation = - serde_json::from_value::(saved_conversation_json)?; - Ok(Self { - id: saved_conversation.id, - zed: saved_conversation.zed, - version: saved_conversation.version, - text: saved_conversation.text, - messages: saved_conversation.messages, - message_metadata: saved_conversation.message_metadata, - summary: saved_conversation.summary, - }) - } - _ => Err(anyhow!( - "unrecognized saved conversation version: {}", - version - )), - }, - _ => Err(anyhow!("version not found on saved conversation")), - } - } -} - -#[derive(Serialize, Deserialize)] -struct SavedConversationV0_1_0 { - id: Option, - zed: String, - version: String, - text: String, - messages: Vec, - message_metadata: HashMap, - summary: String, - api_url: Option, - model: OpenAiModel, -} - -pub struct SavedConversationMetadata { - pub title: String, - pub path: PathBuf, - pub mtime: chrono::DateTime, -} - -impl SavedConversationMetadata { - pub async fn list(fs: Arc) -> Result> { - fs.create_dir(&CONVERSATIONS_DIR).await?; - - let mut paths = fs.read_dir(&CONVERSATIONS_DIR).await?; - let mut conversations = Vec::::new(); - while let Some(path) = paths.next().await { - let path = path?; - if path.extension() != Some(OsStr::new("json")) { - continue; - } - - let pattern = r" - \d+.zed.json$"; - let re = Regex::new(pattern).unwrap(); - - let metadata = fs.metadata(&path).await?; - if let Some((file_name, metadata)) = path - .file_name() - .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(), - path, - mtime: metadata.mtime.into(), - }); - } - } - conversations.sort_unstable_by_key(|conversation| Reverse(conversation.mtime)); - - Ok(conversations) - } -}