Add provider-based web search with Tavily support

- Add `SEARCH_PROVIDER` config with Tavily/Brave API key validation in server and prod script
- Introduce unified `web_search` tool and shared search service with Tavily + Brave backends
- Update chat UI tool status/result labels to treat both search tools consistently
This commit is contained in:
Jason Tudisco 2026-03-16 20:18:27 -06:00
parent 16bc3315eb
commit 927d106eae
13 changed files with 245 additions and 45 deletions

View File

@ -101,7 +101,7 @@
</div> </div>
<template if={props.aiToolStatus}> <template if={props.aiToolStatus}>
<span class="tool-status-text"> <span class="tool-status-text">
{props.aiToolStatus.tool === 'brave_search' ? '🔍 Searching...' : props.aiToolStatus.tool === 'web_fetch' ? '🌐 Reading page...' : '⚙️ Using tool...'} {isSearchTool(props.aiToolStatus.tool) ? '🔍 Searching...' : props.aiToolStatus.tool === 'web_fetch' ? '🌐 Reading page...' : '⚙️ Using tool...'}
</span> </span>
</template> </template>
<template if={!props.aiToolStatus}> <template if={!props.aiToolStatus}>
@ -692,6 +692,10 @@
textarea.style.height = 'auto' textarea.style.height = 'auto'
} }
}, },
isSearchTool(toolName) {
return toolName === 'web_search' || toolName === 'brave_search'
},
} }
</script> </script>
</chat-room> </chat-room>

View File

@ -28,8 +28,8 @@
<div if={hasToolResults()} class="tool-results-section"> <div if={hasToolResults()} class="tool-results-section">
<div each={tr in getToolResults()} class="tool-result-item"> <div each={tr in getToolResults()} class="tool-result-item">
<button class="tool-result-toggle" onclick={toggleToolResult}> <button class="tool-result-toggle" onclick={toggleToolResult}>
<span class="tool-result-icon">{tr.tool === 'brave_search' ? '🔍' : tr.tool === 'web_fetch' ? '🌐' : '⚙️'}</span> <span class="tool-result-icon">{isSearchTool(tr.tool) ? '🔍' : tr.tool === 'web_fetch' ? '🌐' : '⚙️'}</span>
<span class="tool-result-label">{tr.tool === 'brave_search' ? 'Search' : tr.tool === 'web_fetch' ? 'Fetched' : tr.tool}: {tr.input}</span> <span class="tool-result-label">{isSearchTool(tr.tool) ? 'Search' : tr.tool === 'web_fetch' ? 'Fetched' : tr.tool}: {tr.input}</span>
<span class="tool-result-arrow">▼</span> <span class="tool-result-arrow">▼</span>
</button> </button>
<div class="tool-result-body collapsed"> <div class="tool-result-body collapsed">
@ -462,6 +462,10 @@
return this.props.message?.ai_meta?.tool_results || [] return this.props.message?.ai_meta?.tool_results || []
}, },
isSearchTool(toolName) {
return toolName === 'web_search' || toolName === 'brave_search'
},
toggleToolResult(e) { toggleToolResult(e) {
const toggle = e.currentTarget const toggle = e.currentTarget
const body = toggle.nextElementSibling const body = toggle.nextElementSibling

16
prod.sh
View File

@ -38,12 +38,24 @@ check_env() {
source "$ROOT/server/.env" source "$ROOT/server/.env"
set +a set +a
SEARCH_PROVIDER="${SEARCH_PROVIDER:-tavily}"
if [ -z "$OPENROUTER_API_KEY" ] || [ "$OPENROUTER_API_KEY" = "your-openrouter-api-key-here" ]; then if [ -z "$OPENROUTER_API_KEY" ] || [ "$OPENROUTER_API_KEY" = "your-openrouter-api-key-here" ]; then
echo -e "${RED}[prod] OPENROUTER_API_KEY not set in server/.env${NC}" echo -e "${RED}[prod] OPENROUTER_API_KEY not set in server/.env${NC}"
exit 1 exit 1
fi fi
if [ -z "$BRAVE_API_KEY" ] || [ "$BRAVE_API_KEY" = "your-brave-api-key-here" ]; then if [ "$SEARCH_PROVIDER" = "tavily" ]; then
echo -e "${RED}[prod] BRAVE_API_KEY not set in server/.env${NC}" if [ -z "$TAVILY_API_KEY" ] || [ "$TAVILY_API_KEY" = "tvly-your-key-here" ]; then
echo -e "${RED}[prod] TAVILY_API_KEY not set in server/.env${NC}"
exit 1
fi
elif [ "$SEARCH_PROVIDER" = "brave" ]; then
if [ -z "$BRAVE_API_KEY" ] || [ "$BRAVE_API_KEY" = "your-brave-api-key-here" ]; then
echo -e "${RED}[prod] BRAVE_API_KEY not set in server/.env${NC}"
exit 1
fi
else
echo -e "${RED}[prod] SEARCH_PROVIDER must be 'tavily' or 'brave'${NC}"
exit 1 exit 1
fi fi
if [ -z "$JWT_SECRET" ] || [ "$JWT_SECRET" = "dev-secret-change-me" ]; then if [ -z "$JWT_SECRET" ] || [ "$JWT_SECRET" = "dev-secret-change-me" ]; then

View File

@ -11,7 +11,13 @@ JWT_SECRET=change-me-to-a-random-secret
# OpenRouter API # OpenRouter API
OPENROUTER_API_KEY=sk-or-v1-your-key-here OPENROUTER_API_KEY=sk-or-v1-your-key-here
# Brave Search API # Search provider: tavily or brave
SEARCH_PROVIDER=tavily
# Tavily Search API
TAVILY_API_KEY=tvly-your-key-here
# Brave Search API (optional unless SEARCH_PROVIDER=brave)
BRAVE_API_KEY=your-brave-api-key-here BRAVE_API_KEY=your-brave-api-key-here
# Production: path to built client files (default: ../client/dist) # Production: path to built client files (default: ../client/dist)

View File

@ -12,7 +12,7 @@ use uuid::Uuid;
use crate::{ use crate::{
middleware::auth::decode_token, middleware::auth::decode_token,
models::{BroadcastEvent, MessagePayload, WsClientMessage, WsServerMessage}, models::{BroadcastEvent, MessagePayload, WsClientMessage, WsServerMessage},
services::{brave, fetch, openrouter}, services::{fetch, openrouter, search},
AppState, AppState,
}; };
@ -337,7 +337,9 @@ async fn handle_send_message(
let tool_result = execute_tool( let tool_result = execute_tool(
&tool_call.function.name, &tool_call.function.name,
&tool_call.function.arguments, &tool_call.function.arguments,
&state.brave_api_key, state.search_provider,
state.tavily_api_key.as_deref(),
state.brave_api_key.as_deref(),
) )
.await; .await;
@ -486,16 +488,22 @@ async fn encode_image_as_data_url(url: &str) -> Option<String> {
fn extract_tool_input(tool_name: &str, arguments: &str) -> String { fn extract_tool_input(tool_name: &str, arguments: &str) -> String {
let args: serde_json::Value = serde_json::from_str(arguments).unwrap_or_default(); let args: serde_json::Value = serde_json::from_str(arguments).unwrap_or_default();
match tool_name { match tool_name {
"brave_search" => args["query"].as_str().unwrap_or("").to_string(), "web_search" | "brave_search" => args["query"].as_str().unwrap_or("").to_string(),
"web_fetch" => args["url"].as_str().unwrap_or("").to_string(), "web_fetch" => args["url"].as_str().unwrap_or("").to_string(),
_ => arguments.to_string(), _ => arguments.to_string(),
} }
} }
/// Execute a tool call by name, returning the result as a string. /// Execute a tool call by name, returning the result as a string.
async fn execute_tool(name: &str, arguments: &str, brave_api_key: &str) -> String { async fn execute_tool(
name: &str,
arguments: &str,
search_provider: search::SearchProvider,
tavily_api_key: Option<&str>,
brave_api_key: Option<&str>,
) -> String {
match name { match name {
"brave_search" => { "web_search" | "brave_search" => {
let args: serde_json::Value = serde_json::from_str(arguments).unwrap_or_default(); let args: serde_json::Value = serde_json::from_str(arguments).unwrap_or_default();
let query = args["query"].as_str().unwrap_or("").to_string(); let query = args["query"].as_str().unwrap_or("").to_string();
let count = args["count"].as_u64().unwrap_or(5) as u8; let count = args["count"].as_u64().unwrap_or(5) as u8;
@ -504,8 +512,8 @@ async fn execute_tool(name: &str, arguments: &str, brave_api_key: &str) -> Strin
return "Error: search query is required".into(); return "Error: search query is required".into();
} }
match brave::search(&query, brave_api_key, count).await { match search::search(search_provider, &query, tavily_api_key, brave_api_key, count).await {
Ok(results) => brave::format_results(&results), Ok(results) => search::format_results(&results),
Err(e) => format!("Search error: {}", e), Err(e) => format!("Search error: {}", e),
} }
} }

View File

@ -14,6 +14,8 @@ use tower_http::cors::{Any, CorsLayer};
use tower_http::services::{ServeDir, ServeFile}; use tower_http::services::{ServeDir, ServeFile};
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
use crate::services::search::SearchProvider;
/// Extract the file path from a SQLite DATABASE_URL like "sqlite:chat.db?mode=rwc" /// Extract the file path from a SQLite DATABASE_URL like "sqlite:chat.db?mode=rwc"
fn db_file_path(database_url: &str) -> Option<String> { fn db_file_path(database_url: &str) -> Option<String> {
let path = database_url.strip_prefix("sqlite:")?; let path = database_url.strip_prefix("sqlite:")?;
@ -135,7 +137,9 @@ pub struct AppState {
pub db: sqlx::SqlitePool, pub db: sqlx::SqlitePool,
pub jwt_secret: String, pub jwt_secret: String,
pub openrouter_key: String, pub openrouter_key: String,
pub brave_api_key: String, pub search_provider: SearchProvider,
pub tavily_api_key: Option<String>,
pub brave_api_key: Option<String>,
pub tx: broadcast::Sender<models::BroadcastEvent>, pub tx: broadcast::Sender<models::BroadcastEvent>,
} }
@ -153,7 +157,20 @@ async fn main() {
let database_url = std::env::var("DATABASE_URL").unwrap_or_else(|_| "sqlite:chat.db?mode=rwc".into()); let database_url = std::env::var("DATABASE_URL").unwrap_or_else(|_| "sqlite:chat.db?mode=rwc".into());
let jwt_secret = std::env::var("JWT_SECRET").unwrap_or_else(|_| "dev-secret-change-me".into()); let jwt_secret = std::env::var("JWT_SECRET").unwrap_or_else(|_| "dev-secret-change-me".into());
let openrouter_key = std::env::var("OPENROUTER_API_KEY").expect("OPENROUTER_API_KEY must be set"); let openrouter_key = std::env::var("OPENROUTER_API_KEY").expect("OPENROUTER_API_KEY must be set");
let brave_api_key = std::env::var("BRAVE_API_KEY").expect("BRAVE_API_KEY must be set"); let search_provider = SearchProvider::from_env(std::env::var("SEARCH_PROVIDER").ok().as_deref())
.unwrap_or_else(|e| panic!("{}", e));
let tavily_api_key = std::env::var("TAVILY_API_KEY").ok();
let brave_api_key = std::env::var("BRAVE_API_KEY").ok();
match search_provider {
SearchProvider::Tavily if tavily_api_key.as_deref().unwrap_or("").is_empty() => {
panic!("TAVILY_API_KEY must be set when SEARCH_PROVIDER=tavily");
}
SearchProvider::Brave if brave_api_key.as_deref().unwrap_or("").is_empty() => {
panic!("BRAVE_API_KEY must be set when SEARCH_PROVIDER=brave");
}
_ => {}
}
// Backup the database before connecting and running migrations // Backup the database before connecting and running migrations
backup_database(&database_url); backup_database(&database_url);
@ -271,6 +288,8 @@ async fn main() {
db, db,
jwt_secret, jwt_secret,
openrouter_key, openrouter_key,
search_provider,
tavily_api_key,
brave_api_key, brave_api_key,
tx, tx,
}); });

View File

@ -106,7 +106,7 @@ fn default_ai_name() -> String {
} }
fn default_system_prompt() -> String { fn default_system_prompt() -> String {
"You are a helpful AI assistant participating in a group chat. Be conversational, helpful, and concise. You can see messages from all participants. When mentioned with @ai, respond helpfully.\n\nYou have access to tools:\n- **brave_search**: Search the web for current information. Use this when asked about recent events, news, facts you're unsure about, or anything that needs up-to-date information.\n- **web_fetch**: Fetch and read the content of a web page. Use this when a user shares a URL and wants you to read/summarize it, or when you need more details from a search result.\n\nUse tools proactively when they would help answer the question better. You don't need to ask permission to use them.".to_string() "You are a helpful AI assistant participating in a group chat. Be conversational, helpful, and concise. You can see messages from all participants. When mentioned with @ai, respond helpfully.\n\nYou have access to tools:\n- **web_search**: Search the web for current information. Use this when asked about recent events, news, facts you're unsure about, or anything that needs up-to-date information.\n- **web_fetch**: Fetch and read the content of a web page. Use this when a user shares a URL and wants you to read/summarize it, or when you need more details from a search result.\n\nUse tools proactively when they would help answer the question better. You don't need to ask permission to use them.".to_string()
} }
#[derive(Debug, Serialize)] #[derive(Debug, Serialize)]

View File

@ -1,5 +1,7 @@
use serde::Deserialize; use serde::Deserialize;
use crate::services::search::SearchResult;
const BRAVE_SEARCH_URL: &str = "https://api.search.brave.com/res/v1/web/search"; const BRAVE_SEARCH_URL: &str = "https://api.search.brave.com/res/v1/web/search";
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
@ -23,15 +25,6 @@ struct BraveResult {
extra_snippets: Option<Vec<String>>, extra_snippets: Option<Vec<String>>,
} }
/// A simplified search result for consumption by the AI.
#[derive(Debug)]
pub struct SearchResult {
pub title: String,
pub url: String,
pub description: String,
pub age: Option<String>,
}
/// Search the web using the Brave Search API. /// Search the web using the Brave Search API.
/// Returns a list of simplified search results. /// Returns a list of simplified search results.
pub async fn search( pub async fn search(
@ -91,21 +84,3 @@ pub async fn search(
Ok(results) Ok(results)
} }
/// Format search results into a readable string for the AI to consume.
pub fn format_results(results: &[SearchResult]) -> String {
if results.is_empty() {
return "No search results found.".to_string();
}
let mut output = String::new();
for (i, r) in results.iter().enumerate() {
output.push_str(&format!("{}. {}\n", i + 1, r.title));
output.push_str(&format!(" URL: {}\n", r.url));
if let Some(age) = &r.age {
output.push_str(&format!(" Age: {}\n", age));
}
output.push_str(&format!(" {}\n\n", r.description));
}
output
}

View File

@ -1,3 +1,5 @@
pub mod brave; pub mod brave;
pub mod fetch; pub mod fetch;
pub mod openrouter; pub mod openrouter;
pub mod search;
pub mod tavily;

View File

@ -149,13 +149,13 @@ pub struct CompletionStats {
pub response_ms: u64, pub response_ms: u64,
} }
/// Build the tool definitions for brave_search and web_fetch. /// Build the tool definitions for web_search and web_fetch.
pub fn build_tools() -> Vec<Tool> { pub fn build_tools() -> Vec<Tool> {
vec![ vec![
Tool { Tool {
r#type: "function".into(), r#type: "function".into(),
function: ToolFunction { function: ToolFunction {
name: "brave_search".into(), name: "web_search".into(),
description: "Search the web for current information. Use this when users ask about recent events, need factual data you're unsure about, or want up-to-date information.".into(), description: "Search the web for current information. Use this when users ask about recent events, need factual data you're unsure about, or want up-to-date information.".into(),
parameters: serde_json::json!({ parameters: serde_json::json!({
"type": "object", "type": "object",

View File

@ -0,0 +1,79 @@
use serde::{Deserialize, Serialize};
use super::{brave, tavily};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SearchProvider {
Tavily,
Brave,
}
impl SearchProvider {
pub fn from_env(value: Option<&str>) -> Result<Self, String> {
match value.unwrap_or("tavily").trim().to_ascii_lowercase().as_str() {
"tavily" => Ok(Self::Tavily),
"brave" => Ok(Self::Brave),
other => Err(format!(
"Unsupported SEARCH_PROVIDER '{}'. Expected 'tavily' or 'brave'.",
other
)),
}
}
pub fn required_key_name(self) -> &'static str {
match self {
Self::Tavily => "TAVILY_API_KEY",
Self::Brave => "BRAVE_API_KEY",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SearchResult {
pub title: String,
pub url: String,
pub description: String,
pub age: Option<String>,
}
pub async fn search(
provider: SearchProvider,
query: &str,
tavily_api_key: Option<&str>,
brave_api_key: Option<&str>,
count: u8,
) -> Result<Vec<SearchResult>, String> {
match provider {
SearchProvider::Tavily => {
let api_key = tavily_api_key
.filter(|key| !key.is_empty())
.ok_or_else(|| "TAVILY_API_KEY is not configured".to_string())?;
tavily::search(query, api_key, count).await
}
SearchProvider::Brave => {
let api_key = brave_api_key
.filter(|key| !key.is_empty())
.ok_or_else(|| "BRAVE_API_KEY is not configured".to_string())?;
brave::search(query, api_key, count).await
}
}
}
pub fn format_results(results: &[SearchResult]) -> String {
if results.is_empty() {
return "No search results found.".to_string();
}
let mut output = String::new();
for (i, r) in results.iter().enumerate() {
output.push_str(&format!("{}. {}\n", i + 1, r.title));
if !r.url.is_empty() {
output.push_str(&format!(" URL: {}\n", r.url));
}
if let Some(age) = &r.age {
output.push_str(&format!(" Age: {}\n", age));
}
output.push_str(&format!(" {}\n\n", r.description));
}
output
}

View File

@ -0,0 +1,91 @@
use serde::Deserialize;
use crate::services::search::SearchResult;
const TAVILY_SEARCH_URL: &str = "https://api.tavily.com/search";
#[derive(Debug, Deserialize)]
struct TavilyResponse {
#[serde(default)]
answer: Option<String>,
#[serde(default)]
results: Vec<TavilyResult>,
}
#[derive(Debug, Deserialize)]
struct TavilyResult {
title: String,
url: String,
#[serde(default)]
content: String,
#[serde(default)]
#[serde(alias = "publishedDate")]
published_date: Option<String>,
}
pub async fn search(
query: &str,
api_key: &str,
count: u8,
) -> Result<Vec<SearchResult>, String> {
let max_results = count.clamp(1, 10);
let client = reqwest::Client::new();
let response = client
.post(TAVILY_SEARCH_URL)
.header("Authorization", format!("Bearer {}", api_key))
.header("Content-Type", "application/json")
.json(&serde_json::json!({
"query": query,
"topic": "general",
"search_depth": "advanced",
"include_answer": true,
"include_raw_content": false,
"max_results": max_results,
}))
.send()
.await
.map_err(|e| format!("Tavily search request failed: {}", e))?;
if !response.status().is_success() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
return Err(format!("Tavily search error {}: {}", status, body));
}
let tavily_response: TavilyResponse = response
.json()
.await
.map_err(|e| format!("Failed to parse Tavily response: {}", e))?;
let answer = tavily_response.answer.unwrap_or_default();
let mut results: Vec<SearchResult> = tavily_response
.results
.into_iter()
.map(|result| SearchResult {
title: result.title,
url: result.url,
description: result.content,
age: result.published_date,
})
.collect();
if !answer.is_empty() {
if let Some(first) = results.first_mut() {
if first.description.is_empty() {
first.description = format!("AI summary: {}", answer);
} else {
first.description = format!("AI summary: {}\nSource excerpt: {}", answer, first.description);
}
} else {
results.push(SearchResult {
title: "AI Summary".to_string(),
url: String::new(),
description: answer,
age: None,
});
}
}
Ok(results)
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB