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
+52
-4
@@ -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.
|
||||
Generated
+101
@@ -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"
|
||||
|
||||
@@ -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.
|
||||
+165
@@ -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"}
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
@@ -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))?
|
||||
}
|
||||
@@ -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": {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
Reference in new issue
Block a user