feat: add local agent MCP tools and optional source-grounded AI answers
Core CI / core (ubuntu-latest) (push) Failing after 3m6s
Core CI / core (macos-latest) (push) Canceled after 0s
Core CI / core (windows-latest) (push) Canceled after 0s
Core CI / desktop (ubuntu-latest) (push) Successful in 5m41s
Core CI / desktop (macos-latest) (push) Canceled after 0s
Core CI / desktop (windows-latest) (push) Canceled after 0s

This commit is contained in:
karl committed 2026-10-12 20:28:38 +02:00
1 parent 70746ef428
commit f5b03c6fb0
27 files changed
+2988 -13

No files matched your search

+11
View File
@@ -3688,6 +3688,15 @@ version = "1.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d68f2ec51b097e4c1a75b681a8bec621909b5e91f15bb7b840c4f2f7b01148b2"
[[package]]
name = "searchi-ai"
version = "0.1.0"
dependencies = [
"reqwest 0.12.28",
"serde",
"serde_json",
]
[[package]]
name = "searchi-assets"
version = "0.1.0"
@@ -3720,6 +3729,7 @@ version = "0.1.0"
dependencies = [
"anyhow",
"image",
"searchi-ai",
"searchi-assets",
"searchi-core",
"searchi-media",
@@ -3732,6 +3742,7 @@ dependencies = [
"tauri-plugin-global-shortcut",
"tauri-plugin-opener",
"tempfile",
"uuid",
]
[[package]]
+2
View File
@@ -26,4 +26,6 @@ searchi-core = { path = "../../../crates/searchi-core" }
searchi-service = { path = "../../../crates/searchi-service" }
searchi-assets = { path = "../../../crates/searchi-assets" }
searchi-media = { path = "../../../crates/searchi-media" }
searchi-ai = { path = "../../../crates/searchi-ai" }
uuid = { version = "1", features = ["v4"] }
image = { version = "0.25", default-features = false, features = ["jpeg", "png", "webp", "bmp"] }
+375
View File
@@ -0,0 +1,375 @@
//! Explicit optional requests, grounded in a single prepared local source.
//! Credentials are request/session memory only; this module never saves config.
use crate::AppState;
use searchi_ai::{AiAnswer, AiClient, AiConfig, AiMode, AiRequest, AiSource};
use searchi_core::{SearchIndex, SourceLocation};
use serde::{Deserialize, Serialize};
use std::sync::{
atomic::{AtomicBool, AtomicU64, Ordering},
Arc, Mutex,
};
use tauri::State;
#[derive(Default)]
pub(crate) struct AiSession {
prepared: Mutex<Option<Prepared>>,
running: AtomicBool,
latest_revision: AtomicU64,
}
#[derive(Clone)]
struct Prepared {
id: String,
path: String,
source: SourceLocation,
text: String,
revision: u64,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct SourceIdentity {
kind: String,
number: u32,
}
#[derive(Serialize)]
pub(crate) struct SourcePreview {
id: String,
text: String,
location: String,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct GenerateInput {
enabled: bool,
config: AiConfig,
source_id: String,
mode: AiMode,
question: Option<String>,
}
impl AiSession {
fn prepare(
&self,
index: &SearchIndex,
path: &str,
source: SourceLocation,
revision: u64,
) -> Result<SourcePreview, String> {
let passage = index
.source_passage(path, &source, 4096)
.map_err(|_| "Selected passage is unavailable.".to_owned())?
.ok_or("Select an unchanged indexed text passage before using optional AI.")?;
let preview = SourcePreview {
id: uuid::Uuid::new_v4().to_string(),
text: passage.text,
location: format!("{} {}", source.kind, source.number),
};
if preview.text.trim().is_empty() || preview.text.len() > searchi_ai::MAX_SOURCE_TEXT_BYTES
{
return Err("The selected passage is empty or exceeds the AI text limit.".into());
}
let mut catalog = self
.prepared
.lock()
.map_err(|_| "Selected AI source is unavailable.")?;
if revision != self.latest_revision.load(Ordering::Acquire) {
return Err("The selected passage changed while preparing it.".into());
}
*catalog = Some(Prepared {
id: preview.id.clone(),
path: passage.path,
source,
text: preview.text.clone(),
revision,
});
Ok(preview)
}
fn grounded_request(
&self,
index: &SearchIndex,
input: &GenerateInput,
) -> Result<AiRequest, String> {
if !input.enabled {
return Err(
"Enable optional AI and explicitly request an answer before any text is sent."
.into(),
);
}
input.config.validate().map_err(|error| error.to_string())?;
if input.source_id.len() > 64
|| input
.question
.as_ref()
.is_some_and(|question| question.len() > searchi_ai::MAX_QUESTION_BYTES)
{
return Err("AI request exceeds its source or question limit.".into());
}
let prepared = self
.prepared
.lock()
.map_err(|_| "Selected AI source is unavailable.")?
.clone()
.filter(|value| value.id == input.source_id)
.ok_or("Prepare and review the current selected passage before requesting AI.")?;
if prepared.revision != self.latest_revision.load(Ordering::Acquire) {
return Err("Review the current selected passage before requesting AI.".into());
}
let actual = index
.source_passage(&prepared.path, &prepared.source, 4096)
.map_err(|_| "Selected passage is unavailable.".to_owned())?
.ok_or(
"The file changed or is unavailable. Reindex it and review the passage again.",
)?;
if actual.text != prepared.text {
return Err("The indexed passage changed. Review it again before sending.".into());
}
if matches!(input.mode, AiMode::Question)
&& input
.question
.as_ref()
.is_none_or(|value| value.trim().is_empty())
{
return Err("Enter a question about the selected passage.".into());
}
Ok(AiRequest {
mode: input.mode.clone(),
question: if matches!(input.mode, AiMode::Question) {
input.question.clone()
} else {
None
},
sources: vec![AiSource {
id: "selected".into(),
title: "Selected passage".into(),
text: actual.text,
location: Some(format!("{} {}", actual.source.kind, actual.source.number)),
}],
})
}
}
struct RunningGuard(Arc<AiSession>);
impl Drop for RunningGuard {
fn drop(&mut self) {
self.0.running.store(false, Ordering::Release);
}
}
#[tauri::command]
pub(crate) async fn prepare_ai_source(
enabled: bool,
path: String,
source: SourceIdentity,
revision: u64,
state: State<'_, AppState>,
) -> Result<SourcePreview, String> {
if !enabled {
return Err("Optional AI is disabled.".into());
}
if path.len() > 32768 || source.kind.len() > 16 {
return Err("Selected source identity exceeds its limit.".into());
}
let database = state.database.clone();
let session = state.ai.clone();
session
.latest_revision
.fetch_max(revision, Ordering::AcqRel);
tauri::async_runtime::spawn_blocking(move || {
let index = SearchIndex::open(database)
.map_err(|_| "Local source index is unavailable.".to_owned())?;
session.prepare(
&index,
&path,
SourceLocation {
kind: source.kind,
number: source.number,
},
revision,
)
})
.await
.map_err(|_| "Selected passage could not be prepared.".to_owned())?
}
#[tauri::command]
pub(crate) async fn generate_ai(
input: GenerateInput,
state: State<'_, AppState>,
) -> Result<AiAnswer, String> {
if !input.enabled {
return Err("Optional AI is disabled. No text was sent.".into());
}
input.config.validate().map_err(|error| error.to_string())?;
if state
.ai
.running
.compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
.is_err()
{
return Err("An earlier AI request is still finishing. Wait before trying again.".into());
}
let guard = RunningGuard(state.ai.clone());
let database = state.database.clone();
let session = state.ai.clone();
tauri::async_runtime::spawn_blocking(move || {
let _guard = guard;
let index = SearchIndex::open(database)
.map_err(|_| "Local source index is unavailable.".to_owned())?;
let request = session.grounded_request(&index, &input)?;
// This explicit generate action is the sole endpoint call. Paths and
// filenames never enter the source records sent to the client.
let key = input.config.api_key.clone();
let mut answer = AiClient::new(input.config)
.and_then(|client| client.generate(&request))
.map_err(|error| error.to_string())?;
if let Some(key) = key.filter(|key| !key.is_empty()) {
answer.text = answer.text.replace(&key, "[redacted credential]");
answer.model = answer.model.replace(&key, "[redacted credential]");
}
Ok(answer)
})
.await
.map_err(|_| "AI request could not finish.".to_owned())?
}
#[cfg(test)]
mod tests {
use super::*;
fn fixture() -> (tempfile::TempDir, SearchIndex, String) {
let root = tempfile::tempdir().unwrap();
let path = root.path().join("private-title.txt");
std::fs::write(&path, "Supplier cancellation requires sixty days notice.").unwrap();
let mut index = SearchIndex::open(root.path().join("index.sqlite")).unwrap();
index.index_folder(root.path()).unwrap();
let selected_path = index.search("private-title", 1).unwrap()[0].path.clone();
(root, index, selected_path)
}
fn config() -> AiConfig {
AiConfig {
base_url: "http://127.0.0.1:1/v1".into(),
model: "configured-model".into(),
api_key: Some("session-secret-key".into()),
}
}
#[test]
fn disabled_unknown_and_changed_sources_cannot_form_network_requests() {
let (_root, index, path) = fixture();
let session = AiSession::default();
let preview = session
.prepare(
&index,
&path,
SourceLocation {
kind: "line".into(),
number: 1,
},
0,
)
.unwrap();
let mut input = GenerateInput {
enabled: false,
config: config(),
source_id: preview.id.clone(),
mode: AiMode::Summary,
question: None,
};
assert!(session.grounded_request(&index, &input).is_err());
input.enabled = true;
input.source_id = "unknown".into();
assert!(session.grounded_request(&index, &input).is_err());
input.source_id = preview.id;
std::fs::write(path, "changed").unwrap();
assert!(session.grounded_request(&index, &input).is_err());
}
#[test]
fn prepared_preview_equals_outgoing_text_without_filename_path_or_credential() {
let (_root, index, path) = fixture();
let session = AiSession::default();
let preview = session
.prepare(
&index,
&path,
SourceLocation {
kind: "line".into(),
number: 1,
},
0,
)
.unwrap();
let input = GenerateInput {
enabled: true,
config: config(),
source_id: preview.id.clone(),
mode: AiMode::Summary,
question: None,
};
let request = session.grounded_request(&index, &input).unwrap();
assert_eq!(request.sources[0].text, preview.text);
let json = serde_json::to_string(&request).unwrap();
assert!(!json.contains("private-title"));
assert!(!json.contains(&path));
assert!(!json.contains("session-secret"));
assert!(
!serde_json::to_string(&crate::settings::Settings::default())
.unwrap()
.contains("api_key")
);
}
#[test]
fn source_replacement_and_question_limits_are_enforced_offline() {
let (_root, index, path) = fixture();
let session = AiSession::default();
let first = session
.prepare(
&index,
&path,
SourceLocation {
kind: "line".into(),
number: 1,
},
0,
)
.unwrap();
session
.prepare(
&index,
&path,
SourceLocation {
kind: "line".into(),
number: 1,
},
0,
)
.unwrap();
let input = GenerateInput {
enabled: true,
config: config(),
source_id: first.id,
mode: AiMode::Question,
question: Some("x".repeat(2049)),
};
assert!(session.grounded_request(&index, &input).is_err());
}
#[test]
fn older_preparation_cannot_replace_current_catalog_or_send_stale_source() {
let (_root, index, path) = fixture();
let session = AiSession::default();
session.latest_revision.store(2, Ordering::Release);
let source = SourceLocation {
kind: "line".into(),
number: 1,
};
let current = session.prepare(&index, &path, source.clone(), 2).unwrap();
assert!(session.prepare(&index, &path, source, 1).is_err());
let input = GenerateInput {
enabled: true,
config: config(),
source_id: current.id,
mode: AiMode::Summary,
question: None,
};
assert!(session.grounded_request(&index, &input).is_ok());
session.latest_revision.store(3, Ordering::Release);
assert!(session.grounded_request(&index, &input).is_err());
}
}
+6
View File
@@ -1,9 +1,11 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
mod ai_commands;
mod model_download;
mod semantic_commands;
mod settings;
use ai_commands::{generate_ai, prepare_ai_source, AiSession};
use searchi_core::metrics::{MetricsMode, SearchMetrics, SearchResponse, TraceCollector};
use searchi_core::{IndexReport, IndexStats, SearchIndex, SearchOptions, SearchResult};
use semantic_commands::{
@@ -25,6 +27,7 @@ use tauri_plugin_global_shortcut::{GlobalShortcutExt, ShortcutState};
use tauri_plugin_opener::OpenerExt;
struct AppState {
ai: Arc<AiSession>,
database: PathBuf,
lexical: Arc<Mutex<SearchIndex>>,
indexing: Arc<AtomicBool>,
@@ -461,6 +464,7 @@ fn main() {
}
};
app.manage(AppState {
ai: Arc::new(AiSession::default()),
database,
lexical,
indexing: Arc::new(AtomicBool::new(false)),
@@ -471,6 +475,8 @@ fn main() {
Ok(())
})
.invoke_handler(tauri::generate_handler![
prepare_ai_source,
generate_ai,
search,
search_measured,
stats,