@@ -462,6 +462,10 @@
return this.props.message?.ai_meta?.tool_results || []
},
+ isSearchTool(toolName) {
+ return toolName === 'web_search' || toolName === 'brave_search'
+ },
+
toggleToolResult(e) {
const toggle = e.currentTarget
const body = toggle.nextElementSibling
diff --git a/prod.sh b/prod.sh
index 6fd642c..8dde32d 100755
--- a/prod.sh
+++ b/prod.sh
@@ -38,12 +38,24 @@ check_env() {
source "$ROOT/server/.env"
set +a
+ SEARCH_PROVIDER="${SEARCH_PROVIDER:-tavily}"
+
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}"
exit 1
fi
- 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}"
+ if [ "$SEARCH_PROVIDER" = "tavily" ]; then
+ 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
fi
if [ -z "$JWT_SECRET" ] || [ "$JWT_SECRET" = "dev-secret-change-me" ]; then
diff --git a/server/.env.example b/server/.env.example
index 7da919b..52c093e 100644
--- a/server/.env.example
+++ b/server/.env.example
@@ -11,7 +11,13 @@ JWT_SECRET=change-me-to-a-random-secret
# OpenRouter API
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
# Production: path to built client files (default: ../client/dist)
diff --git a/server/src/handlers/ws.rs b/server/src/handlers/ws.rs
index e55d2bd..2790b11 100644
--- a/server/src/handlers/ws.rs
+++ b/server/src/handlers/ws.rs
@@ -12,7 +12,7 @@ use uuid::Uuid;
use crate::{
middleware::auth::decode_token,
models::{BroadcastEvent, MessagePayload, WsClientMessage, WsServerMessage},
- services::{brave, fetch, openrouter},
+ services::{fetch, openrouter, search},
AppState,
};
@@ -337,7 +337,9 @@ async fn handle_send_message(
let tool_result = execute_tool(
&tool_call.function.name,
&tool_call.function.arguments,
- &state.brave_api_key,
+ state.search_provider,
+ state.tavily_api_key.as_deref(),
+ state.brave_api_key.as_deref(),
)
.await;
@@ -486,16 +488,22 @@ async fn encode_image_as_data_url(url: &str) -> Option {
fn extract_tool_input(tool_name: &str, arguments: &str) -> String {
let args: serde_json::Value = serde_json::from_str(arguments).unwrap_or_default();
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(),
_ => arguments.to_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 {
- "brave_search" => {
+ "web_search" | "brave_search" => {
let args: serde_json::Value = serde_json::from_str(arguments).unwrap_or_default();
let query = args["query"].as_str().unwrap_or("").to_string();
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();
}
- match brave::search(&query, brave_api_key, count).await {
- Ok(results) => brave::format_results(&results),
+ match search::search(search_provider, &query, tavily_api_key, brave_api_key, count).await {
+ Ok(results) => search::format_results(&results),
Err(e) => format!("Search error: {}", e),
}
}
diff --git a/server/src/main.rs b/server/src/main.rs
index 5ef29fe..549cf73 100644
--- a/server/src/main.rs
+++ b/server/src/main.rs
@@ -14,6 +14,8 @@ use tower_http::cors::{Any, CorsLayer};
use tower_http::services::{ServeDir, ServeFile};
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"
fn db_file_path(database_url: &str) -> Option {
let path = database_url.strip_prefix("sqlite:")?;
@@ -135,7 +137,9 @@ pub struct AppState {
pub db: sqlx::SqlitePool,
pub jwt_secret: String,
pub openrouter_key: String,
- pub brave_api_key: String,
+ pub search_provider: SearchProvider,
+ pub tavily_api_key: Option,
+ pub brave_api_key: Option,
pub tx: broadcast::Sender,
}
@@ -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 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 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_database(&database_url);
@@ -271,6 +288,8 @@ async fn main() {
db,
jwt_secret,
openrouter_key,
+ search_provider,
+ tavily_api_key,
brave_api_key,
tx,
});
diff --git a/server/src/models/mod.rs b/server/src/models/mod.rs
index 61e8b5b..455a98c 100644
--- a/server/src/models/mod.rs
+++ b/server/src/models/mod.rs
@@ -106,7 +106,7 @@ fn default_ai_name() -> 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)]
diff --git a/server/src/services/brave.rs b/server/src/services/brave.rs
index fd431f9..a949cb4 100644
--- a/server/src/services/brave.rs
+++ b/server/src/services/brave.rs
@@ -1,5 +1,7 @@
use serde::Deserialize;
+use crate::services::search::SearchResult;
+
const BRAVE_SEARCH_URL: &str = "https://api.search.brave.com/res/v1/web/search";
#[derive(Debug, Deserialize)]
@@ -23,15 +25,6 @@ struct BraveResult {
extra_snippets: Option>,
}
-/// 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,
-}
-
/// Search the web using the Brave Search API.
/// Returns a list of simplified search results.
pub async fn search(
@@ -91,21 +84,3 @@ pub async fn search(
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
-}
diff --git a/server/src/services/mod.rs b/server/src/services/mod.rs
index c9e0c75..f382938 100644
--- a/server/src/services/mod.rs
+++ b/server/src/services/mod.rs
@@ -1,3 +1,5 @@
pub mod brave;
pub mod fetch;
pub mod openrouter;
+pub mod search;
+pub mod tavily;
diff --git a/server/src/services/openrouter.rs b/server/src/services/openrouter.rs
index a1f44a6..c78cc98 100644
--- a/server/src/services/openrouter.rs
+++ b/server/src/services/openrouter.rs
@@ -149,13 +149,13 @@ pub struct CompletionStats {
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 {
vec![
Tool {
r#type: "function".into(),
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(),
parameters: serde_json::json!({
"type": "object",
diff --git a/server/src/services/search.rs b/server/src/services/search.rs
new file mode 100644
index 0000000..fc351e1
--- /dev/null
+++ b/server/src/services/search.rs
@@ -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 {
+ 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,
+}
+
+pub async fn search(
+ provider: SearchProvider,
+ query: &str,
+ tavily_api_key: Option<&str>,
+ brave_api_key: Option<&str>,
+ count: u8,
+) -> Result, 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
+}
diff --git a/server/src/services/tavily.rs b/server/src/services/tavily.rs
new file mode 100644
index 0000000..e516bfd
--- /dev/null
+++ b/server/src/services/tavily.rs
@@ -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,
+ #[serde(default)]
+ results: Vec,
+}
+
+#[derive(Debug, Deserialize)]
+struct TavilyResult {
+ title: String,
+ url: String,
+ #[serde(default)]
+ content: String,
+ #[serde(default)]
+ #[serde(alias = "publishedDate")]
+ published_date: Option,
+}
+
+pub async fn search(
+ query: &str,
+ api_key: &str,
+ count: u8,
+) -> Result, 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 = 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)
+}
diff --git a/server/uploads/avatars/17fc5479-fa75-4a91-818a-06e6ea01e689.png b/server/uploads/avatars/17fc5479-fa75-4a91-818a-06e6ea01e689.png
new file mode 100644
index 0000000..cfe1e6f
Binary files /dev/null and b/server/uploads/avatars/17fc5479-fa75-4a91-818a-06e6ea01e689.png differ