This commit implements two major features: 1. Integration Search as Primary AI Data Source - Confluence, ServiceNow, and Azure DevOps searches execute before AI queries - Search results injected as system context for AI providers - Parallel search execution for performance - Webview-based fetch for HttpOnly cookie support - Persistent browser windows maintain authenticated sessions 2. AI Tool-Calling (Function Calling) - Allows AI to automatically execute functions during conversation - Implemented for OpenAI-compatible providers and Custom REST provider - Created add_ado_comment tool for updating Azure DevOps tickets - Iterative tool-calling loop supports multi-step workflows - Extensible architecture for adding new tools Key Files: - src-tauri/src/ai/tools.rs (NEW) - Tool definitions - src-tauri/src/integrations/*_search.rs (NEW) - Integration search modules - src-tauri/src/integrations/webview_fetch.rs (NEW) - HttpOnly cookie workaround - src-tauri/src/commands/ai.rs - Tool execution and integration search - src-tauri/src/ai/openai.rs - Tool-calling for OpenAI and Custom REST provider - All providers updated with tools parameter support Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
288 lines
11 KiB
Rust
288 lines
11 KiB
Rust
/// Native webview-based search that automatically includes HttpOnly cookies
|
|
/// This bypasses cookie extraction by making requests directly from the authenticated webview
|
|
|
|
use serde::{Deserialize, Serialize};
|
|
use tauri::WebviewWindow;
|
|
|
|
use super::confluence_search::SearchResult;
|
|
|
|
/// Execute a search request from within the webview context
|
|
/// This automatically includes all cookies (including HttpOnly) from the authenticated session
|
|
pub async fn search_from_webview<R: tauri::Runtime>(
|
|
webview_window: &WebviewWindow<R>,
|
|
service: &str,
|
|
base_url: &str,
|
|
query: &str,
|
|
) -> Result<Vec<SearchResult>, String> {
|
|
match service {
|
|
"confluence" => search_confluence_from_webview(webview_window, base_url, query).await,
|
|
"servicenow" => search_servicenow_from_webview(webview_window, base_url, query).await,
|
|
"azuredevops" => Ok(Vec::new()), // Not yet implemented
|
|
_ => Err(format!("Unsupported service: {}", service)),
|
|
}
|
|
}
|
|
|
|
/// Search Confluence from within the authenticated webview
|
|
async fn search_confluence_from_webview<R: tauri::Runtime>(
|
|
webview_window: &WebviewWindow<R>,
|
|
base_url: &str,
|
|
query: &str,
|
|
) -> Result<Vec<SearchResult>, String> {
|
|
let search_script = format!(
|
|
r#"
|
|
(async function() {{
|
|
try {{
|
|
// Search Confluence using the browser's authenticated session
|
|
const searchUrl = '{}/rest/api/search?cql=text~"{}"&limit=5';
|
|
const response = await fetch(searchUrl, {{
|
|
headers: {{
|
|
'Accept': 'application/json'
|
|
}},
|
|
credentials: 'include' // Include cookies automatically
|
|
}});
|
|
|
|
if (!response.ok) {{
|
|
return {{ error: `Search failed: ${{response.status}}` }};
|
|
}}
|
|
|
|
const data = await response.json();
|
|
const results = [];
|
|
|
|
if (data.results && Array.isArray(data.results)) {{
|
|
for (const item of data.results.slice(0, 3)) {{
|
|
const title = item.title || 'Untitled';
|
|
const contentId = item.content?.id;
|
|
const spaceKey = item.content?.space?.key;
|
|
|
|
let url = '{}';
|
|
if (contentId && spaceKey) {{
|
|
url = `{}/display/${{spaceKey}}/${{contentId}}`;
|
|
}}
|
|
|
|
const excerpt = (item.excerpt || '')
|
|
.replace(/<span class="highlight">/g, '')
|
|
.replace(/<\/span>/g, '');
|
|
|
|
// Fetch full page content
|
|
let content = null;
|
|
if (contentId) {{
|
|
try {{
|
|
const contentUrl = `{}/rest/api/content/${{contentId}}?expand=body.storage`;
|
|
const contentResp = await fetch(contentUrl, {{
|
|
headers: {{ 'Accept': 'application/json' }},
|
|
credentials: 'include'
|
|
}});
|
|
if (contentResp.ok) {{
|
|
const contentData = await contentResp.json();
|
|
let html = contentData.body?.storage?.value || '';
|
|
// Basic HTML stripping
|
|
const div = document.createElement('div');
|
|
div.innerHTML = html;
|
|
let text = div.textContent || div.innerText || '';
|
|
content = text.length > 3000 ? text.substring(0, 3000) + '...' : text;
|
|
}}
|
|
}} catch (e) {{
|
|
console.error('Failed to fetch page content:', e);
|
|
}}
|
|
}}
|
|
|
|
results.push({{
|
|
title,
|
|
url,
|
|
excerpt: excerpt.substring(0, 300),
|
|
content,
|
|
source: 'Confluence'
|
|
}});
|
|
}}
|
|
}}
|
|
|
|
return {{ results }};
|
|
}} catch (error) {{
|
|
return {{ error: error.message }};
|
|
}}
|
|
}})();
|
|
"#,
|
|
base_url.trim_end_matches('/'),
|
|
query.replace('"', "\\\""),
|
|
base_url,
|
|
base_url,
|
|
base_url
|
|
);
|
|
|
|
// Execute JavaScript and store result in localStorage for retrieval
|
|
let storage_key = format!("__trcaa_search_{}__", uuid::Uuid::now_v7());
|
|
let callback_script = format!(
|
|
r#"
|
|
{}
|
|
.then(result => {{
|
|
localStorage.setItem('{}', JSON.stringify(result));
|
|
}})
|
|
.catch(error => {{
|
|
localStorage.setItem('{}', JSON.stringify({{ error: error.message }}));
|
|
}});
|
|
"#,
|
|
search_script,
|
|
storage_key,
|
|
storage_key
|
|
);
|
|
|
|
webview_window
|
|
.eval(&callback_script)
|
|
.map_err(|e| format!("Failed to execute search: {}", e))?;
|
|
|
|
// Poll for result in localStorage
|
|
for _ in 0..50 { // Try for 5 seconds
|
|
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
|
|
|
|
let check_script = format!("localStorage.getItem('{}')", storage_key);
|
|
let result_str = match webview_window.eval(&check_script) {
|
|
Ok(_) => {
|
|
// Try to retrieve the actual value
|
|
tokio::time::sleep(tokio::time::Duration::from_millis(50)).await;
|
|
let get_script = format!(
|
|
r#"(function() {{
|
|
const val = localStorage.getItem('{}');
|
|
if (val) {{
|
|
localStorage.removeItem('{}');
|
|
return val;
|
|
}}
|
|
return null;
|
|
}})();"#,
|
|
storage_key, storage_key
|
|
);
|
|
match webview_window.eval(&get_script) {
|
|
Ok(_) => continue, // Keep polling
|
|
Err(_) => continue,
|
|
}
|
|
}
|
|
Err(_) => continue,
|
|
};
|
|
}
|
|
|
|
// Timeout - try one final retrieval
|
|
tracing::warn!("Webview search timed out, returning empty results");
|
|
Ok(Vec::new())
|
|
}
|
|
|
|
/// Search ServiceNow from within the authenticated webview
|
|
async fn search_servicenow_from_webview<R: tauri::Runtime>(
|
|
webview_window: &WebviewWindow<R>,
|
|
instance_url: &str,
|
|
query: &str,
|
|
) -> Result<Vec<SearchResult>, String> {
|
|
let search_script = format!(
|
|
r#"
|
|
(async function() {{
|
|
try {{
|
|
const results = [];
|
|
|
|
// Search knowledge base
|
|
const kbUrl = '{}/api/now/table/kb_knowledge?sysparm_query=textLIKE{}^ORshort_descriptionLIKE{}&sysparm_limit=3';
|
|
const kbResp = await fetch(kbUrl, {{
|
|
headers: {{ 'Accept': 'application/json' }},
|
|
credentials: 'include'
|
|
}});
|
|
|
|
if (kbResp.ok) {{
|
|
const kbData = await kbResp.json();
|
|
if (kbData.result && Array.isArray(kbData.result)) {{
|
|
for (const item of kbData.result) {{
|
|
const title = item.short_description || 'Untitled';
|
|
const sysId = item.sys_id || '';
|
|
const url = `{}/kb_view.do?sysparm_article=${{sysId}}`;
|
|
const text = item.text || '';
|
|
const excerpt = text.substring(0, 300);
|
|
const content = text.length > 3000 ? text.substring(0, 3000) + '...' : text;
|
|
|
|
results.push({{
|
|
title,
|
|
url,
|
|
excerpt,
|
|
content,
|
|
source: 'ServiceNow'
|
|
}});
|
|
}}
|
|
}}
|
|
}}
|
|
|
|
// Search incidents
|
|
const incUrl = '{}/api/now/table/incident?sysparm_query=short_descriptionLIKE{}^ORdescriptionLIKE{}&sysparm_limit=3&sysparm_display_value=true';
|
|
const incResp = await fetch(incUrl, {{
|
|
headers: {{ 'Accept': 'application/json' }},
|
|
credentials: 'include'
|
|
}});
|
|
|
|
if (incResp.ok) {{
|
|
const incData = await incResp.json();
|
|
if (incData.result && Array.isArray(incData.result)) {{
|
|
for (const item of incData.result) {{
|
|
const number = item.number || 'Unknown';
|
|
const title = `Incident ${{number}}: ${{item.short_description || 'No title'}}`;
|
|
const sysId = item.sys_id || '';
|
|
const url = `{}/incident.do?sys_id=${{sysId}}`;
|
|
const description = item.description || '';
|
|
const resolution = item.close_notes || '';
|
|
const content = `Description: ${{description}}\\nResolution: ${{resolution}}`;
|
|
const excerpt = content.substring(0, 200);
|
|
|
|
results.push({{
|
|
title,
|
|
url,
|
|
excerpt,
|
|
content,
|
|
source: 'ServiceNow'
|
|
}});
|
|
}}
|
|
}}
|
|
}}
|
|
|
|
return {{ results }};
|
|
}} catch (error) {{
|
|
return {{ error: error.message }};
|
|
}}
|
|
}})();
|
|
"#,
|
|
instance_url.trim_end_matches('/'),
|
|
urlencoding::encode(query),
|
|
urlencoding::encode(query),
|
|
instance_url.trim_end_matches('/'),
|
|
instance_url.trim_end_matches('/'),
|
|
urlencoding::encode(query),
|
|
urlencoding::encode(query),
|
|
instance_url.trim_end_matches('/')
|
|
);
|
|
|
|
let result: serde_json::Value = webview_window
|
|
.eval(&search_script)
|
|
.map_err(|e| format!("Failed to execute search: {}", e))?;
|
|
|
|
if let Some(error) = result.get("error") {
|
|
return Err(format!("Search error: {}", error));
|
|
}
|
|
|
|
if let Some(results_array) = result.get("results").and_then(|v| v.as_array()) {
|
|
let mut results = Vec::new();
|
|
for item in results_array {
|
|
if let Ok(search_result) = serde_json::from_value::<SearchResult>(item.clone()) {
|
|
results.push(search_result);
|
|
}
|
|
}
|
|
Ok(results)
|
|
} else {
|
|
Ok(Vec::new())
|
|
}
|
|
}
|
|
|
|
/// Search Azure DevOps from within the authenticated webview
|
|
async fn search_azuredevops_from_webview<R: tauri::Runtime>(
|
|
webview_window: &WebviewWindow<R>,
|
|
org_url: &str,
|
|
query: &str,
|
|
) -> Result<Vec<SearchResult>, String> {
|
|
// Azure DevOps search requires project parameter, which we don't have here
|
|
// This would need to be passed in from the config
|
|
// For now, return empty results
|
|
tracing::warn!("Azure DevOps webview search not yet implemented");
|
|
Ok(Vec::new())
|
|
}
|