1use tracing::info;
2use uuid::Uuid;
3
4use crate::backend::web_search;
5
6const WEB_SEARCH_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(15);
7
8pub struct InjectedPrompt {
10 pub content: String,
12 pub performed: bool,
14}
15
16fn log(cb: &std::sync::Mutex<Option<Box<dyn Fn(String) + Send + Sync>>>, msg: String) {
17 if let Some(c) = cb.lock().unwrap().as_ref() {
18 c(msg);
19 }
20}
21
22pub async fn build_injected_prompt(
25 preset_name: &str,
26 messages: &serde_json::Value,
27 web_search_enabled: bool,
28 web_search_engine: &str,
29 web_search_engine_url: &str,
30 web_search_api_key: &str,
31 log_callback: &std::sync::Mutex<Option<Box<dyn Fn(String) + Send + Sync>>>,
32) -> InjectedPrompt {
33 log(
34 log_callback,
35 format!(
36 "Web search: preset='{}', enabled={}",
37 preset_name, web_search_enabled
38 ),
39 );
40 if !web_search_enabled {
41 log(
42 log_callback,
43 "Web search: disabled in config, skipping".into(),
44 );
45 return InjectedPrompt {
46 content: String::new(),
47 performed: false,
48 };
49 }
50
51 let messages_array = match messages.get("messages").and_then(|m| m.as_array()) {
52 Some(m) => {
53 info!("Web search: found {} messages", m.len());
54 log(
55 log_callback,
56 format!("Web search: found {} messages", m.len()),
57 );
58 m
59 }
60 None => {
61 info!("Web search: no messages array in request");
62 log(
63 log_callback,
64 "Web search: no messages array in request".into(),
65 );
66 return InjectedPrompt {
67 content: String::new(),
68 performed: false,
69 };
70 }
71 };
72
73 if messages_array.is_empty() {
74 log(log_callback, "Web search: empty messages array".into());
75 return InjectedPrompt {
76 content: String::new(),
77 performed: false,
78 };
79 }
80
81 let last_msg = messages_array.last().unwrap();
82 let user_content = last_msg.get("content");
83 let content = match user_content {
84 Some(serde_json::Value::String(s)) => {
85 info!("Web search: content is String ({} chars)", s.len());
86 log(
87 log_callback,
88 format!("Web search: content is String ({} chars)", s.len()),
89 );
90 s.clone()
91 }
92 Some(serde_json::Value::Array(parts)) => {
93 info!("Web search: content is Array with {} parts", parts.len());
94 let text_parts: Vec<&str> = parts
95 .iter()
96 .filter_map(|p| p.get("text").and_then(|t| t.as_str()))
97 .collect();
98 if text_parts.is_empty() {
99 info!("Web search: no text parts in array");
100 log(log_callback, "Web search: no text parts in array".into());
101 return InjectedPrompt {
102 content: String::new(),
103 performed: false,
104 };
105 }
106 let joined = text_parts.join(" ");
107 info!("Web search: joined content ({} chars)", joined.len());
108 log(
109 log_callback,
110 format!("Web search: joined content ({} chars)", joined.len()),
111 );
112 joined
113 }
114 _ => {
115 info!(
116 "Web search: content type is {:?}",
117 user_content.map(|v| {
118 match v {
119 serde_json::Value::Null => "null",
120 serde_json::Value::Bool(_) => "bool",
121 serde_json::Value::Number(_) => "number",
122 serde_json::Value::String(_) => "string",
123 serde_json::Value::Array(_) => "array",
124 serde_json::Value::Object(_) => "object",
125 }
126 })
127 );
128 log(log_callback, "Web search: unsupported content type".into());
129 return InjectedPrompt {
130 content: String::new(),
131 performed: false,
132 };
133 }
134 };
135
136 let needs = web_search::needs_search(&content);
137 info!(
138 "Web search: needs_search={} for '{}'",
139 needs,
140 &content[..content
141 .char_indices()
142 .nth(80)
143 .map(|(i, _)| i)
144 .unwrap_or(content.len())]
145 );
146 log(
147 log_callback,
148 format!(
149 "Web search: needs_search={} for '{}'",
150 needs,
151 &content[..content
152 .char_indices()
153 .nth(80)
154 .map(|(i, _)| i)
155 .unwrap_or(content.len())]
156 ),
157 );
158 if !needs {
159 log(
160 log_callback,
161 "Web search: no search keywords found, skipping".into(),
162 );
163 return InjectedPrompt {
164 content: String::new(),
165 performed: false,
166 };
167 }
168
169 info!(
170 "Web search: triggering for message: {}",
171 &content[..content
172 .char_indices()
173 .nth(100)
174 .map(|(i, _)| i)
175 .unwrap_or(content.len())]
176 );
177 log(
178 log_callback,
179 format!(
180 "Web search: triggering for: '{}'",
181 &content[..content
182 .char_indices()
183 .nth(100)
184 .map(|(i, _)| i)
185 .unwrap_or(content.len())]
186 ),
187 );
188
189 let query = content.to_string();
190 let engine = web_search_engine.to_string();
191 let engine_url = web_search_engine_url.to_string();
192 let api_key = web_search_api_key.to_string();
193 log(
194 log_callback,
195 format!("Web search: engine={}, url={}", engine, engine_url),
196 );
197 let search_handle = tokio::spawn(async move {
198 web_search::gather_search_context(&query, &engine, &engine_url, &api_key).await
199 });
200
201 let search_result = match tokio::time::timeout(WEB_SEARCH_TIMEOUT, search_handle).await {
202 Ok(Ok(Ok((ctx, sources)))) => {
203 info!("Web search: gathered context ({} chars)", ctx.len());
204 log(
205 log_callback,
206 format!(
207 "Web search: gathered {} chars, {} sources",
208 ctx.len(),
209 sources.len()
210 ),
211 );
212 (ctx, sources)
213 }
214 Ok(Ok(Err(e))) => {
215 info!("Web search failed: {}", e);
216 log(log_callback, format!("Web search failed: {}", e));
217 return InjectedPrompt {
218 content: String::new(),
219 performed: false,
220 };
221 }
222 Ok(Err(e)) => {
223 info!("Web search task panicked: {}", e);
224 log(log_callback, format!("Web search task panicked: {}", e));
225 return InjectedPrompt {
226 content: String::new(),
227 performed: false,
228 };
229 }
230 Err(_) => {
231 info!("Web search timed out");
232 log(log_callback, "Web search timed out".into());
233 return InjectedPrompt {
234 content: String::new(),
235 performed: false,
236 };
237 }
238 };
239
240 let (search_context, sources) = search_result;
241
242 let sources_section = if sources.is_empty() {
244 String::new()
245 } else {
246 let sources_list: String = sources
247 .iter()
248 .enumerate()
249 .map(|(i, url)| format!("{}. {}", i + 1, url))
250 .collect::<Vec<_>>()
251 .join("\n");
252 format!(
253 "\n\n---\n\n**Sources:**\n{}\n\n**When using information from these sources, display the original URL as a reference.**",
254 sources_list
255 )
256 };
257
258 info!(
259 "Web search: gathered context ({} chars)",
260 search_context.len()
261 );
262
263 let ctx_id = Uuid::new_v4();
264 let new_content = format!(
265 "[WEB-CTX-{}]\nINSTRUCTION: Cite sources using inline markdown links in your answer. Format: [source name](URL). Place links directly after the facts they support. If you find PDF link, add them to the list with brief description. Do NOT include claims you cannot verify.\n\n{}\n[/WEB-CTX-{}]\n\n{}\n\n---\n\n{}",
266 ctx_id, search_context, sources_section, ctx_id, content
267 );
268
269 if let Some(cb) = log_callback.lock().unwrap().as_ref() {
270 cb(format!(
271 "Web search: results injected ({} chars)",
272 search_context.len()
273 ));
274 }
275
276 InjectedPrompt {
277 content: new_content,
278 performed: true,
279 }
280}