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

+221 -3
View File
@@ -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;