feat: add multimodal search and granular performance diagnostics
Core CI / core (ubuntu-latest) (push) Failing after 3m31s
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 5m39s
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 19:16:23 +02:00
1 parent d3bb8c7618
commit b8400c4fbf
110 files changed
+79395 -250

No files matched your search

+9
View File
@@ -14,6 +14,10 @@ import arrowDown from "@fluentui/svg-icons/icons/arrow_down_16_regular.svg?raw";
import open from "@fluentui/svg-icons/icons/open_20_regular.svg?raw";
import chevron from "@fluentui/svg-icons/icons/chevron_down_16_regular.svg?raw";
import sparkles from "@fluentui/svg-icons/icons/sparkle_20_regular.svg?raw";
import image from "@fluentui/svg-icons/icons/image_24_regular.svg?raw";
import video from "@fluentui/svg-icons/icons/video_24_regular.svg?raw";
import audio from "@fluentui/svg-icons/icons/music_note_2_24_regular.svg?raw";
import { mediaKind } from "./media-display";
const assets = {
search,
@@ -31,11 +35,16 @@ const assets = {
open,
chevron,
sparkles,
image,
video,
audio,
};
export function icon(name: keyof typeof assets) {
return `<span class="icon" aria-hidden="true">${assets[name]}</span>`;
}
export function fileIcon(filename: string) {
const media = mediaKind(filename);
if (media) return { html: icon(media), type: media };
const extension = filename.split(".").pop()?.toLowerCase();
if (extension === "pdf") return { html: icon("pdf"), type: "pdf" };
if (
+333 -28
View File
@@ -1,4 +1,4 @@
import { invoke, isTauri } from "@tauri-apps/api/core";
import { invoke, isTauri, convertFileSrc } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
import {
AutomaticIndexGate,
@@ -7,8 +7,22 @@ import {
} from "./search-state";
import type { SemanticPhase } from "./search-state";
import { fileIcon, icon } from "./icons";
import {
SearchMeasurement,
measuredFailure,
spanDepth,
briefMs,
} from "./search-performance";
import type { MeasuredResponse, SearchLane } from "./search-performance";
import "@fontsource-variable/inter/wght.css";
import "./style.css";
import {
mediaKind,
matchLabel,
sourceLabel,
seekSeconds,
PreviewRevision,
} from "./media-display";
interface SearchResult {
path: string;
@@ -16,7 +30,7 @@ interface SearchResult {
snippet: string;
score: number;
match_kind: string;
source?: { kind: string; number: number };
source?: { kind: string; number: number; matched_query?: string };
extraction_error?: string;
}
interface IndexReport {
@@ -55,11 +69,16 @@ interface SemanticStatus {
stale: boolean;
pending: number;
warming: boolean;
media_tools_ready?: boolean;
media_setup_error?: string | null;
media_failures?: { path: string; message: string }[];
worker_info?: {
model?: string;
device?: string;
runtime?: string;
threads?: number;
dimensions?: number;
media_supported?: boolean;
} | null;
}
interface SearchRequest {
@@ -67,15 +86,16 @@ interface SearchRequest {
query: string;
filters: Record<string, string | number | null>;
started: number;
measurement: SearchMeasurement;
}
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>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></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></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><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><div class="passage"><p class="eyebrow">EXTRACTED PASSAGE</p><pre id="preview-text"></pre></div></aside></div>
<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><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>`;
const element = <T extends HTMLElement>(id: string) =>
@@ -83,6 +103,12 @@ const element = <T extends HTMLElement>(id: string) =>
const query = element<HTMLInputElement>("query");
const list = element<HTMLUListElement>("results");
const notice = element<HTMLParagraphElement>("notice");
try {
element<HTMLInputElement>("performance-toggle").checked =
localStorage.getItem("searchi-performance-details") === "true";
} catch {
/* Diagnostics remain opt-in when storage is unavailable. */
}
let results: SearchResult[] = [];
let selected = -1;
let requestId = 0;
@@ -99,6 +125,89 @@ let folderRevision = 0;
let localSemanticError: string | null = null;
let querySemanticFailed = false;
let previewPath: string | null = null;
const previewRevision = new PreviewRevision();
interface PreviewRequest {
revision: number;
result: SearchResult;
}
function unloadMedia() {
element("media-preview")
.querySelectorAll<HTMLMediaElement>("audio,video")
.forEach((player) => {
player.pause();
player.removeAttribute("src");
player.load();
});
element("media-preview").replaceChildren();
element("media-preview").hidden = true;
element("media-preview-status").hidden = true;
}
function clearMedia() {
previewRevision.clear();
mediaLane.clear();
unloadMedia();
}
const mediaLane = new LatestLane<PreviewRequest>(
async (request) => {
const prepared = await invoke<{
path: string;
kind: "image" | "audio" | "video";
}>("prepare_media_preview", { path: request.result.path });
if (!previewRevision.accepts(request.revision)) return;
const host = element("media-preview");
const status = element("media-preview-status");
const failed = () => {
if (!previewRevision.accepts(request.revision)) return;
status.hidden = false;
status.textContent =
"This format cannot be previewed here. Use Open file to view it in your default application.";
};
if (prepared.kind === "image") {
const image = document.createElement("img");
image.alt = request.result.filename;
image.addEventListener("error", failed, { once: true });
image.src = convertFileSrc(prepared.path);
host.append(image);
} else {
const player = document.createElement(prepared.kind);
player.controls = true;
player.preload = "metadata";
player.addEventListener("error", failed, { once: true });
player.addEventListener(
"loadedmetadata",
() => {
if (!previewRevision.accepts(request.revision)) return;
const target = seekSeconds(request.result.source);
if (target !== undefined) {
if (!Number.isFinite(player.duration) || target > player.duration) {
status.hidden = false;
status.textContent =
"The matched timestamp is unavailable in this preview. Use Open file to check the recording.";
} else {
try {
player.currentTime = target;
} catch {
failed();
}
}
}
},
{ once: true },
);
player.src = convertFileSrc(prepared.path);
host.append(player);
}
host.hidden = false;
status.hidden = true;
},
(request) => previewRevision.accepts(request.revision),
(request, error) => {
if (!previewRevision.accepts(request.revision)) return;
element("media-preview-status").hidden = false;
element("media-preview-status").textContent =
`Preview unavailable: ${String(error)}. Use Open file.`;
},
);
let displayedRequestId: number | null = null;
let expandedError: string | null = null;
const reducedMotion = matchMedia("(prefers-reduced-motion: reduce)");
@@ -168,7 +277,7 @@ async function buildSemanticIndex() {
"index_embeddings",
);
element("semantic-status").textContent =
`Semantic search indexed ${report.chunks.toLocaleString()} passages.`;
`Semantic search indexed ${report.chunks.toLocaleString()} content segments.`;
} catch (error) {
localSemanticError = String(error);
} finally {
@@ -229,7 +338,7 @@ async function refreshSemanticStatus() {
message =
"Building your semantic index… Keyword search remains available.";
else if (status.ready && status.chunks > 0)
message = `Semantic search ready · ${status.chunks.toLocaleString()} passages${status.stale ? " · Updates pending" : ""}`;
message = `Semantic search ready · ${status.chunks.toLocaleString()} content segments${status.stale ? " · Updates pending" : ""}${status.media_failures?.length ? ` · ${status.media_failures.length} files could not be indexed` : ""}`;
else if (status.ready)
message = "Google model ready. Index a folder to search by meaning.";
else
@@ -240,6 +349,18 @@ async function refreshSemanticStatus() {
element("semantic-detail").textContent = status.worker_info
? `${status.model} · ${status.worker_info.runtime || "Local inference"} · Your files stay on this device.`
: "The verified Google model downloads automatically once (865 MB). After setup, inference runs locally.";
const mediaDetail = status.media_setup_error
? `Media decoder setup needs attention: ${status.media_setup_error}. Text search remains available.`
: status.worker_info && !status.worker_info.media_supported
? "The installed worker needs a media-capable runtime update. Text search remains available."
: status.media_tools_ready && status.worker_info?.media_supported
? "Image, audio, and video encoders are available locally."
: "Preparing local image, audio, and video support.";
element("semantic-detail").textContent += ` ${mediaDetail}`;
if (status.media_failures?.length) {
element("semantic-detail").textContent +=
` ${status.media_failures.length} files could not be indexed. The first issue: ${status.media_failures[0].path}: ${status.media_failures[0].message}. Filename search remains available; rebuild after fixing the file.`;
}
const retry = element<HTMLButtonElement>("retry-model");
retry.hidden = !error || status.ready;
retry.disabled = working;
@@ -266,7 +387,7 @@ async function refreshSemanticStatus() {
canSearchSemantically() &&
latestSearch?.id === requestId
)
hybridLane.submit(latestSearch);
submitSearch(latestSearch, "hybrid");
if (working || (!status.ready && !error)) queueSemanticRefresh();
} catch (error) {
element("semantic-status").textContent =
@@ -349,15 +470,33 @@ function select(index: number, focus = false) {
element("preview-title").textContent = result.filename;
element("preview-path").textContent = result.path;
element("preview-kind").textContent =
`${result.match_kind} match${result.source ? ` · ${result.source.kind} ${result.source.number}` : ""}`;
`${matchLabel(result.match_kind)}${result.source ? ` · ${sourceLabel(result.source)}` : ""}`;
const media = mediaKind(result.filename);
element("passage-label").textContent = media
? "MATCHED CONTENT"
: "EXTRACTED PASSAGE";
element("preview-text").textContent =
result.snippet ||
(result.extraction_error
? `Content could not be extracted: ${result.extraction_error}`
: "No extracted text is available for this file.");
: media
? result.source?.matched_query || "Preview the matched media below."
: "No extracted text is available for this file.");
element<HTMLButtonElement>("open-result").title = result.source
? `Opens this file in your default application. ${result.source.kind} ${result.source.number} is shown here; exact-location opening is not supported by the default application interface.`
? `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";
const selection = previewRevision.select(
JSON.stringify([result.path, result.source?.kind, result.source?.number]),
);
if (selection.changed) {
mediaLane.clear();
unloadMedia();
if (media && isTauri()) {
element("media-preview-status").hidden = false;
element("media-preview-status").textContent = "Preparing media preview…";
mediaLane.submit({ revision: selection.revision, result });
}
}
if (previewPath !== result.path) {
const passage = document.querySelector<HTMLElement>(".passage")!;
passage.scrollTop = 0;
@@ -388,6 +527,7 @@ function render() {
if (!results.length) {
element("preview").hidden = true;
previewPath = null;
clearMedia();
}
results.forEach((result, i) => {
let entry = resultRows.get(result.path);
@@ -419,9 +559,7 @@ function render() {
entry.title.textContent = result.filename;
entry.path.textContent = result.path;
entry.snippet.textContent = result.snippet;
entry.kind.textContent = result.source
? `${result.source.kind} ${result.source.number}`
: result.match_kind;
entry.kind.textContent = `${matchLabel(result.match_kind)}${result.source ? ` · ${sourceLabel(result.source)}` : ""}`;
const asset = fileIcon(result.filename);
if (entry.file.dataset.type !== asset.type) {
entry.file.innerHTML = asset.html;
@@ -443,6 +581,14 @@ function captureFilters() {
throw new Error("File size must be a nonnegative whole number of bytes.");
return value;
};
const duration = (id: string) => {
const text = input(id);
if (!text) return null;
const ms = Math.round(Number(text) * 1000);
if (!Number.isFinite(ms) || ms < 0 || ms > 4_294_967_295)
throw new Error("Enter a valid nonnegative media duration in seconds.");
return ms;
};
// Date controls represent the user's local calendar day, including its end.
const date = (id: string, end: boolean) => {
const text = input(id);
@@ -457,6 +603,8 @@ function captureFilters() {
return {
folder: input("filter-folder") || null,
extension: input("filter-extension") || null,
min_duration_ms: duration("filter-duration-min"),
max_duration_ms: duration("filter-duration-max"),
min_size: size("filter-min"),
max_size: size("filter-max"),
modified_after_unix_ms: date("filter-after", false),
@@ -474,6 +622,7 @@ function applyResults(
(lane === "lexical" && shownLane === "hybrid")
)
return;
const renderStarted = performance.now();
const selectedPath = results[selected]?.path;
const resultsHadFocus = list.contains(document.activeElement);
shownLane = lane;
@@ -484,18 +633,131 @@ function applyResults(
const preserved = results.findIndex((result) => result.path === selectedPath);
if (preserved >= 0) select(preserved, resultsHadFocus);
else if (results.length) select(0, resultsHadFocus);
element("result-summary").textContent =
`${found.length} RESULTS · ${lane === "hybrid" ? "KEYWORD + MEANING" : "KEYWORD"} · ${Math.round(performance.now() - request.started)} MS`;
if (!found.length) refreshEmptySearchState();
request.measurement.displayed(
lane,
renderStarted,
performance.now(),
found.length,
);
const metric = request.measurement.latestNative(lane);
const trace = request.measurement.export();
element("result-summary").textContent =
`${found.length} RESULTS · ${lane === "hybrid" ? "KEYWORD + MEANING" : "KEYWORD"} · NATIVE ${briefMs(metric?.total_ms ?? 0)} MS · ${lane === "hybrid" ? "FINAL" : "FIRST RESPONSE"} ${Math.round(lane === "hybrid" ? (trace.hybrid_final_results_ms ?? 0) : (trace.first_results_ms ?? 0))} MS`;
refreshPerformance(request);
}
function refreshPerformance(request: SearchRequest) {
if (request.id !== requestId) return;
const panel = element<HTMLDetailsElement>("performance-panel");
panel.hidden = !request.measurement.debug;
if (!request.measurement.debug) return;
const body = element("performance-detail");
body.replaceChildren();
const report = request.measurement.export();
const append = (text: string, className = "") => {
const row = document.createElement("p");
row.textContent = text;
row.className = className;
body.append(row);
return row;
};
append(
"Times below use separate UI and native clocks. Nested spans and parallel lanes must not be added together. DOM update measures synchronous changes, not paint or deferred media preview loading.",
"hint",
);
append(
`Input debounce ${report.debounce_ms.toFixed(1)} ms · First displayed response ${report.first_results_ms?.toFixed(1) ?? "pending"} ms · Latest displayed ${report.latest_displayed_ms?.toFixed(1) ?? "pending"} ms · Hybrid final ${report.hybrid_final_results_ms?.toFixed(1) ?? "pending / unavailable"} ms`,
);
for (const [lane, delay] of Object.entries(report.lane_schedule_ms))
append(
`${lane}: scheduled ${delay.toFixed(1)} ms after debounce${lane === "hybrid" ? " (includes intentional 350 ms semantic delay or waiting for readiness)" : ""}`,
);
for (const attempt of report.attempts) {
append(
`${attempt.lane} attempt ${attempt.attempt}${attempt.failed ? " · FAILED" : ""}: pending queue ${attempt.queued_ms.toFixed(1)} ms · retry wait ${attempt.retry_wait_ms.toFixed(1)} ms · invoke ${attempt.invoke_ms.toFixed(1)} ms`,
"performance-attempt",
);
if (!attempt.native) {
append("Native timing unavailable for this failure.", "hint");
continue;
}
append(
`Native command ${attempt.native.total_ms.toFixed(1)} ms · ${attempt.native.result_count} results · estimated transport / serialization / scheduling ${attempt.transport_estimate_ms?.toFixed(1)} ms`,
"hint",
);
if (attempt.native.trace === null)
append("Detailed native trace unavailable for this attempt.", "hint");
const spans = attempt.native.trace ?? [];
spans.forEach((span, index) => {
const counts = Object.entries(span.counters)
.map(([name, value]) =>
name.endsWith("_us")
? `${name.slice(0, -3).replaceAll("_", " ")}: ${(value / 1000).toFixed(3)} ms`
: `${name.replaceAll("_", " ")}: ${value}`,
)
.join(" · ");
const row = append(
`${span.name}: ${span.duration_ms.toFixed(2)} ms · starts +${span.start_ms.toFixed(2)} ms${counts ? ` · ${counts}` : ""}`,
"performance-span",
);
row.style.paddingLeft = `${spanDepth(spans, index) * 14}px`;
});
if (attempt.native.dropped_spans)
append(`${attempt.native.dropped_spans} native spans dropped.`, "hint");
}
for (const display of report.displays)
append(
`${display.lane} synchronous DOM update ${display.dom_update_ms.toFixed(2)} ms · ${display.count} displayed results`,
"hint",
);
if (report.dropped_attempts || report.dropped_displays)
append(
`History capped: ${report.dropped_attempts} attempts and ${report.dropped_displays} displays dropped.`,
"hint",
);
}
async function invokeMeasured(request: SearchRequest, lane: SearchLane) {
const attempt = request.measurement.begin(lane, performance.now());
try {
const response = await invoke<MeasuredResponse<SearchResult>>(
lane === "lexical" ? "search_measured" : "hybrid_search_measured",
{
query: request.query,
filters: request.filters,
debugPerformance: request.measurement.debug,
},
);
request.measurement.completed(
attempt,
performance.now(),
response.metrics,
false,
);
return response.results;
} catch (error) {
const failure = measuredFailure(error);
request.measurement.completed(
attempt,
performance.now(),
failure?.metrics ?? null,
true,
);
refreshPerformance(request);
throw failure ? new Error(failure.message) : error;
}
}
function submitSearch(request: SearchRequest, lane: SearchLane) {
request.measurement.submitted(lane, performance.now());
(lane === "lexical" ? lexicalLane : hybridLane).submit(request);
}
const isCurrent = (request: SearchRequest) => request.id === requestId;
const lexicalLane = new LatestLane<SearchRequest>(
async (request) => {
const found = await invoke<SearchResult[]>("search", {
query: request.query,
filters: request.filters,
});
const found = await invokeMeasured(request, "lexical");
applyResults(found, request, "lexical");
},
isCurrent,
@@ -509,10 +771,7 @@ const lexicalLane = new LatestLane<SearchRequest>(
const hybridLane = new LatestLane<SearchRequest>(
async (request) => {
if (!canSearchSemantically()) return;
const found = await invoke<SearchResult[]>("hybrid_search", {
query: request.query,
filters: request.filters,
});
const found = await invokeMeasured(request, "hybrid");
applyResults(found, request, "hybrid");
},
isCurrent,
@@ -534,12 +793,13 @@ function invalidateSearch() {
latestSearch = null;
}
async function search() {
async function search(inputAt = performance.now()) {
invalidateSearch();
querySemanticFailed = false;
shownLane = "lexical";
const value = query.value.trim();
if (!value) {
element("performance-panel").hidden = true;
results = [];
displayedRequestId = null;
selected = -1;
@@ -557,13 +817,28 @@ async function search() {
query: value,
filters: captureFilters(),
started: performance.now(),
measurement: new SearchMeasurement(
inputAt,
performance.now(),
element<HTMLInputElement>("performance-toggle").checked,
{
model: semanticStatus?.worker_info?.model ?? null,
device: semanticStatus?.worker_info?.device ?? null,
runtime: semanticStatus?.worker_info?.runtime ?? null,
threads: semanticStatus?.worker_info?.threads ?? null,
dimensions: semanticStatus?.worker_info?.dimensions ?? null,
semantic_ready: canSearchSemantically(),
indexed_chunks: semanticStatus?.chunks ?? null,
},
),
};
latestSearch = request;
refreshPerformance(request);
element("result-summary").textContent = "SEARCHING…";
lexicalLane.submit(request);
submitSearch(request, "lexical");
hybridTimer = setTimeout(() => {
if (isCurrent(request) && canSearchSemantically())
hybridLane.submit(request);
submitSearch(request, "hybrid");
}, 350);
} catch (error) {
showError(error);
@@ -573,12 +848,41 @@ async function search() {
async function refreshStats() {
const stats = await invoke<IndexStats>("stats");
element("index-status").textContent =
`${stats.files.toLocaleString()} files · ${stats.content_files.toLocaleString()} with searchable content`;
`${stats.files.toLocaleString()} files · ${stats.content_files.toLocaleString()} with extracted text`;
}
query.addEventListener("input", () => {
const inputAt = performance.now();
invalidateSearch();
timer = setTimeout(search, 60);
timer = setTimeout(() => void search(inputAt), 60);
});
element("performance-toggle").addEventListener("change", () => {
const enabled = element<HTMLInputElement>("performance-toggle").checked;
try {
localStorage.setItem("searchi-performance-details", String(enabled));
} catch {
/* Session preference still works. */
}
void search();
});
element("copy-performance").addEventListener("click", () => {
if (!latestSearch?.measurement.debug) return;
if (!navigator.clipboard) {
element("copy-performance-status").textContent =
"Clipboard unavailable. Timings remain visible here.";
return;
}
const report = JSON.stringify(latestSearch.measurement.export(), null, 2);
void navigator.clipboard.writeText(report).then(
() => {
element("copy-performance-status").textContent =
"Timing report copied. No query or file paths included.";
},
() => {
element("copy-performance-status").textContent =
"Clipboard unavailable. Timings remain visible here.";
},
);
});
element("search-form").addEventListener("submit", (event) => {
event.preventDefault();
@@ -714,6 +1018,7 @@ document.addEventListener("keydown", (event) => {
query.select();
}
if (event.key === "Escape") {
clearMedia();
document
.querySelectorAll<HTMLDetailsElement>(".popover[open]")
.forEach((popover) => {
+98
View File
@@ -0,0 +1,98 @@
/** Media formatting and selection revisions shared by the preview and tests. */
export interface MediaSource {
kind: string;
number: number;
}
export type MediaKind = "image" | "audio" | "video";
export function timestampLabel(milliseconds: number): string {
if (!Number.isFinite(milliseconds) || milliseconds < 0) return "Unknown time";
const seconds = Math.floor(milliseconds / 1000);
return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, "0")}`;
}
export function seekSeconds(source?: MediaSource): number | undefined {
return source?.kind === "timestamp_ms" &&
Number.isFinite(source.number) &&
source.number >= 0
? source.number / 1000
: undefined;
}
export function sourceLabel(source?: MediaSource): string {
if (!source) return "";
if (source.kind === "image") return "Image";
if (source.kind === "timestamp_ms") return timestampLabel(source.number);
return `${source.kind} ${source.number}`;
}
export function matchLabel(kind: string): string {
const labels: Record<string, string> = {
semantic_image: "Image meaning",
semantic_video: "Video frame meaning",
semantic_audio: "Audio meaning",
semantic: "Meaning",
hybrid: "Keyword + meaning",
exact_filename: "Exact filename",
exact_identifier: "Exact identifier",
filename: "Filename",
keyword: "Content",
};
return labels[kind] ?? kind.replaceAll("_", " ");
}
export function mediaKind(filename: string): MediaKind | undefined {
const extension = filename.split(".").pop()?.toLowerCase() ?? "";
if (
[
"png",
"jpg",
"jpeg",
"webp",
"bmp",
"gif",
"tiff",
"tif",
"heic",
"avif",
].includes(extension)
)
return "image";
if (
["mp4", "mkv", "mov", "webm", "avi", "m4v", "mpeg", "mpg"].includes(
extension,
)
)
return "video";
if (
[
"mp3",
"wav",
"flac",
"ogg",
"m4a",
"aac",
"opus",
"wma",
"aiff",
"aif",
].includes(extension)
)
return "audio";
return undefined;
}
/** Even selecting the same file at a new timestamp invalidates old responses. */
export class PreviewRevision {
private revision = 0;
private key = "";
select(key: string): { revision: number; changed: boolean } {
const changed = this.key !== key;
if (changed) {
this.key = key;
this.revision++;
}
return { revision: this.revision, changed };
}
accepts(revision: number) {
return revision === this.revision && this.key !== "";
}
clear() {
this.key = "";
this.revision++;
}
}
+203
View File
@@ -0,0 +1,203 @@
/** UI times use performance.now(); native spans use their own command clock. */
export interface NativeSpan {
name: string;
start_ms: number;
duration_ms: number;
parent: number | null;
counters: Record<string, number>;
}
export interface NativeMetrics {
total_ms: number;
result_count: number;
trace: NativeSpan[] | null;
dropped_spans: number;
}
export interface MeasuredResponse<T> {
results: T[];
metrics: NativeMetrics;
}
export type SearchLane = "lexical" | "hybrid";
export interface AttemptStart {
lane: SearchLane;
startedAt: number;
submittedAt: number;
submission: number;
}
interface Attempt {
lane: SearchLane;
submission: number;
attempt: number;
queued_ms: number;
retry_wait_ms: number;
invoke_ms: number;
native: NativeMetrics | null;
transport_estimate_ms: number | null;
failed: boolean;
ended_at: number;
}
export class SearchMeasurement {
debug: boolean;
inputAt: number;
scheduledAt: number;
context: Record<string, string | number | boolean | null>;
submissions = new Map<SearchLane, number>();
latestSubmission = new Map<SearchLane, number>();
submissionSequence = new Map<SearchLane, number>();
laneAttempts = new Map<SearchLane, number>();
attempts: Attempt[] = [];
droppedAttempts = 0;
droppedDisplays = 0;
firstDisplayMs: number | null = null;
displays: {
lane: SearchLane;
elapsed_ms: number;
dom_update_ms: number;
count: number;
}[] = [];
constructor(
inputAt: number,
scheduledAt: number,
debug: boolean,
context: Record<string, string | number | boolean | null> = {},
) {
this.inputAt = inputAt;
this.scheduledAt = scheduledAt;
this.debug = debug;
this.context = { ...context };
}
submitted(lane: SearchLane, at: number) {
if (!this.submissions.has(lane)) this.submissions.set(lane, at);
this.latestSubmission.set(lane, at);
this.submissionSequence.set(
lane,
(this.submissionSequence.get(lane) ?? 0) + 1,
);
}
begin(lane: SearchLane, startedAt: number): AttemptStart {
return {
lane,
startedAt,
submittedAt: this.latestSubmission.get(lane) ?? startedAt,
submission: this.submissionSequence.get(lane) ?? 0,
};
}
completed(
token: AttemptStart,
end: number,
native: NativeMetrics | null,
failed: boolean,
) {
const { lane, startedAt: start, submittedAt, submission } = token;
const prior = this.attempts.filter((attempt) => attempt.lane === lane);
const previous = prior.at(-1);
const retry = previous?.failed && previous.submission === submission;
const attemptNumber = (this.laneAttempts.get(lane) ?? 0) + 1;
this.laneAttempts.set(lane, attemptNumber);
const elapsed = Math.max(0, end - start);
this.attempts.push({
lane,
submission,
attempt: attemptNumber,
queued_ms: retry ? 0 : Math.max(0, start - submittedAt),
retry_wait_ms: retry ? Math.max(0, start - previous.ended_at) : 0,
invoke_ms: elapsed,
native,
transport_estimate_ms: native
? Math.max(0, elapsed - native.total_ms)
: null,
failed,
ended_at: end,
});
if (this.attempts.length > (this.debug ? 32 : 4)) {
this.attempts.shift();
this.droppedAttempts++;
}
}
displayed(lane: SearchLane, start: number, end: number, count: number) {
if (this.firstDisplayMs === null)
this.firstDisplayMs = Math.max(0, end - this.inputAt);
this.displays.push({
lane,
elapsed_ms: Math.max(0, end - this.inputAt),
dom_update_ms: Math.max(0, end - start),
count,
});
if (this.displays.length > (this.debug ? 16 : 2)) {
this.displays.shift();
this.droppedDisplays++;
}
}
latestNative(lane: SearchLane) {
return (
this.attempts
.filter((attempt) => attempt.lane === lane && !attempt.failed)
.at(-1)?.native ?? null
);
}
/** Deliberately exclude query text, filenames, paths, snippets and errors. */
export() {
return {
version: 1,
context: this.context,
clock_note:
"UI monotonic elapsed times; native command-relative spans. Nested spans and parallel lanes must not be summed.",
debounce_ms: Math.max(0, this.scheduledAt - this.inputAt),
lane_schedule_ms: Object.fromEntries(
[...this.submissions].map(([lane, at]) => [
lane,
Math.max(0, at - this.scheduledAt),
]),
),
attempts: this.attempts.map(({ ended_at: _end, ...attempt }) => attempt),
displays: this.displays,
first_results_ms: this.firstDisplayMs,
latest_displayed_ms: this.displays.at(-1)?.elapsed_ms ?? null,
hybrid_final_results_ms:
[...this.displays]
.reverse()
.find((display) => display.lane === "hybrid")?.elapsed_ms ?? null,
dropped_attempts: this.droppedAttempts,
dropped_displays: this.droppedDisplays,
};
}
}
export function measuredFailure(
error: unknown,
): { message: string; metrics: NativeMetrics } | null {
if (!error || typeof error !== "object") return null;
const value = error as { message?: unknown; metrics?: NativeMetrics };
return typeof value.message === "string" &&
value.metrics &&
Number.isFinite(value.metrics.total_ms)
? { message: value.message, metrics: value.metrics }
: null;
}
/** Malformed/cyclic parent chains cannot trap the debug renderer. */
export function spanDepth(spans: NativeSpan[], index: number) {
let depth = 0;
const visited = new Set([index]);
let parent = spans[index]?.parent;
while (
parent != null &&
spans[parent] &&
!visited.has(parent) &&
depth < 16
) {
visited.add(parent);
depth++;
parent = spans[parent].parent;
}
return depth;
}
export function briefMs(value: number) {
return value < 1
? "<1"
: value < 10
? value.toFixed(1)
: String(Math.round(value));
}
+70
View File
@@ -766,3 +766,73 @@ footer {
outline-color: Highlight;
}
}
/* Keep one scoped media player within the existing result preview. */
.media-preview {
margin: 0 20px 14px;
overflow: hidden;
border-radius: 10px;
background: #f3f4f7;
}
.media-preview img,
.media-preview video {
display: block;
width: 100%;
max-height: 340px;
object-fit: contain;
}
.media-preview audio {
display: block;
width: 100%;
}
#media-preview-status {
margin: 0 20px 14px;
}
.file-icon[data-type="image"] {
color: #6c62b5;
}
.file-icon[data-type="video"] {
color: #a16a48;
}
.file-icon[data-type="audio"] {
color: #608271;
}
.performance-toggle {
display: flex;
align-items: center;
gap: 8px;
margin-top: 14px;
}
.performance-toggle input {
width: auto;
accent-color: var(--brand);
}
#performance-panel {
margin: 0 20px 12px;
padding: 10px 14px;
border: 1px solid var(--line);
border-radius: 10px;
background: var(--surface);
font-size: 12px;
}
#performance-panel summary {
cursor: pointer;
font-weight: 600;
}
#performance-detail {
max-height: 280px;
overflow: auto;
font-variant-numeric: tabular-nums;
}
#performance-detail p {
margin: 8px 0;
}
.performance-attempt {
font-weight: 600;
}
.performance-span {
border-left: 2px solid var(--line);
}
#copy-performance-status {
margin-left: 8px;
color: var(--muted);
}