feat: highlight search matches and document the working UI
Core CI / core (ubuntu-latest) (push) Failing after 3m29s
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 7m23s
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 3m29s
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 7m23s
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
b8400c4fbf
commit
79553ce921
26 files changed
+4124
-50
No files matched your search
@@ -0,0 +1,100 @@
|
||||
/** Explicit, read-only bridge for documented browser captures of the real engine.
|
||||
* This never activates in a normal browser preview or remote website. */
|
||||
const commands = new Set([
|
||||
"stats",
|
||||
"desktop_status",
|
||||
"semantic_status",
|
||||
"search",
|
||||
"hybrid_search",
|
||||
"search_measured",
|
||||
"hybrid_search_measured",
|
||||
"prepare_media_preview",
|
||||
]);
|
||||
|
||||
export function captureRequested(href: string) {
|
||||
const url = new URL(href);
|
||||
return (
|
||||
url.searchParams.get("capture") === "1" &&
|
||||
url.protocol === "http:" &&
|
||||
["127.0.0.1", "localhost", "[::1]"].includes(url.hostname)
|
||||
);
|
||||
}
|
||||
|
||||
export class CaptureBridge {
|
||||
private token: string | null = null;
|
||||
private origin: string | null = null;
|
||||
get active() {
|
||||
return this.token !== null;
|
||||
}
|
||||
|
||||
async initialize(href: string, request: typeof fetch = fetch) {
|
||||
this.token = null;
|
||||
this.origin = null;
|
||||
if (!captureRequested(href)) return false;
|
||||
const origin = new URL(href).origin;
|
||||
const response = await request(`${origin}/capture-info`, {
|
||||
credentials: "omit",
|
||||
cache: "no-store",
|
||||
redirect: "error",
|
||||
});
|
||||
if (!response.ok)
|
||||
throw new Error("The local capture engine is unavailable.");
|
||||
const info = (await response.json()) as {
|
||||
mode?: unknown;
|
||||
token?: unknown;
|
||||
ready?: unknown;
|
||||
};
|
||||
if (
|
||||
info.mode !== "readme-capture" ||
|
||||
info.ready !== true ||
|
||||
typeof info.token !== "string" ||
|
||||
info.token.length < 32 ||
|
||||
info.token.length > 512
|
||||
)
|
||||
throw new Error(
|
||||
"The local endpoint did not identify a ready read-only capture engine.",
|
||||
);
|
||||
this.origin = origin;
|
||||
this.token = info.token;
|
||||
return true;
|
||||
}
|
||||
|
||||
async invoke<T>(
|
||||
command: string,
|
||||
args?: Record<string, unknown>,
|
||||
request: typeof fetch = fetch,
|
||||
): Promise<T> {
|
||||
if (!this.active || !this.origin)
|
||||
throw new Error("Run the desktop app to use real local search.");
|
||||
if (!commands.has(command))
|
||||
throw new Error(
|
||||
"This action is unavailable in the read-only documentation capture.",
|
||||
);
|
||||
const response = await request(`${this.origin}/capture-api/${command}`, {
|
||||
method: "POST",
|
||||
credentials: "omit",
|
||||
cache: "no-store",
|
||||
redirect: "error",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-Searchi-Capture-Token": this.token!,
|
||||
},
|
||||
body: JSON.stringify(args ?? {}),
|
||||
});
|
||||
const value = await response.json();
|
||||
if (!response.ok) throw value;
|
||||
return value as T;
|
||||
}
|
||||
|
||||
mediaURL(path: string) {
|
||||
if (
|
||||
!this.active ||
|
||||
!this.origin ||
|
||||
!/^\/capture-media\/[A-Za-z0-9_-]+(?:\.[A-Za-z0-9]+)?$/.test(path)
|
||||
)
|
||||
throw new Error(
|
||||
"Capture media must be an indexed fixture URL issued by the local engine.",
|
||||
);
|
||||
return new URL(path, this.origin).href;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import {
|
||||
invoke as nativeInvoke,
|
||||
isTauri,
|
||||
convertFileSrc,
|
||||
} from "@tauri-apps/api/core";
|
||||
import { CaptureBridge } from "./capture-bridge";
|
||||
|
||||
const capture = new CaptureBridge();
|
||||
export const isNativeDesktop = () => isTauri();
|
||||
export const isCaptureSurface = () => capture.active;
|
||||
export const isDesktopSurface = () => isTauri() || capture.active;
|
||||
|
||||
export async function initializeDesktopSurface() {
|
||||
if (isTauri()) return;
|
||||
await capture.initialize(window.location.href);
|
||||
}
|
||||
|
||||
export function invoke<T>(
|
||||
command: string,
|
||||
args?: Record<string, unknown>,
|
||||
): Promise<T> {
|
||||
return isTauri()
|
||||
? nativeInvoke<T>(command, args)
|
||||
: capture.invoke<T>(command, args);
|
||||
}
|
||||
|
||||
export function prepareMediaURL(path: string) {
|
||||
if (isTauri()) return convertFileSrc(path);
|
||||
return capture.mediaURL(path);
|
||||
}
|
||||
+140
-45
@@ -1,4 +1,11 @@
|
||||
import { invoke, isTauri, convertFileSrc } from "@tauri-apps/api/core";
|
||||
import {
|
||||
invoke,
|
||||
initializeDesktopSurface,
|
||||
isNativeDesktop,
|
||||
isDesktopSurface,
|
||||
isCaptureSurface,
|
||||
prepareMediaURL,
|
||||
} from "./desktop-api";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import {
|
||||
AutomaticIndexGate,
|
||||
@@ -14,6 +21,13 @@ import {
|
||||
briefMs,
|
||||
} from "./search-performance";
|
||||
import type { MeasuredResponse, SearchLane } from "./search-performance";
|
||||
import { displayPath } from "./path-display";
|
||||
import {
|
||||
literalQuery,
|
||||
renderHighlighted,
|
||||
displayExcerpt,
|
||||
matchExplanation,
|
||||
} from "./match-display";
|
||||
import "@fontsource-variable/inter/wght.css";
|
||||
import "./style.css";
|
||||
import {
|
||||
@@ -95,7 +109,7 @@ document.querySelector<HTMLDivElement>("#app")!.innerHTML = `
|
||||
<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><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>
|
||||
<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>`;
|
||||
|
||||
const element = <T extends HTMLElement>(id: string) =>
|
||||
@@ -110,6 +124,8 @@ try {
|
||||
/* Diagnostics remain opt-in when storage is unavailable. */
|
||||
}
|
||||
let results: SearchResult[] = [];
|
||||
let displayedLiteralQuery = literalQuery("");
|
||||
let explainedSelection = "";
|
||||
let selected = -1;
|
||||
let requestId = 0;
|
||||
let timer: ReturnType<typeof setTimeout>;
|
||||
@@ -166,7 +182,7 @@ const mediaLane = new LatestLane<PreviewRequest>(
|
||||
const image = document.createElement("img");
|
||||
image.alt = request.result.filename;
|
||||
image.addEventListener("error", failed, { once: true });
|
||||
image.src = convertFileSrc(prepared.path);
|
||||
image.src = prepareMediaURL(prepared.path);
|
||||
host.append(image);
|
||||
} else {
|
||||
const player = document.createElement(prepared.kind);
|
||||
@@ -194,7 +210,7 @@ const mediaLane = new LatestLane<PreviewRequest>(
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
player.src = convertFileSrc(prepared.path);
|
||||
player.src = prepareMediaURL(prepared.path);
|
||||
host.append(player);
|
||||
}
|
||||
host.hidden = false;
|
||||
@@ -363,13 +379,14 @@ async function refreshSemanticStatus() {
|
||||
}
|
||||
const retry = element<HTMLButtonElement>("retry-model");
|
||||
retry.hidden = !error || status.ready;
|
||||
retry.disabled = working;
|
||||
retry.disabled = working || isCaptureSurface();
|
||||
const rebuild = element<HTMLButtonElement>("rebuild-semantic");
|
||||
rebuild.hidden = !status.ready || (!status.chunks && !status.pending);
|
||||
rebuild.disabled = working || indexingActive;
|
||||
rebuild.disabled = working || indexingActive || isCaptureSurface();
|
||||
element<HTMLSelectElement>("cpu-threads").disabled =
|
||||
working || indexingActive;
|
||||
working || indexingActive || isCaptureSurface();
|
||||
if (
|
||||
!isCaptureSurface() &&
|
||||
status.ready &&
|
||||
status.stale &&
|
||||
status.pending > 0 &&
|
||||
@@ -411,6 +428,7 @@ async function refreshDesktopStatus() {
|
||||
const threads = element<HTMLSelectElement>("cpu-threads");
|
||||
threads.value = String(status.settings.cpu_threads);
|
||||
threads.disabled =
|
||||
isCaptureSurface() ||
|
||||
status.indexing ||
|
||||
semanticJobActive ||
|
||||
Boolean(
|
||||
@@ -418,18 +436,19 @@ async function refreshDesktopStatus() {
|
||||
semanticStatus?.warming ||
|
||||
semanticStatus?.embedding,
|
||||
);
|
||||
element<HTMLButtonElement>("add-folder").disabled = status.indexing;
|
||||
element<HTMLButtonElement>("add-folder").disabled =
|
||||
status.indexing || isCaptureSurface();
|
||||
const roots = element("roots");
|
||||
roots.replaceChildren();
|
||||
for (const root of status.settings.roots) {
|
||||
const item = document.createElement("li");
|
||||
const path = document.createElement("span");
|
||||
path.textContent = root;
|
||||
path.textContent = displayPath(root);
|
||||
path.className = "path";
|
||||
const reindex = document.createElement("button");
|
||||
reindex.type = "button";
|
||||
reindex.textContent = "Reindex";
|
||||
reindex.disabled = status.indexing;
|
||||
reindex.disabled = status.indexing || isCaptureSurface();
|
||||
reindex.addEventListener("click", () => {
|
||||
element("folder-form").hidden = false;
|
||||
element<HTMLInputElement>("folder").value = root;
|
||||
@@ -467,21 +486,57 @@ function select(index: number, focus = false) {
|
||||
const selectedIcon = fileIcon(result.filename);
|
||||
element("preview-icon").innerHTML = selectedIcon.html;
|
||||
element("preview-icon").dataset.type = selectedIcon.type;
|
||||
element("preview-title").textContent = result.filename;
|
||||
element("preview-path").textContent = result.path;
|
||||
renderHighlighted(
|
||||
element("preview-title"),
|
||||
result.filename,
|
||||
displayedLiteralQuery,
|
||||
);
|
||||
renderHighlighted(
|
||||
element("preview-path"),
|
||||
displayPath(result.path),
|
||||
displayedLiteralQuery,
|
||||
);
|
||||
element("preview-kind").textContent =
|
||||
`${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 =
|
||||
const explanation = matchExplanation(result);
|
||||
const semanticMedia = [
|
||||
"semantic_image",
|
||||
"semantic_video",
|
||||
"semantic_audio",
|
||||
].includes(result.match_kind);
|
||||
element("passage-label").textContent = semanticMedia
|
||||
? "MATCHED MEDIA"
|
||||
: explanation.semanticPassage
|
||||
? "MATCHED PASSAGE"
|
||||
: "EXTRACTED PASSAGE";
|
||||
const passageText =
|
||||
result.snippet ||
|
||||
(result.extraction_error
|
||||
? `Content could not be extracted: ${result.extraction_error}`
|
||||
: media
|
||||
? result.source?.matched_query || "Preview the matched media below."
|
||||
? "Preview the matched media below."
|
||||
: "No extracted text is available for this file.");
|
||||
renderHighlighted(
|
||||
element("preview-text"),
|
||||
passageText,
|
||||
semanticMedia ? literalQuery("") : displayedLiteralQuery,
|
||||
);
|
||||
document
|
||||
.querySelector<HTMLElement>(".passage")!
|
||||
.classList.toggle("semantic-passage", explanation.semanticPassage);
|
||||
element("match-reason").textContent = explanation.reason;
|
||||
element("match-legend").textContent = explanation.legend;
|
||||
const explanationKey = JSON.stringify([
|
||||
result.path,
|
||||
result.match_kind,
|
||||
result.source?.kind,
|
||||
result.source?.number,
|
||||
displayedLiteralQuery.query,
|
||||
]);
|
||||
if (explanationKey !== explainedSelection)
|
||||
element<HTMLDetailsElement>("match-details").open = false;
|
||||
explainedSelection = 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";
|
||||
@@ -491,7 +546,7 @@ function select(index: number, focus = false) {
|
||||
if (selection.changed) {
|
||||
mediaLane.clear();
|
||||
unloadMedia();
|
||||
if (media && isTauri()) {
|
||||
if (media && isDesktopSurface()) {
|
||||
element("media-preview-status").hidden = false;
|
||||
element("media-preview-status").textContent = "Preparing media preview…";
|
||||
mediaLane.submit({ revision: selection.revision, result });
|
||||
@@ -555,10 +610,23 @@ function render() {
|
||||
resultRows.set(result.path, entry);
|
||||
}
|
||||
entry.button.dataset.index = String(i);
|
||||
entry.button.title = result.path;
|
||||
entry.title.textContent = result.filename;
|
||||
entry.path.textContent = result.path;
|
||||
entry.snippet.textContent = result.snippet;
|
||||
entry.button.title = displayPath(result.path);
|
||||
renderHighlighted(entry.title, result.filename, displayedLiteralQuery);
|
||||
renderHighlighted(
|
||||
entry.path,
|
||||
displayPath(result.path),
|
||||
displayedLiteralQuery,
|
||||
);
|
||||
const semanticMedia = [
|
||||
"semantic_image",
|
||||
"semantic_video",
|
||||
"semantic_audio",
|
||||
].includes(result.match_kind);
|
||||
renderHighlighted(
|
||||
entry.snippet,
|
||||
displayExcerpt(result.snippet, displayedLiteralQuery),
|
||||
semanticMedia ? literalQuery("") : displayedLiteralQuery,
|
||||
);
|
||||
entry.kind.textContent = `${matchLabel(result.match_kind)}${result.source ? ` · ${sourceLabel(result.source)}` : ""}`;
|
||||
const asset = fileIcon(result.filename);
|
||||
if (entry.file.dataset.type !== asset.type) {
|
||||
@@ -627,6 +695,7 @@ function applyResults(
|
||||
const resultsHadFocus = list.contains(document.activeElement);
|
||||
shownLane = lane;
|
||||
displayedRequestId = request.id;
|
||||
displayedLiteralQuery = literalQuery(request.query);
|
||||
results = found;
|
||||
selected = -1;
|
||||
render();
|
||||
@@ -643,7 +712,7 @@ function applyResults(
|
||||
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`;
|
||||
`${found.length} RESULTS · ${lane === "hybrid" ? "KEYWORD + MEANING" : "KEYWORD"} · SEARCH ${briefMs(metric?.total_ms ?? 0)} MS · SHOWN ${briefMs(trace.latest_displayed_ms ?? 0)} MS`;
|
||||
refreshPerformance(request);
|
||||
}
|
||||
|
||||
@@ -801,6 +870,7 @@ async function search(inputAt = performance.now()) {
|
||||
if (!value) {
|
||||
element("performance-panel").hidden = true;
|
||||
results = [];
|
||||
displayedLiteralQuery = literalQuery("");
|
||||
displayedRequestId = null;
|
||||
selected = -1;
|
||||
render();
|
||||
@@ -1044,27 +1114,52 @@ document.addEventListener("keydown", (event) => {
|
||||
}
|
||||
}
|
||||
});
|
||||
if (isTauri()) {
|
||||
void refreshStats().catch(showError);
|
||||
void refreshDesktopStatus().catch(showError);
|
||||
void refreshSemanticStatus();
|
||||
void listen("focus-search", () => {
|
||||
query.focus();
|
||||
query.select();
|
||||
async function initializeSurface() {
|
||||
await initializeDesktopSurface();
|
||||
if (isDesktopSurface()) {
|
||||
if (isCaptureSurface()) {
|
||||
for (const id of [
|
||||
"add-folder",
|
||||
"open-result",
|
||||
"reveal-result",
|
||||
"browse-folder",
|
||||
"retry-model",
|
||||
"rebuild-semantic",
|
||||
"cpu-threads",
|
||||
]) {
|
||||
const control = element<HTMLButtonElement>(id);
|
||||
control.disabled = true;
|
||||
control.title = "Unavailable in the read-only documentation capture";
|
||||
}
|
||||
}
|
||||
void refreshStats().catch(showError);
|
||||
void refreshDesktopStatus().catch(showError);
|
||||
queueSemanticRefresh(0);
|
||||
}).catch(showError);
|
||||
// Poll only during a user-triggered job; idle indexing status does no work.
|
||||
setInterval(() => {
|
||||
if (indexingActive) void refreshDesktopStatus().catch(showError);
|
||||
}, 2000);
|
||||
query.focus();
|
||||
} else {
|
||||
element("index-status").textContent = "Desktop preview";
|
||||
element("semantic-status").textContent =
|
||||
"Native semantic search runs in the desktop app.";
|
||||
element("semantic-progress").hidden = true;
|
||||
element("semantic-detail").textContent =
|
||||
"This browser preview does not download models or return simulated search results.";
|
||||
showError("Run the Tauri desktop app to index and search real files.");
|
||||
void refreshSemanticStatus();
|
||||
if (isNativeDesktop())
|
||||
void listen("focus-search", () => {
|
||||
query.focus();
|
||||
query.select();
|
||||
void refreshDesktopStatus().catch(showError);
|
||||
queueSemanticRefresh(0);
|
||||
}).catch(showError);
|
||||
// Poll only during a user-triggered job; idle indexing status does no work.
|
||||
setInterval(() => {
|
||||
if (indexingActive) void refreshDesktopStatus().catch(showError);
|
||||
}, 2000);
|
||||
query.focus();
|
||||
} else {
|
||||
element("index-status").textContent = "Desktop preview";
|
||||
element("semantic-status").textContent =
|
||||
"Native semantic search runs in the desktop app.";
|
||||
element("semantic-progress").hidden = true;
|
||||
element("semantic-detail").textContent =
|
||||
"This browser preview does not download models or return simulated search results.";
|
||||
showError("Run the Tauri desktop app to index and search real files.");
|
||||
}
|
||||
}
|
||||
void initializeSurface().catch((error) => {
|
||||
element("index-status").textContent = "Capture unavailable";
|
||||
element("semantic-status").textContent =
|
||||
"The real local capture engine could not initialize.";
|
||||
showError(error);
|
||||
});
|
||||
@@ -0,0 +1,287 @@
|
||||
/** Literal text evidence only. Semantic relevance never implies word importance. */
|
||||
import { timestampLabel } from "./media-display.ts";
|
||||
|
||||
const fold = (value: string) =>
|
||||
value.normalize("NFD").replace(/\p{M}/gu, "").toLowerCase();
|
||||
const stopwords = new Set(
|
||||
"a an and are as at be been but by can could did do does for from had has have how i if in into is it its me my of on or our should so than that the their them there these they this those to was we were what when where which who will with would you your am auch auf aus bei bin bis das dass dem den der des die dieser du ein eine einer einem einen eines er es für hat ich im ist mit nach nicht noch nur oder sein sind so sie sich und uns vom von vor war waren was wie wir wird zu zum zur"
|
||||
.split(" ")
|
||||
.map(fold),
|
||||
);
|
||||
export interface LiteralQuery {
|
||||
readonly query: string;
|
||||
readonly patterns: readonly string[];
|
||||
}
|
||||
export interface HighlightPart {
|
||||
text: string;
|
||||
matched: boolean;
|
||||
}
|
||||
|
||||
export function literalQuery(value: string): LiteralQuery {
|
||||
const query = value.slice(0, 4096);
|
||||
const patterns = new Set<string>();
|
||||
const add = (text: string) => {
|
||||
const normalized = fold(text.trim());
|
||||
if (
|
||||
normalized &&
|
||||
!stopwords.has(normalized) &&
|
||||
/[\p{L}\p{N}]/u.test(normalized) &&
|
||||
patterns.size < 64
|
||||
)
|
||||
patterns.add(normalized);
|
||||
};
|
||||
const meaningful = (text: string) =>
|
||||
[...fold(text).matchAll(/[\p{L}\p{N}]+/gu)].some(
|
||||
(word) => !stopwords.has(word[0]),
|
||||
);
|
||||
// Fixed parsers only: user input is never compiled as a regular expression.
|
||||
for (const phrase of query.matchAll(/["“]([^"”]+)["”]/gu)) {
|
||||
if (meaningful(phrase[1])) add(phrase[1]);
|
||||
}
|
||||
const unquoted = query.replace(/["“”]/gu, "");
|
||||
const words = unquoted.split(/\s+/u);
|
||||
for (const raw of words) {
|
||||
const word = raw.replace(/^[,;:!?()[\]{}]+|[,;:!?()[\]{}.]+$/gu, "");
|
||||
add(word);
|
||||
if (patterns.size >= 64) break;
|
||||
}
|
||||
return Object.freeze({
|
||||
query,
|
||||
patterns: Object.freeze([...patterns].sort((a, b) => b.length - a.length)),
|
||||
});
|
||||
}
|
||||
|
||||
export function highlightParts(
|
||||
text: string,
|
||||
query: LiteralQuery,
|
||||
): HighlightPart[] {
|
||||
if (!text || !query.patterns.length) return [{ text, matched: false }];
|
||||
const offsets: { start: number; end: number }[] = [];
|
||||
let normalized = "";
|
||||
// Index bounded text only; untouched suffixes still preserve the complete source.
|
||||
for (const segment of text
|
||||
.slice(0, 65_536)
|
||||
.matchAll(/[^\p{M}]\p{M}*|\p{M}+/gu)) {
|
||||
const value = fold(segment[0]);
|
||||
normalized += value;
|
||||
for (let index = 0; index < value.length; index++)
|
||||
offsets.push({
|
||||
start: segment.index,
|
||||
end: segment.index + segment[0].length,
|
||||
});
|
||||
}
|
||||
const intervals: { start: number; end: number }[] = [];
|
||||
for (const pattern of query.patterns) {
|
||||
let from = 0;
|
||||
let matches = 0;
|
||||
while (matches < 256) {
|
||||
const start = normalized.indexOf(pattern, from);
|
||||
if (start < 0) break;
|
||||
const end = start + pattern.length;
|
||||
const identifier = /[_-]/u.test(pattern);
|
||||
const left = normalized.slice(Math.max(0, start - 2), start);
|
||||
const right = normalized.slice(end, end + 2);
|
||||
const joinedLeft = identifier
|
||||
? /[\p{L}\p{N}_-]$/u.test(left)
|
||||
: /[\p{L}\p{N}]$/u.test(left);
|
||||
const joinedRight = identifier
|
||||
? /^[\p{L}\p{N}_-]/u.test(right)
|
||||
: /^[\p{L}\p{N}]/u.test(right);
|
||||
if (!joinedLeft && !joinedRight && offsets[start] && offsets[end - 1]) {
|
||||
intervals.push({
|
||||
start: offsets[start].start,
|
||||
end: offsets[end - 1].end,
|
||||
});
|
||||
matches++;
|
||||
}
|
||||
from = end;
|
||||
}
|
||||
}
|
||||
intervals.sort((a, b) => a.start - b.start || b.end - a.end);
|
||||
const merged: { start: number; end: number }[] = [];
|
||||
for (const interval of intervals) {
|
||||
const previous = merged.at(-1);
|
||||
if (previous && interval.start < previous.end)
|
||||
previous.end = Math.max(previous.end, interval.end);
|
||||
else merged.push({ ...interval });
|
||||
}
|
||||
const parts: HighlightPart[] = [];
|
||||
let position = 0;
|
||||
for (const interval of merged.slice(0, 256)) {
|
||||
if (position < interval.start)
|
||||
parts.push({
|
||||
text: text.slice(position, interval.start),
|
||||
matched: false,
|
||||
});
|
||||
parts.push({
|
||||
text: text.slice(interval.start, interval.end),
|
||||
matched: true,
|
||||
});
|
||||
position = interval.end;
|
||||
}
|
||||
if (position < text.length)
|
||||
parts.push({ text: text.slice(position), matched: false });
|
||||
return parts.length ? parts : [{ text, matched: false }];
|
||||
}
|
||||
|
||||
const rendered = new WeakMap<
|
||||
HTMLElement,
|
||||
{ text: string; query: LiteralQuery }
|
||||
>();
|
||||
const excerpts = new WeakMap<LiteralQuery, Map<string, string>>();
|
||||
|
||||
export function displayExcerpt(
|
||||
text: string,
|
||||
query: LiteralQuery,
|
||||
length = 420,
|
||||
) {
|
||||
if (text.length <= length) return text;
|
||||
let cache = excerpts.get(query);
|
||||
if (!cache) {
|
||||
cache = new Map();
|
||||
excerpts.set(query, cache);
|
||||
}
|
||||
const key = `${length}:${text}`;
|
||||
const stored = cache.get(key);
|
||||
if (stored !== undefined) return stored;
|
||||
let position = 0;
|
||||
let firstMatch = 0;
|
||||
for (const part of highlightParts(text, query)) {
|
||||
if (part.matched) {
|
||||
firstMatch = position;
|
||||
break;
|
||||
}
|
||||
position += part.text.length;
|
||||
}
|
||||
let start = Math.max(0, firstMatch - 100);
|
||||
if (start > 0 && /[\uDC00-\uDFFF]/u.test(text[start])) start--;
|
||||
let end = Math.min(text.length, start + length);
|
||||
if (end < text.length && /[\uD800-\uDBFF]/u.test(text[end - 1])) end--;
|
||||
const excerpt = `${start ? "… " : ""}${text.slice(start, end)}${end < text.length ? " …" : ""}`;
|
||||
if (cache.size >= 128) cache.delete(cache.keys().next().value!);
|
||||
cache.set(key, excerpt);
|
||||
return excerpt;
|
||||
}
|
||||
|
||||
export function renderHighlighted(
|
||||
target: HTMLElement,
|
||||
text: string,
|
||||
query: LiteralQuery,
|
||||
) {
|
||||
const previous = rendered.get(target);
|
||||
if (previous?.text === text && previous.query === query) return;
|
||||
const doc = target.ownerDocument;
|
||||
const fragment = doc.createDocumentFragment();
|
||||
for (const part of highlightParts(text, query)) {
|
||||
if (part.matched) {
|
||||
const mark = doc.createElement("mark");
|
||||
mark.dataset.match = "literal";
|
||||
mark.textContent = part.text;
|
||||
fragment.append(mark);
|
||||
} else fragment.append(doc.createTextNode(part.text));
|
||||
}
|
||||
target.replaceChildren(fragment);
|
||||
rendered.set(target, { text, query });
|
||||
}
|
||||
|
||||
interface MatchResult {
|
||||
match_kind: string;
|
||||
source?: { kind: string; number: number };
|
||||
}
|
||||
export function matchExplanation(result: MatchResult) {
|
||||
const source = result.source;
|
||||
const location =
|
||||
source?.kind === "page"
|
||||
? ` on page ${source.number}`
|
||||
: source?.kind === "paragraph"
|
||||
? ` in paragraph ${source.number}`
|
||||
: "";
|
||||
const time =
|
||||
source?.kind === "timestamp_ms"
|
||||
? ` near ${timestampLabel(source.number)}`
|
||||
: "";
|
||||
const literalLegend =
|
||||
"Marked words are literal query matches, ignoring case and accents.";
|
||||
switch (result.match_kind) {
|
||||
case "semantic_image":
|
||||
return {
|
||||
reason:
|
||||
"Related image content. The match applies to the image as a whole.",
|
||||
legend: literalLegend,
|
||||
semanticPassage: false,
|
||||
};
|
||||
case "semantic_video":
|
||||
return {
|
||||
reason: `Related video frame${time}. The timestamp is the indexed frame position.`,
|
||||
legend: literalLegend,
|
||||
semanticPassage: false,
|
||||
};
|
||||
case "semantic_audio":
|
||||
return {
|
||||
reason: `Related audio${time}. The preview starts at an indexed segment, not an exact spoken phrase.`,
|
||||
legend: literalLegend,
|
||||
semanticPassage: false,
|
||||
};
|
||||
case "hybrid":
|
||||
return {
|
||||
reason: `Query words and related meaning matched this file; showing the passage${location}.`,
|
||||
legend: `${literalLegend} Meaning can match without the same words.`,
|
||||
semanticPassage: false,
|
||||
};
|
||||
case "semantic":
|
||||
return {
|
||||
reason: `Related passage${location}. Similar meaning was found in this passage or its file title.`,
|
||||
legend: `${literalLegend} Meaning can match without the same words.`,
|
||||
semanticPassage: true,
|
||||
};
|
||||
case "exact_filename":
|
||||
return {
|
||||
reason: "The filename matches your query.",
|
||||
legend: literalLegend,
|
||||
semanticPassage: false,
|
||||
};
|
||||
case "filename":
|
||||
return {
|
||||
reason: "Your query appears in the filename.",
|
||||
legend: literalLegend,
|
||||
semanticPassage: false,
|
||||
};
|
||||
case "path":
|
||||
return {
|
||||
reason: "Your query appears in the file path.",
|
||||
legend: literalLegend,
|
||||
semanticPassage: false,
|
||||
};
|
||||
case "name_terms":
|
||||
return {
|
||||
reason: "Query words matched indexed filename, path or content terms.",
|
||||
legend: literalLegend,
|
||||
semanticPassage: false,
|
||||
};
|
||||
case "media_metadata":
|
||||
return {
|
||||
reason: "Query words matched artist, title or other media metadata.",
|
||||
legend: literalLegend,
|
||||
semanticPassage: false,
|
||||
};
|
||||
case "exact_identifier":
|
||||
return {
|
||||
reason: "An exact identifier matched extracted content.",
|
||||
legend: literalLegend,
|
||||
semanticPassage: false,
|
||||
};
|
||||
case "content":
|
||||
case "keyword":
|
||||
return {
|
||||
reason: `Query words matched extracted content${location}.`,
|
||||
legend: literalLegend,
|
||||
semanticPassage: false,
|
||||
};
|
||||
default:
|
||||
return {
|
||||
reason: "This result was returned by local search.",
|
||||
legend: literalLegend,
|
||||
semanticPassage: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
/** Explorer-style labels; callers retain the original path for file operations. */
|
||||
export function displayPath(path: string) {
|
||||
if (path.startsWith("\\\\?\\UNC\\")) return "\\\\" + path.slice(8);
|
||||
if (/^\\\\\?\\[A-Za-z]:\\/.test(path)) return path.slice(4);
|
||||
return path;
|
||||
}
|
||||
@@ -836,3 +836,48 @@ footer {
|
||||
margin-left: 8px;
|
||||
color: var(--muted);
|
||||
}
|
||||
mark[data-match="literal"] {
|
||||
background: #ffebae;
|
||||
color: #31280c;
|
||||
border-radius: 2px;
|
||||
padding: 0 1px;
|
||||
font-weight: 600;
|
||||
box-decoration-break: clone;
|
||||
}
|
||||
.passage.semantic-passage {
|
||||
background: var(--selected);
|
||||
border-left: 3px solid var(--brand);
|
||||
}
|
||||
.match-details {
|
||||
margin: 0 20px 12px;
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
.match-details summary {
|
||||
width: fit-content;
|
||||
cursor: pointer;
|
||||
}
|
||||
.match-details p {
|
||||
margin: 6px 0 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
#match-legend {
|
||||
font-size: 10px;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
mark[data-match="literal"] {
|
||||
background: #65531e;
|
||||
color: #fff2bd;
|
||||
}
|
||||
}
|
||||
@media (forced-colors: active) {
|
||||
mark[data-match="literal"] {
|
||||
background: Highlight;
|
||||
color: HighlightText;
|
||||
outline: 1px solid Highlight;
|
||||
}
|
||||
.passage.semantic-passage {
|
||||
background: Canvas;
|
||||
border-color: Highlight;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user