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
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:
1 parent
d3bb8c7618
commit
b8400c4fbf
110 files changed
+79395
-250
No files matched your search
@@ -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
@@ -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) => {
|
||||
|
||||
@@ -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++;
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
Reference in new issue
Block a user