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
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:
1 parent
70746ef428
commit
f5b03c6fb0
27 files changed
+2988
-13
No files matched your search
Generated
+11
@@ -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]]
|
||||
|
||||
@@ -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"] }
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
/** Optional generation is separate from local search and never starts itself. */
|
||||
export interface AiConfiguration {
|
||||
enabled: boolean;
|
||||
base_url: string;
|
||||
model: string;
|
||||
api_key: string | null;
|
||||
}
|
||||
const bytes = (text: string) => new TextEncoder().encode(text).length;
|
||||
export function aiConfigurationProblem(config: AiConfiguration): string | null {
|
||||
if (!config.enabled) return "Optional AI is disabled.";
|
||||
let endpoint: URL;
|
||||
try {
|
||||
endpoint = new URL(config.base_url);
|
||||
} catch {
|
||||
return "Enter your OpenAI-compatible endpoint URL.";
|
||||
}
|
||||
if (
|
||||
!["http:", "https:"].includes(endpoint.protocol) ||
|
||||
endpoint.username ||
|
||||
endpoint.password ||
|
||||
endpoint.search ||
|
||||
endpoint.hash ||
|
||||
bytes(config.base_url) > 2048
|
||||
)
|
||||
return "Use an HTTP(S) endpoint without URL credentials, query or fragment.";
|
||||
if (
|
||||
!config.model.trim() ||
|
||||
bytes(config.model) > 200 ||
|
||||
/[\u0000-\u001f\u007f]/u.test(config.model)
|
||||
)
|
||||
return "Enter a model name (up to 200 bytes).";
|
||||
if (
|
||||
config.api_key &&
|
||||
(bytes(config.api_key) > 2048 ||
|
||||
/[\u0000-\u001f\u007f]/u.test(config.api_key))
|
||||
)
|
||||
return "The session API key is invalid or too long.";
|
||||
return null;
|
||||
}
|
||||
export function aiQuestionProblem(question: string): string | null {
|
||||
return !question.trim()
|
||||
? "Enter a question about this passage."
|
||||
: bytes(question) > 2048
|
||||
? "The question exceeds 2048 bytes."
|
||||
: null;
|
||||
}
|
||||
export function aiEndpointLabel(baseURL: string) {
|
||||
try {
|
||||
return new URL(baseURL).origin;
|
||||
} catch {
|
||||
return "your configured endpoint";
|
||||
}
|
||||
}
|
||||
export function aiSourceEligible(
|
||||
source?: { kind: string; number: number },
|
||||
media = false,
|
||||
) {
|
||||
return (
|
||||
!media &&
|
||||
Boolean(
|
||||
source &&
|
||||
["page", "paragraph", "line"].includes(source.kind) &&
|
||||
Number.isInteger(source.number) &&
|
||||
source.number > 0,
|
||||
)
|
||||
);
|
||||
}
|
||||
export class AiRevision {
|
||||
private revision = 0;
|
||||
private selection = "";
|
||||
invalidate() {
|
||||
this.revision++;
|
||||
}
|
||||
select(selection: string) {
|
||||
if (selection !== this.selection) {
|
||||
this.selection = selection;
|
||||
this.invalidate();
|
||||
}
|
||||
}
|
||||
current() {
|
||||
return this.revision;
|
||||
}
|
||||
accepts(revision: number) {
|
||||
return revision === this.revision;
|
||||
}
|
||||
}
|
||||
+221
-3
@@ -22,6 +22,14 @@ import {
|
||||
} from "./search-performance";
|
||||
import type { MeasuredResponse, SearchLane } from "./search-performance";
|
||||
import { displayPath } from "./path-display";
|
||||
import {
|
||||
AiRevision,
|
||||
aiConfigurationProblem,
|
||||
aiQuestionProblem,
|
||||
aiEndpointLabel,
|
||||
aiSourceEligible,
|
||||
} from "./ai-state";
|
||||
import type { AiConfiguration } from "./ai-state";
|
||||
import {
|
||||
literalQuery,
|
||||
renderHighlighted,
|
||||
@@ -104,13 +112,13 @@ interface SearchRequest {
|
||||
}
|
||||
|
||||
document.querySelector<HTMLDivElement>("#app")!.innerHTML = `
|
||||
<header><div class="brand"><img class="brand-symbol" src="/searchi-symbol.png" alt=""><span class="wordmark">searchi</span></div><span id="index-status" class="index-status">Loading your index…</span><div class="header-actions"><button id="add-folder" class="quiet-button" type="button">${icon("folderAdd")}<span>Add folder</span></button><details id="filters" class="popover"><summary>${icon("filter")}<span>All files</span>${icon("chevron")}</summary><div class="popover-panel"><h2>Filter your search</h2><div class="filters-grid"><label>Within folder<input id="filter-folder" placeholder="Absolute folder path"></label><label>Extension<input id="filter-extension" placeholder="pdf, md, txt…"></label><label>Minimum size (bytes)<input id="filter-min" type="number" min="0" step="1"></label><label>Maximum size (bytes)<input id="filter-max" type="number" min="0" step="1"></label><label>Minimum duration (seconds)<input id="filter-duration-min" type="number" min="0" step="0.1"></label><label>Maximum duration (seconds)<input id="filter-duration-max" type="number" min="0" step="0.1"></label><label>Modified from<input id="filter-after" type="date"></label><label>Modified through<input id="filter-before" type="date"></label></div></div></details><details id="settings" class="popover"><summary class="icon-button" aria-label="Settings" title="Settings">${icon("settings")}</summary><div class="popover-panel"><h2>Folders & resources</h2><p id="shortcut-status" class="hint"></p><label class="resource-label" for="cpu-threads">CPU thread budget</label><select id="cpu-threads">${Array.from({ length: 8 }, (_, i) => `<option value="${i + 1}">${i + 1} ${i ? "threads" : "thread (default)"}</option>`).join("")}</select><p class="hint">Search automatically uses supported acceleration with CPU fallback.</p><ul id="roots" aria-label="Selected folders"></ul><label class="performance-toggle"><input id="performance-toggle" type="checkbox">Performance details</label><p class="hint">Local timing diagnostics for new searches. Queries and file paths are excluded from copied reports.</p></div></details></div></header>
|
||||
<header><div class="brand"><img class="brand-symbol" src="/searchi-symbol.png" alt=""><span class="wordmark">searchi</span></div><span id="index-status" class="index-status">Loading your index…</span><div class="header-actions"><button id="add-folder" class="quiet-button" type="button">${icon("folderAdd")}<span>Add folder</span></button><details id="filters" class="popover"><summary>${icon("filter")}<span>All files</span>${icon("chevron")}</summary><div class="popover-panel"><h2>Filter your search</h2><div class="filters-grid"><label>Within folder<input id="filter-folder" placeholder="Absolute folder path"></label><label>Extension<input id="filter-extension" placeholder="pdf, md, txt…"></label><label>Minimum size (bytes)<input id="filter-min" type="number" min="0" step="1"></label><label>Maximum size (bytes)<input id="filter-max" type="number" min="0" step="1"></label><label>Minimum duration (seconds)<input id="filter-duration-min" type="number" min="0" step="0.1"></label><label>Maximum duration (seconds)<input id="filter-duration-max" type="number" min="0" step="0.1"></label><label>Modified from<input id="filter-after" type="date"></label><label>Modified through<input id="filter-before" type="date"></label></div></div></details><details id="settings" class="popover"><summary class="icon-button" aria-label="Settings" title="Settings">${icon("settings")}</summary><div class="popover-panel"><h2>Folders & resources</h2><p id="shortcut-status" class="hint"></p><label class="resource-label" for="cpu-threads">CPU thread budget</label><select id="cpu-threads">${Array.from({ length: 8 }, (_, i) => `<option value="${i + 1}">${i + 1} ${i ? "threads" : "thread (default)"}</option>`).join("")}</select><p class="hint">Search automatically uses supported acceleration with CPU fallback.</p><ul id="roots" aria-label="Selected folders"></ul><label class="performance-toggle"><input id="performance-toggle" type="checkbox">Performance details</label><p class="hint">Local timing diagnostics for new searches. Queries and file paths are excluded from copied reports.</p><details id="ai-settings" class="ai-settings"><summary>Optional AI answers</summary><label class="performance-toggle"><input id="ai-enabled" type="checkbox">Enable summaries and questions</label><p class="hint">Disabled by default. Only an explicit action sends the shown passage to your configured endpoint.</p><label>OpenAI-compatible base URL<input id="ai-endpoint" type="url" placeholder="http://localhost:1234/v1" autocomplete="off" maxlength="2048"></label><label>Model<input id="ai-model" placeholder="Your model name" autocomplete="off" maxlength="200"></label><label>API key (optional, this session only)<input id="ai-key" type="password" autocomplete="off" maxlength="2048"></label><button id="ai-clear-key" type="button">Clear key</button><p class="hint">Endpoint, model and key stay in this app session. The key authenticates requests to your endpoint.</p></details></div></details></div></header>
|
||||
<main>
|
||||
<form id="search-form" role="search"><label class="sr-only" for="query">Search your files</label>${icon("search")}<input id="query" type="search" placeholder="Search a filename, phrase, or what you remember…" autocomplete="off" spellcheck="false" maxlength="1000"><kbd id="focus-shortcut">Ctrl K</kbd><button id="clear-query" class="icon-button" type="button" aria-label="Clear search" title="Clear search">${icon("dismiss")}</button></form>
|
||||
<details id="semantic-panel"><summary>${icon("sparkles")}<span id="semantic-status" role="status" aria-live="polite">Preparing local semantic search…</span><span id="semantic-device" class="kind">Local model</span>${icon("chevron")}</summary><div class="semantic-content"><strong>Google EmbeddingGemma 2</strong><progress id="semantic-progress" aria-label="Local model setup progress"></progress><p id="semantic-detail" class="hint">The verified local model downloads automatically on first launch (865 MB). Your files stay on this device.</p><div class="semantic-actions"><button id="retry-model" type="button" hidden>Retry model setup</button><button id="rebuild-semantic" type="button" hidden>Rebuild semantic index</button></div></div></details>
|
||||
<form id="folder-form" hidden><label for="folder">Folder to index</label><div class="folder-row"><input id="folder" placeholder="Absolute folder path" required><button id="browse-folder" type="button">Browse…</button><button type="submit">Index folder</button><button id="cancel-folder" type="button">Cancel</button></div><p class="hint">Only choose folders you want searched. Large folders may take a while.</p></form>
|
||||
<p id="notice" role="status" aria-live="polite"></p><details id="performance-panel" hidden><summary>Performance details</summary><div id="performance-detail"></div><button id="copy-performance" type="button">Copy local timing report</button><span id="copy-performance-status" role="status"></span></details><div class="workspace"><section class="results-pane" aria-label="Search results"><p id="result-summary" class="eyebrow">READY WHEN YOU ARE</p><ul id="results" aria-label="Matching files"></ul><div id="empty">${icon("search")}<h2>Find what matters. Locally.</h2><p>Index a folder, then search a filename, phrase, or what you remember.</p></div></section><aside id="preview" aria-label="Result preview" hidden><div class="preview-header"><span id="preview-icon" class="file-icon"></span><div class="preview-heading"><h2 id="preview-title"></h2><p id="preview-path" class="path"></p></div></div><div class="preview-toolbar"><span id="preview-kind"></span><div class="preview-actions"><button id="open-result" type="button">${icon("open")}<span>Open file</span></button><button id="reveal-result" type="button" title="Show in folder" aria-label="Show in folder">${icon("folder")}</button></div></div><details id="match-details" class="match-details"><summary>Why this result?</summary><p id="match-reason"></p><p id="match-legend"></p></details><div id="media-preview" class="media-preview" hidden></div><p id="media-preview-status" class="hint" role="status" hidden></p><div class="passage"><p id="passage-label" class="eyebrow">EXTRACTED PASSAGE</p><pre id="preview-text"></pre></div></aside></div>
|
||||
</main><footer><span class="footer-privacy">${icon("lock")}<span>Private search. Everything stays on your device.</span><span id="privacy" class="privacy">Local</span></span><span class="keyboard-hints"><kbd>${icon("arrowUp")}</kbd><kbd>${icon("arrowDown")}</kbd><span>Navigate</span><kbd>Enter</kbd><span>Open</span><kbd>Esc</kbd><span>Clear</span></span></footer>`;
|
||||
<p id="notice" role="status" aria-live="polite"></p><details id="performance-panel" hidden><summary>Performance details</summary><div id="performance-detail"></div><button id="copy-performance" type="button">Copy local timing report</button><span id="copy-performance-status" role="status"></span></details><div class="workspace"><section class="results-pane" aria-label="Search results"><p id="result-summary" class="eyebrow">READY WHEN YOU ARE</p><ul id="results" aria-label="Matching files"></ul><div id="empty">${icon("search")}<h2>Find what matters. Locally.</h2><p>Index a folder, then search a filename, phrase, or what you remember.</p></div></section><aside id="preview" aria-label="Result preview" hidden><div class="preview-header"><span id="preview-icon" class="file-icon"></span><div class="preview-heading"><h2 id="preview-title"></h2><p id="preview-path" class="path"></p></div></div><div class="preview-toolbar"><span id="preview-kind"></span><div class="preview-actions"><button id="open-result" type="button">${icon("open")}<span>Open file</span></button><button id="reveal-result" type="button" title="Show in folder" aria-label="Show in folder">${icon("folder")}</button></div></div><details id="match-details" class="match-details"><summary>Why this result?</summary><p id="match-reason"></p><p id="match-legend"></p></details><div id="media-preview" class="media-preview" hidden></div><p id="media-preview-status" class="hint" role="status" hidden></p><details id="ai-panel" class="ai-panel" hidden><summary>Summarize or ask</summary><p id="ai-disclosure" class="hint"></p><p class="eyebrow">PASSAGE THAT WILL BE SENT</p><pre id="ai-source-text"></pre><div class="ai-actions"><button id="ai-summarize" type="button" disabled>Summarize this passage</button><label class="sr-only" for="ai-question">Question about this passage</label><input id="ai-question" placeholder="Ask about this passage…" maxlength="2048"><button id="ai-ask" type="button" disabled>Ask</button></div><p id="ai-status" class="hint" role="status"></p><pre id="ai-answer"></pre><p id="ai-answer-label" class="hint"></p><button id="ai-citation" type="button" hidden>Selected source</button></details><div class="passage"><p id="passage-label" class="eyebrow">EXTRACTED PASSAGE</p><pre id="preview-text"></pre></div></aside></div>
|
||||
</main><footer><span class="footer-privacy">${icon("lock")}<span id="privacy-summary">Private search. Everything stays on your device.</span><span id="privacy" class="privacy">Local</span></span><span class="keyboard-hints"><kbd>${icon("arrowUp")}</kbd><kbd>${icon("arrowDown")}</kbd><span>Navigate</span><kbd>Enter</kbd><span>Open</span><kbd>Esc</kbd><span>Clear</span></span></footer>`;
|
||||
|
||||
const element = <T extends HTMLElement>(id: string) =>
|
||||
document.getElementById(id) as T;
|
||||
@@ -472,6 +480,210 @@ function showError(error: unknown) {
|
||||
notice.textContent = String(error);
|
||||
notice.className = "error";
|
||||
}
|
||||
interface AiPreview {
|
||||
id: string;
|
||||
text: string;
|
||||
location: string;
|
||||
}
|
||||
interface AiAnswer {
|
||||
text: string;
|
||||
citations: string[];
|
||||
model: string;
|
||||
}
|
||||
const aiRevision = new AiRevision();
|
||||
let aiSelectionKey = "";
|
||||
let aiPrepared: AiPreview | null = null;
|
||||
let aiRunning = false;
|
||||
function aiConfiguration(): AiConfiguration {
|
||||
return {
|
||||
enabled: element<HTMLInputElement>("ai-enabled").checked,
|
||||
base_url: element<HTMLInputElement>("ai-endpoint").value.trim(),
|
||||
model: element<HTMLInputElement>("ai-model").value.trim(),
|
||||
api_key: element<HTMLInputElement>("ai-key").value || null,
|
||||
};
|
||||
}
|
||||
function clearAiAnswer() {
|
||||
element("ai-answer").textContent = "";
|
||||
element("ai-answer-label").textContent = "";
|
||||
element("ai-citation").hidden = true;
|
||||
}
|
||||
function updateAiControls() {
|
||||
const config = aiConfiguration();
|
||||
const panel = element<HTMLDetailsElement>("ai-panel");
|
||||
panel.hidden = !config.enabled;
|
||||
const result = results[selected];
|
||||
const eligible = aiSourceEligible(
|
||||
result?.source,
|
||||
Boolean(result && mediaKind(result.filename)),
|
||||
);
|
||||
const problem = aiConfigurationProblem(config);
|
||||
const blocked =
|
||||
!isNativeDesktop() ||
|
||||
Boolean(problem) ||
|
||||
!eligible ||
|
||||
!aiPrepared ||
|
||||
aiRunning;
|
||||
element<HTMLButtonElement>("ai-summarize").disabled = blocked;
|
||||
element<HTMLButtonElement>("ai-ask").disabled =
|
||||
blocked ||
|
||||
Boolean(aiQuestionProblem(element<HTMLInputElement>("ai-question").value));
|
||||
element("ai-disclosure").textContent = !isNativeDesktop()
|
||||
? "Optional AI actions are available only in the desktop app."
|
||||
: !eligible
|
||||
? "Select a result with an indexed text passage to summarize or ask a question."
|
||||
: problem
|
||||
? `${problem} Configuration stays in this app session.`
|
||||
: `Clicking Summarize or Ask sends the exact passage shown below and, for Ask, your question to ${aiEndpointLabel(config.base_url)}. File names and folder paths aren't added. Your session key authenticates that endpoint.`;
|
||||
element("privacy-summary").textContent =
|
||||
aiRunning && !config.enabled
|
||||
? "Local search. An earlier optional AI request is finishing."
|
||||
: config.enabled
|
||||
? "Local search. Optional AI shares the shown passage only when requested."
|
||||
: "Private search. Everything stays on your device.";
|
||||
}
|
||||
const aiPreparation = new LatestLane<{
|
||||
revision: number;
|
||||
path: string;
|
||||
source: { kind: string; number: number };
|
||||
}>(
|
||||
async (request) => {
|
||||
const preview = await invoke<AiPreview>("prepare_ai_source", {
|
||||
enabled: true,
|
||||
path: request.path,
|
||||
source: request.source,
|
||||
revision: request.revision,
|
||||
});
|
||||
if (!aiRevision.accepts(request.revision)) return;
|
||||
aiPrepared = preview;
|
||||
element("ai-source-text").textContent = preview.text;
|
||||
element("ai-status").textContent =
|
||||
`Review the passage beginning from ${preview.location}. Only this shown excerpt will be sent.`;
|
||||
updateAiControls();
|
||||
},
|
||||
(request) => aiRevision.accepts(request.revision),
|
||||
(_request, error) => {
|
||||
element("ai-status").textContent = String(error);
|
||||
updateAiControls();
|
||||
},
|
||||
);
|
||||
function prepareAiSource() {
|
||||
const result = results[selected];
|
||||
if (
|
||||
!aiConfiguration().enabled ||
|
||||
!isNativeDesktop() ||
|
||||
!result?.source ||
|
||||
!aiSourceEligible(result.source, Boolean(mediaKind(result.filename)))
|
||||
)
|
||||
return;
|
||||
element("ai-status").textContent =
|
||||
"Preparing the selected local passage. Nothing is sent to the endpoint.";
|
||||
aiPreparation.submit({
|
||||
revision: aiRevision.current(),
|
||||
path: result.path,
|
||||
source: { kind: result.source.kind, number: result.source.number },
|
||||
});
|
||||
}
|
||||
function updateAiSelection(key: string) {
|
||||
if (aiSelectionKey !== key) {
|
||||
aiSelectionKey = key;
|
||||
aiRevision.select(key);
|
||||
aiPreparation.clear();
|
||||
aiPrepared = null;
|
||||
element("ai-source-text").textContent = "";
|
||||
element<HTMLInputElement>("ai-question").value = "";
|
||||
element("ai-status").textContent = "";
|
||||
clearAiAnswer();
|
||||
updateAiControls();
|
||||
if (element<HTMLDetailsElement>("ai-panel").open) prepareAiSource();
|
||||
}
|
||||
}
|
||||
async function requestAi(mode: "summary" | "question") {
|
||||
const config = aiConfiguration();
|
||||
const question = element<HTMLInputElement>("ai-question").value.trim();
|
||||
const problem =
|
||||
aiConfigurationProblem(config) ||
|
||||
(mode === "question" ? aiQuestionProblem(question) : null);
|
||||
if (problem || !aiPrepared || !isNativeDesktop() || aiRunning) {
|
||||
element("ai-status").textContent =
|
||||
problem || "Prepare and review the current selected passage first.";
|
||||
return;
|
||||
}
|
||||
const revision = aiRevision.current();
|
||||
const source = aiPrepared;
|
||||
aiRunning = true;
|
||||
clearAiAnswer();
|
||||
updateAiControls();
|
||||
element("ai-status").textContent =
|
||||
`Sending the shown passage${mode === "question" ? " and question" : ""} to ${aiEndpointLabel(config.base_url)}…`;
|
||||
const started = performance.now();
|
||||
try {
|
||||
const answer = await invoke<AiAnswer>("generate_ai", {
|
||||
input: {
|
||||
enabled: true,
|
||||
config: {
|
||||
base_url: config.base_url,
|
||||
model: config.model,
|
||||
api_key: config.api_key,
|
||||
},
|
||||
source_id: source.id,
|
||||
mode,
|
||||
question: mode === "question" ? question : null,
|
||||
},
|
||||
});
|
||||
if (!aiRevision.accepts(revision)) return;
|
||||
element("ai-answer").textContent = answer.text;
|
||||
element("ai-answer-label").textContent =
|
||||
`Model-generated ${answer.citations.length ? "draft" : "uncited draft"} · ${answer.model} · ${briefMs(performance.now() - started)} ms. Verify against the passage.`;
|
||||
element("ai-status").textContent = "";
|
||||
const citation = element<HTMLButtonElement>("ai-citation");
|
||||
citation.hidden = !answer.citations.includes("selected");
|
||||
citation.textContent = `Source: ${source.location}`;
|
||||
} catch (error) {
|
||||
if (aiRevision.accepts(revision))
|
||||
element("ai-status").textContent = String(error);
|
||||
} finally {
|
||||
aiRunning = false;
|
||||
updateAiControls();
|
||||
}
|
||||
}
|
||||
element("ai-panel").addEventListener("toggle", () => {
|
||||
if (element<HTMLDetailsElement>("ai-panel").open && !aiPrepared)
|
||||
prepareAiSource();
|
||||
});
|
||||
for (const id of ["ai-enabled", "ai-endpoint", "ai-model", "ai-key"]) {
|
||||
element(id).addEventListener("input", () => {
|
||||
aiRevision.invalidate();
|
||||
aiPreparation.clear();
|
||||
aiPrepared = null;
|
||||
clearAiAnswer();
|
||||
element("ai-source-text").textContent = "";
|
||||
updateAiControls();
|
||||
if (element<HTMLDetailsElement>("ai-panel").open) prepareAiSource();
|
||||
});
|
||||
}
|
||||
element("ai-question").addEventListener("input", () => {
|
||||
aiRevision.invalidate();
|
||||
clearAiAnswer();
|
||||
updateAiControls();
|
||||
if (!aiPrepared && element<HTMLDetailsElement>("ai-panel").open)
|
||||
prepareAiSource();
|
||||
});
|
||||
element("ai-clear-key").addEventListener("click", () => {
|
||||
element<HTMLInputElement>("ai-key").value = "";
|
||||
element("ai-key").dispatchEvent(new Event("input"));
|
||||
});
|
||||
element("ai-summarize").addEventListener("click", () => {
|
||||
void requestAi("summary");
|
||||
});
|
||||
element("ai-ask").addEventListener("click", () => {
|
||||
void requestAi("question");
|
||||
});
|
||||
element("ai-citation").addEventListener("click", () => {
|
||||
element("ai-source-text").scrollIntoView({
|
||||
behavior: reducedMotion.matches ? "auto" : "smooth",
|
||||
block: "nearest",
|
||||
});
|
||||
});
|
||||
function select(index: number, focus = false) {
|
||||
if (!results.length) return;
|
||||
selected = Math.max(0, Math.min(index, results.length - 1));
|
||||
@@ -537,6 +749,7 @@ function select(index: number, focus = false) {
|
||||
if (explanationKey !== explainedSelection)
|
||||
element<HTMLDetailsElement>("match-details").open = false;
|
||||
explainedSelection = explanationKey;
|
||||
updateAiSelection(explanationKey);
|
||||
element<HTMLButtonElement>("open-result").title = result.source
|
||||
? `Open in your default application. The matched location ${sourceLabel(result.source)} is shown in Searchi; default application opening starts at its own location.`
|
||||
: "Open in your default application";
|
||||
@@ -583,6 +796,7 @@ function render() {
|
||||
element("preview").hidden = true;
|
||||
previewPath = null;
|
||||
clearMedia();
|
||||
updateAiSelection("");
|
||||
}
|
||||
results.forEach((result, i) => {
|
||||
let entry = resultRows.get(result.path);
|
||||
@@ -1126,6 +1340,10 @@ async function initializeSurface() {
|
||||
"retry-model",
|
||||
"rebuild-semantic",
|
||||
"cpu-threads",
|
||||
"ai-enabled",
|
||||
"ai-endpoint",
|
||||
"ai-model",
|
||||
"ai-key",
|
||||
]) {
|
||||
const control = element<HTMLButtonElement>(id);
|
||||
control.disabled = true;
|
||||
|
||||
@@ -864,6 +864,62 @@ mark[data-match="literal"] {
|
||||
#match-legend {
|
||||
font-size: 10px;
|
||||
}
|
||||
.ai-settings {
|
||||
margin-top: 14px;
|
||||
}
|
||||
.ai-settings summary {
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
}
|
||||
.ai-settings label:not(.performance-toggle) {
|
||||
display: block;
|
||||
margin: 10px 0;
|
||||
}
|
||||
.ai-settings input:not([type="checkbox"]) {
|
||||
width: 100%;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.ai-panel {
|
||||
margin: 0 20px 12px;
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
}
|
||||
.ai-panel summary {
|
||||
cursor: pointer;
|
||||
width: fit-content;
|
||||
}
|
||||
.ai-panel .eyebrow {
|
||||
margin: 12px 0 6px;
|
||||
}
|
||||
#ai-source-text,
|
||||
#ai-answer {
|
||||
max-height: 150px;
|
||||
overflow: auto;
|
||||
padding: 10px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 7px;
|
||||
background: var(--surface);
|
||||
color: var(--ink);
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
#ai-answer:empty {
|
||||
display: none;
|
||||
}
|
||||
.ai-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
.ai-actions input {
|
||||
flex: 1 1 170px;
|
||||
min-width: 0;
|
||||
}
|
||||
.ai-actions button,
|
||||
#ai-citation {
|
||||
font-size: 11px;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
mark[data-match="literal"] {
|
||||
background: #65531e;
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {AiRevision,aiConfigurationProblem,aiQuestionProblem,aiEndpointLabel,aiSourceEligible} from '../src/ai-state.ts';
|
||||
const config=()=>({enabled:false,base_url:'',model:'',api_key:null});
|
||||
|
||||
test('optional generation is disabled initially and requires an explicit endpoint/model',()=>{
|
||||
assert.match(aiConfigurationProblem(config()),/disabled/);
|
||||
assert.match(aiConfigurationProblem({...config(),enabled:true}),/endpoint/);
|
||||
assert.equal(aiConfigurationProblem({...config(),enabled:true,base_url:'http://localhost:1234/v1',model:'local-model'}),null);
|
||||
assert.equal(aiConfigurationProblem({...config(),enabled:true,base_url:'https://chosen.example/v1',model:'chosen-model',api_key:'session-key'}),null);
|
||||
});
|
||||
test('unsafe URL credentials and query parameters cannot leak through configuration errors',()=>{
|
||||
const secret='private-session-key';
|
||||
for(const base_url of [`https://user:${secret}@example.com/v1`,`https://example.com/v1?key=${secret}`,'file:///secret']){
|
||||
const error=aiConfigurationProblem({...config(),enabled:true,base_url,model:'m'});
|
||||
assert.ok(error);assert.ok(!error.includes(secret));
|
||||
}
|
||||
assert.equal(aiEndpointLabel(`https://user:${secret}@example.com/v1?key=${secret}`),'https://example.com');
|
||||
});
|
||||
test('UTF8 byte budgets and actual text source types gate explicit questions',()=>{
|
||||
assert.ok(aiQuestionProblem('界'.repeat(700)));
|
||||
assert.equal(aiQuestionProblem('What notice is required?'),null);
|
||||
assert.equal(aiSourceEligible({kind:'page',number:2}),true);
|
||||
assert.equal(aiSourceEligible({kind:'timestamp_ms',number:100}),false);
|
||||
assert.equal(aiSourceEligible({kind:'page',number:2},true),false);
|
||||
assert.equal(aiSourceEligible(undefined),false);
|
||||
});
|
||||
test('selection/configuration changes invalidate older preparation and answers',()=>{
|
||||
const revision=new AiRevision();revision.select('first:page2');const old=revision.current();
|
||||
revision.select('second:page1');assert.equal(revision.accepts(old),false);
|
||||
const selected=revision.current();revision.select('second:page1');assert.equal(revision.accepts(selected),true);
|
||||
revision.invalidate();assert.equal(revision.accepts(selected),false);
|
||||
});
|
||||
Reference in new issue
Block a user