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>
|
</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>
|
||||||
|
|||||||
@ -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
16
prod.sh
@ -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
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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)]
|
||||||
|
|||||||
@ -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
|
|
||||||
}
|
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
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