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:
parent
16bc3315eb
commit
927d106eae
@ -101,7 +101,7 @@
|
||||
</div>
|
||||
<template if={props.aiToolStatus}>
|
||||
<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>
|
||||
</template>
|
||||
<template if={!props.aiToolStatus}>
|
||||
@ -692,6 +692,10 @@
|
||||
textarea.style.height = 'auto'
|
||||
}
|
||||
},
|
||||
|
||||
isSearchTool(toolName) {
|
||||
return toolName === 'web_search' || toolName === 'brave_search'
|
||||
},
|
||||
}
|
||||
</script>
|
||||
</chat-room>
|
||||
|
||||
@ -28,8 +28,8 @@
|
||||
<div if={hasToolResults()} class="tool-results-section">
|
||||
<div each={tr in getToolResults()} class="tool-result-item">
|
||||
<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-label">{tr.tool === 'brave_search' ? 'Search' : tr.tool === 'web_fetch' ? 'Fetched' : tr.tool}: {tr.input}</span>
|
||||
<span class="tool-result-icon">{isSearchTool(tr.tool) ? '🔍' : tr.tool === 'web_fetch' ? '🌐' : '⚙️'}</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>
|
||||
</button>
|
||||
<div class="tool-result-body collapsed">
|
||||
@ -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
|
||||
|
||||
16
prod.sh
16
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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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<String> {
|
||||
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),
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<String> {
|
||||
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<String>,
|
||||
pub brave_api_key: Option<String>,
|
||||
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 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,
|
||||
});
|
||||
|
||||
@ -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)]
|
||||
|
||||
@ -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<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.
|
||||
/// 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
|
||||
}
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
pub mod brave;
|
||||
pub mod fetch;
|
||||
pub mod openrouter;
|
||||
pub mod search;
|
||||
pub mod tavily;
|
||||
|
||||
@ -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<Tool> {
|
||||
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",
|
||||
|
||||
79
server/src/services/search.rs
Normal file
79
server/src/services/search.rs
Normal 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
|
||||
}
|
||||
91
server/src/services/tavily.rs
Normal file
91
server/src/services/tavily.rs
Normal 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)
|
||||
}
|
||||
BIN
server/uploads/avatars/17fc5479-fa75-4a91-818a-06e6ea01e689.png
Normal file
BIN
server/uploads/avatars/17fc5479-fa75-4a91-818a-06e6ea01e689.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.4 MiB |
Loading…
x
Reference in New Issue
Block a user