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

+52 -4
View File
@@ -1,11 +1,24 @@
# Searchi desktop
This Tauri 2 shell calls the same Rust core as the CLI and the real Google EmbeddingGemma 2 service. It provides folder indexing, native folder selection, progressive filename/content/semantic search, folder/extension/size/date filters, keyboard navigation, escaped text previews, native open/reveal actions, persisted selected folders/resource settings, and a global shortcut. There are no mocked results or embeddings. Watchers and release installers remain roadmap work.
This Tauri 2 shell calls the same Rust core as the CLI and the real Google EmbeddingGemma 2 service. It provides folder indexing, native folder selection, progressive filename/content/semantic search, folder/extension/size/date/media-duration filters, keyboard navigation, escaped text and scoped image/audio/video previews, native open/reveal actions, persisted selected folders/resource settings, and a global shortcut. There are no mocked results or embeddings. Watchers and release installers remain roadmap work.
## Develop
Install Node.js LTS, Rust stable, and the platform prerequisites documented at https://v2.tauri.app/start/prerequisites/. Windows requires MSVC build tools and WebView2; Linux requires WebKitGTK and system development packages; macOS requires Xcode command line tools.
Install Node.js 24 LTS, Rust stable, and the platform prerequisites documented at https://v2.tauri.app/start/prerequisites/. Windows requires MSVC build tools and WebView2; Linux requires WebKitGTK and system development packages; macOS requires Xcode command line tools.
Before launching from a checkout, build and stage both native helpers from the
repository root. These explicit setup commands access the network and verify
pinned artifacts; they do not install tools globally:
```sh
python tools/embeddinggemma2/build_runtime.py --backend cpu --jobs 2
python tools/embeddinggemma2/stage_runtime.py --backend cpu
python tools/media-runtime/provision.py --stage
```
The decoder manifest currently has Windows/Linux x86-64 and ARM64 artifacts;
a vetted macOS decoder package remains a release gate. Build `cpu` in the
Windows VM. Production acceleration requires separate platform/hardware QA.
```sh
cd apps/desktop
npm ci
@@ -25,7 +38,7 @@ The desktop index is stored in the platform app-data directory under `dev.search
On first launch, the native app downloads and verifies the pinned Google model artifacts (310 MB text model plus 555 MB media projector, approximately 865 MB total) into app-managed storage and prepares the packaged embedding worker. No model paths, runtime paths, or terminal setup are required. The UI shows native download bytes, verification/warming states, the actual worker device, and explicit errors with a retry control. The first download requires network access; file extraction, embeddings, and queries stay local.
After a folder is indexed, semantic indexing starts automatically when the model is ready. Existing keyword results stay searchable during setup/indexing. Progress is indeterminate during embedding because the native command does not expose a chunk completion count. Failed automatic builds retry only after a changed index/status revision or an explicit rebuild, avoiding repeated failure loops. Status polls run once per second while setup/indexing/warming is active and stop when idle.
After a folder is indexed, unified document/media semantic indexing starts automatically when the model and packaged media helpers are ready. Images use actual pixels; video frames and full primary-stream audio windows use the Google media projector. Audio/music files also retain artist/title metadata for keyword retrieval. The UI reports unavailable decoder/runtime support and per-file failures while keeping text search available. Existing keyword results stay searchable during setup/indexing. Progress is indeterminate during embedding because the native command does not expose a chunk completion count. Failed automatic builds retry only after a changed index/status revision or an explicit rebuild, avoiding repeated failure loops. Status polls run once per second while setup/indexing/warming is active and stop when idle.
Each query captures one immutable filter snapshot. Keyword results appear first; real hybrid results follow after the query settles. `src/search-state.ts` bounds each lane to one active native request and one latest pending request, discards old revisions, and retries a busy request at most once. Escape clears both pending lanes and their timers; already-running native work finishes without changing cleared results. Hybrid updates preserve the selected file and keyboard focus where possible.
@@ -38,7 +51,7 @@ node --test tests/search-state.test.mjs
For Windows VM UI testing, disable WebView2 GPU rendering as well, before starting Tauri in PowerShell:
```powershell
$env:WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS = '--disable-gpu'
$env:WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS = '--disable-gpu --disable-accelerated-video-decode'
$env:SEARCHI_FORCE_CPU = '1'
npm.cmd run tauri dev
```
@@ -56,3 +69,38 @@ Visual fidelity remains subject to native screenshot QA. Compilation and state t
## Verification and packaging
`npm run build` checks strict TypeScript and creates production frontend assets. `npm run tauri build -- --no-bundle` compiles the native shell when platform prerequisites are installed. Installer bundling is intentionally disabled until release icons, signing and native platform checks are complete. Native Windows, macOS, and Linux checks must be recorded separately; a frontend build does not prove the native shell works.
## Media preview and quality
The result pane renders images and audio/video controls through Tauri's scoped
asset protocol. Native preparation verifies the selected path belongs to a
current indexed file before exposing it. There is no general filesystem access
or local HTTP server. Players use metadata preload, never autoplay, and seek
matched millisecond timestamps after metadata loads. Selection changes and
Escape pause/unload playback; stale preparation responses cannot replace the
current preview. Unsupported WebView codecs show an Open file fallback.
Opening the default application does not guarantee exact-location navigation.
Preview seeking uses the returned window/frame start, which may differ from the
actual utterance. The [independent media quality report](../../docs/MEDIA-QUALITY.md)
records real file recall, speech alignment failures, music hard negatives and
unmet no-answer handling. Genuine multimodal support is implemented; production
quality and native playback/visual interaction QA remain open gates.
## Performance details
Normal results show the actual native search duration and latest result-display
latency from input. The **Performance details** checkbox in Settings is opt-in
and captured per request. Its expandable panel separates input debounce,
intentional semantic delay, pending-lane queue, failed attempts and bounded retry
wait, invocation time, native stage hierarchy and synchronous result DOM updates.
First response and hybrid-final latency are distinct; keyword-only results do not
claim semantic completion. Native spans use a command-relative clock and nested
or parallel spans must not be summed. Invocation minus native total is explicitly
an estimated transport/serialization/scheduling residual, not an exact IPC cost.
DOM update timings do not claim actual screen paint or deferred media loading.
The local clipboard report excludes queries, paths, filenames, snippets and error
strings. It captures available model/device/thread/runtime context without
guessing missing values. Histories are bounded and dropped counts remain visible.
Clipboard failure is explicit; no diagnostic telemetry is uploaded.
+101
View File
@@ -412,6 +412,12 @@ version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
[[package]]
name = "byteorder-lite"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
[[package]]
name = "bytes"
version = "1.12.1"
@@ -1845,6 +1851,12 @@ dependencies = [
"pin-project-lite",
]
[[package]]
name = "http-range"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "21dec9db110f5f872ed9699c3ecf50cf16f423502706ba5c72462e28d3157573"
[[package]]
name = "httparse"
version = "1.10.1"
@@ -2063,6 +2075,32 @@ dependencies = [
"icu_properties",
]
[[package]]
name = "image"
version = "0.25.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104"
dependencies = [
"bytemuck",
"byteorder-lite",
"image-webp",
"moxcms",
"num-traits",
"png 0.18.1",
"zune-core",
"zune-jpeg",
]
[[package]]
name = "image-webp"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3"
dependencies = [
"byteorder-lite",
"quick-error",
]
[[package]]
name = "indexmap"
version = "1.9.3"
@@ -2257,6 +2295,12 @@ dependencies = [
"syn 2.0.119",
]
[[package]]
name = "jpeg-decoder"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00810f1d8b74be64b13dbf3db89ac67740615d6c891f0e7b6179326533011a07"
[[package]]
name = "js-sys"
version = "0.3.106"
@@ -2522,6 +2566,16 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "moxcms"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b"
dependencies = [
"num-traits",
"pxfm",
]
[[package]]
name = "muda"
version = "0.20.0"
@@ -3157,6 +3211,18 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "pxfm"
version = "0.1.30"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
[[package]]
name = "quick-error"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
[[package]]
name = "quick-xml"
version = "0.42.0"
@@ -3652,8 +3718,11 @@ dependencies = [
name = "searchi-desktop"
version = "0.1.0"
dependencies = [
"anyhow",
"image",
"searchi-assets",
"searchi-core",
"searchi-media",
"searchi-service",
"serde",
"serde_json",
@@ -3665,11 +3734,26 @@ dependencies = [
"tempfile",
]
[[package]]
name = "searchi-media"
version = "0.1.0"
dependencies = [
"anyhow",
"image",
"serde",
"serde_json",
"sha2 0.10.9",
"tempfile",
]
[[package]]
name = "searchi-semantic"
version = "0.1.0"
dependencies = [
"anyhow",
"base64 0.22.1",
"image",
"jpeg-decoder",
"serde",
"serde_json",
"sha2 0.10.9",
@@ -3683,6 +3767,7 @@ dependencies = [
"fs2",
"rusqlite",
"searchi-core",
"searchi-media",
"searchi-semantic",
"serde",
"sha2 0.10.9",
@@ -4218,6 +4303,7 @@ dependencies = [
"gtk",
"heck 0.5.0",
"http",
"http-range",
"jni",
"libc",
"log",
@@ -5993,6 +6079,21 @@ dependencies = [
"simd-adler32",
]
[[package]]
name = "zune-core"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d56377fd46368984a170bc5aac5567e52ca5da874caa60bea39fcbca78fb658b"
[[package]]
name = "zune-jpeg"
version = "0.5.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296"
dependencies = [
"zune-core",
]
[[package]]
name = "zvariant"
version = "5.15.0"
+4 -1
View File
@@ -14,7 +14,8 @@ custom-protocol = ["tauri/custom-protocol"]
tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = [] }
anyhow = "1"
tauri = { version = "2", features = ["protocol-asset"] }
tauri-plugin-dialog = "2"
tauri-plugin-opener = "2"
tauri-plugin-global-shortcut = "2"
@@ -24,3 +25,5 @@ tempfile = "3"
searchi-core = { path = "../../../crates/searchi-core" }
searchi-service = { path = "../../../crates/searchi-service" }
searchi-assets = { path = "../../../crates/searchi-assets" }
searchi-media = { path = "../../../crates/searchi-media" }
image = { version = "0.25", default-features = false, features = ["jpeg", "png", "webp", "bmp"] }
@@ -0,0 +1,165 @@
GNU LESSER GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
This version of the GNU Lesser General Public License incorporates
the terms and conditions of version 3 of the GNU General Public
License, supplemented by the additional permissions listed below.
0. Additional Definitions.
As used herein, "this License" refers to version 3 of the GNU Lesser
General Public License, and the "GNU GPL" refers to version 3 of the GNU
General Public License.
"The Library" refers to a covered work governed by this License,
other than an Application or a Combined Work as defined below.
An "Application" is any work that makes use of an interface provided
by the Library, but which is not otherwise based on the Library.
Defining a subclass of a class defined by the Library is deemed a mode
of using an interface provided by the Library.
A "Combined Work" is a work produced by combining or linking an
Application with the Library. The particular version of the Library
with which the Combined Work was made is also called the "Linked
Version".
The "Minimal Corresponding Source" for a Combined Work means the
Corresponding Source for the Combined Work, excluding any source code
for portions of the Combined Work that, considered in isolation, are
based on the Application, and not on the Linked Version.
The "Corresponding Application Code" for a Combined Work means the
object code and/or source code for the Application, including any data
and utility programs needed for reproducing the Combined Work from the
Application, but excluding the System Libraries of the Combined Work.
1. Exception to Section 3 of the GNU GPL.
You may convey a covered work under sections 3 and 4 of this License
without being bound by section 3 of the GNU GPL.
2. Conveying Modified Versions.
If you modify a copy of the Library, and, in your modifications, a
facility refers to a function or data to be supplied by an Application
that uses the facility (other than as an argument passed when the
facility is invoked), then you may convey a copy of the modified
version:
a) under this License, provided that you make a good faith effort to
ensure that, in the event an Application does not supply the
function or data, the facility still operates, and performs
whatever part of its purpose remains meaningful, or
b) under the GNU GPL, with none of the additional permissions of
this License applicable to that copy.
3. Object Code Incorporating Material from Library Header Files.
The object code form of an Application may incorporate material from
a header file that is part of the Library. You may convey such object
code under terms of your choice, provided that, if the incorporated
material is not limited to numerical parameters, data structure
layouts and accessors, or small macros, inline functions and templates
(ten or fewer lines in length), you do both of the following:
a) Give prominent notice with each copy of the object code that the
Library is used in it and that the Library and its use are
covered by this License.
b) Accompany the object code with a copy of the GNU GPL and this license
document.
4. Combined Works.
You may convey a Combined Work under terms of your choice that,
taken together, effectively do not restrict modification of the
portions of the Library contained in the Combined Work and reverse
engineering for debugging such modifications, if you also do each of
the following:
a) Give prominent notice with each copy of the Combined Work that
the Library is used in it and that the Library and its use are
covered by this License.
b) Accompany the Combined Work with a copy of the GNU GPL and this license
document.
c) For a Combined Work that displays copyright notices during
execution, include the copyright notice for the Library among
these notices, as well as a reference directing the user to the
copies of the GNU GPL and this license document.
d) Do one of the following:
0) Convey the Minimal Corresponding Source under the terms of this
License, and the Corresponding Application Code in a form
suitable for, and under terms that permit, the user to
recombine or relink the Application with a modified version of
the Linked Version to produce a modified Combined Work, in the
manner specified by section 6 of the GNU GPL for conveying
Corresponding Source.
1) Use a suitable shared library mechanism for linking with the
Library. A suitable mechanism is one that (a) uses at run time
a copy of the Library already present on the user's computer
system, and (b) will operate properly with a modified version
of the Library that is interface-compatible with the Linked
Version.
e) Provide Installation Information, but only if you would otherwise
be required to provide such information under section 6 of the
GNU GPL, and only to the extent that such information is
necessary to install and execute a modified version of the
Combined Work produced by recombining or relinking the
Application with a modified version of the Linked Version. (If
you use option 4d0, the Installation Information must accompany
the Minimal Corresponding Source and Corresponding Application
Code. If you use option 4d1, you must provide the Installation
Information in the manner specified by section 6 of the GNU GPL
for conveying Corresponding Source.)
5. Combined Libraries.
You may place library facilities that are a work based on the
Library side by side in a single library together with other library
facilities that are not Applications and are not covered by this
License, and convey such a combined library under terms of your
choice, if you do both of the following:
a) Accompany the combined library with a copy of the same work based
on the Library, uncombined with any other library facilities,
conveyed under the terms of this License.
b) Give prominent notice with the combined library that part of it
is a work based on the Library, and explaining where to find the
accompanying uncombined form of the same work.
6. Revised Versions of the GNU Lesser General Public License.
The Free Software Foundation may publish revised and/or new versions
of the GNU Lesser General Public License from time to time. Such new
versions will be similar in spirit to the present version, but may
differ in detail to address new problems or concerns.
Each version is given a distinguishing version number. If the
Library as you received it specifies that a certain numbered version
of the GNU Lesser General Public License "or any later version"
applies to it, you have the option of following the terms and
conditions either of that published version or of any later version
published by the Free Software Foundation. If the Library as you
received it does not specify a version number of the GNU Lesser
General Public License, you may choose any version of the GNU Lesser
General Public License ever published by the Free Software Foundation.
If the Library as you received it specifies that a proxy can decide
whether future versions of the GNU Lesser General Public License shall
apply, that proxy's public statement of acceptance of any version is
permanent authorization for you to choose that version for the
Library.
@@ -0,0 +1,165 @@
GNU LESSER GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
This version of the GNU Lesser General Public License incorporates
the terms and conditions of version 3 of the GNU General Public
License, supplemented by the additional permissions listed below.
0. Additional Definitions.
As used herein, "this License" refers to version 3 of the GNU Lesser
General Public License, and the "GNU GPL" refers to version 3 of the GNU
General Public License.
"The Library" refers to a covered work governed by this License,
other than an Application or a Combined Work as defined below.
An "Application" is any work that makes use of an interface provided
by the Library, but which is not otherwise based on the Library.
Defining a subclass of a class defined by the Library is deemed a mode
of using an interface provided by the Library.
A "Combined Work" is a work produced by combining or linking an
Application with the Library. The particular version of the Library
with which the Combined Work was made is also called the "Linked
Version".
The "Minimal Corresponding Source" for a Combined Work means the
Corresponding Source for the Combined Work, excluding any source code
for portions of the Combined Work that, considered in isolation, are
based on the Application, and not on the Linked Version.
The "Corresponding Application Code" for a Combined Work means the
object code and/or source code for the Application, including any data
and utility programs needed for reproducing the Combined Work from the
Application, but excluding the System Libraries of the Combined Work.
1. Exception to Section 3 of the GNU GPL.
You may convey a covered work under sections 3 and 4 of this License
without being bound by section 3 of the GNU GPL.
2. Conveying Modified Versions.
If you modify a copy of the Library, and, in your modifications, a
facility refers to a function or data to be supplied by an Application
that uses the facility (other than as an argument passed when the
facility is invoked), then you may convey a copy of the modified
version:
a) under this License, provided that you make a good faith effort to
ensure that, in the event an Application does not supply the
function or data, the facility still operates, and performs
whatever part of its purpose remains meaningful, or
b) under the GNU GPL, with none of the additional permissions of
this License applicable to that copy.
3. Object Code Incorporating Material from Library Header Files.
The object code form of an Application may incorporate material from
a header file that is part of the Library. You may convey such object
code under terms of your choice, provided that, if the incorporated
material is not limited to numerical parameters, data structure
layouts and accessors, or small macros, inline functions and templates
(ten or fewer lines in length), you do both of the following:
a) Give prominent notice with each copy of the object code that the
Library is used in it and that the Library and its use are
covered by this License.
b) Accompany the object code with a copy of the GNU GPL and this license
document.
4. Combined Works.
You may convey a Combined Work under terms of your choice that,
taken together, effectively do not restrict modification of the
portions of the Library contained in the Combined Work and reverse
engineering for debugging such modifications, if you also do each of
the following:
a) Give prominent notice with each copy of the Combined Work that
the Library is used in it and that the Library and its use are
covered by this License.
b) Accompany the Combined Work with a copy of the GNU GPL and this license
document.
c) For a Combined Work that displays copyright notices during
execution, include the copyright notice for the Library among
these notices, as well as a reference directing the user to the
copies of the GNU GPL and this license document.
d) Do one of the following:
0) Convey the Minimal Corresponding Source under the terms of this
License, and the Corresponding Application Code in a form
suitable for, and under terms that permit, the user to
recombine or relink the Application with a modified version of
the Linked Version to produce a modified Combined Work, in the
manner specified by section 6 of the GNU GPL for conveying
Corresponding Source.
1) Use a suitable shared library mechanism for linking with the
Library. A suitable mechanism is one that (a) uses at run time
a copy of the Library already present on the user's computer
system, and (b) will operate properly with a modified version
of the Library that is interface-compatible with the Linked
Version.
e) Provide Installation Information, but only if you would otherwise
be required to provide such information under section 6 of the
GNU GPL, and only to the extent that such information is
necessary to install and execute a modified version of the
Combined Work produced by recombining or relinking the
Application with a modified version of the Linked Version. (If
you use option 4d0, the Installation Information must accompany
the Minimal Corresponding Source and Corresponding Application
Code. If you use option 4d1, you must provide the Installation
Information in the manner specified by section 6 of the GNU GPL
for conveying Corresponding Source.)
5. Combined Libraries.
You may place library facilities that are a work based on the
Library side by side in a single library together with other library
facilities that are not Applications and are not covered by this
License, and convey such a combined library under terms of your
choice, if you do both of the following:
a) Accompany the combined library with a copy of the same work based
on the Library, uncombined with any other library facilities,
conveyed under the terms of this License.
b) Give prominent notice with the combined library that part of it
is a work based on the Library, and explaining where to find the
accompanying uncombined form of the same work.
6. Revised Versions of the GNU Lesser General Public License.
The Free Software Foundation may publish revised and/or new versions
of the GNU Lesser General Public License from time to time. Such new
versions will be similar in spirit to the present version, but may
differ in detail to address new problems or concerns.
Each version is given a distinguishing version number. If the
Library as you received it specifies that a certain numbered version
of the GNU Lesser General Public License "or any later version"
applies to it, you have the option of following the terms and
conditions either of that published version or of any later version
published by the Free Software Foundation. If the Library as you
received it does not specify a version number of the GNU Lesser
General Public License, you may choose any version of the GNU Lesser
General Public License ever published by the Free Software Foundation.
If the Library as you received it specifies that a proxy can decide
whether future versions of the GNU Lesser General Public License shall
apply, that proxy's public statement of acceptance of any version is
permanent authorization for you to choose that version for the
Library.
@@ -0,0 +1,11 @@
{
"provider": "BtbN/FFmpeg-Builds",
"release": "autobuild-2026-10-12-13-12",
"version": "9.0.2-27-g7e93014c5b",
"platforms": {
"windows-x86_64": {"archive": "ffmpeg-n9.0.2-27-g7e93014c5b-win64-lgpl-9.0.zip", "bytes": 171476229, "sha256": "104a019fe34c0747b208bde4842a0bac8b70a307cc2bb33aa4648fc15ddb2e39"},
"windows-aarch64": {"archive": "ffmpeg-n9.0.2-27-g7e93014c5b-winarm64-lgpl-9.0.zip", "bytes": 118006941, "sha256": "68d3bac585b80b6f17e459eb4a203ed1f01b7635d4c611f1685de1ae65ca0987"},
"linux-x86_64": {"archive": "ffmpeg-n9.0.2-27-g7e93014c5b-linux64-lgpl-9.0.tar.xz", "bytes": 137901812, "sha256": "12445b38a4700ba1ae7c9c7e57062f602c927dc4187555bfadc9f3e2f75f05b0"},
"linux-aarch64": {"archive": "ffmpeg-n9.0.2-27-g7e93014c5b-linuxarm64-lgpl-9.0.tar.xz", "bytes": 117008524, "sha256": "c9160ae8440c17c016fb2f6b3fcce4bb9d633a15845e908b2303948a7724f227"}
}
}
+197 -3
View File
@@ -4,9 +4,11 @@ mod model_download;
mod semantic_commands;
mod settings;
use searchi_core::metrics::{MetricsMode, SearchMetrics, SearchResponse, TraceCollector};
use searchi_core::{IndexReport, IndexStats, SearchIndex, SearchOptions, SearchResult};
use semantic_commands::{
hybrid_search, index_embeddings, semantic_status, setup_model, SemanticState,
hybrid_search, hybrid_search_measured, index_embeddings, semantic_status, setup_model,
SemanticState,
};
use serde::{Deserialize, Serialize};
use settings::{Settings, SettingsStore};
@@ -31,6 +33,46 @@ struct AppState {
semantic: Arc<SemanticState>,
}
#[derive(Serialize)]
struct MeasuredFailure {
message: String,
metrics: SearchMetrics,
}
impl MeasuredFailure {
fn new(message: impl Into<String>, trace: TraceCollector) -> Self {
Self {
message: message.into(),
metrics: trace.finish(0),
}
}
fn task(message: impl Into<String>, started: std::time::Instant) -> Self {
Self {
message: message.into(),
metrics: SearchMetrics {
total_ms: started.elapsed().as_secs_f64() * 1000.0,
result_count: 0,
trace: None,
dropped_spans: 1,
},
}
}
}
fn measured_outcome(
result: Result<Vec<SearchResult>, String>,
trace: TraceCollector,
) -> Result<SearchResponse<SearchResult>, MeasuredFailure> {
match result {
Ok(results) => Ok(SearchResponse {
metrics: trace.finish(results.len()),
results,
}),
Err(message) => Err(MeasuredFailure::new(message, trace)),
}
}
#[derive(Serialize)]
struct DesktopStatus {
settings: Settings,
@@ -78,6 +120,8 @@ struct Filters {
extension: Option<String>,
min_size: Option<u64>,
max_size: Option<u64>,
min_duration_ms: Option<u32>,
max_duration_ms: Option<u32>,
modified_after_unix_ms: Option<i64>,
modified_before_unix_ms: Option<i64>,
}
@@ -89,6 +133,8 @@ impl From<Filters> for SearchOptions {
extension: value.extension,
min_size: value.min_size,
max_size: value.max_size,
min_duration_ms: value.min_duration_ms,
max_duration_ms: value.max_duration_ms,
modified_after_unix_ms: value.modified_after_unix_ms,
modified_before_unix_ms: value.modified_before_unix_ms,
}
@@ -114,6 +160,46 @@ async fn search(
.map_err(|e| e.to_string())?
}
/// Measured command preserves the legacy array-returning API for integrations.
#[tauri::command]
async fn search_measured(
query: String,
filters: Option<Filters>,
debug_performance: bool,
state: State<'_, AppState>,
) -> Result<SearchResponse<SearchResult>, MeasuredFailure> {
let started = std::time::Instant::now();
let mut trace = TraceCollector::new(if debug_performance {
MetricsMode::Debug
} else {
MetricsMode::Basic
});
let queue = trace.begin("desktop.worker_queue", None);
let lexical = state.lexical.clone();
tauri::async_runtime::spawn_blocking(move || {
trace.end(queue, Default::default());
let result = (|| {
let index = trace.timed("desktop.lexical_lock", None, |_, _| {
lexical
.try_lock()
.map_err(|_| "Keyword worker is busy; try the latest query again.".to_owned())
})?;
index
.search_with_options_traced(
&query,
50,
&filters.unwrap_or_default().into(),
&mut trace,
None,
)
.map_err(|error| error.to_string())
})();
measured_outcome(result, trace)
})
.await
.map_err(|error| MeasuredFailure::task(error.to_string(), started))?
}
#[tauri::command]
async fn pick_folder(app: tauri::AppHandle) -> Result<Option<String>, String> {
tauri::async_runtime::spawn_blocking(move || {
@@ -148,6 +234,68 @@ fn validated_result(index: &SearchIndex, path: &str) -> Result<PathBuf, String>
Ok(canonical)
}
#[derive(Serialize)]
struct MediaPreview {
path: String,
kind: &'static str,
}
// The asset protocol starts with an empty scope. Only a validated indexed
// media file is granted access; the webview receives no general filesystem API.
#[tauri::command]
async fn prepare_media_preview(
path: String,
app: tauri::AppHandle,
state: State<'_, AppState>,
) -> Result<MediaPreview, String> {
let database = state.database.clone();
tauri::async_runtime::spawn_blocking(move || {
let index = SearchIndex::open(database).map_err(|error| error.to_string())?;
let canonical = validated_result(&index, &path)?;
let kind = match searchi_media::classify(&canonical) {
Some(searchi_media::MediaKind::Image) => "image",
Some(searchi_media::MediaKind::Audio) => "audio",
Some(searchi_media::MediaKind::Video) => "video",
None => return Err("This file is not a supported media preview.".into()),
};
if kind == "image" {
let bytes = std::fs::metadata(&canonical)
.map_err(|error| error.to_string())?
.len();
if bytes > 64 * 1024 * 1024 {
return Err("Image exceeds the 64 MiB preview limit.".into());
}
let (width, height) = image::ImageReader::open(&canonical)
.map_err(|error| error.to_string())?
.with_guessed_format()
.map_err(|error| error.to_string())?
.into_dimensions()
.map_err(|error| format!("Image preview is unavailable: {error}"))?;
let jpeg = canonical
.extension()
.and_then(|value| value.to_str())
.is_some_and(|value| matches!(value.to_ascii_lowercase().as_str(), "jpg" | "jpeg"));
let max_pixels = if jpeg {
64 * 1024 * 1024
} else {
16 * 1024 * 1024
};
if width == 0 || height == 0 || u64::from(width) * u64::from(height) > max_pixels {
return Err("Image exceeds the supported preview dimensions.".into());
}
}
app.asset_protocol_scope()
.allow_file(&canonical)
.map_err(|error| error.to_string())?;
Ok(MediaPreview {
path: canonical.to_string_lossy().into_owned(),
kind,
})
})
.await
.map_err(|error| error.to_string())?
}
#[tauri::command]
async fn open_result(
path: String,
@@ -279,7 +427,24 @@ fn main() {
app.path().resource_dir().unwrap_or_else(|_| directory.clone()).join(worker_name)
}
});
let semantic = Arc::new(SemanticState::new(model_directory, worker));
let media_tools = (|| -> Result<searchi_media::FfmpegTools, String> {
if let Some(tools) = searchi_media::FfmpegTools::from_environment()
.map_err(|error| error.to_string())? {
return Ok(tools);
}
let development_media = development_root.join(".searchi/media-runtime/bin");
let media_directory = if cfg!(debug_assertions) && development_media.is_dir() {
development_media
} else {
app.path().resource_dir().map_err(|error| error.to_string())?.join("media-runtime/bin")
};
let ffmpeg = media_directory.join(if cfg!(windows) { "ffmpeg.exe" } else { "ffmpeg" });
let ffprobe = media_directory.join(if cfg!(windows) { "ffprobe.exe" } else { "ffprobe" });
searchi_media::FfmpegTools::new(ffmpeg, ffprobe).map_err(|_| {
"The media decoder is missing from this installation. Repair or reinstall searchi.".into()
})
})();
let semantic = Arc::new(SemanticState::new(model_directory, worker, media_tools));
semantic.begin_automatic_setup(database.clone(), settings.value.cpu_threads);
#[cfg(target_os = "macos")]
let shortcut = "Shift+Super+Space";
@@ -307,16 +472,19 @@ fn main() {
})
.invoke_handler(tauri::generate_handler![
search,
search_measured,
stats,
index_folder,
pick_folder,
open_result,
prepare_media_preview,
desktop_status,
set_cpu_threads,
semantic_status,
setup_model,
index_embeddings,
hybrid_search
hybrid_search,
hybrid_search_measured
])
.run(tauri::generate_context!())
.expect("Searchi desktop could not start");
@@ -326,6 +494,32 @@ fn main() {
mod tests {
use super::*;
#[test]
fn measured_failure_retains_closed_trace_without_inventing_results() {
let mut trace = TraceCollector::new(MetricsMode::Debug);
let parent = trace.begin("desktop.request", None);
trace.begin("desktop.lexical_lock", parent);
let failed = measured_outcome(Err("Keyword worker is busy".into()), trace)
.err()
.unwrap();
assert_eq!(failed.message, "Keyword worker is busy");
assert_eq!(failed.metrics.result_count, 0);
let spans = failed.metrics.trace.unwrap();
assert_eq!(spans.len(), 2);
assert_eq!(spans[1].parent, Some(0));
assert!(spans[1].start_ms + spans[1].duration_ms <= failed.metrics.total_ms + 0.001);
}
#[test]
fn measured_empty_success_is_distinct_from_failure_and_basic_has_no_trace() {
let response = measured_outcome(Ok(Vec::new()), TraceCollector::new(MetricsMode::Basic))
.ok()
.unwrap();
assert!(response.results.is_empty());
assert_eq!(response.metrics.result_count, 0);
assert!(response.metrics.trace.is_none());
}
#[test]
fn indexing_gate_is_released_when_worker_unwinds() {
let indexing = Arc::new(AtomicBool::new(true));
+134 -4
View File
@@ -1,5 +1,6 @@
//! Desktop-owned orchestration: managed model setup and persistent hybrid queries.
use crate::{model_download, AppState, Filters};
use crate::{measured_outcome, model_download, AppState, Filters, MeasuredFailure};
use searchi_core::metrics::{MetricsMode, SearchResponse, TraceCollector};
use searchi_core::SearchResult;
use searchi_service::{EmbeddingReport, HybridSearch};
use serde::Serialize;
@@ -24,10 +25,20 @@ pub struct SemanticState {
downloaded_bytes: AtomicU64,
error: Mutex<Option<String>>,
service: Mutex<Option<HybridSearch>>,
media_tools: Option<searchi_media::FfmpegTools>,
media_setup_error: Option<String>,
}
impl SemanticState {
pub fn new(model_directory: PathBuf, worker: PathBuf) -> Self {
pub fn new(
model_directory: PathBuf,
worker: PathBuf,
media_tools: Result<searchi_media::FfmpegTools, String>,
) -> Self {
let (media_tools, media_setup_error) = match media_tools {
Ok(tools) => (Some(tools), None),
Err(error) => (None, Some(error)),
};
Self {
model_directory,
worker,
@@ -40,6 +51,8 @@ impl SemanticState {
downloaded_bytes: AtomicU64::new(0),
error: Mutex::new(None),
service: Mutex::new(None),
media_tools,
media_setup_error,
}
}
@@ -118,6 +131,9 @@ pub struct SemanticStatus {
pending: usize,
warming: bool,
worker_info: Option<searchi_service::WorkerInfo>,
media_tools_ready: bool,
media_setup_error: Option<String>,
media_failures: Vec<searchi_service::MediaFailure>,
}
struct WorkGuard<'a>(&'a AtomicBool);
@@ -158,6 +174,9 @@ pub fn semantic_status(state: State<'_, AppState>) -> Result<SemanticStatus, Str
.try_lock()
.ok()
.and_then(|service| service.as_ref().and_then(HybridSearch::worker_info)),
media_tools_ready: semantic.media_tools.is_some(),
media_setup_error: semantic.media_setup_error.clone(),
media_failures: index.failures,
})
}
@@ -217,13 +236,42 @@ pub async fn index_embeddings(state: State<'_, AppState>) -> Result<EmbeddingRep
semantic.record_error(None);
// Indexing uses its own isolated worker; interactive queries keep their own session.
let result = HybridSearch::open(
database,
&database,
&semantic.model_directory,
&semantic.worker,
threads,
)
.and_then(|mut service| service.index_embeddings())
.and_then(|mut service| {
let tools = semantic.media_tools.as_ref().ok_or_else(|| {
anyhow::anyhow!("The media decoder is missing from this installation. Repair or reinstall searchi.")
})?;
service.index_embeddings_with_media(tools, &searchi_media::MediaLimits::default())
})
.map_err(|error| error.to_string());
// Prepare the newly activated graph before advertising semantic readiness.
// Keyword search stays available while the cached query session catches up.
if result.is_ok() {
semantic.warmed.store(false, Ordering::Release);
semantic.warming.store(true, Ordering::Release);
let _warming_guard = WorkGuard(&semantic.warming);
let preparation = (|| {
let mut cached = semantic
.service
.lock()
.map_err(|_| "Semantic session is unavailable.".to_string())?;
let service = cached
.as_mut()
.ok_or_else(|| "Semantic query session has not been prepared.".to_string())?;
service.warmup().map_err(|error| error.to_string())
})();
semantic.warmed.store(preparation.is_ok(), Ordering::Release);
if let Err(error) = preparation {
semantic.record_error(Some(format!(
"The index was saved, but search preparation failed: {error}"
)));
return result;
}
}
semantic.record_error(result.as_ref().err().cloned());
result
})
@@ -283,3 +331,85 @@ pub async fn hybrid_search(
.await
.map_err(|error| error.to_string())?
}
#[tauri::command]
pub async fn hybrid_search_measured(
query: String,
filters: Option<Filters>,
debug_performance: bool,
state: State<'_, AppState>,
) -> Result<SearchResponse<SearchResult>, MeasuredFailure> {
let started = std::time::Instant::now();
let mut trace = TraceCollector::new(if debug_performance {
MetricsMode::Debug
} else {
MetricsMode::Basic
});
let semantic = state.semantic.clone();
if !semantic.warmed.load(Ordering::Acquire) {
return Err(MeasuredFailure::new(
"Semantic search is preparing in the background; keyword results are available.",
trace,
));
}
let threads = trace.timed("desktop.settings_lock", None, |_, _| {
state
.settings
.lock()
.map(|settings| settings.value.cpu_threads)
.map_err(|_| "Settings are unavailable.")
});
let threads = match threads {
Ok(threads) => threads,
Err(error) => return Err(MeasuredFailure::new(error, trace)),
};
if semantic
.querying
.compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
.is_err()
{
return Err(MeasuredFailure::new(
"Semantic worker is busy; keyword results remain available.",
trace,
));
}
let database = state.database.clone();
let queue = trace.begin("desktop.worker_queue", None);
tauri::async_runtime::spawn_blocking(move || {
let _guard = WorkGuard(&semantic.querying);
trace.end(queue, Default::default());
let result = (|| {
let mut cached = trace.timed("desktop.semantic_lock", None, |_, _| {
semantic
.service
.lock()
.map_err(|_| "Semantic session is unavailable.")
})?;
if cached.is_none() {
*cached = Some(trace.timed("desktop.session_initialize", None, |_, _| {
HybridSearch::open(
database,
&semantic.model_directory,
&semantic.worker,
threads,
)
.map_err(|error| error.to_string())
})?);
}
cached
.as_mut()
.ok_or("Semantic session is unavailable.")?
.search_traced(
&query,
50,
&filters.unwrap_or_default().into(),
&mut trace,
None,
)
.map_err(|error| error.to_string())
})();
measured_outcome(result, trace)
})
.await
.map_err(|error| MeasuredFailure::task(error.to_string(), started))?
}
+2 -1
View File
@@ -20,7 +20,8 @@
}
],
"security": {
"csp": "default-src 'self'; connect-src ipc: http://ipc.localhost; style-src 'self' 'unsafe-inline'; img-src 'self' data:"
"csp": "default-src 'self'; connect-src ipc: http://ipc.localhost; style-src 'self' 'unsafe-inline'; img-src 'self' data: asset: http://asset.localhost https://asset.localhost; media-src 'self' asset: http://asset.localhost https://asset.localhost",
"assetProtocol": { "enable": true, "scope": [] }
}
},
"bundle": {
+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);
}
+83
View File
@@ -0,0 +1,83 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
timestampLabel,
seekSeconds,
sourceLabel,
matchLabel,
PreviewRevision,
} from "../src/media-display.ts";
import { LatestLane } from "../src/search-state.ts";
const settle = () => new Promise((resolve) => setImmediate(resolve));
test("millisecond seeks preserve fractions and readable minute labels", () => {
assert.equal(timestampLabel(125450), "2:05");
assert.equal(timestampLabel(3600000), "60:00");
assert.equal(seekSeconds({ kind: "timestamp_ms", number: 125450 }), 125.45);
assert.equal(seekSeconds({ kind: "page", number: 4 }), undefined);
assert.equal(seekSeconds({ kind: "timestamp_ms", number: -1 }), undefined);
assert.equal(timestampLabel(Infinity), "Unknown time");
assert.equal(sourceLabel({ kind: "image", number: 0 }), "Image");
assert.equal(matchLabel("semantic_audio"), "Audio meaning");
assert.equal(matchLabel("semantic_video"), "Video frame meaning");
});
test("another timestamp in the same movie invalidates older preparations", () => {
const gate = new PreviewRevision();
const first = gate.select("movie:1000");
assert.equal(gate.select("movie:1000").changed, false);
const second = gate.select("movie:3000");
assert.equal(gate.accepts(first.revision), false);
assert.equal(gate.accepts(second.revision), true);
gate.clear();
assert.equal(gate.accepts(second.revision), false);
});
test("one active preview and only latest pending reject stale output", async () => {
const gate = new PreviewRevision();
const executed = [];
const accepted = [];
let release;
const lane = new LatestLane(
async (request) => {
executed.push(request.path);
if (request.path === "first")
await new Promise((resolve) => {
release = resolve;
});
if (gate.accepts(request.revision)) accepted.push(request.path);
},
(request) => gate.accepts(request.revision),
() => assert.fail("Unexpected preview failure"),
);
lane.submit({ path: "first", revision: gate.select("first").revision });
lane.submit({ path: "middle", revision: gate.select("middle").revision });
lane.submit({ path: "latest", revision: gate.select("latest").revision });
release();
await settle();
assert.deepEqual(executed, ["first", "latest"]);
assert.deepEqual(accepted, ["latest"]);
});
test("Escape clears queued preparation and rejects active output", async () => {
const gate = new PreviewRevision();
let release;
const accepted = [];
const lane = new LatestLane(
async (request) => {
await new Promise((resolve) => {
release = resolve;
});
if (gate.accepts(request.revision)) accepted.push(request.path);
},
(request) => gate.accepts(request.revision),
() => assert.fail("Unexpected preview failure"),
);
lane.submit({ path: "one", revision: gate.select("one").revision });
lane.submit({ path: "two", revision: gate.select("two").revision });
gate.clear();
lane.clear();
release();
await settle();
assert.deepEqual(accepted, []);
});
@@ -0,0 +1,102 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { SearchMeasurement, measuredFailure, spanDepth, briefMs } from '../src/search-performance.ts';
const finish = (trace, lane, start, end, metrics, failed) => trace.completed(trace.begin(lane, start), end, metrics, failed);
const native = { total_ms: 12, result_count: 2, trace: [], dropped_spans: 0 };
test('separates debounce, intentional semantic delay, queue, invoke and DOM update', () => {
const trace = new SearchMeasurement(100, 165, true);
trace.submitted('lexical', 165);
finish(trace, 'lexical', 180, 200, native, false);
trace.displayed('lexical', 200, 203, 2);
trace.submitted('hybrid', 515);
finish(trace, 'hybrid', 520, 550, native, false);
trace.displayed('hybrid', 550, 554, 3);
const report = trace.export();
assert.equal(report.debounce_ms, 65);
assert.equal(report.lane_schedule_ms.hybrid, 350);
assert.equal(report.attempts[0].queued_ms, 15);
assert.equal(report.attempts[0].invoke_ms, 20);
assert.equal(report.attempts[0].transport_estimate_ms, 8);
assert.equal(report.displays[0].dom_update_ms, 3);
assert.equal(report.first_results_ms, 103);
assert.equal(report.hybrid_final_results_ms, 454);
});
test('failed attempt timings survive retry and lexical response does not pretend hybrid completion', () => {
const trace = new SearchMeasurement(0, 0, true);
trace.submitted('lexical', 0);
finish(trace, 'lexical', 0, 2, native, true);
finish(trace, 'lexical', 402, 414, native, false);
trace.displayed('lexical', 414, 415, 2);
const report = trace.export();
assert.equal(report.attempts[1].retry_wait_ms, 400);
assert.equal(report.attempts[0].failed, true);
assert.equal(report.attempts[0].transport_estimate_ms, 0);
assert.equal(report.hybrid_final_results_ms, null);
assert.equal(report.latest_displayed_ms, 415);
assert.equal(measuredFailure({message: 'worker busy', metrics: native}).message, 'worker busy');
assert.equal(measuredFailure('legacy failure'), null);
});
test('debug history is bounded and first response survives dropped displays', () => {
const context = { device: 'cpu', threads: 1 };
const trace = new SearchMeasurement(0, 0, true, context);
context.threads = 8;
for (let i = 0; i < 40; i++) {
finish(trace, 'hybrid', i, i + 1, native, false);
trace.displayed('hybrid', i, i + 1, 1);
}
const report = trace.export();
assert.equal(report.attempts.length, 32);
assert.equal(report.displays.length, 16);
assert.equal(report.dropped_attempts, 8);
assert.equal(report.dropped_displays, 24);
assert.equal(report.first_results_ms, 1);
assert.equal(report.context.threads, 1);
assert.doesNotMatch(JSON.stringify(report), /query|filename|snippet|ended_at/);
});
test('cyclic or invalid native span parents cannot hang hierarchy rendering', () => {
const spans = [{parent: 1}, {parent: 0}, {parent: 99}];
assert.equal(spanDepth(spans, 0), 1);
assert.equal(spanDepth(spans, 2), 0);
});
test('a later refresh has queue timing rather than being misreported as a busy retry', () => {
const trace = new SearchMeasurement(0, 0, true);
trace.submitted('hybrid', 350);
finish(trace, 'hybrid', 350, 360, native, false);
trace.submitted('hybrid', 1000);
finish(trace, 'hybrid', 1010, 1020, native, false);
assert.equal(trace.export().attempts[1].queued_ms, 10);
assert.equal(trace.export().attempts[1].retry_wait_ms, 0);
assert.equal(briefMs(.44), '<1');
assert.equal(briefMs(3.14), '3.1');
});
test('new submission after a failure measures pending queue rather than retry wait', () => {
const trace = new SearchMeasurement(0, 0, true);
trace.submitted('hybrid', 350);
finish(trace, 'hybrid', 350, 360, native, true);
trace.submitted('hybrid', 1000);
finish(trace, 'hybrid', 1010, 1020, native, false);
assert.equal(trace.export().attempts[1].queued_ms, 10);
assert.equal(trace.export().attempts[1].retry_wait_ms, 0);
});
test('an in-flight attempt keeps its captured submission when a refresh queues', () => {
const trace = new SearchMeasurement(0, 0, true);
trace.submitted('hybrid', 350);
const active = trace.begin('hybrid', 355);
trace.submitted('hybrid', 1000);
trace.completed(active, 1005, native, true);
finish(trace, 'hybrid', 1010, 1020, native, false);
const attempts = trace.export().attempts;
assert.equal(attempts[0].queued_ms, 5);
assert.equal(attempts[1].queued_ms, 10);
assert.equal(attempts[1].retry_wait_ms, 0);
assert.notEqual(attempts[0].submission, attempts[1].submission);
});