mod assistant_settings; mod attachments; mod completion_provider; mod tools; pub mod ui; use ::ui::{div, prelude::*, Color, ViewContext}; use anyhow::{Context, Result}; use assistant_tooling::{ToolFunctionCall, ToolRegistry}; use attachments::{ActiveEditorAttachmentTool, UserAttachment, UserAttachmentStore}; use client::{proto, Client, UserStore}; use collections::HashMap; use completion_provider::*; use editor::Editor; use feature_flags::FeatureFlagAppExt as _; use futures::{future::join_all, StreamExt}; use gpui::{ list, AnyElement, AppContext, AsyncWindowContext, ClickEvent, EventEmitter, FocusHandle, FocusableView, ListAlignment, ListState, Model, Render, Task, View, WeakView, }; 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 settings::Settings; use std::sync::Arc; use ui::{ActiveFileButton, Composer, ProjectIndexButton}; use util::{maybe, paths::EMBEDDINGS_DIR, ResultExt}; use workspace::{ dock::{DockPosition, Panel, PanelEvent}, Workspace, }; pub use assistant_settings::AssistantSettings; use crate::tools::{CreateBufferTool, ProjectIndexTool}; use crate::ui::UserOrAssistant; const MAX_COMPLETION_CALLS_PER_SUBMISSION: usize = 5; #[derive(Eq, PartialEq, Copy, Clone, Deserialize)] pub struct Submit(SubmitMode); /// There are multiple different ways to submit a model request, represented by this enum. #[derive(Eq, PartialEq, Copy, Clone, Deserialize)] pub enum SubmitMode { /// Only include the conversation. Simple, /// Send the current file as context. CurrentFile, /// Search the codebase and send relevant excerpts. Codebase, } gpui::actions!(assistant2, [Cancel, ToggleFocus, DebugProjectIndex]); gpui::impl_actions!(assistant2, [Submit]); pub fn init(client: Arc, cx: &mut AppContext) { AssistantSettings::register(cx); cx.spawn(|mut cx| { let client = client.clone(); async move { let embedding_provider = CloudEmbeddingProvider::new(client.clone()); let semantic_index = SemanticIndex::new( EMBEDDINGS_DIR.join("semantic-index-db.0.mdb"), Arc::new(embedding_provider), &mut cx, ) .await?; cx.update(|cx| cx.set_global(semantic_index)) } }) .detach(); cx.set_global(CompletionProvider::new(CloudCompletionProvider::new( client, ))); cx.observe_new_views( |workspace: &mut Workspace, _cx: &mut ViewContext| { workspace.register_action(|workspace, _: &ToggleFocus, cx| { workspace.toggle_panel_focus::(cx); }); workspace.register_action(|workspace, _: &DebugProjectIndex, cx| { if let Some(panel) = workspace.panel::(cx) { if let Some(index) = panel.read(cx).chat.read(cx).project_index.clone() { let view = cx.new_view(|cx| ProjectIndexDebugView::new(index, cx)); workspace.add_item_to_center(Box::new(view), cx); } } }); }, ) .detach(); } pub fn enabled(cx: &AppContext) -> bool { cx.is_staff() } pub struct AssistantPanel { chat: View, width: Option, } impl AssistantPanel { pub fn load( workspace: WeakView, cx: AsyncWindowContext, ) -> Task>> { cx.spawn(|mut cx| async move { let (app_state, project) = workspace.update(&mut cx, |workspace, _| { (workspace.app_state().clone(), workspace.project().clone()) })?; cx.new_view(|cx| { let project_index = cx.update_global(|semantic_index: &mut SemanticIndex, cx| { semantic_index.project_index(project.clone(), cx) }); let mut tool_registry = ToolRegistry::new(); tool_registry .register( ProjectIndexTool::new(project_index.clone(), project.read(cx).fs().clone()), cx, ) .context("failed to register ProjectIndexTool") .log_err(); tool_registry .register( CreateBufferTool::new(workspace.clone(), project.clone()), cx, ) .context("failed to register CreateBufferTool") .log_err(); let mut attachment_store = UserAttachmentStore::new(); attachment_store.register(ActiveEditorAttachmentTool::new(workspace.clone(), cx)); Self::new( app_state.languages.clone(), Arc::new(tool_registry), Arc::new(attachment_store), app_state.user_store.clone(), Some(project_index), workspace, cx, ) }) }) } pub fn new( language_registry: Arc, tool_registry: Arc, attachment_store: Arc, user_store: Model, project_index: Option>, workspace: WeakView, cx: &mut ViewContext, ) -> Self { let chat = cx.new_view(|cx| { AssistantChat::new( language_registry, tool_registry.clone(), attachment_store, user_store, project_index, workspace, cx, ) }); Self { width: None, chat } } } impl Render for AssistantPanel { fn render(&mut self, cx: &mut ViewContext) -> impl IntoElement { div() .size_full() .v_flex() .p_2() .bg(cx.theme().colors().background) .child(self.chat.clone()) } } impl Panel for AssistantPanel { fn persistent_name() -> &'static str { "AssistantPanelv2" } fn position(&self, _cx: &WindowContext) -> workspace::dock::DockPosition { // todo!("Add a setting / use assistant settings") DockPosition::Right } fn position_is_valid(&self, position: workspace::dock::DockPosition) -> bool { matches!(position, DockPosition::Right) } fn set_position(&mut self, _: workspace::dock::DockPosition, _: &mut ViewContext) { // Do nothing until we have a setting for this } fn size(&self, _cx: &WindowContext) -> Pixels { self.width.unwrap_or(px(400.)) } fn set_size(&mut self, size: Option, cx: &mut ViewContext) { self.width = size; cx.notify(); } fn icon(&self, _cx: &WindowContext) -> Option<::ui::IconName> { Some(IconName::ZedAssistant) } fn icon_tooltip(&self, _: &WindowContext) -> Option<&'static str> { Some("Assistant Panel ✨") } fn toggle_action(&self) -> Box { Box::new(ToggleFocus) } } impl EventEmitter for AssistantPanel {} impl FocusableView for AssistantPanel { fn focus_handle(&self, cx: &AppContext) -> FocusHandle { self.chat.read(cx).composer_editor.read(cx).focus_handle(cx) } } pub struct AssistantChat { model: String, messages: Vec, list_state: ListState, language_registry: Arc, composer_editor: View, project_index_button: Option>, active_file_button: Option>, user_store: Model, next_message_id: MessageId, collapsed_messages: HashMap, editing_message: Option, pending_completion: Option>, attachment_store: Arc, tool_registry: Arc, project_index: Option>, } struct EditingMessage { id: MessageId, old_body: Arc, body: View, } impl AssistantChat { fn new( language_registry: Arc, tool_registry: Arc, attachment_store: Arc, user_store: Model, project_index: Option>, workspace: WeakView, cx: &mut ViewContext, ) -> Self { let model = CompletionProvider::get(cx).default_model(); let view = cx.view().downgrade(); let list_state = ListState::new( 0, ListAlignment::Bottom, px(1024.), move |ix, cx: &mut WindowContext| { view.update(cx, |this, cx| this.render_message(ix, cx)) .unwrap() }, ); let project_index_button = project_index.clone().map(|project_index| { cx.new_view(|cx| ProjectIndexButton::new(project_index, tool_registry.clone(), cx)) }); let active_file_button = match workspace.upgrade() { Some(workspace) => { Some(cx.new_view( |cx| ActiveFileButton::new(attachment_store.clone(), workspace, cx), // )) } _ => None, }; Self { model, messages: Vec::new(), composer_editor: cx.new_view(|cx| { let mut editor = Editor::auto_height(80, cx); editor.set_soft_wrap_mode(SoftWrap::EditorWidth, cx); editor.set_placeholder_text("Send a message…", cx); editor }), list_state, user_store, language_registry, project_index_button, active_file_button, project_index, next_message_id: MessageId(0), editing_message: None, collapsed_messages: HashMap::default(), pending_completion: None, attachment_store, tool_registry, } } fn editing_message_id(&self) -> Option { self.editing_message.as_ref().map(|message| message.id) } fn focused_message_id(&self, cx: &WindowContext) -> Option { self.messages.iter().find_map(|message| match message { ChatMessage::User(message) => message .body .focus_handle(cx) .contains_focused(cx) .then_some(message.id), ChatMessage::Assistant(_) => None, }) } fn cancel(&mut self, _: &Cancel, cx: &mut ViewContext) { // If we're currently editing a message, cancel the edit. if let Some(editing_message) = self.editing_message.take() { editing_message .body .update(cx, |body, cx| body.set_text(editing_message.old_body, cx)); return; } if self.pending_completion.take().is_some() { if let Some(ChatMessage::Assistant(message)) = self.messages.last() { if message.body.text.is_empty() { self.pop_message(cx); } } return; } cx.propagate(); } fn submit(&mut self, Submit(mode): &Submit, cx: &mut ViewContext) { if let Some(focused_message_id) = self.focused_message_id(cx) { self.truncate_messages(focused_message_id, cx); self.pending_completion.take(); self.composer_editor.focus_handle(cx).focus(cx); if self.editing_message_id() == Some(focused_message_id) { self.editing_message.take(); } } else if self.composer_editor.focus_handle(cx).is_focused(cx) { // Don't allow multiple concurrent completions. if self.pending_completion.is_some() { cx.propagate(); return; } let message = self.composer_editor.update(cx, |composer_editor, cx| { let text = composer_editor.text(cx); let id = self.next_message_id.post_inc(); let body = cx.new_view(|cx| { let mut editor = Editor::auto_height(80, cx); editor.set_text(text, cx); editor.set_soft_wrap_mode(SoftWrap::EditorWidth, cx); editor }); composer_editor.clear(cx); ChatMessage::User(UserMessage { id, body, attachments: Vec::new(), }) }); self.push_message(message, cx); } else { log::error!("unexpected state: no user message editor is focused."); return; } let mode = *mode; self.pending_completion = Some(cx.spawn(move |this, mut cx| async move { let attachments_task = this.update(&mut cx, |this, cx| { let attachment_store = this.attachment_store.clone(); attachment_store.call_all_attachment_tools(cx) }); let attachments = maybe!(async { let attachments_task = attachments_task?; let attachments = attachments_task.await?; anyhow::Ok(attachments) }) .await .log_err() .unwrap_or_default(); // Set the attachments to the _last_ user message this.update(&mut cx, |this, _cx| { if let Some(ChatMessage::User(message)) = this.messages.last_mut() { message.attachments = attachments; } }) .log_err(); Self::request_completion( this.clone(), mode, MAX_COMPLETION_CALLS_PER_SUBMISSION, &mut cx, ) .await .log_err(); this.update(&mut cx, |this, _cx| { this.pending_completion = None; }) .context("Failed to push new user message") .log_err(); })); } async fn request_completion( this: WeakView, mode: SubmitMode, limit: usize, cx: &mut AsyncWindowContext, ) -> Result<()> { let mut call_count = 0; loop { let complete = async { let completion = this.update(cx, |this, cx| { this.push_new_assistant_message(cx); let definitions = if call_count < limit && matches!(mode, SubmitMode::Codebase | SubmitMode::Simple) { this.tool_registry.definitions() } else { Vec::new() }; call_count += 1; let messages = this.completion_messages(cx); CompletionProvider::get(cx).complete( this.model.clone(), messages, Vec::new(), 1.0, definitions, ) }); let mut stream = completion?.await?; let mut body = String::new(); while let Some(delta) = stream.next().await { let delta = delta?; this.update(cx, |this, cx| { if let Some(ChatMessage::Assistant(AssistantMessage { body: message_body, tool_calls: message_tool_calls, .. })) = this.messages.last_mut() { if let Some(content) = &delta.content { body.push_str(content); } for tool_call in delta.tool_calls { let index = tool_call.index as usize; if index >= message_tool_calls.len() { message_tool_calls.resize_with(index + 1, Default::default); } let call = &mut message_tool_calls[index]; if let Some(id) = &tool_call.id { call.id.push_str(id); } match tool_call.variant { Some(proto::tool_call_delta::Variant::Function(tool_call)) => { if let Some(name) = &tool_call.name { call.name.push_str(name); } if let Some(arguments) = &tool_call.arguments { call.arguments.push_str(arguments); } } None => {} } } *message_body = RichText::new(body.clone(), &[], &this.language_registry); cx.notify(); } else { unreachable!() } })?; } anyhow::Ok(()) } .await; let mut tool_tasks = Vec::new(); this.update(cx, |this, cx| { if let Some(ChatMessage::Assistant(AssistantMessage { error: message_error, tool_calls, .. })) = this.messages.last_mut() { if let Err(error) = complete { message_error.replace(SharedString::from(error.to_string())); cx.notify(); } else { for tool_call in tool_calls.iter() { tool_tasks.push(this.tool_registry.call(tool_call, cx)); } } } })?; if tool_tasks.is_empty() { return Ok(()); } let tools = join_all(tool_tasks.into_iter()).await; // If the WindowContext went away for any tool's view we don't include it // especially since the below call would fail for the same reason. let tools = tools.into_iter().filter_map(|tool| tool.ok()).collect(); this.update(cx, |this, cx| { if let Some(ChatMessage::Assistant(AssistantMessage { tool_calls, .. })) = this.messages.last_mut() { *tool_calls = tools; cx.notify(); } })?; } } fn push_new_assistant_message(&mut self, cx: &mut ViewContext) { let message = ChatMessage::Assistant(AssistantMessage { id: self.next_message_id.post_inc(), body: RichText::default(), tool_calls: Vec::new(), error: None, }); self.push_message(message, cx); } fn push_message(&mut self, message: ChatMessage, cx: &mut ViewContext) { let old_len = self.messages.len(); let focus_handle = Some(message.focus_handle(cx)); self.messages.push(message); self.list_state .splice_focusable(old_len..old_len, focus_handle); cx.notify(); } fn pop_message(&mut self, cx: &mut ViewContext) { if self.messages.is_empty() { return; } self.messages.pop(); self.list_state .splice(self.messages.len()..self.messages.len() + 1, 0); cx.notify(); } fn truncate_messages(&mut self, last_message_id: MessageId, cx: &mut ViewContext) { if let Some(index) = self.messages.iter().position(|message| match message { ChatMessage::User(message) => message.id == last_message_id, ChatMessage::Assistant(message) => message.id == last_message_id, }) { self.list_state.splice(index + 1..self.messages.len(), 0); self.messages.truncate(index + 1); cx.notify(); } } fn is_message_collapsed(&self, id: &MessageId) -> bool { self.collapsed_messages.get(id).copied().unwrap_or_default() } fn toggle_message_collapsed(&mut self, id: MessageId) { let entry = self.collapsed_messages.entry(id).or_insert(false); *entry = !*entry; } fn render_error( &self, error: Option, _ix: usize, cx: &mut ViewContext, ) -> AnyElement { let theme = cx.theme(); if let Some(error) = error { div() .py_1() .px_2() .mx_neg_1() .rounded_md() .border_1() .border_color(theme.status().error_border) // .bg(theme.status().error_background) .text_color(theme.status().error) .child(error.clone()) .into_any_element() } else { div().into_any_element() } } fn render_message(&self, ix: usize, cx: &mut ViewContext) -> AnyElement { let is_last = ix == self.messages.len() - 1; match &self.messages[ix] { ChatMessage::User(UserMessage { id, body, attachments, }) => div() .id(SharedString::from(format!("message-{}-container", id.0))) .when(!is_last, |element| element.mb_2()) .map(|element| { if self.editing_message_id() == Some(*id) { element.child(Composer::new( body.clone(), self.project_index_button.clone(), self.active_file_button.clone(), crate::ui::ModelSelector::new( cx.view().downgrade(), self.model.clone(), ) .into_any_element(), )) } else { element .on_click(cx.listener({ let id = *id; let body = body.clone(); move |assistant_chat, event: &ClickEvent, cx| { if event.up.click_count == 2 { assistant_chat.editing_message = Some(EditingMessage { id, body: body.clone(), old_body: body.read(cx).text(cx).into(), }); body.focus_handle(cx).focus(cx); } } })) .child(crate::ui::ChatMessage::new( *id, UserOrAssistant::User(self.user_store.read(cx).current_user()), Some( RichText::new( body.read(cx).text(cx), &[], &self.language_registry, ) .element(ElementId::from(id.0), cx), ), Some( h_flex() .gap_2() .children( attachments .iter() .map(|attachment| attachment.view.clone()), ) .into_any_element(), ), self.is_message_collapsed(id), Box::new(cx.listener({ let id = *id; move |assistant_chat, _event, _cx| { assistant_chat.toggle_message_collapsed(id) } })), )) } }) .into_any(), ChatMessage::Assistant(AssistantMessage { id, body, error, tool_calls, .. }) => { let assistant_body = if body.text.is_empty() { None } else { Some( div() .p_2() .child(body.element(ElementId::from(id.0), cx)) .into_any_element(), ) }; let tools = tool_calls .iter() .map(|tool_call| self.tool_registry.render_tool_call(tool_call, cx)) .collect::>(); let tools_body = if tools.is_empty() { None } else { Some(div().children(tools).into_any_element()) }; div() .when(!is_last, |element| element.mb_2()) .child(crate::ui::ChatMessage::new( *id, UserOrAssistant::Assistant, assistant_body, tools_body, self.is_message_collapsed(id), Box::new(cx.listener({ let id = *id; move |assistant_chat, _event, _cx| { assistant_chat.toggle_message_collapsed(id) } })), )) .child(self.render_error(error.clone(), ix, cx)) .into_any() } } } fn completion_messages(&self, cx: &mut WindowContext) -> Vec { let mut completion_messages = Vec::new(); for message in &self.messages { match message { ChatMessage::User(UserMessage { body, attachments, .. }) => { completion_messages.extend( attachments .into_iter() .filter_map(|attachment| attachment.message.clone()) .map(|content| CompletionMessage::System { content }), ); // Show user's message last so that the assistant is grounded in the user's request completion_messages.push(CompletionMessage::User { content: body.read(cx).text(cx), }); } ChatMessage::Assistant(AssistantMessage { body, tool_calls, .. }) => { // In no case do we want to send an empty message. This shouldn't happen, but we might as well // not break the Chat API if it does. if body.text.is_empty() && tool_calls.is_empty() { continue; } let tool_calls_from_assistant = tool_calls .iter() .map(|tool_call| ToolCall { content: ToolCallContent::Function { function: FunctionContent { name: tool_call.name.clone(), arguments: tool_call.arguments.clone(), }, }, id: tool_call.id.clone(), }) .collect(); completion_messages.push(CompletionMessage::Assistant { content: Some(body.text.to_string()), tool_calls: tool_calls_from_assistant, }); for tool_call in tool_calls { // Every tool call _must_ have a result by ID, otherwise OpenAI will error. let content = match &tool_call.result { Some(result) => result.format(&tool_call.name), None => "".to_string(), }; completion_messages.push(CompletionMessage::Tool { content, tool_call_id: tool_call.id.clone(), }); } } } } completion_messages } } impl Render for AssistantChat { fn render(&mut self, cx: &mut ViewContext) -> impl IntoElement { div() .relative() .flex_1() .v_flex() .key_context("AssistantChat") .on_action(cx.listener(Self::submit)) .on_action(cx.listener(Self::cancel)) .text_color(Color::Default.color(cx)) .child(list(self.list_state.clone()).flex_1()) .child(Composer::new( self.composer_editor.clone(), self.project_index_button.clone(), self.active_file_button.clone(), crate::ui::ModelSelector::new(cx.view().downgrade(), self.model.clone()) .into_any_element(), )) } } #[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy)] pub struct MessageId(usize); impl MessageId { fn post_inc(&mut self) -> Self { let id = *self; self.0 += 1; id } } enum ChatMessage { User(UserMessage), Assistant(AssistantMessage), } impl ChatMessage { fn focus_handle(&self, cx: &AppContext) -> Option { match self { ChatMessage::User(UserMessage { body, .. }) => Some(body.focus_handle(cx)), ChatMessage::Assistant(_) => None, } } } struct UserMessage { id: MessageId, body: View, attachments: Vec, } struct AssistantMessage { id: MessageId, body: RichText, tool_calls: Vec, error: Option, }