Start on an assistant panel based on multi-buffers

Each message is represented as a multibuffer excerpt to allow for
fluid editing of the conversation transcript.

Co-Authored-By: Antonio Scandurra <antonio@zed.dev>
This commit is contained in:
Nathan Sobo 2023-05-26 10:09:55 -06:00
parent 128c19875d
commit 80080a43e4
7 changed files with 381 additions and 34 deletions

4
Cargo.lock generated
View File

@ -106,9 +106,13 @@ dependencies = [
"futures 0.3.28",
"gpui",
"isahc",
"language",
"search",
"serde",
"serde_json",
"theme",
"util",
"workspace",
]
[[package]]

View File

@ -1,3 +1,3 @@
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M10.6667 0.400196H1.33346C0.819658 0.400196 0.399658 0.820196 0.399658 1.3326V10.6658C0.399658 11.181 0.816998 11.5982 1.33206 11.5982C1.58966 11.5982 1.82206 11.4932 1.99146 11.3238L4.51706 8.79684H10.6639C11.1763 8.79684 11.5963 8.37544 11.5963 7.86304V1.3298C11.5963 0.815996 11.1749 0.395996 10.6625 0.395996L10.6667 0.400196ZM2.2667 2.2664H6.00008V3.1988H2.26628V2.265L2.2667 2.2664ZM7.8667 6.93316H2.2667V5.99936H7.8667V6.93176V6.93316ZM9.7329 5.06556H2.26488V4.13176H9.73164V5.06416L9.7329 5.06556Z" fill="#282C34"/>
<svg width="12" height="11" viewBox="0 0 12 11" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M6.01077 0.000234794C2.69085 0.000234794 0.000639074 2.18612 0.000639074 4.88385C0.000639074 6.0491 0.501914 7.11387 1.33823 7.95254C1.04475 9.13517 0.0640321 10.1894 0.0522927 10.2011C-0.00053487 10.2539 -0.0153266 10.3356 0.0170743 10.4061C0.0464229 10.4763 0.111459 10.5185 0.187766 10.5185C1.74324 10.5185 2.89019 9.77286 3.4889 9.31197C4.25431 9.60052 5.10894 9.76722 6.01053 9.76722C9.33045 9.76722 12 7.58063 12 4.88361C12 2.18659 9.33045 0 6.01053 0L6.01077 0.000234794Z" fill="#FAFAFA"/>
</svg>

Before

Width:  |  Height:  |  Size: 636 B

After

Width:  |  Height:  |  Size: 609 B

View File

@ -188,12 +188,6 @@
"alt-[": "copilot::PreviousSuggestion"
}
},
{
"context": "Editor && extension == zmd",
"bindings": {
"cmd-enter": "ai::Assist"
}
},
{
"context": "Editor && mode == auto_height",
"bindings": {
@ -201,6 +195,12 @@
"cmd-alt-enter": "editor::NewlineBelow"
}
},
{
"context": "ContextEditor > Editor",
"bindings": {
"cmd-enter": "assistant::Assist"
}
},
{
"context": "BufferSearchBar",
"bindings": {
@ -375,27 +375,39 @@
],
"cmd-b": [
"workspace::ToggleLeftDock",
{ "focus": true }
{
"focus": true
}
],
"cmd-shift-b": [
"workspace::ToggleLeftDock",
{ "focus": false }
{
"focus": false
}
],
"cmd-r": [
"workspace::ToggleRightDock",
{ "focus": true }
{
"focus": true
}
],
"cmd-shift-r": [
"workspace::ToggleRightDock",
{ "focus": false }
{
"focus": false
}
],
"cmd-j": [
"workspace::ToggleBottomDock",
{ "focus": true }
{
"focus": true
}
],
"cmd-shift-j": [
"workspace::ToggleBottomDock",
{ "focus": false }
{
"focus": false
}
],
"cmd-shift-f": "workspace::NewSearch",
"cmd-k cmd-t": "theme_selector::Toggle",

View File

@ -13,7 +13,11 @@ assets = { path = "../assets"}
collections = { path = "../collections"}
editor = { path = "../editor" }
gpui = { path = "../gpui" }
language = { path = "../language" }
search = { path = "../search" }
theme = { path = "../theme" }
util = { path = "../util" }
workspace = { path = "../workspace" }
serde.workspace = true
serde_json.workspace = true

View File

@ -1,3 +1,5 @@
mod assistant;
use anyhow::{anyhow, Result};
use assets::Assets;
use collections::HashMap;
@ -16,6 +18,8 @@ use std::{io, sync::Arc};
use util::channel::{ReleaseChannel, RELEASE_CHANNEL};
use util::{ResultExt, TryFutureExt};
pub use assistant::AssistantPanel;
actions!(ai, [Assist]);
// Data types for chat completion requests
@ -38,7 +42,7 @@ struct ResponseMessage {
content: Option<String>,
}
#[derive(Serialize, Deserialize, Debug, Eq, PartialEq)]
#[derive(Clone, Copy, Serialize, Deserialize, Debug, Eq, PartialEq)]
#[serde(rename_all = "lowercase")]
enum Role {
User,
@ -86,25 +90,27 @@ struct OpenAIChoice {
}
pub fn init(cx: &mut AppContext) {
if *RELEASE_CHANNEL == ReleaseChannel::Stable {
return;
}
// if *RELEASE_CHANNEL == ReleaseChannel::Stable {
// return;
// }
let assistant = Rc::new(Assistant::default());
cx.add_action({
let assistant = assistant.clone();
move |editor: &mut Editor, _: &Assist, cx: &mut ViewContext<Editor>| {
assistant.assist(editor, cx).log_err();
}
});
cx.capture_action({
let assistant = assistant.clone();
move |_: &mut Editor, _: &editor::Cancel, cx: &mut ViewContext<Editor>| {
if !assistant.cancel_last_assist(cx.view_id()) {
cx.propagate_action();
}
}
});
assistant::init(cx);
// let assistant = Rc::new(Assistant::default());
// cx.add_action({
// let assistant = assistant.clone();
// move |editor: &mut Editor, _: &Assist, cx: &mut ViewContext<Editor>| {
// assistant.assist(editor, cx).log_err();
// }
// });
// cx.capture_action({
// let assistant = assistant.clone();
// move |_: &mut Editor, _: &editor::Cancel, cx: &mut ViewContext<Editor>| {
// if !assistant.cancel_last_assist(cx.view_id()) {
// cx.propagate_action();
// }
// }
// });
}
type CompletionId = usize;

316
crates/ai/src/assistant.rs Normal file
View File

@ -0,0 +1,316 @@
use crate::{stream_completion, OpenAIRequest, RequestMessage, Role};
use editor::{Editor, ExcerptRange, MultiBuffer};
use futures::StreamExt;
use gpui::{
actions, elements::*, Action, AppContext, Entity, ModelHandle, Subscription, View, ViewContext,
ViewHandle, WeakViewHandle, WindowContext,
};
use language::{language_settings::SoftWrap, Anchor, Buffer};
use std::sync::Arc;
use util::ResultExt;
use workspace::{
dock::{DockPosition, Panel},
item::Item,
pane, Pane, Workspace,
};
actions!(assistant, [NewContext, Assist]);
pub fn init(cx: &mut AppContext) {
cx.add_action(ContextEditor::assist);
}
pub enum AssistantPanelEvent {
ZoomIn,
ZoomOut,
Focus,
Close,
}
pub struct AssistantPanel {
width: Option<f32>,
pane: ViewHandle<Pane>,
workspace: WeakViewHandle<Workspace>,
_subscriptions: Vec<Subscription>,
}
impl AssistantPanel {
pub fn new(workspace: &Workspace, cx: &mut ViewContext<Self>) -> Self {
let weak_self = cx.weak_handle();
let pane = cx.add_view(|cx| {
let window_id = cx.window_id();
let mut pane = Pane::new(
workspace.weak_handle(),
workspace.app_state().background_actions,
Default::default(),
cx,
);
pane.set_can_split(false, cx);
pane.set_can_navigate(false, cx);
pane.on_can_drop(move |_, cx| false);
pane.set_render_tab_bar_buttons(cx, move |pane, cx| {
let this = weak_self.clone();
Flex::row()
.with_child(Pane::render_tab_bar_button(
0,
"icons/plus_12.svg",
Some(("New Context".into(), Some(Box::new(NewContext)))),
cx,
move |_, cx| {},
None,
))
.with_child(Pane::render_tab_bar_button(
1,
if pane.is_zoomed() {
"icons/minimize_8.svg"
} else {
"icons/maximize_8.svg"
},
Some(("Toggle Zoom".into(), Some(Box::new(workspace::ToggleZoom)))),
cx,
move |pane, cx| pane.toggle_zoom(&Default::default(), cx),
None,
))
.into_any()
});
let buffer_search_bar = cx.add_view(search::BufferSearchBar::new);
pane.toolbar()
.update(cx, |toolbar, cx| toolbar.add_item(buffer_search_bar, cx));
pane
});
let subscriptions = vec![
cx.observe(&pane, |_, _, cx| cx.notify()),
cx.subscribe(&pane, Self::handle_pane_event),
];
Self {
pane,
workspace: workspace.weak_handle(),
width: None,
_subscriptions: subscriptions,
}
}
fn handle_pane_event(
&mut self,
_pane: ViewHandle<Pane>,
event: &pane::Event,
cx: &mut ViewContext<Self>,
) {
match event {
pane::Event::ZoomIn => cx.emit(AssistantPanelEvent::ZoomIn),
pane::Event::ZoomOut => cx.emit(AssistantPanelEvent::ZoomOut),
pane::Event::Focus => cx.emit(AssistantPanelEvent::Focus),
pane::Event::Remove => cx.emit(AssistantPanelEvent::Close),
_ => {}
}
}
}
impl Entity for AssistantPanel {
type Event = AssistantPanelEvent;
}
impl View for AssistantPanel {
fn ui_name() -> &'static str {
"AssistantPanel"
}
fn render(&mut self, cx: &mut ViewContext<Self>) -> AnyElement<Self> {
ChildView::new(&self.pane, cx).into_any()
}
}
impl Panel for AssistantPanel {
fn position(&self, cx: &WindowContext) -> DockPosition {
DockPosition::Right
}
fn position_is_valid(&self, position: DockPosition) -> bool {
matches!(position, DockPosition::Right)
}
fn set_position(&mut self, position: DockPosition, cx: &mut ViewContext<Self>) {}
fn size(&self, cx: &WindowContext) -> f32 {
self.width.unwrap_or(480.)
}
fn set_size(&mut self, size: f32, cx: &mut ViewContext<Self>) {
self.width = Some(size);
cx.notify();
}
fn should_zoom_in_on_event(event: &AssistantPanelEvent) -> bool {
matches!(event, AssistantPanelEvent::ZoomIn)
}
fn should_zoom_out_on_event(event: &AssistantPanelEvent) -> bool {
matches!(event, AssistantPanelEvent::ZoomOut)
}
fn is_zoomed(&self, cx: &WindowContext) -> bool {
self.pane.read(cx).is_zoomed()
}
fn set_zoomed(&mut self, zoomed: bool, cx: &mut ViewContext<Self>) {
self.pane.update(cx, |pane, cx| pane.set_zoomed(zoomed, cx));
}
fn set_active(&mut self, active: bool, cx: &mut ViewContext<Self>) {
if active && self.pane.read(cx).items_len() == 0 {
cx.defer(|this, cx| {
if let Some(workspace) = this.workspace.upgrade(cx) {
workspace.update(cx, |workspace, cx| {
let focus = this.pane.read(cx).has_focus();
let editor = Box::new(cx.add_view(|cx| ContextEditor::new(cx)));
Pane::add_item(workspace, &this.pane, editor, true, focus, None, cx);
})
}
});
}
}
fn icon_path(&self) -> &'static str {
"icons/speech_bubble_12.svg"
}
fn icon_tooltip(&self) -> (String, Option<Box<dyn Action>>) {
("Assistant Panel".into(), None)
}
fn should_change_position_on_event(event: &Self::Event) -> bool {
false
}
fn should_activate_on_event(_: &Self::Event) -> bool {
false
}
fn should_close_on_event(event: &AssistantPanelEvent) -> bool {
matches!(event, AssistantPanelEvent::Close)
}
fn has_focus(&self, cx: &WindowContext) -> bool {
self.pane.read(cx).has_focus()
}
fn is_focus_event(event: &Self::Event) -> bool {
matches!(event, AssistantPanelEvent::Focus)
}
}
struct ContextEditor {
messages: Vec<Message>,
editor: ViewHandle<Editor>,
}
impl ContextEditor {
fn new(cx: &mut ViewContext<Self>) -> Self {
let messages = vec![Message {
role: Role::User,
content: cx.add_model(|cx| Buffer::new(0, "", cx)),
}];
let multibuffer = cx.add_model(|cx| {
let mut multibuffer = MultiBuffer::new(0);
for message in &messages {
multibuffer.push_excerpts_with_context_lines(
message.content.clone(),
vec![Anchor::MIN..Anchor::MAX],
0,
cx,
);
}
multibuffer
});
let editor = cx.add_view(|cx| {
let mut editor = Editor::for_multibuffer(multibuffer, None, cx);
editor.set_soft_wrap_mode(SoftWrap::EditorWidth, cx);
editor
});
Self { messages, editor }
}
fn assist(&mut self, _: &Assist, cx: &mut ViewContext<Self>) {
let messages = self
.messages
.iter()
.map(|message| RequestMessage {
role: message.role,
content: message.content.read(cx).text(),
})
.collect();
let request = OpenAIRequest {
model: "gpt-3.5-turbo".into(),
messages,
stream: true,
};
if let Some(api_key) = std::env::var("OPENAI_API_KEY").log_err() {
let stream = stream_completion(api_key, cx.background_executor().clone(), request);
let content = cx.add_model(|cx| Buffer::new(0, "", cx));
self.messages.push(Message {
role: Role::Assistant,
content: content.clone(),
});
self.editor.update(cx, |editor, cx| {
editor.buffer().update(cx, |multibuffer, cx| {
multibuffer.push_excerpts_with_context_lines(
content.clone(),
vec![Anchor::MIN..Anchor::MAX],
0,
cx,
);
});
});
cx.spawn(|_, mut cx| async move {
let mut messages = stream.await?;
while let Some(message) = messages.next().await {
let mut message = message?;
if let Some(choice) = message.choices.pop() {
content.update(&mut cx, |content, cx| {
let text: Arc<str> = choice.delta.content?.into();
content.edit([(content.len()..content.len(), text)], None, cx);
Some(())
});
}
}
anyhow::Ok(())
})
.detach_and_log_err(cx);
}
}
}
impl Entity for ContextEditor {
type Event = ();
}
impl View for ContextEditor {
fn ui_name() -> &'static str {
"ContextEditor"
}
fn render(&mut self, cx: &mut ViewContext<Self>) -> AnyElement<Self> {
ChildView::new(&self.editor, cx).into_any()
}
}
impl Item for ContextEditor {
fn tab_content<V: View>(
&self,
_: Option<usize>,
style: &theme::Tab,
_: &gpui::AppContext,
) -> AnyElement<V> {
Label::new("New Context", style.label.clone()).into_any()
}
}
struct Message {
role: Role,
content: ModelHandle<Buffer>,
}

View File

@ -2,6 +2,7 @@ pub mod languages;
pub mod menus;
#[cfg(any(test, feature = "test-support"))]
pub mod test;
use ai::AssistantPanel;
use anyhow::Context;
use assets::Assets;
use breadcrumbs::Breadcrumbs;
@ -357,7 +358,11 @@ pub fn initialize_workspace(
workspace.toggle_dock(project_panel_position, false, cx);
}
workspace.add_panel(terminal_panel, cx)
workspace.add_panel(terminal_panel, cx);
// TODO: deserialize state.
let assistant_panel = cx.add_view(|cx| AssistantPanel::new(workspace, cx));
workspace.add_panel(assistant_panel, cx);
})?;
Ok(())
})