From eca6d5a04e2e5be6aefdd3dbd01f00fd19c0e36b Mon Sep 17 00:00:00 2001 From: Bennet Bo Fenner Date: Tue, 22 Apr 2025 11:01:01 +0200 Subject: [PATCH] agent: Support pasting images as context (#29177) https://github.com/user-attachments/assets/d6a27b05-3590-4f40-a820-f6f99f6bd581 Release Notes: - agent: Added support for pasting images as context --------- Co-authored-by: Danilo Leal --- assets/icons/image.svg | 1 + crates/agent/src/active_thread.rs | 1 + crates/agent/src/context.rs | 35 +- crates/agent/src/context_store.rs | 37 +- crates/agent/src/message_editor.rs | 44 ++- crates/agent/src/thread.rs | 36 +- crates/agent/src/ui/context_pill.rs | 331 +++++++++++++----- .../src/context_editor.rs | 2 +- crates/gpui/src/platform.rs | 9 + crates/icons/src/icons.rs | 1 + crates/language_model/src/request.rs | 9 +- 11 files changed, 407 insertions(+), 99 deletions(-) create mode 100644 assets/icons/image.svg diff --git a/assets/icons/image.svg b/assets/icons/image.svg new file mode 100644 index 0000000000..4b17300f47 --- /dev/null +++ b/assets/icons/image.svg @@ -0,0 +1 @@ + diff --git a/crates/agent/src/active_thread.rs b/crates/agent/src/active_thread.rs index f0063722e3..37afa2730c 100644 --- a/crates/agent/src/active_thread.rs +++ b/crates/agent/src/active_thread.rs @@ -3344,6 +3344,7 @@ pub(crate) fn open_context( }), cx, ), + AssistantContext::Image(_) => {} } } diff --git a/crates/agent/src/context.rs b/crates/agent/src/context.rs index 3caef5b7fe..321f3bebb6 100644 --- a/crates/agent/src/context.rs +++ b/crates/agent/src/context.rs @@ -4,9 +4,10 @@ use std::{ sync::Arc, }; -use gpui::{App, Entity, SharedString}; +use futures::{FutureExt, future::Shared}; +use gpui::{App, Entity, SharedString, Task}; use language::Buffer; -use language_model::LanguageModelRequestMessage; +use language_model::{LanguageModelImage, LanguageModelRequestMessage}; use project::{ProjectEntryId, ProjectPath, Worktree}; use prompt_store::UserPromptId; use rope::Point; @@ -36,6 +37,7 @@ pub enum ContextKind { FetchedUrl, Thread, Rules, + Image, } impl ContextKind { @@ -48,6 +50,7 @@ impl ContextKind { ContextKind::FetchedUrl => IconName::Globe, ContextKind::Thread => IconName::MessageBubbles, ContextKind::Rules => RULES_ICON, + ContextKind::Image => IconName::Image, } } } @@ -61,6 +64,7 @@ pub enum AssistantContext { Thread(ThreadContext), Excerpt(ExcerptContext), Rules(RulesContext), + Image(ImageContext), } impl AssistantContext { @@ -73,6 +77,7 @@ impl AssistantContext { Self::Thread(thread) => thread.id, Self::Excerpt(excerpt) => excerpt.id, Self::Rules(rules) => rules.id, + Self::Image(image) => image.id, } } } @@ -140,6 +145,31 @@ impl ThreadContext { } } +#[derive(Debug, Clone)] +pub struct ImageContext { + pub id: ContextId, + pub original_image: Arc, + pub image_task: Shared>>, +} + +impl ImageContext { + pub fn image(&self) -> Option { + self.image_task.clone().now_or_never().flatten() + } + + pub fn is_loading(&self) -> bool { + self.image_task.clone().now_or_never().is_none() + } + + pub fn is_error(&self) -> bool { + self.image_task + .clone() + .now_or_never() + .map(|result| result.is_none()) + .unwrap_or(false) + } +} + #[derive(Clone)] pub struct ContextBuffer { pub id: BufferId, @@ -227,6 +257,7 @@ pub fn format_context_as_string<'a>( AssistantContext::FetchedUrl(context) => fetch_context.push(context), AssistantContext::Thread(context) => thread_context.push(context), AssistantContext::Rules(context) => rules_context.push(context), + AssistantContext::Image(_) => {} } } diff --git a/crates/agent/src/context_store.rs b/crates/agent/src/context_store.rs index a6255a7901..9cb7825a19 100644 --- a/crates/agent/src/context_store.rs +++ b/crates/agent/src/context_store.rs @@ -6,8 +6,9 @@ use anyhow::{Context as _, Result, anyhow}; use collections::{BTreeMap, HashMap, HashSet}; use futures::future::join_all; use futures::{self, Future, FutureExt, future}; -use gpui::{App, AppContext as _, Context, Entity, SharedString, Task, WeakEntity}; +use gpui::{App, AppContext as _, Context, Entity, Image, SharedString, Task, WeakEntity}; use language::Buffer; +use language_model::LanguageModelImage; use project::{Project, ProjectEntryId, ProjectItem, ProjectPath, Worktree}; use prompt_store::UserPromptId; use rope::{Point, Rope}; @@ -17,7 +18,8 @@ use util::{ResultExt as _, maybe}; use crate::ThreadStore; use crate::context::{ AssistantContext, ContextBuffer, ContextId, ContextSymbol, ContextSymbolId, DirectoryContext, - ExcerptContext, FetchedUrlContext, FileContext, RulesContext, SymbolContext, ThreadContext, + ExcerptContext, FetchedUrlContext, FileContext, ImageContext, RulesContext, SymbolContext, + ThreadContext, }; use crate::context_strip::SuggestedContext; use crate::thread::{Thread, ThreadId}; @@ -448,6 +450,32 @@ impl ContextStore { cx.notify(); } + pub fn add_image(&mut self, image: Arc, cx: &mut Context) { + let image_task = LanguageModelImage::from_image(image.clone(), cx).shared(); + let id = self.next_context_id.post_inc(); + self.context.push(AssistantContext::Image(ImageContext { + id, + original_image: image, + image_task, + })); + cx.notify(); + } + + pub fn wait_for_images(&self, cx: &App) -> Task<()> { + let tasks = self + .context + .iter() + .filter_map(|ctx| match ctx { + AssistantContext::Image(ctx) => Some(ctx.image_task.clone()), + _ => None, + }) + .collect::>(); + + cx.spawn(async move |_cx| { + join_all(tasks).await; + }) + } + pub fn add_excerpt( &mut self, range: Range, @@ -545,6 +573,7 @@ impl ContextStore { AssistantContext::Rules(RulesContext { prompt_id, .. }) => { self.user_rules.remove(&prompt_id); } + AssistantContext::Image(_) => {} } cx.notify(); @@ -673,7 +702,8 @@ impl ContextStore { | AssistantContext::Excerpt(_) | AssistantContext::FetchedUrl(_) | AssistantContext::Thread(_) - | AssistantContext::Rules(_) => None, + | AssistantContext::Rules(_) + | AssistantContext::Image(_) => None, }) .collect() } @@ -907,6 +937,7 @@ pub fn refresh_context_store_text( let context_store = context_store.clone(); return Some(refresh_user_rules(context_store, user_rules_context, cx)); } + AssistantContext::Image(_) => {} } None diff --git a/crates/agent/src/message_editor.rs b/crates/agent/src/message_editor.rs index ac16df4c97..e6f311c924 100644 --- a/crates/agent/src/message_editor.rs +++ b/crates/agent/src/message_editor.rs @@ -6,7 +6,7 @@ use crate::context::{AssistantContext, format_context_as_string}; use crate::tool_compatibility::{IncompatibleToolsState, IncompatibleToolsTooltip}; use buffer_diff::BufferDiff; use collections::HashSet; -use editor::actions::MoveUp; +use editor::actions::{MoveUp, Paste}; use editor::{ ContextMenuOptions, ContextMenuPlacement, Editor, EditorElement, EditorEvent, EditorMode, EditorStyle, MultiBuffer, @@ -14,8 +14,8 @@ use editor::{ use file_icons::FileIcons; use fs::Fs; use gpui::{ - Animation, AnimationExt, App, Entity, EventEmitter, Focusable, Subscription, Task, TextStyle, - WeakEntity, linear_color_stop, linear_gradient, point, pulsating_between, + Animation, AnimationExt, App, ClipboardEntry, Entity, EventEmitter, Focusable, Subscription, + Task, TextStyle, WeakEntity, linear_color_stop, linear_gradient, point, pulsating_between, }; use language::{Buffer, Language}; use language_model::{ConfiguredModel, LanguageModelRegistry, LanguageModelRequestMessage}; @@ -271,6 +271,7 @@ impl MessageEditor { let refresh_task = refresh_context_store_text(self.context_store.clone(), &HashSet::default(), cx); + let wait_for_images = self.context_store.read(cx).wait_for_images(cx); let thread = self.thread.clone(); let context_store = self.context_store.clone(); @@ -280,6 +281,7 @@ impl MessageEditor { cx.spawn(async move |this, cx| { let checkpoint = checkpoint.await.ok(); refresh_task.await; + wait_for_images.await; thread .update(cx, |thread, cx| { @@ -293,7 +295,12 @@ impl MessageEditor { let excerpt_ids = context_store .context() .iter() - .filter(|ctx| matches!(ctx, AssistantContext::Excerpt(_))) + .filter(|ctx| { + matches!( + ctx, + AssistantContext::Excerpt(_) | AssistantContext::Image(_) + ) + }) .map(|ctx| ctx.id()) .collect::>(); @@ -370,6 +377,34 @@ impl MessageEditor { } } + fn paste(&mut self, _: &Paste, _: &mut Window, cx: &mut Context) { + let images = cx + .read_from_clipboard() + .map(|item| { + item.into_entries() + .filter_map(|entry| { + if let ClipboardEntry::Image(image) = entry { + Some(image) + } else { + None + } + }) + .collect::>() + }) + .unwrap_or_default(); + + if images.is_empty() { + return; + } + cx.stop_propagation(); + + self.context_store.update(cx, |store, cx| { + for image in images { + store.add_image(Arc::new(image), cx); + } + }); + } + fn handle_review_click(&self, window: &mut Window, cx: &mut Context) { AgentDiff::deploy(self.thread.clone(), self.workspace.clone(), window, cx).log_err(); } @@ -445,6 +480,7 @@ impl MessageEditor { .on_action(cx.listener(Self::move_up)) .on_action(cx.listener(Self::toggle_chat_mode)) .on_action(cx.listener(Self::expand_message_editor)) + .capture_action(cx.listener(Self::paste)) .gap_2() .p_2() .bg(editor_bg_color) diff --git a/crates/agent/src/thread.rs b/crates/agent/src/thread.rs index 608cbe9711..3d1a1cc242 100644 --- a/crates/agent/src/thread.rs +++ b/crates/agent/src/thread.rs @@ -16,7 +16,7 @@ use git::repository::DiffType; use gpui::{App, AppContext, Context, Entity, EventEmitter, SharedString, Task, WeakEntity}; use language_model::{ ConfiguredModel, LanguageModel, LanguageModelCompletionEvent, LanguageModelId, - LanguageModelKnownError, LanguageModelRegistry, LanguageModelRequest, + LanguageModelImage, LanguageModelKnownError, LanguageModelRegistry, LanguageModelRequest, LanguageModelRequestMessage, LanguageModelRequestTool, LanguageModelToolResult, LanguageModelToolUseId, MaxMonthlySpendReachedError, MessageContent, ModelRequestLimitReachedError, PaymentRequiredError, RequestUsage, Role, StopReason, @@ -97,6 +97,7 @@ pub struct Message { pub role: Role, pub segments: Vec, pub context: String, + pub images: Vec, } impl Message { @@ -415,6 +416,7 @@ impl Thread { }) .collect(), context: message.context, + images: Vec::new(), }) .collect(), next_message_id, @@ -747,6 +749,19 @@ impl Thread { } } + if let Some(message) = self.messages.iter_mut().find(|m| m.id == message_id) { + message.images = new_context + .iter() + .filter_map(|context| { + if let AssistantContext::Image(image_context) = context { + image_context.image_task.clone().now_or_never().flatten() + } else { + None + } + }) + .collect::>(); + } + self.action_log.update(cx, |log, cx| { // Track all buffers added as context for ctx in &new_context { @@ -773,7 +788,8 @@ impl Thread { } AssistantContext::FetchedUrl(_) | AssistantContext::Thread(_) - | AssistantContext::Rules(_) => {} + | AssistantContext::Rules(_) + | AssistantContext::Image(_) => {} } } }); @@ -814,6 +830,7 @@ impl Thread { role, segments, context: String::new(), + images: Vec::new(), }); self.touch_updated_at(); cx.emit(ThreadEvent::MessageAdded(id)); @@ -1037,6 +1054,21 @@ impl Thread { .push(MessageContent::Text(message.context.to_string())); } + if !message.images.is_empty() { + // Some providers only support image parts after an initial text part + if request_message.content.is_empty() { + request_message + .content + .push(MessageContent::Text("Images attached by user:".to_string())); + } + + for image in &message.images { + request_message + .content + .push(MessageContent::Image(image.clone())) + } + } + for segment in &message.segments { match segment { MessageSegment::Text(text) => { diff --git a/crates/agent/src/ui/context_pill.rs b/crates/agent/src/ui/context_pill.rs index 6f739a1d41..be2934e0a9 100644 --- a/crates/agent/src/ui/context_pill.rs +++ b/crates/agent/src/ui/context_pill.rs @@ -1,11 +1,14 @@ +use std::sync::Arc; use std::{rc::Rc, time::Duration}; use file_icons::FileIcons; -use gpui::ClickEvent; -use gpui::{Animation, AnimationExt as _, pulsating_between}; -use ui::{IconButtonShape, Tooltip, prelude::*}; +use futures::FutureExt; +use gpui::{Animation, AnimationExt as _, AnyView, Image, MouseButton, pulsating_between}; +use gpui::{ClickEvent, Task}; +use language_model::LanguageModelImage; +use ui::{IconButtonShape, Tooltip, prelude::*, tooltip_container}; -use crate::context::{AssistantContext, ContextId, ContextKind}; +use crate::context::{AssistantContext, ContextId, ContextKind, ImageContext}; #[derive(IntoElement)] pub enum ContextPill { @@ -120,74 +123,95 @@ impl RenderOnce for ContextPill { on_remove, focused, on_click, - } => base_pill - .bg(color.element_background) - .border_color(if *focused { - color.border_focused - } else { - color.border.opacity(0.5) - }) - .pr(if on_remove.is_some() { px(2.) } else { px(4.) }) - .child( - h_flex() - .id("context-data") - .gap_1() - .child( - div().max_w_64().child( - Label::new(context.name.clone()) - .size(LabelSize::Small) - .truncate(), - ), - ) - .when_some(context.parent.as_ref(), |element, parent_name| { - if *dupe_name { - element.child( - Label::new(parent_name.clone()) - .size(LabelSize::XSmall) - .color(Color::Muted), - ) - } else { - element - } - }) - .when_some(context.tooltip.as_ref(), |element, tooltip| { - element.tooltip(Tooltip::text(tooltip.clone())) - }), - ) - .when_some(on_remove.as_ref(), |element, on_remove| { - element.child( - IconButton::new(("remove", context.id.0), IconName::Close) - .shape(IconButtonShape::Square) - .icon_size(IconSize::XSmall) - .tooltip(Tooltip::text("Remove Context")) - .on_click({ - let on_remove = on_remove.clone(); - move |event, window, cx| on_remove(event, window, cx) + } => { + let status_is_error = matches!(context.status, ContextStatus::Error { .. }); + + base_pill + .pr(if on_remove.is_some() { px(2.) } else { px(4.) }) + .map(|pill| { + if status_is_error { + pill.bg(cx.theme().status().error_background) + .border_color(cx.theme().status().error_border) + } else if *focused { + pill.bg(color.element_background) + .border_color(color.border_focused) + } else { + pill.bg(color.element_background) + .border_color(color.border.opacity(0.5)) + } + }) + .child( + h_flex() + .id("context-data") + .gap_1() + .child( + div().max_w_64().child( + Label::new(context.name.clone()) + .size(LabelSize::Small) + .truncate(), + ), + ) + .when_some(context.parent.as_ref(), |element, parent_name| { + if *dupe_name { + element.child( + Label::new(parent_name.clone()) + .size(LabelSize::XSmall) + .color(Color::Muted), + ) + } else { + element + } + }) + .when_some(context.tooltip.as_ref(), |element, tooltip| { + element.tooltip(Tooltip::text(tooltip.clone())) + }) + .map(|element| match &context.status { + ContextStatus::Ready => element + .when_some( + context.show_preview.as_ref(), + |element, show_preview| { + element.hoverable_tooltip({ + let show_preview = show_preview.clone(); + move |window, cx| show_preview(window, cx) + }) + }, + ) + .into_any(), + ContextStatus::Loading { message } => element + .tooltip(ui::Tooltip::text(message.clone())) + .with_animation( + "pulsating-ctx-pill", + Animation::new(Duration::from_secs(2)) + .repeat() + .with_easing(pulsating_between(0.4, 0.8)), + |label, delta| label.opacity(delta), + ) + .into_any_element(), + ContextStatus::Error { message } => element + .tooltip(ui::Tooltip::text(message.clone())) + .into_any_element(), }), ) - }) - .when_some(on_click.as_ref(), |element, on_click| { - let on_click = on_click.clone(); - element - .cursor_pointer() - .on_click(move |event, window, cx| on_click(event, window, cx)) - }) - .map(|element| { - if context.summarizing { + .when_some(on_remove.as_ref(), |element, on_remove| { + element.child( + IconButton::new(("remove", context.id.0), IconName::Close) + .shape(IconButtonShape::Square) + .icon_size(IconSize::XSmall) + .tooltip(Tooltip::text("Remove Context")) + .on_click({ + let on_remove = on_remove.clone(); + move |event, window, cx| on_remove(event, window, cx) + }), + ) + }) + .when_some(on_click.as_ref(), |element, on_click| { + let on_click = on_click.clone(); element - .tooltip(ui::Tooltip::text("Summarizing...")) - .with_animation( - "pulsating-ctx-pill", - Animation::new(Duration::from_secs(2)) - .repeat() - .with_easing(pulsating_between(0.4, 0.8)), - |label, delta| label.opacity(delta), - ) - .into_any_element() - } else { - element.into_any() - } - }), + .cursor_pointer() + .on_click(move |event, window, cx| on_click(event, window, cx)) + }) + .into_any_element() + } ContextPill::Suggested { name, icon_path: _, @@ -198,15 +222,15 @@ impl RenderOnce for ContextPill { .cursor_pointer() .pr_1() .border_dashed() - .border_color(if *focused { - color.border_focused - } else { - color.border + .map(|pill| { + if *focused { + pill.border_color(color.border_focused) + .bg(color.element_background.opacity(0.5)) + } else { + pill.border_color(color.border) + } }) .hover(|style| style.bg(color.element_hover.opacity(0.5))) - .when(*focused, |this| { - this.bg(color.element_background.opacity(0.5)) - }) .child( div().max_w_64().child( Label::new(name.clone()) @@ -227,6 +251,13 @@ impl RenderOnce for ContextPill { } } +pub enum ContextStatus { + Ready, + Loading { message: SharedString }, + Error { message: SharedString }, +} + +#[derive(RegisterComponent)] pub struct AddedContext { pub id: ContextId, pub kind: ContextKind, @@ -234,7 +265,8 @@ pub struct AddedContext { pub parent: Option, pub tooltip: Option, pub icon_path: Option, - pub summarizing: bool, + pub status: ContextStatus, + pub show_preview: Option AnyView + 'static>>, } impl AddedContext { @@ -259,7 +291,8 @@ impl AddedContext { parent, tooltip: Some(full_path_string), icon_path: FileIcons::get_icon(&full_path, cx), - summarizing: false, + status: ContextStatus::Ready, + show_preview: None, } } @@ -289,7 +322,8 @@ impl AddedContext { parent, tooltip: Some(full_path_string), icon_path: None, - summarizing: false, + status: ContextStatus::Ready, + show_preview: None, } } @@ -300,7 +334,8 @@ impl AddedContext { parent: None, tooltip: None, icon_path: None, - summarizing: false, + status: ContextStatus::Ready, + show_preview: None, }, AssistantContext::Excerpt(excerpt_context) => { @@ -327,12 +362,13 @@ impl AddedContext { AddedContext { id: excerpt_context.id, - kind: ContextKind::File, // Use File icon for excerpts + kind: ContextKind::File, name: name.into(), parent, tooltip: Some(full_path_string.into()), icon_path: FileIcons::get_icon(&full_path, cx), - summarizing: false, + status: ContextStatus::Ready, + show_preview: None, } } @@ -343,7 +379,8 @@ impl AddedContext { parent: None, tooltip: None, icon_path: None, - summarizing: false, + status: ContextStatus::Ready, + show_preview: None, }, AssistantContext::Thread(thread_context) => AddedContext { @@ -353,10 +390,18 @@ impl AddedContext { parent: None, tooltip: None, icon_path: None, - summarizing: thread_context + status: if thread_context .thread .read(cx) - .is_generating_detailed_summary(), + .is_generating_detailed_summary() + { + ContextStatus::Loading { + message: "Summarizing…".into(), + } + } else { + ContextStatus::Ready + }, + show_preview: None, }, AssistantContext::Rules(user_rules_context) => AddedContext { @@ -366,8 +411,122 @@ impl AddedContext { parent: None, tooltip: None, icon_path: None, - summarizing: false, + status: ContextStatus::Ready, + show_preview: None, + }, + + AssistantContext::Image(image_context) => AddedContext { + id: image_context.id, + kind: ContextKind::Image, + name: "Image".into(), + parent: None, + tooltip: None, + icon_path: None, + status: if image_context.is_loading() { + ContextStatus::Loading { + message: "Loading…".into(), + } + } else if image_context.is_error() { + ContextStatus::Error { + message: "Failed to load image".into(), + } + } else { + ContextStatus::Ready + }, + show_preview: Some(Rc::new({ + let image = image_context.original_image.clone(); + move |_, cx| { + cx.new(|_| ImagePreview { + image: image.clone(), + }) + .into() + } + })), }, } } } + +struct ImagePreview { + image: Arc, +} + +impl Render for ImagePreview { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + tooltip_container(window, cx, move |this, _, _| { + this.occlude() + .on_mouse_move(|_, _, cx| cx.stop_propagation()) + .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation()) + .child(gpui::img(self.image.clone()).max_w_96().max_h_96()) + }) + } +} + +impl Component for AddedContext { + fn scope() -> ComponentScope { + ComponentScope::Agent + } + + fn sort_name() -> &'static str { + "AddedContext" + } + + fn preview(_window: &mut Window, cx: &mut App) -> Option { + let image_ready = ( + "Ready", + AddedContext::new( + &AssistantContext::Image(ImageContext { + id: ContextId(0), + original_image: Arc::new(Image::empty()), + image_task: Task::ready(Some(LanguageModelImage::empty())).shared(), + }), + cx, + ), + ); + + let image_loading = ( + "Loading", + AddedContext::new( + &AssistantContext::Image(ImageContext { + id: ContextId(1), + original_image: Arc::new(Image::empty()), + image_task: cx + .background_spawn(async move { + smol::Timer::after(Duration::from_secs(60 * 5)).await; + Some(LanguageModelImage::empty()) + }) + .shared(), + }), + cx, + ), + ); + + let image_error = ( + "Error", + AddedContext::new( + &AssistantContext::Image(ImageContext { + id: ContextId(2), + original_image: Arc::new(Image::empty()), + image_task: Task::ready(None).shared(), + }), + cx, + ), + ); + + Some( + v_flex() + .gap_6() + .children( + vec![image_ready, image_loading, image_error] + .into_iter() + .map(|(text, context)| { + single_example( + text, + ContextPill::added(context, false, false, None).into_any_element(), + ) + }), + ) + .into_any(), + ) + } +} diff --git a/crates/assistant_context_editor/src/context_editor.rs b/crates/assistant_context_editor/src/context_editor.rs index f21fee3f4c..522f0f92c6 100644 --- a/crates/assistant_context_editor/src/context_editor.rs +++ b/crates/assistant_context_editor/src/context_editor.rs @@ -2089,7 +2089,7 @@ impl ContextEditor { continue; }; let image_id = image.id(); - let image_task = LanguageModelImage::from_image(image, cx).shared(); + let image_task = LanguageModelImage::from_image(Arc::new(image), cx).shared(); for image_position in image_positions.iter() { context.insert_content( diff --git a/crates/gpui/src/platform.rs b/crates/gpui/src/platform.rs index 6f9b615000..270107f80c 100644 --- a/crates/gpui/src/platform.rs +++ b/crates/gpui/src/platform.rs @@ -1497,6 +1497,15 @@ impl Hash for Image { } impl Image { + /// An empty image containing no data + pub fn empty() -> Self { + Self { + format: ImageFormat::Png, + bytes: Vec::new(), + id: 0, + } + } + /// Get this image's ID pub fn id(&self) -> u64 { self.id diff --git a/crates/icons/src/icons.rs b/crates/icons/src/icons.rs index d7f4a820da..fc19181c2b 100644 --- a/crates/icons/src/icons.rs +++ b/crates/icons/src/icons.rs @@ -139,6 +139,7 @@ pub enum IconName { Globe, Hash, HistoryRerun, + Image, Indicator, Info, InlayHint, diff --git a/crates/language_model/src/request.rs b/crates/language_model/src/request.rs index 44fb82b88a..9816bc73c0 100644 --- a/crates/language_model/src/request.rs +++ b/crates/language_model/src/request.rs @@ -32,7 +32,14 @@ impl std::fmt::Debug for LanguageModelImage { const ANTHROPIC_SIZE_LIMT: f32 = 1568.; impl LanguageModelImage { - pub fn from_image(data: Image, cx: &mut App) -> Task> { + pub fn empty() -> Self { + Self { + source: "".into(), + size: size(DevicePixels(0), DevicePixels(0)), + } + } + + pub fn from_image(data: Arc, cx: &mut App) -> Task> { cx.background_spawn(async move { match data.format() { gpui::ImageFormat::Png