
Now the edit tool can access files outside the current project (just like the terminal tool can), but it's behind a prompt (unlike other edit tool actions). Release Notes: - The edit tool can now access files outside the current project, but only if the user grants it permission to.
329 lines
12 KiB
Rust
329 lines
12 KiB
Rust
use std::{sync::Arc, time::Duration};
|
|
|
|
use crate::schema::json_schema_for;
|
|
use crate::ui::ToolCallCardHeader;
|
|
use anyhow::{Context as _, Result, anyhow};
|
|
use assistant_tool::{
|
|
ActionLog, Tool, ToolCard, ToolResult, ToolResultContent, ToolResultOutput, ToolUseStatus,
|
|
};
|
|
use futures::{Future, FutureExt, TryFutureExt};
|
|
use gpui::{
|
|
AnyWindowHandle, App, AppContext, Context, Entity, IntoElement, Task, WeakEntity, Window,
|
|
};
|
|
use language_model::{LanguageModel, LanguageModelRequest, LanguageModelToolSchemaFormat};
|
|
use project::Project;
|
|
use schemars::JsonSchema;
|
|
use serde::{Deserialize, Serialize};
|
|
use ui::{IconName, Tooltip, prelude::*};
|
|
use web_search::WebSearchRegistry;
|
|
use workspace::Workspace;
|
|
use zed_llm_client::{WebSearchResponse, WebSearchResult};
|
|
|
|
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
|
|
pub struct WebSearchToolInput {
|
|
/// The search term or question to query on the web.
|
|
query: String,
|
|
}
|
|
|
|
pub struct WebSearchTool;
|
|
|
|
impl Tool for WebSearchTool {
|
|
fn name(&self) -> String {
|
|
"web_search".into()
|
|
}
|
|
|
|
fn needs_confirmation(&self, _: &serde_json::Value, _: &Entity<Project>, _: &App) -> bool {
|
|
false
|
|
}
|
|
|
|
fn may_perform_edits(&self) -> bool {
|
|
false
|
|
}
|
|
|
|
fn description(&self) -> String {
|
|
"Search the web for information using your query. Use this when you need real-time information, facts, or data that might not be in your training. Results will include snippets and links from relevant web pages.".into()
|
|
}
|
|
|
|
fn icon(&self) -> IconName {
|
|
IconName::Globe
|
|
}
|
|
|
|
fn input_schema(&self, format: LanguageModelToolSchemaFormat) -> Result<serde_json::Value> {
|
|
json_schema_for::<WebSearchToolInput>(format)
|
|
}
|
|
|
|
fn ui_text(&self, _input: &serde_json::Value) -> String {
|
|
"Searching the Web".to_string()
|
|
}
|
|
|
|
fn run(
|
|
self: Arc<Self>,
|
|
input: serde_json::Value,
|
|
_request: Arc<LanguageModelRequest>,
|
|
_project: Entity<Project>,
|
|
_action_log: Entity<ActionLog>,
|
|
_model: Arc<dyn LanguageModel>,
|
|
_window: Option<AnyWindowHandle>,
|
|
cx: &mut App,
|
|
) -> ToolResult {
|
|
let input = match serde_json::from_value::<WebSearchToolInput>(input) {
|
|
Ok(input) => input,
|
|
Err(err) => return Task::ready(Err(anyhow!(err))).into(),
|
|
};
|
|
let Some(provider) = WebSearchRegistry::read_global(cx).active_provider() else {
|
|
return Task::ready(Err(anyhow!("Web search is not available."))).into();
|
|
};
|
|
|
|
let search_task = provider.search(input.query, cx).map_err(Arc::new).shared();
|
|
let output = cx.background_spawn({
|
|
let search_task = search_task.clone();
|
|
async move {
|
|
let response = search_task.await.map_err(|err| anyhow!(err))?;
|
|
Ok(ToolResultOutput {
|
|
content: ToolResultContent::Text(
|
|
serde_json::to_string(&response)
|
|
.context("Failed to serialize search results")?,
|
|
),
|
|
output: Some(serde_json::to_value(response)?),
|
|
})
|
|
}
|
|
});
|
|
|
|
ToolResult {
|
|
output,
|
|
card: Some(cx.new(|cx| WebSearchToolCard::new(search_task, cx)).into()),
|
|
}
|
|
}
|
|
|
|
fn deserialize_card(
|
|
self: Arc<Self>,
|
|
output: serde_json::Value,
|
|
_project: Entity<Project>,
|
|
_window: &mut Window,
|
|
cx: &mut App,
|
|
) -> Option<assistant_tool::AnyToolCard> {
|
|
let output = serde_json::from_value::<WebSearchResponse>(output).ok()?;
|
|
let card = cx.new(|cx| WebSearchToolCard::new(Task::ready(Ok(output)), cx));
|
|
Some(card.into())
|
|
}
|
|
}
|
|
|
|
#[derive(RegisterComponent)]
|
|
struct WebSearchToolCard {
|
|
response: Option<Result<WebSearchResponse>>,
|
|
_task: Task<()>,
|
|
}
|
|
|
|
impl WebSearchToolCard {
|
|
fn new(
|
|
search_task: impl 'static + Future<Output = Result<WebSearchResponse, Arc<anyhow::Error>>>,
|
|
cx: &mut Context<Self>,
|
|
) -> Self {
|
|
let _task = cx.spawn(async move |this, cx| {
|
|
let response = search_task.await.map_err(|err| anyhow!(err));
|
|
this.update(cx, |this, cx| {
|
|
this.response = Some(response);
|
|
cx.notify();
|
|
})
|
|
.ok();
|
|
});
|
|
|
|
Self {
|
|
response: None,
|
|
_task,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl ToolCard for WebSearchToolCard {
|
|
fn render(
|
|
&mut self,
|
|
_status: &ToolUseStatus,
|
|
_window: &mut Window,
|
|
_workspace: WeakEntity<Workspace>,
|
|
cx: &mut Context<Self>,
|
|
) -> impl IntoElement {
|
|
let icon = IconName::ToolWeb;
|
|
|
|
let header = match self.response.as_ref() {
|
|
Some(Ok(response)) => {
|
|
let text: SharedString = if response.results.len() == 1 {
|
|
"1 result".into()
|
|
} else {
|
|
format!("{} results", response.results.len()).into()
|
|
};
|
|
ToolCallCardHeader::new(icon, "Searched the Web").with_secondary_text(text)
|
|
}
|
|
Some(Err(error)) => {
|
|
ToolCallCardHeader::new(icon, "Web Search").with_error(error.to_string())
|
|
}
|
|
None => ToolCallCardHeader::new(icon, "Searching the Web").loading(),
|
|
};
|
|
|
|
let content = self.response.as_ref().and_then(|response| match response {
|
|
Ok(response) => Some(
|
|
v_flex()
|
|
.overflow_hidden()
|
|
.ml_1p5()
|
|
.pl(px(5.))
|
|
.border_l_1()
|
|
.border_color(cx.theme().colors().border_variant)
|
|
.gap_1()
|
|
.children(response.results.iter().enumerate().map(|(index, result)| {
|
|
let title = result.title.clone();
|
|
let url = SharedString::from(result.url.clone());
|
|
|
|
Button::new(("result", index), title)
|
|
.label_size(LabelSize::Small)
|
|
.color(Color::Muted)
|
|
.icon(IconName::ArrowUpRight)
|
|
.icon_size(IconSize::XSmall)
|
|
.icon_position(IconPosition::End)
|
|
.truncate(true)
|
|
.tooltip({
|
|
let url = url.clone();
|
|
move |window, cx| {
|
|
Tooltip::with_meta(
|
|
"Web Search Result",
|
|
None,
|
|
url.clone(),
|
|
window,
|
|
cx,
|
|
)
|
|
}
|
|
})
|
|
.on_click({
|
|
let url = url.clone();
|
|
move |_, _, cx| cx.open_url(&url)
|
|
})
|
|
}))
|
|
.into_any(),
|
|
),
|
|
Err(_) => None,
|
|
});
|
|
|
|
v_flex().mb_3().gap_1().child(header).children(content)
|
|
}
|
|
}
|
|
|
|
impl Component for WebSearchToolCard {
|
|
fn scope() -> ComponentScope {
|
|
ComponentScope::Agent
|
|
}
|
|
|
|
fn preview(window: &mut Window, cx: &mut App) -> Option<AnyElement> {
|
|
let in_progress_search = cx.new(|cx| WebSearchToolCard {
|
|
response: None,
|
|
_task: cx.spawn(async move |_this, cx| {
|
|
loop {
|
|
cx.background_executor()
|
|
.timer(Duration::from_secs(60))
|
|
.await
|
|
}
|
|
}),
|
|
});
|
|
|
|
let successful_search = cx.new(|_cx| WebSearchToolCard {
|
|
response: Some(Ok(example_search_response())),
|
|
_task: Task::ready(()),
|
|
});
|
|
|
|
let error_search = cx.new(|_cx| WebSearchToolCard {
|
|
response: Some(Err(anyhow!("Failed to resolve https://google.com"))),
|
|
_task: Task::ready(()),
|
|
});
|
|
|
|
Some(
|
|
v_flex()
|
|
.gap_6()
|
|
.children(vec![example_group(vec![
|
|
single_example(
|
|
"In Progress",
|
|
div()
|
|
.size_full()
|
|
.child(in_progress_search.update(cx, |tool, cx| {
|
|
tool.render(
|
|
&ToolUseStatus::Pending,
|
|
window,
|
|
WeakEntity::new_invalid(),
|
|
cx,
|
|
)
|
|
.into_any_element()
|
|
}))
|
|
.into_any_element(),
|
|
),
|
|
single_example(
|
|
"Successful",
|
|
div()
|
|
.size_full()
|
|
.child(successful_search.update(cx, |tool, cx| {
|
|
tool.render(
|
|
&ToolUseStatus::Finished("".into()),
|
|
window,
|
|
WeakEntity::new_invalid(),
|
|
cx,
|
|
)
|
|
.into_any_element()
|
|
}))
|
|
.into_any_element(),
|
|
),
|
|
single_example(
|
|
"Error",
|
|
div()
|
|
.size_full()
|
|
.child(error_search.update(cx, |tool, cx| {
|
|
tool.render(
|
|
&ToolUseStatus::Error("".into()),
|
|
window,
|
|
WeakEntity::new_invalid(),
|
|
cx,
|
|
)
|
|
.into_any_element()
|
|
}))
|
|
.into_any_element(),
|
|
),
|
|
])])
|
|
.into_any_element(),
|
|
)
|
|
}
|
|
}
|
|
|
|
fn example_search_response() -> WebSearchResponse {
|
|
WebSearchResponse {
|
|
results: vec![
|
|
WebSearchResult {
|
|
title: "Alo".to_string(),
|
|
url: "https://www.google.com/maps/search/Alo%2C+Toronto%2C+Canada".to_string(),
|
|
text: "Alo is a popular restaurant in Toronto.".to_string(),
|
|
},
|
|
WebSearchResult {
|
|
title: "Alo".to_string(),
|
|
url: "https://www.google.com/maps/search/Alo%2C+Toronto%2C+Canada".to_string(),
|
|
text: "Information about Alo restaurant in Toronto.".to_string(),
|
|
},
|
|
WebSearchResult {
|
|
title: "Edulis".to_string(),
|
|
url: "https://www.google.com/maps/search/Edulis%2C+Toronto%2C+Canada".to_string(),
|
|
text: "Details about Edulis restaurant in Toronto.".to_string(),
|
|
},
|
|
WebSearchResult {
|
|
title: "Sushi Masaki Saito".to_string(),
|
|
url: "https://www.google.com/maps/search/Sushi+Masaki+Saito%2C+Toronto%2C+Canada"
|
|
.to_string(),
|
|
text: "Information about Sushi Masaki Saito in Toronto.".to_string(),
|
|
},
|
|
WebSearchResult {
|
|
title: "Shoushin".to_string(),
|
|
url: "https://www.google.com/maps/search/Shoushin%2C+Toronto%2C+Canada".to_string(),
|
|
text: "Details about Shoushin restaurant in Toronto.".to_string(),
|
|
},
|
|
WebSearchResult {
|
|
title: "Restaurant 20 Victoria".to_string(),
|
|
url:
|
|
"https://www.google.com/maps/search/Restaurant+20+Victoria%2C+Toronto%2C+Canada"
|
|
.to_string(),
|
|
text: "Information about Restaurant 20 Victoria in Toronto.".to_string(),
|
|
},
|
|
],
|
|
}
|
|
}
|