diff --git a/.github/actions/updater-flags/action.yml b/.github/actions/updater-flags/action.yml new file mode 100644 index 0000000..65e35eb --- /dev/null +++ b/.github/actions/updater-flags/action.yml @@ -0,0 +1,35 @@ +name: Resolve updater signing +description: >- + Decides whether this build should produce signed in-app updater artifacts. + `bundle.createUpdaterArtifacts` is a hard error when TAURI_SIGNING_PRIVATE_KEY + is missing, so it lives in tauri.updater.conf.json and is only merged in when + the secret actually exists. Without it, builds are byte-identical to before and + the app reports the `unconfigured` update status instead of offering a download + it could never verify. + +outputs: + config: + description: >- + Either the `--config ` flag to append to `tauri build`, or empty. + value: ${{ steps.resolve.outputs.config }} + signed: + description: '"true" when updater artifacts will be produced.' + value: ${{ steps.resolve.outputs.signed }} + +runs: + using: composite + steps: + - id: resolve + shell: bash + run: | + set -euo pipefail + + if [[ -n "${TAURI_SIGNING_PRIVATE_KEY:-}" ]]; then + echo 'config=--config src-tauri/tauri.updater.conf.json' >> "$GITHUB_OUTPUT" + echo 'signed=true' >> "$GITHUB_OUTPUT" + echo "Updater artifacts enabled for this build." + else + echo 'config=' >> "$GITHUB_OUTPUT" + echo 'signed=false' >> "$GITHUB_OUTPUT" + echo "::notice title=No updater signing key::TAURI_SIGNING_PRIVATE_KEY is not set, so this build ships without in-app update artifacts. Generate a keypair per docs/SIGNING.md to enable them." + fi diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 3ea65ce..db5b493 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -25,6 +25,11 @@ env: CARGO_HTTP_MULTIPLEXING: 'false' CARGO_HTTP_TIMEOUT: '60' CARGO_NET_RETRY: '10' + # Signs the in-app updater artifacts (minisign; unrelated to OS code signing). + # Absent until the keypair exists, and every job below degrades to a plain + # unsigned build when it is — see the "Resolve updater signing" steps. + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} jobs: macos: @@ -40,7 +45,16 @@ jobs: workspaces: src-tauri - run: bun install --frozen-lockfile - - run: bun run build + + - id: updater + uses: ./.github/actions/updater-flags + + # These three mirror the `build` script in package.json. They are spelled out + # here only so the updater `--config` flag can be injected into the middle + # one; keep them in step with that script. + - run: bun run typecheck + - run: bun run tauri build --bundles app ${{ steps.updater.outputs.config }} + - run: bun run dmg - uses: actions/upload-artifact@v4 with: @@ -49,6 +63,18 @@ jobs: path: src-tauri/target/release/bundle/dmg/*.dmg if-no-files-found: error + # The in-app updater installs this tarball, not the DMG: it replaces the + # bundle in place, so it must not be wrapped in a disk image. + - if: steps.updater.outputs.signed == 'true' + uses: actions/upload-artifact@v4 + with: + name: aether-macos-updater + retention-days: 14 + path: | + src-tauri/target/release/bundle/macos/*.app.tar.gz + src-tauri/target/release/bundle/macos/*.app.tar.gz.sig + if-no-files-found: error + linux: name: Linux (x86_64) runs-on: ubuntu-latest @@ -71,7 +97,13 @@ jobs: - run: bun install --frozen-lockfile - run: bun run typecheck:web - - run: bun run tauri build --bundles deb,appimage + # Unit tests are platform-independent, so one runner is enough. + - run: bun run test + + - id: updater + uses: ./.github/actions/updater-flags + + - run: bun run tauri build --bundles deb,appimage ${{ steps.updater.outputs.config }} - run: scripts/normalize-deb-package.sh src-tauri/target/release/bundle/deb/*.deb - uses: actions/upload-artifact@v4 @@ -83,6 +115,17 @@ jobs: src-tauri/target/release/bundle/appimage/*.AppImage if-no-files-found: error + # AppImage only. A .deb install is owned by the package manager, so ÆTHER + # refuses to self-update there (see updater_install_support in lib.rs) and + # publishing a signature for one would be misleading. + - if: steps.updater.outputs.signed == 'true' + uses: actions/upload-artifact@v4 + with: + name: aether-linux-x86_64-updater + retention-days: 14 + path: src-tauri/target/release/bundle/appimage/*.AppImage.sig + if-no-files-found: error + linux-arm64: name: Linux (ARM64) runs-on: ubuntu-24.04-arm @@ -132,7 +175,11 @@ jobs: - run: bun install --frozen-lockfile - run: bun run typecheck:web - - run: bun run tauri build --bundles nsis + + - id: updater + uses: ./.github/actions/updater-flags + + - run: bun run tauri build --bundles nsis ${{ steps.updater.outputs.config }} - uses: actions/upload-artifact@v4 with: @@ -143,6 +190,14 @@ jobs: src-tauri/target/release/bundle/msi/*.msi if-no-files-found: error + - if: steps.updater.outputs.signed == 'true' + uses: actions/upload-artifact@v4 + with: + name: aether-windows-x86_64-updater + retention-days: 14 + path: src-tauri/target/release/bundle/nsis/*.exe.sig + if-no-files-found: error + android: name: Android (APK) if: github.event_name == 'workflow_dispatch' @@ -196,6 +251,10 @@ jobs: permissions: contents: write steps: + # Needed for scripts/updater-manifest.sh; this job previously only consumed + # artifacts and so did not check out the repository. + - uses: actions/checkout@v4 + - name: Download all build artifacts uses: actions/download-artifact@v4 with: @@ -230,6 +289,24 @@ jobs: copy_first_from_artifact "aether-linux-x86_64" "*.AppImage" "AETHER_amd64.AppImage" copy_first_from_artifact "aether-linux-arm64" "*.deb" "AETHER_arm64.deb" + # The updater downloads this tarball rather than the DMG. Renamed off + # "ÆTHER.app.tar.gz" because a non-ASCII release asset name has to be + # percent-encoded in latest.json, and that is a needless way to fail. + copy_first_from_artifact "aether-macos-updater" "*.app.tar.gz" "AETHER_macOS.app.tar.gz" + + # Writes nothing when no signing key produced signatures, which is the normal + # state before the keypair exists — the release then publishes installers + # only and the app reports the `unconfigured` update status. + - name: Build updater manifest + shell: bash + run: | + scripts/updater-manifest.sh \ + "$GITHUB_REF_NAME" "$GITHUB_REPOSITORY" artifacts release-assets/latest.json + + if [[ ! -f release-assets/latest.json ]]; then + echo "::warning title=No updater manifest::No updater signatures were produced, so latest.json is not published and in-app updates stay disabled for this release." + fi + - name: Create release and attach installers uses: softprops/action-gh-release@v2 with: diff --git a/.planning/debug/resolved/ice-interaction-crystallization.md b/.planning/debug/resolved/ice-interaction-crystallization.md new file mode 100644 index 0000000..dc4b5df --- /dev/null +++ b/.planning/debug/resolved/ice-interaction-crystallization.md @@ -0,0 +1,45 @@ +--- +status: resolved +trigger: "iCE card clicks teleport cards to the top-left instead of centering with a slight zoom; Ordered Topics centers without zoom; crystallization intermittently fails for Quantum; percentage labels and Open in Library should be removed." +created: 2026-07-25T20:55:39+0200 +updated: 2026-07-25T21:38:00+0200 +--- + +## Symptoms + +- expected: Clicking a canvas card or Ordered Topics entry centers that card and slightly zooms the canvas. +- actual: Canvas-card clicks teleport the raised card to the top-left. Ordered Topics centers without zoom. +- errors: The app reports "Crystallization failed." Intermittently reproducible with "Quantum". +- timeline: These behaviors worked on the main branch and regressed during the overhaul. +- reproduction: Generate an iCE map, click canvas cards and Ordered Topics entries, then repeatedly crystallize "Quantum". +- requested cleanup: Remove floating percentage labels and the "Open in Library" action. + +## Current Focus + +- hypothesis: confirmed +- test: complete +- expecting: fixed +- next_action: user verification in the native app +- reasoning_checkpoint: The first fix moved handlers onto the foreignObject content but left the unsupported transform path in place. It addressed neither the WebKit relocation trigger nor native SVG hit testing. +- tdd_checkpoint: Added a regression test for recovering complete iceberg items from truncated model output. + +## Evidence + +- Both canvas cards and the canvas pan handler received events through an SVG `foreignObject`. WebKit did not reliably resolve the previous ancestor guard across that namespace boundary. +- Ordered Topics already used the correct `selectItem` and `focusItem` path. Canvas cards now call the same path directly from their native HTML button. +- The normalizer required a closing array bracket. A model response cut off during its final item discarded every complete item generated before it. +- Generation had a single attempt and a 4200-token output ceiling despite requesting up to 45 richly annotated items. +- The first interaction fix left selected, hover, and focus transforms on the `foreignObject`. That is the WebKit operation responsible for relocating cards to SVG origin. +- Direct pointer handling inside the embedded HTML remained dependent on events crossing the HTML/SVG namespace boundary. + +## Eliminated + +- The pan/zoom centering formula was not the source of the top-left card jump. +- Topic coverage lookup was not involved in canvas selection. + +## Resolution + +- root_cause: Canvas presses crossed an unreliable HTML-in-SVG event boundary, while selected and focused states transformed the `foreignObject` and triggered WebKit's top-left relocation bug. The 172% focus target was also too weak to communicate focus consistently. Truncated local-model JSON had no recovery or retry path. +- fix: Made embedded HTML presentation-only, added a native SVG hit rectangle, restored SVG-native click and keyboard handling, removed every local transform from the `foreignObject`, and raised the shared canvas/sidebar focus target to 190%. Also added balanced-object recovery for truncated arrays, one compact low-temperature retry, a 5200-token generation allowance, concrete backend errors, and the requested UI removals. +- verification: `bun run typecheck:web`, `bun run lint`, `bun run build:vite`, and `cargo test --lib` all pass. Rust result: 64 passed, 0 failed, 2 ignored. The new truncated-output regression test passes. +- files_changed: src/renderer/src/components/Crystallizer.tsx, src/renderer/src/assets/styles/crystallizer.css, src/renderer/src/App.tsx, src-tauri/src/iceberg.rs, src-tauri/src/inference.rs, src-tauri/src/lib.rs diff --git a/README.md b/README.md index fdb5644..f34a586 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,56 @@ ÆTHER dashboard with portals, knowledge hubs, saved iCE atlases, and AiON

+## Install + +Download the latest build for your platform from +**[Releases](https://github.com/CanPixel/aether/releases/latest)**: + +| Platform | File | +|---|---| +| macOS (Apple Silicon, 11+) | `AETHER_macOS.dmg` | +| Windows (x86_64) | `AETHER_x64-setup.exe` | +| Linux (x86_64) | `AETHER_amd64.deb` · `AETHER_amd64.AppImage` | +| Linux (ARM64) | `AETHER_arm64.deb` | + +> [!NOTE] +> **Intel Macs are not supported.** Releases are built `arm64` only, and Rosetta +> translates Intel binaries to Apple Silicon — not the other way round — so there is +> no way to run this DMG on an Intel Mac. Building `universal-apple-darwin` would fix +> it at the cost of doubling an already slow llama.cpp compile. + +> [!IMPORTANT] +> **Releases are not yet code-signed**, so your OS will block the first launch. This +> is a signing status, not a warning about the app — but you should only work around +> it for software you actually trust, and you can verify what ÆTHER does by reading +> this repository. Signing and notarization are the top roadmap item; the setup guide +> is in [docs/SIGNING.md](docs/SIGNING.md). + +**macOS.** The `.dmg` is unsigned and un-notarized, so macOS quarantines it and +reports *"ÆTHER is damaged and can't be opened"*. It is not damaged. Drag the app to +`/Applications`, then clear the quarantine flag: + +```bash +xattr -dr com.apple.quarantine /Applications/ÆTHER.app +``` + +Then open it normally. (Right-click → *Open* alone does not work for un-notarized +apps on current macOS.) + +**Windows.** SmartScreen shows *"Windows protected your PC"*. Click **More info**, +then **Run anyway**. + +**Linux.** No workaround needed. + +```bash +sudo dpkg -i AETHER_amd64.deb # or: chmod +x AETHER_amd64.AppImage +``` + +After first launch, ÆTHER asks you to install a local model. Only **AiON MiST** +(639 MB) is required — capture, search, and cited passage retrieval all work with it +alone. The chat models are optional and can be added later from the model picker. +See [Local Wisdom Setup](#local-wisdom-setup). + ## The Æther That Is > **New here?** Read the plain-language introduction: [What is ÆTHER?](docs/WHAT-IS-AETHER.md) @@ -87,15 +137,23 @@ Fresh installs use **AiON Launch**, the in-app setup flow for downloading local | Model | Role | Official source | Size | | ------------- | ------------------------------------------------------------- | ------------------------------------- | -------: | | **AiON MiST** | Required embedding model for search, capture, and retrieval | `Qwen/Qwen3-Embedding-0.6B-GGUF` | ~0.64 GB | -| **AiON LiTE** | Smaller, faster chat model for everyday answers and summaries | `google/gemma-4-E2B-it-qat-q4_0-gguf` | ~3.35 GB | -| **AiON WiSE** | Larger chat model for richer synthesis and iCE maps | `google/gemma-4-E4B-it-qat-q4_0-gguf` | ~5.15 GB | +| **AiON LiTE** | Optional chat model for everyday answers and summaries | `google/gemma-4-E2B-it-qat-q4_0-gguf` | ~3.35 GB | +| **AiON WiSE** | Optional chat model for richer synthesis and iCE maps | `google/gemma-4-E4B-it-qat-q4_0-gguf` | ~5.15 GB | Install choices: -- **MiST + LiTE**: best default for laptops, mobile-class hardware, and quick grounded answers. -- **MiST + WiSE**: better for deeper synthesis, longer answers, and iCE generation. +- **MiST alone (~0.64 GB)**: the smallest complete install. Capture, semantic search + across hubs, Flow, and Ask all work — Ask returns the best-matching passages from + your sources with citations instead of a written answer. Chat models can be added + later from Settings without re-indexing anything. +- **MiST + LiTE**: adds written answers. Best default for laptops and mobile-class + hardware. +- **MiST + WiSE**: deeper synthesis, longer answers, and iCE map generation. - **MiST + LiTE + WiSE**: lets the user switch between speed and depth. +Only **iCE map generation** strictly requires a chat model; everything else degrades +to retrieval rather than failing. + Manual development installs can also place models here: ```text @@ -263,11 +321,21 @@ Current storage paths: ```text /aether-library/library.json /aether-realms/chunks.json +/aether-realms/chunks.vec /aether-settings/settings.json /aether-icebergs/icebergs.json +/aether-conversations/conversations.json +/aether-session/session.json +/aether-backups/aether-export-/ ./aether-models/ ``` +Every store is written temp-file-then-rename and keeps the previous good copy as +`.bak`. On load, an unreadable store falls back to its backup, and the damaged +file is preserved as `.corrupt-` rather than overwritten. Settings +also offers **Export Library**, which snapshots every store into a timestamped folder +under `aether-backups/`. + `library.json` stores: - Knowledge hub summaries. @@ -277,10 +345,11 @@ Current storage paths: - Chunk counts. - Legacy migration flags. -`chunks.json` stores embedded chunk rows: +`chunks.json` stores chunk **metadata** (store format v2): - `id` -- `vector` +- `vectorSlot` — index into `chunks.vec` +- `needsReembed` — set when the chunk's text is kept but its vector is unusable - `text` - `collectionId` - `captureId` @@ -290,6 +359,40 @@ Current storage paths: - `capturedAt` - `chunkIndex` +`chunks.vec` stores the embeddings themselves as raw little-endian `f32`, at a fixed +stride of `dim * 4` bytes per slot. Keeping vectors out of JSON matters at scale: as +decimal text a 1024-dim vector costs roughly 12 KB versus 4 KB binary, and — more +importantly — the sidecar is **append-only**, so capturing a page writes only the new +vectors instead of re-serializing every vector in the library. Deleting sources leaves +dead slots behind; once they exceed half the file, the store compacts and renumbers. + +A v1 `chunks.json` (vectors inline) is migrated automatically on first load. The +untouched original is archived as `chunks.v1.json` — a name no ordinary save touches, +unlike `.bak`, which is one generation deep and would be recycled by the next capture. + +### Changing the embedding model + +The store holds exactly one embedding width, because the sidecar has a fixed stride. +A v1 store written across a model change therefore holds vectors that cannot all fit: +the migration keeps the width the most chunks use and **parks** the rest — their text +is retained and `needsReembed` is set, so they are simply invisible to semantic search +rather than deleted. + +Vectors from different models cannot be compared at all (cosine distance is undefined +across widths), so parked chunks — and any chunk embedded by a superseded model — are +recovered by **Settings → Re-index Library**. Because chunk text lives in the store, +re-indexing is local compute: no page is fetched again. + +`conversations.json` stores the AiON thread per knowledge hub (plus one thread for +current-page-only asks): prompt, answer, model, citations, and metrics for each turn. +The most recent turns are replayed into the prompt so follow-up questions work, and +each thread keeps its last 40 turns. + +`session.json` stores the open browser tabs, the active tab, and the window size and +position, so quitting no longer discards them. It is written after every tab change +rather than at exit, because the app force-exits on quit (see the llama.cpp Metal note) +and never reaches a shutdown hook. + `settings.json` stores app preferences such as the default search engine, Developer Mode, and selected local model paths. `icebergs.json` stores manually saved iCE generations: @@ -396,6 +499,30 @@ Renderer responsibilities: --- +## Tests + +```bash +bun run test +``` + +Runs the Rust unit suite (also run in CI on the Linux job). Alongside the usual unit +tests it includes a **retrieval eval**: fixture documents are pushed through the real +pipeline — `split_text` → `push_chunks` → the binary vector store → `rank_library_hits` +— and each question must surface its expected source in the top three, with a stricter +top-one assertion for distinctive terms. + +The eval substitutes a deterministic hashed bag-of-words embedder for the real model, +because the embedding model is a ~640 MB download that CI cannot fetch. That still +covers every model-independent way retrieval regresses — chunk boundaries and overlap, +vector/slot alignment in the sidecar, per-capture grouping, ordering stability, scoping +and limits — but it does **not** judge model quality. + +To check the real model is wired up locally: + +```bash +AETHER_EMBEDDING_MODEL=/path/to/embedding.gguf cargo test --manifest-path src-tauri/Cargo.toml --lib real_model -- --ignored +``` + ## Development Prerequisites Required: @@ -730,18 +857,19 @@ iCE depends on the local chat model returning parseable JSON. Try: ## Current Limitations -- macOS packages are local unsigned/ad-hoc builds until Developer ID signing and notarization are configured. +- Tab favicons are fetched directly from each site by the privileged window, so that one request per host is not local. See [docs/SECURITY.md](docs/SECURITY.md). +- Releases are unsigned on macOS and Windows, so the first launch has to be unblocked manually — see [Install](#install) for the steps and [docs/SIGNING.md](docs/SIGNING.md) for the fix. - Capture quality depends on page structure, active webview snapshots, and fallback HTTP extraction quality. - App-like authenticated services can still have browser API or popup edge cases. - iCE generation depends on local model quality and JSON compliance. -- Update checks notify about newer app releases, but they do not download or install updates yet. +- In-app updates are implemented but inert until an updater signing keypair exists; until then the Install button explains that it cannot verify a download. `.deb`/`.rpm` and Linux ARM64 installs never self-update by design. See [docs/SIGNING.md](docs/SIGNING.md#in-app-updates--minisign). - Search and Ask currently use one selected hub plus optional current page, not arbitrary multi-hub selection. ## Roadmap Ideas Likely next improvements: -- Production signing and notarization flow. +- Production signing and notarization flow ([setup guide](docs/SIGNING.md)), and generating the updater keypair that switches in-app updates on. - Import/export for knowledge hubs. - Full capture library view with filtering and bulk actions. - Per-hub retrieval/model settings. diff --git a/bun.lock b/bun.lock index 1825352..430a666 100644 --- a/bun.lock +++ b/bun.lock @@ -6,121 +6,75 @@ "name": "aether-browser", "dependencies": { "ldrs": "^1.1.9", - "lucide-react": "^1.17.0", + "lucide-react": "^1.26.0", }, "devDependencies": { - "@eslint/js": "^9.39.4", - "@tauri-apps/api": "^2.11.0", - "@tauri-apps/cli": "^2.11.2", + "@babel/core": "^8.0.1", + "@eslint/js": "^9.39.5", + "@tauri-apps/api": "^2.11.1", + "@tauri-apps/cli": "^2.11.4", "@types/bun": "^1.3.14", - "@types/node": "^25.9.1", - "@types/react": "^19.2.15", + "@types/node": "^25.9.5", + "@types/react": "^19.2.17", "@types/react-dom": "^19.2.3", - "@vitejs/plugin-react": "^6.0.2", + "@vitejs/plugin-react": "^6.0.4", "appdmg": "^0.6.6", - "eslint": "^10.4.0", + "brace-expansion": "^5.0.8", + "eslint": "^10.8.0", "eslint-config-prettier": "^10.1.8", "eslint-plugin-react-hooks": "^7.1.1", - "eslint-plugin-react-refresh": "^0.5.2", - "prettier": "^3.8.3", - "react": "^19.2.6", - "react-dom": "^19.2.6", + "eslint-plugin-react-refresh": "^0.5.3", + "prettier": "^3.9.6", + "react": "^19.2.8", + "react-dom": "^19.2.8", "typescript": "^6.0.3", - "typescript-eslint": "^8.59.2", - "vite": "^8.0.14", + "typescript-eslint": "^8.65.0", + "vite": "^8.1.5", }, }, }, + "overrides": { + "@babel/core": "^7.29.1", + "brace-expansion": "^5.0.7", + }, "packages": { - "@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="], - - "@babel/compat-data": ["@babel/compat-data@7.29.3", "", {}, "sha512-LIVqM46zQWZhj17qA8wb4nW/ixr2y1Nw+r1etiAWgRM6U1IqP+LNhL1yg440jYZR72jCWcWbLWzIosH+uP1fqg=="], - - "@babel/core": ["@babel/core@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-module-transforms": "^7.28.6", "@babel/helpers": "^7.28.6", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/traverse": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA=="], - - "@babel/generator": ["@babel/generator@7.29.1", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw=="], - - "@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.28.6", "", { "dependencies": { "@babel/compat-data": "^7.28.6", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA=="], - - "@babel/helper-globals": ["@babel/helper-globals@7.28.0", "", {}, "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw=="], - - "@babel/helper-module-imports": ["@babel/helper-module-imports@7.28.6", "", { "dependencies": { "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw=="], - - "@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.28.6", "", { "dependencies": { "@babel/helper-module-imports": "^7.28.6", "@babel/helper-validator-identifier": "^7.28.5", "@babel/traverse": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA=="], - - "@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="], - - "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], - - "@babel/helper-validator-option": ["@babel/helper-validator-option@7.27.1", "", {}, "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg=="], - - "@babel/helpers": ["@babel/helpers@7.29.2", "", { "dependencies": { "@babel/template": "^7.28.6", "@babel/types": "^7.29.0" } }, "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw=="], - - "@babel/parser": ["@babel/parser@7.29.3", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": { "parser": "bin/babel-parser.js" } }, "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA=="], - - "@babel/template": ["@babel/template@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="], - - "@babel/traverse": ["@babel/traverse@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/types": "^7.29.0", "debug": "^4.3.1" } }, "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA=="], - - "@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], - - "@emnapi/core": ["@emnapi/core@1.10.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="], - - "@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="], + "@babel/code-frame": ["@babel/code-frame@7.29.7", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="], - "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="], + "@babel/compat-data": ["@babel/compat-data@7.29.7", "", {}, "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg=="], - "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="], + "@babel/core": ["@babel/core@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", "@babel/helper-compilation-targets": "^7.29.7", "@babel/helper-module-transforms": "^7.29.7", "@babel/helpers": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/template": "^7.29.7", "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA=="], - "@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="], + "@babel/generator": ["@babel/generator@7.29.7", "", { "dependencies": { "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ=="], - "@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.12", "", { "os": "android", "cpu": "arm64" }, "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg=="], + "@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.29.7", "", { "dependencies": { "@babel/compat-data": "^7.29.7", "@babel/helper-validator-option": "^7.29.7", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g=="], - "@esbuild/android-x64": ["@esbuild/android-x64@0.25.12", "", { "os": "android", "cpu": "x64" }, "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg=="], + "@babel/helper-globals": ["@babel/helper-globals@7.29.7", "", {}, "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA=="], - "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg=="], + "@babel/helper-module-imports": ["@babel/helper-module-imports@7.29.7", "", { "dependencies": { "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g=="], - "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA=="], + "@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.29.7", "", { "dependencies": { "@babel/helper-module-imports": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7", "@babel/traverse": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg=="], - "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg=="], + "@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="], - "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ=="], + "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="], - "@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.12", "", { "os": "linux", "cpu": "arm" }, "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw=="], + "@babel/helper-validator-option": ["@babel/helper-validator-option@7.29.7", "", {}, "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw=="], - "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ=="], + "@babel/helpers": ["@babel/helpers@7.29.7", "", { "dependencies": { "@babel/template": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg=="], - "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="], + "@babel/parser": ["@babel/parser@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" }, "bin": "./bin/babel-parser.js" }, "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg=="], - "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng=="], + "@babel/template": ["@babel/template@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg=="], - "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw=="], + "@babel/traverse": ["@babel/traverse@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", "@babel/helper-globals": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/template": "^7.29.7", "@babel/types": "^7.29.7", "debug": "^4.3.1" } }, "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw=="], - "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA=="], + "@babel/types": ["@babel/types@7.29.7", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA=="], - "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w=="], + "@emnapi/core": ["@emnapi/core@1.11.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" } }, "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ=="], - "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg=="], + "@emnapi/runtime": ["@emnapi/runtime@1.11.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw=="], - "@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.12", "", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="], - - "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg=="], - - "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.12", "", { "os": "none", "cpu": "x64" }, "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ=="], - - "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.12", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A=="], - - "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw=="], - - "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg=="], - - "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w=="], - - "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg=="], - - "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ=="], - - "@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="], + "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA=="], "@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.9.1", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ=="], @@ -128,15 +82,15 @@ "@eslint/config-array": ["@eslint/config-array@0.23.5", "", { "dependencies": { "@eslint/object-schema": "^3.0.5", "debug": "^4.3.1", "minimatch": "^10.2.4" } }, "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA=="], - "@eslint/config-helpers": ["@eslint/config-helpers@0.6.0", "", { "dependencies": { "@eslint/core": "^1.2.1" } }, "sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA=="], + "@eslint/config-helpers": ["@eslint/config-helpers@0.7.0", "", { "dependencies": { "@eslint/core": "^1.2.1" } }, "sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw=="], "@eslint/core": ["@eslint/core@1.2.1", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ=="], - "@eslint/js": ["@eslint/js@9.39.4", "", {}, "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw=="], + "@eslint/js": ["@eslint/js@9.39.5", "", {}, "sha512-QywQuszQh77pIXCsq998c8hbhSTI/azTty1Z6N53dmAudKHhy573j3yvRLsX2BSp8YpLtoCEG8E9DJe+8zUh4A=="], "@eslint/object-schema": ["@eslint/object-schema@3.0.5", "", {}, "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw=="], - "@eslint/plugin-kit": ["@eslint/plugin-kit@0.7.1", "", { "dependencies": { "@eslint/core": "^1.2.1", "levn": "^0.4.1" } }, "sha512-rZAP3aVgB9ds9KOeUSL+zZ21hPmo8dh6fnIFwRQj5EAZl9gzR7wxYbYXYysAM8CTqGmUGyp2S4kUdV17MnGuWQ=="], + "@eslint/plugin-kit": ["@eslint/plugin-kit@0.7.2", "", { "dependencies": { "@eslint/core": "^1.2.1", "levn": "^0.4.1" } }, "sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A=="], "@humanfs/core": ["@humanfs/core@0.19.2", "", { "dependencies": { "@humanfs/types": "^0.15.0" } }, "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA=="], @@ -158,69 +112,69 @@ "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], - "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.4", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow=="], + "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.6", "", { "dependencies": { "@tybys/wasm-util": "^0.10.3" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg=="], - "@oxc-project/types": ["@oxc-project/types@0.132.0", "", {}, "sha512-FESMOxil5Se014ui/Eq8fT5uHJo6nIRwH0PfJrZJXs6Gek3ZVFOrpUv3YIZT20m+extU98Hg1Ym72U58rlsxUQ=="], + "@oxc-project/types": ["@oxc-project/types@0.139.0", "", {}, "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw=="], - "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.2", "", { "os": "android", "cpu": "arm64" }, "sha512-ZS4D1JPGn/MYQN/SYDWftIE/nVsM8j/AFOYEzAoOE2O3NktQOZru+/vYXGbR/qtdLdIfGCP0lcoJiYVzsEz+iQ=="], + "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.1.5", "", { "os": "android", "cpu": "arm64" }, "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ=="], - "@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-vdFA9+C/rekyGce7WqHs/xoT0ioZEWaOFyZLIV1mEeNFaFDUQrPIo8Vs2GvJ6eetb3rzDUtUBgzto3ExpXJB3w=="], + "@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.1.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw=="], - "@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.0.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-BewSOwTHazv77DTYiAZXSqqKZ4KP/KonFisDMVU7PImxoWfB2aepnPhd2E4SWz3zDzYgDNbs6jBmTdgNnF02GA=="], + "@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.1.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g=="], - "@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.0.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-m41o7M0YWtUdqk61Tb+jnKb2rN++iRdIASlExkUoKfIAH30DOHCB8fVLzSUpbWHHU8esmEioY62PxzexE8MBuA=="], + "@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.1.5", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA=="], - "@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.0.2", "", { "os": "linux", "cpu": "arm" }, "sha512-jcojB9H7W/jS29pMKWAK1N+fU99vXodHDTatS3b3y/XSOCiHo0kkA74pL3jJmkoQtYpOCxDvaKs1fo2Ij/1X5w=="], + "@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.1.5", "", { "os": "linux", "cpu": "arm" }, "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw=="], - "@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.0.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-1jn6qDU5iiOgFgygDzKUuKP0maTi0/f1+sBLgvij/76C77Nm3ts6ufz9Bjg5q5dduxiUIxtq86JIoBvo1xQ4Ig=="], + "@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.1.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q=="], - "@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.0.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-QVLO/czFMdoMFSqlX3bcswcJNm/23r+qoa/jgtmFc/qEp6/jXmIkDjF/XIo8dPfGaiwy1xfQn8o77L79GeXFgw=="], + "@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.1.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA=="], - "@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.0.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-hgO5Abm0w5UL6FEa2iFnZqo2KlK7TQ5QhV5x09hujBf7t5KzHQ1VmfPuTpqRy/rNlSxua3eWH374xxiVrP+lcA=="], + "@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.1.5", "", { "os": "linux", "cpu": "ppc64" }, "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg=="], - "@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.0.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-fy8rXxuYEu602abC8MUNaPjYLIFzReOaEIEMKMUa0rFEUxNpVXhs15KSSQ4qlqSaM7B6rcj9rDZgADh/IGDzLQ=="], + "@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.1.5", "", { "os": "linux", "cpu": "s390x" }, "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA=="], - "@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.0.2", "", { "os": "linux", "cpu": "x64" }, "sha512-0+bOkiQ779+r1WpoHOWHqncvyySci0vKph+myNDYb+im6meJAzHQXay6oEgnkHuUGouM1LKTZwqKpBow6Kj7CQ=="], + "@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.1.5", "", { "os": "linux", "cpu": "x64" }, "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ=="], - "@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.0.2", "", { "os": "linux", "cpu": "x64" }, "sha512-mjSkrzZK5Qsl0a9d1JgILOiuZOSDTVdKENcSXBoqbzSrspLR/4/IRVDo5wd2GgZjNss/viBFJdeq+j7qH2nypw=="], + "@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.1.5", "", { "os": "linux", "cpu": "x64" }, "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg=="], - "@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.0.2", "", { "os": "none", "cpu": "arm64" }, "sha512-1v5vHasdfQAZoEHakBV72LIFAC9JjnymsiKxp+GEr/ma3+NJCPSaYK+qavInOovJkgwFrs7GccX2d6IgDA3Z5w=="], + "@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.1.5", "", { "os": "none", "cpu": "arm64" }, "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw=="], - "@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.0.2", "", { "dependencies": { "@emnapi/core": "1.10.0", "@emnapi/runtime": "1.10.0", "@napi-rs/wasm-runtime": "^1.1.4" }, "cpu": "none" }, "sha512-mb1VobWn6NheziTk5/WEaR6AKVbrwT5sOi6C7zk3gy/pD1qtJfU1j4PgTo2NJnOtbL9Dl3Aeei8w9jJ7qC2jZQ=="], + "@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.1.5", "", { "dependencies": { "@emnapi/core": "1.11.1", "@emnapi/runtime": "1.11.1", "@napi-rs/wasm-runtime": "^1.1.6" }, "cpu": "none" }, "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA=="], - "@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.0.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-SqKonF56vA/L2yHwHYcEp2P34URpOZ7d1fS635cTkpDnUtEGdUbhI6NzsPdqeSWvAAeGDrxjWjNmibDIdFf9/A=="], + "@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.1.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw=="], - "@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.0.2", "", { "os": "win32", "cpu": "x64" }, "sha512-v7qRI7gXLRINcOGXt+7YmAZ6iFuyZVMIoXAxhd8oP+DR9dLfL9GfNIx7PLMxmhZdvq8waUJBQiWN9EKNy+TRBQ=="], + "@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.1.5", "", { "os": "win32", "cpu": "x64" }, "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA=="], "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="], - "@tauri-apps/api": ["@tauri-apps/api@2.11.0", "", {}, "sha512-7CinYODhky9lmO23xHnUFv0Xt43fbtWMyxZcLcRBlFkcgXKuEirBvHpmtJ89YMhyeGcq20Wuc47Fa4XjyniywA=="], + "@tauri-apps/api": ["@tauri-apps/api@2.11.1", "", {}, "sha512-M2FPuYND2m+wh5hfW9ZpSdxMPdEJovPBWwoHJmwUpysTYNHaOkVFN419m/K0LIgjb/7KU2vBgsUepJWugQCvAA=="], - "@tauri-apps/cli": ["@tauri-apps/cli@2.11.2", "", { "optionalDependencies": { "@tauri-apps/cli-darwin-arm64": "2.11.2", "@tauri-apps/cli-darwin-x64": "2.11.2", "@tauri-apps/cli-linux-arm-gnueabihf": "2.11.2", "@tauri-apps/cli-linux-arm64-gnu": "2.11.2", "@tauri-apps/cli-linux-arm64-musl": "2.11.2", "@tauri-apps/cli-linux-riscv64-gnu": "2.11.2", "@tauri-apps/cli-linux-x64-gnu": "2.11.2", "@tauri-apps/cli-linux-x64-musl": "2.11.2", "@tauri-apps/cli-win32-arm64-msvc": "2.11.2", "@tauri-apps/cli-win32-ia32-msvc": "2.11.2", "@tauri-apps/cli-win32-x64-msvc": "2.11.2" }, "bin": { "tauri": "tauri.js" } }, "sha512-bk3HemqvGRoy+5D/dVMUQHKMYLglD0jVnMm/0iGMH6ufZ+p8r14m6BpIixwij3PBvZdvORUp1YifTD8QxVZ1Nw=="], + "@tauri-apps/cli": ["@tauri-apps/cli@2.11.4", "", { "optionalDependencies": { "@tauri-apps/cli-darwin-arm64": "2.11.4", "@tauri-apps/cli-darwin-x64": "2.11.4", "@tauri-apps/cli-linux-arm-gnueabihf": "2.11.4", "@tauri-apps/cli-linux-arm64-gnu": "2.11.4", "@tauri-apps/cli-linux-arm64-musl": "2.11.4", "@tauri-apps/cli-linux-riscv64-gnu": "2.11.4", "@tauri-apps/cli-linux-x64-gnu": "2.11.4", "@tauri-apps/cli-linux-x64-musl": "2.11.4", "@tauri-apps/cli-win32-arm64-msvc": "2.11.4", "@tauri-apps/cli-win32-ia32-msvc": "2.11.4", "@tauri-apps/cli-win32-x64-msvc": "2.11.4" }, "bin": { "tauri": "tauri.js" } }, "sha512-R8xGtMpwyetawSqm9kYOuMmEqkhUbvcUy8n0aNXIxollKBLESUu5f4Fx+64hgASYm1H+jSWq6jCW6zqTnH6hqQ=="], - "@tauri-apps/cli-darwin-arm64": ["@tauri-apps/cli-darwin-arm64@2.11.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-+4UZzLt+eOAEQCwgd+TqKgyUJMrvx+BgdXLLaqJYmPqzP+nE6YZr/hY6CWLYGQb8jFn99jEkmC6uA3tNvamA1w=="], + "@tauri-apps/cli-darwin-arm64": ["@tauri-apps/cli-darwin-arm64@2.11.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-1ryOF3ZhpZ/nemHV5zVwBQBz9jDGKmKPvWPADOhc83ig0P4bMc2iER4NbC6r9sjeIZ6RVQ4g3RZIYvezhcl4TQ=="], - "@tauri-apps/cli-darwin-x64": ["@tauri-apps/cli-darwin-x64@2.11.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-VjYYtZUPqDMLutSfJEyxFE3Bz+DPi7c8wC3imckgvciLDZLq4qwKJxBicg0BXGhXjJsl8vKWgWRFNMPELQ+Xyg=="], + "@tauri-apps/cli-darwin-x64": ["@tauri-apps/cli-darwin-x64@2.11.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-uFsGQAAfuyz1k/yGLmkWfkBlgKAqZfxqlHmLWx81QU27RJWfmbNHCIq8T8w1e+VClleIuZUjpHWfoE4E3DLo3A=="], - "@tauri-apps/cli-linux-arm-gnueabihf": ["@tauri-apps/cli-linux-arm-gnueabihf@2.11.2", "", { "os": "linux", "cpu": "arm" }, "sha512-yMemD6f4i95AQriS8EazyOFzbE34yjnP16i3IOzpHGQvBoy2DjypFMFBq0NtPuITURv/cOGguRtHR5d79/9CSA=="], + "@tauri-apps/cli-linux-arm-gnueabihf": ["@tauri-apps/cli-linux-arm-gnueabihf@2.11.4", "", { "os": "linux", "cpu": "arm" }, "sha512-IaHZn5CdBL21oUmjiVOS1ctw6Ip1O0pjp70FwOWmYz1myWe0SY96ZIj2FYf7pT0m8bI2h/hrs5ZbEXXh44/MkQ=="], - "@tauri-apps/cli-linux-arm64-gnu": ["@tauri-apps/cli-linux-arm64-gnu@2.11.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-cgI91D2wL8GSgoWwZXDqt+DwnuZCP2/bz03QAE4TrhgAKIsrB4hX26W/H1EONPUUNkqrsgeCD0wU6pcNjV/5kw=="], + "@tauri-apps/cli-linux-arm64-gnu": ["@tauri-apps/cli-linux-arm64-gnu@2.11.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-N41/ukTRVe6XSuUTESuFdGeOW2i7k62tK+6gHK5Kd5/q5RPvvi19GaWAVPPb9u95HSGmTChSolBfzynUsssFaA=="], - "@tauri-apps/cli-linux-arm64-musl": ["@tauri-apps/cli-linux-arm64-musl@2.11.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-X1rm0BERqAAggtYTESSgXrS3sz4Sb/OiPiz54UqISlXW+GkR3vNIGnsy/lejNmoXGVqri3Q53BCfQiclOIyRPw=="], + "@tauri-apps/cli-linux-arm64-musl": ["@tauri-apps/cli-linux-arm64-musl@2.11.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-v277UnT/fB64xAfSroL5N3Km3tLmvATWqJJw/wRI+g6o+HkeD0slyE7gOhNs1MbjE41R7bQOTxMVoL3aomUJmw=="], - "@tauri-apps/cli-linux-riscv64-gnu": ["@tauri-apps/cli-linux-riscv64-gnu@2.11.2", "", { "os": "linux", "cpu": "none" }, "sha512-usbMLJbT3KtkOrBMDVeGYNM35aTHXx38SJSzTMSqqjeUIOQ+iVPjb2yAGNAE+KqmBbAx4FOFIyMeKXx2M/JKGQ=="], + "@tauri-apps/cli-linux-riscv64-gnu": ["@tauri-apps/cli-linux-riscv64-gnu@2.11.4", "", { "os": "linux", "cpu": "none" }, "sha512-qqgNkQ2u1yZHxjhxsZaxUtRDW8dIqIYm33rx/mzwQv0SfY9x1B+iraj8vWeFiXjjSVVhEMepXSOts1TqPzvXNQ=="], - "@tauri-apps/cli-linux-x64-gnu": ["@tauri-apps/cli-linux-x64-gnu@2.11.2", "", { "os": "linux", "cpu": "x64" }, "sha512-Ru4gwJKPG0ctVGchRGpRup4Y4lW2SSfFnrbQcyHhCliKy4g8Qz97TrUgCur4CbWyAgKxvGh3SjrkA0LDYzDGiw=="], + "@tauri-apps/cli-linux-x64-gnu": ["@tauri-apps/cli-linux-x64-gnu@2.11.4", "", { "os": "linux", "cpu": "x64" }, "sha512-2VRNWl84FOH0m2giiDkO2h0QXlcMJeX+zJDpI5kDIQAx6s+geF3v48F4DXfJez4GS/FdoDGnPnw1C2iYGbQ7bQ=="], - "@tauri-apps/cli-linux-x64-musl": ["@tauri-apps/cli-linux-x64-musl@2.11.2", "", { "os": "linux", "cpu": "x64" }, "sha512-eUm7T6clN1MMmNSRQ9gaWsQdyehQx2Gmn5hht/QUlqZQI/qcP2OJK5dnaxqwFzCr2HdsEo9ydxaqcS1oJzMvUw=="], + "@tauri-apps/cli-linux-x64-musl": ["@tauri-apps/cli-linux-x64-musl@2.11.4", "", { "os": "linux", "cpu": "x64" }, "sha512-o9GyhYor/nc7xarmwDE3ka2szuW3uuZzXjHWh64Q8YX5AtSgxdQkFWzrY4O8KiGtVNvFBI14H3Q49Qj5TOIP/A=="], - "@tauri-apps/cli-win32-arm64-msvc": ["@tauri-apps/cli-win32-arm64-msvc@2.11.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-HeeZW80jU+gVTOEX4X/hC6NVSAdDVXajwP5fxIZ/3z9WvUC7qrudX2GMTilYq6Dg0e0sk0XgsAJD1hZ5wPBXUA=="], + "@tauri-apps/cli-win32-arm64-msvc": ["@tauri-apps/cli-win32-arm64-msvc@2.11.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-ld5Ehb598m0VkYyylRPNeCFsBe/km0jxis6KgMpl3IGY6I/i1RwQXO05I1AsXUXO2WC6AvB/Lw4qTf/asiuEiQ=="], - "@tauri-apps/cli-win32-ia32-msvc": ["@tauri-apps/cli-win32-ia32-msvc@2.11.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-YhjQNZcXfbkCLyazSv1nPnJ9iRFE1wm6kc51FDbU10/Dk09io+6PAGMLjkxnX2GdM0qMnDmTjstY8mTDVvtKeA=="], + "@tauri-apps/cli-win32-ia32-msvc": ["@tauri-apps/cli-win32-ia32-msvc@2.11.4", "", { "os": "win32", "cpu": "ia32" }, "sha512-12Hxi0XX/H5VFxO/bGgHkFWhml9VMgEOu9CidjeCeTNQ1l6fpUlbiGgSP7CLI3PFtW9/FfbeHieZ+kyWK5H7CA=="], - "@tauri-apps/cli-win32-x64-msvc": ["@tauri-apps/cli-win32-x64-msvc@2.11.2", "", { "os": "win32", "cpu": "x64" }, "sha512-d2JchlFIpZevZVReyqhQOekJmb1UH3rhZ5VX6sH3ty9ETE0TKQavpihvoScUXfKKpW6HZC0MrFGRU0ZtD+w3gA=="], + "@tauri-apps/cli-win32-x64-msvc": ["@tauri-apps/cli-win32-x64-msvc@2.11.4", "", { "os": "win32", "cpu": "x64" }, "sha512-+vDiqBIU5dMISg/wNvX3sF+ZHfgJGJ5T0AcO+EHNXV9GGAG+P5fzodlDXD3QdKCRgZxMoCm5PPvj3BqLNjBthw=="], - "@tybys/wasm-util": ["@tybys/wasm-util@0.10.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg=="], + "@tybys/wasm-util": ["@tybys/wasm-util@0.10.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg=="], "@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="], @@ -230,33 +184,33 @@ "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], - "@types/node": ["@types/node@25.9.1", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg=="], + "@types/node": ["@types/node@25.9.5", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-OScDchr2fwuUmWdf4kZ9h7PcJiYDVInhJizG/biAq3cAvqwYktuy/TYGGdZNMtNTFUP7rnb0NU4TUdm82kt4Rg=="], - "@types/react": ["@types/react@19.2.15", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-eRwcGNHve+E8qtEQSSRl6urh+rFop4v8gm6O8rGv25CodbvFdLjA1vVQ1KkiFE0w0UPOnb8tDiFKL5lp0rtY5Q=="], + "@types/react": ["@types/react@19.2.17", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw=="], "@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="], - "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.59.2", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.59.2", "@typescript-eslint/type-utils": "8.59.2", "@typescript-eslint/utils": "8.59.2", "@typescript-eslint/visitor-keys": "8.59.2", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.59.2", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-j/bwmkBvHUtPNxzuWe5z6BEk3q54YRyGlBXkSsmfoih7zNrBvl5A9A98anlp/7JbyZcWIJ8KXo/3Tq/DjFLtuQ=="], + "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.65.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.65.0", "@typescript-eslint/type-utils": "8.65.0", "@typescript-eslint/utils": "8.65.0", "@typescript-eslint/visitor-keys": "8.65.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.65.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-IEgob78X12rHpUmtcwFsXhZdVGJtwTVP8FiCLZkR6GlYVrl2PcuB+KhCE5BlVC/eQpQnu8WXRtkHZuPar+gCRA=="], - "@typescript-eslint/parser": ["@typescript-eslint/parser@8.59.2", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.59.2", "@typescript-eslint/types": "8.59.2", "@typescript-eslint/typescript-estree": "8.59.2", "@typescript-eslint/visitor-keys": "8.59.2", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-plR3pp6D+SSUn1HM7xvSkx12/DhoHInI2YF35KAcVFNZvlC0gtrWqx7Qq1oH2Ssgi0vlFRCTbP+DZc7B9+TtsQ=="], + "@typescript-eslint/parser": ["@typescript-eslint/parser@8.65.0", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.65.0", "@typescript-eslint/types": "8.65.0", "@typescript-eslint/typescript-estree": "8.65.0", "@typescript-eslint/visitor-keys": "8.65.0", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-CZ4nMxWwgu1HEEFNkeaCptra9QCtkmKdgf3sWh1rl1trIhmxLilgTV4cwcbQ4wemnT4sWQN8CaKOmdYx+g2gMA=="], - "@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.59.2", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.59.2", "@typescript-eslint/types": "^8.59.2", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-+2hqvEkeyf/0FBor67duF0Ll7Ot8jyKzDQOSrxazF/danillRq2DwR9dLptsXpoZQqxE1UisSmoZewrlPas9Vw=="], + "@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.65.0", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.65.0", "@typescript-eslint/types": "^8.65.0", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-SxnPhbTsGahizDgbu7oqFH/xVtzIqMd/s+WtnSxNxJZJpLbdT5IPdzg8EZxO3+PoKahXmwJLeNQOpKJb3/bi7Q=="], - "@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.59.2", "", { "dependencies": { "@typescript-eslint/types": "8.59.2", "@typescript-eslint/visitor-keys": "8.59.2" } }, "sha512-JzfyEpEtOU89CcFSwyNS3mu4MLvLSXqnmX05+aKBDM+TdR5jzcGOEBwxwGNxrEQ7p/z6kK2WyioCGBf2zZBnvg=="], + "@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.65.0", "", { "dependencies": { "@typescript-eslint/types": "8.65.0", "@typescript-eslint/visitor-keys": "8.65.0" } }, "sha512-Esbl8OSYiVxBokYgWPf7VVWg/BE798wXhimnn9ML9Pt5qoDf8bfQlgjlKXR/k98+AcNzlLKYrpCcrcuZ9DZLgg=="], - "@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.59.2", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-BKK4alN7oi4C/zv4VqHQ+uRU+lTa6JGIZ7s1juw7b3RHo9OfKB+bKX3u0iVZetdsUCBBkSbdWbarJbmN0fTeSw=="], + "@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.65.0", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-j6GzGqCiRdA7Qhur2VVmKZAkBLfnHFQfx4TaJGL9RMveZqCo48jSHHO0DTgizEnGhtWnqmbtCUSrqSkdiY/0Hg=="], - "@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.59.2", "", { "dependencies": { "@typescript-eslint/types": "8.59.2", "@typescript-eslint/typescript-estree": "8.59.2", "@typescript-eslint/utils": "8.59.2", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-nhqaj1nmTdVVl/BP5omXNRGO38jn5iosis2vbdmupF2txCf8ylWT8lx+JlvMYYVqzGVKtjojUFoQ3JRWK+mfzQ=="], + "@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.65.0", "", { "dependencies": { "@typescript-eslint/types": "8.65.0", "@typescript-eslint/typescript-estree": "8.65.0", "@typescript-eslint/utils": "8.65.0", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-YjaZ7PRI5qY7ax2L3PbvX0rRyGtipAReCWs0mhhDBHjH/vl0g0BonaGXrKdKpMbIIsMIwDgbk/xzkBTyAltS5g=="], - "@typescript-eslint/types": ["@typescript-eslint/types@8.59.2", "", {}, "sha512-e82GVOE8Ps3E++Egvb6Y3Dw0S10u8NkQ9KXmtRhCWJJ8kDhOJTvtMAWnFL16kB1583goCWXsr0NieKCZMs2/0Q=="], + "@typescript-eslint/types": ["@typescript-eslint/types@8.65.0", "", {}, "sha512-JSSwWNy+H0E/01jJEM+hrX6N0OFDzFzeIhHFSAS01tlVaevpG8cFyYRPhS5yjGOvBUx3sqQHVMjCL1CAZZMxBg=="], - "@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.59.2", "", { "dependencies": { "@typescript-eslint/project-service": "8.59.2", "@typescript-eslint/tsconfig-utils": "8.59.2", "@typescript-eslint/types": "8.59.2", "@typescript-eslint/visitor-keys": "8.59.2", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-o0XPGNwcWw+FIwStOWn+BwBuEmL6QXP0rsvAFg7ET1dey1Nr6Wb1ac8p5HEsK0ygO/6mUxlk+YWQD9xcb/nnXg=="], + "@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.65.0", "", { "dependencies": { "@typescript-eslint/project-service": "8.65.0", "@typescript-eslint/tsconfig-utils": "8.65.0", "@typescript-eslint/types": "8.65.0", "@typescript-eslint/visitor-keys": "8.65.0", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-JboAE2swaYt4tb1fHhHTABE2K+OLy09XfcTbhnk4Pw96f9dd2e9iYsJ28gBggHlo5z5x1rkyWvcPoTuNTd4oGg=="], - "@typescript-eslint/utils": ["@typescript-eslint/utils@8.59.2", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.59.2", "@typescript-eslint/types": "8.59.2", "@typescript-eslint/typescript-estree": "8.59.2" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-Juw3EinkXqjaffxz6roowvV7GZT/kET5vSKKZT6upl5TXdWkLkYmNPXwDDL2Vkt2DPn0nODIS4egC/0AGxKo/Q=="], + "@typescript-eslint/utils": ["@typescript-eslint/utils@8.65.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.65.0", "@typescript-eslint/types": "8.65.0", "@typescript-eslint/typescript-estree": "8.65.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-gXiwIHsYreboxeJucHKPvgwl7dXt50mF8s1/c00cP/WoVTyWKFdtfhRWwZiXYFU5H2O8vVoSLNrexFZjYS/SGA=="], - "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.59.2", "", { "dependencies": { "@typescript-eslint/types": "8.59.2", "eslint-visitor-keys": "^5.0.0" } }, "sha512-NwjLUnGy8/Zfx23fl50tRC8rYaYnM52xNRYFAXvmiil9yh1+K6aRVQMnzW6gQB/1DLgWt977lYQn7C+wtgXZiA=="], + "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.65.0", "", { "dependencies": { "@typescript-eslint/types": "8.65.0", "eslint-visitor-keys": "^5.0.0" } }, "sha512-8C71BQkGjiMmXtop7pHVJu1l2NNShFdkCyD6a2ezzs5vU/L3LRtb69EtcteFwz0mYMPzIgOw0n6OV4VBUWZd7A=="], - "@vitejs/plugin-react": ["@vitejs/plugin-react@6.0.2", "", { "dependencies": { "@rolldown/pluginutils": "^1.0.0" }, "peerDependencies": { "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", "babel-plugin-react-compiler": "^1.0.0", "vite": "^8.0.0" }, "optionalPeers": ["@rolldown/plugin-babel", "babel-plugin-react-compiler"] }, "sha512-DlSMqo4WhThw4vB8Mpn0Woe9J+Jfq1geJ61AKW0QEgLzGMNwtIMdxbDUzLxcun8W7NbJO0e2Jg/Nxm3cCSVzzg=="], + "@vitejs/plugin-react": ["@vitejs/plugin-react@6.0.4", "", { "dependencies": { "@rolldown/pluginutils": "^1.0.1" }, "peerDependencies": { "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", "babel-plugin-react-compiler": "^1.0.0", "vite": "^8.0.0" }, "optionalPeers": ["@rolldown/plugin-babel", "babel-plugin-react-compiler"] }, "sha512-XcCQz0TBpBgljhj0gMuuDj49i6Ytqh5q1osT/Gp5uAVJUCTWxyskk/l1jwYYiu2xcNHHipdMz40EGfM1VdamVg=="], "acorn": ["acorn@8.16.0", "", { "bin": "bin/acorn" }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="], @@ -276,7 +230,7 @@ "bplist-creator": ["bplist-creator@0.0.8", "", { "dependencies": { "stream-buffers": "~2.2.0" } }, "sha512-Za9JKzD6fjLC16oX2wsXfc+qBEhJBJB1YPInoAQpMLhDuj5aVOv1baGeIQSq1Fr3OCqzvsoQcSBSwGId/Ja2PA=="], - "brace-expansion": ["brace-expansion@5.0.6", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g=="], + "brace-expansion": ["brace-expansion@5.0.8", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg=="], "browserslist": ["browserslist@4.28.2", "", { "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", "electron-to-chromium": "^1.5.328", "node-releases": "^2.0.36", "update-browserslist-db": "^1.2.3" }, "bin": "cli.js" }, "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg=="], @@ -306,19 +260,17 @@ "end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="], - "esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": "bin/esbuild" }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="], - "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], - "eslint": ["eslint@10.4.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", "@eslint/config-array": "^0.23.5", "@eslint/config-helpers": "^0.6.0", "@eslint/core": "^1.2.1", "@eslint/plugin-kit": "^0.7.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^9.1.2", "eslint-visitor-keys": "^5.0.1", "espree": "^11.2.0", "esquery": "^1.7.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "minimatch": "^10.2.4", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-loXy6bWOoP3EP6JA7jo6p5jMpBJmHmsNZM5SFRHLdh1MGOPurMnNBj4ZlAbaqUAaQWbCr7jHV4P7gzAyryZWkQ=="], + "eslint": ["eslint@10.8.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", "@eslint/config-array": "^0.23.5", "@eslint/config-helpers": "^0.7.0", "@eslint/core": "^1.2.1", "@eslint/plugin-kit": "^0.7.2", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^9.1.2", "eslint-visitor-keys": "^5.0.1", "espree": "^11.2.0", "esquery": "^1.7.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "minimatch": "^10.2.5", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-nuKKvN+oIBO0koN7Tm7dlkmnkc21mtt0QJLwAKzjLq14y6lRTdVG36MZHJ8eQHwdJMwZbQNMlPOYedMq/oVJvQ=="], "eslint-config-prettier": ["eslint-config-prettier@10.1.8", "", { "peerDependencies": { "eslint": ">=7.0.0" }, "bin": "bin/cli.js" }, "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w=="], "eslint-plugin-react-hooks": ["eslint-plugin-react-hooks@7.1.1", "", { "dependencies": { "@babel/core": "^7.24.4", "@babel/parser": "^7.24.4", "hermes-parser": "^0.25.1", "zod": "^3.25.0 || ^4.0.0", "zod-validation-error": "^3.5.0 || ^4.0.0" }, "peerDependencies": { "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0" } }, "sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g=="], - "eslint-plugin-react-refresh": ["eslint-plugin-react-refresh@0.5.2", "", { "peerDependencies": { "eslint": "^9 || ^10" } }, "sha512-hmgTH57GfzoTFjVN0yBwTggnsVUF2tcqi7RJZHqi9lIezSs4eFyAMktA68YD4r5kNw1mxyY4dmkyoFDb3FIqrA=="], + "eslint-plugin-react-refresh": ["eslint-plugin-react-refresh@0.5.3", "", { "peerDependencies": { "eslint": "^9 || ^10" } }, "sha512-5EMmLCV98Pi4o/f/3DP/v/tNqLHMIc9I8LKClNDWhZ9JTho89/kQcitCXQBMG7sAfVRK0Ie3T2EDOzp1YXYiVA=="], "eslint-scope": ["eslint-scope@9.1.2", "", { "dependencies": { "@types/esrecurse": "^4.3.1", "@types/estree": "^1.0.8", "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ=="], @@ -396,8 +348,6 @@ "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], - "jiti": ["jiti@2.7.0", "", { "bin": "lib/jiti-cli.mjs" }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="], - "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], "jsesc": ["jsesc@3.1.0", "", { "bin": "bin/jsesc" }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], @@ -446,7 +396,7 @@ "lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], - "lucide-react": ["lucide-react@1.17.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-9FA9evdox/JQL5PT57fdA1x/yg8T7knJ98+zjTL3UfKza6pflQUUh3XtaQIHKvnsJw1lmsEyHVlt5jchYxOQ5w=="], + "lucide-react": ["lucide-react@1.26.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-raglYVR2+VkMfJL158krjVmE+rV5ST2lzA/KQm1FRSjMHT4MnWaegHxoVEpmc2So3nOEhp9oGejJwAPX8MoAjg=="], "macos-alias": ["macos-alias@0.2.12", "", { "dependencies": { "nan": "^2.4.0" }, "os": "darwin" }, "sha512-yiLHa7cfJcGRFq4FrR4tMlpNHb4Vy4mWnpajlSSIFM5k4Lv8/7BbbDLzCAVogWNl0LlLhizRp1drXv0hK9h0Yw=="], @@ -460,7 +410,7 @@ "nan": ["nan@2.27.0", "", {}, "sha512-hC+0LidcL3XE4rp1C4H54KujgXKzbfyTngZTwBByQxsOxCEKZT0MPQ4hOKUH2jU1OYstqdDH4onyHPDzcV0XdQ=="], - "nanoid": ["nanoid@3.3.12", "", { "bin": "bin/nanoid.cjs" }, "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ=="], + "nanoid": ["nanoid@3.3.16", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q=="], "natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="], @@ -488,13 +438,13 @@ "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], - "picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="], + "picomatch": ["picomatch@4.0.5", "", {}, "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A=="], - "postcss": ["postcss@8.5.15", "", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A=="], + "postcss": ["postcss@8.5.23", "", { "dependencies": { "nanoid": "^3.3.16", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg=="], "prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="], - "prettier": ["prettier@3.8.3", "", { "bin": "bin/prettier.cjs" }, "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw=="], + "prettier": ["prettier@3.9.6", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g=="], "pump": ["pump@3.0.4", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA=="], @@ -502,13 +452,13 @@ "random-path": ["random-path@0.1.2", "", { "dependencies": { "base32-encode": "^0.1.0 || ^1.0.0", "murmur-32": "^0.1.0 || ^0.2.0" } }, "sha512-4jY0yoEaQ5v9StCl5kZbNIQlg1QheIDBrdkDn53EynpPb9FgO6//p3X/tgMnrC45XN6QZCzU1Xz/+pSSsJBpRw=="], - "react": ["react@19.2.6", "", {}, "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q=="], + "react": ["react@19.2.8", "", {}, "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw=="], - "react-dom": ["react-dom@19.2.6", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.6" } }, "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g=="], + "react-dom": ["react-dom@19.2.8", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.8" } }, "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ=="], "repeat-string": ["repeat-string@1.6.1", "", {}, "sha512-PV0dzCYDNfRi1jCDbJzpW7jNNDRuCOG/jI5ctQcGKt/clZD+YcPS3yIlWuTJMmESC8aevCFmWJy5wjAFgNqN6w=="], - "rolldown": ["rolldown@1.0.2", "", { "dependencies": { "@oxc-project/types": "=0.132.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.2", "@rolldown/binding-darwin-arm64": "1.0.2", "@rolldown/binding-darwin-x64": "1.0.2", "@rolldown/binding-freebsd-x64": "1.0.2", "@rolldown/binding-linux-arm-gnueabihf": "1.0.2", "@rolldown/binding-linux-arm64-gnu": "1.0.2", "@rolldown/binding-linux-arm64-musl": "1.0.2", "@rolldown/binding-linux-ppc64-gnu": "1.0.2", "@rolldown/binding-linux-s390x-gnu": "1.0.2", "@rolldown/binding-linux-x64-gnu": "1.0.2", "@rolldown/binding-linux-x64-musl": "1.0.2", "@rolldown/binding-openharmony-arm64": "1.0.2", "@rolldown/binding-wasm32-wasi": "1.0.2", "@rolldown/binding-win32-arm64-msvc": "1.0.2", "@rolldown/binding-win32-x64-msvc": "1.0.2" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-oZx5zVDtVB44AW3eaifgDml1gWRDZGvjcfdxonE4swNPG98PrrXjaO/KrnUjzlMnztCCRVlUueA1kCXhARGk6g=="], + "rolldown": ["rolldown@1.1.5", "", { "dependencies": { "@oxc-project/types": "=0.139.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.1.5", "@rolldown/binding-darwin-arm64": "1.1.5", "@rolldown/binding-darwin-x64": "1.1.5", "@rolldown/binding-freebsd-x64": "1.1.5", "@rolldown/binding-linux-arm-gnueabihf": "1.1.5", "@rolldown/binding-linux-arm64-gnu": "1.1.5", "@rolldown/binding-linux-arm64-musl": "1.1.5", "@rolldown/binding-linux-ppc64-gnu": "1.1.5", "@rolldown/binding-linux-s390x-gnu": "1.1.5", "@rolldown/binding-linux-x64-gnu": "1.1.5", "@rolldown/binding-linux-x64-musl": "1.1.5", "@rolldown/binding-openharmony-arm64": "1.1.5", "@rolldown/binding-wasm32-wasi": "1.1.5", "@rolldown/binding-win32-arm64-msvc": "1.1.5", "@rolldown/binding-win32-x64-msvc": "1.1.5" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA=="], "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], @@ -526,7 +476,7 @@ "strip-eof": ["strip-eof@1.0.0", "", {}, "sha512-7FCwGGmx8mD5xQd3RPUvnSpUXHM3BWuzjtpD4TXsfcZ9EL4azvVVUscFYwD9nx8Kh+uCBC00XBtAykoMHwTh8Q=="], - "tinyglobby": ["tinyglobby@0.2.16", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="], + "tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], "tn1150": ["tn1150@0.1.0", "", { "dependencies": { "unorm": "^1.4.1" } }, "sha512-DbplOfQFkqG5IHcDyyrs/lkvSr3mPUVsFf/RbDppOshs22yTPnSJWEe6FkYd1txAwU/zcnR905ar2fi4kwF29w=="], @@ -540,7 +490,7 @@ "typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="], - "typescript-eslint": ["typescript-eslint@8.59.2", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.59.2", "@typescript-eslint/parser": "8.59.2", "@typescript-eslint/typescript-estree": "8.59.2", "@typescript-eslint/utils": "8.59.2" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-pJw051uomb3ZeCzGTpRb8RbEqB5Y4WWet8gl/GcTlU35BSx0PVdZ86/bqkQCyKKuraVQEK7r6kBHQXF+fBhkoQ=="], + "typescript-eslint": ["typescript-eslint@8.65.0", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.65.0", "@typescript-eslint/parser": "8.65.0", "@typescript-eslint/typescript-estree": "8.65.0", "@typescript-eslint/utils": "8.65.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-/ggrHAwyjENDusvyxbuqxAC2dTnZg/Z8F+fgQtYIz+L6n/9HfSlEZcFGV/NsMNa6CkGk0xUjUAFwC0vHOflvIA=="], "undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], @@ -550,7 +500,7 @@ "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], - "vite": ["vite@8.0.14", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.15", "rolldown": "1.0.2", "tinyglobby": "^0.2.16" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.18", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-s4BJJ+5y1pYL6Otw51FHhVJQhPnuRinKig64g/1+EUNaJsd3gCKdD31IPFvswUgW9/60QT9oFHbZHbQK5imcxw=="], + "vite": ["vite@8.1.5", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.5", "postcss": "^8.5.17", "rolldown": "~1.1.5", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.3.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw=="], "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], @@ -562,8 +512,6 @@ "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], - "yaml": ["yaml@2.8.4", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-ml/JPOj9fOQK8RNnWojA67GbZ0ApXAUlN2UQclwv2eVgTgn7O9gg9o7paZWKMp4g0H3nTLtS9LVzhkpOFIKzog=="], - "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], "zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], @@ -576,10 +524,16 @@ "@typescript-eslint/typescript-estree/semver": ["semver@7.8.0", "", { "bin": "bin/semver.js" }, "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA=="], + "bun-types/@types/node": ["@types/node@25.9.1", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg=="], + + "eslint-plugin-react-hooks/@babel/parser": ["@babel/parser@7.29.3", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": { "parser": "bin/babel-parser.js" } }, "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA=="], + "execa/cross-spawn": ["cross-spawn@6.0.6", "", { "dependencies": { "nice-try": "^1.0.4", "path-key": "^2.0.1", "semver": "^5.5.0", "shebang-command": "^1.2.0", "which": "^1.2.9" } }, "sha512-VqCUuhcd1iB+dsv8gxPttb5iZh/D0iubSP21g36KXdEuf6I5JiioesUVjpCdHV9MZRUfVFlvwtIUyPfxo5trtw=="], "npm-run-path/path-key": ["path-key@2.0.1", "", {}, "sha512-fEHGKCSmUSDPv4uoj8AlD+joPlq3peND+HRYyxFz4KPw4z926S/b8rIuFs2FYJg3BwsxJf6A9/3eIdLaYC+9Dw=="], + "eslint-plugin-react-hooks/@babel/parser/@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], + "execa/cross-spawn/path-key": ["path-key@2.0.1", "", {}, "sha512-fEHGKCSmUSDPv4uoj8AlD+joPlq3peND+HRYyxFz4KPw4z926S/b8rIuFs2FYJg3BwsxJf6A9/3eIdLaYC+9Dw=="], "execa/cross-spawn/semver": ["semver@5.7.2", "", { "bin": { "semver": "bin/semver" } }, "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g=="], @@ -588,6 +542,10 @@ "execa/cross-spawn/which": ["which@1.3.1", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "which": "./bin/which" } }, "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ=="], + "eslint-plugin-react-hooks/@babel/parser/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="], + + "eslint-plugin-react-hooks/@babel/parser/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], + "execa/cross-spawn/shebang-command/shebang-regex": ["shebang-regex@1.0.0", "", {}, "sha512-wpoSFAxys6b2a2wHZ1XpDSgD7N9iVjg29Ph9uV/uaP9Ex/KXlkTZTeddxDPSYQpgvzKLGJke2UU0AzoGCjNIvQ=="], } } diff --git a/docs/LICENSING.md b/docs/LICENSING.md new file mode 100644 index 0000000..99b74f4 --- /dev/null +++ b/docs/LICENSING.md @@ -0,0 +1,123 @@ +# Licensing — options and open questions + +A decision record, not a decision. Nothing here has been applied: ÆTHER is still +under **PolyForm Strict License 1.0.0**. This exists so the reasoning is on paper +when the choice is actually made. + +Not legal advice. Anything with revenue attached to it is worth a lawyer's hour. + +## Three gaps in the current setup + +These are true today and worth fixing **whatever licence is chosen**. + +### 1. Even local modification is not permitted + +PolyForm Strict grants everything *"other than distributing the software **or making +changes or new works based on the software**."* + +That second clause is stricter than it usually reads. It means: + +- A user cannot legally patch a bug for their own machine. +- A contributor cannot legally fork to open a pull request — a fork is + redistribution, and a patch is a new work. + +So the project cannot accept outside contributions without granting permission +out-of-band first, per contributor, before they have written anything. + +### 2. The README promises a CLA that does not exist + +> External contributions require a signed Contributor License Agreement (CLA) or +> another written contributor agreement. + +There is no `CONTRIBUTING.md`, no CLA text, and no CLA bot. The stated route to +contributing terminates in nothing. Either write it or stop referring to it. + +### 3. There is no way to buy a commercial licence + +Redistribution and commercial use "require separate written permission from +CanPixel" — and no email, form, or contact appears anywhere in the repository. The +single revenue path the licence exists to protect has no door in it. + +This one is worth fixing immediately and costs one line. + +## What constrains the choice + +**Nothing in the dependency tree.** Gemma 4 E2B/E4B and Qwen3-Embedding-0.6B are +Apache-2.0; llama.cpp, Tauri, and the rest are Apache-2.0/MIT. No copyleft +obligation reaches ÆTHER's own code. + +**The product shape does.** ÆTHER is a local desktop app with no server. That rules +out the usual open-core playbook — there is no hosted tier to sell, and AGPL has no +leverage because there is no network service to trigger the source obligation. The +realistic revenue is a one-time purchase or a per-seat commercial licence, which is +what PolyForm already reserves. + +**The audience does too.** Researchers, journalists, and people in regulated or +air-gapped environments care about auditability and about not being cut off. Both +argue for source-available with a durable guarantee, and against anything that +makes moving a build onto an offline machine legally awkward. + +## Options + +### A. PolyForm Noncommercial 1.0.0 + +Permits modification and redistribution; still bans commercial use. + +- Fixes all three frictions: local fixes, sneakernet to an air-gapped machine, PRs. +- Gives up nothing sellable — noncommercial use was already permitted. +- Roughly a one-word change in the licence family, plus `package.json` and README. + +The smallest change that removes real friction. Still not OSI open source, so it +does not buy open-source goodwill or a place in distro repositories. + +### B. FSL-1.1-Apache-2.0 (Functional Source License) + +Same commercial protection now; each release converts to Apache-2.0 two years after +it ships. + +- The strongest "this will not be taken away from you" signal short of going open, + which matters for the trust-sensitive audience. +- Irrevocable per release — a promise that cannot be walked back. +- Two years is a long time in this category; by the time a release converts, it is + unlikely to be competitive. + +### C. AGPL-3.0 + commercial dual licence + +Real OSI open source, with a commercial licence sold to anyone who cannot comply. + +- Weak here. The copyleft trigger is *conveying* or *network use*; a local desktop + app with no server rarely trips either, so the commercial pressure that makes + dual-licensing work mostly is not there. +- A competitor could fork commercially provided they publish source. +- Meaningful legal and administrative overhead for a solo project. + +### D. Keep PolyForm Strict, close the gaps + +Write the CLA and `CONTRIBUTING.md` the README already promises, add a commercial +contact. + +- Zero licence risk, and the documentation is owed regardless. +- Contributors still cannot legally fork to submit a PR, so the contribution path + stays theoretical. + +### E. Fully permissive (Apache-2.0 / MIT) + +- Maximum adoption and goodwill. +- No revenue path at all for a local app with no hosted component, and no + protection against a rebranded fork. + +## A correction to the audit that prompted this + +The audit said the current setup has *"the costs of proprietary and the revenue of +open source."* That is unfair as written. PolyForm Strict **does** establish the +legal basis for a commercial story — every commercial right is retained. What is +missing is everything on the other side of it: no price, no tier, no contact. Gap 3 +above is the real finding; the licence family is a secondary question. + +## Suggested order + +1. **Add a commercial-licence contact.** One line. Fixes a gap that exists under + every option, and is the only one currently costing money. +2. **Resolve the CLA claim** — write it, or remove the sentence. +3. **Then** decide between A and B, if either. That decision is about how much + openness is worth to the audience, not about unblocking anything technical. diff --git a/docs/SECURITY.md b/docs/SECURITY.md new file mode 100644 index 0000000..ad61772 --- /dev/null +++ b/docs/SECURITY.md @@ -0,0 +1,93 @@ +# Security Notes + +Not a policy document — a record of the decisions that are easy to undo by accident. + +## Two kinds of webview + +ÆTHER runs visited pages in **child webviews**, separate from the window that hosts +the app's own UI. That split is the main boundary: + +| | Privileged window (`main`) | Child webviews (tabs) | +|---|---|---| +| Content | ÆTHER's own bundled UI | arbitrary web pages | +| IPC bridge | yes — all Tauri commands | no | +| CSP | `app.security.csp` (below) | the site's own | + +A page cannot reach the command bridge, because it is not in the context that has +one. This is why an aggressive CSP on the privileged window costs page +compatibility nothing: the policy never applies to page content. + +## Content Security Policy + +Lives in `src-tauri/tauri.conf.json` under `app.security.csp`, with a looser +`devCsp` for Vite's HMR (inline scripts, `eval`, and a WebSocket to +`127.0.0.1:1420`). Tauri injects it at load. + +**Deliberately not also a `` tag in `index.html`.** It used to be. Two +policies are *intersected* by the engine, so with both in place a tightening in +either silently overrides the other and the pair drifts apart. One source of truth. + +Why each directive is what it is: + +| Directive | Value | Reason | +|---|---|---| +| `default-src` | `'self'` | Nothing loads from anywhere else unless listed below. | +| `script-src` | `'self'` | One bundled module script. No inline, no `eval`. | +| `style-src` | `'self' 'unsafe-inline'` | The UI uses React `style` attributes throughout. This permits inline *style*, not inline script. | +| `img-src` | `'self' data: blob: https: http:` | See favicons below. `data:`/`blob:` are tab thumbnails. | +| `connect-src` | `'self' ipc: http://ipc.localhost` | Tauri's IPC transport. Removing these breaks every command. | +| `object-src` | `'none'` | No plugins, ever. | +| `base-uri` | `'self'` | Stops injected markup repointing relative URLs. | +| `form-action` | `'none'` | The UI has no server to post to. | +| `frame-ancestors` | `'none'` | Nothing may embed the privileged window. | + +**`img-src` allows any host, and that is a real hole.** Tab favicons are fetched +straight from `https:///favicon.ico` by an `` in the privileged window +(`favicon_for_url` in `src-tauri/src/util.rs`). Narrowing this needs favicons +proxied through Rust and cached locally, which would also stop the privileged +window making any outbound request at all. Worth doing; not done. + +## What the app sends anywhere + +Outbound requests, all from Rust except where noted: + +- **Hugging Face** — only while downloading a model the user chose. +- **GitHub Releases API** — the update check, if enabled in Settings. +- **The update endpoint** — only when the user presses Install Update. +- **Pages the user visits** — in child webviews, as any browser. +- **Favicons** — from the privileged window, per the above. + +No analytics, no crash reporting, no phone-home. Captured text, embeddings, +answers, and iCE atlases never leave the machine. + +## Diagnostics log + +`src-tauri/src/diagnostics.rs`. Replaces 25 `eprintln!` calls that went to a stderr +nobody reads — which mattered because the Windows and Linux builds ship without ever +being run, and there is no telemetry to notice a failure. + +- Written only to the app data directory, capped at 512 KiB, rolling over to the + newest half rather than emptying. +- Visible in Settings → Diagnostics (most recent first). +- Leaves the machine only via **Export Log**, which writes a copy and reveals it. + +**Deliberately never recorded:** page text, captured content, search queries, chat +prompts, and answers. Entries are operational — what failed, and where. That is the +constraint that lets the log be exportable at all; an exported log must not be able +to become a transcript of what someone was reading. + +Paths *are* recorded, including model and store paths under the user's home +directory. Worth knowing before attaching a log to a public issue. + +## Capabilities + +`src-tauri/capabilities/default.json` grants `core:default` and `opener:default` to +the `main` window only. The opener permission is what `reveal_item_in_dir` and +external-link opening need. Nothing else is granted, and child webviews appear in no +capability. + +## Known gaps + +- Releases are unsigned; see [SIGNING.md](SIGNING.md). +- `img-src` is open to any host, per above. +- `style-src` needs `'unsafe-inline'` until the UI stops using `style` attributes. diff --git a/docs/SIGNING.md b/docs/SIGNING.md new file mode 100644 index 0000000..77d72c9 --- /dev/null +++ b/docs/SIGNING.md @@ -0,0 +1,195 @@ +# Code Signing and Notarization + +ÆTHER currently ships **unsigned** on every platform. This document is the setup +guide for changing that. + +There are two independent signing schemes here, and they are easy to confuse: + +| | Purpose | Cost | Status | +|---|---|---|---| +| **OS code signing** (Apple Developer ID, Azure Trusted Signing) | Stops the OS blocking the *first install* | ~$220/year | Not wired up — see below | +| **Updater signing** (minisign) | Lets the app verify an *update* it downloads itself | Free | Wired up, waiting on a keypair | + +They are unrelated: the updater's minisign key is generated locally by the Tauri CLI +and has nothing to do with Apple or Microsoft. So [in-app +updates](#in-app-updates--minisign) can be turned on today, without waiting for the +paid certificates. + +Until OS signing is done, see [Install](../README.md#install) for what users have to +do to open an unsigned build. + +## Why it matters + +An unsigned, un-notarized `.dmg` downloaded through a browser is quarantined by +macOS. The user does not get a "are you sure?" prompt they can click through — they +get **"ÆTHER is damaged and can't be opened. You should move it to the Bin."** That +message is indistinguishable from a malware warning, and it is the first thing a new +user sees. + +On Windows, an unsigned NSIS installer triggers SmartScreen's "Windows protected +your PC — unknown publisher" interstitial, where the *Run anyway* button is behind a +*More info* link. + +For a tool whose entire pitch is that it keeps your data on your machine, asking the +user to override their OS's security check is the wrong first impression. + +## macOS — Developer ID + notarization + +**Cost:** Apple Developer Program, $99/year. + +1. **Enrol** at . Individual enrolment is + enough; it can take a day or two to be approved. +2. **Create a Developer ID Application certificate** in Certificates, Identifiers & + Profiles. Note this is *Developer ID Application*, not *Mac App Distribution* — + the latter only works for the Mac App Store and will not help with direct + downloads. +3. **Export it as a `.p12`** from Keychain Access (right-click the certificate → + Export), set a password, then base64 it for CI: + ```bash + base64 -i certificate.p12 | pbcopy + ``` +4. **Create an App Store Connect API key** (Users and Access → Integrations → Keys) + with the *Developer* role. Download the `.p8` — Apple only lets you download it + once. Record the Key ID and Issuer ID shown next to it. +5. **Add GitHub repository secrets:** + + | Secret | Value | + |---|---| + | `APPLE_CERTIFICATE` | base64 of the `.p12` | + | `APPLE_CERTIFICATE_PASSWORD` | the `.p12` export password | + | `APPLE_SIGNING_IDENTITY` | e.g. `Developer ID Application: Your Name (TEAMID)` | + | `APPLE_API_KEY_ID` | Key ID from step 4 | + | `APPLE_API_ISSUER` | Issuer ID from step 4 | + | `APPLE_API_KEY` | base64 of the `.p8` | + +6. **Config changes.** In `src-tauri/tauri.conf.json`, under `bundle.macOS`, add a + hardened-runtime entitlements file. Notarization rejects binaries without the + hardened runtime, and llama.cpp's Metal path needs the JIT entitlement: + + ```xml + + com.apple.security.cs.allow-jit + com.apple.security.cs.allow-unsigned-executable-memory + ``` + + Tauri's bundler reads `APPLE_SIGNING_IDENTITY` and the `APPLE_API_*` variables + directly, so the macOS CI job needs the secrets in `env:` and an import step for + the certificate — it does not need a separate `notarytool` invocation. + +7. **Verify** a built app before trusting the pipeline: + ```bash + codesign --verify --deep --strict --verbose=2 "ÆTHER.app" + spctl --assess --type execute --verbose "ÆTHER.app" + xcrun stapler validate "ÆTHER.app" + ``` + `spctl` must report `accepted source=Notarized Developer ID`. + +**Note on the DMG.** The installer is built by `scripts/make-styled-dmg.sh` via +`appdmg`, not by Tauri's bundler, so the `.dmg` itself needs signing and stapling as +a separate step after it is assembled — signing the `.app` alone is not enough. + +## Windows — Azure Trusted Signing + +**Cost:** roughly $10/month. The older route is an OV or EV certificate from a CA +(DigiCert, Sectigo) at a few hundred dollars a year; EV additionally requires a +hardware token, which does not work in CI without a cloud HSM. Azure Trusted Signing +is the cheaper and more CI-friendly option for a solo project, and it accrues +SmartScreen reputation against Microsoft's root rather than from zero. + +1. Create an Azure account and a **Trusted Signing** account and certificate profile. + Identity validation takes a few days for an individual. +2. Register an app in Entra ID, grant it the *Trusted Signing Certificate Profile + Signer* role, and record the tenant, client ID, and client secret. +3. Add `AZURE_TENANT_ID`, `AZURE_CLIENT_ID`, `AZURE_CLIENT_SECRET`, + `AZURE_ENDPOINT`, `AZURE_CODE_SIGNING_NAME`, `AZURE_CERT_PROFILE_NAME` as + repository secrets. +4. Set `bundle.windows.signCommand` in `tauri.conf.json` to invoke the Trusted + Signing CLI over `%1`. Tauri passes each artifact path through it. + +Reputation is earned per-publisher over download volume, so expect SmartScreen to +keep warning for the first weeks even once signing works. + +## Linux + +No signing is required for `.deb` or AppImage — neither apt nor the AppImage +runtime enforces publisher identity by default. If ÆTHER is ever published through +Flathub, that has its own review and build process, and signing is handled there. + +## In-app updates — minisign + +This part **is** wired up. Settings → Updates offers *Install Update* when a newer +release exists, downloads it, verifies its signature, and installs it in place. All +that is missing is a keypair, which is free and takes a minute. + +Until the key exists nothing changes: builds are byte-identical to before, and the +Install button reports `unconfigured` — *"This build has no update signing key, so +ÆTHER cannot verify a download"* — rather than fetching something it could not check. + +### Turning it on + +1. **Generate the keypair.** The password may be empty, but a real one is better — + anyone with the private key can push an update to every ÆTHER install. + ```bash + bun run tauri signer generate -w ~/.tauri/aether-updater.key + ``` + This prints a public key and writes the private key to that path. **Back both up + somewhere durable.** Losing the private key means no existing install can ever be + updated again — every user has to reinstall by hand. + +2. **Commit the public key** into `src-tauri/tauri.conf.json` under + `plugins.updater.pubkey`, replacing the empty placeholder. It is a public key; + committing it is the intended usage. + +3. **Add two repository secrets:** + + | Secret | Value | + |---|---| + | `TAURI_SIGNING_PRIVATE_KEY` | contents of `~/.tauri/aether-updater.key` | + | `TAURI_SIGNING_PRIVATE_KEY_PASSWORD` | the password from step 1 | + + The moment `TAURI_SIGNING_PRIVATE_KEY` exists, the build jobs start producing + signed updater artifacts and the release job publishes `latest.json`. Nothing else + needs changing — see `.github/actions/updater-flags`. + +4. **Verify** after the next tagged release: + ```bash + curl -sL https://github.com/CanPixel/aether/releases/latest/download/latest.json | jq . + ``` + It should list `darwin-aarch64`, `windows-x86_64`, and `linux-x86_64`, each with a + non-empty `signature` and a URL pinned to the release tag. + +### What updates, and what does not + +| Install | Self-updates | Why | +|---|---|---| +| macOS `.dmg` → `/Applications` | Yes | Replaces the `.app` bundle in place | +| Windows `-setup.exe` | Yes | Re-runs the NSIS installer silently | +| Linux AppImage | Yes | Rewrites the AppImage file | +| Linux `.deb` / `.rpm` | **No** | Owned by the package manager; ÆTHER reports `unsupported` rather than corrupting it | +| Linux ARM64 | **No** | Only a `.deb` is published, so there is no signed artifact | +| Android | **No** | Store-managed | + +### Two things worth knowing + +**`createUpdaterArtifacts` is a hard build error without the private key.** That is +why it lives in `src-tauri/tauri.updater.conf.json` and is merged in only when the +secret exists, instead of sitting in the main config — otherwise every branch push +would fail until the key was created. + +**Updates may sidestep the quarantine problem.** Tauri's updater downloads and +extracts the bundle itself rather than going through a browser, so it should not +attach `com.apple.quarantine` — meaning the `xattr` dance in the README would apply +only to the *first* install, not to subsequent updates. This follows from how +quarantine is applied and has **not been verified against a real signed release**; +confirm it before relying on it. + +## Order of work + +1. **Updater keypair first.** It is free, takes a minute, and is the only item here + that improves anything today — without it, a security fix reaches only users who + happen to re-download by hand. +2. macOS Developer ID second. It is the only platform where the current state is a + hard block rather than a bypassable warning, and it is the platform ÆTHER is + developed on. +3. Windows last. SmartScreen is unpleasant but clickable, and reputation takes + weeks to build regardless of when you start. diff --git a/package.json b/package.json index 80c8aca..b7179ab 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "typecheck:web": "tsc --noEmit -p tsconfig.web.json --composite false", "typecheck:tauri": "cargo check --manifest-path src-tauri/Cargo.toml", "typecheck": "bun run typecheck:web && bun run typecheck:tauri", + "test": "cargo test --manifest-path src-tauri/Cargo.toml --lib", "version:bump": "bun scripts/bump-version.ts", "version:check": "bun scripts/bump-version.ts --check", "release": "bun scripts/release.ts", @@ -43,29 +44,35 @@ "build:desktop-local": "bun run build && bun run linux:arm64:build && bun run linux:x64:build", "tauri": "tauri" }, + "overrides": { + "brace-expansion": "^5.0.7", + "@babel/core": "^7.29.1" + }, "dependencies": { "ldrs": "^1.1.9", - "lucide-react": "^1.17.0" + "lucide-react": "^1.26.0" }, "devDependencies": { - "@eslint/js": "^9.39.4", - "@tauri-apps/api": "^2.11.0", - "@tauri-apps/cli": "^2.11.2", + "@babel/core": "^8.0.1", + "brace-expansion": "^5.0.8", + "@eslint/js": "^9.39.5", + "@tauri-apps/api": "^2.11.1", + "@tauri-apps/cli": "^2.11.4", "@types/bun": "^1.3.14", - "@types/node": "^25.9.1", - "@types/react": "^19.2.15", + "@types/node": "^25.9.5", + "@types/react": "^19.2.17", "@types/react-dom": "^19.2.3", - "@vitejs/plugin-react": "^6.0.2", + "@vitejs/plugin-react": "^6.0.4", "appdmg": "^0.6.6", - "eslint": "^10.4.0", + "eslint": "^10.8.0", "eslint-config-prettier": "^10.1.8", "eslint-plugin-react-hooks": "^7.1.1", - "eslint-plugin-react-refresh": "^0.5.2", - "prettier": "^3.8.3", - "react": "^19.2.6", - "react-dom": "^19.2.6", + "eslint-plugin-react-refresh": "^0.5.3", + "prettier": "^3.9.6", + "react": "^19.2.8", + "react-dom": "^19.2.8", "typescript": "^6.0.3", - "typescript-eslint": "^8.59.2", - "vite": "^8.0.14" + "typescript-eslint": "^8.65.0", + "vite": "^8.1.5" } } diff --git a/scripts/updater-manifest.sh b/scripts/updater-manifest.sh new file mode 100755 index 0000000..78390cd --- /dev/null +++ b/scripts/updater-manifest.sh @@ -0,0 +1,75 @@ +#!/usr/bin/env bash +# Builds the `latest.json` that the in-app updater reads (tauri-plugin-updater's +# "static" manifest format: a version plus one entry per `{os}-{arch}` target). +# +# Signatures are embedded as strings, so the .sig files produced by the bundler are +# read here and never published as release assets. Any platform whose signature is +# missing is left out of the manifest entirely — the app then reports "no build for +# this platform" rather than downloading something it cannot verify. If no platform +# has a signature at all, nothing is written and the script exits 0: that is the +# normal state before an updater signing key exists (see docs/SIGNING.md). +# +# The shape this produces is locked by `updater_manifest_matches_the_plugin_format` +# in src-tauri/src/lib.rs. Change one and the other fails. +# +# Usage: scripts/updater-manifest.sh +set -euo pipefail + +tag="${1:?tag required, e.g. v1.0.30}" +repo="${2:?repo required, e.g. CanPixel/aether}" +artifacts="${3:?artifacts directory required}" +out="${4:?output file required}" + +version="${tag#v}" +base="https://github.com/${repo}/releases/download/${tag}" + +read_sig() { + local source + source="$(find "$artifacts/$1" -type f -name "$2" 2>/dev/null | sort | head -n 1)" + [[ -n "$source" ]] && tr -d '\n' < "$source" +} + +mac_sig="$(read_sig "aether-macos-updater" "*.app.tar.gz.sig" || true)" +win_sig="$(read_sig "aether-windows-x86_64-updater" "*.exe.sig" || true)" +linux_sig="$(read_sig "aether-linux-x86_64-updater" "*.AppImage.sig" || true)" + +if [[ -z "$mac_sig$win_sig$linux_sig" ]]; then + echo "No updater signatures found under $artifacts; not writing $out." >&2 + exit 0 +fi + +# Both macOS arches map to the same tarball: the bundle is built on Apple Silicon, +# and an Intel Mac running it under Rosetta still reports darwin-x86_64 to the +# updater. Drop darwin-x86_64 here if a real Intel or universal build is ever +# published as a separate asset. +jq -n \ + --arg version "$version" \ + --arg pub_date "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ + --arg notes "See https://github.com/${repo}/releases/tag/${tag}" \ + --arg mac_sig "$mac_sig" \ + --arg mac_url "$base/AETHER_macOS.app.tar.gz" \ + --arg win_sig "$win_sig" \ + --arg win_url "$base/AETHER_x64-setup.exe" \ + --arg linux_sig "$linux_sig" \ + --arg linux_url "$base/AETHER_amd64.AppImage" \ + '{ + version: $version, + notes: $notes, + pub_date: $pub_date, + platforms: ( + {} + + (if $mac_sig == "" then {} else { + "darwin-aarch64": { signature: $mac_sig, url: $mac_url }, + "darwin-x86_64": { signature: $mac_sig, url: $mac_url } + } end) + + (if $win_sig == "" then {} else { + "windows-x86_64": { signature: $win_sig, url: $win_url } + } end) + + (if $linux_sig == "" then {} else { + "linux-x86_64": { signature: $linux_sig, url: $linux_url } + } end) + ) + }' > "$out" + +echo "Wrote $out for $version with targets:" >&2 +jq -c '.platforms | keys' "$out" >&2 diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index cecba8b..a5e27db 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -22,6 +22,7 @@ dependencies = [ "tauri", "tauri-build", "tauri-plugin-opener", + "tauri-plugin-updater", "tokio", "url", "uuid", @@ -66,6 +67,15 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +dependencies = [ + "derive_arbitrary", +] + [[package]] name = "async-broadcast" version = "0.7.2" @@ -762,6 +772,17 @@ dependencies = [ "serde_core", ] +[[package]] +name = "derive_arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "derive_more" version = "2.1.1" @@ -1067,6 +1088,16 @@ dependencies = [ "rustc_version", ] +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -1979,6 +2010,36 @@ dependencies = [ "windows-sys 0.45.0", ] +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys 0.4.1", + "log", + "simd_cesu8", + "thiserror 2.0.18", + "walkdir", + "windows-link 0.2.1", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.117", +] + [[package]] name = "jni-sys" version = "0.3.1" @@ -2263,6 +2324,12 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" +[[package]] +name = "minisign-verify" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22f9645cb765ea72b8111f36c522475d2daa0d22c957a9826437e97534bc4e9e" + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -2505,6 +2572,7 @@ checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ "bitflags 2.13.0", "block2", + "libc", "objc2", "objc2-core-foundation", ] @@ -2520,6 +2588,18 @@ dependencies = [ "objc2-core-foundation", ] +[[package]] +name = "objc2-osa-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f112d1746737b0da274ef79a23aac283376f335f4095a083a267a082f21db0c0" +dependencies = [ + "bitflags 2.13.0", + "objc2", + "objc2-app-kit", + "objc2-foundation", +] + [[package]] name = "objc2-quartz-core" version = "0.3.2" @@ -2595,6 +2675,12 @@ dependencies = [ "pathdiff", ] +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + [[package]] name = "option-ext" version = "0.2.0" @@ -2611,6 +2697,20 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "osakit" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "732c71caeaa72c065bb69d7ea08717bd3f4863a4f451402fc9513e29dbd5261b" +dependencies = [ + "objc2", + "objc2-foundation", + "objc2-osa-kit", + "serde", + "serde_json", + "thiserror 2.0.18", +] + [[package]] name = "pango" version = "0.18.3" @@ -3216,15 +3316,20 @@ dependencies = [ "http-body", "http-body-util", "hyper", + "hyper-rustls", "hyper-util", "js-sys", "log", "percent-encoding", "pin-project-lite", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", "serde", "serde_json", "sync_wrapper", "tokio", + "tokio-rustls", "tokio-util", "tower", "tower-http", @@ -3292,6 +3397,18 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + [[package]] name = "rustls-pki-types" version = "1.14.1" @@ -3302,6 +3419,33 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-platform-verifier" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" +dependencies = [ + "core-foundation", + "core-foundation-sys", + "jni 0.22.4", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + [[package]] name = "rustls-webpki" version = "0.103.13" @@ -3334,6 +3478,15 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "schemars" version = "0.8.22" @@ -3406,6 +3559,29 @@ dependencies = [ "tendril 0.4.3", ] +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.13.0", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "selectors" version = "0.31.0" @@ -3663,6 +3839,22 @@ version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + [[package]] name = "siphasher" version = "1.0.3" @@ -3889,7 +4081,7 @@ dependencies = [ "gdkwayland-sys", "gdkx11-sys", "gtk", - "jni", + "jni 0.21.1", "libc", "log", "ndk", @@ -3922,6 +4114,17 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "tar" +version = "0.4.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" +dependencies = [ + "filetime", + "libc", + "xattr", +] + [[package]] name = "target-lexicon" version = "0.12.16" @@ -3945,7 +4148,7 @@ dependencies = [ "gtk", "heck 0.5.0", "http", - "jni", + "jni 0.21.1", "libc", "log", "mime", @@ -4079,6 +4282,39 @@ dependencies = [ "zbus", ] +[[package]] +name = "tauri-plugin-updater" +version = "2.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "806d9dac662c2e4594ff03c647a552f2c9bd544e7d0f683ec58f872f952ce4af" +dependencies = [ + "base64 0.22.1", + "dirs", + "flate2", + "futures-util", + "http", + "infer", + "log", + "minisign-verify", + "osakit", + "percent-encoding", + "reqwest 0.13.4", + "rustls", + "semver", + "serde", + "serde_json", + "tar", + "tauri", + "tauri-plugin", + "tempfile", + "thiserror 2.0.18", + "time", + "tokio", + "url", + "windows-sys 0.60.2", + "zip", +] + [[package]] name = "tauri-runtime" version = "2.11.2" @@ -4089,7 +4325,7 @@ dependencies = [ "dpi", "gtk", "http", - "jni", + "jni 0.21.1", "objc2", "objc2-ui-kit", "objc2-web-kit", @@ -4112,7 +4348,7 @@ checksum = "b83849ee63ecb27a8e8d0fe51915ca215076914aca43f96db1179f0f415f6cd9" dependencies = [ "gtk", "http", - "jni", + "jni 0.21.1", "log", "objc2", "objc2-app-kit", @@ -4320,9 +4556,21 @@ dependencies = [ "mio", "pin-project-lite", "socket2", + "tokio-macros", "windows-sys 0.61.2", ] +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "tokio-rustls" version = "0.26.4" @@ -4985,6 +5233,15 @@ dependencies = [ "system-deps", ] +[[package]] +name = "webpki-root-certs" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "webpki-roots" version = "1.0.7" @@ -5616,7 +5873,7 @@ dependencies = [ "gtk", "http", "javascriptcore-rs", - "jni", + "jni 0.21.1", "libc", "ndk", "objc2", @@ -5663,6 +5920,16 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix", +] + [[package]] name = "yoke" version = "0.8.3" @@ -5827,6 +6094,18 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "zip" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa8cd6af31c3b31c6631b8f483848b91589021b28fffe50adada48d4f4d2ed1" +dependencies = [ + "arbitrary", + "crc32fast", + "indexmap 2.14.0", + "memchr", +] + [[package]] name = "zmij" version = "1.0.21" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index e0508e4..9e58109 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -30,6 +30,17 @@ tokio = { version = "1", features = ["fs", "io-util", "sync", "time"] } url = "2" uuid = { version = "1", features = ["v4", "serde"] } +[dev-dependencies] +# The store-durability tests drive async fs helpers, which needs a runtime. Dev +# features are not unified into non-test builds, so this does not affect the app. +tokio = { version = "1", features = ["fs", "io-util", "macros", "rt"] } + +# The updater replaces the installed bundle in place, which only means anything on +# desktop: Android and iOS updates go through their stores. Keeping the plugin out +# of the mobile dependency graph entirely also keeps it from breaking those builds. +[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies] +tauri-plugin-updater = "2" + [target.'cfg(target_os = "macos")'.dependencies] # Cargo unions features across target tables, so on macOS llama.cpp gets metal + sampler. llama-cpp-2 = { version = "0.1.146", default-features = false, features = ["metal"] } diff --git a/src-tauri/src/air.rs b/src-tauri/src/air.rs new file mode 100644 index 0000000..994f74d --- /dev/null +++ b/src-tauri/src/air.rs @@ -0,0 +1,791 @@ +//! AiR: rendering a set of captures into a Markdown dossier on disk. + +use super::*; + +pub(crate) async fn air_prepare_dossier( + state: &State<'_, Backend>, + input: AirDossierInput, + synthesize: bool, +) -> Cmd { + let lens_kind = input.lens_kind.unwrap_or_default(); + let lens = input.lens.trim().to_string(); + let visible_lens = if lens.is_empty() { + match lens_kind { + AirLensKind::Topic => "Local knowledge".to_string(), + AirLensKind::Flow => "Current Flow lens".to_string(), + AirLensKind::Hub => "Selected knowledge hub".to_string(), + AirLensKind::Answer => "Latest AiON answer".to_string(), + AirLensKind::Iceberg => "Saved iCE map".to_string(), + } + } else { + lens + }; + let generated_at = now(); + let limit = input.limit.unwrap_or(12).clamp(1, 24); + let (sources, seed_answer, ice_map, seed_model) = + air_gather_context(state, &input, lens_kind, &visible_lens, limit).await?; + let title = format!("AiR Dossier: {visible_lens}"); + let output_dir = resolve_air_export_dir(&state.paths, false).await?; + + let mut model = seed_model.unwrap_or_else(|| "deterministic-scaffold".to_string()); + let synthesized_sections = if synthesize { + match air_synthesize_sections( + state, + &visible_lens, + lens_kind, + &sources, + seed_answer.as_deref(), + ice_map.as_deref(), + ) + .await + { + Ok((synthesis_model, sections)) => { + model = synthesis_model; + Some(sections) + } + Err(_) => None, + } + } else { + None + }; + + let markdown_preview = build_air_markdown(AirMarkdownInput { + title: &title, + lens: &visible_lens, + lens_kind, + generated_at: &generated_at, + model: &model, + sources: &sources, + synthesized_sections: synthesized_sections.as_deref(), + seed_answer: seed_answer.as_deref(), + ice_map: ice_map.as_deref(), + }); + + Ok(AirPreparedDossier { + title, + lens: visible_lens, + lens_kind, + generated_at, + model: Some(model), + output_dir: output_dir.display().to_string(), + markdown_preview, + sources, + }) +} + +pub(crate) async fn air_gather_context( + state: &State<'_, Backend>, + input: &AirDossierInput, + lens_kind: AirLensKind, + lens: &str, + limit: usize, +) -> Cmd<( + Vec, + Option, + Option, + Option, +)> { + match lens_kind { + AirLensKind::Answer => { + let Some(answer) = input.answer.clone() else { + return Ok((Vec::new(), None, None, None)); + }; + let library = load_library(&state.paths.library_path).await?; + let collection_names = library + .collections + .iter() + .map(|collection| (collection.id.clone(), collection.name.clone())) + .collect::>(); + let sources = search_results_to_air_sources( + dedupe_citations(answer.citations) + .into_iter() + .take(limit) + .collect(), + &collection_names, + ); + Ok((sources, Some(answer.answer), None, Some(answer.model))) + } + AirLensKind::Iceberg => { + let icebergs = load_icebergs(&state.paths.icebergs_path).await?; + let selected = input + .saved_iceberg_id + .as_deref() + .and_then(|id| icebergs.icebergs.iter().find(|iceberg| iceberg.id == id)) + .or_else(|| { + icebergs + .icebergs + .iter() + .find(|iceberg| iceberg.title.eq_ignore_ascii_case(lens)) + }) + .or_else(|| icebergs.icebergs.first()); + let Some(iceberg) = selected else { + return Ok((Vec::new(), None, None, None)); + }; + let mut items = iceberg.iceberg.items.clone(); + items.sort_by(|left, right| { + left.level + .cmp(&right.level) + .then_with(|| left.name.to_lowercase().cmp(&right.name.to_lowercase())) + }); + let sources = items + .iter() + .take(limit) + .map(|item| AirDossierSource { + id: format!("iceberg-{}", item.id), + title: item.name.clone(), + excerpt: format!("Level {}: {}", item.level, item.description), + collection_name: Some(format!("iCE · {}", iceberg.title)), + url: None, + host: None, + captured_at: Some(iceberg.updated_at.clone()), + score: None, + }) + .collect::>(); + let map = items + .iter() + .map(|item| { + format!( + "- Level {} · {}: {}", + item.level, item.name, item.description + ) + }) + .collect::>() + .join("\n"); + Ok(( + sources, + None, + Some(map), + Some(iceberg.iceberg.model.clone()), + )) + } + AirLensKind::Hub => { + let sources = air_sources_for_hub(state, input.collection_id.as_deref(), limit).await?; + Ok((sources, None, None, None)) + } + AirLensKind::Flow => { + if let Some(collection_id) = input.collection_id.as_deref() { + let sources = air_sources_for_hub(state, Some(collection_id), limit).await?; + return Ok((sources, None, None, None)); + } + let mut sources = match input.capture_id.as_deref() { + Some(capture_id) => air_sources_for_capture(state, capture_id).await?, + None => Vec::new(), + }; + let existing = sources + .iter() + .map(|source| source.id.clone()) + .collect::>(); + let related = air_sources_for_topic(state, lens, limit).await?; + sources.extend( + related + .into_iter() + .filter(|source| !existing.contains(&source.id)) + .take(limit.saturating_sub(sources.len())), + ); + Ok((sources, None, None, None)) + } + AirLensKind::Topic => { + let sources = air_sources_for_topic(state, lens, limit).await?; + Ok((sources, None, None, None)) + } + } +} + +pub(crate) async fn air_sources_for_hub( + state: &State<'_, Backend>, + collection_id: Option<&str>, + limit: usize, +) -> Cmd> { + let library = load_library(&state.paths.library_path).await?; + let collection_names = library + .collections + .iter() + .map(|collection| (collection.id.clone(), collection.name.clone())) + .collect::>(); + let vectors = with_vectors_read(state, |vectors| vectors.chunks.clone()).await?; + let mut chunks_by_capture = HashMap::>::new(); + for chunk in vectors { + chunks_by_capture + .entry(chunk.capture_id.clone()) + .or_default() + .push(chunk); + } + let mut captures = library + .captures + .into_iter() + .filter(|capture| { + collection_id + .map(|id| capture.collection_id == id) + .unwrap_or(true) + }) + .collect::>(); + captures.sort_by(|left, right| right.captured_at.cmp(&left.captured_at)); + Ok(captures + .into_iter() + .take(limit) + .map(|capture| { + let excerpt = chunks_by_capture + .get(&capture.id) + .and_then(|chunks| chunks.iter().max_by_key(|chunk| chunk.text.chars().count())) + .map(|chunk| semantic_trail_excerpt(&chunk.text, 700)) + .or_else(|| { + capture + .metadata + .as_ref() + .and_then(|metadata| metadata.summary.clone()) + }) + .unwrap_or_default(); + AirDossierSource { + id: capture.id.clone(), + title: capture.title, + excerpt, + collection_name: collection_names.get(&capture.collection_id).cloned(), + url: Some(capture.url.clone()), + host: Some(get_tab_host(&capture.url)), + captured_at: Some(capture.captured_at), + score: None, + } + }) + .collect()) +} + +pub(crate) async fn air_sources_for_capture( + state: &State<'_, Backend>, + capture_id: &str, +) -> Cmd> { + let library = load_library(&state.paths.library_path).await?; + let collection_names = library + .collections + .iter() + .map(|collection| (collection.id.clone(), collection.name.clone())) + .collect::>(); + let Some(capture) = library + .captures + .iter() + .find(|capture| capture.id == capture_id) + .cloned() + else { + return Ok(Vec::new()); + }; + let mut chunks = with_vectors_read(state, |vectors| { + vectors + .chunks + .iter() + .filter(|chunk| chunk.capture_id == capture_id) + .cloned() + .collect::>() + }) + .await?; + chunks.sort_by_key(|chunk| chunk.chunk_index); + let excerpt = semantic_trail_excerpt( + &chunks + .iter() + .map(|chunk| chunk.text.as_str()) + .collect::>() + .join("\n\n"), + 1200, + ); + Ok(vec![AirDossierSource { + id: capture.id.clone(), + title: capture.title, + excerpt, + collection_name: collection_names.get(&capture.collection_id).cloned(), + url: Some(capture.url.clone()), + host: Some(get_tab_host(&capture.url)), + captured_at: Some(capture.captured_at), + score: Some(100.0), + }]) +} + +pub(crate) async fn air_sources_for_topic( + state: &State<'_, Backend>, + lens: &str, + limit: usize, +) -> Cmd> { + let library = load_library(&state.paths.library_path).await?; + let collection_names = library + .collections + .iter() + .map(|collection| (collection.id.clone(), collection.name.clone())) + .collect::>(); + let vectors = with_vectors_read(state, |vectors| vectors.chunks.clone()).await?; + if vectors.is_empty() { + return Ok(Vec::new()); + } + + let results = if lens.trim().is_empty() || lens == "Current Flow lens" { + let mut chunks = vectors; + chunks.sort_by(|left, right| right.captured_at.cmp(&left.captured_at)); + chunks + .into_iter() + .take(limit * 2) + .map(|chunk| SearchResult { + score: 0.0, + id: chunk.id, + collection_id: chunk.collection_id, + capture_id: chunk.capture_id, + app_id: chunk.app_id, + title: chunk.title, + url: chunk.url, + captured_at: chunk.captured_at, + chunk_index: chunk.chunk_index, + text: chunk.text, + }) + .collect::>() + } else { + let settings = load_settings(&state.paths.settings_path).await?; + let query_vector = local_embed_query(state, &settings, lens.to_string()).await?; + let mut scored = vectors + .into_iter() + .filter_map(|chunk| { + let distance = cosine_distance(&query_vector, &chunk.vector); + if !distance.is_finite() { + return None; + } + Some((distance, chunk)) + }) + .collect::>(); + scored.sort_by(|left, right| left.0.partial_cmp(&right.0).unwrap_or(Ordering::Equal)); + scored + .into_iter() + .take(limit * 3) + .map(|(score, chunk)| SearchResult { + score, + id: chunk.id, + collection_id: chunk.collection_id, + capture_id: chunk.capture_id, + app_id: chunk.app_id, + title: chunk.title, + url: chunk.url, + captured_at: chunk.captured_at, + chunk_index: chunk.chunk_index, + text: chunk.text, + }) + .collect::>() + }; + Ok(search_results_to_air_sources( + dedupe_citations(results).into_iter().take(limit).collect(), + &collection_names, + )) +} + +pub(crate) fn search_results_to_air_sources( + results: Vec, + collection_names: &HashMap, +) -> Vec { + results + .into_iter() + .map(|result| { + let collection_name = collection_names.get(&result.collection_id).cloned(); + AirDossierSource { + id: result.capture_id.clone(), + title: result.title, + excerpt: semantic_trail_excerpt(&result.text, 850), + collection_name, + url: Some(result.url.clone()), + host: Some(get_tab_host(&result.url)), + captured_at: Some(result.captured_at), + score: Some(round_score(semantic_score_from_distance(result.score))), + } + }) + .collect() +} + +pub(crate) async fn air_synthesize_sections( + state: &State<'_, Backend>, + lens: &str, + lens_kind: AirLensKind, + sources: &[AirDossierSource], + seed_answer: Option<&str>, + ice_map: Option<&str>, +) -> Cmd<(String, String)> { + let settings = load_settings(&state.paths.settings_path).await?; + let citations = sources + .iter() + .enumerate() + .map(|(index, source)| SearchResult { + id: source.id.clone(), + collection_id: source.collection_name.clone().unwrap_or_default(), + capture_id: source.id.clone(), + app_id: "air".to_string(), + title: source.title.clone(), + url: source + .url + .clone() + .unwrap_or_else(|| format!("aether://air/source/{}", index + 1)), + captured_at: source.captured_at.clone().unwrap_or_else(now), + chunk_index: index, + text: source.excerpt.clone(), + score: source.score.unwrap_or(0.0), + }) + .collect::>(); + let source_rule = if citations.is_empty() { + "No local source excerpts matched. Say that coverage is currently sparse." + } else { + "Use only the numbered local source excerpts. Cite every concrete claim with bracket citations like [1] or [2]." + }; + let prompt = format!( + "Render a concise Markdown dossier for Æther AiR.\nLens kind: {}\nLens: {lens}\n{source_rule}\n\nInclude exactly these sections:\n## Summary\n## Key Findings\n## Source-Backed Notes\n## Unresolved Questions\n\nDo not include YAML frontmatter, a title, or a source index. Keep prose dense and professional.\n\nSeed AiON answer:\n{}\n\nSaved iCE map:\n{}", + air_lens_label(lens_kind), + seed_answer.unwrap_or("None"), + ice_map.unwrap_or("None") + ); + let result = local_chat(state, &settings, &prompt, citations, &[], None).await?; + Ok(( + result.model, + normalize_model_markdown_citations(&result.answer, sources.len()), + )) +} + +pub(crate) struct AirMarkdownInput<'a> { + pub(crate) title: &'a str, + pub(crate) lens: &'a str, + pub(crate) lens_kind: AirLensKind, + pub(crate) generated_at: &'a str, + pub(crate) model: &'a str, + pub(crate) sources: &'a [AirDossierSource], + pub(crate) synthesized_sections: Option<&'a str>, + pub(crate) seed_answer: Option<&'a str>, + pub(crate) ice_map: Option<&'a str>, +} + +pub(crate) fn build_air_markdown(input: AirMarkdownInput<'_>) -> String { + let tags = air_tags(input.lens) + .into_iter() + .map(|tag| yaml_string(&tag)) + .collect::>() + .join(", "); + let mut markdown = format!( + "---\ntitle: {}\ncreated: {}\naether_lens: {}\nsource_count: {}\ntags: [{}]\nmodel: {}\ntype: aether-dossier\n---\n\n# {}\n\n_Rendered by AiR from the {} lens on {}._\n\n", + yaml_string(input.title), + yaml_string(input.generated_at), + yaml_string(input.lens), + input.sources.len(), + tags, + yaml_string(input.model), + markdown_escape(input.title), + air_lens_label(input.lens_kind), + markdown_escape(input.generated_at) + ); + + if let Some(sections) = input + .synthesized_sections + .map(str::trim) + .filter(|sections| !sections.is_empty()) + { + markdown.push_str(sections); + markdown.push_str("\n\n"); + } else { + markdown.push_str("## Summary\n\n"); + if input.sources.is_empty() { + markdown.push_str(&format!( + "No local sources matched [[{}]] yet. This dossier preserves the requested lens and can be regenerated after more captures are available.\n\n", + markdown_escape(input.lens) + )); + } else { + markdown.push_str(&format!( + "This dossier gathers {} local source{} around [[{}]]. It is generated as a deterministic citation scaffold from Æther's captured knowledge.\n\n", + input.sources.len(), + if input.sources.len() == 1 { "" } else { "s" }, + markdown_escape(input.lens) + )); + } + + markdown.push_str("## Key Findings\n\n"); + if input.sources.is_empty() { + markdown.push_str("- Coverage is sparse; capture or connect more relevant material before treating this lens as complete.\n\n"); + } else { + for (index, source) in input.sources.iter().take(6).enumerate() { + markdown.push_str(&format!( + "- **{}** anchors this lens with: {} [^{}]\n", + markdown_escape(&source.title), + markdown_escape(&semantic_trail_excerpt(&source.excerpt, 180)), + index + 1 + )); + } + markdown.push('\n'); + } + + markdown.push_str("## Source-Backed Notes\n\n"); + for (index, source) in input.sources.iter().enumerate() { + markdown.push_str(&format!( + "### {}. {}\n\n{}\n\n", + index + 1, + markdown_escape(&source.title), + markdown_escape(&source.excerpt) + )); + let mut meta = Vec::new(); + if let Some(collection) = &source.collection_name { + meta.push(format!("Hub: {}", markdown_escape(collection))); + } + if let Some(host) = &source.host { + meta.push(format!("Host: {}", markdown_escape(host))); + } + if let Some(captured_at) = &source.captured_at { + meta.push(format!("Captured: {}", markdown_escape(captured_at))); + } + if let Some(score) = source.score { + meta.push(format!("Match: {score:.1}")); + } + if !meta.is_empty() { + markdown.push_str(&format!("{}\n\n", meta.join(" · "))); + } + } + + if let Some(answer) = input + .seed_answer + .map(str::trim) + .filter(|answer| !answer.is_empty()) + { + markdown.push_str("## AiON Seed\n\n"); + markdown.push_str(&markdown_escape(answer)); + markdown.push_str("\n\n"); + } + + if let Some(map) = input.ice_map.map(str::trim).filter(|map| !map.is_empty()) { + markdown.push_str("## iCE Map\n\n"); + markdown.push_str(&markdown_escape(map)); + markdown.push_str("\n\n"); + } + + markdown.push_str("## Unresolved Questions\n\n"); + markdown.push_str("- Which claims need fresher or primary-source confirmation?\n"); + markdown.push_str("- Which adjacent hubs should be captured next to improve coverage?\n\n"); + } + + markdown.push_str("## Source Index\n\n"); + if input.sources.is_empty() { + markdown.push_str("No source index entries were available for this render.\n"); + } else { + for (index, source) in input.sources.iter().enumerate() { + let title = markdown_escape(&source.title); + let collection = source + .collection_name + .as_deref() + .map(markdown_escape) + .unwrap_or_else(|| "Knowledge Hub".to_string()); + let captured_at = source + .captured_at + .as_deref() + .map(markdown_escape) + .unwrap_or_else(|| "unknown capture date".to_string()); + if let Some(url) = &source.url { + markdown.push_str(&format!( + "[^{}]: [{}]({}) — {} · {}\n", + index + 1, + title, + url.replace(')', "%29"), + collection, + captured_at + )); + } else { + markdown.push_str(&format!( + "[^{}]: {} — {} · {}\n", + index + 1, + title, + collection, + captured_at + )); + } + } + } + markdown +} + +pub(crate) async fn resolve_air_export_dir(paths: &DataPaths, create: bool) -> Cmd { + let preferred = documents_air_dir(); + let candidates = preferred + .into_iter() + .chain(std::iter::once(paths.air_exports_path.clone())) + .collect::>(); + for candidate in candidates { + if !create { + if candidate.exists() + || candidate + .parent() + .is_some_and(|parent| parent.exists() && parent.is_dir()) + { + return Ok(candidate); + } + continue; + } + if tokio::fs::create_dir_all(&candidate).await.is_ok() { + return Ok(candidate); + } + } + if create { + Err("Could not create an AiR export folder.".to_string()) + } else { + Ok(paths.air_exports_path.clone()) + } +} + +pub(crate) fn documents_air_dir() -> Option { + env::var_os("HOME") + .or_else(|| env::var_os("USERPROFILE")) + .map(PathBuf::from) + .map(|home| home.join("Documents").join("Æther").join("AiR")) +} + +pub(crate) async fn air_list_recent(paths: &DataPaths) -> Cmd> { + let mut files = Vec::::new(); + let mut directories = Vec::new(); + if let Some(documents) = documents_air_dir() { + directories.push(documents); + } + directories.push(paths.air_exports_path.clone()); + + for directory in directories { + let Ok(mut entries) = tokio::fs::read_dir(&directory).await else { + continue; + }; + while let Some(entry) = entries + .next_entry() + .await + .map_err(|error| error.to_string())? + { + let path = entry.path(); + if !path + .extension() + .and_then(|extension| extension.to_str()) + .is_some_and(|extension| extension.eq_ignore_ascii_case("md")) + { + continue; + } + let filename = path + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("aether-dossier.md") + .to_string(); + let metadata = entry.metadata().await.ok(); + let rendered_at = metadata + .and_then(|metadata| metadata.modified().ok()) + .map(|modified| { + DateTime::::from(modified) + .to_rfc3339_opts(chrono::SecondsFormat::Millis, true) + }) + .unwrap_or_else(now); + let content = tokio::fs::read_to_string(&path).await.unwrap_or_default(); + files.push(AirRecentFile { + title: air_frontmatter_value(&content, "title") + .unwrap_or_else(|| air_title_from_filename(&filename)), + lens: air_frontmatter_value(&content, "aether_lens").unwrap_or_default(), + source_count: air_frontmatter_value(&content, "source_count") + .and_then(|value| value.parse::().ok()) + .unwrap_or(0), + path: path.display().to_string(), + filename, + rendered_at, + }); + } + } + files.sort_by(|left, right| right.rendered_at.cmp(&left.rendered_at)); + files.truncate(12); + Ok(files) +} + +pub(crate) fn air_frontmatter_value(markdown: &str, key: &str) -> Option { + let mut lines = markdown.lines(); + if lines.next()? != "---" { + return None; + } + let prefix = format!("{key}:"); + for line in lines { + if line == "---" { + break; + } + if let Some(value) = line.strip_prefix(&prefix) { + return Some(value.trim().trim_matches('"').replace("\\\"", "\"")); + } + } + None +} + +pub(crate) fn air_dossier_filename(title: &str, generated_at: &str) -> String { + let _ = generated_at; + let filename = title + .trim() + .chars() + .map(|char| match char { + '/' | '\\' | '\0' => '-', + char if char.is_control() => ' ', + char => char, + }) + .collect::() + .split_whitespace() + .collect::>() + .join(" ") + .trim_matches(['.', ' ']) + .to_string(); + if filename.is_empty() { + "AiR Dossier.md".to_string() + } else { + format!("{filename}.md") + } +} + +pub(crate) fn air_title_from_filename(filename: &str) -> String { + filename + .trim_end_matches(".md") + .split('-') + .filter(|part| !part.chars().all(|char| char.is_ascii_digit())) + .take(8) + .map(|part| { + let mut chars = part.chars(); + match chars.next() { + Some(first) => format!("{}{}", first.to_uppercase(), chars.as_str()), + None => String::new(), + } + }) + .collect::>() + .join(" ") +} + +pub(crate) fn yaml_string(value: &str) -> String { + format!( + "\"{}\"", + value + .replace('\\', "\\\\") + .replace('"', "\\\"") + .replace(['\n', '\r'], " ") + ) +} + +pub(crate) fn markdown_escape(value: &str) -> String { + strip_numeric_bracket_markers(value) + .replace('\r', "") + .trim() + .to_string() +} + +pub(crate) fn air_tags(lens: &str) -> Vec { + let mut tags = vec![ + "aether".to_string(), + "air".to_string(), + "dossier".to_string(), + ]; + for word in lens + .split(|char: char| !char.is_alphanumeric()) + .filter(|word| word.chars().count() >= 3) + .take(4) + { + tags.push(word.to_lowercase()); + } + tags.sort(); + tags.dedup(); + tags +} + +pub(crate) fn air_lens_label(kind: AirLensKind) -> &'static str { + match kind { + AirLensKind::Topic => "topic", + AirLensKind::Flow => "Flow", + AirLensKind::Hub => "hub", + AirLensKind::Answer => "AiON answer", + AirLensKind::Iceberg => "iCE", + } +} + +pub(crate) fn normalize_model_markdown_citations(markdown: &str, source_count: usize) -> String { + normalize_answer_citations(&clean_model_output(markdown), source_count) +} diff --git a/src-tauri/src/chat.rs b/src-tauri/src/chat.rs new file mode 100644 index 0000000..6642233 --- /dev/null +++ b/src-tauri/src/chat.rs @@ -0,0 +1,319 @@ +//! Chat prompt assembly and model-output cleanup. +//! +//! The cleanup half exists because a local model's raw output is not presentable: +//! it leaks stop markers, invents citation numbers past the source count, and +//! spaces brackets inconsistently. + +use super::*; + +pub(crate) fn chat_citation_limit() -> usize { + if cfg!(mobile) { + 5 + } else { + 8 + } +} + +pub(crate) fn chat_snippet_char_limit() -> usize { + if cfg!(mobile) { + 1100 + } else { + usize::MAX + } +} + +pub(crate) fn build_chat_messages( + prompt: &str, + citations: &[SearchResult], + history: &[ConversationTurn], +) -> Vec { + let snippet_limit = chat_snippet_char_limit(); + let context_block = citations + .iter() + .enumerate() + .map(|(index, item)| { + let mut source_text = strip_numeric_bracket_markers(&item.text); + if source_text.chars().count() > snippet_limit { + source_text = source_text.chars().take(snippet_limit).collect(); + } + format!( + "[{}] {}\nURL: {}\nCollection: {}\n{}", + index + 1, + item.title, + item.url, + item.collection_id, + source_text + ) + }) + .collect::>() + .join("\n\n"); + let context = if context_block.is_empty() { + "No stored context was retrieved." + } else { + &context_block + }; + // Phones decode on CPU, so every generated token is user-visible latency: + // steer the model toward tight answers instead of hard-truncating them. + let brevity = if cfg!(mobile) { + " Keep answers concise: a few sentences or a short list unless the question needs more." + } else { + "" + }; + let mut messages = vec![ChatPromptMessage { + role: "system", + content: format!( + "You are Æther, a private local research assistant. Answer only from the supplied local collection context. If the context is insufficient, say what is missing. Cite sources only with Æther source numbers [1] through [{}]. Do not copy bracketed reference numbers from webpage text.{brevity}", + citations.len().max(1) + ), + }]; + + // Prior turns come first so "what about that?" resolves, but each answer is + // condensed: the citations for *this* question must stay the dominant context. + for turn in history { + messages.push(ChatPromptMessage { + role: "user", + content: turn.prompt.clone(), + }); + messages.push(ChatPromptMessage { + role: "assistant", + content: condense_history_answer(&turn.answer), + }); + } + + messages.push(ChatPromptMessage { + role: "user", + content: format!("Local collection context:\n{context}\n\nQuestion: {prompt}"), + }); + messages +} + +// Strips citation markers and clips length. Replaying markers from an old answer +// would let the model reuse source numbers that no longer refer to anything. +pub(crate) fn condense_history_answer(answer: &str) -> String { + let cleaned = strip_numeric_bracket_markers(answer); + let trimmed = cleaned.trim(); + if trimmed.chars().count() <= PROMPT_HISTORY_ANSWER_CHARS { + return trimmed.to_string(); + } + let mut condensed = trimmed + .chars() + .take(PROMPT_HISTORY_ANSWER_CHARS) + .collect::(); + condensed.push('…'); + condensed +} + +pub(crate) fn render_model_chat_prompt( + model: &LlamaModel, + messages: &[ChatPromptMessage], +) -> Cmd { + let template = match model.chat_template(None) { + Ok(template) => template, + Err(_) => { + return Ok(RenderedChatPrompt { + prompt: fallback_chat_prompt(messages), + add_bos: AddBos::Never, + }) + } + }; + let chat = messages + .iter() + .map(|message| LlamaChatMessage::new(message.role.to_string(), message.content.clone())) + .collect::, _>>() + .map_err(|error| error.to_string())?; + match model.apply_chat_template(&template, &chat, true) { + Ok(prompt) => Ok(RenderedChatPrompt { + prompt, + add_bos: AddBos::Never, + }), + Err(_) => Ok(RenderedChatPrompt { + prompt: fallback_chat_prompt(messages), + add_bos: AddBos::Never, + }), + } +} + +pub(crate) fn fallback_chat_prompt(messages: &[ChatPromptMessage]) -> String { + let mut prompt = String::from(""); + let mut system_messages = Vec::new(); + + for message in messages { + match message.role { + "system" | "developer" => { + system_messages.push(message.content.trim().to_string()); + } + "assistant" => { + prompt.push_str("<|turn>model\n"); + prompt.push_str(message.content.trim()); + prompt.push_str("\n"); + } + "user" => { + if !system_messages.is_empty() { + prompt.push_str("<|turn>system\n"); + prompt.push_str(&system_messages.join("\n\n")); + prompt.push_str("\n"); + system_messages.clear(); + } + prompt.push_str("<|turn>user\n"); + prompt.push_str(message.content.trim()); + prompt.push_str("\n"); + } + role => { + prompt.push_str("<|turn>"); + prompt.push_str(role); + prompt.push('\n'); + prompt.push_str(message.content.trim()); + prompt.push_str("\n"); + } + } + } + + if !system_messages.is_empty() { + prompt.push_str("<|turn>system\n"); + prompt.push_str(&system_messages.join("\n\n")); + prompt.push_str("\n"); + } + + prompt.push_str("<|turn>model\n"); + prompt +} + +// Streaming deltas hold back a short tail starting at the most recent '<' so a +// stop marker arriving across several tokens is never shown to the user. +pub(crate) fn stream_safe_len(output: &str) -> usize { + let tail_start = output.len().saturating_sub(18); + let Some((boundary, _)) = output + .char_indices() + .find(|(index, _)| *index >= tail_start) + else { + return output.len(); + }; + match output[boundary..].rfind('<') { + Some(position) => boundary + position, + None => output.len(), + } +} + +pub(crate) fn contains_stop_marker(output: &str) -> bool { + output.contains("") + || output.contains("") + || output.contains("") + || output.contains("<|turn>") + || output.contains("<|eot_id|>") + || output.contains("<|end|>") +} + +pub(crate) fn clean_model_output(output: &str) -> String { + let mut cleaned = output.to_string(); + for marker in [ + "", + "model", + "assistant", + "", + "", + "<|turn>model", + "<|turn>assistant", + "<|turn>user", + "<|turn>system", + "<|turn>", + "<|eot_id|>", + "<|end|>", + ] { + cleaned = cleaned.replace(marker, ""); + } + cleaned.trim().to_string() +} + +pub(crate) fn normalize_answer_citations(answer: &str, citation_count: usize) -> String { + tidy_citation_spacing(&rewrite_numeric_bracket_markers( + answer, + citation_count, + true, + )) +} + +pub(crate) fn strip_numeric_bracket_markers(text: &str) -> String { + rewrite_numeric_bracket_markers(text, 0, false) +} + +pub(crate) fn rewrite_numeric_bracket_markers( + text: &str, + citation_count: usize, + keep_valid: bool, +) -> String { + let mut rewritten = String::with_capacity(text.len()); + let mut cursor = 0usize; + + while let Some(relative_start) = text[cursor..].find('[') { + let start = cursor + relative_start; + let Some(relative_end) = text[start + 1..].find(']') else { + break; + }; + let end = start + 1 + relative_end; + let inner = &text[start + 1..end]; + let Some(numbers) = parse_numeric_citation_marker(inner) else { + rewritten.push_str(&text[cursor..=start]); + cursor = start + 1; + continue; + }; + + rewritten.push_str(&text[cursor..start]); + if keep_valid { + let valid = numbers + .into_iter() + .filter(|number| *number > 0 && *number <= citation_count) + .map(|number| number.to_string()) + .collect::>(); + if !valid.is_empty() { + rewritten.push('['); + rewritten.push_str(&valid.join(", ")); + rewritten.push(']'); + } + } + cursor = end + 1; + } + + rewritten.push_str(&text[cursor..]); + rewritten +} + +pub(crate) fn parse_numeric_citation_marker(value: &str) -> Option> { + if value.trim().is_empty() + || !value.chars().all(|character| { + character.is_ascii_digit() || character == ',' || character.is_whitespace() + }) + { + return None; + } + + let mut numbers = Vec::new(); + for part in value.split(',') { + let part = part.trim(); + if part.is_empty() { + return None; + } + let number = part.parse::().ok()?; + numbers.push(number); + } + (!numbers.is_empty()).then_some(numbers) +} + +pub(crate) fn tidy_citation_spacing(value: &str) -> String { + let mut tidied = value.trim().to_string(); + for (from, to) in [ + (" .", "."), + (" ,", ","), + (" ;", ";"), + (" :", ":"), + (" !", "!"), + (" ?", "?"), + (" )", ")"), + ("( ", "("), + ] { + tidied = tidied.replace(from, to); + } + while tidied.contains(" ") { + tidied = tidied.replace(" ", " "); + } + tidied +} diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs new file mode 100644 index 0000000..0df2f2d --- /dev/null +++ b/src-tauri/src/commands.rs @@ -0,0 +1,2052 @@ +//! Every `#[tauri::command]` the renderer can invoke. +//! +//! Thin by design: each one validates its input, delegates to the domain function +//! below, and maps the result into `Cmd`. Command names are the IPC contract +//! with src/renderer/src/tauri-aether.ts and are also listed in `generate_handler!` +//! in lib.rs — adding one means touching all three. + +use super::*; + +#[tauri::command] +pub(crate) fn aether_state(state: State) -> Cmd { + Ok(lock_tabs(&state)?.state()) +} + +#[tauri::command] +pub(crate) fn aether_apps_list(state: State) -> Cmd> { + Ok(lock_tabs(&state)?.apps()) +} + +#[tauri::command(rename_all = "camelCase")] +pub(crate) fn aether_apps_activate( + app: AppHandle, + state: State, + app_id: String, +) -> Cmd<()> { + if app_id != "browser" { + return Err(format!("Unknown app: {app_id}")); + } + { + let mut tabs = lock_tabs(&state)?; + tabs.dashboard_open = false; + } + let active_tab_id = lock_tabs(&state)?.active_tab_id.clone(); + ensure_native_webview(&app, &state, &active_tab_id)?; + emit_state(&app, &state) +} + +#[tauri::command(rename_all = "camelCase")] +pub(crate) async fn aether_apps_navigate( + app: AppHandle, + state: State<'_, Backend>, + app_id: String, + url: String, +) -> Cmd<()> { + if app_id != "browser" { + return Err(format!("Unknown app: {app_id}")); + } + navigate_active_tab(&app, &state, &url).await +} + +#[tauri::command(rename_all = "camelCase")] +pub(crate) fn aether_apps_go_back( + app: AppHandle, + state: State, + app_id: String, +) -> Cmd<()> { + if app_id != "browser" { + return Err(format!("Unknown app: {app_id}")); + } + aether_tabs_go_back(app, state, String::new()) +} + +#[tauri::command(rename_all = "camelCase")] +pub(crate) fn aether_apps_go_forward( + app: AppHandle, + state: State, + app_id: String, +) -> Cmd<()> { + if app_id != "browser" { + return Err(format!("Unknown app: {app_id}")); + } + aether_tabs_go_forward(app, state, String::new()) +} + +#[tauri::command] +pub(crate) fn aether_tabs_list(state: State) -> Cmd> { + Ok(lock_tabs(&state)?.tabs()) +} + +#[tauri::command] +pub(crate) async fn aether_tabs_create( + app: AppHandle, + state: State<'_, Backend>, + input: Option, +) -> Cmd { + let settings = load_settings(&state.paths.settings_path).await?; + // No URL → open a blank start-page tab (Portals + search) rather than a search engine. + let requested_url = input.and_then(|input| input.url); + let url = match requested_url { + Some(raw_url) => normalize_url(&raw_url, &settings.browser.default_search_engine), + None => START_PAGE_URL.to_string(), + }; + let tab = ManagedTab::new("browser", &url); + let tab_id = tab.id.clone(); + let summary = tab.summary(true); + { + let mut tabs = lock_tabs(&state)?; + tabs.active_tab_id = tab.id.clone(); + tabs.active_app_id = tab.app_id.clone(); + tabs.dashboard_open = false; + tabs.tabs.push(tab); + } + ensure_native_webview(&app, &state, &tab_id)?; + emit_state(&app, &state)?; + schedule_session_save(&app); + Ok(summary) +} + +#[tauri::command(rename_all = "camelCase")] +pub(crate) fn aether_tabs_activate( + app: AppHandle, + state: State, + tab_id: String, +) -> Cmd<()> { + { + let mut tabs = lock_tabs(&state)?; + if !tabs.tabs.iter().any(|tab| tab.id == tab_id) { + return Err(format!("Unknown tab: {tab_id}")); + } + tabs.active_tab_id = tab_id.clone(); + tabs.active_app_id = "browser".to_string(); + tabs.dashboard_open = false; + } + ensure_native_webview(&app, &state, &tab_id)?; + emit_state(&app, &state)?; + schedule_session_save(&app); + Ok(()) +} + +#[tauri::command(rename_all = "camelCase")] +pub(crate) fn aether_tabs_close(app: AppHandle, state: State, tab_id: String) -> Cmd<()> { + let mut next_active_tab_id = None; + { + let mut tabs = lock_tabs(&state)?; + if tabs.tabs.len() == 1 { + return Ok(()); + } + let was_active = tabs.active_tab_id == tab_id; + tabs.tabs.retain(|tab| tab.id != tab_id); + if was_active { + if let Some(next_id) = tabs.tabs.last().map(|tab| tab.id.clone()) { + tabs.active_tab_id = next_id.clone(); + next_active_tab_id = Some(next_id); + } + } + } + close_native_webview(&app, &state, &tab_id)?; + if let Some(active_tab_id) = next_active_tab_id { + ensure_native_webview(&app, &state, &active_tab_id)?; + } else { + sync_native_webview_visibility(&app, &state)?; + } + emit_state(&app, &state)?; + schedule_session_save(&app); + Ok(()) +} + +#[tauri::command] +pub(crate) fn aether_tabs_reorder( + app: AppHandle, + state: State, + ids: Vec, +) -> Cmd> { + let summaries = { + let mut tabs = lock_tabs(&state)?; + let has_every_tab_once = ids.len() == tabs.tabs.len() + && ids + .iter() + .all(|id| tabs.tabs.iter().any(|tab| tab.id == *id)) + && !ids + .iter() + .enumerate() + .any(|(index, id)| ids[..index].iter().any(|previous| previous == id)); + if !has_every_tab_once { + return Err("Tab order does not match the open tabs.".to_string()); + } + + let mut remaining = std::mem::take(&mut tabs.tabs); + tabs.tabs = ids + .iter() + .map(|id| { + let index = remaining + .iter() + .position(|tab| tab.id == *id) + .expect("tab order was validated"); + remaining.remove(index) + }) + .collect(); + tabs.tabs() + }; + emit_state(&app, &state)?; + schedule_session_save(&app); + Ok(summaries) +} + +#[tauri::command(rename_all = "camelCase")] +pub(crate) async fn aether_tabs_navigate( + app: AppHandle, + state: State<'_, Backend>, + tab_id: String, + url: String, +) -> Cmd<()> { + let settings = load_settings(&state.paths.settings_path).await?; + let target_url = { + let mut tabs = lock_tabs(&state)?; + let tab = tabs + .tabs + .iter_mut() + .find(|tab| tab.id == tab_id) + .ok_or_else(|| format!("Unknown tab: {tab_id}"))?; + tab.navigate(&url, &settings.browser.default_search_engine); + let target_url = tab.url.clone(); + tabs.active_tab_id = tab.id.clone(); + tabs.dashboard_open = false; + target_url + }; + navigate_native_webview(&app, &state, &tab_id, &target_url)?; + emit_state(&app, &state)?; + schedule_session_save(&app); + Ok(()) +} + +#[tauri::command(rename_all = "camelCase")] +pub(crate) fn aether_tabs_scroll_to_text( + app: AppHandle, + state: State, + tab_id: String, + text: String, +) -> Cmd<()> { + { + let tabs = lock_tabs(&state)?; + if !tabs.tabs.iter().any(|tab| tab.id == tab_id) { + return Err(format!("Unknown tab: {tab_id}")); + } + } + scroll_native_webview_to_text(&app, &state, &tab_id, &text) +} + +#[tauri::command(rename_all = "camelCase")] +pub(crate) fn aether_tabs_find( + app: AppHandle, + state: State, + tab_id: String, + query: Option, + action: Option, +) -> Cmd<()> { + let target_tab_id = { + let tabs = lock_tabs(&state)?; + if tab_id.is_empty() { + tabs.active_tab_id.clone() + } else if tabs.tabs.iter().any(|tab| tab.id == tab_id) { + tab_id + } else { + return Err(format!("Unknown tab: {tab_id}")); + } + }; + let action = action.as_deref().unwrap_or("find"); + find_native_webview_text(&app, &state, &target_tab_id, query.as_deref(), action) +} + +#[tauri::command(rename_all = "camelCase")] +pub(crate) fn aether_tabs_go_back( + app: AppHandle, + state: State, + tab_id: String, +) -> Cmd<()> { + let mut restore_start_page = false; + let target_tab_id = { + let mut tabs = lock_tabs(&state)?; + let tab = if tab_id.is_empty() { + tabs.active_tab_mut() + .ok_or_else(|| "No active browser tab.".to_string())? + } else { + tabs.tabs + .iter_mut() + .find(|tab| tab.id == tab_id) + .ok_or_else(|| format!("Unknown tab: {tab_id}"))? + }; + // The start page is a renderer overlay, not a native page, so the webview can't + // navigate back to it. When the previous history entry is the start page, park + // the tab back on it (its webview is kept hidden for a later forward). + if tab.can_go_back() + && tab.history.get(tab.history_index - 1).map(String::as_str) == Some(START_PAGE_URL) + { + tab.history_index -= 1; + tab.url = START_PAGE_URL.to_string(); + tab.title = "New tab".to_string(); + tab.is_loading = false; + restore_start_page = true; + } + tab.id.clone() + }; + if restore_start_page { + sync_native_webview_visibility(&app, &state)?; + } else { + navigate_native_webview_history( + &app, + &state, + &target_tab_id, + WebviewHistoryDirection::Back, + )?; + } + emit_state(&app, &state) +} + +#[tauri::command(rename_all = "camelCase")] +pub(crate) fn aether_tabs_go_forward( + app: AppHandle, + state: State, + tab_id: String, +) -> Cmd<()> { + let mut leave_start_page = false; + let target_tab_id = { + let mut tabs = lock_tabs(&state)?; + let tab = if tab_id.is_empty() { + tabs.active_tab_mut() + .ok_or_else(|| "No active browser tab.".to_string())? + } else { + tabs.tabs + .iter_mut() + .find(|tab| tab.id == tab_id) + .ok_or_else(|| format!("Unknown tab: {tab_id}"))? + }; + // Forwarding off the start page: advance to the real page whose (hidden) webview + // we kept, then reveal it instead of issuing a native history.forward(). + if tab.url == START_PAGE_URL && tab.can_go_forward() { + tab.history_index += 1; + tab.url = tab.history[tab.history_index].clone(); + tab.title = title_from_url(&tab.url); + tab.is_loading = false; + leave_start_page = true; + } + tab.id.clone() + }; + if leave_start_page { + ensure_native_webview(&app, &state, &target_tab_id)?; + } else { + navigate_native_webview_history( + &app, + &state, + &target_tab_id, + WebviewHistoryDirection::Forward, + )?; + } + emit_state(&app, &state) +} + +// Mobile-only feedback channel: the Kotlin TabsPlugin evaluates +// `window.__AETHER_TAB_EVENT__(...)` in the main webview and the renderer +// forwards the payload here, mirroring how desktop child-webview callbacks +// (on_navigation / on_page_load / on_document_title_changed) feed tab state. +#[tauri::command] +pub(crate) fn aether_tabs_report_native_event( + app: AppHandle, + state: State, + input: NativeTabEventInput, +) -> Cmd<()> { + match input.kind.as_str() { + "navigation" => { + let parked_on_start_page = { + let tabs = lock_tabs(&state)?; + tabs.tabs + .iter() + .find(|tab| tab.id == input.tab_id) + .map(|tab| tab.url == START_PAGE_URL) + .unwrap_or(true) + }; + // A tab parked on the start page keeps its (hidden) webview alive; + // ignore its stray events so the start-page sentinel survives. + if parked_on_start_page { + return Ok(()); + } + if let Some(url) = input.url.as_deref() { + update_tab_navigation_state( + &state, + &input.tab_id, + url, + input.is_loading.unwrap_or(false), + ); + } + { + let mut tabs = lock_tabs(&state)?; + if let Some(tab) = tabs.tabs.iter_mut().find(|tab| tab.id == input.tab_id) { + tab.native_can_go_back = input.can_go_back; + tab.native_can_go_forward = input.can_go_forward; + } + } + emit_state(&app, &state) + } + "title" => { + if let Some(title) = input.title.as_deref() { + update_tab_title(&state, &input.tab_id, title); + } + emit_state(&app, &state) + } + "find" => app + .emit( + AETHER_FIND_RESULT_EVENT, + FindResultPayload { + tab_id: input.tab_id, + current: input.current.unwrap_or(0), + total: input.total.unwrap_or(0), + }, + ) + .map_err(|error| error.to_string()), + _ => Ok(()), + } +} + +// Preview image (data-URI JPEG) for the mobile tab-grid switcher. Desktop +// renders live child webviews and never asks for one, so it returns None. +// Async on purpose: the Kotlin side resolves on the Android UI thread, so the +// blocking run_mobile_plugin call must sit on a tokio worker. +#[tauri::command(rename_all = "camelCase")] +pub(crate) async fn aether_tabs_thumbnail(app: AppHandle, tab_id: String) -> Cmd> { + #[cfg(target_os = "android")] + { + let response: android_tabs::ThumbnailResponse = app + .state::() + .run_for("thumbnail", android_tabs::TabPayload { tab_id: &tab_id })?; + return Ok(response.image); + } + #[allow(unreachable_code)] + { + let _ = (app, tab_id); + Ok(None) + } +} + +// System-bar/cutout insets (CSS px) for the edge-to-edge Android activity; +// zero on desktop, where the OS window frame handles this. Async for the same +// UI-thread reason as aether_tabs_thumbnail. +#[tauri::command] +pub(crate) async fn aether_layout_window_insets(app: AppHandle) -> Cmd { + #[cfg(target_os = "android")] + { + let response: android_tabs::InsetsResponse = app + .state::() + .run_for("insets", serde_json::json!({}))?; + return serde_json::to_value(response).map_err(|error| error.to_string()); + } + #[allow(unreachable_code)] + { + let _ = app; + serde_json::to_value(serde_json::json!({ + "top": 0.0, "bottom": 0.0, "left": 0.0, "right": 0.0 + })) + .map_err(|error| error.to_string()) + } +} + +// The renderer measures where Android tab WebViews belong (MobileTabView's +// The renderer measures the slot where live web content belongs (a placeholder div's +// bounding rect, CSS px) and reports it here. Both shells position their native web +// views from this, so a chrome restyle or a panel resize moves the content with it +// instead of drifting away from hardcoded offsets. +#[tauri::command(rename_all = "camelCase")] +pub(crate) fn aether_layout_set_web_content_bounds( + app: AppHandle, + state: State, + top: f64, + left: f64, + width: f64, + height: f64, +) -> Cmd<()> { + let next = WebContentBounds { + top, + left, + width, + height, + }; + { + let mut stored = state + .web_content_bounds + .lock() + .map_err(|_| "layout bounds are unavailable.".to_string())?; + // A ResizeObserver fires on every layout pass; repositioning native webviews + // for an unchanged rect causes visible flicker on desktop. + if *stored == next { + return Ok(()); + } + *stored = next; + } + sync_native_webview_visibility(&app, &state) +} + +#[tauri::command] +pub(crate) fn aether_dashboard_open(app: AppHandle, state: State) -> Cmd<()> { + { + let mut tabs = lock_tabs(&state)?; + tabs.dashboard_open = true; + } + sync_native_webview_visibility(&app, &state)?; + emit_state(&app, &state) +} + +#[tauri::command] +pub(crate) async fn aether_hub_list(state: State<'_, Backend>) -> Cmd> { + Ok(load_library(&state.paths.library_path).await?.shortcuts) +} + +#[tauri::command] +pub(crate) async fn aether_hub_create( + state: State<'_, Backend>, + input: CreateShortcutInput, +) -> Cmd { + let title = input.title.trim().to_string(); + if title.is_empty() { + return Err("Shortcut title is required.".to_string()); + } + let url = normalize_url(&input.url, "google"); + let mut data = load_library(&state.paths.library_path).await?; + let favicon = input + .favicon + .as_deref() + .map(str::trim) + .filter(|favicon| !favicon.is_empty()) + .map(str::to_string); + let theme_color = input.theme_color.as_deref().and_then(normalize_theme_color); + if let Some(existing) = data + .shortcuts + .iter_mut() + .find(|shortcut| shortcut.url == url) + { + let mut changed = false; + if existing.favicon.is_none() && favicon.is_some() { + existing.favicon = favicon; + changed = true; + } + if existing.theme_color.is_none() && theme_color.is_some() { + existing.theme_color = theme_color; + changed = true; + } + let shortcut = existing.clone(); + if changed { + save_json(&state.paths.library_path, &data).await?; + } + return Ok(shortcut); + } + let shortcut = HubShortcutSummary { + id: uuid(), + title, + host: get_tab_host(&url), + url, + created_at: now(), + favicon, + theme_color, + }; + data.shortcuts.insert(0, shortcut.clone()); + save_json(&state.paths.library_path, &data).await?; + Ok(shortcut) +} + +#[tauri::command] +pub(crate) async fn aether_hub_reorder( + state: State<'_, Backend>, + ids: Vec, +) -> Cmd> { + let mut data = load_library(&state.paths.library_path).await?; + data.shortcuts = reorder(data.shortcuts, &ids, |shortcut| &shortcut.id); + save_json(&state.paths.library_path, &data).await?; + Ok(data.shortcuts) +} + +#[tauri::command] +pub(crate) async fn aether_hub_delete(state: State<'_, Backend>, id: String) -> Cmd<()> { + let mut data = load_library(&state.paths.library_path).await?; + data.shortcuts.retain(|shortcut| shortcut.id != id); + save_json(&state.paths.library_path, &data).await +} + +#[tauri::command] +pub(crate) async fn aether_collections_list( + state: State<'_, Backend>, +) -> Cmd> { + Ok(load_library(&state.paths.library_path).await?.collections) +} + +#[tauri::command] +pub(crate) async fn aether_collections_create( + state: State<'_, Backend>, + input: CreateCollectionInput, +) -> Cmd { + let name = input.name.trim().to_string(); + if name.is_empty() { + return Err("Collection name is required.".to_string()); + } + let mut data = load_library(&state.paths.library_path).await?; + let now = now(); + let existing = data + .collections + .iter() + .map(|collection| collection.id.clone()) + .collect::>(); + let collection = CollectionSummary { + id: unique_slug(&name, &existing), + name, + description: input.description.unwrap_or_default().trim().to_string(), + icon: Some(input.icon.unwrap_or_else(|| "book".to_string())) + .map(|icon| icon.trim().to_string()) + .filter(|icon| !icon.is_empty()), + created_at: now.clone(), + updated_at: now, + capture_count: 0, + chunk_count: 0, + }; + data.collections.push(collection.clone()); + save_json(&state.paths.library_path, &data).await?; + Ok(collection) +} + +#[tauri::command] +pub(crate) async fn aether_collections_update( + state: State<'_, Backend>, + input: UpdateCollectionInput, +) -> Cmd { + let mut data = load_library(&state.paths.library_path).await?; + let collection = data + .collections + .iter_mut() + .find(|collection| collection.id == input.id) + .ok_or_else(|| "Collection not found.".to_string())?; + if let Some(name) = input.name { + let name = name.trim(); + if name.is_empty() { + return Err("Collection name is required.".to_string()); + } + collection.name = name.to_string(); + } + if let Some(description) = input.description { + collection.description = description.trim().to_string(); + } + if let Some(icon) = input.icon { + collection.icon = Some(icon.trim().to_string()).filter(|icon| !icon.is_empty()); + } + collection.updated_at = now(); + let updated = collection.clone(); + save_json(&state.paths.library_path, &data).await?; + Ok(updated) +} + +#[tauri::command] +pub(crate) async fn aether_collections_reorder( + state: State<'_, Backend>, + ids: Vec, +) -> Cmd> { + let mut data = load_library(&state.paths.library_path).await?; + data.collections = reorder(data.collections, &ids, |collection| &collection.id); + save_json(&state.paths.library_path, &data).await?; + Ok(data.collections) +} + +#[tauri::command] +pub(crate) async fn aether_collections_delete(state: State<'_, Backend>, id: String) -> Cmd<()> { + let mut library = load_library(&state.paths.library_path).await?; + library.collections.retain(|collection| collection.id != id); + library + .captures + .retain(|capture| capture.collection_id != id); + save_json(&state.paths.library_path, &library).await?; + + with_vectors_mut(&state, |vectors| { + vectors.chunks.retain(|chunk| chunk.collection_id != id); + }) + .await +} + +#[tauri::command(rename_all = "camelCase")] +pub(crate) async fn aether_collections_captures( + state: State<'_, Backend>, + collection_id: String, +) -> Cmd> { + let mut captures = load_library(&state.paths.library_path) + .await? + .captures + .into_iter() + .filter(|capture| capture.collection_id == collection_id) + .collect::>(); + captures.sort_by(|left, right| right.captured_at.cmp(&left.captured_at)); + Ok(captures) +} + +// Strict counterpart to normalize_url: capture must never silently turn a typo into +// a search-engine URL and then index the results page. Bare hosts are still +// accepted, since that is how people paste links. +pub(crate) fn capture_target_url(raw: &str) -> Cmd { + let trimmed = raw.trim(); + if trimmed.is_empty() { + return Err("Enter a web address to capture.".to_string()); + } + let lowered = trimmed.to_ascii_lowercase(); + let candidate = if lowered.starts_with("http://") || lowered.starts_with("https://") { + trimmed.to_string() + } else if lowered.contains("://") { + return Err("Only http and https pages can be captured.".to_string()); + } else if !trimmed.contains(char::is_whitespace) && trimmed.contains('.') { + format!("https://{trimmed}") + } else { + return Err(format!("\"{trimmed}\" is not a web address.")); + }; + let parsed = + Url::parse(&candidate).map_err(|_| format!("\"{trimmed}\" is not a web address."))?; + if parsed.host_str().unwrap_or_default().is_empty() { + return Err(format!("\"{trimmed}\" is not a web address.")); + } + Ok(parsed.to_string()) +} + +#[tauri::command] +pub(crate) async fn aether_capture_current_page( + app: AppHandle, + state: State<'_, Backend>, + input: CaptureCurrentPageInput, +) -> Cmd { + emit_capture_progress(&app, "Reading current page", None, None); + let active_tab = { + let tabs = lock_tabs(&state)?; + if tabs.dashboard_open { + return Err("Open a website before capturing into a collection.".to_string()); + } + tabs.active_tab() + .cloned() + .ok_or_else(|| "No active browser tab.".to_string())? + }; + let app_id = active_tab.app_id.clone(); + let captured = extract_readable_active_page(&state, &active_tab).await?; + capture_page_into_collection(&app, &state, &input.collection_id, captured, &app_id).await +} + +// Captures a page ÆTHER never had to load. This is what lets sources arrive from a +// pasted link, a dropped link, or a batch of open tabs instead of only from the +// active tab, so the library can grow without the app being the default browser. +#[tauri::command] +pub(crate) async fn aether_capture_url( + app: AppHandle, + state: State<'_, Backend>, + input: CaptureUrlInput, +) -> Cmd { + let target = capture_target_url(&input.url)?; + emit_capture_progress(&app, "Fetching page", None, None); + let captured = extract_readable_page(&state.client, &target).await?; + capture_page_into_collection(&app, &state, &input.collection_id, captured, "browser").await +} + +// Bulk sibling of aether_capture_url. One bad link in a batch must not discard the +// pages that did work, so failures are reported per URL instead of aborting. +#[tauri::command] +pub(crate) async fn aether_capture_urls( + app: AppHandle, + state: State<'_, Backend>, + input: CaptureUrlsInput, +) -> Cmd { + if input.urls.is_empty() { + return Err("No links to capture.".to_string()); + } + let collection = get_collection(&state.paths.library_path, &input.collection_id).await?; + + let total = input.urls.len(); + let mut captured = Vec::new(); + let mut failures = Vec::new(); + + for (index, raw_url) in input.urls.iter().enumerate() { + emit_capture_progress( + &app, + format!("Capturing link {} of {total}", index + 1), + Some(index), + Some(total), + ); + let outcome = async { + let target = capture_target_url(raw_url)?; + let page = extract_readable_page(&state.client, &target).await?; + capture_page_into_collection(&app, &state, &input.collection_id, page, "browser").await + } + .await; + + match outcome { + Ok(result) => captured.push(result.capture), + Err(reason) => failures.push(BulkCaptureFailure { + url: raw_url.clone(), + reason, + }), + } + } + + emit_capture_progress(&app, "Finished capturing links", Some(total), Some(total)); + + Ok(BulkCaptureResult { + captured, + collection_name: collection.name, + failures, + }) +} + +// Shared tail of every capture path: chunk, embed, store vectors, update the +// library manifest. Kept in one place so the fetch-based captures cannot drift +// from the active-tab capture. +pub(crate) async fn capture_page_into_collection( + app: &AppHandle, + state: &State<'_, Backend>, + collection_id: &str, + captured: CapturedPage, + app_id: &str, +) -> Cmd { + let settings = load_settings(&state.paths.settings_path).await?; + let mut library = load_library(&state.paths.library_path).await?; + let collection = library + .collections + .iter() + .find(|collection| collection.id == collection_id) + .cloned() + .ok_or_else(|| "Collection not found.".to_string())?; + emit_capture_progress(app, "Chunking readable text", None, None); + let captured_key = normalize_capture_url_key(&captured.url); + if library.captures.iter().any(|capture| { + capture.collection_id == collection.id + && normalize_capture_url_key(&capture.url) == captured_key + }) { + return Err(format!("Page is already in {}.", collection.name)); + } + + let (chunk_size, chunk_overlap) = capture_chunk_settings(&state.paths, &settings); + let chunks = split_text(&captured.text, chunk_size, chunk_overlap); + if chunks.is_empty() { + return Err("No readable text found on that page.".to_string()); + } + emit_capture_progress( + app, + format!("Embedding {} chunks", chunks.len()), + Some(0), + Some(chunks.len()), + ); + let embeddings = local_embed_with_progress( + state, + &settings, + chunks.clone(), + Some(EmbeddingProgress { + app: app.clone(), + message: "Embedding chunks".to_string(), + }), + ) + .await?; + if embeddings.len() != chunks.len() { + return Err( + "Local embedding model returned an unexpected number of embeddings.".to_string(), + ); + } + emit_capture_progress( + app, + "Saving capture", + Some(chunks.len()), + Some(chunks.len()), + ); + + let capture_id = uuid(); + let captured_at = now(); + let records = chunks + .into_iter() + .enumerate() + .map(|(index, text)| ChunkRecord { + id: uuid(), + vector: embeddings[index].clone(), + // Assigned by push_chunks when the store accepts the record. + vector_slot: 0, + needs_reembed: false, + text, + collection_id: collection.id.clone(), + capture_id: capture_id.clone(), + title: captured.title.clone(), + url: captured.url.clone(), + app_id: app_id.to_string(), + captured_at: captured_at.clone(), + chunk_index: index, + }) + .collect::>(); + + // push_chunks assigns the sidecar slots; never extend `chunks` directly. + with_vectors_mut(state, |vectors| { + vectors.push_chunks(records.iter().cloned()); + }) + .await?; + + let capture = CaptureSummary { + id: capture_id, + collection_id: collection.id.clone(), + title: captured.title, + url: captured.url, + app_id: app_id.to_string(), + captured_at, + chunk_count: records.len(), + metadata: None, + }; + library.captures.push(capture.clone()); + if let Some(stored_collection) = library + .collections + .iter_mut() + .find(|item| item.id == collection.id) + { + stored_collection.capture_count += 1; + stored_collection.chunk_count += records.len(); + stored_collection.updated_at = capture.captured_at.clone(); + } + save_json(&state.paths.library_path, &library).await?; + + Ok(CaptureResult { + capture, + collection_name: collection.name, + }) +} + +#[tauri::command] +pub(crate) async fn aether_capture_move( + state: State<'_, Backend>, + input: MoveCaptureInput, +) -> Cmd { + let mut library = load_library(&state.paths.library_path).await?; + let now = now(); + let target_exists = library + .collections + .iter() + .any(|collection| collection.id == input.collection_id); + if !target_exists { + return Err("Target collection not found.".to_string()); + } + let capture = library + .captures + .iter_mut() + .find(|capture| capture.id == input.capture_id) + .ok_or_else(|| "Capture not found.".to_string())?; + if capture.collection_id == input.collection_id { + return Ok(capture.clone()); + } + let source_collection_id = capture.collection_id.clone(); + let chunk_count = capture.chunk_count; + capture.collection_id = input.collection_id.clone(); + let moved = capture.clone(); + for collection in &mut library.collections { + if collection.id == source_collection_id { + collection.capture_count = collection.capture_count.saturating_sub(1); + collection.chunk_count = collection.chunk_count.saturating_sub(chunk_count); + collection.updated_at = now.clone(); + } + if collection.id == input.collection_id { + collection.capture_count += 1; + collection.chunk_count += chunk_count; + collection.updated_at = now.clone(); + } + } + save_json(&state.paths.library_path, &library).await?; + + with_vectors_mut(&state, |vectors| { + for chunk in &mut vectors.chunks { + if chunk.capture_id == input.capture_id { + chunk.collection_id = input.collection_id.clone(); + } + } + }) + .await?; + Ok(moved) +} + +#[tauri::command(rename_all = "camelCase")] +pub(crate) async fn aether_capture_delete( + state: State<'_, Backend>, + capture_id: String, +) -> Cmd<()> { + let mut library = load_library(&state.paths.library_path).await?; + let deleted = library + .captures + .iter() + .find(|capture| capture.id == capture_id) + .cloned(); + library.captures.retain(|capture| capture.id != capture_id); + if let Some(deleted) = deleted { + if let Some(collection) = library + .collections + .iter_mut() + .find(|collection| collection.id == deleted.collection_id) + { + collection.capture_count = collection.capture_count.saturating_sub(1); + collection.chunk_count = collection.chunk_count.saturating_sub(deleted.chunk_count); + collection.updated_at = now(); + } + } + save_json(&state.paths.library_path, &library).await?; + with_vectors_mut(&state, |vectors| { + vectors + .chunks + .retain(|chunk| chunk.capture_id != capture_id); + }) + .await +} + +#[tauri::command] +pub(crate) async fn aether_search_collection( + state: State<'_, Backend>, + input: SearchCollectionInput, +) -> Cmd> { + search_collection(&state, input).await +} + +#[tauri::command] +pub(crate) async fn aether_semantic_trail_generate( + state: State<'_, Backend>, + input: Option, +) -> Cmd { + semantic_trail_generate( + &state, + input.unwrap_or(SemanticTrailInput { + query: None, + limit: None, + }), + ) + .await +} + +#[tauri::command] +pub(crate) async fn aether_flow_graph( + state: State<'_, Backend>, + input: Option, +) -> Cmd { + flow_graph_generate( + &state, + input.unwrap_or(FlowGraphInput { + query: None, + source_limit: None, + }), + ) + .await +} + +#[tauri::command] +pub(crate) async fn aether_air_prepare( + state: State<'_, Backend>, + input: AirDossierInput, +) -> Cmd { + air_prepare_dossier(&state, input, false).await +} + +#[tauri::command] +pub(crate) async fn aether_air_render( + state: State<'_, Backend>, + input: AirDossierInput, +) -> Cmd { + let prepared = air_prepare_dossier(&state, input, true).await?; + let output_dir = resolve_air_export_dir(&state.paths, true).await?; + let filename = air_dossier_filename(&prepared.title, &prepared.generated_at); + let path = output_dir.join(filename); + tokio::fs::write(&path, prepared.markdown_preview.as_bytes()) + .await + .map_err(|error| error.to_string())?; + Ok(AirRenderResult { + path: path.display().to_string(), + filename: path + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("aether-dossier.md") + .to_string(), + title: prepared.title, + source_count: prepared.sources.len(), + rendered_at: prepared.generated_at, + }) +} + +#[tauri::command] +pub(crate) async fn aether_air_list_recent(state: State<'_, Backend>) -> Cmd> { + air_list_recent(&state.paths).await +} + +#[tauri::command(rename_all = "camelCase")] +pub(crate) fn aether_air_open(app: AppHandle, path: String) -> Cmd<()> { + let path = PathBuf::from(path); + if !path.exists() { + return Err("AiR file not found.".to_string()); + } + app.opener() + .open_path(path.display().to_string(), None::) + .map_err(|error| error.to_string()) +} + +#[tauri::command(rename_all = "camelCase")] +pub(crate) fn aether_air_reveal(app: AppHandle, path: String) -> Cmd<()> { + let path = PathBuf::from(path); + if !path.exists() { + return Err("AiR file not found.".to_string()); + } + app.opener() + .reveal_item_in_dir(path) + .map_err(|error| error.to_string()) +} + +#[tauri::command] +pub(crate) async fn aether_capture_suggest_hub( + state: State<'_, Backend>, +) -> Cmd> { + suggest_capture_hub(&state).await +} + +#[tauri::command] +pub(crate) async fn aether_chat_ask( + app: AppHandle, + state: State<'_, Backend>, + input: AskChatInput, +) -> Cmd { + let prompt = input.prompt.trim().to_string(); + if prompt.is_empty() { + return Err("Enter a question before asking Æther.".to_string()); + } + state + .generation_cancelled + .store(false, AtomicOrdering::Relaxed); + let stream = ChatStreamEmitter { + app, + request_id: input.request_id.clone().unwrap_or_else(uuid), + }; + let settings = load_settings(&state.paths.settings_path).await?; + let mut citations = if let Some(collection_id) = input.collection_id.clone() { + stream.status("Searching your knowledge hub"); + search_collection( + &state, + SearchCollectionInput { + collection_id, + query: prompt.clone(), + limit: Some(8), + }, + ) + .await? + } else { + Vec::new() + }; + + if input.include_current_page.unwrap_or(false) { + stream.status("Reading current page"); + if let Ok(active_url) = active_tab_url(&state) { + let active_tab = { + let tabs = lock_tabs(&state)?; + tabs.active_tab().cloned() + }; + let captured = if let Some(active_tab) = active_tab { + extract_readable_active_page(&state, &active_tab).await.ok() + } else { + extract_readable_page(&state.client, &active_url).await.ok() + }; + if let Some(captured) = captured { + // Give the current page fewer slots when a hub is also in play so the + // hub still contributes; let it use the full budget on its own. + let page_limit = if input.collection_id.is_some() { + 3 + } else { + chat_citation_limit() + }; + let page_citations = current_page_citations( + &state, + &settings, + captured, + &prompt, + input.collection_id.as_deref(), + page_limit, + ) + .await; + // Prepend so the current page takes priority over hub matches. + citations.splice(0..0, page_citations); + } + } + } + let citations = dedupe_citations(citations) + .into_iter() + .take(chat_citation_limit()) + .collect::>(); + + // Only the most recent turns are replayed; older ones stay on disk for reading but + // would otherwise crowd the retrieved sources out of the context window. + let thread = conversation_thread(&state.paths, input.collection_id.as_deref()).await; + let history = thread + .iter() + .rev() + .take(PROMPT_HISTORY_TURNS) + .rev() + .cloned() + .collect::>(); + + let result = local_chat( + &state, + &settings, + &prompt, + citations, + &history, + Some(stream), + ) + .await?; + + // A failed write must not discard the answer the user is already reading. + if let Err(error) = append_conversation_turn( + &state.paths, + input.collection_id.as_deref(), + ConversationTurn { + id: uuid(), + prompt: prompt.clone(), + answer: result.answer.clone(), + model: result.model.clone(), + asked_at: now(), + citations: result.citations.clone(), + metrics: result.metrics.clone(), + }, + ) + .await + { + diag_warn!("could not save conversation turn: {error}"); + } + + Ok(result) +} + +#[tauri::command(rename_all = "camelCase")] +pub(crate) async fn aether_chat_history( + state: State<'_, Backend>, + collection_id: Option, +) -> Cmd> { + Ok(conversation_thread(&state.paths, collection_id.as_deref()).await) +} + +#[tauri::command(rename_all = "camelCase")] +pub(crate) async fn aether_chat_clear_history( + state: State<'_, Backend>, + collection_id: Option, +) -> Cmd<()> { + let key = conversation_thread_key(collection_id.as_deref()); + let mut data = load_conversations(&state.paths.conversations_path).await?; + data.threads.remove(&key); + save_json(&state.paths.conversations_path, &data).await +} + +#[tauri::command] +pub(crate) async fn aether_crystallizer_generate( + state: State<'_, Backend>, + input: GenerateIcebergInput, +) -> Cmd { + let topic = input.keyword.trim().to_string(); + if topic.is_empty() { + return Err("Enter a topic before crystallizing.".to_string()); + } + state + .generation_cancelled + .store(false, AtomicOrdering::Relaxed); + let settings = load_settings(&state.paths.settings_path).await?; + local_generate_iceberg(&state, &settings, &topic).await +} + +#[tauri::command] +pub(crate) fn aether_chat_cancel(state: State) -> Cmd<()> { + state + .generation_cancelled + .store(true, AtomicOrdering::Relaxed); + Ok(()) +} + +#[tauri::command] +pub(crate) async fn aether_crystallizer_list_saved( + state: State<'_, Backend>, +) -> Cmd> { + Ok(load_icebergs(&state.paths.icebergs_path) + .await? + .icebergs + .iter() + .map(saved_iceberg_summary) + .collect()) +} + +#[tauri::command(rename_all = "camelCase")] +pub(crate) async fn aether_crystallizer_get_saved( + state: State<'_, Backend>, + id: String, +) -> Cmd { + load_icebergs(&state.paths.icebergs_path) + .await? + .icebergs + .into_iter() + .find(|iceberg| iceberg.id == id) + .ok_or_else(|| "Saved iceberg not found.".to_string()) +} + +#[tauri::command] +pub(crate) async fn aether_crystallizer_save( + state: State<'_, Backend>, + input: SaveIcebergInput, +) -> Cmd { + let title = input.title.trim().to_string(); + let keyword = input.keyword.trim().to_string(); + let model = input.model.trim().to_string(); + let generated_at = input.generated_at.trim().to_string(); + let items = normalize_saved_items(input.items); + if title.is_empty() { + return Err("Iceberg title is required.".to_string()); + } + if keyword.is_empty() { + return Err("Iceberg keyword is required.".to_string()); + } + if model.is_empty() { + return Err("Iceberg model is required.".to_string()); + } + if generated_at.is_empty() { + return Err("Iceberg generation time is required.".to_string()); + } + if items.is_empty() { + return Err("Iceberg has no usable items to save.".to_string()); + } + let now = now(); + let iceberg = SavedIceberg { + iceberg: IcebergResult { + keyword, + model, + items, + generated_at, + }, + id: uuid(), + title, + icon: normalize_iceberg_icon(input.icon), + saved_at: now.clone(), + updated_at: now, + }; + let mut data = load_icebergs(&state.paths.icebergs_path).await?; + data.icebergs.insert(0, iceberg.clone()); + save_json(&state.paths.icebergs_path, &data).await?; + Ok(iceberg) +} + +#[tauri::command] +pub(crate) async fn aether_crystallizer_reorder_saved( + state: State<'_, Backend>, + ids: Vec, +) -> Cmd> { + let mut data = load_icebergs(&state.paths.icebergs_path).await?; + data.icebergs = reorder(data.icebergs, &ids, |iceberg| &iceberg.id); + let summaries = data.icebergs.iter().map(saved_iceberg_summary).collect(); + save_json(&state.paths.icebergs_path, &data).await?; + Ok(summaries) +} + +#[tauri::command(rename_all = "camelCase")] +pub(crate) async fn aether_crystallizer_delete_saved( + state: State<'_, Backend>, + id: String, +) -> Cmd<()> { + let mut data = load_icebergs(&state.paths.icebergs_path).await?; + data.icebergs.retain(|iceberg| iceberg.id != id); + save_json(&state.paths.icebergs_path, &data).await +} + +#[tauri::command] +pub(crate) async fn aether_system_status(state: State<'_, Backend>) -> Cmd { + system_status(&state).await +} + +#[tauri::command] +pub(crate) async fn aether_system_settings(state: State<'_, Backend>) -> Cmd { + let settings = load_settings(&state.paths.settings_path).await?; + Ok(AppSettings { + browser: settings.browser, + developer_mode: settings.developer_mode, + updates: settings.updates, + appearance: settings.appearance, + }) +} + +#[tauri::command] +pub(crate) async fn aether_system_update_settings( + state: State<'_, Backend>, + input: UpdateSettingsInput, +) -> Cmd { + let mut settings = load_settings(&state.paths.settings_path).await?; + if let Some(browser) = input.browser { + if let Some(default_search_engine) = browser.default_search_engine { + settings.browser.default_search_engine = + normalize_search_engine_id(&default_search_engine); + } + } + if let Some(developer_mode) = input.developer_mode { + settings.developer_mode = developer_mode; + } + if let Some(updates) = input.updates { + if let Some(auto_check) = updates.auto_check { + settings.updates.auto_check = auto_check; + } + } + if let Some(appearance) = input.appearance { + settings.appearance = appearance; + } + save_json(&state.paths.settings_path, &settings).await?; + Ok(AppSettings { + browser: settings.browser, + developer_mode: settings.developer_mode, + updates: settings.updates, + appearance: settings.appearance, + }) +} + +#[tauri::command] +pub(crate) async fn aether_system_check_for_update( + state: State<'_, Backend>, +) -> Cmd { + let checked_at = now(); + let current_version = env!("CARGO_PKG_VERSION").to_string(); + + let request = state + .client + .get(AETHER_RELEASES_API_URL) + .header("Accept", "application/vnd.github+json") + .header("X-GitHub-Api-Version", "2022-11-28") + .header("User-Agent", "AETHER-update-checker"); + let response = match request.send().await { + Ok(response) => response, + Err(error) => { + persist_update_last_checked_at(&state.paths, &checked_at).await?; + return Ok(UpdateCheckResult { + current_version, + checked_at, + update_available: false, + latest_version: None, + latest_name: None, + release_url: None, + release_notes: None, + published_at: None, + error: Some(format!("Could not reach GitHub Releases: {error}")), + }); + } + }; + + if response.status().as_u16() == 404 { + persist_update_last_checked_at(&state.paths, &checked_at).await?; + return Ok(UpdateCheckResult { + current_version, + checked_at, + update_available: false, + latest_version: None, + latest_name: None, + release_url: None, + release_notes: None, + published_at: None, + error: Some("No published GitHub release found yet.".to_string()), + }); + } + + if !response.status().is_success() { + persist_update_last_checked_at(&state.paths, &checked_at).await?; + return Ok(UpdateCheckResult { + current_version, + checked_at, + update_available: false, + latest_version: None, + latest_name: None, + release_url: None, + release_notes: None, + published_at: None, + error: Some(format!( + "GitHub Releases returned HTTP {}.", + response.status().as_u16() + )), + }); + } + + let release = match response.json::().await { + Ok(release) => release, + Err(error) => { + persist_update_last_checked_at(&state.paths, &checked_at).await?; + return Ok(UpdateCheckResult { + current_version, + checked_at, + update_available: false, + latest_version: None, + latest_name: None, + release_url: None, + release_notes: None, + published_at: None, + error: Some(format!("Could not read GitHub release metadata: {error}")), + }); + } + }; + let latest_version = release_version_from_tag(&release.tag_name); + let update_available = version_is_newer(&latest_version, ¤t_version); + persist_update_last_checked_at(&state.paths, &checked_at).await?; + + Ok(UpdateCheckResult { + current_version, + checked_at, + update_available, + latest_version: Some(latest_version), + latest_name: release.name.or(Some(release.tag_name)), + release_url: Some(release.html_url), + release_notes: release + .body + .map(|body| body.trim().chars().take(1400).collect::()) + .filter(|body| !body.is_empty()), + published_at: release.published_at, + error: None, + }) +} + +// Empty unless a real minisign public key has been put in tauri.conf.json (see +// docs/SIGNING.md). Unsigned local builds keep working; they just report +// `unconfigured` instead of downloading something they could never verify. +#[cfg(desktop)] +pub(crate) fn updater_pubkey(app: &AppHandle) -> Option { + updater_pubkey_from_config(app.config().plugins.0.get("updater")) +} + +// Split out so the placeholder-versus-real-key branch is testable without an +// AppHandle. Whitespace is trimmed because the key is pasted in by hand during +// signing setup, and a trailing newline is the obvious way to get a "configured" +// build that fails every signature check. +#[cfg(desktop)] +pub(crate) fn updater_pubkey_from_config(updater: Option<&serde_json::Value>) -> Option { + updater + .and_then(|updater| updater.get("pubkey")) + .and_then(|pubkey| pubkey.as_str()) + .map(str::trim) + .filter(|pubkey| !pubkey.is_empty()) + .map(str::to_string) +} + +// Replacing the app in place only works where the install is a self-contained +// bundle we own. A .deb or .rpm is owned by the system package manager, and +// overwriting its files from inside the running process would corrupt it — those +// installs update through apt/dnf or a fresh download. +#[cfg(desktop)] +pub(crate) fn updater_install_support(app: &AppHandle) -> Result<(), String> { + let _ = app; + #[cfg(target_os = "linux")] + if app.env().appimage.is_none() { + return Err( + "This Linux install is managed by your package manager. Update it with apt \ + (or download the latest .deb) rather than from inside ÆTHER." + .to_string(), + ); + } + Ok(()) +} + +// Downloads, signature-verifies, and installs the newest release. Deliberately +// separate from aether_system_check_for_update: that one reads the GitHub API for +// human-readable release notes, this one reads the signed updater manifest. They +// can disagree — a release whose latest.json failed to upload is visible to the +// first and invisible to the second — and the UI needs to say so rather than +// silently do nothing. +#[tauri::command] +pub(crate) async fn aether_system_install_update(app: AppHandle) -> Cmd { + #[cfg(not(desktop))] + { + let _ = app; + return Ok(UpdateInstallResult::new( + UpdateInstallStatus::Unsupported, + "Mobile builds update through the app store, not from inside ÆTHER.", + )); + } + + #[cfg(desktop)] + aether_install_update_desktop(app).await +} + +#[cfg(desktop)] +pub(crate) async fn aether_install_update_desktop(app: AppHandle) -> Cmd { + use tauri_plugin_updater::UpdaterExt; + + let Some(pubkey) = updater_pubkey(&app) else { + return Ok(UpdateInstallResult::new( + UpdateInstallStatus::Unconfigured, + "This build has no update signing key, so ÆTHER cannot verify a download. \ + Install the new version from the releases page instead.", + )); + }; + + if let Err(message) = updater_install_support(&app) { + return Ok(UpdateInstallResult::new( + UpdateInstallStatus::Unsupported, + message, + )); + } + + let updater = app + .updater_builder() + .pubkey(pubkey) + .build() + .map_err(|error| format!("Could not start the updater: {error}"))?; + + let update = match updater.check().await { + Ok(Some(update)) => update, + Ok(None) => { + return Ok(UpdateInstallResult::new( + UpdateInstallStatus::Unavailable, + "The update manifest has no newer signed build for this platform yet.", + )) + } + // A release can exist while carrying no artifact for this os/arch — ÆTHER + // publishes no ARM Linux AppImage, for one. That is a gap in the release, + // not a failure the user can act on, so it reads as unavailable rather + // than as an error about a manifest they have never heard of. + Err(tauri_plugin_updater::Error::TargetNotFound(target)) => { + return Ok(UpdateInstallResult::new( + UpdateInstallStatus::Unavailable, + format!("The latest release has no build for {target}."), + )) + } + Err(tauri_plugin_updater::Error::TargetsNotFound(targets)) => { + return Ok(UpdateInstallResult::new( + UpdateInstallStatus::Unavailable, + format!( + "The latest release has no build for this platform ({}).", + targets.join(" or ") + ), + )) + } + Err(error) => { + return Err(format!("Could not read the update manifest: {error}")); + } + }; + + let version = update.version.clone(); + let progress_app = app.clone(); + let mut downloaded_bytes: u64 = 0; + + update + .download_and_install( + move |chunk_length, total_bytes| { + downloaded_bytes += chunk_length as u64; + let _ = progress_app.emit( + AETHER_UPDATE_PROGRESS_EVENT, + UpdateInstallProgress { + downloaded_bytes, + total_bytes, + done: false, + }, + ); + }, + { + let app = app.clone(); + move || { + let _ = app.emit( + AETHER_UPDATE_PROGRESS_EVENT, + UpdateInstallProgress { + downloaded_bytes: 0, + total_bytes: None, + done: true, + }, + ); + } + }, + ) + .await + .map_err(|error| format!("Could not install ÆTHER {version}: {error}"))?; + + Ok(UpdateInstallResult { + status: UpdateInstallStatus::Installed.as_str().to_string(), + version: Some(version.clone()), + needs_restart: true, + message: format!("ÆTHER {version} is installed and starts on the next launch."), + }) +} + +// Tauri's own AppHandle::restart() cannot be used here, on two counts. +// +// Commands run off the main thread, so restart() takes its threaded path: it asks +// the runtime to exit and waits for RunEvent::ExitRequested to carry out the +// relaunch. Our ExitRequested handler answers with force_exit(), so the process +// dies before the relaunch ever happens — the user clicks Restart and the app +// simply quits. +// +// Its fallback re-exec is no better: tauri::process::restart ends in +// std::process::exit, which runs the C++ static destructors that force_exit() +// exists specifically to avoid (see the comment there). That would spawn the new +// ÆTHER and then crash the old one on the way out. +// +// So spawn the replacement ourselves and leave the same way every other quit does. +#[cfg(desktop)] +pub(crate) fn relaunch_after_update(app: &AppHandle) -> ! { + let env = app.env(); + let binary = tauri::process::current_binary(&env); + + // Relaunch the bundle, not the inner binary: `open` re-registers the freshly + // written .app with Launch Services, which is what the Dock and Gatekeeper + // know about. `-n` is required — without it, `open` sees the still-running old + // process, activates that instead, and nothing restarts once it exits. + #[cfg(target_os = "macos")] + if let Ok(binary) = binary.as_ref() { + let bundle = binary + .parent() + .and_then(|macos| macos.parent()) + .and_then(|contents| contents.parent()); + if let Some(bundle) = bundle.filter(|path| path.extension().is_some_and(|ext| ext == "app")) + { + let _ = std::process::Command::new("/usr/bin/open") + .arg("-n") + .arg(bundle) + .spawn(); + force_exit(); + } + } + + if let Ok(binary) = binary { + let _ = std::process::Command::new(binary) + .args(env.args_os.iter().skip(1)) + .spawn(); + } + force_exit(); +} + +// Split from the install so the user chooses when to lose their current window. +// Session state is already persisted per-action, but an update is not a good +// moment to surprise someone mid-read. +#[tauri::command] +pub(crate) async fn aether_system_relaunch(app: AppHandle) -> Cmd<()> { + #[cfg(desktop)] + { + save_window_geometry_now(&app); + relaunch_after_update(&app); + } + #[cfg(not(desktop))] + { + let _ = app; + Err("Restarting from inside the app is only supported on desktop.".to_string()) + } +} + +#[tauri::command] +pub(crate) async fn aether_system_update_models( + state: State<'_, Backend>, + input: UpdateModelsInput, +) -> Cmd { + let mut settings = load_settings(&state.paths.settings_path).await?; + if let Some(model) = input.embedding_model { + settings.local_model.embedding_model = + Some(model.trim().to_string()).filter(|item| !item.is_empty()); + } + if let Some(model) = input.chat_model { + settings.local_model.chat_model = + Some(model.trim().to_string()).filter(|item| !item.is_empty()); + } + save_json(&state.paths.settings_path, &settings).await?; + system_status(&state).await +} + +#[tauri::command] +pub(crate) async fn aether_system_download_models( + app: AppHandle, + state: State<'_, Backend>, + input: DownloadModelsInput, +) -> Cmd { + download_managed_models(&app, &state, input).await?; + system_status(&state).await +} + +/// Recent diagnostics, newest first, for the Settings panel. In-memory only — the +/// on-disk log is for export, not for rendering. +#[tauri::command] +pub(crate) async fn aether_system_diagnostics() -> Cmd> { + Ok(diagnostics::recent()) +} + +/// Copies the diagnostics log somewhere the user can attach it to a bug report, and +/// reveals it. Nothing leaves the machine until they do that themselves — which is +/// the whole reason this is an export rather than an upload. +#[tauri::command] +pub(crate) async fn aether_system_export_diagnostics( + app: AppHandle, + state: State<'_, Backend>, +) -> Cmd { + let source = diagnostics::diagnostics_log_path() + .ok_or_else(|| "The diagnostics log has not been opened yet.".to_string())?; + + let exported_at = now(); + let filename = format!("aether-diagnostics-{}.log", exported_at.replace(':', "-")); + let target = state.paths.exports_path.join(&filename); + ensure_parent_dir(&target).await?; + + // Written from the file rather than the in-memory buffer so an export covers + // earlier sessions too, which is usually where the interesting failure is. + let contents = tokio::fs::read(&source).await.unwrap_or_default(); + let byte_size = contents.len() as u64; + tokio::fs::write(&target, contents) + .await + .map_err(|error| format!("Could not write the diagnostics export: {error}"))?; + + if let Err(error) = app.opener().reveal_item_in_dir(&target) { + diag_warn!("exported diagnostics but could not reveal them: {error}"); + } + + Ok(DiagnosticsExportResult { + path: target.display().to_string(), + filename, + byte_size, + exported_at, + }) +} + +// ÆTHER keeps no cloud copy, so a user-owned export is the only real backup. This +// snapshots every store into one timestamped folder the user can copy anywhere. +#[tauri::command] +pub(crate) async fn aether_system_export_library( + app: AppHandle, + state: State<'_, Backend>, +) -> Cmd { + let paths = &state.paths; + let exported_at = now(); + let folder_name = format!("aether-export-{}", exported_at.replace(':', "-")); + let target_dir = paths.exports_path.join(&folder_name); + tokio::fs::create_dir_all(&target_dir) + .await + .map_err(|error| error.to_string())?; + + // chunks.json holds only metadata; without the binary sidecar beside it the + // export would restore a library whose sources cannot be searched. + // + // `chunks.v1.json` is deliberately not exported. It holds only vectors from a + // superseded embedding model, and the text they belong to is already in + // chunks.json — so a restore plus a re-index reproduces everything it contains, + // for none of the ~5x size. + let chunks_vec_path = vector_data_path(&paths.chunks_path); + let sources: [(&PathBuf, &str); 7] = [ + (&paths.library_path, "library.json"), + (&paths.chunks_path, "chunks.json"), + (&chunks_vec_path, "chunks.vec"), + (&paths.settings_path, "settings.json"), + (&paths.icebergs_path, "icebergs.json"), + (&paths.conversations_path, "conversations.json"), + (&paths.session_path, "session.json"), + ]; + + let mut files = Vec::new(); + let mut byte_size = 0_u64; + for (source, name) in sources { + if !tokio::fs::try_exists(source).await.unwrap_or(false) { + continue; + } + let copied = tokio::fs::copy(source, target_dir.join(name)) + .await + .map_err(|error| format!("Could not export {name}: {error}"))?; + byte_size += copied; + files.push(name.to_string()); + } + + if files.is_empty() { + // Leaving an empty folder behind would look like a successful export. + let _ = tokio::fs::remove_dir(&target_dir).await; + return Err("There is nothing to export yet.".to_string()); + } + + let library = load_library(&paths.library_path).await.unwrap_or_default(); + let capture_count = library.captures.len(); + let chunk_count = library + .collections + .iter() + .map(|collection| collection.chunk_count) + .sum::(); + + let manifest = serde_json::json!({ + "app": "aether", + "appVersion": app.package_info().version.to_string(), + "exportedAt": exported_at, + "files": files, + "captureCount": capture_count, + "chunkCount": chunk_count, + "collectionCount": library.collections.len(), + }); + let manifest_raw = + serde_json::to_string_pretty(&manifest).map_err(|error| error.to_string())?; + tokio::fs::write( + target_dir.join("manifest.json"), + format!("{manifest_raw}\n"), + ) + .await + .map_err(|error| error.to_string())?; + files.push("manifest.json".to_string()); + + // Opening the folder is the expected payoff of an explicit export click, but a + // desktop without a file manager should not fail the export. + if let Err(error) = app.opener().reveal_item_in_dir(&target_dir) { + diag_warn!("exported but could not reveal folder: {error}"); + } + + Ok(LibraryExportResult { + path: target_dir.display().to_string(), + exported_at, + files, + capture_count, + chunk_count, + byte_size, + }) +} + +// Deliberately a separate command rather than a field on SystemStatus: answering it +// forces the vector store to load, and SystemStatus runs at startup where that would +// add a full parse of the metadata plus the sidecar to launch. Settings asks for this +// only when opened, which is also the only place the re-index button lives. +#[tauri::command] +pub(crate) async fn aether_library_index_status( + state: State<'_, Backend>, +) -> Cmd { + with_vectors_read(&state, |vectors| LibraryIndexStatus { + dim: vectors.dim, + embedded: vectors.embedded_count() as usize, + pending_reembed: vectors.pending_reembed_count(), + }) + .await +} + +// Re-embeds every retained chunk with the loaded embedding model. This is the only +// way out of a store whose vectors came from a different model: the widths cannot be +// compared, so those chunks are invisible to search until they are embedded again. +// Chunk text is kept in the store, so this is local compute — no page is refetched. +#[tauri::command] +pub(crate) async fn aether_library_reindex( + app: AppHandle, + state: State<'_, Backend>, +) -> Cmd { + let settings = load_settings(&state.paths.settings_path).await?; + + // Snapshot ids and text without holding the lock: embedding takes minutes on a + // large library, and blocking every capture for its duration is not acceptable. + let pending = with_vectors_read(&state, |vectors| { + vectors + .chunks + .iter() + .map(|chunk| (chunk.id.clone(), chunk.text.clone())) + .collect::>() + }) + .await?; + + if pending.is_empty() { + return Err("There is nothing to re-index yet.".to_string()); + } + + let total = pending.len(); + emit_capture_progress(&app, "Re-indexing library", Some(0), Some(total)); + + let mut vectors_by_id: HashMap> = HashMap::with_capacity(total); + // Batched so progress advances steadily and peak memory stays bounded, rather than + // handing the runtime every chunk in the library at once. + for (batch_index, batch) in pending.chunks(REINDEX_BATCH_SIZE).enumerate() { + let inputs = batch + .iter() + .map(|(_, text)| text.clone()) + .collect::>(); + let embeddings = local_embed_with_progress( + &state, + &settings, + inputs, + Some(EmbeddingProgress { + app: app.clone(), + message: "Re-indexing library".to_string(), + }), + ) + .await?; + if embeddings.len() != batch.len() { + return Err( + "Local embedding model returned an unexpected number of embeddings.".to_string(), + ); + } + for ((id, _), vector) in batch.iter().zip(embeddings) { + vectors_by_id.insert(id.clone(), vector); + } + emit_capture_progress( + &app, + "Re-indexing library", + Some(((batch_index + 1) * REINDEX_BATCH_SIZE).min(total)), + Some(total), + ); + } + + let dim = vectors_by_id + .values() + .map(Vec::len) + .max() + .filter(|dim| *dim > 0) + .ok_or_else(|| "The embedding model returned no usable vectors.".to_string())?; + + let mut guard = state.vectors.write().await; + if guard.is_none() { + *guard = Some(load_vectors(&state.paths.chunks_path).await?); + } + let data = guard.as_mut().expect("vector store cache"); + + // Rebuild rather than patch: the store's width is changing, so every slot has to + // be reassigned against the new stride. + data.dim = dim; + data.next_slot = 0; + let existing = std::mem::take(&mut data.chunks); + // Matched by id, so chunks captured while the embedding ran keep their own vectors + // instead of being matched to the wrong text by position. + data.push_chunks(existing.into_iter().map(|mut chunk| { + if let Some(vector) = vectors_by_id.remove(&chunk.id) { + chunk.vector = vector; + } + chunk.needs_reembed = false; + chunk + })); + + let embedded = data.embedded_count() as usize; + let still_pending = data.pending_reembed_count(); + write_vector_sidecar(&state.paths.chunks_path, data, 0).await?; + save_vector_metadata(&state.paths.chunks_path, data).await?; + drop(guard); + + emit_capture_progress(&app, "Re-index complete", Some(total), Some(total)); + Ok(LibraryReindexResult { + embedded, + still_pending, + dim, + reindexed_at: now(), + }) +} + +#[tauri::command(rename_all = "camelCase")] +pub(crate) fn aether_system_open_external_url(app: AppHandle, url: String) -> Cmd<()> { + let parsed = Url::parse(url.trim()).map_err(|_| "Invalid release URL.".to_string())?; + match parsed.scheme() { + "https" | "http" => app + .opener() + .open_url(parsed.to_string(), None::) + .map_err(|error| error.to_string()), + _ => Err("Only web URLs can be opened from Settings.".to_string()), + } +} + +#[tauri::command(rename_all = "camelCase")] +pub(crate) fn aether_layout_set_panel_collapsed( + app: AppHandle, + state: State, + collapsed: bool, +) -> Cmd<()> { + { + let mut tabs = lock_tabs(&state)?; + tabs.panel_collapsed = collapsed; + } + sync_native_webview_visibility(&app, &state)?; + emit_state(&app, &state) +} + +#[tauri::command(rename_all = "camelCase")] +pub(crate) fn aether_layout_set_modal_overlay_open( + app: AppHandle, + state: State, + open: bool, +) -> Cmd<()> { + { + let mut tabs = lock_tabs(&state)?; + tabs.modal_overlay_open = open; + } + sync_native_webview_visibility(&app, &state) +} + +#[tauri::command] +pub(crate) fn aether_layout_show_status_toast(input: StatusToastInput) -> Cmd<()> { + let _ = (input.message, input.tone, input.duration_ms); + Ok(()) +} diff --git a/src-tauri/src/diagnostics.rs b/src-tauri/src/diagnostics.rs new file mode 100644 index 0000000..dfc1e71 --- /dev/null +++ b/src-tauri/src/diagnostics.rs @@ -0,0 +1,189 @@ +//! A local diagnostics log the user can read and export. +//! +//! ÆTHER ships CPU-only Windows and Linux builds that are never run before +//! release, and it collects no telemetry — so without this, a failure on those +//! platforms produces a line on a stderr nobody will ever see. The point is a +//! feedback loop that costs the privacy promise nothing: the log is written only +//! to the user's own machine, is visible in Settings, and leaves it only when the +//! user presses Export. +//! +//! What is deliberately not recorded: page text, captured content, search queries, +//! and answers. Entries are operational — what failed and where — so an exported +//! log cannot become an accidental transcript of what someone was reading. + +use super::*; +use std::collections::VecDeque; +use std::fmt::Write as _; +use std::sync::OnceLock; + +/// Kept in memory so Settings can show recent entries without reading the file +/// back. Bounded because a tight failure loop must not grow the process. +const MAX_ENTRIES: usize = 400; +/// The on-disk log is truncated to the newest half once it passes this. A log that +/// grows without limit is a bug report nobody can attach. +const MAX_LOG_BYTES: u64 = 512 * 1024; + +pub(crate) const DIAGNOSTICS_DIR: &str = "aether-diagnostics"; +pub(crate) const DIAGNOSTICS_FILE: &str = "aether.log"; + +#[derive(Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "lowercase")] +pub(crate) enum DiagnosticLevel { + Info, + Warn, + Error, +} + +impl DiagnosticLevel { + fn as_str(self) -> &'static str { + match self { + Self::Info => "info", + Self::Warn => "warn", + Self::Error => "error", + } + } +} + +#[derive(Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct DiagnosticEntry { + pub(crate) at: String, + pub(crate) level: DiagnosticLevel, + pub(crate) message: String, +} + +fn buffer() -> &'static Mutex> { + static BUFFER: OnceLock>> = OnceLock::new(); + BUFFER.get_or_init(|| Mutex::new(VecDeque::with_capacity(MAX_ENTRIES))) +} + +// Set once during setup. Held separately from Backend because the first entries +// can be recorded while the app state is still being built — session restore runs +// before anything else and is one of the paths most worth logging. +fn log_path() -> &'static OnceLock { + static LOG_PATH: OnceLock = OnceLock::new(); + &LOG_PATH +} + +pub(crate) fn set_log_path(app_data_dir: &Path) { + let _ = log_path().set(app_data_dir.join(DIAGNOSTICS_DIR).join(DIAGNOSTICS_FILE)); +} + +pub(crate) fn record(level: DiagnosticLevel, message: impl Into) { + let message = message.into(); + let entry = DiagnosticEntry { + at: now(), + level, + message, + }; + + // Still goes to stderr: `tauri dev` and a terminal launch are where this is + // most useful, and losing that would be a downgrade. Must stay a raw + // eprintln! — routing it through a diag_* macro recurses into this function. + eprintln!("aether [{}] {}", level.as_str(), entry.message); + + if let Ok(mut entries) = buffer().lock() { + if entries.len() == MAX_ENTRIES { + entries.pop_front(); + } + entries.push_back(entry.clone()); + } + + append_to_file(&entry); +} + +// Synchronous and best-effort by design. These are rare (failures, migrations), +// and a diagnostics write must never be able to fail an operation or block on an +// async runtime that may not exist on the calling thread. +fn append_to_file(entry: &DiagnosticEntry) { + let Some(path) = log_path().get() else { + return; + }; + write_entry(path, entry); +} + +// Split from append_to_file so the rollover and formatting can be tested without +// the process-wide OnceLock, which only one test could ever claim. +pub(crate) fn write_entry(path: &Path, entry: &DiagnosticEntry) { + if let Some(parent) = path.parent() { + if fs::create_dir_all(parent).is_err() { + return; + } + } + + if let Ok(metadata) = fs::metadata(path) { + if metadata.len() > MAX_LOG_BYTES { + truncate_to_newest_half(path); + } + } + + let mut line = String::new(); + let _ = writeln!( + line, + "{} [{}] {}", + entry.at, + entry.level.as_str(), + entry.message + ); + + use std::io::Write as _; + if let Ok(mut file) = fs::OpenOptions::new().create(true).append(true).open(path) { + let _ = file.write_all(line.as_bytes()); + } +} + +// Drops the oldest half rather than the whole file: a wrapping log that discards +// everything on rollover tends to be empty exactly when someone needs it. +fn truncate_to_newest_half(path: &Path) { + let Ok(existing) = fs::read_to_string(path) else { + return; + }; + let lines: Vec<&str> = existing.lines().collect(); + let keep = lines.split_at(lines.len() / 2).1.join("\n"); + let _ = fs::write(path, format!("{keep}\n")); +} + +pub(crate) fn recent() -> Vec { + buffer() + .lock() + .map(|entries| entries.iter().rev().cloned().collect()) + .unwrap_or_default() +} + +pub(crate) fn diagnostics_log_path() -> Option { + log_path().get().cloned() +} + +/// Records at warn level. Replaces the bare `eprintln!` calls that used to go to a +/// stderr nobody reads. +macro_rules! diag_warn { + ($($arg:tt)*) => { + crate::diagnostics::record( + crate::diagnostics::DiagnosticLevel::Warn, + format!($($arg)*), + ) + }; +} + +/// Records at error level: something the user's library or a core action depends on. +macro_rules! diag_error { + ($($arg:tt)*) => { + crate::diagnostics::record( + crate::diagnostics::DiagnosticLevel::Error, + format!($($arg)*), + ) + }; +} + +/// Records at info level. For state changes worth seeing in a bug report — +/// migrations, compactions, recoveries — not for routine activity. +macro_rules! diag_info { + ($($arg:tt)*) => { + crate::diagnostics::record( + crate::diagnostics::DiagnosticLevel::Info, + format!($($arg)*), + ) + }; +} + +pub(crate) use {diag_error, diag_info, diag_warn}; diff --git a/src-tauri/src/extract.rs b/src-tauri/src/extract.rs new file mode 100644 index 0000000..e297769 --- /dev/null +++ b/src-tauri/src/extract.rs @@ -0,0 +1,208 @@ +//! Turning a live page into capturable text: a snapshot from the webview where one +//! exists, an HTTP fetch and `scraper` pass where it does not. + +use super::*; + +pub(crate) async fn extract_readable_active_page( + state: &State<'_, Backend>, + active_tab: &ManagedTab, +) -> Cmd { + #[cfg(desktop)] + { + if let Ok(page) = extract_readable_page_from_webview(state, active_tab).await { + return Ok(page); + } + } + + #[cfg(target_os = "android")] + { + match extract_readable_page_from_android(active_tab) { + Ok(page) => return Ok(page), + Err(_) => {} + } + } + + extract_readable_page(&state.client, &active_tab.url).await +} + +// Android counterpart of extract_readable_page_from_webview: the Kotlin +// TabsPlugin's `snapshot` command reads the live DOM through +// evaluateJavascript's value callback, so logged-in and JS-rendered pages +// capture correctly instead of falling back to an anonymous HTTP re-fetch. +#[cfg(target_os = "android")] +pub(crate) fn extract_readable_page_from_android(active_tab: &ManagedTab) -> Cmd { + let response: android_tabs::SnapshotResponse = android_tabs::run_for_global( + "snapshot", + android_tabs::TabPayload { + tab_id: &active_tab.id, + }, + )?; + let payload = response.payload.trim(); + if payload.is_empty() || payload == "null" { + return Err("Unable to read the active page.".to_string()); + } + let snapshot = parse_page_snapshot(payload)?; + snapshot_to_captured_page(snapshot, &active_tab.title) +} + +#[cfg(desktop)] +pub(crate) async fn extract_readable_page_from_webview( + state: &State<'_, Backend>, + active_tab: &ManagedTab, +) -> Cmd { + let webview = state + .webviews + .lock() + .map_err(|_| "Æther webviews are unavailable.".to_string())? + .views + .get(&active_tab.id) + .cloned() + .ok_or_else(|| "Active browser webview is not ready.".to_string())?; + let script = r#"(() => { + const clone = document.documentElement.cloneNode(true); + clone.querySelectorAll('script, style, noscript, iframe, form, nav, footer, svg').forEach((node) => node.remove()); + return { + html: '' + clone.outerHTML, + url: location.href, + title: document.title, + description: document.querySelector('meta[name="description"]')?.getAttribute('content') || '', + bodyText: document.body?.innerText || '' + }; + })()"#; + let (sender, receiver) = tokio::sync::oneshot::channel::(); + let sender = Arc::new(Mutex::new(Some(sender))); + webview + .eval_with_callback(script, { + let sender = Arc::clone(&sender); + move |payload| { + if let Ok(mut sender) = sender.lock() { + if let Some(sender) = sender.take() { + let _ = sender.send(payload); + } + } + } + }) + .map_err(|error| error.to_string())?; + let payload = tokio::time::timeout(Duration::from_secs(5), receiver) + .await + .map_err(|_| "Timed out reading the active page.".to_string())? + .map_err(|_| "Unable to read the active page.".to_string())?; + let snapshot = parse_page_snapshot(&payload)?; + snapshot_to_captured_page(snapshot, &active_tab.title) +} + +pub(crate) fn parse_page_snapshot(payload: &str) -> Cmd { + parse_json_payload::(payload) +} + +pub(crate) fn parse_json_payload(payload: &str) -> Cmd { + let value = + serde_json::from_str::(payload).map_err(|error| error.to_string())?; + if let Some(inner) = value.as_str() { + serde_json::from_str::(inner).map_err(|error| error.to_string()) + } else { + serde_json::from_value::(value).map_err(|error| error.to_string()) + } +} + +pub(crate) fn snapshot_to_captured_page( + snapshot: BrowserPageSnapshot, + fallback_title: &str, +) -> Cmd { + let url = snapshot + .url + .filter(|url| !url.trim().is_empty()) + .ok_or_else(|| "Unable to read the active page.".to_string())?; + let parsed_document = snapshot + .html + .as_ref() + .map(|html| Html::parse_document(html)); + let title = snapshot + .title + .filter(|title| !title.trim().is_empty()) + .or_else(|| { + parsed_document + .as_ref() + .and_then(|document| select_first_text(document, "title")) + }) + .unwrap_or_else(|| fallback_title.to_string()); + let description = snapshot.description.unwrap_or_else(|| { + parsed_document + .as_ref() + .and_then(|document| select_meta_content(document, "description")) + .unwrap_or_default() + }); + let body_text = snapshot.body_text.unwrap_or_else(|| { + parsed_document + .as_ref() + .map(select_body_text) + .unwrap_or_default() + }); + let text = normalize_captured_text(&format!("{title}\n\n{description}\n\n{body_text}")); + + if text.len() < MIN_CAPTURE_TEXT_LENGTH { + return Err("This page does not contain enough readable text to capture.".to_string()); + } + + Ok(CapturedPage { title, url, text }) +} + +pub(crate) async fn extract_readable_page(client: &Client, url: &str) -> Cmd { + let parsed = Url::parse(url).map_err(|_| "Unable to read the active page URL.".to_string())?; + if parsed.scheme() != "http" && parsed.scheme() != "https" { + return Err("Only http and https pages can be captured in the Tauri build.".to_string()); + } + let response = client + .get(url) + .timeout(Duration::from_secs(20)) + .send() + .await + .map_err(|error| error.to_string())?; + if !response.status().is_success() { + return Err(format!("Unable to fetch page: {}", response.status())); + } + let html = response.text().await.map_err(|error| error.to_string())?; + let document = Html::parse_document(&html); + let title = select_first_text(&document, "title") + .filter(|title| !title.is_empty()) + .unwrap_or_else(|| title_from_url(url)); + let description = select_meta_content(&document, "description").unwrap_or_default(); + let body_text = select_body_text(&document); + let text = normalize_captured_text(&format!("{title}\n\n{description}\n\n{body_text}")); + if text.len() < MIN_CAPTURE_TEXT_LENGTH { + return Err("This page does not contain enough readable text to capture.".to_string()); + } + Ok(CapturedPage { + title, + url: url.to_string(), + text, + }) +} + +pub(crate) fn select_first_text(document: &Html, selector: &str) -> Option { + let selector = Selector::parse(selector).ok()?; + document + .select(&selector) + .next() + .map(|node| node.text().collect::>().join(" ").trim().to_string()) +} + +pub(crate) fn select_meta_content(document: &Html, name: &str) -> Option { + let selector = Selector::parse(&format!("meta[name=\"{name}\"]")).ok()?; + document + .select(&selector) + .next() + .and_then(|node| node.value().attr("content")) + .map(|value| value.trim().to_string()) +} + +pub(crate) fn select_body_text(document: &Html) -> String { + let selector = Selector::parse("body").expect("body selector"); + document + .select(&selector) + .flat_map(|node| node.text()) + .map(str::trim) + .filter(|text| !text.is_empty()) + .collect::>() + .join(" ") +} diff --git a/src-tauri/src/flow.rs b/src-tauri/src/flow.rs new file mode 100644 index 0000000..63bfd39 --- /dev/null +++ b/src-tauri/src/flow.rs @@ -0,0 +1,285 @@ +//! The Flow query graph: hubs and sources as nodes, semantic similarity as edges. + +use super::*; + +pub(crate) async fn flow_graph_generate( + state: &State<'_, Backend>, + input: FlowGraphInput, +) -> Cmd { + let query = input.query.unwrap_or_default().trim().to_string(); + let source_limit = input + .source_limit + .unwrap_or(DEFAULT_FLOW_GRAPH_SOURCE_LIMIT) + .clamp(1, MAX_FLOW_GRAPH_SOURCE_LIMIT); + let library = load_library(&state.paths.library_path).await?; + let collection_names = library + .collections + .iter() + .map(|collection| (collection.id.clone(), collection.name.clone())) + .collect::>(); + let chunks = with_vectors_read(state, |vectors| vectors.chunks.clone()).await?; + let mut chunks_by_capture = HashMap::>::new(); + for chunk in chunks { + chunks_by_capture + .entry(chunk.capture_id.clone()) + .or_default() + .push(chunk); + } + + let mut captures = library.captures.clone(); + captures.sort_by(|left, right| right.captured_at.cmp(&left.captured_at)); + let indexed_source_count = captures + .iter() + .filter(|capture| chunks_by_capture.contains_key(&capture.id)) + .count(); + let mut sources = Vec::::new(); + for capture in captures { + if sources.len() >= source_limit { + break; + } + let Some(chunks) = chunks_by_capture.get(&capture.id) else { + continue; + }; + let Some(vector) = average_flow_source_vector(chunks) else { + continue; + }; + let collection_name = collection_names + .get(&capture.collection_id) + .cloned() + .unwrap_or_else(|| "Knowledge Hub".to_string()); + let excerpt = chunks + .iter() + .max_by_key(|chunk| chunk.text.chars().count()) + .map(|chunk| semantic_trail_excerpt(&chunk.text, 300)) + .unwrap_or_default(); + sources.push(FlowSourceCandidate { + capture, + collection_name, + vector, + excerpt, + }); + } + + let query_scores = if query.is_empty() || sources.is_empty() { + HashMap::new() + } else { + let settings = load_settings(&state.paths.settings_path).await?; + let query_vector = local_embed_query(state, &settings, query.clone()).await?; + sources + .iter() + .map(|source| { + let distance = cosine_distance(&query_vector, &source.vector); + ( + flow_source_node_id(&source.capture.id), + semantic_score_from_distance(distance), + ) + }) + .filter(|(_, score)| score.is_finite()) + .collect::>() + }; + + let mut nodes = Vec::::new(); + if !query.is_empty() { + nodes.push(FlowGraphNode { + id: "query".to_string(), + kind: FlowGraphNodeKind::Query, + title: query.clone(), + subtitle: "Semantic search lens".to_string(), + weight: 72.0, + collection_id: None, + collection_name: None, + capture_id: None, + url: None, + host: None, + captured_at: None, + excerpt: None, + score: None, + }); + } + + for collection in &library.collections { + nodes.push(FlowGraphNode { + id: flow_hub_node_id(&collection.id), + kind: FlowGraphNodeKind::Hub, + title: collection.name.clone(), + subtitle: format!( + "{} sources · {} chunks", + collection.capture_count, collection.chunk_count + ), + weight: (42.0 + (collection.capture_count as f64 * 7.0)).min(92.0), + collection_id: Some(collection.id.clone()), + collection_name: Some(collection.name.clone()), + capture_id: None, + url: None, + host: None, + captured_at: Some(collection.updated_at.clone()), + excerpt: Some(collection.description.clone()) + .filter(|description| !description.is_empty()), + score: None, + }); + } + + for source in &sources { + let node_id = flow_source_node_id(&source.capture.id); + nodes.push(FlowGraphNode { + id: node_id.clone(), + kind: FlowGraphNodeKind::Source, + title: source.capture.title.clone(), + subtitle: source.collection_name.clone(), + weight: (24.0 + (source.capture.chunk_count as f64 * 2.0)).min(58.0), + collection_id: Some(source.capture.collection_id.clone()), + collection_name: Some(source.collection_name.clone()), + capture_id: Some(source.capture.id.clone()), + url: Some(source.capture.url.clone()), + host: Some(get_tab_host(&source.capture.url)), + captured_at: Some(source.capture.captured_at.clone()), + excerpt: Some(source.excerpt.clone()).filter(|excerpt| !excerpt.is_empty()), + score: query_scores.get(&node_id).copied().map(round_score), + }); + } + + let mut edges = Vec::::new(); + for source in &sources { + push_flow_graph_edge( + &mut edges, + &flow_hub_node_id(&source.capture.collection_id), + &flow_source_node_id(&source.capture.id), + FlowGraphEdgeKind::Contains, + 36.0, + ); + } + + if !query_scores.is_empty() { + let mut matches = query_scores.iter().collect::>(); + matches.sort_by(|left, right| right.1.partial_cmp(left.1).unwrap_or(Ordering::Equal)); + for (node_id, score) in matches.into_iter().take(FLOW_GRAPH_QUERY_MATCH_LIMIT) { + if *score >= FLOW_GRAPH_MIN_EDGE_SCORE { + push_flow_graph_edge( + &mut edges, + "query", + node_id, + FlowGraphEdgeKind::QueryMatch, + *score, + ); + } + } + } + + let mut semantic_edges = Vec::::new(); + for left_index in 0..sources.len() { + for right_index in (left_index + 1)..sources.len() { + let left = &sources[left_index]; + let right = &sources[right_index]; + let distance = cosine_distance(&left.vector, &right.vector); + let weight = semantic_score_from_distance(distance); + if weight >= FLOW_GRAPH_MIN_EDGE_SCORE { + semantic_edges.push(FlowEdgeCandidate { + from: flow_source_node_id(&left.capture.id), + to: flow_source_node_id(&right.capture.id), + weight, + }); + } + } + } + semantic_edges.sort_by(|left, right| { + right + .weight + .partial_cmp(&left.weight) + .unwrap_or(Ordering::Equal) + }); + let mut neighbor_counts = HashMap::::new(); + let mut semantic_edge_count = 0_usize; + for candidate in semantic_edges { + if semantic_edge_count >= FLOW_GRAPH_MAX_SEMANTIC_EDGES { + break; + } + let left_count = *neighbor_counts.get(&candidate.from).unwrap_or(&0); + let right_count = *neighbor_counts.get(&candidate.to).unwrap_or(&0); + if left_count >= FLOW_GRAPH_NEIGHBORS_PER_SOURCE + || right_count >= FLOW_GRAPH_NEIGHBORS_PER_SOURCE + { + continue; + } + push_flow_graph_edge( + &mut edges, + &candidate.from, + &candidate.to, + FlowGraphEdgeKind::Semantic, + candidate.weight, + ); + semantic_edge_count += 1; + *neighbor_counts.entry(candidate.from).or_insert(0) += 1; + *neighbor_counts.entry(candidate.to).or_insert(0) += 1; + } + + Ok(FlowGraphResult { + query, + generated_at: now(), + nodes, + edges, + hub_count: library.collections.len(), + source_count: sources.len(), + omitted_source_count: indexed_source_count.saturating_sub(sources.len()), + }) +} + +pub(crate) fn average_flow_source_vector(chunks: &[ChunkRecord]) -> Option> { + let first = chunks.first()?; + let dimensions = first.vector.len(); + if dimensions == 0 { + return None; + } + let mut total = vec![0.0_f32; dimensions]; + let mut count = 0.0_f32; + for chunk in chunks { + if chunk.vector.len() != dimensions { + continue; + } + for (index, value) in chunk.vector.iter().enumerate() { + total[index] += *value; + } + count += 1.0; + } + if count == 0.0 { + return None; + } + for value in &mut total { + *value /= count; + } + Some(normalize_embedding(&total)) +} + +pub(crate) fn flow_hub_node_id(collection_id: &str) -> String { + format!("hub-{collection_id}") +} + +pub(crate) fn flow_source_node_id(capture_id: &str) -> String { + format!("source-{capture_id}") +} + +pub(crate) fn push_flow_graph_edge( + edges: &mut Vec, + from: &str, + to: &str, + kind: FlowGraphEdgeKind, + weight: f64, +) { + if from == to { + return; + } + edges.push(FlowGraphEdge { + id: format!("{}-{from}-{to}", flow_edge_kind_label(kind)), + from: from.to_string(), + to: to.to_string(), + kind, + weight: round_score(weight), + }); +} + +pub(crate) fn flow_edge_kind_label(kind: FlowGraphEdgeKind) -> &'static str { + match kind { + FlowGraphEdgeKind::Contains => "contains", + FlowGraphEdgeKind::Semantic => "semantic", + FlowGraphEdgeKind::QueryMatch => "query", + } +} diff --git a/src-tauri/src/iceberg.rs b/src-tauri/src/iceberg.rs new file mode 100644 index 0000000..e7de80c --- /dev/null +++ b/src-tauri/src/iceberg.rs @@ -0,0 +1,564 @@ +//! iCE (Iceberg Complexity Explorer): prompt construction and the normaliser that +//! turns a model's loosely-shaped JSON into laid-out, level-banded topics. +//! +//! The normaliser is defensive on purpose — a 4B model will return missing fields, +//! out-of-range scores, and duplicate names, and none of that should reach the UI. + +use super::*; + +pub(crate) fn build_iceberg_prompt(keyword: &str) -> String { + format!( + r#"Create an iceberg chart for the topic "{keyword}". + +Return JSON only with this exact shape: +{{ + "recommendedItemCount": 24, + "items": [ + {{ + "name": "Visible phrase", + "description": "One short explanation.", + "depthScore": 12, + "familiarity": 85, + "specificity": 20, + "jargonDensity": 15, + "prerequisiteDepth": 10, + "obscurity": 12, + "reason": "Common public entry point." + }} + ] +}} + +Rules: +- Choose recommendedItemCount based on topic scope: 12-18 for narrow topics, 20-30 for medium topics, 32-45 for broad domains. +- Return between 12 and 45 usable items. +- Cover all five iceberg layers whenever the topic has enough material. Include at least one item intentionally scored for each depth band: 0-19, 20-39, 40-59, 60-79, and 80-100. +- Prefer 2-5 genuinely obscure or specialist items for the 80-100 band instead of stopping at intermediate concepts. +- Do not include more than 10 items that would clearly belong to the same depth band. +- Use concise item names that fit on a node. +- Every item must include a non-empty description. +- Scores are integers from 0-100, never 1-10. +- depthScore: intended iceberg depth, where 0-19 is public surface knowledge and 80-100 is obscure, specialist, prerequisite-heavy, or rarely explained. +- familiarity: how likely a general audience already knows this. +- specificity: how narrow the concept is within the topic. +- jargonDensity: how much specialist vocabulary is needed. +- prerequisiteDepth: how much prior conceptual knowledge is required. +- obscurity: how rarely this appears in mainstream explainers or beginner material. +- reason: one short explanation for why the item sits at its depth. +- Do not include level; the app will compute levels from the scoring rubric. +- Do not include markdown, prose, or comments."# + ) +} + +#[derive(Clone)] +pub(crate) struct ScoredIcebergItem { + pub(crate) item: IcebergItem, + pub(crate) score: f64, +} + +fn recover_complete_iceberg_items(json_text: &str) -> Option { + let array_start = json_text.find('[')?; + let mut objects = Vec::new(); + let mut object_start = None; + let mut object_depth = 0_u32; + let mut in_string = false; + let mut escaped = false; + + for (offset, character) in json_text[array_start + 1..].char_indices() { + let index = array_start + 1 + offset; + + if in_string { + if escaped { + escaped = false; + } else if character == '\\' { + escaped = true; + } else if character == '"' { + in_string = false; + } + continue; + } + + match character { + '"' if object_depth > 0 => in_string = true, + '{' => { + if object_depth == 0 { + object_start = Some(index); + } + object_depth += 1; + } + '}' if object_depth > 0 => { + object_depth -= 1; + if object_depth == 0 { + let start = object_start.take()?; + if let Ok(value) = + serde_json::from_str::(&json_text[start..=index]) + { + objects.push(value); + } + } + } + ']' if object_depth == 0 => break, + _ => {} + } + } + + (!objects.is_empty()).then(|| serde_json::Value::Array(objects)) +} + +pub(crate) fn normalize_iceberg_items(response: &str) -> Cmd> { + let json_text = response + .trim() + .trim_start_matches("```json") + .trim_start_matches("```") + .trim_end_matches("```") + .trim(); + let parsed = match serde_json::from_str::(json_text) { + Ok(value) => value, + Err(_) => { + let parsed_array = + json_text + .find('[') + .zip(json_text.rfind(']')) + .and_then(|(start, end)| { + serde_json::from_str::(&json_text[start..=end]).ok() + }); + parsed_array + .or_else(|| recover_complete_iceberg_items(json_text)) + .ok_or_else(|| "Local model did not return valid iceberg JSON.".to_string())? + } + }; + let requested_count = iceberg_requested_count(&parsed); + let items_value = parsed.get("items").cloned().unwrap_or(parsed); + let raw_items = items_value + .as_array() + .ok_or_else(|| "Local model did not return valid iceberg JSON.".to_string())?; + let target_count = requested_count + .unwrap_or(raw_items.len()) + .clamp(ICEBERG_MIN_ITEMS, ICEBERG_MAX_ITEMS); + let mut candidates = Vec::::new(); + let mut seen_names = HashSet::::new(); + for raw in raw_items { + let name = raw + .get("name") + .and_then(|value| value.as_str()) + .unwrap_or("") + .trim(); + let description = raw + .get("description") + .and_then(|value| value.as_str()) + .unwrap_or("") + .trim(); + if name.is_empty() || description.is_empty() { + continue; + } + + let dedupe_key = slugify(name); + if dedupe_key.is_empty() || !seen_names.insert(dedupe_key) { + continue; + } + + let fallback_level = iceberg_level_field(raw).unwrap_or(3); + let fallback_score = iceberg_fallback_score(fallback_level); + let familiarity = iceberg_metric_field(raw, &["familiarity"]); + let specificity = iceberg_metric_field(raw, &["specificity"]); + let jargon_density = iceberg_metric_field(raw, &["jargonDensity", "jargon_density"]); + let prerequisite_depth = + iceberg_metric_field(raw, &["prerequisiteDepth", "prerequisite_depth"]); + let obscurity = iceberg_metric_field(raw, &["obscurity"]); + let explicit_depth = + iceberg_metric_field(raw, &["depthScore", "depth_score", "complexityScore"]); + let score = explicit_depth.unwrap_or_else(|| { + iceberg_depth_score( + familiarity, + specificity, + jargon_density, + prerequisite_depth, + obscurity, + fallback_score, + ) + }); + let score = round_score(score.clamp(0.0, 100.0)); + let level = iceberg_level_from_score(score); + let reason = iceberg_string_field(raw, &["reason", "rationale", "levelReason"]) + .map(|reason| reason.chars().take(220).collect::()); + + candidates.push(ScoredIcebergItem { + item: IcebergItem { + id: String::new(), + name: name.to_string(), + description: description.to_string(), + level, + x: 0.0, + y: 0.0, + depth_score: Some(score), + familiarity, + specificity, + jargon_density, + prerequisite_depth, + obscurity, + reason, + }, + score, + }); + } + + stretch_iceberg_candidate_levels(&mut candidates); + + candidates.sort_by(|left, right| { + left.item + .level + .cmp(&right.item.level) + .then_with(|| { + left.score + .partial_cmp(&right.score) + .unwrap_or(Ordering::Equal) + }) + .then_with(|| left.item.name.cmp(&right.item.name)) + }); + + let mut buckets = HashMap::>::new(); + for candidate in candidates { + buckets + .entry(candidate.item.level) + .or_default() + .push(candidate); + } + + let mut selected = Vec::::new(); + let mut by_level = HashMap::::new(); + + for level in 1..=ICEBERG_LEVEL_COUNT { + if selected.len() >= target_count { + break; + } + if let Some(candidate) = take_iceberg_candidate(&mut buckets, level) { + *by_level.entry(level).or_default() += 1; + selected.push(candidate); + } + } + + while selected.len() < target_count { + let mut added_any = false; + for level in 1..=ICEBERG_LEVEL_COUNT { + if selected.len() >= target_count { + break; + } + if by_level.get(&level).copied().unwrap_or_default() >= ICEBERG_MAX_ITEMS_PER_LEVEL { + continue; + } + if let Some(candidate) = take_iceberg_candidate(&mut buckets, level) { + *by_level.entry(level).or_default() += 1; + selected.push(candidate); + added_any = true; + } + } + + if !added_any { + break; + } + } + + selected.sort_by(|left, right| { + left.item + .level + .cmp(&right.item.level) + .then_with(|| { + left.score + .partial_cmp(&right.score) + .unwrap_or(Ordering::Equal) + }) + .then_with(|| left.item.name.cmp(&right.item.name)) + }); + + let mut normalized = Vec::::new(); + let mut ids = Vec::::new(); + let mut layout_counts = HashMap::::new(); + for candidate in selected { + let level_count = layout_counts.entry(candidate.item.level).or_default(); + let index = *level_count; + *level_count += 1; + let mut item = candidate.item; + item.id = unique_slug(&format!("{}-{}-{}", item.level, index + 1, item.name), &ids); + ids.push(item.id.clone()); + item.x = ICEBERG_LEVEL_LANES[index % ICEBERG_LEVEL_LANES.len()]; + item.y = 120.0 + index as f64 * 44.0; + normalized.push(item); + } + + if normalized.is_empty() { + Err("Local model did not return any usable iceberg items.".to_string()) + } else { + Ok(normalized) + } +} + +pub(crate) fn stretch_iceberg_candidate_levels(candidates: &mut [ScoredIcebergItem]) { + if candidates.len() < ICEBERG_LEVEL_COUNT as usize { + return; + } + + let present_levels = candidates + .iter() + .map(|candidate| candidate.item.level) + .collect::>(); + if present_levels.len() >= ICEBERG_LEVEL_COUNT as usize { + return; + } + + // Small local models often compress everything into middle scores. For iCE, + // the useful ranking is relative to the requested topic, so spread a usable + // set across all five bands instead of returning empty deep layers. + let mut indices = (0..candidates.len()).collect::>(); + indices.sort_by(|left, right| { + candidates[*left] + .score + .partial_cmp(&candidates[*right].score) + .unwrap_or(Ordering::Equal) + .then_with(|| { + candidates[*left] + .item + .name + .cmp(&candidates[*right].item.name) + }) + }); + let total = candidates.len(); + for (rank, index) in indices.into_iter().enumerate() { + let level = ((rank * ICEBERG_LEVEL_COUNT as usize) / total + 1) + .min(ICEBERG_LEVEL_COUNT as usize) as u8; + let score = iceberg_score_for_level_band(candidates[index].score, level); + candidates[index].score = score; + candidates[index].item.level = level; + candidates[index].item.depth_score = Some(score); + } +} + +pub(crate) fn iceberg_score_for_level_band(score: f64, level: u8) -> f64 { + let level = level.clamp(1, ICEBERG_LEVEL_COUNT); + let lower = f64::from(level.saturating_sub(1)) * 20.0; + let upper = if level == ICEBERG_LEVEL_COUNT { + 100.0 + } else { + f64::from(level) * 20.0 - 1.0 + }; + round_score(score.clamp(lower, upper)) +} + +pub(crate) fn take_iceberg_candidate( + buckets: &mut HashMap>, + level: u8, +) -> Option { + let bucket = buckets.get_mut(&level)?; + if bucket.is_empty() { + None + } else { + Some(bucket.remove(0)) + } +} + +pub(crate) fn iceberg_requested_count(value: &serde_json::Value) -> Option { + let count = value + .get("recommendedItemCount") + .or_else(|| value.get("itemCount")) + .or_else(|| value.get("recommended_count")) + .and_then(|value| value.as_u64())?; + usize::try_from(count).ok() +} + +pub(crate) fn iceberg_level_field(value: &serde_json::Value) -> Option { + let level = value + .get("level") + .or_else(|| value.get("proposedLevel")) + .or_else(|| value.get("depthLevel")) + .and_then(|value| value.as_u64())?; + Some((level as u8).clamp(1, ICEBERG_LEVEL_COUNT)) +} + +pub(crate) fn iceberg_metric_field(value: &serde_json::Value, names: &[&str]) -> Option { + names + .iter() + .find_map(|name| value.get(*name).and_then(json_number)) + .map(normalize_iceberg_metric) +} + +pub(crate) fn iceberg_string_field(value: &serde_json::Value, names: &[&str]) -> Option { + names + .iter() + .find_map(|name| value.get(*name).and_then(|value| value.as_str())) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToString::to_string) +} + +pub(crate) fn json_number(value: &serde_json::Value) -> Option { + value + .as_f64() + .or_else(|| { + value + .as_str() + .and_then(|text| text.trim().parse::().ok()) + }) + .filter(|value| value.is_finite()) +} + +pub(crate) fn normalize_iceberg_metric(value: f64) -> f64 { + let normalized = if (0.0..=1.0).contains(&value) { + value * 100.0 + } else { + value + }; + round_score(normalized.clamp(0.0, 100.0)) +} + +pub(crate) fn iceberg_fallback_score(level: u8) -> f64 { + match level.clamp(1, ICEBERG_LEVEL_COUNT) { + 1 => 10.0, + 2 => 30.0, + 3 => 50.0, + 4 => 70.0, + _ => 90.0, + } +} + +pub(crate) fn iceberg_depth_score( + familiarity: Option, + specificity: Option, + jargon_density: Option, + prerequisite_depth: Option, + obscurity: Option, + fallback_score: f64, +) -> f64 { + let familiarity = familiarity.unwrap_or(100.0 - fallback_score); + let specificity = specificity.unwrap_or(fallback_score); + let jargon_density = jargon_density.unwrap_or(fallback_score); + let prerequisite_depth = prerequisite_depth.unwrap_or(fallback_score); + let obscurity = obscurity.unwrap_or(fallback_score); + + specificity * 0.3 + + jargon_density * 0.25 + + prerequisite_depth * 0.2 + + obscurity * 0.15 + + (100.0 - familiarity) * 0.1 +} + +pub(crate) fn iceberg_level_from_score(score: f64) -> u8 { + match score { + value if value < 20.0 => 1, + value if value < 40.0 => 2, + value if value < 60.0 => 3, + value if value < 80.0 => 4, + _ => 5, + } +} + +pub(crate) fn clamp_optional_score(value: Option, min: f64, max: f64) -> Option { + value + .filter(|value| value.is_finite()) + .map(|value| round_score(value.clamp(min, max))) +} + +pub(crate) fn normalize_saved_items(items: Vec) -> Vec { + items + .into_iter() + .filter(|item| !item.name.trim().is_empty() && !item.description.trim().is_empty()) + .map(|mut item| { + item.name = item.name.trim().to_string(); + item.description = item.description.trim().to_string(); + item.level = item.level.clamp(1, ICEBERG_LEVEL_COUNT); + item.depth_score = clamp_optional_score(item.depth_score, 0.0, 100.0); + item.familiarity = clamp_optional_score(item.familiarity, 0.0, 100.0); + item.specificity = clamp_optional_score(item.specificity, 0.0, 100.0); + item.jargon_density = clamp_optional_score(item.jargon_density, 0.0, 100.0); + item.prerequisite_depth = clamp_optional_score(item.prerequisite_depth, 0.0, 100.0); + item.obscurity = clamp_optional_score(item.obscurity, 0.0, 100.0); + item.reason = item + .reason + .map(|reason| reason.trim().chars().take(220).collect::()) + .filter(|reason| !reason.is_empty()); + if item.id.trim().is_empty() { + item.id = unique_slug(&format!("{}-{}", item.level, item.name), &[]); + } + item + }) + .collect() +} + +pub(crate) fn saved_iceberg_summary(iceberg: &SavedIceberg) -> SavedIcebergSummary { + SavedIcebergSummary { + id: iceberg.id.clone(), + title: iceberg.title.clone(), + keyword: iceberg.iceberg.keyword.clone(), + model: iceberg.iceberg.model.clone(), + icon: iceberg.icon.clone(), + generated_at: iceberg.iceberg.generated_at.clone(), + saved_at: iceberg.saved_at.clone(), + updated_at: iceberg.updated_at.clone(), + item_count: iceberg.iceberg.items.len(), + } +} + +pub(crate) fn dedupe_citations(citations: Vec) -> Vec { + let mut unique = Vec::::new(); + let mut indexes = HashMap::::new(); + for citation in citations { + let key = normalize_citation_key(&citation.url); + if let Some(existing_index) = indexes.get(&key).copied() { + let existing = &mut unique[existing_index]; + if !existing.text.contains(&citation.text) { + // Join non-contiguous excerpts from the same page with a neutral + // marker. A numbered "Chunk N" label here leaks into the model's + // context and gets echoed as an uncitable "[Chunk N]" reference. + existing.text = format!("{}\n\n[…]\n\n{}", existing.text, citation.text) + .chars() + .take(9000) + .collect(); + } + existing.score = existing.score.min(citation.score); + } else { + indexes.insert(key, unique.len()); + unique.push(citation); + } + } + unique +} + +pub(crate) fn split_text(text: &str, chunk_size: usize, overlap: usize) -> Vec { + let chars = text.chars().collect::>(); + let mut chunks = Vec::new(); + let mut start = 0; + while start < chars.len() { + let end = (start + chunk_size).min(chars.len()); + let chunk = chars[start..end] + .iter() + .collect::() + .trim() + .to_string(); + if !chunk.is_empty() { + chunks.push(chunk); + } + if end == chars.len() { + break; + } + start = end.saturating_sub(overlap); + } + chunks +} + +pub(crate) fn cosine_distance(left: &[f32], right: &[f32]) -> f64 { + if left.is_empty() || left.len() != right.len() { + return f64::INFINITY; + } + let mut dot = 0.0_f64; + let mut left_norm = 0.0_f64; + let mut right_norm = 0.0_f64; + for (left, right) in left.iter().zip(right.iter()) { + let left = *left as f64; + let right = *right as f64; + dot += left * right; + left_norm += left * left; + right_norm += right * right; + } + if left_norm == 0.0 || right_norm == 0.0 { + f64::INFINITY + } else { + 1.0 - dot / (left_norm.sqrt() * right_norm.sqrt()) + } +} diff --git a/src-tauri/src/inference.rs b/src-tauri/src/inference.rs new file mode 100644 index 0000000..e8405db --- /dev/null +++ b/src-tauri/src/inference.rs @@ -0,0 +1,709 @@ +//! The llama.cpp calls themselves: embeddings, chat completion, and iCE generation, +//! plus the extractive fallback used when only the embedding model is installed. + +use super::*; + +// The chat path streams tokens and status lines back to the renderer through boxed +// callbacks. Named here so the generate/complete signatures stay readable. +pub(crate) type TokenSink = Box; +pub(crate) type StatusSink = Box; + +/// Where a generation reports back to. Both sinks come from the same +/// ChatStreamEmitter and are always supplied together, so they travel together. +#[derive(Default)] +pub(crate) struct ChatSinks { + pub(crate) token: Option, + pub(crate) status: Option, +} + +pub(crate) async fn local_embed( + state: &State<'_, Backend>, + settings: &UserSettings, + inputs: Vec, +) -> Cmd>> { + local_embed_with_progress(state, settings, inputs, None).await +} + +pub(crate) async fn local_embed_query( + state: &State<'_, Backend>, + settings: &UserSettings, + query: String, +) -> Cmd> { + local_embed( + state, + settings, + vec![embedding_query_input(&state.paths, settings, &query)], + ) + .await? + .into_iter() + .next() + .ok_or_else(|| "Local embedding model returned no embedding.".to_string()) +} + +pub(crate) fn embedding_query_input( + paths: &DataPaths, + settings: &UserSettings, + query: &str, +) -> String { + let catalog = model_catalog(paths, &settings.local_model); + let Some(model_path) = catalog.embedding_model.as_deref() else { + return query.to_string(); + }; + let label = model_label(model_path).to_lowercase(); + + if label.contains("qwen3-embedding") { + return format!("Instruct: {QWEN3_EMBEDDING_RETRIEVAL_INSTRUCTION}\nQuery: {query}"); + } + + query.to_string() +} + +pub(crate) async fn local_embed_with_progress( + state: &State<'_, Backend>, + settings: &UserSettings, + inputs: Vec, + progress: Option, +) -> Cmd>> { + let catalog = model_catalog(&state.paths, &settings.local_model); + let model_path = catalog.embedding_model.ok_or_else(|| { + format!( + "No local embedding model found. Install AiON MiST/Qwen3 Embedding, add another embedding GGUF to {}, or set {AETHER_EMBEDDING_MODEL_ENV}.", + state.paths.models_path.display() + ) + })?; + let runtime = Arc::clone(&state.native_runtime); + task::spawn_blocking(move || { + let mut runtime = runtime + .lock() + .map_err(|_| "Local model runtime is unavailable.".to_string())?; + match progress { + Some(progress) => runtime.embed_with_progress(&model_path, inputs, Some(progress)), + None => runtime.embed(&model_path, inputs), + } + }) + .await + .map_err(|error| error.to_string())? +} + +pub(crate) async fn local_chat( + state: &State<'_, Backend>, + settings: &UserSettings, + prompt: &str, + citations: Vec, + // Prior turns for follow-up questions. Empty for one-shot callers such as AiR. + history: &[ConversationTurn], + stream: Option, +) -> Cmd { + let started_at = Instant::now(); + let catalog = model_catalog(&state.paths, &settings.local_model); + // With only the embedding model installed there is nothing to generate with, but + // retrieval still works. Returning the ranked passages is far more useful than an + // error, and it is what makes MiST-only a usable install rather than a dead end. + let Some(model_path) = catalog.chat_model else { + if citations.is_empty() { + return Err(format!( + "No local chat model is installed, and no captured passages matched. Capture a page or install a chat model in {}.", + state.paths.models_path.display() + )); + } + if let Some(stream) = &stream { + stream.citations(&citations); + } + return Ok(extractive_answer( + citations, + started_at.elapsed().as_secs_f64(), + )); + }; + if let Some(stream) = &stream { + stream.citations(&citations); + } + let messages = build_chat_messages(prompt, &citations, history); + let runtime = Arc::clone(&state.native_runtime); + let cancel = Arc::clone(&state.generation_cancelled); + let model_label = model_label(&model_path); + let completion = task::spawn_blocking(move || { + let mut runtime = runtime + .lock() + .map_err(|_| "Local model runtime is unavailable.".to_string())?; + let token_stream = stream.clone(); + let token: Option = token_stream + .map(|stream| Box::new(move |delta: &str| stream.delta(delta)) as TokenSink); + let status: Option = stream + .map(|stream| Box::new(move |status: String| stream.status(&status)) as StatusSink); + runtime.complete_chat( + &model_path, + messages, + DEFAULT_GENERATION_TOKENS, + 0.2, + &cancel, + ChatSinks { token, status }, + ) + }) + .await + .map_err(|error| error.to_string())??; + let elapsed_seconds = started_at.elapsed().as_secs_f64(); + let answer = normalize_answer_citations(&clean_model_output(&completion.text), citations.len()); + let tokens_per_second = if completion.generated_tokens > 0 && elapsed_seconds > 0.0 { + completion.generated_tokens as f64 / elapsed_seconds + } else { + 0.0 + }; + let chunks = citations.len(); + Ok(ChatResult { + answer, + model: model_label, + citations, + metrics: ChatMetrics { + generated_tokens: completion.generated_tokens, + tokens_per_second, + elapsed_seconds, + chunks, + }, + }) +} + +// The retrieval-only answer. Deliberately presented as quoted passages with their +// sources rather than as prose: nothing here was generated, and dressing excerpts up +// as an answer would imply reasoning that did not happen. +pub(crate) const EXTRACTIVE_MODEL_LABEL: &str = "AiON MiST (passages only)"; + +pub(crate) fn extractive_answer(citations: Vec, elapsed_seconds: f64) -> ChatResult { + let mut answer = String::from( + "No chat model is installed, so ÆTHER cannot write an answer. These are the passages from your library that best match the question:\n\n", + ); + for (index, citation) in citations.iter().enumerate() { + answer.push_str(&format!( + "**{}. {}** [{}]\n\n> {}\n\n", + index + 1, + citation.title.trim(), + index + 1, + semantic_trail_excerpt(citation.text.trim(), 480).replace('\n', " ") + )); + } + answer.push_str( + "_Install a chat model in Settings to get written answers grounded in these sources._", + ); + + let chunks = citations.len(); + ChatResult { + answer, + model: EXTRACTIVE_MODEL_LABEL.to_string(), + citations, + metrics: ChatMetrics { + // Nothing was generated, so the token metrics are honestly zero. + generated_tokens: 0, + tokens_per_second: 0.0, + elapsed_seconds, + chunks, + }, + } +} + +pub(crate) async fn local_generate_iceberg( + state: &State<'_, Backend>, + settings: &UserSettings, + topic: &str, +) -> Cmd { + let catalog = model_catalog(&state.paths, &settings.local_model); + let model_path = catalog.chat_model.ok_or_else(|| { + format!( + "No local generative GGUF model found. Add Gemma or another chat model to {} or set {AETHER_CHAT_MODEL_ENV}.", + state.paths.models_path.display() + ) + })?; + let prompt = build_iceberg_prompt(topic); + let generated = + complete_iceberg_attempt(state, model_path.clone(), prompt.clone(), 0.35).await?; + let items = match normalize_iceberg_items(&generated) { + Ok(items) => items, + Err(first_error) => { + let retry_prompt = format!( + "{prompt}\n\nThe previous response was malformed. Return exactly 18 compact items \ + and close every JSON object and array. Keep each description and reason to one \ + short sentence. Output JSON only." + ); + let retry = + complete_iceberg_attempt(state, model_path.clone(), retry_prompt, 0.2).await?; + normalize_iceberg_items(&retry).map_err(|retry_error| { + format!( + "Crystallization returned malformed data twice. First attempt: {first_error} \ + Retry: {retry_error}" + ) + })? + } + }; + Ok(IcebergResult { + keyword: topic.to_string(), + model: model_label(&model_path), + items, + generated_at: now(), + }) +} + +async fn complete_iceberg_attempt( + state: &State<'_, Backend>, + model_path: PathBuf, + prompt: String, + temperature: f32, +) -> Cmd { + let messages = vec![ChatPromptMessage { + role: "user", + content: prompt, + }]; + let runtime = Arc::clone(&state.native_runtime); + let cancel = Arc::clone(&state.generation_cancelled); + let completion = task::spawn_blocking(move || { + let mut runtime = runtime + .lock() + .map_err(|_| "Local model runtime is unavailable.".to_string())?; + runtime.complete_chat( + &model_path, + messages, + DEFAULT_ICEBERG_GENERATION_TOKENS, + temperature, + &cancel, + ChatSinks::default(), + ) + }) + .await + .map_err(|error| error.to_string())??; + let generated = completion.text; + if state.generation_cancelled.load(AtomicOrdering::Relaxed) { + return Err("Crystallization stopped.".to_string()); + } + Ok(clean_model_output(&generated)) +} + +impl NativeModelRuntime { + pub(crate) fn ensure_backend(&mut self) -> Cmd<()> { + if self.backend.is_some() { + return Ok(()); + } + + let mut backend = LlamaBackend::init().map_err(|error| error.to_string())?; + backend.void_logs(); + self.backend = Some(backend); + Ok(()) + } + + pub(crate) fn ensure_model(&mut self, kind: NativeModelKind, path: &Path) -> Cmd<()> { + let path = canonical_model_path(path); + let current_path = match kind { + NativeModelKind::Chat => self.chat.as_ref().map(|loaded| loaded.path.as_path()), + NativeModelKind::Embedding => { + self.embedding.as_ref().map(|loaded| loaded.path.as_path()) + } + }; + if current_path == Some(path.as_path()) { + return Ok(()); + } + + self.ensure_backend()?; + let backend = self + .backend + .as_ref() + .ok_or_else(|| "Local model backend is not initialized.".to_string())?; + // Mobile: load weights into anonymous memory instead of mmapping the + // GGUF. Mmapped weight pages are ordinary page cache, and Android + // evicts them under the memory pressure the native tab WebViews + // create — after which every generated token faults back to flash and + // decode slows to a crawl. Malloc'd weights are app RSS and stay put. + let use_mmap = if cfg!(mobile) { + false + } else { + backend.supports_mmap() + }; + let mut params = LlamaModelParams::default().with_use_mmap(use_mmap); + let use_gpu = match kind { + NativeModelKind::Chat => local_gpu_enabled(), + NativeModelKind::Embedding => embedding_gpu_enabled(), + }; + if use_gpu && backend.supports_gpu_offload() { + params = params.with_n_gpu_layers(999); + } else { + params = params + .with_n_gpu_layers(0) + .with_devices(&[]) + .map_err(|error| format!("Failed to select CPU model backend: {error}"))?; + } + let model = LlamaModel::load_from_file(backend, &path, ¶ms).map_err(|error| { + format!("Failed to load local model {}: {error}", model_label(&path)) + })?; + let loaded = LoadedNativeModel { path, model }; + match kind { + NativeModelKind::Chat => self.chat = Some(loaded), + NativeModelKind::Embedding => self.embedding = Some(loaded), + } + Ok(()) + } + + pub(crate) fn embed(&mut self, model_path: &Path, inputs: Vec) -> Cmd>> { + self.embed_with_progress(model_path, inputs, None) + } + + pub(crate) fn embed_with_progress( + &mut self, + model_path: &Path, + inputs: Vec, + progress: Option, + ) -> Cmd>> { + if inputs.is_empty() { + return Ok(Vec::new()); + } + + self.ensure_model(NativeModelKind::Embedding, model_path)?; + let backend = self + .backend + .as_ref() + .ok_or_else(|| "Local model backend is not initialized.".to_string())?; + let model = &self + .embedding + .as_ref() + .ok_or_else(|| "Local embedding model is not loaded.".to_string())? + .model; + let threads = auto_thread_count(); + let total = inputs.len(); + let mut embeddings = Vec::with_capacity(total); + let mut tokenized_inputs = Vec::with_capacity(total); + + if let Some(progress) = &progress { + progress.emit_message("Tokenizing chunks", 0, total); + } + + for input in inputs { + let tokens = model + .str_to_token(&input, AddBos::Always) + .map_err(|error| error.to_string())?; + if tokens.is_empty() { + return Err("Local embedding input produced no tokens.".to_string()); + } + tokenized_inputs.push(tokens); + } + + let max_sequences = if is_qwen3_embedding_model(model_path) { + 1 + } else { + embedding_batch_size().min(16) + }; + let max_batch_tokens = embedding_batch_token_limit(); + let mut input_index = 0; + let mut batches = Vec::new(); + + while input_index < tokenized_inputs.len() { + let mut batch_token_count = 0usize; + let mut batch_end = input_index; + + while batch_end < tokenized_inputs.len() + && batch_end - input_index < max_sequences + && (batch_token_count == 0 + || batch_token_count + tokenized_inputs[batch_end].len() <= max_batch_tokens) + { + batch_token_count += tokenized_inputs[batch_end].len(); + batch_end += 1; + } + + batches.push((input_index, batch_end, batch_token_count)); + input_index = batch_end; + } + + let max_batch_token_count = batches + .iter() + .map(|(_, _, batch_token_count)| *batch_token_count) + .max() + .unwrap_or_default(); + let max_batch_sequence_count = batches + .iter() + .map(|(batch_start, batch_end, _)| batch_end - batch_start) + .max() + .unwrap_or(1); + let n_ctx = embedding_context_tokens(max_batch_token_count); + if max_batch_token_count as u32 > n_ctx { + return Err(format!( + "Local embedding batch is too long for the embedding context: {} tokens exceeds {}.", + max_batch_token_count, n_ctx + )); + } + let n_batch = n_ctx.max(max_batch_token_count as u32).max(512); + let offload_embedding_ops = embedding_gpu_enabled(); + let ctx_params = LlamaContextParams::default() + .with_n_ctx(NonZeroU32::new(n_ctx)) + .with_n_seq_max(max_batch_sequence_count as u32) + .with_n_batch(n_batch) + .with_n_ubatch(n_batch) + .with_n_threads(threads) + .with_n_threads_batch(threads) + .with_embeddings(true) + .with_offload_kqv(offload_embedding_ops) + .with_op_offload(offload_embedding_ops) + .with_attention_type(embedding_attention_type(model_path)) + .with_pooling_type(embedding_pooling_type(model_path)); + let mut ctx = model + .new_context(backend, ctx_params) + .map_err(|error| error.to_string())?; + + for (batch_start, batch_end, batch_token_count) in batches { + let batch_sequence_count = batch_end - batch_start; + if let Some(progress) = &progress { + progress.emit_message( + format!( + "Embedding chunks {}-{batch_end} of {total}", + batch_start + 1 + ), + batch_start, + total, + ); + } + + ctx.clear_kv_cache(); + let mut batch = LlamaBatch::new(batch_token_count, batch_sequence_count as i32); + for (sequence_index, tokens) in + tokenized_inputs[batch_start..batch_end].iter().enumerate() + { + batch + .add_sequence(tokens, sequence_index as i32, false) + .map_err(|error| error.to_string())?; + } + // Qwen3 Embedding is decoder-style; the llama_encode path segfaults in + // llm_build_qwen3 on macOS with llama-cpp-2 0.1.146. + if qwen3_embedding_decode(model_path) { + ctx.decode(&mut batch).map_err(|error| error.to_string())?; + } else { + ctx.encode(&mut batch).map_err(|error| error.to_string())?; + } + + for sequence_index in 0..batch_sequence_count { + let embedding = ctx + .embeddings_seq_ith(sequence_index as i32) + .map_err(|error| error.to_string())?; + embeddings.push(normalize_embedding(embedding)); + } + if let Some(progress) = &progress { + progress.emit(batch_end, total); + } + } + + Ok(embeddings) + } + + #[cfg(desktop)] + pub(crate) fn warm_embedding_model(&mut self, model_path: &Path) -> Cmd<()> { + self.ensure_model(NativeModelKind::Embedding, model_path) + } + + pub(crate) fn complete_chat( + &mut self, + model_path: &Path, + messages: Vec, + max_tokens: usize, + temperature: f32, + cancel: &AtomicBool, + mut sinks: ChatSinks, + ) -> Cmd { + // The first ask pays for the multi-GB model load; on phone-class + // storage that is long enough to read as a hang without a status. + let needs_load = self + .chat + .as_ref() + .map(|loaded| loaded.path != canonical_model_path(model_path)) + .unwrap_or(true); + if needs_load { + if let Some(callback) = sinks.status.as_mut() { + callback(format!( + "Loading {} (first ask takes a moment)", + friendly_model_label(model_path) + )); + } + } + self.ensure_model(NativeModelKind::Chat, model_path)?; + let rendered = { + let model = &self + .chat + .as_ref() + .ok_or_else(|| "Local chat model is not loaded.".to_string())? + .model; + render_model_chat_prompt(model, &messages)? + }; + self.complete_loaded_prompt( + &rendered.prompt, + max_tokens, + temperature, + rendered.add_bos, + cancel, + sinks, + ) + } + + #[allow(clippy::too_many_arguments)] + pub(crate) fn complete_loaded_prompt( + &mut self, + prompt: &str, + max_tokens: usize, + temperature: f32, + add_bos: AddBos, + cancel: &AtomicBool, + mut sinks: ChatSinks, + ) -> Cmd { + let backend = self + .backend + .as_ref() + .ok_or_else(|| "Local model backend is not initialized.".to_string())?; + let model = &self + .chat + .as_ref() + .ok_or_else(|| "Local chat model is not loaded.".to_string())? + .model; + let mut tokens = model + .str_to_token(prompt, add_bos) + .map_err(|error| error.to_string())?; + if tokens.is_empty() { + return Err("Local chat prompt produced no tokens.".to_string()); + } + + let n_ctx = chat_context_tokens(); + let max_prompt_tokens = + n_ctx.saturating_sub((max_tokens as u32).min(1024)).max(512) as usize; + if tokens.len() > max_prompt_tokens { + tokens = tokens[tokens.len() - max_prompt_tokens..].to_vec(); + } + let n_batch = (chat_batch_token_limit() as u32).min(n_ctx).max(512); + // Mobile: small micro-batches keep the compute buffer allocation + // phone-sized and make prefill progress tick in visible steps. + let n_ubatch = if cfg!(mobile) { + n_batch.min(512) + } else { + n_batch.min(2048) + } + .max(512); + let threads = auto_thread_count(); + let offload_ops = local_gpu_enabled(); + let ctx_params = LlamaContextParams::default() + .with_n_ctx(NonZeroU32::new(n_ctx)) + .with_n_batch(n_batch) + .with_n_ubatch(n_ubatch) + .with_n_threads(threads) + .with_n_threads_batch(threads) + .with_offload_kqv(offload_ops) + .with_op_offload(offload_ops); + let mut ctx = model + .new_context(backend, ctx_params) + .map_err(|error| error.to_string())?; + + let last_prompt_index = tokens.len().saturating_sub(1); + let prompt_batch_limit = if cfg!(mobile) { + (n_batch as usize).min(512) + } else { + n_batch as usize + }; + let total_prompt_tokens = tokens.len(); + let mut prompt_cursor = 0usize; + let mut sample_index = 0; + while prompt_cursor < tokens.len() { + if cancel.load(AtomicOrdering::Relaxed) { + return Err("Generation stopped.".to_string()); + } + if let Some(callback) = sinks.status.as_mut() { + let percent = prompt_cursor * 100 / total_prompt_tokens; + callback(format!("Reading context {percent}%")); + } + let prompt_end = (prompt_cursor + prompt_batch_limit).min(tokens.len()); + let mut prompt_batch = LlamaBatch::new(prompt_end - prompt_cursor, 1); + for (offset, token) in tokens[prompt_cursor..prompt_end].iter().enumerate() { + let index = prompt_cursor + offset; + prompt_batch + .add(*token, index as i32, &[0], index == last_prompt_index) + .map_err(|error| error.to_string())?; + } + ctx.decode(&mut prompt_batch) + .map_err(|error| error.to_string())?; + if prompt_end == tokens.len() { + sample_index = prompt_batch.n_tokens() - 1; + } + prompt_cursor = prompt_end; + } + if let Some(callback) = sinks.status.as_mut() { + callback("Generating answer".to_string()); + } + + let mut sampler = LlamaSampler::chain_simple([ + LlamaSampler::top_k(DEFAULT_TOP_K), + LlamaSampler::top_p(DEFAULT_TOP_P, 1), + LlamaSampler::temp(temperature), + LlamaSampler::dist(0xA371_2026), + ]); + let mut decoder = UTF_8.new_decoder(); + let mut output = String::new(); + let mut generated_tokens = 0usize; + let mut streamed_len = 0usize; + let mut batch = LlamaBatch::new(1, 1); + // Not a loop counter: this is the absolute position in the KV cache, which + // starts after the prompt and is what llama_batch entries are keyed by. + let mut position = tokens.len() as i32; + + // clippy reads `position` as a loop counter and suggests enumerate(). It is + // not one: it is the absolute KV-cache position that llama_batch entries are + // keyed by, it starts after the prompt, and it is not incremented on every + // iteration. + #[allow(clippy::explicit_counter_loop)] + for _ in 0..max_tokens { + if cancel.load(AtomicOrdering::Relaxed) { + break; + } + let token = sampler.sample(&ctx, sample_index); + if model.is_eog_token(token) { + break; + } + generated_tokens = generated_tokens.saturating_add(1); + let piece = model + .token_to_piece(token, &mut decoder, true, None) + .map_err(|error| error.to_string())?; + output.push_str(&piece); + if contains_stop_marker(&output) { + break; + } + if let Some(on_token) = sinks.token.as_mut() { + let safe_end = stream_safe_len(&output); + if safe_end > streamed_len { + on_token(&output[streamed_len..safe_end]); + streamed_len = safe_end; + } + } + + batch.clear(); + batch + .add(token, position, &[0], true) + .map_err(|error| error.to_string())?; + ctx.decode(&mut batch).map_err(|error| error.to_string())?; + sample_index = batch.n_tokens() - 1; + position += 1; + } + + if output.trim().is_empty() && cancel.load(AtomicOrdering::Relaxed) { + return Err("Generation stopped.".to_string()); + } + + Ok(ChatCompletion { + text: output, + generated_tokens, + }) + } +} + +#[derive(Clone)] +pub(crate) struct ModelDownloadSpec { + pub(crate) id: &'static str, + pub(crate) label: &'static str, + pub(crate) repository: &'static str, + pub(crate) revision: &'static str, + pub(crate) filename: &'static str, + pub(crate) destination: PathBuf, + pub(crate) expected_bytes: u64, +} + +impl ModelDownloadSpec { + pub(crate) fn source_url(&self) -> String { + format!( + "https://huggingface.co/{}/resolve/{}/{}?download=true", + self.repository, self.revision, self.filename + ) + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index eb83dfa..6134df3 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,3 +1,23 @@ +mod air; +mod chat; +mod commands; +mod diagnostics; +mod extract; +mod flow; +mod iceberg; +mod inference; +mod model_catalog; +mod model_downloads; +mod retrieval; +mod retrieval_scoring; +mod store; +mod system; +mod trail; +mod types; +mod util; +mod vectors; +mod webview; + use chrono::{DateTime, Utc}; use encoding_rs::UTF_8; use llama_cpp_2::{ @@ -25,7 +45,7 @@ use std::{ #[cfg(desktop)] use tauri::{ menu::{Menu, MenuItem, PredefinedMenuItem, Submenu}, - webview::{NewWindowResponse, PageLoadEvent}, + webview::{DownloadEvent, NewWindowResponse, PageLoadEvent}, LogicalPosition, LogicalSize, Position, Rect, Size, Webview, WebviewBuilder, WebviewUrl, Window, WindowEvent, }; @@ -35,7 +55,47 @@ use tokio::io::AsyncWriteExt; use tokio::task; use url::Url; +use air::*; +use chat::*; +use commands::*; +use diagnostics::{diag_error, diag_info, diag_warn}; + +use extract::*; +use flow::*; +use iceberg::*; +use inference::*; +use model_catalog::*; +use model_downloads::*; +use retrieval::*; +use retrieval_scoring::*; +use store::*; +use system::*; +use trail::*; +use types::*; +use util::*; +use vectors::*; +use webview::*; + const CHUNKS_TABLE: &str = "chunks"; +// Every on-disk store is written temp-then-rename, keeping the previous good copy +// beside it. See write_bytes_atomically / read_json_or_default. +const BACKUP_SUFFIX: &str = ".bak"; +const TEMP_WRITE_SUFFIX: &str = ".tmp"; +// Distinct from BACKUP_SUFFIX so the one-generation `.bak` rotation can never +// overwrite the only copy of a store in its pre-migration format. +const PRE_MIGRATION_SUFFIX: &str = ".v1.json"; +const LIBRARY_EXPORT_DIR: &str = "aether-backups"; +// Thread key for asks scoped to the open page rather than a hub. +const CURRENT_PAGE_THREAD_KEY: &str = "current-page"; +// Turns kept per thread on disk. Enough to reread a session, bounded so the store +// cannot grow without limit. +const MAX_THREAD_TURNS: usize = 40; +// Prior turns replayed into the prompt. Each one costs context that citations also +// need, so this stays small deliberately. +const PROMPT_HISTORY_TURNS: usize = 3; +// Answers from earlier turns are summarised into the prompt, not replayed whole; a +// long previous answer would otherwise crowd out the retrieved sources. +const PROMPT_HISTORY_ANSWER_CHARS: usize = 480; #[cfg(desktop)] const SIDEBAR_WIDTH: f64 = 76.0; #[cfg(desktop)] @@ -67,7 +127,11 @@ const AETHER_CAPTURE_PAGE_MENU_ID: &str = "aether-capture-page"; const AETHER_FIND_REQUESTED_EVENT: &str = "aether:find-requested"; const AETHER_FIND_RESULT_EVENT: &str = "aether:find-result"; const AETHER_CHAT_STREAM_EVENT: &str = "aether:chat-stream"; +const AETHER_DOWNLOAD_EVENT: &str = "aether:download"; const AETHER_MODEL_DOWNLOAD_PROGRESS_EVENT: &str = "aether:model-download-progress"; +// Only the desktop updater emits progress; mobile updates through the store. +#[cfg(desktop)] +const AETHER_UPDATE_PROGRESS_EVENT: &str = "aether:update-progress"; const AETHER_MODEL_DIR_ENV: &str = "AETHER_MODEL_DIR"; const AETHER_CHAT_MODEL_ENV: &str = "AETHER_CHAT_MODEL"; const AETHER_EMBEDDING_MODEL_ENV: &str = "AETHER_EMBEDDING_MODEL"; @@ -82,7 +146,10 @@ const AETHER_EMBED_BATCH_ENV: &str = "AETHER_EMBED_BATCH"; const AETHER_EMBED_BATCH_TOKENS_ENV: &str = "AETHER_EMBED_BATCH_TOKENS"; const AETHER_RELEASES_API_URL: &str = "https://api.github.com/repos/CanPixel/aether/releases/latest"; -const DEFAULT_CHAT_CONTEXT_TOKENS: u32 = 6144; +// 8 citations of ~550 tokens plus a 900-token answer already filled the old 6144 +// window; replaying prior turns needs the extra headroom. Mobile keeps its smaller +// window (see chat_context_tokens) because the KV cache there is the binding limit. +const DEFAULT_CHAT_CONTEXT_TOKENS: u32 = 8192; const DEFAULT_CHAT_BATCH_TOKENS: usize = 2048; const DEFAULT_EMBEDDING_CONTEXT_TOKENS: u32 = 2048; const DEFAULT_EMBEDDING_BATCH_SIZE: usize = 8; @@ -107,7 +174,7 @@ const CAPTURE_SUGGEST_MIN_SCORE: f64 = 50.0; // of loading a remote page. Must match START_PAGE_URL in src/renderer/src/App.tsx. const START_PAGE_URL: &str = "aether://start"; const DEFAULT_GENERATION_TOKENS: usize = 900; -const DEFAULT_ICEBERG_GENERATION_TOKENS: usize = 4200; +const DEFAULT_ICEBERG_GENERATION_TOKENS: usize = 5200; const DEFAULT_TOP_K: i32 = 64; const DEFAULT_TOP_P: f32 = 0.95; const QWEN3_EMBEDDING_RETRIEVAL_INSTRUCTION: &str = @@ -191,8517 +258,2496 @@ const ICEBERG_MAX_ITEMS_PER_LEVEL: usize = 10; type Cmd = Result; -struct Backend { - paths: DataPaths, - tabs: Mutex, - #[cfg(desktop)] - webviews: Mutex, - // Where the renderer wants Android tab WebViews placed, in CSS pixels - // (reported by MobileTabView via aether_layout_set_mobile_tab_bounds). - #[cfg(not(desktop))] - mobile_tab_bounds: Mutex, - client: Client, - native_runtime: Arc>, - vectors: tokio::sync::RwLock>, - generation_cancelled: Arc, +fn vector_data_path(json_path: &Path) -> PathBuf { + json_path.with_extension("vec") } -#[cfg(desktop)] -#[derive(Default)] -struct NativeBrowserViews { - views: HashMap, +// v1 stored vectors inline as JSON numbers. Kept only to migrate existing installs. +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct LegacyChunkRecord { + id: String, + vector: Vec, + text: String, + collection_id: String, + capture_id: String, + title: String, + url: String, + app_id: String, + captured_at: String, + chunk_index: usize, } -#[cfg(not(desktop))] -#[derive(Clone, Copy, Default)] -struct MobileTabBounds { - top: f64, - left: f64, - width: f64, - height: f64, +#[derive(Deserialize)] +struct LegacyVectorStoreData { + #[serde(default)] + chunks: Vec, } -#[derive(Default)] -struct NativeModelRuntime { - backend: Option, - chat: Option, - embedding: Option, +struct CapturedPage { + title: String, + url: String, + text: String, } -struct LoadedNativeModel { - path: PathBuf, - model: LlamaModel, +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct BrowserPageSnapshot { + html: Option, + url: Option, + title: Option, + description: Option, + body_text: Option, } -#[derive(Clone)] -struct EmbeddingProgress { - app: AppHandle, - message: String, +impl Backend { + fn new(app_data_dir: PathBuf) -> Self { + let db_path = app_data_dir.join("aether-realms"); + let models_path = default_models_path(&app_data_dir); + Self { + paths: DataPaths { + chunks_path: db_path.join(format!("{CHUNKS_TABLE}.json")), + db_path, + library_path: app_data_dir.join("aether-library").join("library.json"), + settings_path: app_data_dir.join("aether-settings").join("settings.json"), + icebergs_path: app_data_dir.join("aether-icebergs").join("icebergs.json"), + conversations_path: app_data_dir + .join("aether-conversations") + .join("conversations.json"), + session_path: app_data_dir.join("aether-session").join("session.json"), + air_exports_path: app_data_dir.join("aether-air"), + exports_path: app_data_dir.join(LIBRARY_EXPORT_DIR), + models_path, + }, + tabs: Mutex::new(TabState::new()), + #[cfg(desktop)] + webviews: Mutex::new(NativeBrowserViews::default()), + web_content_bounds: Mutex::new(WebContentBounds::default()), + client: Client::builder() + .user_agent("Aether/1.0 Tauri") + .build() + .expect("reqwest client"), + native_runtime: Arc::new(Mutex::new(NativeModelRuntime::default())), + vectors: tokio::sync::RwLock::new(None), + generation_cancelled: Arc::new(AtomicBool::new(false)), + #[cfg(desktop)] + window_geometry_saved_at: Mutex::new(None), + #[cfg(desktop)] + pending_downloads: Mutex::new(HashMap::new()), + } + } } -impl EmbeddingProgress { - fn emit(&self, current: usize, total: usize) { - emit_capture_progress(&self.app, &self.message, Some(current), Some(total)); +impl TabState { + fn new() -> Self { + let initial = ManagedTab::new("browser", START_PAGE_URL); + let active_tab_id = initial.id.clone(); + Self { + tabs: vec![initial], + active_app_id: "browser".to_string(), + active_tab_id, + dashboard_open: true, + modal_overlay_open: false, + panel_collapsed: true, + } } - fn emit_message(&self, message: impl Into, current: usize, total: usize) { - emit_capture_progress(&self.app, message, Some(current), Some(total)); + fn state(&self) -> AetherState { + AetherState { + apps: self.apps(), + tabs: self.tabs(), + active_app_id: self.active_app_id.clone(), + active_tab_id: self.active_tab_id.clone(), + dashboard_open: self.dashboard_open, + panel_collapsed: self.panel_collapsed, + } } -} -struct ChatPromptMessage { - role: &'static str, - content: String, -} + fn apps(&self) -> Vec { + let active = self.active_tab(); + vec![AppSummary { + id: "browser".to_string(), + name: "Browser".to_string(), + category: "Web".to_string(), + home_url: "https://www.google.com".to_string(), + current_url: active + .map(|tab| tab.url.clone()) + .unwrap_or_else(|| "https://www.google.com".to_string()), + title: active + .map(|tab| tab.title.clone()) + .unwrap_or_else(|| "Browser".to_string()), + is_active: !self.dashboard_open, + is_loading: active.map(|tab| tab.is_loading).unwrap_or(false), + can_go_back: active.map(|tab| tab.can_go_back()).unwrap_or(false), + can_go_forward: active.map(|tab| tab.can_go_forward()).unwrap_or(false), + }] + } -struct RenderedChatPrompt { - prompt: String, - add_bos: AddBos, -} + fn tabs(&self) -> Vec { + self.tabs + .iter() + .map(|tab| tab.summary(tab.id == self.active_tab_id && !self.dashboard_open)) + .collect() + } -#[derive(Clone, Copy)] -enum NativeModelKind { - Chat, - Embedding, -} + fn active_tab(&self) -> Option<&ManagedTab> { + self.tabs.iter().find(|tab| tab.id == self.active_tab_id) + } -enum WebviewHistoryDirection { - Back, - Forward, + fn active_tab_mut(&mut self) -> Option<&mut ManagedTab> { + let active_tab_id = self.active_tab_id.clone(); + self.tabs.iter_mut().find(|tab| tab.id == active_tab_id) + } } -struct ModelCatalog { - models: Vec, - chat_model: Option, - embedding_model: Option, - error: Option, -} +impl ManagedTab { + fn new(app_id: &str, raw_url: &str) -> Self { + let url = normalize_url(raw_url, "google"); + let title = if url == START_PAGE_URL { + "New tab".to_string() + } else { + title_from_url(&url) + }; + Self { + id: uuid(), + app_id: app_id.to_string(), + title, + url: url.clone(), + is_loading: false, + favicon: None, + theme_color: None, + history: vec![url], + history_index: 0, + native_can_go_back: None, + native_can_go_forward: None, + } + } -#[derive(Clone)] -struct DataPaths { - db_path: PathBuf, - library_path: PathBuf, - settings_path: PathBuf, - icebergs_path: PathBuf, - air_exports_path: PathBuf, - chunks_path: PathBuf, - models_path: PathBuf, -} + fn navigate(&mut self, raw_url: &str, search_engine: &str) { + let url = normalize_url(raw_url, search_engine); + self.url = url.clone(); + self.title = title_from_url(&url); + self.favicon = favicon_for_url(&url); + self.theme_color = None; + self.is_loading = false; + self.history.truncate(self.history_index + 1); + self.history.push(url); + self.history_index = self.history.len().saturating_sub(1); + // Unknown until the native webview reports in after the load. + self.native_can_go_back = None; + self.native_can_go_forward = None; + } -#[derive(Clone)] -struct TabState { - tabs: Vec, - active_app_id: String, - active_tab_id: String, - dashboard_open: bool, - modal_overlay_open: bool, - panel_collapsed: bool, -} + fn commit_history_url(&mut self, url: String) { + if self.history.get(self.history_index) == Some(&url) { + return; + } -#[derive(Clone)] -struct ManagedTab { - id: String, - app_id: String, - title: String, - url: String, - is_loading: bool, - favicon: Option, - theme_color: Option, - history: Vec, - history_index: usize, - // On Android the tab's real history lives in its native WebView, whose - // canGoBack/canGoForward are reported via aether_tabs_report_native_event. - // They extend (OR with) the Rust-side history, which still tracks entries - // the WebView never saw — most notably the aether://start page. - native_can_go_back: Option, - native_can_go_forward: Option, -} + if let Some(existing_index) = self + .history + .iter() + .enumerate() + .rev() + .find_map(|(index, item)| (item == &url).then_some(index)) + { + self.history_index = existing_index; + return; + } -#[derive(Clone, Serialize)] -#[serde(rename_all = "camelCase")] -struct AppSummary { - id: String, - name: String, - category: String, - home_url: String, - current_url: String, - title: String, - is_active: bool, - is_loading: bool, - can_go_back: bool, - can_go_forward: bool, -} + self.history.truncate(self.history_index + 1); + self.history.push(url); + self.history_index = self.history.len().saturating_sub(1); + } -#[derive(Clone, Serialize)] -#[serde(rename_all = "camelCase")] -struct BrowserTabSummary { - id: String, - app_id: String, - title: String, - url: String, - host: String, - is_active: bool, - is_loading: bool, - can_go_back: bool, - can_go_forward: bool, - #[serde(skip_serializing_if = "Option::is_none")] - favicon: Option, - #[serde(skip_serializing_if = "Option::is_none")] - theme_color: Option, -} + fn can_go_back(&self) -> bool { + self.history_index > 0 + } -#[derive(Clone, Serialize)] -#[serde(rename_all = "camelCase")] -struct AetherState { - apps: Vec, - tabs: Vec, - active_app_id: String, - active_tab_id: String, - dashboard_open: bool, - panel_collapsed: bool, -} + fn can_go_forward(&self) -> bool { + self.history_index + 1 < self.history.len() + } -#[derive(Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -struct HubShortcutSummary { - id: String, - title: String, - url: String, - host: String, - created_at: String, - #[serde(skip_serializing_if = "Option::is_none")] - favicon: Option, - #[serde(skip_serializing_if = "Option::is_none")] - theme_color: Option, + fn summary(&self, is_active: bool) -> BrowserTabSummary { + BrowserTabSummary { + id: self.id.clone(), + app_id: self.app_id.clone(), + title: self.title.clone(), + url: self.url.clone(), + host: get_tab_host(&self.url), + is_active, + is_loading: self.is_loading, + can_go_back: self.can_go_back() || self.native_can_go_back.unwrap_or(false), + can_go_forward: self.can_go_forward() || self.native_can_go_forward.unwrap_or(false), + favicon: self.favicon.clone(), + theme_color: self.theme_color.clone(), + } + } } -#[derive(Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -struct BrowserSettings { - default_search_engine: String, +// macOS quit (`-[NSApplication terminate:]`) calls libc `exit()`, which runs +// C++ static destructors. llama.cpp's Metal backend frees its global device +// registry there and asserts that all residency sets were released first — but +// our loaded models (which hold those Metal buffers) are never dropped, because +// the Cocoa quit path skips Rust's normal teardown. That assert aborts the +// process ("quit unexpectedly"). Terminate immediately via `_exit`, which +// bypasses the static destructors entirely; the OS reclaims Metal/host memory, +// and all app state is already persisted per-action (no exit-time flush). +#[cfg(desktop)] +fn force_exit() -> ! { + extern "C" { + fn _exit(code: i32) -> !; + } + unsafe { _exit(0) } } -#[derive(Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -struct UpdateSettings { - #[serde(default = "default_update_auto_check")] - auto_check: bool, - #[serde(default, skip_serializing_if = "Option::is_none")] - last_checked_at: Option, -} +// Bridge to the Kotlin TabsPlugin (gen/android/.../TabsPlugin.kt), which hosts +// one android.webkit.WebView per browser tab above the main app webview. This +// is the Android counterpart of the desktop `Window::add_child` path: the same +// `*_native_webview` functions drive it, keeping Rust the source of truth for +// tab state. Navigation events come back through the renderer via the +// `aether_tabs_report_native_event` command. +#[cfg(target_os = "android")] +mod android_tabs { + use serde::{de::DeserializeOwned, Deserialize, Serialize}; + use std::sync::OnceLock; + use tauri::{ + plugin::{Builder, PluginHandle, TauriPlugin}, + Manager, Wry, + }; -#[derive(Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -struct AppSettings { - browser: BrowserSettings, - developer_mode: bool, - updates: UpdateSettings, -} + pub struct AndroidTabs(PluginHandle); -#[derive(Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -struct CollectionSummary { - id: String, - name: String, - description: String, - #[serde(skip_serializing_if = "Option::is_none")] - icon: Option, - created_at: String, - updated_at: String, - capture_count: usize, - chunk_count: usize, -} + // The plugin handle, additionally kept in a module global so helpers that + // only receive `State` (page capture) can reach the Kotlin side + // without threading an AppHandle through every call site. + static HANDLE: OnceLock> = OnceLock::new(); -#[derive(Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -struct CaptureMetadata { - #[serde(skip_serializing_if = "Option::is_none")] - note: Option, - #[serde(skip_serializing_if = "Option::is_none")] - summary: Option, - #[serde(skip_serializing_if = "Option::is_none")] - tags: Option>, -} - -#[derive(Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -struct CaptureSummary { - id: String, - collection_id: String, - title: String, - url: String, - app_id: String, - captured_at: String, - chunk_count: usize, - #[serde(skip_serializing_if = "Option::is_none")] - metadata: Option, -} - -#[derive(Clone, Serialize)] -#[serde(rename_all = "camelCase")] -struct CaptureResult { - #[serde(flatten)] - capture: CaptureSummary, - collection_name: String, -} - -#[derive(Clone, Serialize)] -#[serde(rename_all = "camelCase")] -struct CaptureProgress { - message: String, - #[serde(skip_serializing_if = "Option::is_none")] - current: Option, - #[serde(skip_serializing_if = "Option::is_none")] - total: Option, -} - -#[derive(Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -struct SearchResult { - id: String, - collection_id: String, - capture_id: String, - app_id: String, - title: String, - url: String, - captured_at: String, - chunk_index: usize, - text: String, - score: f64, -} - -#[derive(Clone, Serialize)] -#[serde(rename_all = "camelCase")] -struct SemanticTrailRoot { - title: String, - url: String, - host: String, - excerpt: String, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] -#[serde(rename_all = "kebab-case")] -enum SemanticTrailReason { - SemanticMatch, - RecentCapture, - SameCollection, -} - -#[derive(Clone, Copy, Serialize)] -#[serde(rename_all = "kebab-case")] -enum SemanticTrailEdgeKind { - SemanticMatch, - SameHost, - SameCollection, -} - -#[derive(Clone, Serialize)] -#[serde(rename_all = "camelCase")] -struct SemanticTrailScoreBreakdown { - total: f64, - semantic: f64, - recency: f64, -} - -#[derive(Clone, Serialize)] -#[serde(rename_all = "camelCase")] -struct SemanticTrailItem { - id: String, - collection_id: String, - collection_name: String, - capture_id: String, - app_id: String, - title: String, - url: String, - host: String, - captured_at: String, - chunk_index: usize, - excerpt: String, - score: SemanticTrailScoreBreakdown, - reasons: Vec, -} - -#[derive(Clone, Serialize)] -#[serde(rename_all = "camelCase")] -struct SemanticTrailEdge { - from: String, - to: String, - kind: SemanticTrailEdgeKind, - weight: f64, -} - -#[derive(Clone, Serialize)] -#[serde(rename_all = "camelCase")] -struct CaptureHubSuggestion { - collection_id: String, - collection_name: String, - confidence: f64, - sample_title: String, -} - -#[derive(Clone, Serialize)] -#[serde(rename_all = "camelCase")] -struct SemanticTrailResult { - query: String, - generated_at: String, - root: SemanticTrailRoot, - items: Vec, - edges: Vec, -} - -#[derive(Clone, Copy, Serialize)] -#[serde(rename_all = "kebab-case")] -enum FlowGraphNodeKind { - Query, - Hub, - Source, -} - -#[derive(Clone, Copy, Serialize)] -#[serde(rename_all = "kebab-case")] -enum FlowGraphEdgeKind { - Contains, - Semantic, - QueryMatch, -} - -#[derive(Clone, Serialize)] -#[serde(rename_all = "camelCase")] -struct FlowGraphNode { - id: String, - kind: FlowGraphNodeKind, - title: String, - subtitle: String, - weight: f64, - #[serde(skip_serializing_if = "Option::is_none")] - collection_id: Option, - #[serde(skip_serializing_if = "Option::is_none")] - collection_name: Option, - #[serde(skip_serializing_if = "Option::is_none")] - capture_id: Option, - #[serde(skip_serializing_if = "Option::is_none")] - url: Option, - #[serde(skip_serializing_if = "Option::is_none")] - host: Option, - #[serde(skip_serializing_if = "Option::is_none")] - captured_at: Option, - #[serde(skip_serializing_if = "Option::is_none")] - excerpt: Option, - #[serde(skip_serializing_if = "Option::is_none")] - score: Option, -} - -#[derive(Clone, Serialize)] -#[serde(rename_all = "camelCase")] -struct FlowGraphEdge { - id: String, - from: String, - to: String, - kind: FlowGraphEdgeKind, - weight: f64, -} - -#[derive(Clone, Serialize)] -#[serde(rename_all = "camelCase")] -struct FlowGraphResult { - query: String, - generated_at: String, - nodes: Vec, - edges: Vec, - hub_count: usize, - source_count: usize, - omitted_source_count: usize, -} - -#[derive(Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -struct ChatResult { - answer: String, - model: String, - citations: Vec, - metrics: ChatMetrics, -} - -#[derive(Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -struct ChatMetrics { - generated_tokens: usize, - tokens_per_second: f64, - elapsed_seconds: f64, - chunks: usize, -} - -struct ChatCompletion { - text: String, - generated_tokens: usize, -} - -#[derive(Clone, Copy, Serialize, Deserialize)] -#[serde(rename_all = "kebab-case")] -enum AirLensKind { - Topic, - Flow, - Hub, - Answer, - Iceberg, -} + impl AndroidTabs { + pub fn run(&self, command: &str, payload: impl Serialize) -> Result<(), String> { + self.0 + .run_mobile_plugin::<()>(command, payload) + .map_err(|error| error.to_string()) + } -impl Default for AirLensKind { - fn default() -> Self { - Self::Topic + pub fn run_for( + &self, + command: &str, + payload: impl Serialize, + ) -> Result { + self.0 + .run_mobile_plugin::(command, payload) + .map_err(|error| error.to_string()) + } } -} - -#[derive(Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -struct AirDossierInput { - lens: String, - lens_kind: Option, - collection_id: Option, - capture_id: Option, - saved_iceberg_id: Option, - answer: Option, - limit: Option, -} - -#[derive(Clone, Serialize)] -#[serde(rename_all = "camelCase")] -struct AirDossierSource { - id: String, - title: String, - excerpt: String, - #[serde(skip_serializing_if = "Option::is_none")] - collection_name: Option, - #[serde(skip_serializing_if = "Option::is_none")] - url: Option, - #[serde(skip_serializing_if = "Option::is_none")] - host: Option, - #[serde(skip_serializing_if = "Option::is_none")] - captured_at: Option, - #[serde(skip_serializing_if = "Option::is_none")] - score: Option, -} - -#[derive(Clone, Serialize)] -#[serde(rename_all = "camelCase")] -struct AirPreparedDossier { - title: String, - lens: String, - lens_kind: AirLensKind, - generated_at: String, - #[serde(skip_serializing_if = "Option::is_none")] - model: Option, - output_dir: String, - markdown_preview: String, - sources: Vec, -} - -#[derive(Clone, Serialize)] -#[serde(rename_all = "camelCase")] -struct AirRenderResult { - path: String, - filename: String, - title: String, - source_count: usize, - rendered_at: String, -} - -#[derive(Clone, Serialize)] -#[serde(rename_all = "camelCase")] -struct AirRecentFile { - path: String, - filename: String, - title: String, - lens: String, - source_count: usize, - rendered_at: String, -} -#[derive(Clone, Serialize)] -#[serde(rename_all = "camelCase")] -struct ChatStreamPayload { - request_id: String, - #[serde(skip_serializing_if = "Option::is_none")] - status: Option, - #[serde(skip_serializing_if = "Option::is_none")] - delta: Option, - #[serde(skip_serializing_if = "Option::is_none")] - citations: Option>, -} - -#[derive(Clone)] -struct ChatStreamEmitter { - app: AppHandle, - request_id: String, -} - -impl ChatStreamEmitter { - fn emit(&self, payload: ChatStreamPayload) { - let _ = self.app.emit(AETHER_CHAT_STREAM_EVENT, payload); + // Run a plugin command via the global handle. Blocks until Kotlin resolves, + // so only call from async commands (tokio workers), never the main thread — + // Kotlin resolves on the Android UI thread and would deadlock against it. + pub fn run_for_global( + command: &str, + payload: impl Serialize, + ) -> Result { + HANDLE + .get() + .ok_or_else(|| "Android tabs plugin is not ready.".to_string())? + .run_mobile_plugin::(command, payload) + .map_err(|error| error.to_string()) } - fn status(&self, status: &str) { - self.emit(ChatStreamPayload { - request_id: self.request_id.clone(), - status: Some(status.to_string()), - delta: None, - citations: None, - }); + pub fn init() -> TauriPlugin { + Builder::new("aether-tabs") + .setup(|app, api| { + let handle = api.register_android_plugin("com.canur.aether", "TabsPlugin")?; + let _ = HANDLE.set(handle.clone()); + app.manage(AndroidTabs(handle)); + Ok(()) + }) + .build() } - fn citations(&self, citations: &[SearchResult]) { - self.emit(ChatStreamPayload { - request_id: self.request_id.clone(), - status: Some("Generating answer".to_string()), - delta: None, - citations: Some(citations.to_vec()), - }); + #[derive(Deserialize)] + pub struct ThumbnailResponse { + pub image: Option, } - fn delta(&self, delta: &str) { - self.emit(ChatStreamPayload { - request_id: self.request_id.clone(), - status: None, - delta: Some(delta.to_string()), - citations: None, - }); + #[derive(Deserialize)] + pub struct SnapshotResponse { + pub payload: String, } -} - -#[derive(Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -struct IcebergItem { - id: String, - name: String, - description: String, - level: u8, - x: f64, - y: f64, - #[serde(default, skip_serializing_if = "Option::is_none")] - depth_score: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - familiarity: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - specificity: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - jargon_density: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - prerequisite_depth: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - obscurity: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - confidence: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - reason: Option, -} - -#[derive(Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -struct IcebergResult { - keyword: String, - model: String, - items: Vec, - generated_at: String, -} - -#[derive(Clone, Serialize)] -#[serde(rename_all = "camelCase")] -struct SavedIcebergSummary { - id: String, - title: String, - keyword: String, - model: String, - #[serde(skip_serializing_if = "Option::is_none")] - icon: Option, - generated_at: String, - saved_at: String, - updated_at: String, - item_count: usize, -} -#[derive(Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -struct SavedIceberg { - #[serde(flatten)] - iceberg: IcebergResult, - id: String, - title: String, - #[serde(skip_serializing_if = "Option::is_none")] - icon: Option, - saved_at: String, - updated_at: String, -} - -#[derive(Clone, Serialize)] -#[serde(rename_all = "camelCase")] -struct SystemStatus { - runtime_ready: bool, - runtime_name: String, - embedding_model: Option, - chat_model: Option, - available_models: Vec, - chat_models: Vec, - embedding_models: Vec, - model_dir: String, - db_path: String, - library_path: String, - collections: Vec, - #[serde(skip_serializing_if = "Option::is_none")] - error: Option, -} - -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -struct CreateTabInput { - url: Option, -} + #[derive(Deserialize, Serialize, Default)] + pub struct InsetsResponse { + pub top: f64, + pub bottom: f64, + pub left: f64, + pub right: f64, + } -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -struct CreateShortcutInput { - title: String, - url: String, - favicon: Option, - theme_color: Option, -} + #[derive(Serialize)] + #[serde(rename_all = "camelCase")] + pub struct TabUrlPayload<'a> { + pub tab_id: &'a str, + pub url: &'a str, + } -#[cfg(desktop)] -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -struct PageMetadataSnapshot { - theme_color: Option, - favicon: Option, -} + #[derive(Serialize)] + #[serde(rename_all = "camelCase")] + pub struct SyncPayload<'a> { + pub active_tab_id: Option<&'a str>, + pub top: f64, + pub left: f64, + pub width: f64, + pub height: f64, + } -#[cfg(desktop)] -#[derive(Deserialize)] -struct FindMatchSnapshot { - current: usize, - total: usize, -} + #[derive(Serialize)] + #[serde(rename_all = "camelCase")] + pub struct TabPayload<'a> { + pub tab_id: &'a str, + } -// Event payload forwarded by the renderer from the Kotlin TabsPlugin -// (window.__AETHER_TAB_EVENT__): per-tab navigation, title, and find updates. -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -struct NativeTabEventInput { - tab_id: String, - kind: String, - #[serde(default)] - url: Option, - #[serde(default)] - title: Option, - #[serde(default)] - is_loading: Option, - #[serde(default)] - can_go_back: Option, - #[serde(default)] - can_go_forward: Option, - #[serde(default)] - current: Option, - #[serde(default)] - total: Option, -} + #[derive(Serialize)] + #[serde(rename_all = "camelCase")] + pub struct EvalPayload<'a> { + pub tab_id: &'a str, + pub script: String, + } -#[derive(Clone, Serialize)] -#[serde(rename_all = "camelCase")] -struct FindResultPayload { - tab_id: String, - current: usize, - total: usize, + #[derive(Serialize)] + #[serde(rename_all = "camelCase")] + pub struct FindPayload<'a> { + pub tab_id: &'a str, + pub query: Option<&'a str>, + pub action: &'a str, + } } -#[derive(Deserialize)] -struct CreateCollectionInput { - name: String, - description: Option, - icon: Option, -} - -#[derive(Deserialize)] -struct UpdateCollectionInput { - id: String, - name: Option, - description: Option, - icon: Option, -} - -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -struct CaptureCurrentPageInput { - collection_id: String, -} - -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -struct MoveCaptureInput { - capture_id: String, - collection_id: String, -} - -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -struct SearchCollectionInput { - collection_id: String, - query: String, - limit: Option, -} - -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -struct SemanticTrailInput { - query: Option, - limit: Option, -} - -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -struct FlowGraphInput { - query: Option, - source_limit: Option, -} - -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -struct AskChatInput { - collection_id: Option, - prompt: String, - include_current_page: Option, - request_id: Option, -} - -#[derive(Deserialize)] -struct GenerateIcebergInput { - keyword: String, -} - -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -struct SaveIcebergInput { - title: String, - keyword: String, - model: String, - icon: Option, - generated_at: String, - items: Vec, -} - -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -struct UpdateSettingsInput { - browser: Option, - developer_mode: Option, - updates: Option, -} - -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -struct PartialBrowserSettings { - default_search_engine: Option, -} - -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -struct PartialUpdateSettings { - auto_check: Option, -} - -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -struct UpdateCheckResult { - current_version: String, - checked_at: String, - update_available: bool, - #[serde(skip_serializing_if = "Option::is_none")] - latest_version: Option, - #[serde(skip_serializing_if = "Option::is_none")] - latest_name: Option, - #[serde(skip_serializing_if = "Option::is_none")] - release_url: Option, - #[serde(skip_serializing_if = "Option::is_none")] - release_notes: Option, - #[serde(skip_serializing_if = "Option::is_none")] - published_at: Option, - #[serde(skip_serializing_if = "Option::is_none")] - error: Option, -} - -#[derive(Deserialize)] -struct GithubRelease { - tag_name: String, - name: Option, - html_url: String, - body: Option, - published_at: Option, -} - -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -struct UpdateModelsInput { - embedding_model: Option, - chat_model: Option, -} - -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -struct DownloadModelsInput { - chat_models: Vec, - #[serde(default)] - hf_token: Option, -} - -#[derive(Clone, Serialize)] -#[serde(rename_all = "camelCase")] -struct ModelDownloadProgress { - id: String, - label: String, - filename: String, - status: String, - downloaded_bytes: u64, - #[serde(skip_serializing_if = "Option::is_none")] - total_bytes: Option, - overall_downloaded_bytes: u64, - #[serde(skip_serializing_if = "Option::is_none")] - overall_total_bytes: Option, - #[serde(skip_serializing_if = "Option::is_none")] - message: Option, -} - -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -struct StatusToastInput { - message: String, - tone: String, - duration_ms: Option, -} - -#[derive(Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -struct LibraryData { - version: u8, - collections: Vec, - captures: Vec, - shortcuts: Vec, - migrated_realm_tables: Vec, -} - -impl Default for LibraryData { - fn default() -> Self { - Self { - version: 1, - collections: Vec::new(), - captures: Vec::new(), - shortcuts: Vec::new(), - migrated_realm_tables: Vec::new(), - } - } -} - -#[derive(Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -struct UserSettings { - #[serde(default = "default_settings_version")] - version: u8, - #[serde(default)] - browser: BrowserSettings, - #[serde(default)] - developer_mode: bool, - #[serde(default)] - updates: UpdateSettings, - #[serde(default, alias = "ollama")] - local_model: LocalModelSettings, -} - -#[derive(Clone, Serialize, Deserialize, Default)] -#[serde(rename_all = "camelCase")] -struct LocalModelSettings { - embedding_model: Option, - chat_model: Option, -} - -impl Default for BrowserSettings { - fn default() -> Self { - Self { - default_search_engine: "google".to_string(), - } - } -} - -impl Default for UpdateSettings { - fn default() -> Self { - Self { - auto_check: default_update_auto_check(), - last_checked_at: None, - } - } -} - -impl Default for UserSettings { - fn default() -> Self { - Self { - version: default_settings_version(), - browser: BrowserSettings::default(), - developer_mode: false, - updates: UpdateSettings::default(), - local_model: LocalModelSettings::default(), - } - } -} - -fn default_settings_version() -> u8 { - 1 -} - -fn default_update_auto_check() -> bool { - true -} - -#[derive(Serialize, Deserialize)] -struct IcebergData { - version: u8, - icebergs: Vec, -} - -impl Default for IcebergData { - fn default() -> Self { - Self { - version: 1, - icebergs: Vec::new(), - } - } -} - -#[derive(Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -struct ChunkRecord { - id: String, - vector: Vec, - text: String, - collection_id: String, - capture_id: String, - title: String, - url: String, - app_id: String, - captured_at: String, - chunk_index: usize, -} - -#[derive(Serialize, Deserialize)] -struct VectorStoreData { - version: u8, - chunks: Vec, -} - -impl Default for VectorStoreData { - fn default() -> Self { - Self { - version: 1, - chunks: Vec::new(), - } - } -} - -struct CapturedPage { - title: String, - url: String, - text: String, -} - -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -struct BrowserPageSnapshot { - html: Option, - url: Option, - title: Option, - description: Option, - body_text: Option, -} - -impl Backend { - fn new(app_data_dir: PathBuf) -> Self { - let db_path = app_data_dir.join("aether-realms"); - let models_path = default_models_path(&app_data_dir); - Self { - paths: DataPaths { - chunks_path: db_path.join(format!("{CHUNKS_TABLE}.json")), - db_path, - library_path: app_data_dir.join("aether-library").join("library.json"), - settings_path: app_data_dir.join("aether-settings").join("settings.json"), - icebergs_path: app_data_dir.join("aether-icebergs").join("icebergs.json"), - air_exports_path: app_data_dir.join("aether-air"), - models_path, - }, - tabs: Mutex::new(TabState::new()), - #[cfg(desktop)] - webviews: Mutex::new(NativeBrowserViews::default()), - #[cfg(not(desktop))] - mobile_tab_bounds: Mutex::new(MobileTabBounds::default()), - client: Client::builder() - .user_agent("Aether/1.0 Tauri") - .build() - .expect("reqwest client"), - native_runtime: Arc::new(Mutex::new(NativeModelRuntime::default())), - vectors: tokio::sync::RwLock::new(None), - generation_cancelled: Arc::new(AtomicBool::new(false)), - } - } -} - -impl TabState { - fn new() -> Self { - let initial = ManagedTab::new("browser", START_PAGE_URL); - let active_tab_id = initial.id.clone(); - Self { - tabs: vec![initial], - active_app_id: "browser".to_string(), - active_tab_id, - dashboard_open: true, - modal_overlay_open: false, - panel_collapsed: true, - } - } - - fn state(&self) -> AetherState { - AetherState { - apps: self.apps(), - tabs: self.tabs(), - active_app_id: self.active_app_id.clone(), - active_tab_id: self.active_tab_id.clone(), - dashboard_open: self.dashboard_open, - panel_collapsed: self.panel_collapsed, - } - } - - fn apps(&self) -> Vec { - let active = self.active_tab(); - vec![AppSummary { - id: "browser".to_string(), - name: "Browser".to_string(), - category: "Web".to_string(), - home_url: "https://www.google.com".to_string(), - current_url: active - .map(|tab| tab.url.clone()) - .unwrap_or_else(|| "https://www.google.com".to_string()), - title: active - .map(|tab| tab.title.clone()) - .unwrap_or_else(|| "Browser".to_string()), - is_active: !self.dashboard_open, - is_loading: active.map(|tab| tab.is_loading).unwrap_or(false), - can_go_back: active.map(|tab| tab.can_go_back()).unwrap_or(false), - can_go_forward: active.map(|tab| tab.can_go_forward()).unwrap_or(false), - }] - } - - fn tabs(&self) -> Vec { - self.tabs - .iter() - .map(|tab| tab.summary(tab.id == self.active_tab_id && !self.dashboard_open)) - .collect() - } - - fn active_tab(&self) -> Option<&ManagedTab> { - self.tabs.iter().find(|tab| tab.id == self.active_tab_id) - } - - fn active_tab_mut(&mut self) -> Option<&mut ManagedTab> { - let active_tab_id = self.active_tab_id.clone(); - self.tabs.iter_mut().find(|tab| tab.id == active_tab_id) - } -} - -impl ManagedTab { - fn new(app_id: &str, raw_url: &str) -> Self { - let url = normalize_url(raw_url, "google"); - let title = if url == START_PAGE_URL { - "New tab".to_string() - } else { - title_from_url(&url) - }; - Self { - id: uuid(), - app_id: app_id.to_string(), - title, - url: url.clone(), - is_loading: false, - favicon: None, - theme_color: None, - history: vec![url], - history_index: 0, - native_can_go_back: None, - native_can_go_forward: None, - } - } - - fn navigate(&mut self, raw_url: &str, search_engine: &str) { - let url = normalize_url(raw_url, search_engine); - self.url = url.clone(); - self.title = title_from_url(&url); - self.favicon = favicon_for_url(&url); - self.theme_color = None; - self.is_loading = false; - self.history.truncate(self.history_index + 1); - self.history.push(url); - self.history_index = self.history.len().saturating_sub(1); - // Unknown until the native webview reports in after the load. - self.native_can_go_back = None; - self.native_can_go_forward = None; - } - - fn commit_history_url(&mut self, url: String) { - if self.history.get(self.history_index) == Some(&url) { - return; - } - - if let Some(existing_index) = self - .history - .iter() - .enumerate() - .rev() - .find_map(|(index, item)| (item == &url).then_some(index)) - { - self.history_index = existing_index; - return; - } - - self.history.truncate(self.history_index + 1); - self.history.push(url); - self.history_index = self.history.len().saturating_sub(1); - } - - fn can_go_back(&self) -> bool { - self.history_index > 0 - } - - fn can_go_forward(&self) -> bool { - self.history_index + 1 < self.history.len() - } - - fn summary(&self, is_active: bool) -> BrowserTabSummary { - BrowserTabSummary { - id: self.id.clone(), - app_id: self.app_id.clone(), - title: self.title.clone(), - url: self.url.clone(), - host: get_tab_host(&self.url), - is_active, - is_loading: self.is_loading, - can_go_back: self.can_go_back() || self.native_can_go_back.unwrap_or(false), - can_go_forward: self.can_go_forward() || self.native_can_go_forward.unwrap_or(false), - favicon: self.favicon.clone(), - theme_color: self.theme_color.clone(), - } - } -} - -#[cfg(desktop)] -fn ensure_native_webview(app: &AppHandle, state: &State, tab_id: &str) -> Cmd<()> { - let tab = { - let tabs = lock_tabs(state)?; - tabs.tabs - .iter() - .find(|tab| tab.id == tab_id) - .cloned() - .ok_or_else(|| format!("Unknown tab: {tab_id}"))? - }; - - // A blank start-page tab has no remote page to load — just reconcile visibility so - // any previously-active webview is hidden and the renderer's start page shows. - if tab.url == START_PAGE_URL { - return sync_native_webview_visibility(app, state); - } - - let exists = state - .webviews - .lock() - .map_err(|_| "Æther webviews are unavailable.".to_string())? - .views - .contains_key(tab_id); - if !exists { - let webview = create_native_webview(app, state, &tab)?; - state - .webviews - .lock() - .map_err(|_| "Æther webviews are unavailable.".to_string())? - .views - .insert(tab.id.clone(), webview); - } - - sync_native_webview_visibility(app, state) -} - -#[cfg(not(desktop))] -fn ensure_native_webview(app: &AppHandle, state: &State, tab_id: &str) -> Cmd<()> { - #[cfg(target_os = "android")] - { - let tab = { - let tabs = lock_tabs(state)?; - tabs.tabs - .iter() - .find(|tab| tab.id == tab_id) - .cloned() - .ok_or_else(|| format!("Unknown tab: {tab_id}"))? - }; - // Like the desktop path: a start-page tab has nothing to load, only - // visibility to reconcile so the renderer's start page shows. - if tab.url != START_PAGE_URL { - app.state::().run( - "ensure", - android_tabs::TabUrlPayload { - tab_id: &tab.id, - url: &tab.url, - }, - )?; - } - return sync_native_webview_visibility(app, state); - } - #[allow(unreachable_code)] - { - let _ = (app, state, tab_id); - Ok(()) - } -} - -#[cfg(desktop)] -fn create_native_webview( - app: &AppHandle, - state: &State, - tab: &ManagedTab, -) -> Cmd { - let window = app - .get_window("main") - .ok_or_else(|| "Æther main window is not ready.".to_string())?; - let bounds = native_webview_bounds(&window, state)?; - let label = native_webview_label(&tab.id); - let tab_id_for_navigation = tab.id.clone(); - let tab_id_for_load = tab.id.clone(); - let tab_id_for_title = tab.id.clone(); - let app_for_navigation = app.clone(); - let app_for_load = app.clone(); - let app_for_title = app.clone(); - let app_for_new_window = app.clone(); - let url = Url::parse(&tab.url).map_err(|error| error.to_string())?; - - let builder = WebviewBuilder::new(label, WebviewUrl::External(url)) - .user_agent(DESKTOP_BROWSER_USER_AGENT) - .on_navigation(move |url| { - let state = app_for_navigation.state::(); - update_tab_navigation_state(&state, &tab_id_for_navigation, url.as_str(), true); - let _ = emit_state(&app_for_navigation, &state); - true - }) - .on_page_load(move |webview, payload| { - let state = app_for_load.state::(); - let is_loading = payload.event() == PageLoadEvent::Started; - update_tab_navigation_state( - &state, - &tab_id_for_load, - payload.url().as_str(), - is_loading, - ); - let _ = emit_state(&app_for_load, &state); - if payload.event() == PageLoadEvent::Finished { - let _ = webview.eval(NATIVE_WEBVIEW_SCROLLBAR_SCRIPT); - read_native_webview_metadata( - &webview, - app_for_load.clone(), - tab_id_for_load.clone(), - ); - } - }) - .on_document_title_changed(move |_webview, title| { - let state = app_for_title.state::(); - update_tab_title(&state, &tab_id_for_title, &title); - let _ = emit_state(&app_for_title, &state); - }) - .on_new_window(move |url, _features| { - let state = app_for_new_window.state::(); - let _ = create_native_tab_from_url(&app_for_new_window, &state, url.as_str()); - NewWindowResponse::Deny - }); - - let webview = window - .add_child(builder, bounds.position, bounds.size) - .map_err(|error| error.to_string())?; - webview.hide().map_err(|error| error.to_string())?; - Ok(webview) -} - -#[cfg(desktop)] -fn create_native_tab_from_url(app: &AppHandle, state: &State, raw_url: &str) -> Cmd<()> { - let url = normalize_url(raw_url, "google"); - let tab = ManagedTab::new("browser", &url); - let tab_id = tab.id.clone(); - { - let mut tabs = lock_tabs(state)?; - tabs.active_tab_id = tab_id.clone(); - tabs.active_app_id = tab.app_id.clone(); - tabs.dashboard_open = false; - tabs.tabs.push(tab); - } - ensure_native_webview(app, state, &tab_id)?; - emit_state(app, state) -} - -#[cfg(desktop)] -fn navigate_native_webview( - app: &AppHandle, - state: &State, - tab_id: &str, - url: &str, -) -> Cmd<()> { - ensure_native_webview(app, state, tab_id)?; - let parsed = Url::parse(url).map_err(|error| error.to_string())?; - let webview = state - .webviews - .lock() - .map_err(|_| "Æther webviews are unavailable.".to_string())? - .views - .get(tab_id) - .cloned() - .ok_or_else(|| format!("Native webview not found for tab: {tab_id}"))?; - webview.navigate(parsed).map_err(|error| error.to_string()) -} - -#[cfg(not(desktop))] -fn navigate_native_webview( - app: &AppHandle, - state: &State, - tab_id: &str, - url: &str, -) -> Cmd<()> { - #[cfg(target_os = "android")] - { - app.state::() - .run("navigate", android_tabs::TabUrlPayload { tab_id, url })?; - return sync_native_webview_visibility(app, state); - } - #[allow(unreachable_code)] - { - let _ = (app, state, tab_id, url); - Ok(()) - } -} - -#[cfg(desktop)] -fn navigate_native_webview_history( - _app: &AppHandle, - state: &State, - tab_id: &str, - direction: WebviewHistoryDirection, -) -> Cmd<()> { - let webview = state - .webviews - .lock() - .map_err(|_| "Æther webviews are unavailable.".to_string())? - .views - .get(tab_id) - .cloned() - .ok_or_else(|| format!("Native webview not found for tab: {tab_id}"))?; - let script = match direction { - WebviewHistoryDirection::Back => "history.back();", - WebviewHistoryDirection::Forward => "history.forward();", - }; - webview.eval(script).map_err(|error| error.to_string()) -} - -#[cfg(not(desktop))] -fn navigate_native_webview_history( - app: &AppHandle, - state: &State, - tab_id: &str, - direction: WebviewHistoryDirection, -) -> Cmd<()> { - #[cfg(target_os = "android")] - { - let _ = state; - let command = match direction { - WebviewHistoryDirection::Back => "goBack", - WebviewHistoryDirection::Forward => "goForward", - }; - return app - .state::() - .run(command, android_tabs::TabPayload { tab_id }); - } - #[allow(unreachable_code)] - { - let _ = (app, state, tab_id, direction); - Ok(()) - } -} - -#[cfg(desktop)] -fn scroll_native_webview_to_text( - _app: &AppHandle, - state: &State, - tab_id: &str, - text: &str, -) -> Cmd<()> { - let source_text = text.trim(); - if source_text.is_empty() { - return Ok(()); - } - let webview = state - .webviews - .lock() - .map_err(|_| "Æther webviews are unavailable.".to_string())? - .views - .get(tab_id) - .cloned() - .ok_or_else(|| format!("Native webview not found for tab: {tab_id}"))?; - let text_json = serde_json::to_string(source_text).map_err(|error| error.to_string())?; - let script = scroll_to_text_script().replace("__AETHER_SOURCE_TEXT__", &text_json); - webview.eval(script).map_err(|error| error.to_string()) -} - -#[cfg(not(desktop))] -fn scroll_native_webview_to_text( - app: &AppHandle, - state: &State, - tab_id: &str, - text: &str, -) -> Cmd<()> { - #[cfg(target_os = "android")] - { - let _ = state; - let source_text = text.trim(); - if source_text.is_empty() { - return Ok(()); - } - let text_json = serde_json::to_string(source_text).map_err(|error| error.to_string())?; - let script = scroll_to_text_script().replace("__AETHER_SOURCE_TEXT__", &text_json); - return app - .state::() - .run("eval", android_tabs::EvalPayload { tab_id, script }); - } - #[allow(unreachable_code)] - { - let _ = (app, state, tab_id, text); - Ok(()) - } -} - -#[cfg(desktop)] -fn find_native_webview_text( - app: &AppHandle, - state: &State, - tab_id: &str, - query: Option<&str>, - action: &str, -) -> Cmd<()> { - let webview = state - .webviews - .lock() - .map_err(|_| "Æther webviews are unavailable.".to_string())? - .views - .get(tab_id) - .cloned() - .ok_or_else(|| format!("Native webview not found for tab: {tab_id}"))?; - let query_json = match query.map(str::trim).filter(|value| !value.is_empty()) { - Some(value) => serde_json::to_string(value).map_err(|error| error.to_string())?, - None => "null".to_string(), - }; - let action_json = serde_json::to_string(action).map_err(|error| error.to_string())?; - let script = find_in_page_script() - .replace("__AETHER_FIND_QUERY__", &query_json) - .replace("__AETHER_FIND_ACTION__", &action_json); - let app = app.clone(); - let tab_id = tab_id.to_string(); - webview - .eval_with_callback(script, move |payload| { - let Ok(snapshot) = parse_json_payload::(&payload) else { - return; - }; - let _ = app.emit( - AETHER_FIND_RESULT_EVENT, - FindResultPayload { - tab_id: tab_id.clone(), - current: snapshot.current, - total: snapshot.total, - }, - ); - }) - .map_err(|error| error.to_string()) -} - -#[cfg(not(desktop))] -fn find_native_webview_text( - app: &AppHandle, - state: &State, - tab_id: &str, - query: Option<&str>, - action: &str, -) -> Cmd<()> { - #[cfg(target_os = "android")] - { - let _ = state; - // Android WebView has native find support (findAllAsync/findNext); the - // match counts come back through the FindListener as a "find" event on - // aether_tabs_report_native_event. - return app.state::().run( - "find", - android_tabs::FindPayload { - tab_id, - query: query.map(str::trim).filter(|value| !value.is_empty()), - action, - }, - ); - } - #[allow(unreachable_code)] - { - let _ = (app, state, tab_id, query, action); - Ok(()) - } -} - -#[cfg(desktop)] -fn close_native_webview(_app: &AppHandle, state: &State, tab_id: &str) -> Cmd<()> { - if let Some(webview) = state - .webviews - .lock() - .map_err(|_| "Æther webviews are unavailable.".to_string())? - .views - .remove(tab_id) - { - webview.close().map_err(|error| error.to_string())?; - } - Ok(()) -} - -#[cfg(not(desktop))] -fn close_native_webview(app: &AppHandle, state: &State, tab_id: &str) -> Cmd<()> { - #[cfg(target_os = "android")] - { - let _ = state; - return app - .state::() - .run("close", android_tabs::TabPayload { tab_id }); - } - #[allow(unreachable_code)] - { - let _ = (app, state, tab_id); - Ok(()) - } -} - -#[cfg(desktop)] -fn find_in_page_script() -> &'static str { - r#" -(() => { - const action = __AETHER_FIND_ACTION__; - const rawQuery = __AETHER_FIND_QUERY__; - const HL = 'aether-find'; - const HL_CUR = 'aether-find-current'; - const STYLE_ID = 'aether-find-style'; - const MAX = 5000; - const supportsHighlight = - typeof CSS !== 'undefined' && - CSS.highlights && - typeof Highlight !== 'undefined' && - typeof Range !== 'undefined'; - const normalize = (value) => String(value ?? '').replace(/\s+/g, ' ').trim(); - const state = (window.__aetherFind = window.__aetherFind || { query: '', index: 0, total: 0 }); - - const clearHighlights = () => { - if (supportsHighlight) { - try { CSS.highlights.delete(HL); CSS.highlights.delete(HL_CUR); } catch (error) {} - } - document.querySelectorAll('mark[data-aether-find]').forEach((mark) => { - const parent = mark.parentNode; - if (!parent) return; - while (mark.firstChild) parent.insertBefore(mark.firstChild, mark); - parent.removeChild(mark); - parent.normalize(); - }); - }; - - const ensureStyle = () => { - if (document.getElementById(STYLE_ID)) return; - const style = document.createElement('style'); - style.id = STYLE_ID; - style.textContent = - '::highlight(aether-find){background-color:#bfe9f7;color:#0e364a;}' + - '::highlight(aether-find-current){background-color:#247fa7;color:#f4fbff;}' + - 'mark[data-aether-find]{background-color:#bfe9f7;color:#0e364a;border-radius:2px;padding:0;}' + - 'mark[data-aether-find="current"]{background-color:#247fa7;color:#f4fbff;}'; - (document.head || document.documentElement).appendChild(style); - }; - - if (action === 'clear') { - clearHighlights(); - state.query = ''; state.index = 0; state.total = 0; - return { current: 0, total: 0 }; - } - - const query = normalize(rawQuery); - clearHighlights(); - if (!query) { - state.query = ''; state.index = 0; state.total = 0; - return { current: 0, total: 0 }; - } - - const collectRanges = (needle) => { - const lc = needle.toLowerCase(); - const len = lc.length; - const root = document.body || document.documentElement; - if (!root || !len) return []; - const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, { - acceptNode(node) { - if (!node.nodeValue) return NodeFilter.FILTER_REJECT; - const parent = node.parentElement; - if (!parent) return NodeFilter.FILTER_REJECT; - const tag = parent.tagName; - if (tag === 'SCRIPT' || tag === 'STYLE' || tag === 'NOSCRIPT' || tag === 'TEXTAREA') { - return NodeFilter.FILTER_REJECT; - } - return NodeFilter.FILTER_ACCEPT; - } - }); - const nodes = []; - let buffer = ''; - let node; - while ((node = walker.nextNode())) { - nodes.push({ node, start: buffer.length }); - buffer += node.nodeValue; - } - const haystack = buffer.toLowerCase(); - const nodeAt = (offset) => { - let lo = 0, hi = nodes.length - 1, pick = 0; - while (lo <= hi) { - const mid = (lo + hi) >> 1; - if (nodes[mid].start <= offset) { pick = mid; lo = mid + 1; } else { hi = mid - 1; } - } - return pick; - }; - const ranges = []; - let from = 0, at; - while ((at = haystack.indexOf(lc, from)) !== -1) { - const end = at + len; - const startNode = nodeAt(at); - const endNode = nodeAt(end - 1); - try { - const range = document.createRange(); - range.setStart(nodes[startNode].node, at - nodes[startNode].start); - range.setEnd(nodes[endNode].node, end - nodes[endNode].start); - ranges.push(range); - } catch (error) {} - from = end; - if (ranges.length >= MAX) break; - } - return ranges; - }; - - const ranges = collectRanges(query); - const total = ranges.length; - if (total === 0) { - state.query = query; state.index = 0; state.total = 0; - return { current: 0, total: 0 }; - } - - let index; - if ((action === 'next' || action === 'prev') && state.query === query) { - index = state.index + (action === 'next' ? 1 : -1); - } else { - index = 0; - } - index = ((index % total) + total) % total; - state.query = query; state.index = index; state.total = total; - - ensureStyle(); - if (supportsHighlight) { - try { - const all = new Highlight(); - for (const range of ranges) all.add(range); - CSS.highlights.set(HL, all); - const current = new Highlight(); - current.add(ranges[index]); - CSS.highlights.set(HL_CUR, current); - } catch (error) {} - } else { - for (let i = ranges.length - 1; i >= 0; i--) { - try { - const mark = document.createElement('mark'); - mark.setAttribute('data-aether-find', i === index ? 'current' : 'all'); - ranges[i].surroundContents(mark); - } catch (error) {} - } - } - - let scrollTarget = null; - if (supportsHighlight) { - const node = ranges[index].startContainer; - scrollTarget = node.nodeType === 1 ? node : node.parentElement; - } else { - scrollTarget = document.querySelector('mark[data-aether-find="current"]'); - } - try { - if (scrollTarget && scrollTarget.scrollIntoView) { - scrollTarget.scrollIntoView({ block: 'center', inline: 'nearest', behavior: 'smooth' }); - } - } catch (error) {} - - return { current: index + 1, total }; -})() -"# -} - -fn scroll_to_text_script() -> &'static str { - r#" -(() => { - const sourceText = __AETHER_SOURCE_TEXT__; - const normalize = (value) => String(value || '').replace(/\s+/g, ' ').trim().toLowerCase(); - const source = normalize(sourceText); - if (!source) return; - const EXACT_HL = 'aether-source-exact'; - const STYLE_ID = 'aether-source-style'; - const supportsHighlight = - typeof CSS !== 'undefined' && - CSS.highlights && - typeof Highlight !== 'undefined' && - typeof Range !== 'undefined'; - - const words = source.split(' ').filter(Boolean).slice(0, 180); - const snippets = []; - const seen = new Set(); - const addSnippet = (start, length) => { - const snippet = words.slice(start, start + length).join(' '); - if (snippet.length >= 32 && !seen.has(snippet)) { - seen.add(snippet); - snippets.push(snippet); - } - }; - - for (const length of [28, 22, 16, 12, 9, 7]) { - const step = Math.max(3, Math.floor(length / 2)); - for (let start = 0; start < words.length; start += step) { - addSnippet(start, length); - } - } - snippets.sort((left, right) => right.length - left.length); - - const ensureStyle = () => { - if (document.getElementById(STYLE_ID)) return; - const style = document.createElement('style'); - style.id = STYLE_ID; - style.textContent = - '::highlight(aether-source-exact){background-color:rgba(255,224,102,0.72);color:inherit;}' + - 'mark[data-aether-source-range]{background-color:rgba(255,224,102,0.72);color:inherit;border-radius:2px;padding:0;}'; - (document.head || document.documentElement).appendChild(style); - }; - - const clearExactHighlights = () => { - if (supportsHighlight) { - try { CSS.highlights.delete(EXACT_HL); } catch (error) {} - } - document.querySelectorAll('mark[data-aether-source-range]').forEach((mark) => { - const parent = mark.parentNode; - if (!parent) return; - while (mark.firstChild) parent.insertBefore(mark.firstChild, mark); - parent.removeChild(mark); - parent.normalize(); - }); - }; - - const restorePreviousHighlights = () => { - clearExactHighlights(); - document.querySelectorAll('[data-aether-source-highlight="true"]').forEach((element) => { - element.style.outline = element.dataset.aetherPreviousOutline || ''; - element.style.boxShadow = element.dataset.aetherPreviousBoxShadow || ''; - element.style.backgroundColor = element.dataset.aetherPreviousBackgroundColor || ''; - element.removeAttribute('data-aether-source-highlight'); - element.removeAttribute('data-aether-previous-outline'); - element.removeAttribute('data-aether-previous-box-shadow'); - element.removeAttribute('data-aether-previous-background-color'); - }); - }; - - const textNodeAccepted = (node) => { - if (!node.nodeValue) return false; - const parent = node.parentElement; - if (!parent) return false; - const tag = parent.tagName; - return tag !== 'SCRIPT' && tag !== 'STYLE' && tag !== 'NOSCRIPT' && tag !== 'TEXTAREA'; - }; - - const collectTextIndex = () => { - const root = document.body || document.documentElement; - if (!root) return null; - const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, { - acceptNode(node) { - return textNodeAccepted(node) ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT; - } - }); - const map = []; - let text = ''; - let node; - while ((node = walker.nextNode())) { - const value = node.nodeValue || ''; - for (let index = 0; index < value.length; index += 1) { - const char = value[index]; - if (/\s/.test(char)) { - if (text && !text.endsWith(' ')) { - text += ' '; - map.push({ node, offset: index }); - } - } else { - text += char.toLowerCase(); - map.push({ node, offset: index }); - } - } - } - while (text.endsWith(' ')) { - text = text.slice(0, -1); - map.pop(); - } - return { text, map }; - }; - - const rangeFromIndex = (index, length, map) => { - const start = map[index]; - const end = map[index + length - 1]; - if (!start || !end) return null; - try { - const range = document.createRange(); - range.setStart(start.node, start.offset); - range.setEnd(end.node, end.offset + 1); - return range; - } catch (error) { - return null; - } - }; - - const findRangeMatch = () => { - const index = collectTextIndex(); - if (!index) return null; - for (const snippet of snippets) { - const at = index.text.indexOf(snippet); - if (at === -1) continue; - const range = rangeFromIndex(at, snippet.length, index.map); - if (range) return range; - } - return null; - }; - - const scrollRangeIntoView = (range) => { - try { - const rects = range.getClientRects(); - const rect = rects.length ? rects[0] : range.getBoundingClientRect(); - if (rect && Number.isFinite(rect.top)) { - const top = rect.top + window.scrollY - window.innerHeight * 0.42; - window.scrollTo({ top: Math.max(0, top), behavior: 'smooth' }); - return; - } - } catch (error) {} - const node = range.startContainer; - const element = node.nodeType === 1 ? node : node.parentElement; - if (element && element.scrollIntoView) { - element.scrollIntoView({ block: 'center', inline: 'nearest', behavior: 'smooth' }); - } - }; - - const highlightRange = (range) => { - restorePreviousHighlights(); - ensureStyle(); - let highlighted = false; - if (supportsHighlight) { - try { - const exact = new Highlight(); - exact.add(range); - CSS.highlights.set(EXACT_HL, exact); - highlighted = true; - } catch (error) {} - } - if (!highlighted) { - try { - const mark = document.createElement('mark'); - mark.setAttribute('data-aether-source-range', 'true'); - range.surroundContents(mark); - highlighted = true; - } catch (error) {} - } - scrollRangeIntoView(range); - window.setTimeout(clearExactHighlights, 12000); - return highlighted; - }; - - const isVisible = (element) => { - const style = window.getComputedStyle(element); - const rect = element.getBoundingClientRect(); - return style.display !== 'none' && style.visibility !== 'hidden' && rect.width > 0 && rect.height > 0; - }; - - const scoreElement = (element) => { - const tag = element.tagName.toLowerCase(); - if (['p', 'li', 'blockquote', 'td', 'th', 'figcaption', 'dd', 'dt'].includes(tag)) return 0; - if (['article', 'section', 'main'].includes(tag)) return 2; - return 1; - }; - - const highlight = (element) => { - restorePreviousHighlights(); - element.dataset.aetherSourceHighlight = 'true'; - element.dataset.aetherPreviousOutline = element.style.outline || ''; - element.dataset.aetherPreviousBoxShadow = element.style.boxShadow || ''; - element.dataset.aetherPreviousBackgroundColor = element.style.backgroundColor || ''; - element.style.outline = '3px solid rgba(66, 153, 225, 0.72)'; - element.style.boxShadow = '0 0 0 8px rgba(66, 153, 225, 0.16)'; - element.style.backgroundColor = 'rgba(255, 246, 189, 0.42)'; - element.scrollIntoView({ block: 'center', inline: 'nearest', behavior: 'smooth' }); - window.setTimeout(() => { - if (element.dataset.aetherSourceHighlight === 'true') restorePreviousHighlights(); - }, 12000); - }; - - const findMatch = () => { - const elements = Array.from( - document.querySelectorAll('p, li, blockquote, td, th, figcaption, dd, dt, article, section, main, div') - ) - .filter(isVisible) - .map((element) => ({ element, text: normalize(element.textContent) })) - .filter((item) => item.text.length >= 32) - .sort((left, right) => { - const tagScore = scoreElement(left.element) - scoreElement(right.element); - if (tagScore !== 0) return tagScore; - return left.text.length - right.text.length; - }); - - for (const snippet of snippets) { - const match = elements.find((item) => item.text.includes(snippet)); - if (match) return match.element; - } - - return null; - }; - - let attempts = 0; - const retry = () => { - attempts += 1; - const range = findRangeMatch(); - if (range && highlightRange(range)) return; - const match = findMatch(); - if (match) { - highlight(match); - return; - } - if (attempts < 28) window.setTimeout(retry, 250); - }; - - retry(); -})(); -"# -} - -#[cfg(desktop)] -fn resize_native_webviews(app: &AppHandle, state: &State) -> Cmd<()> { - sync_native_webview_visibility(app, state) -} - -#[cfg(desktop)] -fn sync_native_webview_visibility(app: &AppHandle, state: &State) -> Cmd<()> { - let (active_tab_id, show_active, panel_collapsed) = { - let tabs = lock_tabs(state)?; - // A tab parked on the start page must keep its (possibly still-alive) webview - // hidden so the renderer's start page overlay stays visible. - let active_is_start = tabs - .active_tab() - .map(|tab| tab.url == START_PAGE_URL) - .unwrap_or(false); - ( - tabs.active_tab_id.clone(), - !tabs.dashboard_open && !tabs.modal_overlay_open && !active_is_start, - tabs.panel_collapsed, - ) - }; - let window = app - .get_window("main") - .ok_or_else(|| "Æther main window is not ready.".to_string())?; - let bounds = native_webview_bounds_for_window(&window, panel_collapsed)?; - let webviews = state - .webviews - .lock() - .map_err(|_| "Æther webviews are unavailable.".to_string())?; - - for (tab_id, webview) in &webviews.views { - if show_active && tab_id == &active_tab_id { - webview - .set_bounds(bounds) - .map_err(|error| error.to_string())?; - webview.show().map_err(|error| error.to_string())?; - } else { - webview.hide().map_err(|error| error.to_string())?; - } - } - - Ok(()) -} - -#[cfg(not(desktop))] -fn sync_native_webview_visibility(app: &AppHandle, state: &State) -> Cmd<()> { - #[cfg(target_os = "android")] - { - let (active_tab_id, show_active) = { - let tabs = lock_tabs(state)?; - // Same rules as desktop: keep webviews hidden behind the dashboard, - // modal overlays, and the renderer's start-page overlay. - let active_is_start = tabs - .active_tab() - .map(|tab| tab.url == START_PAGE_URL) - .unwrap_or(false); - ( - tabs.active_tab_id.clone(), - !tabs.dashboard_open && !tabs.modal_overlay_open && !active_is_start, - ) - }; - let bounds = *state - .mobile_tab_bounds - .lock() - .map_err(|_| "Æther layout bounds are unavailable.".to_string())?; - return app.state::().run( - "sync", - android_tabs::SyncPayload { - active_tab_id: show_active.then_some(active_tab_id.as_str()), - top: bounds.top, - left: bounds.left, - width: bounds.width, - height: bounds.height, - }, - ); - } - #[allow(unreachable_code)] - { - let _ = (app, state); - Ok(()) - } -} - -#[cfg(desktop)] -fn native_webview_bounds(window: &Window, state: &State) -> Cmd { - let panel_collapsed = lock_tabs(state)?.panel_collapsed; - native_webview_bounds_for_window(window, panel_collapsed) -} - -#[cfg(desktop)] -fn native_webview_bounds_for_window(window: &Window, panel_collapsed: bool) -> Cmd { - let size = window - .inner_size() - .map_err(|error| error.to_string())? - .to_logical::(window.scale_factor().map_err(|error| error.to_string())?); - let right_width = if panel_collapsed { - PANEL_COLLAPSED_WIDTH - } else { - PANEL_WIDTH - }; - let width = (size.width - SIDEBAR_WIDTH - right_width).max(280.0); - let height = (size.height - BROWSER_VIEW_TOP).max(200.0); - - Ok(Rect { - position: Position::Logical(LogicalPosition::new(SIDEBAR_WIDTH, BROWSER_VIEW_TOP)), - size: Size::Logical(LogicalSize::new(width, height)), - }) -} - -#[cfg(desktop)] -fn native_webview_label(tab_id: &str) -> String { - format!("aether-browser-tab-{tab_id}") -} - -#[cfg(desktop)] -fn read_native_webview_metadata(webview: &Webview, app: AppHandle, tab_id: String) { - let script = r#"(() => { - const theme = document.querySelector('meta[name="theme-color"], meta[name="msapplication-TileColor"]'); - const icons = Array.from(document.querySelectorAll('link[rel]')) - .map((link) => { - const rel = link.getAttribute('rel') || ''; - if (!/\b(icon|apple-touch-icon|shortcut icon)\b/i.test(rel)) return null; - const href = link.href || ''; - const sizes = link.getAttribute('sizes') || ''; - const size = sizes - .split(/\s+/) - .map((item) => Number.parseInt(item, 10) || 0) - .reduce((largest, value) => Math.max(largest, value), 0); - return { href, rel, size }; - }) - .filter(Boolean) - .sort((left, right) => { - if (right.size !== left.size) return right.size - left.size; - return Number(/apple-touch-icon/i.test(right.rel)) - Number(/apple-touch-icon/i.test(left.rel)); - }); - return { - themeColor: theme?.getAttribute('content') || '', - favicon: icons[0]?.href || '' - }; - })()"#; - - let _ = webview.eval_with_callback(script, move |payload| { - let metadata = match parse_json_payload::(&payload) { - Ok(metadata) => metadata, - Err(_) => return, - }; - let favicon = metadata - .favicon - .map(|favicon| favicon.trim().to_string()) - .filter(|favicon| !favicon.is_empty()); - let theme_color = metadata - .theme_color - .as_deref() - .and_then(normalize_theme_color); - let state = app.state::(); - if update_tab_metadata(&state, &tab_id, theme_color, favicon) { - let _ = emit_state(&app, &state); - } - }); -} - -fn update_tab_navigation_state(state: &State, tab_id: &str, url: &str, is_loading: bool) { - if let Ok(mut tabs) = lock_tabs(state) { - if let Some(tab) = tabs.tabs.iter_mut().find(|tab| tab.id == tab_id) { - tab.is_loading = is_loading; - let url = url.trim(); - if !should_accept_webview_url(&tab.url, url) { - return; - } - - let url = url.to_string(); - let url_changed = tab.url != url; - tab.url = url.clone(); - tab.favicon = favicon_for_url(&url); - if url_changed { - tab.theme_color = None; - } - if tab.title == "New tab" || tab.title.is_empty() || tab.title == get_tab_host(&tab.url) - { - tab.title = title_from_url(&url); - } - if !is_loading { - tab.commit_history_url(url); - } - } - } -} - -fn should_accept_webview_url(current_url: &str, next_url: &str) -> bool { - if next_url.is_empty() { - return false; - } - // While a tab is parked on the start page, ignore stray events from its hidden - // webview so they don't overwrite the start-page sentinel. - if current_url == START_PAGE_URL { - return false; - } - if is_transient_webview_url(next_url) && !is_transient_webview_url(current_url) { - return false; - } - true -} - -fn is_transient_webview_url(url: &str) -> bool { - let normalized = url.trim().to_ascii_lowercase(); - normalized == "about:blank" - || normalized.starts_with("about:blank#") - || normalized == "about:srcdoc" -} - -#[cfg(desktop)] -fn update_tab_metadata( - state: &State, - tab_id: &str, - theme_color: Option, - favicon: Option, -) -> bool { - if let Ok(mut tabs) = lock_tabs(state) { - if let Some(tab) = tabs.tabs.iter_mut().find(|tab| tab.id == tab_id) { - let favicon = favicon.or_else(|| tab.favicon.clone()); - if tab.theme_color == theme_color && tab.favicon == favicon { - return false; - } - tab.theme_color = theme_color; - tab.favicon = favicon; - return true; - } - } - false -} - -fn update_tab_title(state: &State, tab_id: &str, title: &str) { - let title = title.trim(); - if title.is_empty() { - return; - } - if let Ok(mut tabs) = lock_tabs(state) { - if let Some(tab) = tabs.tabs.iter_mut().find(|tab| tab.id == tab_id) { - tab.title = title.to_string(); - } - } -} - -// macOS quit (`-[NSApplication terminate:]`) calls libc `exit()`, which runs -// C++ static destructors. llama.cpp's Metal backend frees its global device -// registry there and asserts that all residency sets were released first — but -// our loaded models (which hold those Metal buffers) are never dropped, because -// the Cocoa quit path skips Rust's normal teardown. That assert aborts the -// process ("Æther quit unexpectedly"). Terminate immediately via `_exit`, which -// bypasses the static destructors entirely; the OS reclaims Metal/host memory, -// and all app state is already persisted per-action (no exit-time flush). -#[cfg(desktop)] -fn force_exit() -> ! { - extern "C" { - fn _exit(code: i32) -> !; - } - unsafe { _exit(0) } -} - -// Bridge to the Kotlin TabsPlugin (gen/android/.../TabsPlugin.kt), which hosts -// one android.webkit.WebView per browser tab above the main app webview. This -// is the Android counterpart of the desktop `Window::add_child` path: the same -// `*_native_webview` functions drive it, keeping Rust the source of truth for -// tab state. Navigation events come back through the renderer via the -// `aether_tabs_report_native_event` command. -#[cfg(target_os = "android")] -mod android_tabs { - use serde::{de::DeserializeOwned, Deserialize, Serialize}; - use std::sync::OnceLock; - use tauri::{ - plugin::{Builder, PluginHandle, TauriPlugin}, - Manager, Wry, - }; - - pub struct AndroidTabs(PluginHandle); - - // The plugin handle, additionally kept in a module global so helpers that - // only receive `State` (page capture) can reach the Kotlin side - // without threading an AppHandle through every call site. - static HANDLE: OnceLock> = OnceLock::new(); - - impl AndroidTabs { - pub fn run(&self, command: &str, payload: impl Serialize) -> Result<(), String> { - self.0 - .run_mobile_plugin::<()>(command, payload) - .map_err(|error| error.to_string()) - } - - pub fn run_for( - &self, - command: &str, - payload: impl Serialize, - ) -> Result { - self.0 - .run_mobile_plugin::(command, payload) - .map_err(|error| error.to_string()) - } - } - - // Run a plugin command via the global handle. Blocks until Kotlin resolves, - // so only call from async commands (tokio workers), never the main thread — - // Kotlin resolves on the Android UI thread and would deadlock against it. - pub fn run_for_global( - command: &str, - payload: impl Serialize, - ) -> Result { - HANDLE - .get() - .ok_or_else(|| "Android tabs plugin is not ready.".to_string())? - .run_mobile_plugin::(command, payload) - .map_err(|error| error.to_string()) - } - - pub fn init() -> TauriPlugin { - Builder::new("aether-tabs") - .setup(|app, api| { - let handle = api.register_android_plugin("com.canur.aether", "TabsPlugin")?; - let _ = HANDLE.set(handle.clone()); - app.manage(AndroidTabs(handle)); - Ok(()) - }) - .build() - } - - #[derive(Deserialize)] - pub struct ThumbnailResponse { - pub image: Option, - } - - #[derive(Deserialize)] - pub struct SnapshotResponse { - pub payload: String, - } - - #[derive(Deserialize, Serialize, Default)] - pub struct InsetsResponse { - pub top: f64, - pub bottom: f64, - pub left: f64, - pub right: f64, - } - - #[derive(Serialize)] - #[serde(rename_all = "camelCase")] - pub struct TabUrlPayload<'a> { - pub tab_id: &'a str, - pub url: &'a str, - } - - #[derive(Serialize)] - #[serde(rename_all = "camelCase")] - pub struct SyncPayload<'a> { - pub active_tab_id: Option<&'a str>, - pub top: f64, - pub left: f64, - pub width: f64, - pub height: f64, - } - - #[derive(Serialize)] - #[serde(rename_all = "camelCase")] - pub struct TabPayload<'a> { - pub tab_id: &'a str, - } - - #[derive(Serialize)] - #[serde(rename_all = "camelCase")] - pub struct EvalPayload<'a> { - pub tab_id: &'a str, - pub script: String, - } - - #[derive(Serialize)] - #[serde(rename_all = "camelCase")] - pub struct FindPayload<'a> { - pub tab_id: &'a str, - pub query: Option<&'a str>, - pub action: &'a str, - } -} - -#[cfg_attr(mobile, tauri::mobile_entry_point)] -pub fn run() { - let builder = tauri::Builder::default(); - #[cfg(desktop)] - let builder = builder - .menu(|app| { - let menu = Menu::new(app)?; - let focus_address_item = MenuItem::with_id( - app, - AETHER_FOCUS_ADDRESS_MENU_ID, - "Focus Address Bar", - true, - Some("CmdOrCtrl+L"), - )?; - let new_tab_item = MenuItem::with_id( - app, - AETHER_NEW_TAB_MENU_ID, - "New Tab", - true, - Some("CmdOrCtrl+T"), - )?; - let find_item = MenuItem::with_id( - app, - AETHER_FIND_MENU_ID, - "Find in Page", - true, - Some("CmdOrCtrl+F"), - )?; - let open_dashboard_item = MenuItem::with_id( - app, - AETHER_OPEN_DASHBOARD_MENU_ID, - "Open Dashboard", - true, - Some("CmdOrCtrl+1"), - )?; - let open_ice_item = MenuItem::with_id( - app, - AETHER_OPEN_ICE_MENU_ID, - "Open iCE", - true, - Some("CmdOrCtrl+2"), - )?; - let open_browser_item = MenuItem::with_id( - app, - AETHER_OPEN_BROWSER_MENU_ID, - "Open Browser", - true, - Some("CmdOrCtrl+3"), - )?; - let toggle_aion_item = MenuItem::with_id( - app, - AETHER_TOGGLE_AION_MENU_ID, - "Toggle AiON", - true, - Some("CmdOrCtrl+Shift+A"), - )?; - let capture_page_item = MenuItem::with_id( - app, - AETHER_CAPTURE_PAGE_MENU_ID, - "Capture Current Page", - true, - Some("CmdOrCtrl+Shift+C"), - )?; - let shortcuts_menu = Submenu::with_items( - app, - "Shortcuts", - true, - &[ - &focus_address_item, - &new_tab_item, - &find_item, - &open_dashboard_item, - &open_ice_item, - &open_browser_item, - &toggle_aion_item, - &capture_page_item, - ], - )?; - // Standard Edit submenu. Its predefined items carry the native key - // equivalents (Cmd/Ctrl+A/C/V/X and undo/redo), which is what wires up - // select-all/copy/paste in the address bar and other text fields. An - // empty Menu::new has no Edit menu, so those shortcuts would do nothing. - let edit_menu = Submenu::with_items( - app, - "Edit", - true, - &[ - &PredefinedMenuItem::undo(app, None)?, - &PredefinedMenuItem::redo(app, None)?, - &PredefinedMenuItem::separator(app)?, - &PredefinedMenuItem::cut(app, None)?, - &PredefinedMenuItem::copy(app, None)?, - &PredefinedMenuItem::paste(app, None)?, - &PredefinedMenuItem::select_all(app, None)?, - ], - )?; - menu.append(&edit_menu)?; - menu.append(&shortcuts_menu)?; - Ok(menu) - }) - .on_menu_event(|app, event| { - match event.id().as_ref() { - AETHER_FIND_MENU_ID => { - let _ = app.emit(AETHER_FIND_REQUESTED_EVENT, ()); - } - AETHER_FOCUS_ADDRESS_MENU_ID => { - let _ = app.emit(AETHER_SHORTCUT_EVENT, "focus-address"); - } - AETHER_NEW_TAB_MENU_ID => { - let _ = app.emit(AETHER_SHORTCUT_EVENT, "new-tab"); - } - AETHER_OPEN_DASHBOARD_MENU_ID => { - let _ = app.emit(AETHER_SHORTCUT_EVENT, "open-dashboard"); - } - AETHER_OPEN_ICE_MENU_ID => { - let _ = app.emit(AETHER_SHORTCUT_EVENT, "open-ice"); - } - AETHER_OPEN_BROWSER_MENU_ID => { - let _ = app.emit(AETHER_SHORTCUT_EVENT, "open-browser"); - } - AETHER_TOGGLE_AION_MENU_ID => { - let _ = app.emit(AETHER_SHORTCUT_EVENT, "toggle-aion"); - } - AETHER_CAPTURE_PAGE_MENU_ID => { - let _ = app.emit(AETHER_SHORTCUT_EVENT, "capture-page"); - } - _ => {} - } - }); - - let builder = builder.plugin(tauri_plugin_opener::init()); - #[cfg(target_os = "android")] - let builder = builder.plugin(android_tabs::init()); - - builder - .setup(|app| { - let app_data_dir = app.path().app_data_dir().expect("app data dir"); - app.manage(Backend::new(app_data_dir)); - #[cfg(desktop)] - if let Some(window) = app.get_window("main") { - let app_handle = app.handle().clone(); - window.on_window_event(move |event| { - if matches!( - event, - WindowEvent::Resized(_) | WindowEvent::ScaleFactorChanged { .. } - ) { - let state = app_handle.state::(); - let _ = resize_native_webviews(&app_handle, &state); - } - }); - } - #[cfg(desktop)] - { - let app_handle = app.handle().clone(); - let state = app_handle.state::(); - if let Ok(active_tab_id) = active_tab_id(&state) { - if let Err(error) = ensure_native_webview(&app_handle, &state, &active_tab_id) { - eprintln!("Æther browser webview prewarm failed: {error}"); - } - } - prewarm_local_models(&app_handle); - } - Ok(()) - }) - .invoke_handler(tauri::generate_handler![ - aether_state, - aether_apps_list, - aether_apps_activate, - aether_apps_navigate, - aether_apps_go_back, - aether_apps_go_forward, - aether_tabs_list, - aether_tabs_create, - aether_tabs_activate, - aether_tabs_close, - aether_tabs_navigate, - aether_tabs_scroll_to_text, - aether_tabs_find, - aether_tabs_go_back, - aether_tabs_go_forward, - aether_tabs_report_native_event, - aether_tabs_thumbnail, - aether_layout_window_insets, - aether_layout_set_mobile_tab_bounds, - aether_dashboard_open, - aether_hub_list, - aether_hub_create, - aether_hub_reorder, - aether_hub_delete, - aether_collections_list, - aether_collections_create, - aether_collections_update, - aether_collections_reorder, - aether_collections_delete, - aether_collections_captures, - aether_capture_current_page, - aether_capture_move, - aether_capture_delete, - aether_capture_suggest_hub, - aether_search_collection, - aether_semantic_trail_generate, - aether_flow_graph, - aether_air_prepare, - aether_air_render, - aether_air_list_recent, - aether_air_open, - aether_air_reveal, - aether_chat_ask, - aether_chat_cancel, - aether_crystallizer_generate, - aether_crystallizer_list_saved, - aether_crystallizer_get_saved, - aether_crystallizer_save, - aether_crystallizer_reorder_saved, - aether_crystallizer_delete_saved, - aether_system_status, - aether_system_settings, - aether_system_update_settings, - aether_system_update_models, - aether_system_check_for_update, - aether_system_download_models, - aether_system_open_external_url, - aether_layout_set_panel_collapsed, - aether_layout_set_modal_overlay_open, - aether_layout_show_status_toast - ]) - .build(tauri::generate_context!()) - .expect("error while building Æther") - .run(|_app_handle, _event| { - #[cfg(desktop)] - if let tauri::RunEvent::ExitRequested { .. } = _event { - force_exit(); - } - }); -} - -#[cfg(desktop)] -fn prewarm_local_models(app: &AppHandle) { - let state = app.state::(); - let paths = state.paths.clone(); - let runtime = Arc::clone(&state.native_runtime); - - tauri::async_runtime::spawn(async move { - let Ok(settings) = load_settings(&paths.settings_path).await else { - return; - }; - let catalog = model_catalog(&paths, &settings.local_model); - let chat_model = catalog.chat_model; - let embedding_model = catalog.embedding_model; - if chat_model.is_none() && embedding_model.is_none() { - return; - } - let result = task::spawn_blocking(move || { - let mut runtime = runtime - .lock() - .map_err(|_| "Local model runtime is unavailable.".to_string())?; - if let Some(model_path) = &chat_model { - runtime - .ensure_model(NativeModelKind::Chat, model_path) - .map_err(|error| { - format!("chat model {} failed: {error}", model_label(model_path)) - })?; - } - if let Some(model_path) = &embedding_model { - runtime.warm_embedding_model(model_path).map_err(|error| { - format!( - "embedding model {} failed: {error}", - model_label(model_path) - ) - })?; - } - Ok::<(), String>(()) - }) - .await; - - match result { - Ok(Ok(())) => {} - Ok(Err(error)) => eprintln!("Æther model prewarm failed: {error}"), - Err(error) => eprintln!("Æther model prewarm task failed: {error}"), - } - }); -} - -#[tauri::command] -fn aether_state(state: State) -> Cmd { - Ok(lock_tabs(&state)?.state()) -} - -#[tauri::command] -fn aether_apps_list(state: State) -> Cmd> { - Ok(lock_tabs(&state)?.apps()) -} - -#[tauri::command(rename_all = "camelCase")] -fn aether_apps_activate(app: AppHandle, state: State, app_id: String) -> Cmd<()> { - if app_id != "browser" { - return Err(format!("Unknown app: {app_id}")); - } - { - let mut tabs = lock_tabs(&state)?; - tabs.dashboard_open = false; - } - let active_tab_id = lock_tabs(&state)?.active_tab_id.clone(); - ensure_native_webview(&app, &state, &active_tab_id)?; - emit_state(&app, &state) -} - -#[tauri::command(rename_all = "camelCase")] -async fn aether_apps_navigate( - app: AppHandle, - state: State<'_, Backend>, - app_id: String, - url: String, -) -> Cmd<()> { - if app_id != "browser" { - return Err(format!("Unknown app: {app_id}")); - } - navigate_active_tab(&app, &state, &url).await -} - -#[tauri::command(rename_all = "camelCase")] -fn aether_apps_go_back(app: AppHandle, state: State, app_id: String) -> Cmd<()> { - if app_id != "browser" { - return Err(format!("Unknown app: {app_id}")); - } - aether_tabs_go_back(app, state, String::new()) -} - -#[tauri::command(rename_all = "camelCase")] -fn aether_apps_go_forward(app: AppHandle, state: State, app_id: String) -> Cmd<()> { - if app_id != "browser" { - return Err(format!("Unknown app: {app_id}")); - } - aether_tabs_go_forward(app, state, String::new()) -} - -#[tauri::command] -fn aether_tabs_list(state: State) -> Cmd> { - Ok(lock_tabs(&state)?.tabs()) -} - -#[tauri::command] -async fn aether_tabs_create( - app: AppHandle, - state: State<'_, Backend>, - input: Option, -) -> Cmd { - let settings = load_settings(&state.paths.settings_path).await?; - // No URL → open a blank start-page tab (Portals + search) rather than a search engine. - let requested_url = input.and_then(|input| input.url); - let url = match requested_url { - Some(raw_url) => normalize_url(&raw_url, &settings.browser.default_search_engine), - None => START_PAGE_URL.to_string(), - }; - let tab = ManagedTab::new("browser", &url); - let tab_id = tab.id.clone(); - let summary = tab.summary(true); - { - let mut tabs = lock_tabs(&state)?; - tabs.active_tab_id = tab.id.clone(); - tabs.active_app_id = tab.app_id.clone(); - tabs.dashboard_open = false; - tabs.tabs.push(tab); - } - ensure_native_webview(&app, &state, &tab_id)?; - emit_state(&app, &state)?; - Ok(summary) -} - -#[tauri::command(rename_all = "camelCase")] -fn aether_tabs_activate(app: AppHandle, state: State, tab_id: String) -> Cmd<()> { - { - let mut tabs = lock_tabs(&state)?; - if !tabs.tabs.iter().any(|tab| tab.id == tab_id) { - return Err(format!("Unknown tab: {tab_id}")); - } - tabs.active_tab_id = tab_id.clone(); - tabs.active_app_id = "browser".to_string(); - tabs.dashboard_open = false; - } - ensure_native_webview(&app, &state, &tab_id)?; - emit_state(&app, &state) -} - -#[tauri::command(rename_all = "camelCase")] -fn aether_tabs_close(app: AppHandle, state: State, tab_id: String) -> Cmd<()> { - let mut next_active_tab_id = None; - { - let mut tabs = lock_tabs(&state)?; - if tabs.tabs.len() == 1 { - return Ok(()); - } - let was_active = tabs.active_tab_id == tab_id; - tabs.tabs.retain(|tab| tab.id != tab_id); - if was_active { - if let Some(next_id) = tabs.tabs.last().map(|tab| tab.id.clone()) { - tabs.active_tab_id = next_id.clone(); - next_active_tab_id = Some(next_id); - } - } - } - close_native_webview(&app, &state, &tab_id)?; - if let Some(active_tab_id) = next_active_tab_id { - ensure_native_webview(&app, &state, &active_tab_id)?; - } else { - sync_native_webview_visibility(&app, &state)?; - } - emit_state(&app, &state) -} - -#[tauri::command(rename_all = "camelCase")] -async fn aether_tabs_navigate( - app: AppHandle, - state: State<'_, Backend>, - tab_id: String, - url: String, -) -> Cmd<()> { - let settings = load_settings(&state.paths.settings_path).await?; - let target_url = { - let mut tabs = lock_tabs(&state)?; - let tab = tabs - .tabs - .iter_mut() - .find(|tab| tab.id == tab_id) - .ok_or_else(|| format!("Unknown tab: {tab_id}"))?; - tab.navigate(&url, &settings.browser.default_search_engine); - let target_url = tab.url.clone(); - tabs.active_tab_id = tab.id.clone(); - tabs.dashboard_open = false; - target_url - }; - navigate_native_webview(&app, &state, &tab_id, &target_url)?; - emit_state(&app, &state) -} - -#[tauri::command(rename_all = "camelCase")] -fn aether_tabs_scroll_to_text( - app: AppHandle, - state: State, - tab_id: String, - text: String, -) -> Cmd<()> { - { - let tabs = lock_tabs(&state)?; - if !tabs.tabs.iter().any(|tab| tab.id == tab_id) { - return Err(format!("Unknown tab: {tab_id}")); - } - } - scroll_native_webview_to_text(&app, &state, &tab_id, &text) -} - -#[tauri::command(rename_all = "camelCase")] -fn aether_tabs_find( - app: AppHandle, - state: State, - tab_id: String, - query: Option, - action: Option, -) -> Cmd<()> { - let target_tab_id = { - let tabs = lock_tabs(&state)?; - if tab_id.is_empty() { - tabs.active_tab_id.clone() - } else if tabs.tabs.iter().any(|tab| tab.id == tab_id) { - tab_id - } else { - return Err(format!("Unknown tab: {tab_id}")); - } - }; - let action = action.as_deref().unwrap_or("find"); - find_native_webview_text(&app, &state, &target_tab_id, query.as_deref(), action) -} - -#[tauri::command(rename_all = "camelCase")] -fn aether_tabs_go_back(app: AppHandle, state: State, tab_id: String) -> Cmd<()> { - let mut restore_start_page = false; - let target_tab_id = { - let mut tabs = lock_tabs(&state)?; - let tab = if tab_id.is_empty() { - tabs.active_tab_mut() - .ok_or_else(|| "No active browser tab.".to_string())? - } else { - tabs.tabs - .iter_mut() - .find(|tab| tab.id == tab_id) - .ok_or_else(|| format!("Unknown tab: {tab_id}"))? - }; - // The start page is a renderer overlay, not a native page, so the webview can't - // navigate back to it. When the previous history entry is the start page, park - // the tab back on it (its webview is kept hidden for a later forward). - if tab.can_go_back() - && tab.history.get(tab.history_index - 1).map(String::as_str) == Some(START_PAGE_URL) - { - tab.history_index -= 1; - tab.url = START_PAGE_URL.to_string(); - tab.title = "New tab".to_string(); - tab.is_loading = false; - restore_start_page = true; - } - tab.id.clone() - }; - if restore_start_page { - sync_native_webview_visibility(&app, &state)?; - } else { - navigate_native_webview_history( - &app, - &state, - &target_tab_id, - WebviewHistoryDirection::Back, - )?; - } - emit_state(&app, &state) -} - -#[tauri::command(rename_all = "camelCase")] -fn aether_tabs_go_forward(app: AppHandle, state: State, tab_id: String) -> Cmd<()> { - let mut leave_start_page = false; - let target_tab_id = { - let mut tabs = lock_tabs(&state)?; - let tab = if tab_id.is_empty() { - tabs.active_tab_mut() - .ok_or_else(|| "No active browser tab.".to_string())? - } else { - tabs.tabs - .iter_mut() - .find(|tab| tab.id == tab_id) - .ok_or_else(|| format!("Unknown tab: {tab_id}"))? - }; - // Forwarding off the start page: advance to the real page whose (hidden) webview - // we kept, then reveal it instead of issuing a native history.forward(). - if tab.url == START_PAGE_URL && tab.can_go_forward() { - tab.history_index += 1; - tab.url = tab.history[tab.history_index].clone(); - tab.title = title_from_url(&tab.url); - tab.is_loading = false; - leave_start_page = true; - } - tab.id.clone() - }; - if leave_start_page { - ensure_native_webview(&app, &state, &target_tab_id)?; - } else { - navigate_native_webview_history( - &app, - &state, - &target_tab_id, - WebviewHistoryDirection::Forward, - )?; - } - emit_state(&app, &state) -} - -// Mobile-only feedback channel: the Kotlin TabsPlugin evaluates -// `window.__AETHER_TAB_EVENT__(...)` in the main webview and the renderer -// forwards the payload here, mirroring how desktop child-webview callbacks -// (on_navigation / on_page_load / on_document_title_changed) feed tab state. -#[tauri::command] -fn aether_tabs_report_native_event( - app: AppHandle, - state: State, - input: NativeTabEventInput, -) -> Cmd<()> { - match input.kind.as_str() { - "navigation" => { - let parked_on_start_page = { - let tabs = lock_tabs(&state)?; - tabs.tabs - .iter() - .find(|tab| tab.id == input.tab_id) - .map(|tab| tab.url == START_PAGE_URL) - .unwrap_or(true) - }; - // A tab parked on the start page keeps its (hidden) webview alive; - // ignore its stray events so the start-page sentinel survives. - if parked_on_start_page { - return Ok(()); - } - if let Some(url) = input.url.as_deref() { - update_tab_navigation_state( - &state, - &input.tab_id, - url, - input.is_loading.unwrap_or(false), - ); - } - { - let mut tabs = lock_tabs(&state)?; - if let Some(tab) = tabs.tabs.iter_mut().find(|tab| tab.id == input.tab_id) { - tab.native_can_go_back = input.can_go_back; - tab.native_can_go_forward = input.can_go_forward; - } - } - emit_state(&app, &state) - } - "title" => { - if let Some(title) = input.title.as_deref() { - update_tab_title(&state, &input.tab_id, title); - } - emit_state(&app, &state) - } - "find" => app - .emit( - AETHER_FIND_RESULT_EVENT, - FindResultPayload { - tab_id: input.tab_id, - current: input.current.unwrap_or(0), - total: input.total.unwrap_or(0), - }, - ) - .map_err(|error| error.to_string()), - _ => Ok(()), - } -} - -// Preview image (data-URI JPEG) for the mobile tab-grid switcher. Desktop -// renders live child webviews and never asks for one, so it returns None. -// Async on purpose: the Kotlin side resolves on the Android UI thread, so the -// blocking run_mobile_plugin call must sit on a tokio worker. -#[tauri::command(rename_all = "camelCase")] -async fn aether_tabs_thumbnail(app: AppHandle, tab_id: String) -> Cmd> { - #[cfg(target_os = "android")] - { - let response: android_tabs::ThumbnailResponse = app - .state::() - .run_for("thumbnail", android_tabs::TabPayload { tab_id: &tab_id })?; - return Ok(response.image); - } - #[allow(unreachable_code)] - { - let _ = (app, tab_id); - Ok(None) - } -} - -// System-bar/cutout insets (CSS px) for the edge-to-edge Android activity; -// zero on desktop, where the OS window frame handles this. Async for the same -// UI-thread reason as aether_tabs_thumbnail. -#[tauri::command] -async fn aether_layout_window_insets(app: AppHandle) -> Cmd { - #[cfg(target_os = "android")] - { - let response: android_tabs::InsetsResponse = app - .state::() - .run_for("insets", serde_json::json!({}))?; - return serde_json::to_value(response).map_err(|error| error.to_string()); - } - #[allow(unreachable_code)] - { - let _ = app; - serde_json::to_value(serde_json::json!({ - "top": 0.0, "bottom": 0.0, "left": 0.0, "right": 0.0 - })) - .map_err(|error| error.to_string()) - } -} - -// The renderer measures where Android tab WebViews belong (MobileTabView's -// bounding rect, CSS px) and reports it here; desktop computes bounds natively -// from the window size instead, so this is a no-op there. -#[tauri::command(rename_all = "camelCase")] -fn aether_layout_set_mobile_tab_bounds( - app: AppHandle, - state: State, - top: f64, - left: f64, - width: f64, - height: f64, -) -> Cmd<()> { - #[cfg(not(desktop))] - { - *state - .mobile_tab_bounds - .lock() - .map_err(|_| "Æther layout bounds are unavailable.".to_string())? = MobileTabBounds { - top, - left, - width, - height, - }; - return sync_native_webview_visibility(&app, &state); - } - #[allow(unreachable_code)] - { - let _ = (app, state, top, left, width, height); - Ok(()) - } -} - -#[tauri::command] -fn aether_dashboard_open(app: AppHandle, state: State) -> Cmd<()> { - { - let mut tabs = lock_tabs(&state)?; - tabs.dashboard_open = true; - } - sync_native_webview_visibility(&app, &state)?; - emit_state(&app, &state) -} - -#[tauri::command] -async fn aether_hub_list(state: State<'_, Backend>) -> Cmd> { - Ok(load_library(&state.paths.library_path).await?.shortcuts) -} - -#[tauri::command] -async fn aether_hub_create( - state: State<'_, Backend>, - input: CreateShortcutInput, -) -> Cmd { - let title = input.title.trim().to_string(); - if title.is_empty() { - return Err("Shortcut title is required.".to_string()); - } - let url = normalize_url(&input.url, "google"); - let mut data = load_library(&state.paths.library_path).await?; - let favicon = input - .favicon - .as_deref() - .map(str::trim) - .filter(|favicon| !favicon.is_empty()) - .map(str::to_string); - let theme_color = input.theme_color.as_deref().and_then(normalize_theme_color); - if let Some(existing) = data - .shortcuts - .iter_mut() - .find(|shortcut| shortcut.url == url) - { - let mut changed = false; - if existing.favicon.is_none() && favicon.is_some() { - existing.favicon = favicon; - changed = true; - } - if existing.theme_color.is_none() && theme_color.is_some() { - existing.theme_color = theme_color; - changed = true; - } - let shortcut = existing.clone(); - if changed { - save_json(&state.paths.library_path, &data).await?; - } - return Ok(shortcut); - } - let shortcut = HubShortcutSummary { - id: uuid(), - title, - host: get_tab_host(&url), - url, - created_at: now(), - favicon, - theme_color, - }; - data.shortcuts.insert(0, shortcut.clone()); - save_json(&state.paths.library_path, &data).await?; - Ok(shortcut) -} - -#[tauri::command] -async fn aether_hub_reorder( - state: State<'_, Backend>, - ids: Vec, -) -> Cmd> { - let mut data = load_library(&state.paths.library_path).await?; - data.shortcuts = reorder(data.shortcuts, &ids, |shortcut| &shortcut.id); - save_json(&state.paths.library_path, &data).await?; - Ok(data.shortcuts) -} - -#[tauri::command] -async fn aether_hub_delete(state: State<'_, Backend>, id: String) -> Cmd<()> { - let mut data = load_library(&state.paths.library_path).await?; - data.shortcuts.retain(|shortcut| shortcut.id != id); - save_json(&state.paths.library_path, &data).await -} - -#[tauri::command] -async fn aether_collections_list(state: State<'_, Backend>) -> Cmd> { - Ok(load_library(&state.paths.library_path).await?.collections) -} - -#[tauri::command] -async fn aether_collections_create( - state: State<'_, Backend>, - input: CreateCollectionInput, -) -> Cmd { - let name = input.name.trim().to_string(); - if name.is_empty() { - return Err("Collection name is required.".to_string()); - } - let mut data = load_library(&state.paths.library_path).await?; - let now = now(); - let existing = data - .collections - .iter() - .map(|collection| collection.id.clone()) - .collect::>(); - let collection = CollectionSummary { - id: unique_slug(&name, &existing), - name, - description: input.description.unwrap_or_default().trim().to_string(), - icon: Some(input.icon.unwrap_or_else(|| "book".to_string())) - .map(|icon| icon.trim().to_string()) - .filter(|icon| !icon.is_empty()), - created_at: now.clone(), - updated_at: now, - capture_count: 0, - chunk_count: 0, - }; - data.collections.push(collection.clone()); - save_json(&state.paths.library_path, &data).await?; - Ok(collection) -} - -#[tauri::command] -async fn aether_collections_update( - state: State<'_, Backend>, - input: UpdateCollectionInput, -) -> Cmd { - let mut data = load_library(&state.paths.library_path).await?; - let collection = data - .collections - .iter_mut() - .find(|collection| collection.id == input.id) - .ok_or_else(|| "Collection not found.".to_string())?; - if let Some(name) = input.name { - let name = name.trim(); - if name.is_empty() { - return Err("Collection name is required.".to_string()); - } - collection.name = name.to_string(); - } - if let Some(description) = input.description { - collection.description = description.trim().to_string(); - } - if let Some(icon) = input.icon { - collection.icon = Some(icon.trim().to_string()).filter(|icon| !icon.is_empty()); - } - collection.updated_at = now(); - let updated = collection.clone(); - save_json(&state.paths.library_path, &data).await?; - Ok(updated) -} - -#[tauri::command] -async fn aether_collections_reorder( - state: State<'_, Backend>, - ids: Vec, -) -> Cmd> { - let mut data = load_library(&state.paths.library_path).await?; - data.collections = reorder(data.collections, &ids, |collection| &collection.id); - save_json(&state.paths.library_path, &data).await?; - Ok(data.collections) -} - -#[tauri::command] -async fn aether_collections_delete(state: State<'_, Backend>, id: String) -> Cmd<()> { - let mut library = load_library(&state.paths.library_path).await?; - library.collections.retain(|collection| collection.id != id); - library - .captures - .retain(|capture| capture.collection_id != id); - save_json(&state.paths.library_path, &library).await?; - - with_vectors_mut(&state, |vectors| { - vectors.chunks.retain(|chunk| chunk.collection_id != id); - }) - .await -} - -#[tauri::command(rename_all = "camelCase")] -async fn aether_collections_captures( - state: State<'_, Backend>, - collection_id: String, -) -> Cmd> { - let mut captures = load_library(&state.paths.library_path) - .await? - .captures - .into_iter() - .filter(|capture| capture.collection_id == collection_id) - .collect::>(); - captures.sort_by(|left, right| right.captured_at.cmp(&left.captured_at)); - Ok(captures) -} - -#[tauri::command] -async fn aether_capture_current_page( - app: AppHandle, - state: State<'_, Backend>, - input: CaptureCurrentPageInput, -) -> Cmd { - let settings = load_settings(&state.paths.settings_path).await?; - let mut library = load_library(&state.paths.library_path).await?; - let collection = library - .collections - .iter() - .find(|collection| collection.id == input.collection_id) - .cloned() - .ok_or_else(|| "Collection not found.".to_string())?; - emit_capture_progress(&app, "Reading current page", None, None); - let active_tab = { - let tabs = lock_tabs(&state)?; - if tabs.dashboard_open { - return Err("Open a website before capturing into a collection.".to_string()); - } - tabs.active_tab() - .cloned() - .ok_or_else(|| "No active browser tab.".to_string())? - }; - let captured = extract_readable_active_page(&state, &active_tab).await?; - emit_capture_progress(&app, "Chunking readable text", None, None); - let captured_key = normalize_capture_url_key(&captured.url); - if library.captures.iter().any(|capture| { - capture.collection_id == collection.id - && normalize_capture_url_key(&capture.url) == captured_key - }) { - return Err(format!("Page is already in {}.", collection.name)); - } - - let (chunk_size, chunk_overlap) = capture_chunk_settings(&state.paths, &settings); - let chunks = split_text(&captured.text, chunk_size, chunk_overlap); - if chunks.is_empty() { - return Err("No readable text found on the current page.".to_string()); - } - emit_capture_progress( - &app, - &format!("Embedding {} chunks", chunks.len()), - Some(0), - Some(chunks.len()), - ); - let embeddings = local_embed_with_progress( - &state, - &settings, - chunks.clone(), - Some(EmbeddingProgress { - app: app.clone(), - message: "Embedding chunks".to_string(), - }), - ) - .await?; - if embeddings.len() != chunks.len() { - return Err( - "Local embedding model returned an unexpected number of embeddings.".to_string(), - ); - } - emit_capture_progress( - &app, - "Saving capture", - Some(chunks.len()), - Some(chunks.len()), - ); - - let capture_id = uuid(); - let captured_at = now(); - let records = chunks - .into_iter() - .enumerate() - .map(|(index, text)| ChunkRecord { - id: uuid(), - vector: embeddings[index].clone(), - text, - collection_id: collection.id.clone(), - capture_id: capture_id.clone(), - title: captured.title.clone(), - url: captured.url.clone(), - app_id: active_tab.app_id.clone(), - captured_at: captured_at.clone(), - chunk_index: index, - }) - .collect::>(); - - with_vectors_mut(&state, |vectors| { - vectors.chunks.extend(records.iter().cloned()); - }) - .await?; - - let capture = CaptureSummary { - id: capture_id, - collection_id: collection.id.clone(), - title: captured.title, - url: captured.url, - app_id: active_tab.app_id, - captured_at, - chunk_count: records.len(), - metadata: None, - }; - library.captures.push(capture.clone()); - if let Some(stored_collection) = library - .collections - .iter_mut() - .find(|item| item.id == collection.id) - { - stored_collection.capture_count += 1; - stored_collection.chunk_count += records.len(); - stored_collection.updated_at = capture.captured_at.clone(); - } - save_json(&state.paths.library_path, &library).await?; - - Ok(CaptureResult { - capture, - collection_name: collection.name, - }) -} - -#[tauri::command] -async fn aether_capture_move( - state: State<'_, Backend>, - input: MoveCaptureInput, -) -> Cmd { - let mut library = load_library(&state.paths.library_path).await?; - let now = now(); - let target_exists = library - .collections - .iter() - .any(|collection| collection.id == input.collection_id); - if !target_exists { - return Err("Target collection not found.".to_string()); - } - let capture = library - .captures - .iter_mut() - .find(|capture| capture.id == input.capture_id) - .ok_or_else(|| "Capture not found.".to_string())?; - if capture.collection_id == input.collection_id { - return Ok(capture.clone()); - } - let source_collection_id = capture.collection_id.clone(); - let chunk_count = capture.chunk_count; - capture.collection_id = input.collection_id.clone(); - let moved = capture.clone(); - for collection in &mut library.collections { - if collection.id == source_collection_id { - collection.capture_count = collection.capture_count.saturating_sub(1); - collection.chunk_count = collection.chunk_count.saturating_sub(chunk_count); - collection.updated_at = now.clone(); - } - if collection.id == input.collection_id { - collection.capture_count += 1; - collection.chunk_count += chunk_count; - collection.updated_at = now.clone(); - } - } - save_json(&state.paths.library_path, &library).await?; - - with_vectors_mut(&state, |vectors| { - for chunk in &mut vectors.chunks { - if chunk.capture_id == input.capture_id { - chunk.collection_id = input.collection_id.clone(); - } - } - }) - .await?; - Ok(moved) -} - -#[tauri::command(rename_all = "camelCase")] -async fn aether_capture_delete(state: State<'_, Backend>, capture_id: String) -> Cmd<()> { - let mut library = load_library(&state.paths.library_path).await?; - let deleted = library - .captures - .iter() - .find(|capture| capture.id == capture_id) - .cloned(); - library.captures.retain(|capture| capture.id != capture_id); - if let Some(deleted) = deleted { - if let Some(collection) = library - .collections - .iter_mut() - .find(|collection| collection.id == deleted.collection_id) - { - collection.capture_count = collection.capture_count.saturating_sub(1); - collection.chunk_count = collection.chunk_count.saturating_sub(deleted.chunk_count); - collection.updated_at = now(); - } - } - save_json(&state.paths.library_path, &library).await?; - with_vectors_mut(&state, |vectors| { - vectors - .chunks - .retain(|chunk| chunk.capture_id != capture_id); - }) - .await -} - -#[tauri::command] -async fn aether_search_collection( - state: State<'_, Backend>, - input: SearchCollectionInput, -) -> Cmd> { - search_collection(&state, input).await -} - -#[tauri::command] -async fn aether_semantic_trail_generate( - state: State<'_, Backend>, - input: Option, -) -> Cmd { - semantic_trail_generate( - &state, - input.unwrap_or(SemanticTrailInput { - query: None, - limit: None, - }), - ) - .await -} - -#[tauri::command] -async fn aether_flow_graph( - state: State<'_, Backend>, - input: Option, -) -> Cmd { - flow_graph_generate( - &state, - input.unwrap_or(FlowGraphInput { - query: None, - source_limit: None, - }), - ) - .await -} - -#[tauri::command] -async fn aether_air_prepare( - state: State<'_, Backend>, - input: AirDossierInput, -) -> Cmd { - air_prepare_dossier(&state, input, false).await -} - -#[tauri::command] -async fn aether_air_render( - state: State<'_, Backend>, - input: AirDossierInput, -) -> Cmd { - let prepared = air_prepare_dossier(&state, input, true).await?; - let output_dir = resolve_air_export_dir(&state.paths, true).await?; - let filename = air_dossier_filename(&prepared.title, &prepared.generated_at); - let path = output_dir.join(filename); - tokio::fs::write(&path, prepared.markdown_preview.as_bytes()) - .await - .map_err(|error| error.to_string())?; - Ok(AirRenderResult { - path: path.display().to_string(), - filename: path - .file_name() - .and_then(|name| name.to_str()) - .unwrap_or("aether-dossier.md") - .to_string(), - title: prepared.title, - source_count: prepared.sources.len(), - rendered_at: prepared.generated_at, - }) -} - -#[tauri::command] -async fn aether_air_list_recent(state: State<'_, Backend>) -> Cmd> { - air_list_recent(&state.paths).await -} - -#[tauri::command(rename_all = "camelCase")] -fn aether_air_open(app: AppHandle, path: String) -> Cmd<()> { - let path = PathBuf::from(path); - if !path.exists() { - return Err("AiR file not found.".to_string()); - } - app.opener() - .open_path(path.display().to_string(), None::) - .map_err(|error| error.to_string()) -} - -#[tauri::command(rename_all = "camelCase")] -fn aether_air_reveal(app: AppHandle, path: String) -> Cmd<()> { - let path = PathBuf::from(path); - if !path.exists() { - return Err("AiR file not found.".to_string()); - } - app.opener() - .reveal_item_in_dir(path) - .map_err(|error| error.to_string()) -} - -#[tauri::command] -async fn aether_capture_suggest_hub( - state: State<'_, Backend>, -) -> Cmd> { - suggest_capture_hub(&state).await -} - -#[tauri::command] -async fn aether_chat_ask( - app: AppHandle, - state: State<'_, Backend>, - input: AskChatInput, -) -> Cmd { - let prompt = input.prompt.trim().to_string(); - if prompt.is_empty() { - return Err("Enter a question before asking Æther.".to_string()); - } - state - .generation_cancelled - .store(false, AtomicOrdering::Relaxed); - let stream = ChatStreamEmitter { - app, - request_id: input.request_id.clone().unwrap_or_else(uuid), - }; - let settings = load_settings(&state.paths.settings_path).await?; - let mut citations = if let Some(collection_id) = input.collection_id.clone() { - stream.status("Searching your knowledge hub"); - search_collection( - &state, - SearchCollectionInput { - collection_id, - query: prompt.clone(), - limit: Some(8), - }, - ) - .await? - } else { - Vec::new() - }; - - if input.include_current_page.unwrap_or(false) { - stream.status("Reading current page"); - if let Ok(active_url) = active_tab_url(&state) { - let active_tab = { - let tabs = lock_tabs(&state)?; - tabs.active_tab().cloned() - }; - let captured = if let Some(active_tab) = active_tab { - extract_readable_active_page(&state, &active_tab).await.ok() - } else { - extract_readable_page(&state.client, &active_url).await.ok() - }; - if let Some(captured) = captured { - // Give the current page fewer slots when a hub is also in play so the - // hub still contributes; let it use the full budget on its own. - let page_limit = if input.collection_id.is_some() { - 3 - } else { - chat_citation_limit() - }; - let page_citations = current_page_citations( - &state, - &settings, - captured, - &prompt, - input.collection_id.as_deref(), - page_limit, - ) - .await; - // Prepend so the current page takes priority over hub matches. - citations.splice(0..0, page_citations); - } - } - } - let citations = dedupe_citations(citations) - .into_iter() - .take(chat_citation_limit()) - .collect::>(); - local_chat(&state, &settings, &prompt, citations, Some(stream)).await -} - -#[tauri::command] -async fn aether_crystallizer_generate( - state: State<'_, Backend>, - input: GenerateIcebergInput, -) -> Cmd { - let topic = input.keyword.trim().to_string(); - if topic.is_empty() { - return Err("Enter a topic before crystallizing.".to_string()); - } - state - .generation_cancelled - .store(false, AtomicOrdering::Relaxed); - let settings = load_settings(&state.paths.settings_path).await?; - local_generate_iceberg(&state, &settings, &topic).await -} - -#[tauri::command] -fn aether_chat_cancel(state: State) -> Cmd<()> { - state - .generation_cancelled - .store(true, AtomicOrdering::Relaxed); - Ok(()) -} - -#[tauri::command] -async fn aether_crystallizer_list_saved( - state: State<'_, Backend>, -) -> Cmd> { - Ok(load_icebergs(&state.paths.icebergs_path) - .await? - .icebergs - .iter() - .map(saved_iceberg_summary) - .collect()) -} - -#[tauri::command(rename_all = "camelCase")] -async fn aether_crystallizer_get_saved(state: State<'_, Backend>, id: String) -> Cmd { - load_icebergs(&state.paths.icebergs_path) - .await? - .icebergs - .into_iter() - .find(|iceberg| iceberg.id == id) - .ok_or_else(|| "Saved iceberg not found.".to_string()) -} - -#[tauri::command] -async fn aether_crystallizer_save( - state: State<'_, Backend>, - input: SaveIcebergInput, -) -> Cmd { - let title = input.title.trim().to_string(); - let keyword = input.keyword.trim().to_string(); - let model = input.model.trim().to_string(); - let generated_at = input.generated_at.trim().to_string(); - let items = normalize_saved_items(input.items); - if title.is_empty() { - return Err("Iceberg title is required.".to_string()); - } - if keyword.is_empty() { - return Err("Iceberg keyword is required.".to_string()); - } - if model.is_empty() { - return Err("Iceberg model is required.".to_string()); - } - if generated_at.is_empty() { - return Err("Iceberg generation time is required.".to_string()); - } - if items.is_empty() { - return Err("Iceberg has no usable items to save.".to_string()); - } - let now = now(); - let iceberg = SavedIceberg { - iceberg: IcebergResult { - keyword, - model, - items, - generated_at, - }, - id: uuid(), - title, - icon: normalize_iceberg_icon(input.icon), - saved_at: now.clone(), - updated_at: now, - }; - let mut data = load_icebergs(&state.paths.icebergs_path).await?; - data.icebergs.insert(0, iceberg.clone()); - save_json(&state.paths.icebergs_path, &data).await?; - Ok(iceberg) -} - -#[tauri::command] -async fn aether_crystallizer_reorder_saved( - state: State<'_, Backend>, - ids: Vec, -) -> Cmd> { - let mut data = load_icebergs(&state.paths.icebergs_path).await?; - data.icebergs = reorder(data.icebergs, &ids, |iceberg| &iceberg.id); - let summaries = data.icebergs.iter().map(saved_iceberg_summary).collect(); - save_json(&state.paths.icebergs_path, &data).await?; - Ok(summaries) -} - -#[tauri::command(rename_all = "camelCase")] -async fn aether_crystallizer_delete_saved(state: State<'_, Backend>, id: String) -> Cmd<()> { - let mut data = load_icebergs(&state.paths.icebergs_path).await?; - data.icebergs.retain(|iceberg| iceberg.id != id); - save_json(&state.paths.icebergs_path, &data).await -} - -#[tauri::command] -async fn aether_system_status(state: State<'_, Backend>) -> Cmd { - system_status(&state).await -} - -#[tauri::command] -async fn aether_system_settings(state: State<'_, Backend>) -> Cmd { - let settings = load_settings(&state.paths.settings_path).await?; - Ok(AppSettings { - browser: settings.browser, - developer_mode: settings.developer_mode, - updates: settings.updates, - }) -} - -#[tauri::command] -async fn aether_system_update_settings( - state: State<'_, Backend>, - input: UpdateSettingsInput, -) -> Cmd { - let mut settings = load_settings(&state.paths.settings_path).await?; - if let Some(browser) = input.browser { - if let Some(default_search_engine) = browser.default_search_engine { - settings.browser.default_search_engine = - normalize_search_engine_id(&default_search_engine); - } - } - if let Some(developer_mode) = input.developer_mode { - settings.developer_mode = developer_mode; - } - if let Some(updates) = input.updates { - if let Some(auto_check) = updates.auto_check { - settings.updates.auto_check = auto_check; - } - } - save_json(&state.paths.settings_path, &settings).await?; - Ok(AppSettings { - browser: settings.browser, - developer_mode: settings.developer_mode, - updates: settings.updates, - }) -} - -#[tauri::command] -async fn aether_system_check_for_update(state: State<'_, Backend>) -> Cmd { - let checked_at = now(); - let current_version = env!("CARGO_PKG_VERSION").to_string(); - - let request = state - .client - .get(AETHER_RELEASES_API_URL) - .header("Accept", "application/vnd.github+json") - .header("X-GitHub-Api-Version", "2022-11-28") - .header("User-Agent", "AETHER-update-checker"); - let response = match request.send().await { - Ok(response) => response, - Err(error) => { - persist_update_last_checked_at(&state.paths, &checked_at).await?; - return Ok(UpdateCheckResult { - current_version, - checked_at, - update_available: false, - latest_version: None, - latest_name: None, - release_url: None, - release_notes: None, - published_at: None, - error: Some(format!("Could not reach GitHub Releases: {error}")), - }); - } - }; - - if response.status().as_u16() == 404 { - persist_update_last_checked_at(&state.paths, &checked_at).await?; - return Ok(UpdateCheckResult { - current_version, - checked_at, - update_available: false, - latest_version: None, - latest_name: None, - release_url: None, - release_notes: None, - published_at: None, - error: Some("No published GitHub release found yet.".to_string()), - }); - } - - if !response.status().is_success() { - persist_update_last_checked_at(&state.paths, &checked_at).await?; - return Ok(UpdateCheckResult { - current_version, - checked_at, - update_available: false, - latest_version: None, - latest_name: None, - release_url: None, - release_notes: None, - published_at: None, - error: Some(format!( - "GitHub Releases returned HTTP {}.", - response.status().as_u16() - )), - }); - } - - let release = match response.json::().await { - Ok(release) => release, - Err(error) => { - persist_update_last_checked_at(&state.paths, &checked_at).await?; - return Ok(UpdateCheckResult { - current_version, - checked_at, - update_available: false, - latest_version: None, - latest_name: None, - release_url: None, - release_notes: None, - published_at: None, - error: Some(format!("Could not read GitHub release metadata: {error}")), - }); - } - }; - let latest_version = release_version_from_tag(&release.tag_name); - let update_available = version_is_newer(&latest_version, ¤t_version); - persist_update_last_checked_at(&state.paths, &checked_at).await?; - - Ok(UpdateCheckResult { - current_version, - checked_at, - update_available, - latest_version: Some(latest_version), - latest_name: release.name.or_else(|| Some(release.tag_name)), - release_url: Some(release.html_url), - release_notes: release - .body - .map(|body| body.trim().chars().take(1400).collect::()) - .filter(|body| !body.is_empty()), - published_at: release.published_at, - error: None, - }) -} - -#[tauri::command] -async fn aether_system_update_models( - state: State<'_, Backend>, - input: UpdateModelsInput, -) -> Cmd { - let mut settings = load_settings(&state.paths.settings_path).await?; - if let Some(model) = input.embedding_model { - settings.local_model.embedding_model = - Some(model.trim().to_string()).filter(|item| !item.is_empty()); - } - if let Some(model) = input.chat_model { - settings.local_model.chat_model = - Some(model.trim().to_string()).filter(|item| !item.is_empty()); - } - save_json(&state.paths.settings_path, &settings).await?; - system_status(&state).await -} - -#[tauri::command] -async fn aether_system_download_models( - app: AppHandle, - state: State<'_, Backend>, - input: DownloadModelsInput, -) -> Cmd { - download_managed_models(&app, &state, input).await?; - system_status(&state).await -} - -#[tauri::command(rename_all = "camelCase")] -fn aether_system_open_external_url(app: AppHandle, url: String) -> Cmd<()> { - let parsed = Url::parse(url.trim()).map_err(|_| "Invalid release URL.".to_string())?; - match parsed.scheme() { - "https" | "http" => app - .opener() - .open_url(parsed.to_string(), None::) - .map_err(|error| error.to_string()), - _ => Err("Only web URLs can be opened from Settings.".to_string()), - } -} - -#[tauri::command(rename_all = "camelCase")] -fn aether_layout_set_panel_collapsed( - app: AppHandle, - state: State, - collapsed: bool, -) -> Cmd<()> { - { - let mut tabs = lock_tabs(&state)?; - tabs.panel_collapsed = collapsed; - } - sync_native_webview_visibility(&app, &state)?; - emit_state(&app, &state) -} - -#[tauri::command(rename_all = "camelCase")] -fn aether_layout_set_modal_overlay_open( - app: AppHandle, - state: State, - open: bool, -) -> Cmd<()> { - { - let mut tabs = lock_tabs(&state)?; - tabs.modal_overlay_open = open; - } - sync_native_webview_visibility(&app, &state) -} - -#[tauri::command] -fn aether_layout_show_status_toast(input: StatusToastInput) -> Cmd<()> { - let _ = (input.message, input.tone, input.duration_ms); - Ok(()) -} - -fn emit_capture_progress( - app: &AppHandle, - message: impl Into, - current: Option, - total: Option, -) { - let _ = app.emit( - "aether:capture-progress", - CaptureProgress { - message: message.into(), - current, - total, - }, - ); -} - -async fn navigate_active_tab(app: &AppHandle, state: &State<'_, Backend>, url: &str) -> Cmd<()> { - let settings = load_settings(&state.paths.settings_path).await?; - let (tab_id, target_url) = { - let mut tabs = lock_tabs(state)?; - let tab = tabs - .active_tab_mut() - .ok_or_else(|| "No active browser tab.".to_string())?; - tab.navigate(url, &settings.browser.default_search_engine); - let result = (tab.id.clone(), tab.url.clone()); - tabs.dashboard_open = false; - result - }; - navigate_native_webview(app, state, &tab_id, &target_url)?; - emit_state(app, state) -} - -async fn search_collection( - state: &State<'_, Backend>, - input: SearchCollectionInput, -) -> Cmd> { - let query = input.query.trim().to_string(); - if query.is_empty() { - return Ok(Vec::new()); - } - get_collection(&state.paths.library_path, &input.collection_id).await?; - let settings = load_settings(&state.paths.settings_path).await?; - let query_vector = local_embed_query(state, &settings, query).await?; - with_vectors_read(state, |vectors| { - let mut scored = vectors - .chunks - .iter() - .filter(|chunk| chunk.collection_id == input.collection_id) - .map(|chunk| (cosine_distance(&query_vector, &chunk.vector), chunk)) - .collect::>(); - scored.sort_by(|left, right| left.0.partial_cmp(&right.0).unwrap_or(Ordering::Equal)); - scored.truncate(input.limit.unwrap_or(8)); - scored - .into_iter() - .map(|(score, chunk)| SearchResult { - score, - id: chunk.id.clone(), - collection_id: chunk.collection_id.clone(), - capture_id: chunk.capture_id.clone(), - app_id: chunk.app_id.clone(), - title: chunk.title.clone(), - url: chunk.url.clone(), - captured_at: chunk.captured_at.clone(), - chunk_index: chunk.chunk_index, - text: chunk.text.clone(), - }) - .collect::>() - }) - .await -} - -#[derive(Clone)] -struct SemanticTrailChunkCandidate { - chunk: ChunkRecord, - collection_name: String, - score: SemanticTrailScoreBreakdown, - reasons: Vec, -} - -#[derive(Clone)] -struct FlowSourceCandidate { - capture: CaptureSummary, - collection_name: String, - vector: Vec, - excerpt: String, -} - -struct FlowEdgeCandidate { - from: String, - to: String, - weight: f64, -} - -async fn semantic_trail_generate( - state: &State<'_, Backend>, - input: SemanticTrailInput, -) -> Cmd { - let limit = input - .limit - .unwrap_or(DEFAULT_SEMANTIC_TRAIL_LIMIT) - .clamp(1, MAX_SEMANTIC_TRAIL_LIMIT); - let explicit_query = input - .query - .as_deref() - .map(str::trim) - .filter(|query| !query.is_empty()); - let (root, visible_query, embedding_query, root_url_key) = - if let Some(query) = explicit_query { - ( - SemanticTrailRoot { - title: query.to_string(), - url: String::new(), - host: String::new(), - excerpt: "Custom Focus lens matching captured sources across your knowledge hubs." - .to_string(), - }, - query.to_string(), - query.to_string(), - None, - ) - } else { - let active_tab = { - let tabs = lock_tabs(state)?; - if tabs.dashboard_open { - return Err("Open a web page before building Flow.".to_string()); - } - tabs.active_tab() - .cloned() - .ok_or_else(|| "No active browser tab.".to_string())? - }; - let captured = extract_readable_active_page(state, &active_tab).await?; - let root_host = get_tab_host(&captured.url); - let root_url_key = normalize_capture_url_key(&captured.url); - ( - SemanticTrailRoot { - title: captured.title.clone(), - url: captured.url.clone(), - host: root_host, - excerpt: semantic_trail_excerpt(&captured.text, 420), - }, - captured.title.clone(), - semantic_trail_default_query(&captured), - Some(root_url_key), - ) - }; - - let library = load_library(&state.paths.library_path).await?; - let collection_names = library - .collections - .iter() - .map(|collection| (collection.id.clone(), collection.name.clone())) - .collect::>(); - let root_collection_ids = root_url_key - .as_deref() - .map(|key| { - library - .captures - .iter() - .filter(|capture| normalize_capture_url_key(&capture.url) == key) - .map(|capture| capture.collection_id.clone()) - .collect::>() - }) - .unwrap_or_default(); - let chunks = with_vectors_read(state, |vectors| vectors.chunks.clone()).await?; - - if chunks.is_empty() { - return Ok(SemanticTrailResult { - query: visible_query, - generated_at: now(), - root, - items: Vec::new(), - edges: Vec::new(), - }); - } - - let settings = load_settings(&state.paths.settings_path).await?; - let query_vector = local_embed_query(state, &settings, embedding_query).await?; - - let mut candidates = chunks - .into_iter() - .filter_map(|chunk| { - let distance = cosine_distance(&query_vector, &chunk.vector); - if !distance.is_finite() { - return None; - } - let same_collection = root_collection_ids.contains(&chunk.collection_id); - let score = semantic_trail_score_breakdown(distance, &chunk.captured_at); - if score.semantic < SEMANTIC_TRAIL_MIN_SCORE { - return None; - } - let reasons = semantic_trail_reasons(&score, same_collection); - let collection_name = collection_names - .get(&chunk.collection_id) - .cloned() - .unwrap_or_else(|| "Knowledge Hub".to_string()); - Some(SemanticTrailChunkCandidate { - chunk, - collection_name, - score, - reasons, - }) - }) - .collect::>(); - - candidates.sort_by(|left, right| { - right - .score - .total - .partial_cmp(&left.score.total) - .unwrap_or(Ordering::Equal) - .then_with(|| { - right - .score - .semantic - .partial_cmp(&left.score.semantic) - .unwrap_or(Ordering::Equal) - }) - }); - - let items = semantic_trail_items(candidates, limit); - let edges = semantic_trail_edges(&root, &items); - - Ok(SemanticTrailResult { - query: visible_query, - generated_at: now(), - root, - items, - edges, - }) -} - -// Rank the user's hubs against the active page so capture can silently pre-select the best -// home for it. Best-effort: any reason we cannot produce a confident match returns Ok(None) -// rather than an error, so a failed suggestion never blocks or interrupts capturing. -async fn suggest_capture_hub( - state: &State<'_, Backend>, -) -> Cmd> { - let active_tab = { - let tabs = lock_tabs(state)?; - if tabs.dashboard_open { - return Ok(None); - } - match tabs.active_tab().cloned() { - Some(tab) => tab, - None => return Ok(None), - } - }; - - let captured = match extract_readable_active_page(state, &active_tab).await { - Ok(page) => page, - Err(_) => return Ok(None), - }; - - let library = load_library(&state.paths.library_path).await?; - if library.collections.is_empty() { - return Ok(None); - } - let chunks = with_vectors_read(state, |vectors| vectors.chunks.clone()).await?; - if chunks.is_empty() { - return Ok(None); - } - - let settings = load_settings(&state.paths.settings_path).await?; - let embedding_query = semantic_trail_default_query(&captured); - let query_vector = match local_embed_query(state, &settings, embedding_query).await { - Ok(vector) => vector, - Err(_) => return Ok(None), - }; - - // A hub is a strong home for this page if it already holds a source whose meaning is - // close to it, so score each hub by its single closest chunk. - let mut best_by_collection: HashMap = HashMap::new(); - for chunk in &chunks { - let distance = cosine_distance(&query_vector, &chunk.vector); - if !distance.is_finite() { - continue; - } - let semantic = semantic_score_from_distance(distance); - let entry = best_by_collection - .entry(chunk.collection_id.clone()) - .or_insert((0.0, String::new())); - if semantic > entry.0 { - entry.0 = semantic; - entry.1 = chunk.title.clone(); - } - } - - let Some((collection_id, (confidence, sample_title))) = - best_by_collection.into_iter().max_by(|left, right| { - left.1.0.partial_cmp(&right.1.0).unwrap_or(Ordering::Equal) - }) - else { - return Ok(None); - }; - - if confidence < CAPTURE_SUGGEST_MIN_SCORE { - return Ok(None); - } - - let collection_name = library - .collections - .iter() - .find(|collection| collection.id == collection_id) - .map(|collection| collection.name.clone()) - .unwrap_or_else(|| "Knowledge Hub".to_string()); - - Ok(Some(CaptureHubSuggestion { - collection_id, - collection_name, - confidence: round_score(confidence), - sample_title, - })) -} - -async fn flow_graph_generate( - state: &State<'_, Backend>, - input: FlowGraphInput, -) -> Cmd { - let query = input.query.unwrap_or_default().trim().to_string(); - let source_limit = input - .source_limit - .unwrap_or(DEFAULT_FLOW_GRAPH_SOURCE_LIMIT) - .clamp(1, MAX_FLOW_GRAPH_SOURCE_LIMIT); - let library = load_library(&state.paths.library_path).await?; - let collection_names = library - .collections - .iter() - .map(|collection| (collection.id.clone(), collection.name.clone())) - .collect::>(); - let chunks = with_vectors_read(state, |vectors| vectors.chunks.clone()).await?; - let mut chunks_by_capture = HashMap::>::new(); - for chunk in chunks { - chunks_by_capture - .entry(chunk.capture_id.clone()) - .or_default() - .push(chunk); - } - - let mut captures = library.captures.clone(); - captures.sort_by(|left, right| right.captured_at.cmp(&left.captured_at)); - let indexed_source_count = captures - .iter() - .filter(|capture| chunks_by_capture.contains_key(&capture.id)) - .count(); - let mut sources = Vec::::new(); - for capture in captures { - if sources.len() >= source_limit { - break; - } - let Some(chunks) = chunks_by_capture.get(&capture.id) else { - continue; - }; - let Some(vector) = average_flow_source_vector(chunks) else { - continue; - }; - let collection_name = collection_names - .get(&capture.collection_id) - .cloned() - .unwrap_or_else(|| "Knowledge Hub".to_string()); - let excerpt = chunks - .iter() - .max_by_key(|chunk| chunk.text.chars().count()) - .map(|chunk| semantic_trail_excerpt(&chunk.text, 300)) - .unwrap_or_default(); - sources.push(FlowSourceCandidate { - capture, - collection_name, - vector, - excerpt, - }); - } - - let query_scores = if query.is_empty() || sources.is_empty() { - HashMap::new() - } else { - let settings = load_settings(&state.paths.settings_path).await?; - let query_vector = local_embed_query(state, &settings, query.clone()).await?; - sources - .iter() - .map(|source| { - let distance = cosine_distance(&query_vector, &source.vector); - ( - flow_source_node_id(&source.capture.id), - semantic_score_from_distance(distance), - ) - }) - .filter(|(_, score)| score.is_finite()) - .collect::>() - }; - - let mut nodes = Vec::::new(); - if !query.is_empty() { - nodes.push(FlowGraphNode { - id: "query".to_string(), - kind: FlowGraphNodeKind::Query, - title: query.clone(), - subtitle: "Semantic search lens".to_string(), - weight: 72.0, - collection_id: None, - collection_name: None, - capture_id: None, - url: None, - host: None, - captured_at: None, - excerpt: None, - score: None, - }); - } - - for collection in &library.collections { - nodes.push(FlowGraphNode { - id: flow_hub_node_id(&collection.id), - kind: FlowGraphNodeKind::Hub, - title: collection.name.clone(), - subtitle: format!( - "{} sources · {} chunks", - collection.capture_count, collection.chunk_count - ), - weight: (42.0 + (collection.capture_count as f64 * 7.0)).min(92.0), - collection_id: Some(collection.id.clone()), - collection_name: Some(collection.name.clone()), - capture_id: None, - url: None, - host: None, - captured_at: Some(collection.updated_at.clone()), - excerpt: Some(collection.description.clone()) - .filter(|description| !description.is_empty()), - score: None, - }); - } - - for source in &sources { - let node_id = flow_source_node_id(&source.capture.id); - nodes.push(FlowGraphNode { - id: node_id.clone(), - kind: FlowGraphNodeKind::Source, - title: source.capture.title.clone(), - subtitle: source.collection_name.clone(), - weight: (24.0 + (source.capture.chunk_count as f64 * 2.0)).min(58.0), - collection_id: Some(source.capture.collection_id.clone()), - collection_name: Some(source.collection_name.clone()), - capture_id: Some(source.capture.id.clone()), - url: Some(source.capture.url.clone()), - host: Some(get_tab_host(&source.capture.url)), - captured_at: Some(source.capture.captured_at.clone()), - excerpt: Some(source.excerpt.clone()).filter(|excerpt| !excerpt.is_empty()), - score: query_scores.get(&node_id).copied().map(round_score), - }); - } - - let mut edges = Vec::::new(); - for source in &sources { - push_flow_graph_edge( - &mut edges, - &flow_hub_node_id(&source.capture.collection_id), - &flow_source_node_id(&source.capture.id), - FlowGraphEdgeKind::Contains, - 36.0, - ); - } - - if !query_scores.is_empty() { - let mut matches = query_scores.iter().collect::>(); - matches.sort_by(|left, right| right.1.partial_cmp(left.1).unwrap_or(Ordering::Equal)); - for (node_id, score) in matches.into_iter().take(FLOW_GRAPH_QUERY_MATCH_LIMIT) { - if *score >= FLOW_GRAPH_MIN_EDGE_SCORE { - push_flow_graph_edge( - &mut edges, - "query", - node_id, - FlowGraphEdgeKind::QueryMatch, - *score, - ); - } - } - } - - let mut semantic_edges = Vec::::new(); - for left_index in 0..sources.len() { - for right_index in (left_index + 1)..sources.len() { - let left = &sources[left_index]; - let right = &sources[right_index]; - let distance = cosine_distance(&left.vector, &right.vector); - let weight = semantic_score_from_distance(distance); - if weight >= FLOW_GRAPH_MIN_EDGE_SCORE { - semantic_edges.push(FlowEdgeCandidate { - from: flow_source_node_id(&left.capture.id), - to: flow_source_node_id(&right.capture.id), - weight, - }); - } - } - } - semantic_edges.sort_by(|left, right| { - right - .weight - .partial_cmp(&left.weight) - .unwrap_or(Ordering::Equal) - }); - let mut neighbor_counts = HashMap::::new(); - let mut semantic_edge_count = 0_usize; - for candidate in semantic_edges { - if semantic_edge_count >= FLOW_GRAPH_MAX_SEMANTIC_EDGES { - break; - } - let left_count = *neighbor_counts.get(&candidate.from).unwrap_or(&0); - let right_count = *neighbor_counts.get(&candidate.to).unwrap_or(&0); - if left_count >= FLOW_GRAPH_NEIGHBORS_PER_SOURCE - || right_count >= FLOW_GRAPH_NEIGHBORS_PER_SOURCE - { - continue; - } - push_flow_graph_edge( - &mut edges, - &candidate.from, - &candidate.to, - FlowGraphEdgeKind::Semantic, - candidate.weight, - ); - semantic_edge_count += 1; - *neighbor_counts.entry(candidate.from).or_insert(0) += 1; - *neighbor_counts.entry(candidate.to).or_insert(0) += 1; - } - - Ok(FlowGraphResult { - query, - generated_at: now(), - nodes, - edges, - hub_count: library.collections.len(), - source_count: sources.len(), - omitted_source_count: indexed_source_count.saturating_sub(sources.len()), - }) -} - -fn average_flow_source_vector(chunks: &[ChunkRecord]) -> Option> { - let first = chunks.first()?; - let dimensions = first.vector.len(); - if dimensions == 0 { - return None; - } - let mut total = vec![0.0_f32; dimensions]; - let mut count = 0.0_f32; - for chunk in chunks { - if chunk.vector.len() != dimensions { - continue; - } - for (index, value) in chunk.vector.iter().enumerate() { - total[index] += *value; - } - count += 1.0; - } - if count == 0.0 { - return None; - } - for value in &mut total { - *value /= count; - } - Some(normalize_embedding(&total)) -} - -fn flow_hub_node_id(collection_id: &str) -> String { - format!("hub-{collection_id}") -} - -fn flow_source_node_id(capture_id: &str) -> String { - format!("source-{capture_id}") -} - -fn push_flow_graph_edge( - edges: &mut Vec, - from: &str, - to: &str, - kind: FlowGraphEdgeKind, - weight: f64, -) { - if from == to { - return; - } - edges.push(FlowGraphEdge { - id: format!("{}-{from}-{to}", flow_edge_kind_label(kind)), - from: from.to_string(), - to: to.to_string(), - kind, - weight: round_score(weight), - }); -} - -fn flow_edge_kind_label(kind: FlowGraphEdgeKind) -> &'static str { - match kind { - FlowGraphEdgeKind::Contains => "contains", - FlowGraphEdgeKind::Semantic => "semantic", - FlowGraphEdgeKind::QueryMatch => "query", - } -} - -async fn air_prepare_dossier( - state: &State<'_, Backend>, - input: AirDossierInput, - synthesize: bool, -) -> Cmd { - let lens_kind = input.lens_kind.unwrap_or_default(); - let lens = input.lens.trim().to_string(); - let visible_lens = if lens.is_empty() { - match lens_kind { - AirLensKind::Topic => "Local knowledge".to_string(), - AirLensKind::Flow => "Current Flow lens".to_string(), - AirLensKind::Hub => "Selected knowledge hub".to_string(), - AirLensKind::Answer => "Latest AiON answer".to_string(), - AirLensKind::Iceberg => "Saved iCE map".to_string(), - } - } else { - lens - }; - let generated_at = now(); - let limit = input.limit.unwrap_or(12).clamp(1, 24); - let (sources, seed_answer, ice_map, seed_model) = - air_gather_context(state, &input, lens_kind, &visible_lens, limit).await?; - let title = format!("AiR Dossier: {visible_lens}"); - let output_dir = resolve_air_export_dir(&state.paths, false).await?; - - let mut model = seed_model.unwrap_or_else(|| "deterministic-scaffold".to_string()); - let synthesized_sections = if synthesize { - match air_synthesize_sections(state, &visible_lens, lens_kind, &sources, seed_answer.as_deref(), ice_map.as_deref()).await { - Ok((synthesis_model, sections)) => { - model = synthesis_model; - Some(sections) - } - Err(_) => None, - } - } else { - None - }; - - let markdown_preview = build_air_markdown(AirMarkdownInput { - title: &title, - lens: &visible_lens, - lens_kind, - generated_at: &generated_at, - model: &model, - sources: &sources, - synthesized_sections: synthesized_sections.as_deref(), - seed_answer: seed_answer.as_deref(), - ice_map: ice_map.as_deref(), - }); - - Ok(AirPreparedDossier { - title, - lens: visible_lens, - lens_kind, - generated_at, - model: Some(model), - output_dir: output_dir.display().to_string(), - markdown_preview, - sources, - }) -} - -async fn air_gather_context( - state: &State<'_, Backend>, - input: &AirDossierInput, - lens_kind: AirLensKind, - lens: &str, - limit: usize, -) -> Cmd<(Vec, Option, Option, Option)> { - match lens_kind { - AirLensKind::Answer => { - let Some(answer) = input.answer.clone() else { - return Ok((Vec::new(), None, None, None)); - }; - let library = load_library(&state.paths.library_path).await?; - let collection_names = library - .collections - .iter() - .map(|collection| (collection.id.clone(), collection.name.clone())) - .collect::>(); - let sources = search_results_to_air_sources( - dedupe_citations(answer.citations).into_iter().take(limit).collect(), - &collection_names, - ); - Ok((sources, Some(answer.answer), None, Some(answer.model))) - } - AirLensKind::Iceberg => { - let icebergs = load_icebergs(&state.paths.icebergs_path).await?; - let selected = input - .saved_iceberg_id - .as_deref() - .and_then(|id| icebergs.icebergs.iter().find(|iceberg| iceberg.id == id)) - .or_else(|| { - icebergs - .icebergs - .iter() - .find(|iceberg| iceberg.title.eq_ignore_ascii_case(lens)) - }) - .or_else(|| icebergs.icebergs.first()); - let Some(iceberg) = selected else { - return Ok((Vec::new(), None, None, None)); - }; - let mut items = iceberg.iceberg.items.clone(); - items.sort_by(|left, right| { - left.level - .cmp(&right.level) - .then_with(|| left.name.to_lowercase().cmp(&right.name.to_lowercase())) - }); - let sources = items - .iter() - .take(limit) - .map(|item| AirDossierSource { - id: format!("iceberg-{}", item.id), - title: item.name.clone(), - excerpt: format!("Level {}: {}", item.level, item.description), - collection_name: Some(format!("iCE · {}", iceberg.title)), - url: None, - host: None, - captured_at: Some(iceberg.updated_at.clone()), - score: None, - }) - .collect::>(); - let map = items - .iter() - .map(|item| format!("- Level {} · {}: {}", item.level, item.name, item.description)) - .collect::>() - .join("\n"); - Ok((sources, None, Some(map), Some(iceberg.iceberg.model.clone()))) - } - AirLensKind::Hub => { - let sources = air_sources_for_hub(state, input.collection_id.as_deref(), limit).await?; - Ok((sources, None, None, None)) - } - AirLensKind::Flow => { - if let Some(collection_id) = input.collection_id.as_deref() { - let sources = air_sources_for_hub(state, Some(collection_id), limit).await?; - return Ok((sources, None, None, None)); - } - let mut sources = match input.capture_id.as_deref() { - Some(capture_id) => air_sources_for_capture(state, capture_id).await?, - None => Vec::new(), - }; - let existing = sources - .iter() - .map(|source| source.id.clone()) - .collect::>(); - let related = air_sources_for_topic(state, lens, limit).await?; - sources.extend( - related - .into_iter() - .filter(|source| !existing.contains(&source.id)) - .take(limit.saturating_sub(sources.len())), - ); - Ok((sources, None, None, None)) - } - AirLensKind::Topic => { - let sources = air_sources_for_topic(state, lens, limit).await?; - Ok((sources, None, None, None)) - } - } -} - -async fn air_sources_for_hub( - state: &State<'_, Backend>, - collection_id: Option<&str>, - limit: usize, -) -> Cmd> { - let library = load_library(&state.paths.library_path).await?; - let collection_names = library - .collections - .iter() - .map(|collection| (collection.id.clone(), collection.name.clone())) - .collect::>(); - let vectors = with_vectors_read(state, |vectors| vectors.chunks.clone()).await?; - let mut chunks_by_capture = HashMap::>::new(); - for chunk in vectors { - chunks_by_capture - .entry(chunk.capture_id.clone()) - .or_default() - .push(chunk); - } - let mut captures = library - .captures - .into_iter() - .filter(|capture| { - collection_id - .map(|id| capture.collection_id == id) - .unwrap_or(true) - }) - .collect::>(); - captures.sort_by(|left, right| right.captured_at.cmp(&left.captured_at)); - Ok(captures - .into_iter() - .take(limit) - .map(|capture| { - let excerpt = chunks_by_capture - .get(&capture.id) - .and_then(|chunks| chunks.iter().max_by_key(|chunk| chunk.text.chars().count())) - .map(|chunk| semantic_trail_excerpt(&chunk.text, 700)) - .or_else(|| capture.metadata.as_ref().and_then(|metadata| metadata.summary.clone())) - .unwrap_or_default(); - AirDossierSource { - id: capture.id.clone(), - title: capture.title, - excerpt, - collection_name: collection_names.get(&capture.collection_id).cloned(), - url: Some(capture.url.clone()), - host: Some(get_tab_host(&capture.url)), - captured_at: Some(capture.captured_at), - score: None, - } - }) - .collect()) -} - -async fn air_sources_for_capture( - state: &State<'_, Backend>, - capture_id: &str, -) -> Cmd> { - let library = load_library(&state.paths.library_path).await?; - let collection_names = library - .collections - .iter() - .map(|collection| (collection.id.clone(), collection.name.clone())) - .collect::>(); - let Some(capture) = library - .captures - .iter() - .find(|capture| capture.id == capture_id) - .cloned() - else { - return Ok(Vec::new()); - }; - let mut chunks = with_vectors_read(state, |vectors| { - vectors - .chunks - .iter() - .filter(|chunk| chunk.capture_id == capture_id) - .cloned() - .collect::>() - }) - .await?; - chunks.sort_by_key(|chunk| chunk.chunk_index); - let excerpt = semantic_trail_excerpt( - &chunks - .iter() - .map(|chunk| chunk.text.as_str()) - .collect::>() - .join("\n\n"), - 1200, - ); - Ok(vec![AirDossierSource { - id: capture.id.clone(), - title: capture.title, - excerpt, - collection_name: collection_names.get(&capture.collection_id).cloned(), - url: Some(capture.url.clone()), - host: Some(get_tab_host(&capture.url)), - captured_at: Some(capture.captured_at), - score: Some(100.0), - }]) -} - -async fn air_sources_for_topic( - state: &State<'_, Backend>, - lens: &str, - limit: usize, -) -> Cmd> { - let library = load_library(&state.paths.library_path).await?; - let collection_names = library - .collections - .iter() - .map(|collection| (collection.id.clone(), collection.name.clone())) - .collect::>(); - let vectors = with_vectors_read(state, |vectors| vectors.chunks.clone()).await?; - if vectors.is_empty() { - return Ok(Vec::new()); - } - - let results = if lens.trim().is_empty() || lens == "Current Flow lens" { - let mut chunks = vectors; - chunks.sort_by(|left, right| right.captured_at.cmp(&left.captured_at)); - chunks - .into_iter() - .take(limit * 2) - .map(|chunk| SearchResult { - score: 0.0, - id: chunk.id, - collection_id: chunk.collection_id, - capture_id: chunk.capture_id, - app_id: chunk.app_id, - title: chunk.title, - url: chunk.url, - captured_at: chunk.captured_at, - chunk_index: chunk.chunk_index, - text: chunk.text, - }) - .collect::>() - } else { - let settings = load_settings(&state.paths.settings_path).await?; - let query_vector = local_embed_query(state, &settings, lens.to_string()).await?; - let mut scored = vectors - .into_iter() - .filter_map(|chunk| { - let distance = cosine_distance(&query_vector, &chunk.vector); - if !distance.is_finite() { - return None; - } - Some((distance, chunk)) - }) - .collect::>(); - scored.sort_by(|left, right| left.0.partial_cmp(&right.0).unwrap_or(Ordering::Equal)); - scored - .into_iter() - .take(limit * 3) - .map(|(score, chunk)| SearchResult { - score, - id: chunk.id, - collection_id: chunk.collection_id, - capture_id: chunk.capture_id, - app_id: chunk.app_id, - title: chunk.title, - url: chunk.url, - captured_at: chunk.captured_at, - chunk_index: chunk.chunk_index, - text: chunk.text, - }) - .collect::>() - }; - Ok(search_results_to_air_sources( - dedupe_citations(results).into_iter().take(limit).collect(), - &collection_names, - )) -} - -fn search_results_to_air_sources( - results: Vec, - collection_names: &HashMap, -) -> Vec { - results - .into_iter() - .map(|result| { - let collection_name = collection_names.get(&result.collection_id).cloned(); - AirDossierSource { - id: result.capture_id.clone(), - title: result.title, - excerpt: semantic_trail_excerpt(&result.text, 850), - collection_name, - url: Some(result.url.clone()), - host: Some(get_tab_host(&result.url)), - captured_at: Some(result.captured_at), - score: Some(round_score(semantic_score_from_distance(result.score))), - } - }) - .collect() -} - -async fn air_synthesize_sections( - state: &State<'_, Backend>, - lens: &str, - lens_kind: AirLensKind, - sources: &[AirDossierSource], - seed_answer: Option<&str>, - ice_map: Option<&str>, -) -> Cmd<(String, String)> { - let settings = load_settings(&state.paths.settings_path).await?; - let citations = sources - .iter() - .enumerate() - .map(|(index, source)| SearchResult { - id: source.id.clone(), - collection_id: source.collection_name.clone().unwrap_or_default(), - capture_id: source.id.clone(), - app_id: "air".to_string(), - title: source.title.clone(), - url: source.url.clone().unwrap_or_else(|| format!("aether://air/source/{}", index + 1)), - captured_at: source.captured_at.clone().unwrap_or_else(now), - chunk_index: index, - text: source.excerpt.clone(), - score: source.score.unwrap_or(0.0), - }) - .collect::>(); - let source_rule = if citations.is_empty() { - "No local source excerpts matched. Say that coverage is currently sparse." - } else { - "Use only the numbered local source excerpts. Cite every concrete claim with bracket citations like [1] or [2]." - }; - let prompt = format!( - "Render a concise Markdown dossier for Æther AiR.\nLens kind: {}\nLens: {lens}\n{source_rule}\n\nInclude exactly these sections:\n## Summary\n## Key Findings\n## Source-Backed Notes\n## Unresolved Questions\n\nDo not include YAML frontmatter, a title, or a source index. Keep prose dense and professional.\n\nSeed AiON answer:\n{}\n\nSaved iCE map:\n{}", - air_lens_label(lens_kind), - seed_answer.unwrap_or("None"), - ice_map.unwrap_or("None") - ); - let result = local_chat(state, &settings, &prompt, citations, None).await?; - Ok(( - result.model, - normalize_model_markdown_citations(&result.answer, sources.len()), - )) -} - -struct AirMarkdownInput<'a> { - title: &'a str, - lens: &'a str, - lens_kind: AirLensKind, - generated_at: &'a str, - model: &'a str, - sources: &'a [AirDossierSource], - synthesized_sections: Option<&'a str>, - seed_answer: Option<&'a str>, - ice_map: Option<&'a str>, -} - -fn build_air_markdown(input: AirMarkdownInput<'_>) -> String { - let tags = air_tags(input.lens) - .into_iter() - .map(|tag| yaml_string(&tag)) - .collect::>() - .join(", "); - let mut markdown = format!( - "---\ntitle: {}\ncreated: {}\naether_lens: {}\nsource_count: {}\ntags: [{}]\nmodel: {}\ntype: aether-dossier\n---\n\n# {}\n\n_Rendered by AiR from the {} lens on {}._\n\n", - yaml_string(input.title), - yaml_string(input.generated_at), - yaml_string(input.lens), - input.sources.len(), - tags, - yaml_string(input.model), - markdown_escape(input.title), - air_lens_label(input.lens_kind), - markdown_escape(input.generated_at) - ); - - if let Some(sections) = input - .synthesized_sections - .map(str::trim) - .filter(|sections| !sections.is_empty()) - { - markdown.push_str(sections); - markdown.push_str("\n\n"); - } else { - markdown.push_str("## Summary\n\n"); - if input.sources.is_empty() { - markdown.push_str(&format!( - "No local sources matched [[{}]] yet. This dossier preserves the requested lens and can be regenerated after more captures are available.\n\n", - markdown_escape(input.lens) - )); - } else { - markdown.push_str(&format!( - "This dossier gathers {} local source{} around [[{}]]. It is generated as a deterministic citation scaffold from Æther's captured knowledge.\n\n", - input.sources.len(), - if input.sources.len() == 1 { "" } else { "s" }, - markdown_escape(input.lens) - )); - } - - markdown.push_str("## Key Findings\n\n"); - if input.sources.is_empty() { - markdown.push_str("- Coverage is sparse; capture or connect more relevant material before treating this lens as complete.\n\n"); - } else { - for (index, source) in input.sources.iter().take(6).enumerate() { - markdown.push_str(&format!( - "- **{}** anchors this lens with: {} [^{}]\n", - markdown_escape(&source.title), - markdown_escape(&semantic_trail_excerpt(&source.excerpt, 180)), - index + 1 - )); - } - markdown.push('\n'); - } - - markdown.push_str("## Source-Backed Notes\n\n"); - for (index, source) in input.sources.iter().enumerate() { - markdown.push_str(&format!( - "### {}. {}\n\n{}\n\n", - index + 1, - markdown_escape(&source.title), - markdown_escape(&source.excerpt) - )); - let mut meta = Vec::new(); - if let Some(collection) = &source.collection_name { - meta.push(format!("Hub: {}", markdown_escape(collection))); - } - if let Some(host) = &source.host { - meta.push(format!("Host: {}", markdown_escape(host))); - } - if let Some(captured_at) = &source.captured_at { - meta.push(format!("Captured: {}", markdown_escape(captured_at))); - } - if let Some(score) = source.score { - meta.push(format!("Match: {score:.1}")); - } - if !meta.is_empty() { - markdown.push_str(&format!("{}\n\n", meta.join(" · "))); - } - } - - if let Some(answer) = input.seed_answer.map(str::trim).filter(|answer| !answer.is_empty()) { - markdown.push_str("## AiON Seed\n\n"); - markdown.push_str(&markdown_escape(answer)); - markdown.push_str("\n\n"); - } - - if let Some(map) = input.ice_map.map(str::trim).filter(|map| !map.is_empty()) { - markdown.push_str("## iCE Map\n\n"); - markdown.push_str(&markdown_escape(map)); - markdown.push_str("\n\n"); - } - - markdown.push_str("## Unresolved Questions\n\n"); - markdown.push_str("- Which claims need fresher or primary-source confirmation?\n"); - markdown.push_str("- Which adjacent hubs should be captured next to improve coverage?\n\n"); - } - - markdown.push_str("## Source Index\n\n"); - if input.sources.is_empty() { - markdown.push_str("No source index entries were available for this render.\n"); - } else { - for (index, source) in input.sources.iter().enumerate() { - let title = markdown_escape(&source.title); - let collection = source - .collection_name - .as_deref() - .map(markdown_escape) - .unwrap_or_else(|| "Knowledge Hub".to_string()); - let captured_at = source - .captured_at - .as_deref() - .map(markdown_escape) - .unwrap_or_else(|| "unknown capture date".to_string()); - if let Some(url) = &source.url { - markdown.push_str(&format!( - "[^{}]: [{}]({}) — {} · {}\n", - index + 1, - title, - url.replace(')', "%29"), - collection, - captured_at - )); - } else { - markdown.push_str(&format!( - "[^{}]: {} — {} · {}\n", - index + 1, - title, - collection, - captured_at - )); - } - } - } - markdown -} - -async fn resolve_air_export_dir(paths: &DataPaths, create: bool) -> Cmd { - let preferred = documents_air_dir(); - let candidates = preferred - .into_iter() - .chain(std::iter::once(paths.air_exports_path.clone())) - .collect::>(); - for candidate in candidates { - if !create { - if candidate.exists() - || candidate - .parent() - .is_some_and(|parent| parent.exists() && parent.is_dir()) - { - return Ok(candidate); - } - continue; - } - if tokio::fs::create_dir_all(&candidate).await.is_ok() { - return Ok(candidate); - } - } - if create { - Err("Could not create an AiR export folder.".to_string()) - } else { - Ok(paths.air_exports_path.clone()) - } -} - -fn documents_air_dir() -> Option { - env::var_os("HOME") - .or_else(|| env::var_os("USERPROFILE")) - .map(PathBuf::from) - .map(|home| home.join("Documents").join("Æther").join("AiR")) -} - -async fn air_list_recent(paths: &DataPaths) -> Cmd> { - let mut files = Vec::::new(); - let mut directories = Vec::new(); - if let Some(documents) = documents_air_dir() { - directories.push(documents); - } - directories.push(paths.air_exports_path.clone()); - - for directory in directories { - let Ok(mut entries) = tokio::fs::read_dir(&directory).await else { - continue; - }; - while let Some(entry) = entries.next_entry().await.map_err(|error| error.to_string())? { - let path = entry.path(); - if !path - .extension() - .and_then(|extension| extension.to_str()) - .is_some_and(|extension| extension.eq_ignore_ascii_case("md")) - { - continue; - } - let filename = path - .file_name() - .and_then(|name| name.to_str()) - .unwrap_or("aether-dossier.md") - .to_string(); - let metadata = entry.metadata().await.ok(); - let rendered_at = metadata - .and_then(|metadata| metadata.modified().ok()) - .map(|modified| DateTime::::from(modified).to_rfc3339_opts(chrono::SecondsFormat::Millis, true)) - .unwrap_or_else(now); - let content = tokio::fs::read_to_string(&path).await.unwrap_or_default(); - files.push(AirRecentFile { - title: air_frontmatter_value(&content, "title") - .unwrap_or_else(|| air_title_from_filename(&filename)), - lens: air_frontmatter_value(&content, "aether_lens").unwrap_or_default(), - source_count: air_frontmatter_value(&content, "source_count") - .and_then(|value| value.parse::().ok()) - .unwrap_or(0), - path: path.display().to_string(), - filename, - rendered_at, - }); - } - } - files.sort_by(|left, right| right.rendered_at.cmp(&left.rendered_at)); - files.truncate(12); - Ok(files) -} - -fn air_frontmatter_value(markdown: &str, key: &str) -> Option { - let mut lines = markdown.lines(); - if lines.next()? != "---" { - return None; - } - let prefix = format!("{key}:"); - for line in lines { - if line == "---" { - break; - } - if let Some(value) = line.strip_prefix(&prefix) { - return Some(value.trim().trim_matches('"').replace("\\\"", "\"")); - } - } - None -} - -fn air_dossier_filename(title: &str, generated_at: &str) -> String { - let _ = generated_at; - let filename = title - .trim() - .chars() - .map(|char| match char { - '/' | '\\' | '\0' => '-', - char if char.is_control() => ' ', - char => char, - }) - .collect::() - .split_whitespace() - .collect::>() - .join(" ") - .trim_matches(['.', ' ']) - .to_string(); - if filename.is_empty() { - "AiR Dossier.md".to_string() - } else { - format!("{filename}.md") - } -} - -fn air_title_from_filename(filename: &str) -> String { - filename - .trim_end_matches(".md") - .split('-') - .filter(|part| !part.chars().all(|char| char.is_ascii_digit())) - .take(8) - .map(|part| { - let mut chars = part.chars(); - match chars.next() { - Some(first) => format!("{}{}", first.to_uppercase(), chars.as_str()), - None => String::new(), - } - }) - .collect::>() - .join(" ") -} - -fn yaml_string(value: &str) -> String { - format!( - "\"{}\"", - value - .replace('\\', "\\\\") - .replace('"', "\\\"") - .replace('\n', " ") - .replace('\r', " ") - ) -} - -fn markdown_escape(value: &str) -> String { - strip_numeric_bracket_markers(value) - .replace('\r', "") - .trim() - .to_string() -} - -fn air_tags(lens: &str) -> Vec { - let mut tags = vec!["aether".to_string(), "air".to_string(), "dossier".to_string()]; - for word in lens - .split(|char: char| !char.is_alphanumeric()) - .filter(|word| word.chars().count() >= 3) - .take(4) - { - tags.push(word.to_lowercase()); - } - tags.sort(); - tags.dedup(); - tags -} - -fn air_lens_label(kind: AirLensKind) -> &'static str { - match kind { - AirLensKind::Topic => "topic", - AirLensKind::Flow => "Flow", - AirLensKind::Hub => "hub", - AirLensKind::Answer => "AiON answer", - AirLensKind::Iceberg => "iCE", - } -} - -fn normalize_model_markdown_citations(markdown: &str, source_count: usize) -> String { - normalize_answer_citations(&clean_model_output(markdown), source_count) -} - -fn semantic_trail_default_query(captured: &CapturedPage) -> String { - normalize_captured_text(&format!( - "{}\n\n{}", - captured.title, - semantic_trail_excerpt(&captured.text, 1600) - )) -} - -fn semantic_trail_items( - candidates: Vec, - limit: usize, -) -> Vec { - let mut items = Vec::::new(); - let mut indexes = HashMap::::new(); - - for candidate in candidates { - let key = normalize_capture_url_key(&candidate.chunk.url); - if let Some(index) = indexes.get(&key).copied() { - let item = &mut items[index]; - item.excerpt = merge_semantic_trail_excerpts(&item.excerpt, &candidate.chunk.text); - for reason in candidate.reasons { - add_semantic_trail_reason(&mut item.reasons, reason); +#[cfg_attr(mobile, tauri::mobile_entry_point)] +pub fn run() { + let builder = tauri::Builder::default(); + #[cfg(desktop)] + let builder = builder + .menu(|app| { + let menu = Menu::new(app)?; + let focus_address_item = MenuItem::with_id( + app, + AETHER_FOCUS_ADDRESS_MENU_ID, + "Focus Address Bar", + true, + Some("CmdOrCtrl+L"), + )?; + let new_tab_item = MenuItem::with_id( + app, + AETHER_NEW_TAB_MENU_ID, + "New Tab", + true, + Some("CmdOrCtrl+T"), + )?; + let find_item = MenuItem::with_id( + app, + AETHER_FIND_MENU_ID, + "Find in Page", + true, + Some("CmdOrCtrl+F"), + )?; + let open_dashboard_item = MenuItem::with_id( + app, + AETHER_OPEN_DASHBOARD_MENU_ID, + "Open Dashboard", + true, + Some("CmdOrCtrl+1"), + )?; + let open_ice_item = MenuItem::with_id( + app, + AETHER_OPEN_ICE_MENU_ID, + "Open iCE", + true, + Some("CmdOrCtrl+2"), + )?; + let open_browser_item = MenuItem::with_id( + app, + AETHER_OPEN_BROWSER_MENU_ID, + "Open Browser", + true, + Some("CmdOrCtrl+3"), + )?; + let toggle_aion_item = MenuItem::with_id( + app, + AETHER_TOGGLE_AION_MENU_ID, + "Toggle AiON", + true, + Some("CmdOrCtrl+Shift+A"), + )?; + let capture_page_item = MenuItem::with_id( + app, + AETHER_CAPTURE_PAGE_MENU_ID, + "Capture Current Page", + true, + Some("CmdOrCtrl+Shift+C"), + )?; + let shortcuts_menu = Submenu::with_items( + app, + "Shortcuts", + true, + &[ + &focus_address_item, + &new_tab_item, + &find_item, + &open_dashboard_item, + &open_ice_item, + &open_browser_item, + &toggle_aion_item, + &capture_page_item, + ], + )?; + // Standard Edit submenu. Its predefined items carry the native key + // equivalents (Cmd/Ctrl+A/C/V/X and undo/redo), which is what wires up + // select-all/copy/paste in the address bar and other text fields. An + // empty Menu::new has no Edit menu, so those shortcuts would do nothing. + let edit_menu = Submenu::with_items( + app, + "Edit", + true, + &[ + &PredefinedMenuItem::undo(app, None)?, + &PredefinedMenuItem::redo(app, None)?, + &PredefinedMenuItem::separator(app)?, + &PredefinedMenuItem::cut(app, None)?, + &PredefinedMenuItem::copy(app, None)?, + &PredefinedMenuItem::paste(app, None)?, + &PredefinedMenuItem::select_all(app, None)?, + ], + )?; + menu.append(&edit_menu)?; + menu.append(&shortcuts_menu)?; + Ok(menu) + }) + .on_menu_event(|app, event| match event.id().as_ref() { + AETHER_FIND_MENU_ID => { + let _ = app.emit(AETHER_FIND_REQUESTED_EVENT, ()); } - continue; - } - - if items.len() >= limit { - continue; - } - - indexes.insert(key, items.len()); - items.push(SemanticTrailItem { - id: candidate.chunk.capture_id.clone(), - collection_id: candidate.chunk.collection_id.clone(), - collection_name: candidate.collection_name, - capture_id: candidate.chunk.capture_id, - app_id: candidate.chunk.app_id, - title: candidate.chunk.title, - url: candidate.chunk.url.clone(), - host: get_tab_host(&candidate.chunk.url), - captured_at: candidate.chunk.captured_at, - chunk_index: candidate.chunk.chunk_index, - excerpt: semantic_trail_excerpt(&candidate.chunk.text, 520), - score: candidate.score, - reasons: candidate.reasons, + AETHER_FOCUS_ADDRESS_MENU_ID => { + let _ = app.emit(AETHER_SHORTCUT_EVENT, "focus-address"); + } + AETHER_NEW_TAB_MENU_ID => { + let _ = app.emit(AETHER_SHORTCUT_EVENT, "new-tab"); + } + AETHER_OPEN_DASHBOARD_MENU_ID => { + let _ = app.emit(AETHER_SHORTCUT_EVENT, "open-dashboard"); + } + AETHER_OPEN_ICE_MENU_ID => { + let _ = app.emit(AETHER_SHORTCUT_EVENT, "open-ice"); + } + AETHER_OPEN_BROWSER_MENU_ID => { + let _ = app.emit(AETHER_SHORTCUT_EVENT, "open-browser"); + } + AETHER_TOGGLE_AION_MENU_ID => { + let _ = app.emit(AETHER_SHORTCUT_EVENT, "toggle-aion"); + } + AETHER_CAPTURE_PAGE_MENU_ID => { + let _ = app.emit(AETHER_SHORTCUT_EVENT, "capture-page"); + } + _ => {} }); - } - - items -} - -fn semantic_trail_score_breakdown( - distance: f64, - captured_at: &str, -) -> SemanticTrailScoreBreakdown { - // Relatedness is about meaning across the whole library, regardless of where a source - // came from. Semantic similarity dominates; recency is only a gentle tiebreaker. - let semantic = semantic_score_from_distance(distance); - let recency = semantic_trail_recency_score(captured_at); - let total = round_score((semantic * 0.92) + (recency * 0.08)); - - SemanticTrailScoreBreakdown { - total, - semantic, - recency, - } -} -fn semantic_score_from_distance(distance: f64) -> f64 { - if !distance.is_finite() { - return 0.0; - } - round_score((1.0 - (distance / 1.2)).clamp(0.0, 1.0) * 100.0) -} + let builder = builder.plugin(tauri_plugin_opener::init()); + // Registered even when no pubkey is configured: the plugin only fails at check + // time, and aether_system_install_update turns that into one honest message + // instead of a failed download. See updater_pubkey(). + #[cfg(desktop)] + let builder = builder.plugin(tauri_plugin_updater::Builder::new().build()); + #[cfg(target_os = "android")] + let builder = builder.plugin(android_tabs::init()); -fn semantic_trail_recency_score(captured_at: &str) -> f64 { - let Ok(parsed) = DateTime::parse_from_rfc3339(captured_at) else { - return 0.0; - }; - let days = Utc::now() - .signed_duration_since(parsed.with_timezone(&Utc)) - .num_days() - .max(0); - match days { - 0..=7 => 100.0, - 8..=30 => 80.0, - 31..=90 => 60.0, - 91..=180 => 40.0, - 181..=365 => 25.0, - _ => 10.0, - } -} + builder + .setup(|app| { + let app_data_dir = app.path().app_data_dir().expect("app data dir"); + // Before anything else: session restore and model prewarm both log, + // and those are the entries most worth having in a bug report. + diagnostics::set_log_path(&app_data_dir); + app.manage(Backend::new(app_data_dir)); -fn semantic_trail_reasons( - score: &SemanticTrailScoreBreakdown, - same_collection: bool, -) -> Vec { - let mut reasons = Vec::new(); - if score.semantic >= 55.0 { - reasons.push(SemanticTrailReason::SemanticMatch); - } - if score.recency >= 80.0 { - reasons.push(SemanticTrailReason::RecentCapture); - } - if same_collection { - reasons.push(SemanticTrailReason::SameCollection); - } - if reasons.is_empty() { - reasons.push(SemanticTrailReason::SemanticMatch); - } - reasons -} + // Restore the previous session before anything reads tab state or + // prewarms a webview, so the restored active tab is the one warmed. + #[cfg(desktop)] + let restored_window = { + let app_handle = app.handle().clone(); + let state = app_handle.state::(); + match tauri::async_runtime::block_on(load_session(&state.paths.session_path)) { + Ok(session) => { + restore_session_tabs(&state, &session); + session.window + } + Err(error) => { + diag_warn!("could not read session: {error}"); + None + } + } + }; -fn semantic_trail_edges( - root: &SemanticTrailRoot, - items: &[SemanticTrailItem], -) -> Vec { - let mut edges = Vec::new(); - for item in items.iter().take(12) { - push_semantic_trail_edge( - &mut edges, - "root", - &item.id, - SemanticTrailEdgeKind::SemanticMatch, - item.score.total, - ); - if !root.host.is_empty() && root.host == item.host { - push_semantic_trail_edge( - &mut edges, - "root", - &item.id, - SemanticTrailEdgeKind::SameHost, - item.score.total, - ); - } - } + #[cfg(desktop)] + if let Some(window) = app.get_window("main") { + if let Some(geometry) = restored_window { + apply_session_window(&window, geometry); + } - for left_index in 0..items.len().min(8) { - for right_index in (left_index + 1)..items.len().min(8) { - let left = &items[left_index]; - let right = &items[right_index]; - let weight = left.score.total.min(right.score.total); - if left.collection_id == right.collection_id { - push_semantic_trail_edge( - &mut edges, - &left.id, - &right.id, - SemanticTrailEdgeKind::SameCollection, - weight, - ); - } else if !left.host.is_empty() && left.host == right.host { - push_semantic_trail_edge( - &mut edges, - &left.id, - &right.id, - SemanticTrailEdgeKind::SameHost, - weight, - ); + let app_handle = app.handle().clone(); + window.on_window_event(move |event| { + match event { + WindowEvent::Resized(_) | WindowEvent::ScaleFactorChanged { .. } => { + let state = app_handle.state::(); + let _ = resize_native_webviews(&app_handle, &state); + schedule_window_geometry_save(&app_handle); + } + WindowEvent::Moved(_) => schedule_window_geometry_save(&app_handle), + // force_exit() follows a close, so this is the last chance to + // capture the final geometry the throttle may have skipped. + WindowEvent::CloseRequested { .. } | WindowEvent::Destroyed => { + save_window_geometry_now(&app_handle) + } + _ => {} + } + }); } - } - } - - edges -} - -fn push_semantic_trail_edge( - edges: &mut Vec, - from: &str, - to: &str, - kind: SemanticTrailEdgeKind, - weight: f64, -) { - if edges.len() >= 36 || from == to { - return; - } - edges.push(SemanticTrailEdge { - from: from.to_string(), - to: to.to_string(), - kind, - weight: round_score(weight), - }); -} - -fn add_semantic_trail_reason( - reasons: &mut Vec, - reason: SemanticTrailReason, -) { - if !reasons.contains(&reason) { - reasons.push(reason); - } -} - -fn merge_semantic_trail_excerpts(existing: &str, next: &str) -> String { - let next = semantic_trail_excerpt(next, 360); - if next.is_empty() || existing.contains(&next) { - return existing.to_string(); - } - semantic_trail_excerpt(&format!("{existing}\n\n[…]\n\n{next}"), 920) -} - -fn semantic_trail_excerpt(text: &str, limit: usize) -> String { - let normalized = normalize_captured_text(text); - if normalized.chars().count() <= limit { - return normalized; - } - let mut excerpt = normalized.chars().take(limit).collect::(); - excerpt.push('…'); - excerpt -} - -fn round_score(value: f64) -> f64 { - (value * 10.0).round() / 10.0 -} - -fn capture_chunk_settings(_paths: &DataPaths, _settings: &UserSettings) -> (usize, usize) { - (DEFAULT_CAPTURE_CHUNK_SIZE, DEFAULT_CAPTURE_CHUNK_OVERLAP) -} - -fn current_page_search_result( - captured: &CapturedPage, - collection_id: Option<&str>, - chunk_index: usize, - score: f64, - text: String, -) -> SearchResult { - SearchResult { - id: format!("current-{}", uuid()), - collection_id: collection_id - .map(ToString::to_string) - .unwrap_or_else(|| "current-page".to_string()), - capture_id: "current-page".to_string(), - app_id: "browser".to_string(), - title: captured.title.clone(), - url: captured.url.clone(), - captured_at: now(), - chunk_index, - text, - score, - } -} - -// The current page isn't pre-indexed like a knowledge hub, so rank its chunks at -// ask-time. We mirror the hub's semantic retrieval (embed the query + chunks, rank -// by cosine distance, keep the best matches) and fall back to lexical scoring only -// when no embedding model is available. Returning several chunks instead of the -// single best is what lets the model actually answer: dedupe_citations later merges -// these same-URL chunks into one context-dense citation, just like the hub. -async fn current_page_citations( - state: &State<'_, Backend>, - settings: &UserSettings, - captured: CapturedPage, - prompt: &str, - collection_id: Option<&str>, - limit: usize, -) -> Vec { - if limit == 0 { - return Vec::new(); - } - let (chunk_size, chunk_overlap) = capture_chunk_settings(&state.paths, settings); - let chunks = split_text(&captured.text, chunk_size, chunk_overlap); - if chunks.is_empty() { - return Vec::new(); - } - - if let Some(ranked) = semantic_rank_chunks(state, settings, &chunks, prompt, limit).await { - return ranked - .into_iter() - .map(|(index, distance)| { - current_page_search_result( - &captured, - collection_id, - index, - distance, - chunks[index].clone(), - ) - }) - .collect(); - } - - // Lexical fallback (no embedding model): keep the highest-scoring chunks. - let mut scored = chunks - .iter() - .enumerate() - .map(|(index, text)| (lexical_relevance_score(text, prompt), index)) - .filter(|(score, _)| *score > 0.0) - .collect::>(); - scored.sort_by(|left, right| right.0.partial_cmp(&left.0).unwrap_or(Ordering::Equal)); - - if scored.is_empty() { - // Nothing matched lexically — anchor on the start of the page rather than - // returning nothing. - let first = chunks.into_iter().next().unwrap_or_default(); - return vec![current_page_search_result( - &captured, - collection_id, - 0, - 0.0, - first, - )]; - } - - scored - .into_iter() - .take(limit) - .map(|(score, index)| { - current_page_search_result(&captured, collection_id, index, score, chunks[index].clone()) + #[cfg(desktop)] + { + let app_handle = app.handle().clone(); + let state = app_handle.state::(); + if let Ok(active_tab_id) = active_tab_id(&state) { + if let Err(error) = ensure_native_webview(&app_handle, &state, &active_tab_id) { + diag_warn!("browser webview prewarm failed: {error}"); + } + } + prewarm_local_models(&app_handle); + } + Ok(()) }) - .collect() -} - -// Embed the query alongside the page chunks in one batch, then order chunk indices -// by ascending cosine distance. Returns None when embedding is unavailable so the -// caller can fall back to lexical scoring. -async fn semantic_rank_chunks( - state: &State<'_, Backend>, - settings: &UserSettings, - chunks: &[String], - prompt: &str, - limit: usize, -) -> Option> { - let mut inputs = Vec::with_capacity(chunks.len() + 1); - inputs.push(embedding_query_input(&state.paths, settings, prompt)); - inputs.extend(chunks.iter().cloned()); - - let embeddings = local_embed(state, settings, inputs).await.ok()?; - if embeddings.len() != chunks.len() + 1 { - return None; - } - - let query_vector = &embeddings[0]; - let mut scored = embeddings[1..] - .iter() - .enumerate() - .map(|(index, vector)| (cosine_distance(query_vector, vector), index)) - .collect::>(); - scored.sort_by(|left, right| left.0.partial_cmp(&right.0).unwrap_or(Ordering::Equal)); - Some( - scored - .into_iter() - .take(limit) - .map(|(distance, index)| (index, distance)) - .collect(), - ) -} - -fn lexical_relevance_score(text: &str, query: &str) -> f64 { - let terms = query_terms(query); - if terms.is_empty() { - return 0.0; - } - let haystack = text.to_lowercase(); - terms - .iter() - .map(|term| lexical_term_score(&haystack, term)) - .sum() -} - -fn lexical_term_score(haystack: &str, term: &str) -> f64 { - let occurrences = haystack.matches(term).count(); - if occurrences > 0 { - // Reward repeated mentions so a chunk that actually discusses the term beats - // one that merely name-drops it once. Without this, every matching chunk ties - // and the tie-break is arbitrary. - return 2.0 + (term.len() as f64 / 10.0) + (occurrences.saturating_sub(1) as f64) * 0.5; - } - let stem_len = term.len().min(6); - if stem_len >= 5 && haystack.contains(&term[..stem_len]) { - return 1.25 + (stem_len as f64 / 12.0); - } - if let Some(singular) = term.strip_suffix('s') { - if singular.len() >= 5 && haystack.contains(singular) { - return 1.5 + (singular.len() as f64 / 12.0); - } - } - 0.0 -} - -fn query_terms(query: &str) -> Vec { - let stopwords = [ - "a", "an", "and", "are", "as", "at", "be", "by", "can", "do", "does", "for", "from", "how", - "i", "in", "is", "it", "me", "most", "of", "on", "or", "should", "the", "this", "to", - "was", "were", "what", "when", "where", "which", "who", "why", "with", - ]; - let stopwords = stopwords.into_iter().collect::>(); - let mut seen = HashSet::new(); - query - .split(|character: char| !character.is_alphanumeric()) - .map(str::trim) - .map(str::to_lowercase) - .filter(|term| term.len() > 2 && !stopwords.contains(term.as_str())) - .filter(|term| seen.insert(term.clone())) - .collect() -} - -async fn system_status(state: &State<'_, Backend>) -> Cmd { - let settings = load_settings(&state.paths.settings_path).await?; - let library = load_library(&state.paths.library_path).await?; - let catalog = model_catalog(&state.paths, &settings.local_model); - Ok(SystemStatus { - runtime_ready: catalog.chat_model.is_some() || catalog.embedding_model.is_some(), - runtime_name: LOCAL_RUNTIME_NAME.to_string(), - embedding_model: catalog.embedding_model.as_ref().map(path_to_model_value), - chat_model: catalog.chat_model.as_ref().map(path_to_model_value), - available_models: catalog.models.iter().map(path_to_model_value).collect(), - chat_models: catalog - .models - .iter() - .filter(|path| is_chat_model(path)) - .map(path_to_model_value) - .collect(), - embedding_models: catalog - .models - .iter() - .filter(|path| is_embedding_model(path)) - .map(path_to_model_value) - .collect(), - model_dir: state.paths.models_path.display().to_string(), - db_path: state.paths.db_path.display().to_string(), - library_path: state.paths.library_path.display().to_string(), - collections: library.collections, - error: catalog.error, - }) -} - -async fn load_library(path: &Path) -> Cmd { - read_json_or_default(path).await -} - -async fn load_settings(path: &Path) -> Cmd { - read_json_or_default(path).await -} - -async fn persist_update_last_checked_at(paths: &DataPaths, checked_at: &str) -> Cmd<()> { - let mut settings = load_settings(&paths.settings_path).await?; - settings.updates.last_checked_at = Some(checked_at.to_string()); - save_json(&paths.settings_path, &settings).await -} - -fn release_version_from_tag(tag: &str) -> String { - tag.trim() - .trim_start_matches('v') - .trim_start_matches('V') - .to_string() -} - -fn version_is_newer(candidate: &str, current: &str) -> bool { - let candidate_parts = version_parts(candidate); - let current_parts = version_parts(current); - let max_len = candidate_parts.len().max(current_parts.len()).max(3); - for index in 0..max_len { - let candidate_value = candidate_parts.get(index).copied().unwrap_or_default(); - let current_value = current_parts.get(index).copied().unwrap_or_default(); - if candidate_value > current_value { - return true; - } - if candidate_value < current_value { - return false; - } - } - false -} - -fn version_parts(version: &str) -> Vec { - version - .trim() - .trim_start_matches('v') - .trim_start_matches('V') - .split(|character: char| !character.is_ascii_digit()) - .filter(|part| !part.is_empty()) - .filter_map(|part| part.parse::().ok()) - .collect() -} - -async fn load_icebergs(path: &Path) -> Cmd { - read_json_or_default(path).await + .invoke_handler(tauri::generate_handler![ + aether_state, + aether_apps_list, + aether_apps_activate, + aether_apps_navigate, + aether_apps_go_back, + aether_apps_go_forward, + aether_tabs_list, + aether_tabs_create, + aether_tabs_activate, + aether_tabs_close, + aether_tabs_reorder, + aether_tabs_navigate, + aether_tabs_scroll_to_text, + aether_tabs_find, + aether_tabs_go_back, + aether_tabs_go_forward, + aether_tabs_report_native_event, + aether_tabs_thumbnail, + aether_layout_window_insets, + aether_layout_set_web_content_bounds, + aether_dashboard_open, + aether_hub_list, + aether_hub_create, + aether_hub_reorder, + aether_hub_delete, + aether_collections_list, + aether_collections_create, + aether_collections_update, + aether_collections_reorder, + aether_collections_delete, + aether_collections_captures, + aether_capture_current_page, + aether_capture_url, + aether_capture_urls, + aether_capture_move, + aether_capture_delete, + aether_capture_suggest_hub, + aether_search_collection, + aether_search_library, + aether_semantic_trail_generate, + aether_flow_graph, + aether_air_prepare, + aether_air_render, + aether_air_list_recent, + aether_air_open, + aether_air_reveal, + aether_chat_ask, + aether_chat_cancel, + aether_chat_history, + aether_chat_clear_history, + aether_crystallizer_generate, + aether_crystallizer_list_saved, + aether_crystallizer_get_saved, + aether_crystallizer_save, + aether_crystallizer_reorder_saved, + aether_crystallizer_delete_saved, + aether_system_status, + aether_system_settings, + aether_system_update_settings, + aether_system_update_models, + aether_system_check_for_update, + aether_system_install_update, + aether_system_relaunch, + aether_system_download_models, + aether_system_export_library, + aether_system_diagnostics, + aether_system_export_diagnostics, + aether_library_reindex, + aether_library_index_status, + aether_system_open_external_url, + aether_layout_set_panel_collapsed, + aether_layout_set_modal_overlay_open, + aether_layout_show_status_toast + ]) + .build(tauri::generate_context!()) + .expect("error while building Æther") + .run(|_app_handle, _event| { + #[cfg(desktop)] + if let tauri::RunEvent::ExitRequested { .. } = _event { + force_exit(); + } + }); } -async fn load_vectors(path: &Path) -> Cmd { - read_json_or_default(path).await -} +#[cfg(desktop)] +fn prewarm_local_models(app: &AppHandle) { + let state = app.state::(); + let paths = state.paths.clone(); + let runtime = Arc::clone(&state.native_runtime); -async fn with_vectors_read( - state: &State<'_, Backend>, - read: impl FnOnce(&VectorStoreData) -> T, -) -> Cmd { - { - let guard = state.vectors.read().await; - if let Some(vectors) = guard.as_ref() { - return Ok(read(vectors)); + tauri::async_runtime::spawn(async move { + let Ok(settings) = load_settings(&paths.settings_path).await else { + return; + }; + let catalog = model_catalog(&paths, &settings.local_model); + let chat_model = catalog.chat_model; + let embedding_model = catalog.embedding_model; + if chat_model.is_none() && embedding_model.is_none() { + return; } - } - let mut guard = state.vectors.write().await; - if guard.is_none() { - *guard = Some(load_vectors(&state.paths.chunks_path).await?); - } - Ok(read(guard.as_ref().expect("vector store cache"))) -} - -async fn with_vectors_mut( - state: &State<'_, Backend>, - mutate: impl FnOnce(&mut VectorStoreData) -> T, -) -> Cmd { - let mut guard = state.vectors.write().await; - if guard.is_none() { - *guard = Some(load_vectors(&state.paths.chunks_path).await?); - } - let vectors = guard.as_mut().expect("vector store cache"); - let result = mutate(vectors); - save_vectors(&state.paths.chunks_path, vectors).await?; - Ok(result) -} - -// Vector rows are large and machine-managed, so they are persisted as compact -// JSON instead of the pretty format used for small user-editable stores. -async fn save_vectors(path: &Path, data: &VectorStoreData) -> Cmd<()> { - if let Some(parent) = path.parent() { - tokio::fs::create_dir_all(parent) - .await - .map_err(|error| error.to_string())?; - } - let raw = serde_json::to_string(data).map_err(|error| error.to_string())?; - tokio::fs::write(path, raw) - .await - .map_err(|error| error.to_string()) -} + let result = task::spawn_blocking(move || { + let mut runtime = runtime + .lock() + .map_err(|_| "Local model runtime is unavailable.".to_string())?; + if let Some(model_path) = &chat_model { + runtime + .ensure_model(NativeModelKind::Chat, model_path) + .map_err(|error| { + format!("chat model {} failed: {error}", model_label(model_path)) + })?; + } + if let Some(model_path) = &embedding_model { + runtime.warm_embedding_model(model_path).map_err(|error| { + format!( + "embedding model {} failed: {error}", + model_label(model_path) + ) + })?; + } + Ok::<(), String>(()) + }) + .await; -async fn read_json_or_default(path: &Path) -> Cmd -where - T: DeserializeOwned + Default + Serialize, -{ - match tokio::fs::read_to_string(path).await { - Ok(raw) => serde_json::from_str(&raw).map_err(|error| error.to_string()), - Err(_) => { - let data = T::default(); - save_json(path, &data).await?; - Ok(data) + match result { + Ok(Ok(())) => {} + Ok(Err(error)) => diag_warn!("model prewarm failed: {error}"), + Err(error) => diag_warn!("model prewarm task failed: {error}"), } - } + }); } -async fn save_json(path: &Path, data: &T) -> Cmd<()> { - if let Some(parent) = path.parent() { - tokio::fs::create_dir_all(parent) - .await - .map_err(|error| error.to_string())?; - } - let raw = serde_json::to_string_pretty(data).map_err(|error| error.to_string())?; - tokio::fs::write(path, format!("{raw}\n")) - .await - .map_err(|error| error.to_string()) +fn emit_capture_progress( + app: &AppHandle, + message: impl Into, + current: Option, + total: Option, +) { + let _ = app.emit( + "aether:capture-progress", + CaptureProgress { + message: message.into(), + current, + total, + }, + ); } -async fn get_collection(path: &Path, collection_id: &str) -> Cmd { - load_library(path) - .await? - .collections - .into_iter() - .find(|collection| collection.id == collection_id) - .ok_or_else(|| "Collection not found.".to_string()) +async fn navigate_active_tab(app: &AppHandle, state: &State<'_, Backend>, url: &str) -> Cmd<()> { + let settings = load_settings(&state.paths.settings_path).await?; + let (tab_id, target_url) = { + let mut tabs = lock_tabs(state)?; + let tab = tabs + .active_tab_mut() + .ok_or_else(|| "No active browser tab.".to_string())?; + tab.navigate(url, &settings.browser.default_search_engine); + let result = (tab.id.clone(), tab.url.clone()); + tabs.dashboard_open = false; + result + }; + navigate_native_webview(app, state, &tab_id, &target_url)?; + emit_state(app, state) } -async fn local_embed( - state: &State<'_, Backend>, - settings: &UserSettings, - inputs: Vec, -) -> Cmd>> { - local_embed_with_progress(state, settings, inputs, None).await +fn lock_tabs<'a>(state: &'a State<'_, Backend>) -> Cmd> { + state + .tabs + .lock() + .map_err(|_| "tab state is unavailable.".to_string()) } -async fn local_embed_query( - state: &State<'_, Backend>, - settings: &UserSettings, - query: String, -) -> Cmd> { - local_embed( - state, - settings, - vec![embedding_query_input(&state.paths, settings, &query)], - ) - .await? - .into_iter() - .next() - .ok_or_else(|| "Local embedding model returned no embedding.".to_string()) +fn emit_state(app: &AppHandle, state: &State) -> Cmd<()> { + let tabs = lock_tabs(state)?; + app.emit("aether:state", tabs.state()) + .map_err(|error| error.to_string()) } -fn embedding_query_input(paths: &DataPaths, settings: &UserSettings, query: &str) -> String { - let catalog = model_catalog(paths, &settings.local_model); - let Some(model_path) = catalog.embedding_model.as_deref() else { - return query.to_string(); - }; - let label = model_label(model_path).to_lowercase(); - - if label.contains("qwen3-embedding") { - return format!("Instruct: {QWEN3_EMBEDDING_RETRIEVAL_INSTRUCTION}\nQuery: {query}"); - } - - query.to_string() +fn active_tab_url(state: &State) -> Cmd { + let tabs = lock_tabs(state)?; + tabs.active_tab() + .map(|tab| tab.url.clone()) + .ok_or_else(|| "No active browser tab.".to_string()) } -async fn local_embed_with_progress( - state: &State<'_, Backend>, - settings: &UserSettings, - inputs: Vec, - progress: Option, -) -> Cmd>> { - let catalog = model_catalog(&state.paths, &settings.local_model); - let model_path = catalog.embedding_model.ok_or_else(|| { - format!( - "No local embedding model found. Install AiON MiST/Qwen3 Embedding, add another embedding GGUF to {}, or set {AETHER_EMBEDDING_MODEL_ENV}.", - state.paths.models_path.display() - ) - })?; - let runtime = Arc::clone(&state.native_runtime); - task::spawn_blocking(move || { - let mut runtime = runtime - .lock() - .map_err(|_| "Local model runtime is unavailable.".to_string())?; - match progress { - Some(progress) => runtime.embed_with_progress(&model_path, inputs, Some(progress)), - None => runtime.embed(&model_path, inputs), - } - }) - .await - .map_err(|error| error.to_string())? +#[cfg(desktop)] +fn active_tab_id(state: &State) -> Cmd { + Ok(lock_tabs(state)?.active_tab_id.clone()) } -async fn local_chat( - state: &State<'_, Backend>, - settings: &UserSettings, - prompt: &str, - citations: Vec, - stream: Option, -) -> Cmd { - let started_at = Instant::now(); - let catalog = model_catalog(&state.paths, &settings.local_model); - let model_path = catalog.chat_model.ok_or_else(|| { - format!( - "No local chat GGUF model found. Add Gemma or another chat model to {} or set {AETHER_CHAT_MODEL_ENV}.", - state.paths.models_path.display() - ) - })?; - if let Some(stream) = &stream { - stream.citations(&citations); - } - let messages = build_chat_messages(prompt, &citations); - let runtime = Arc::clone(&state.native_runtime); - let cancel = Arc::clone(&state.generation_cancelled); - let model_label = model_label(&model_path); - let completion = task::spawn_blocking(move || { - let mut runtime = runtime - .lock() - .map_err(|_| "Local model runtime is unavailable.".to_string())?; - let token_stream = stream.clone(); - let on_token: Option> = token_stream - .map(|stream| Box::new(move |delta: &str| stream.delta(delta)) as Box); - let on_status: Option> = stream - .map(|stream| Box::new(move |status: String| stream.status(&status)) as Box); - runtime.complete_chat( - &model_path, - messages, - DEFAULT_GENERATION_TOKENS, - 0.2, - &cancel, - on_token, - on_status, - ) - }) - .await - .map_err(|error| error.to_string())??; - let elapsed_seconds = started_at.elapsed().as_secs_f64(); - let answer = normalize_answer_citations(&clean_model_output(&completion.text), citations.len()); - let tokens_per_second = if completion.generated_tokens > 0 && elapsed_seconds > 0.0 { - completion.generated_tokens as f64 / elapsed_seconds - } else { - 0.0 - }; - let chunks = citations.len(); - Ok(ChatResult { - answer, - model: model_label, - citations, - metrics: ChatMetrics { - generated_tokens: completion.generated_tokens, - tokens_per_second, - elapsed_seconds, - chunks, - }, - }) -} +#[cfg(test)] +mod tests { + use super::*; -async fn local_generate_iceberg( - state: &State<'_, Backend>, - settings: &UserSettings, - topic: &str, -) -> Cmd { - let catalog = model_catalog(&state.paths, &settings.local_model); - let model_path = catalog.chat_model.ok_or_else(|| { - format!( - "No local generative GGUF model found. Add Gemma or another chat model to {} or set {AETHER_CHAT_MODEL_ENV}.", - state.paths.models_path.display() - ) - })?; - let messages = vec![ChatPromptMessage { - role: "user", - content: build_iceberg_prompt(topic), - }]; - let runtime = Arc::clone(&state.native_runtime); - let cancel = Arc::clone(&state.generation_cancelled); - let model_label = model_label(&model_path); - let completion = task::spawn_blocking(move || { - let mut runtime = runtime - .lock() - .map_err(|_| "Local model runtime is unavailable.".to_string())?; - runtime.complete_chat( - &model_path, - messages, - DEFAULT_ICEBERG_GENERATION_TOKENS, - 0.35, - &cancel, - None, - None, - ) - }) - .await - .map_err(|error| error.to_string())??; - let generated = completion.text; - if state.generation_cancelled.load(AtomicOrdering::Relaxed) { - return Err("Crystallization stopped.".to_string()); - } - let generated = clean_model_output(&generated); - Ok(IcebergResult { - keyword: topic.to_string(), - model: model_label, - items: normalize_iceberg_items(&generated)?, - generated_at: now(), - }) -} + // The log's whole purpose is to exist when something has gone wrong on a + // platform nobody could test, so the write path cannot be left to be exercised + // for the first time by the failure it is meant to record. Covers the append, + // the directory being created on demand, and the rollover — which is the part + // that could silently throw away the file. + #[test] + fn diagnostics_log_appends_and_rolls_over() { + use crate::diagnostics::{DiagnosticEntry, DiagnosticLevel}; + + let dir = TempDir::new(); + // Deliberately a path whose parent does not exist yet: the first entry is + // usually written before anything has created the directory. + let path = dir.path("aether-diagnostics").join("aether.log"); + + let entry = |message: &str| DiagnosticEntry { + at: "2026-07-25T12:00:00.000Z".to_string(), + level: DiagnosticLevel::Warn, + message: message.to_string(), + }; -impl NativeModelRuntime { - fn ensure_backend(&mut self) -> Cmd<()> { - if self.backend.is_some() { - return Ok(()); - } + crate::diagnostics::write_entry(&path, &entry("first failure")); + crate::diagnostics::write_entry(&path, &entry("second failure")); - let mut backend = LlamaBackend::init().map_err(|error| error.to_string())?; - backend.void_logs(); - self.backend = Some(backend); - Ok(()) - } + let written = fs::read_to_string(&path).expect("log should exist"); + assert!(written.contains("first failure")); + assert!(written.contains("second failure")); + assert!( + written.contains("[warn]"), + "the level has to survive into the file, or an exported log cannot be \ + triaged: {written}" + ); + assert_eq!( + written.lines().count(), + 2, + "entries must append, not replace" + ); - fn ensure_model(&mut self, kind: NativeModelKind, path: &Path) -> Cmd<()> { - let path = canonical_model_path(path); - let current_path = match kind { - NativeModelKind::Chat => self.chat.as_ref().map(|loaded| loaded.path.as_path()), - NativeModelKind::Embedding => { - self.embedding.as_ref().map(|loaded| loaded.path.as_path()) - } - }; - if current_path == Some(path.as_path()) { - return Ok(()); - } + // Past the cap, the oldest half goes and the newest half stays. Discarding + // everything would leave an empty log exactly when one is wanted. + // ~44 bytes a line, so this has to clear MAX_LOG_BYTES (512 KiB). + let bulk = (0..16_000) + .map(|index| format!("2026-07-25T12:00:00.000Z [warn] filler {index}")) + .collect::>() + .join("\n"); + fs::write(&path, format!("{bulk}\n")).expect("seed a large log"); + assert!(fs::metadata(&path).expect("metadata").len() > 512 * 1024); - self.ensure_backend()?; - let backend = self - .backend - .as_ref() - .ok_or_else(|| "Local model backend is not initialized.".to_string())?; - // Mobile: load weights into anonymous memory instead of mmapping the - // GGUF. Mmapped weight pages are ordinary page cache, and Android - // evicts them under the memory pressure the native tab WebViews - // create — after which every generated token faults back to flash and - // decode slows to a crawl. Malloc'd weights are app RSS and stay put. - let use_mmap = if cfg!(mobile) { false } else { backend.supports_mmap() }; - let mut params = LlamaModelParams::default().with_use_mmap(use_mmap); - let use_gpu = match kind { - NativeModelKind::Chat => local_gpu_enabled(), - NativeModelKind::Embedding => embedding_gpu_enabled(), - }; - if use_gpu && backend.supports_gpu_offload() { - params = params.with_n_gpu_layers(999); - } else { - params = params - .with_n_gpu_layers(0) - .with_devices(&[]) - .map_err(|error| format!("Failed to select CPU model backend: {error}"))?; - } - let model = LlamaModel::load_from_file(backend, &path, ¶ms).map_err(|error| { - format!("Failed to load local model {}: {error}", model_label(&path)) - })?; - let loaded = LoadedNativeModel { path, model }; - match kind { - NativeModelKind::Chat => self.chat = Some(loaded), - NativeModelKind::Embedding => self.embedding = Some(loaded), - } - Ok(()) - } + crate::diagnostics::write_entry(&path, &entry("after rollover")); - fn embed(&mut self, model_path: &Path, inputs: Vec) -> Cmd>> { - self.embed_with_progress(model_path, inputs, None) + let rolled = fs::read_to_string(&path).expect("log should survive rollover"); + assert!( + rolled.contains("after rollover"), + "the entry that triggered the rollover must still be recorded" + ); + assert!( + rolled.contains("filler 15999"), + "the newest entries must be the ones kept" + ); + assert!( + !rolled.contains("filler 0\n"), + "the oldest entries should have been dropped" + ); + assert!(rolled.len() < bulk.len(), "the file should have shrunk"); } - fn embed_with_progress( - &mut self, - model_path: &Path, - inputs: Vec, - progress: Option, - ) -> Cmd>> { - if inputs.is_empty() { - return Ok(Vec::new()); - } - - self.ensure_model(NativeModelKind::Embedding, model_path)?; - let backend = self - .backend - .as_ref() - .ok_or_else(|| "Local model backend is not initialized.".to_string())?; - let model = &self - .embedding - .as_ref() - .ok_or_else(|| "Local embedding model is not loaded.".to_string())? - .model; - let threads = auto_thread_count(); - let total = inputs.len(); - let mut embeddings = Vec::with_capacity(total); - let mut tokenized_inputs = Vec::with_capacity(total); - - if let Some(progress) = &progress { - progress.emit_message("Tokenizing chunks", 0, total); - } + // Every colour in the renderer resolves through a channel token, and a typo in + // one is silent: `rgb(var(--surfce-rgb) / 0.7)` is simply an invalid + // declaration, so the element renders with no background at all rather than + // erroring. This catches that, and catches a channel added to the light theme + // without a dark counterpart — which is how a panel ends up white-on-navy. + #[test] + fn theme_channels_are_defined_and_themed() { + const STYLES: &str = "../src/renderer/src/assets/styles"; + let foundation = + std::fs::read_to_string(format!("{STYLES}/foundation.css")).expect("foundation.css"); + + // Definitions live in :root; the dark overrides come after it. + let root_end = foundation.find("\n}").expect("closing brace for :root"); + let (root, dark) = foundation.split_at(root_end); + + let defined: std::collections::HashSet<&str> = root + .lines() + .filter_map(|line| { + let line = line.trim(); + line.strip_prefix("--")? + .split_once(':') + .map(|(name, _)| name) + .filter(|name| name.ends_with("-rgb")) + }) + .collect(); + assert!( + defined.len() > 60, + "expected the full channel set, found {}", + defined.len() + ); - for input in inputs { - let tokens = model - .str_to_token(&input, AddBos::Always) - .map_err(|error| error.to_string())?; - if tokens.is_empty() { - return Err("Local embedding input produced no tokens.".to_string()); + let mut referenced = std::collections::BTreeSet::new(); + for entry in std::fs::read_dir(STYLES).expect("styles dir") { + let path = entry.expect("dir entry").path(); + if path.extension().is_none_or(|ext| ext != "css") { + continue; } - tokenized_inputs.push(tokens); - } - - let max_sequences = if is_qwen3_embedding_model(model_path) { - 1 - } else { - embedding_batch_size().min(16) - }; - let max_batch_tokens = embedding_batch_token_limit(); - let mut input_index = 0; - let mut batches = Vec::new(); - - while input_index < tokenized_inputs.len() { - let mut batch_token_count = 0usize; - let mut batch_end = input_index; - - while batch_end < tokenized_inputs.len() - && batch_end - input_index < max_sequences - && (batch_token_count == 0 - || batch_token_count + tokenized_inputs[batch_end].len() <= max_batch_tokens) - { - batch_token_count += tokenized_inputs[batch_end].len(); - batch_end += 1; + let css = std::fs::read_to_string(&path).expect("stylesheet"); + for (index, _) in css.match_indices("var(--") { + // Stop at the first character that cannot be part of an ident. A + // plain find(')') would run straight through `var(--x, rgb(...))` + // fallbacks and report the whole fallback as the token name. + let name: String = css[index + "var(--".len()..] + .chars() + .take_while(|c| c.is_ascii_alphanumeric() || *c == '-') + .collect(); + if name.ends_with("-rgb") { + referenced.insert(name); + } } - - batches.push((input_index, batch_end, batch_token_count)); - input_index = batch_end; } - let max_batch_token_count = batches - .iter() - .map(|(_, _, batch_token_count)| *batch_token_count) - .max() - .unwrap_or_default(); - let max_batch_sequence_count = batches + let undefined: Vec<_> = referenced .iter() - .map(|(batch_start, batch_end, _)| batch_end - batch_start) - .max() - .unwrap_or(1); - let n_ctx = embedding_context_tokens(max_batch_token_count); - if max_batch_token_count as u32 > n_ctx { - return Err(format!( - "Local embedding batch is too long for the embedding context: {} tokens exceeds {}.", - max_batch_token_count, n_ctx - )); - } - let n_batch = n_ctx.max(max_batch_token_count as u32).max(512); - let offload_embedding_ops = embedding_gpu_enabled(); - let ctx_params = LlamaContextParams::default() - .with_n_ctx(NonZeroU32::new(n_ctx)) - .with_n_seq_max(max_batch_sequence_count as u32) - .with_n_batch(n_batch) - .with_n_ubatch(n_batch) - .with_n_threads(threads) - .with_n_threads_batch(threads) - .with_embeddings(true) - .with_offload_kqv(offload_embedding_ops) - .with_op_offload(offload_embedding_ops) - .with_attention_type(embedding_attention_type(model_path)) - .with_pooling_type(embedding_pooling_type(model_path)); - let mut ctx = model - .new_context(backend, ctx_params) - .map_err(|error| error.to_string())?; - - for (batch_start, batch_end, batch_token_count) in batches { - let batch_sequence_count = batch_end - batch_start; - if let Some(progress) = &progress { - progress.emit_message( - format!( - "Embedding chunks {}-{batch_end} of {total}", - batch_start + 1 - ), - batch_start, - total, - ); - } - - ctx.clear_kv_cache(); - let mut batch = LlamaBatch::new(batch_token_count, batch_sequence_count as i32); - for (sequence_index, tokens) in - tokenized_inputs[batch_start..batch_end].iter().enumerate() - { - batch - .add_sequence(tokens, sequence_index as i32, false) - .map_err(|error| error.to_string())?; - } - // Qwen3 Embedding is decoder-style; the llama_encode path segfaults in - // llm_build_qwen3 on macOS with llama-cpp-2 0.1.146. - if qwen3_embedding_decode(model_path) { - ctx.decode(&mut batch).map_err(|error| error.to_string())?; - } else { - ctx.encode(&mut batch).map_err(|error| error.to_string())?; - } - - for sequence_index in 0..batch_sequence_count { - let embedding = ctx - .embeddings_seq_ith(sequence_index as i32) - .map_err(|error| error.to_string())?; - embeddings.push(normalize_embedding(embedding)); - } - if let Some(progress) = &progress { - progress.emit(batch_end, total); - } - } - - Ok(embeddings) - } + .filter(|name| !defined.contains(name.as_str())) + .collect(); + assert!( + undefined.is_empty(), + "channels referenced but never defined (these render as invalid CSS, \ + silently dropping the declaration): {undefined:?}" + ); - #[cfg(desktop)] - fn warm_embedding_model(&mut self, model_path: &Path) -> Cmd<()> { - self.ensure_model(NativeModelKind::Embedding, model_path) + // Surfaces, ink, and the text-only aliases decide light versus dark. A new + // one without a dark value is the single most likely way to ship a + // white panel on a navy page. + let must_flip: Vec<_> = defined + .iter() + .filter(|name| { + name.starts_with("surface") + || name.starts_with("ink") + || name.starts_with("text-") + || name.starts_with("wordmark") + || [ + "highlight-rgb", + "edge-rgb", + "night-rgb", + "muted-rgb", + "faint-rgb", + ] + .contains(name) + }) + .filter(|name| !dark.contains(&format!("--{name}:"))) + .collect(); + assert!( + must_flip.is_empty(), + "channels with no dark-theme value: {must_flip:?}" + ); } - fn complete_chat( - &mut self, - model_path: &Path, - messages: Vec, - max_tokens: usize, - temperature: f32, - cancel: &AtomicBool, - on_token: Option>, - mut on_status: Option>, - ) -> Cmd { - // The first ask pays for the multi-GB model load; on phone-class - // storage that is long enough to read as a hang without a status. - let needs_load = self - .chat - .as_ref() - .map(|loaded| loaded.path != canonical_model_path(model_path)) - .unwrap_or(true); - if needs_load { - if let Some(callback) = on_status.as_mut() { - callback(format!( - "Loading {} (first ask takes a moment)", - friendly_model_label(model_path) - )); - } - } - self.ensure_model(NativeModelKind::Chat, model_path)?; - let rendered = { - let model = &self - .chat - .as_ref() - .ok_or_else(|| "Local chat model is not loaded.".to_string())? - .model; - render_model_chat_prompt(model, &messages)? + // The AiON panel's primary button shipped broken in dark mode because one opaque + // gradient mixed a themed surface channel (which correctly inverts to navy) with + // unthemed accent channels (which stay pale). The result ran from near-black to + // pale blue under light label text. Low-alpha decorative gradients mix the two + // families harmlessly, so this only guards fills solid enough to sit under text. + #[test] + fn opaque_fills_do_not_mix_themed_and_unthemed_channels() { + const STYLES: &str = "../src/renderer/src/assets/styles"; + let foundation = + std::fs::read_to_string(format!("{STYLES}/foundation.css")).expect("foundation.css"); + + let channels_in = |block: &str| -> std::collections::HashSet { + block + .lines() + .filter_map(|line| { + let line = line.trim(); + let name = line.strip_prefix("--")?.split_once(':')?.0; + name.ends_with("-rgb").then(|| name.to_string()) + }) + .collect() }; - self.complete_loaded_prompt( - &rendered.prompt, - max_tokens, - temperature, - rendered.add_bos, - cancel, - on_token, - on_status, - ) - } - - #[allow(clippy::too_many_arguments)] - fn complete_loaded_prompt( - &mut self, - prompt: &str, - max_tokens: usize, - temperature: f32, - add_bos: AddBos, - cancel: &AtomicBool, - mut on_token: Option>, - mut on_status: Option>, - ) -> Cmd { - let backend = self - .backend - .as_ref() - .ok_or_else(|| "Local model backend is not initialized.".to_string())?; - let model = &self - .chat - .as_ref() - .ok_or_else(|| "Local chat model is not loaded.".to_string())? - .model; - let mut tokens = model - .str_to_token(prompt, add_bos) - .map_err(|error| error.to_string())?; - if tokens.is_empty() { - return Err("Local chat prompt produced no tokens.".to_string()); - } - let n_ctx = chat_context_tokens(); - let max_prompt_tokens = - n_ctx.saturating_sub((max_tokens as u32).min(1024)).max(512) as usize; - if tokens.len() > max_prompt_tokens { - tokens = tokens[tokens.len() - max_prompt_tokens..].to_vec(); - } - let n_batch = (chat_batch_token_limit() as u32).min(n_ctx).max(512); - // Mobile: small micro-batches keep the compute buffer allocation - // phone-sized and make prefill progress tick in visible steps. - let n_ubatch = if cfg!(mobile) { - n_batch.min(512) - } else { - n_batch.min(2048) - } - .max(512); - let threads = auto_thread_count(); - let offload_ops = local_gpu_enabled(); - let ctx_params = LlamaContextParams::default() - .with_n_ctx(NonZeroU32::new(n_ctx)) - .with_n_batch(n_batch) - .with_n_ubatch(n_ubatch) - .with_n_threads(threads) - .with_n_threads_batch(threads) - .with_offload_kqv(offload_ops) - .with_op_offload(offload_ops); - let mut ctx = model - .new_context(backend, ctx_params) - .map_err(|error| error.to_string())?; - - let last_prompt_index = tokens.len().saturating_sub(1); - let prompt_batch_limit = if cfg!(mobile) { - (n_batch as usize).min(512) - } else { - n_batch as usize + let root_end = foundation.find("\n}").expect("closing brace for :root"); + let (root, rest) = foundation.split_at(root_end); + let dark_start = rest + .find(":root[data-theme='dark']") + .expect("explicit dark block"); + let defined = channels_in(root); + let themed = channels_in(&rest[dark_start..]); + + // Only surfaces and ink decide light-versus-dark; a brand accent that keeps + // its hue on both themes is intentional. + let must_flip = |name: &str| { + name.starts_with("surface") || name.starts_with("ink") || name == "highlight-rgb" }; - let total_prompt_tokens = tokens.len(); - let mut prompt_cursor = 0usize; - let mut sample_index = 0; - while prompt_cursor < tokens.len() { - if cancel.load(AtomicOrdering::Relaxed) { - return Err("Generation stopped.".to_string()); - } - if let Some(callback) = on_status.as_mut() { - let percent = prompt_cursor * 100 / total_prompt_tokens; - callback(format!("Reading context {percent}%")); - } - let prompt_end = (prompt_cursor + prompt_batch_limit).min(tokens.len()); - let mut prompt_batch = LlamaBatch::new(prompt_end - prompt_cursor, 1); - for (offset, token) in tokens[prompt_cursor..prompt_end].iter().enumerate() { - let index = prompt_cursor + offset; - prompt_batch - .add(*token, index as i32, &[0], index == last_prompt_index) - .map_err(|error| error.to_string())?; - } - ctx.decode(&mut prompt_batch) - .map_err(|error| error.to_string())?; - if prompt_end == tokens.len() { - sample_index = prompt_batch.n_tokens() - 1; - } - prompt_cursor = prompt_end; - } - if let Some(callback) = on_status.as_mut() { - callback("Generating answer".to_string()); - } - let mut sampler = LlamaSampler::chain_simple([ - LlamaSampler::top_k(DEFAULT_TOP_K), - LlamaSampler::top_p(DEFAULT_TOP_P, 1), - LlamaSampler::temp(temperature), - LlamaSampler::dist(0xA371_2026), - ]); - let mut decoder = UTF_8.new_decoder(); - let mut output = String::new(); - let mut generated_tokens = 0usize; - let mut streamed_len = 0usize; - let mut batch = LlamaBatch::new(1, 1); - let mut position = tokens.len() as i32; - - for _ in 0..max_tokens { - if cancel.load(AtomicOrdering::Relaxed) { - break; - } - let token = sampler.sample(&ctx, sample_index); - if model.is_eog_token(token) { - break; - } - generated_tokens = generated_tokens.saturating_add(1); - let piece = model - .token_to_piece(token, &mut decoder, true, None) - .map_err(|error| error.to_string())?; - output.push_str(&piece); - if contains_stop_marker(&output) { - break; + let mut offenders = Vec::new(); + for entry in std::fs::read_dir(STYLES).expect("styles dir") { + let path = entry.expect("dir entry").path(); + if path.extension().is_none_or(|ext| ext != "css") { + continue; } - if let Some(on_token) = on_token.as_mut() { - let safe_end = stream_safe_len(&output); - if safe_end > streamed_len { - on_token(&output[streamed_len..safe_end]); - streamed_len = safe_end; + let css = std::fs::read_to_string(&path).expect("stylesheet"); + for declaration in css.split(';') { + let Some((prop, value)) = declaration.split_once(':') else { + continue; + }; + if prop.trim_start_matches(['{', '}', '\n', ' ']).trim() != "background" { + continue; + } + // color-mix() composites by percentage, not by the `/ alpha` this + // check reads, so its channels would all look opaque. Those call + // sites are checked by eye instead. + if value.contains("color-mix") { + continue; } - } - - batch.clear(); - batch - .add(token, position, &[0], true) - .map_err(|error| error.to_string())?; - ctx.decode(&mut batch).map_err(|error| error.to_string())?; - sample_index = batch.n_tokens() - 1; - position += 1; - } - - if output.trim().is_empty() && cancel.load(AtomicOrdering::Relaxed) { - return Err("Generation stopped.".to_string()); - } - - Ok(ChatCompletion { - text: output, - generated_tokens, - }) - } -} - -#[derive(Clone)] -struct ModelDownloadSpec { - id: &'static str, - label: &'static str, - repository: &'static str, - revision: &'static str, - filename: &'static str, - destination: PathBuf, - expected_bytes: u64, -} - -impl ModelDownloadSpec { - fn source_url(&self) -> String { - format!( - "https://huggingface.co/{}/resolve/{}/{}?download=true", - self.repository, self.revision, self.filename - ) - } -} - -async fn download_managed_models( - app: &AppHandle, - state: &State<'_, Backend>, - input: DownloadModelsInput, -) -> Cmd<()> { - let specs = selected_model_downloads(&state.paths, &input)?; - let hf_token = input - .hf_token - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(str::to_string) - .or_else(huggingface_token); - let overall_total = specs - .iter() - .map(|spec| spec.expected_bytes) - .reduce(|first, second| first.saturating_add(second)); - let mut overall_downloaded = 0u64; - - for spec in &specs { - if let Some(existing_bytes) = - completed_model_bytes(&spec.destination, spec.expected_bytes).await - { - overall_downloaded = overall_downloaded.saturating_add(existing_bytes); - emit_model_download_progress( - app, - spec, - "skipped", - existing_bytes, - Some(spec.expected_bytes), - overall_downloaded, - overall_total, - Some("Already installed".to_string()), - ); - continue; - } - emit_model_download_progress( - app, - spec, - "queued", - 0, - Some(spec.expected_bytes), - overall_downloaded, - overall_total, - Some("Preparing download".to_string()), - ); + let mut flips = Vec::new(); + let mut fixed = Vec::new(); + for (index, _) in value.match_indices("rgb(var(--") { + let tail = &value[index + "rgb(var(--".len()..]; + let name: String = tail + .chars() + .take_while(|c| c.is_ascii_alphanumeric() || *c == '-') + .collect(); + if !defined.contains(&name) { + continue; + } + // Alpha follows the name as `) / 0.42)`; absent means fully opaque. + // The name has to be stepped over first, or the `/` search finds + // the one in a later stop and every fill reads as opaque. + let after_name = &tail[name.len()..]; + let alpha = after_name + .strip_prefix(')') + .map(str::trim_start) + .and_then(|rest| rest.strip_prefix('/')) + .and_then(|rest| rest.trim_start().split(')').next()) + .and_then(|rest| rest.trim().parse::().ok()) + .unwrap_or(1.0); + if alpha < 0.9 { + continue; + } + if must_flip(&name) { + flips.push(name); + } else if !themed.contains(&name) { + fixed.push(name); + } + } - match download_model_file( - app, - &state.client, - spec, - overall_downloaded, - overall_total, - hf_token.as_deref(), - ) - .await - { - Ok(downloaded_bytes) => { - overall_downloaded = overall_downloaded.saturating_add(downloaded_bytes); - emit_model_download_progress( - app, - spec, - "complete", - downloaded_bytes, - Some(downloaded_bytes), - overall_downloaded, - overall_total, - Some("Installed".to_string()), - ); - } - Err(error) => { - emit_model_download_progress( - app, - spec, - "error", - 0, - Some(spec.expected_bytes), - overall_downloaded, - overall_total, - Some(error.clone()), - ); - return Err(error); + if !flips.is_empty() && !fixed.is_empty() { + offenders.push(format!( + "{}: {} (inverts) mixed with {} (does not)", + path.file_name().unwrap_or_default().to_string_lossy(), + flips.join(", "), + fixed.join(", ") + )); + } } } - } - - persist_downloaded_model_selection(&state.paths, &specs).await -} - -fn selected_model_downloads( - paths: &DataPaths, - input: &DownloadModelsInput, -) -> Cmd> { - let mut specs = vec![managed_model_spec(paths, "mist")?]; - let mut selected = HashSet::new(); - - for model in &input.chat_models { - let normalized = model.trim().to_lowercase(); - if normalized.is_empty() { - continue; - } - if !selected.insert(normalized.clone()) { - continue; - } - specs.push(managed_model_spec(paths, &normalized)?); - } - - Ok(specs) -} - -fn managed_model_spec(paths: &DataPaths, id: &str) -> Cmd { - match id { - "mist" => Ok(ModelDownloadSpec { - id: "mist", - label: "AiON MiST", - repository: "Qwen/Qwen3-Embedding-0.6B-GGUF", - revision: "370f27d7550e0def9b39c1f16d3fbaa13aa67728", - filename: "Qwen3-Embedding-0.6B-Q8_0.gguf", - destination: paths - .models_path - .join("embeddings") - .join("Qwen3-Embedding-0.6B-GGUF") - .join("Qwen3-Embedding-0.6B-Q8_0.gguf"), - expected_bytes: 639_150_592, - }), - "lite" => Ok(ModelDownloadSpec { - id: "lite", - label: "AiON LiTE", - repository: "google/gemma-4-E2B-it-qat-q4_0-gguf", - revision: "1894d1fc0a19d86697abd40483f5983c867df03f", - filename: "gemma-4-E2B_q4_0-it.gguf", - destination: paths - .models_path - .join("chat") - .join("gemma-4-E2B-it-qat-q4_0-gguf") - .join("gemma-4-E2B_q4_0-it.gguf"), - expected_bytes: 3_349_514_112, - }), - "wise" => Ok(ModelDownloadSpec { - id: "wise", - label: "AiON WiSE", - repository: "google/gemma-4-E4B-it-qat-q4_0-gguf", - revision: "bb3b92e6f031fa438b409f898dd9f14f499a0cb0", - filename: "gemma-4-E4B_q4_0-it.gguf", - destination: paths - .models_path - .join("chat") - .join("gemma-4-E4B-it-qat-q4_0-gguf") - .join("gemma-4-E4B_q4_0-it.gguf"), - expected_bytes: 5_154_939_136, - }), - _ => Err(format!("Unknown AiON model selection: {id}")), - } -} - -async fn completed_model_bytes(path: &Path, expected_bytes: u64) -> Option { - let metadata = tokio::fs::metadata(path).await.ok()?; - let len = metadata.len(); - if metadata.is_file() && len == expected_bytes { - Some(len) - } else { - None - } -} - -async fn download_model_file( - app: &AppHandle, - client: &Client, - spec: &ModelDownloadSpec, - overall_base_bytes: u64, - overall_total_bytes: Option, - hf_token: Option<&str>, -) -> Cmd { - let parent = spec - .destination - .parent() - .ok_or_else(|| format!("Invalid model destination: {}", spec.destination.display()))?; - tokio::fs::create_dir_all(parent).await.map_err(|error| { - format!( - "Could not create model directory {}: {error}", - parent.display() - ) - })?; - - let temp_path = spec - .destination - .with_file_name(format!("{}.part", spec.filename)); - let _ = tokio::fs::remove_file(&temp_path).await; - - let mut request = client.get(spec.source_url()); - if let Some(token) = hf_token { - request = request.bearer_auth(token); - } - - let mut response = request - .send() - .await - .map_err(|error| format!("Could not reach Hugging Face for {}: {error}", spec.label))?; - let status = response.status(); - if !status.is_success() { - return Err(huggingface_download_error(spec, status.as_u16())); - } - - let total_bytes = response.content_length().or(Some(spec.expected_bytes)); - let mut file = tokio::fs::File::create(&temp_path).await.map_err(|error| { - format!( - "Could not create temporary model file {}: {error}", - temp_path.display() - ) - })?; - let mut downloaded_bytes = 0u64; - let mut last_emit = Instant::now(); - - emit_model_download_progress( - app, - spec, - "downloading", - downloaded_bytes, - total_bytes, - overall_base_bytes, - overall_total_bytes, - Some("Downloading from Hugging Face".to_string()), - ); - while let Some(chunk) = response - .chunk() - .await - .map_err(|error| format!("Download interrupted for {}: {error}", spec.label))? - { - file.write_all(&chunk) - .await - .map_err(|error| format!("Could not write {}: {error}", spec.filename))?; - downloaded_bytes = downloaded_bytes.saturating_add(chunk.len() as u64); - - if last_emit.elapsed() >= Duration::from_millis(160) { - emit_model_download_progress( - app, - spec, - "downloading", - downloaded_bytes, - total_bytes, - overall_base_bytes.saturating_add(downloaded_bytes), - overall_total_bytes, - None, - ); - last_emit = Instant::now(); - } + assert!( + offenders.is_empty(), + "opaque fills mixing themed and unthemed channels render incoherently in \ + dark mode:\n {}", + offenders.join("\n ") + ); } - file.flush() - .await - .map_err(|error| format!("Could not finalize {}: {error}", spec.filename))?; - drop(file); - - if let Some(total) = total_bytes { - if downloaded_bytes != total { - let _ = tokio::fs::remove_file(&temp_path).await; - return Err(format!( - "Downloaded {} bytes for {}, expected {}.", - downloaded_bytes, spec.label, total - )); + #[test] + fn foreground_channels_clear_wcag_aa_on_their_surfaces() { + let foundation = + std::fs::read_to_string("../src/renderer/src/assets/styles/foundation.css") + .expect("foundation.css"); + let root_end = foundation.find("\n}").expect("closing brace for :root"); + let (light, rest) = foundation.split_at(root_end); + let dark_start = rest + .find(":root[data-theme='dark']") + .expect("explicit dark block"); + let dark = &rest[dark_start..]; + + fn channel(block: &str, name: &str) -> [f64; 3] { + let prefix = format!("--{name}:"); + let value = block + .lines() + .map(str::trim) + .find_map(|line| line.strip_prefix(&prefix)) + .unwrap_or_else(|| panic!("missing channel {name}")); + let values: Vec = value + .trim() + .trim_end_matches(';') + .split_whitespace() + .map(|part| { + part.parse::() + .unwrap_or_else(|_| panic!("invalid channel {name}: {value}")) + }) + .collect(); + assert_eq!(values.len(), 3, "channel {name} must contain three values"); + [values[0], values[1], values[2]] } - } - - let _ = tokio::fs::remove_file(&spec.destination).await; - tokio::fs::rename(&temp_path, &spec.destination) - .await - .map_err(|error| { - format!( - "Could not move {} into {}: {error}", - temp_path.display(), - spec.destination.display() - ) - })?; - - Ok(downloaded_bytes) -} - -async fn persist_downloaded_model_selection( - paths: &DataPaths, - specs: &[ModelDownloadSpec], -) -> Cmd<()> { - let mut settings = load_settings(&paths.settings_path).await?; - if let Some(embedding) = specs.iter().find(|spec| spec.id == "mist") { - settings.local_model.embedding_model = Some(embedding.destination.display().to_string()); - } - if let Some(chat) = specs - .iter() - .rev() - .find(|spec| spec.id == "lite" || spec.id == "wise") - { - settings.local_model.chat_model = Some(chat.destination.display().to_string()); - } - save_json(&paths.settings_path, &settings).await -} -fn huggingface_token() -> Option { - [ - HF_TOKEN_ENV, - HUGGINGFACE_HUB_TOKEN_ENV, - HUGGING_FACE_HUB_TOKEN_ENV, - ] - .iter() - .find_map(|var| env::var(var).ok()) - .map(|value| value.trim().to_string()) - .filter(|value| !value.is_empty()) -} - -fn huggingface_download_error(spec: &ModelDownloadSpec, status: u16) -> String { - let auth_hint = if status == 401 || status == 403 { - format!( - " Accept the model terms on Hugging Face, then paste a Hugging Face read token in setup. Advanced users can also launch ÆTHER with {HF_TOKEN_ENV} or {HUGGINGFACE_HUB_TOKEN_ENV} set." - ) - } else { - String::new() - }; - format!( - "Could not download {} from official Hugging Face source {}/{} ({status}).{}", - spec.label, spec.repository, spec.filename, auth_hint - ) -} - -fn emit_model_download_progress( - app: &AppHandle, - spec: &ModelDownloadSpec, - status: &str, - downloaded_bytes: u64, - total_bytes: Option, - overall_downloaded_bytes: u64, - overall_total_bytes: Option, - message: Option, -) { - let _ = app.emit( - AETHER_MODEL_DOWNLOAD_PROGRESS_EVENT, - ModelDownloadProgress { - id: spec.id.to_string(), - label: spec.label.to_string(), - filename: spec.filename.to_string(), - status: status.to_string(), - downloaded_bytes, - total_bytes, - overall_downloaded_bytes, - overall_total_bytes, - message, - }, - ); -} - -fn model_catalog(paths: &DataPaths, settings: &LocalModelSettings) -> ModelCatalog { - let mut errors = Vec::new(); - let model_dirs = [ - paths.models_path.clone(), - paths.models_path.join("chat"), - paths.models_path.join("embeddings"), - ]; - for dir in &model_dirs { - if let Err(error) = fs::create_dir_all(dir) { - errors.push(format!( - "Could not create model directory {}: {error}", - dir.display() - )); + fn luminance(rgb: [f64; 3]) -> f64 { + let linear = |value: f64| { + let value = value / 255.0; + if value <= 0.04045 { + value / 12.92 + } else { + ((value + 0.055) / 1.055).powf(2.4) + } + }; + 0.2126 * linear(rgb[0]) + 0.7152 * linear(rgb[1]) + 0.0722 * linear(rgb[2]) } - } - let mut models = Vec::new(); - collect_gguf_models(&paths.models_path, &mut models); - if let Ok(dir) = env::var(AETHER_MODEL_DIR_ENV) { - let dir = PathBuf::from(dir); - collect_gguf_models(&dir, &mut models); - } - for var in [AETHER_CHAT_MODEL_ENV, AETHER_EMBEDDING_MODEL_ENV] { - match env_model_path(var) { - Ok(Some(path)) => models.push(path), - Ok(None) => {} - Err(error) => errors.push(error), - } - } - for (value, embedding) in [ - (settings.chat_model.as_deref(), false), - (settings.embedding_model.as_deref(), true), - ] { - if let Some(path) = value.and_then(|value| selected_direct_model_path(value, embedding)) { - models.push(path); + fn contrast(foreground: [f64; 3], background: [f64; 3]) -> f64 { + let foreground = luminance(foreground); + let background = luminance(background); + (foreground.max(background) + 0.05) / (foreground.min(background) + 0.05) } - } - models = dedupe_model_paths(models); - let embedding_model = pick_embedding_model(&models, settings); - let chat_model = pick_chat_model(&models, settings); - if models.is_empty() { - errors.push(format!( - "No local models found. Use Model Setup to install AiON MiST/Qwen3 Embedding and Gemma 4, add compatible GGUF models to {}, or set {AETHER_MODEL_DIR_ENV}.", - paths.models_path.display() - )); - } else { - if embedding_model.is_none() { - errors.push(format!( - "No embedding model selected. Put Qwen3 Embedding or another embedding GGUF in {} or set {AETHER_EMBEDDING_MODEL_ENV}.", - paths.models_path.join("embeddings").display() - )); + fn composite(foreground: [f64; 3], alpha: f64, background: [f64; 3]) -> [f64; 3] { + [ + foreground[0] * alpha + background[0] * (1.0 - alpha), + foreground[1] * alpha + background[1] * (1.0 - alpha), + foreground[2] * alpha + background[2] * (1.0 - alpha), + ] } - if chat_model.is_none() { - errors.push(format!( - "No chat GGUF selected. Put a Gemma chat model in {} or set {AETHER_CHAT_MODEL_ENV}.", - paths.models_path.join("chat").display() - )); + + for (theme, block) in [("light", light), ("dark", dark)] { + let surface = channel(block, "surface-rgb"); + let surface_sky = channel(block, "surface-sky-rgb"); + let surface_tint = channel(block, "surface-tint-rgb"); + let success = channel(block, "success-rgb"); + let success_badge = composite(success, 0.1, surface); + let checks = [ + ("muted-rgb", surface_sky), + ("accent-strong-rgb", surface_sky), + ("text-accent-rgb", surface_sky), + ("slate-blue-rgb", surface_tint), + ("text-success-rgb", success_badge), + ("primary-label-rgb", channel(block, "primary-from-rgb")), + ("primary-label-rgb", channel(block, "primary-mid-rgb")), + ("primary-label-rgb", channel(block, "primary-to-rgb")), + ]; + + for (foreground, background) in checks { + let ratio = contrast(channel(block, foreground), background); + assert!( + ratio >= 4.5, + "{theme} {foreground} has only {ratio:.2}:1 contrast" + ); + } } } - ModelCatalog { - models, - chat_model, - embedding_model, - error: if errors.is_empty() { + // The shipped config carries an empty pubkey placeholder, and that has to read + // as "not configured" rather than as a key — otherwise the app offers an + // Install button that downloads a hundred megabytes and then fails signature + // verification. A pasted key with stray whitespace has to work. + #[cfg(desktop)] + #[test] + fn updater_pubkey_treats_a_placeholder_as_unconfigured() { + let placeholder = serde_json::json!({ "pubkey": "" }); + assert_eq!(updater_pubkey_from_config(Some(&placeholder)), None); + + let whitespace = serde_json::json!({ "pubkey": " \n" }); + assert_eq!(updater_pubkey_from_config(Some(&whitespace)), None); + + assert_eq!(updater_pubkey_from_config(None), None); + assert_eq!( + updater_pubkey_from_config(Some(&serde_json::json!({}))), None - } else { - Some(errors.join(" ")) - }, - } -} + ); -fn selected_direct_model_path(value: &str, embedding: bool) -> Option { - let value = value.trim(); - if value.is_empty() { - return None; - } - let path = PathBuf::from(value); - if selected_model_matches_kind(&path, embedding) { - Some(canonical_model_path(&path)) - } else { - None - } -} + let real = serde_json::json!({ "pubkey": " dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWdu\n" }); + assert_eq!( + updater_pubkey_from_config(Some(&real)).as_deref(), + Some("dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWdu") + ); -fn collect_gguf_models(root: &Path, models: &mut Vec) { - let mut stack = vec![(root.to_path_buf(), 0usize)]; - while let Some((dir, depth)) = stack.pop() { - if depth > 4 { - continue; - } - let Ok(entries) = fs::read_dir(&dir) else { - continue; - }; - for entry in entries.flatten() { - let path = entry.path(); - if path.is_dir() { - stack.push((path, depth + 1)); - } else if is_gguf_model(&path) { - models.push(path); - } - } + // And the real config file must still be a placeholder: committing a key + // here would be committing a trust anchor nobody reviewed. + let config: serde_json::Value = serde_json::from_str(include_str!("../tauri.conf.json")) + .expect("tauri.conf.json should be valid JSON"); + let shipped = updater_pubkey_from_config( + config + .get("plugins") + .and_then(|plugins| plugins.get("updater")), + ); + assert!( + shipped.is_none() || shipped.as_deref().is_some_and(|key| key.len() > 40), + "plugins.updater.pubkey should be either empty or a real minisign key" + ); } -} -fn default_models_path(app_data_dir: &Path) -> PathBuf { - // The repo-relative dev path is a compile-time string from the build - // machine; on a phone it would point at a nonexistent host filesystem. - if cfg!(all(debug_assertions, desktop)) { - project_models_path() - } else { - app_data_dir.join("aether-models") + // tauri-plugin-updater deserializes `plugins.updater` during plugin setup, and + // its `pubkey` field has no serde default — a missing or misshapen block fails + // app startup outright, not the update path. Since the key is pasted in by hand + // when signing is set up (docs/SIGNING.md), this checks the real config file + // rather than a fixture. + #[cfg(desktop)] + #[test] + fn updater_plugin_config_deserializes() { + let config: serde_json::Value = serde_json::from_str(include_str!("../tauri.conf.json")) + .expect("tauri.conf.json should be valid JSON"); + let updater = config + .get("plugins") + .and_then(|plugins| plugins.get("updater")) + .expect("plugins.updater should be configured"); + + let parsed: tauri_plugin_updater::Config = serde_json::from_value(updater.clone()) + .expect("plugins.updater must deserialize or the app fails to start"); + + // Empty endpoints make Updater::build() fail with EmptyEndpoints, which + // would surface to the user as a broken Install button. + assert!( + !parsed.endpoints.is_empty(), + "at least one update endpoint is required" + ); + assert!( + parsed.endpoints.iter().all(|url| url.scheme() == "https"), + "release builds reject non-https update endpoints" + ); } -} - -fn project_models_path() -> PathBuf { - PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .parent() - .map(|path| path.join("aether-models")) - .unwrap_or_else(|| PathBuf::from("aether-models")) -} -fn dedupe_model_paths(models: Vec) -> Vec { - let mut seen = HashSet::new(); - let mut deduped = Vec::new(); - for path in models { - let path = canonical_model_path(&path); - let key = path.display().to_string(); - if seen.insert(key) { - deduped.push(path); - } + // The release workflow builds latest.json in bash (scripts/updater-manifest.sh) + // and the app reads it through tauri-plugin-updater, so nothing else checks + // that the two agree. A renamed field or a wrong nesting would be invisible + // until a real user pressed Install on a real release — the one place a + // mistake cannot be taken back. This deserializes the script's exact output + // with the plugin's own type. + // + // Regenerate with: + // scripts/updater-manifest.sh v1.0.30 CanPixel/aether /dev/stdout + #[cfg(desktop)] + #[test] + fn updater_manifest_matches_the_plugin_format() { + let manifest = r#"{ + "version": "1.0.30", + "notes": "See https://github.com/CanPixel/aether/releases/tag/v1.0.30", + "pub_date": "2026-07-25T12:32:59Z", + "platforms": { + "darwin-aarch64": { + "signature": "MACSIG==", + "url": "https://github.com/CanPixel/aether/releases/download/v1.0.30/AETHER_macOS.app.tar.gz" + }, + "darwin-x86_64": { + "signature": "MACSIG==", + "url": "https://github.com/CanPixel/aether/releases/download/v1.0.30/AETHER_macOS.app.tar.gz" + }, + "windows-x86_64": { + "signature": "WINSIG==", + "url": "https://github.com/CanPixel/aether/releases/download/v1.0.30/AETHER_x64-setup.exe" + }, + "linux-x86_64": { + "signature": "LXSIG==", + "url": "https://github.com/CanPixel/aether/releases/download/v1.0.30/AETHER_amd64.AppImage" } - deduped.sort_by_key(|path| model_label(path).to_lowercase()); - deduped -} + } +}"#; + + let release: tauri_plugin_updater::RemoteRelease = + serde_json::from_str(manifest).expect("plugin should accept the generated manifest"); + + assert_eq!(release.version.to_string(), "1.0.30"); + + // These keys are `{os}-{arch}` as the plugin derives them at runtime. A + // typo here means the updater reports "no build for this platform" on a + // release that does in fact contain one. + for target in [ + "darwin-aarch64", + "darwin-x86_64", + "windows-x86_64", + "linux-x86_64", + ] { + let url = release + .download_url(target) + .unwrap_or_else(|error| panic!("no download url for {target}: {error}")); + assert!( + url.as_str() + .starts_with("https://github.com/CanPixel/aether/releases/download/v1.0.30/"), + "{target} url should be pinned to the tag, not to /latest/: {url}" + ); + assert!( + release.signature(target).is_ok(), + "no signature for {target}" + ); + } -fn env_model_path(var: &str) -> Cmd> { - let Ok(value) = env::var(var) else { - return Ok(None); - }; - let path = PathBuf::from(value.trim()); - let valid = match var { - AETHER_CHAT_MODEL_ENV => is_chat_model(&path), - AETHER_EMBEDDING_MODEL_ENV => is_embedding_model(&path), - _ => is_gguf_model(&path), - }; - if valid { - Ok(Some(path)) - } else { - Err(format!( - "{var} does not point to an existing local model: {}", - path.display() - )) + // Linux ARM ships as a .deb only, so it is deliberately absent. The app + // turns this into the `unavailable` status rather than an error. + assert!(release.download_url("linux-aarch64").is_err()); } -} -fn pick_embedding_model(models: &[PathBuf], settings: &LocalModelSettings) -> Option { - if let Ok(Some(path)) = env_model_path(AETHER_EMBEDDING_MODEL_ENV) { - return Some(canonical_model_path(&path)); - } - if let Some(model) = settings - .embedding_model - .as_deref() - .and_then(|value| pick_selected_model(models, value, true)) - { - return Some(model); + fn block_on(future: F) -> F::Output { + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("test runtime") + .block_on(future) } - models - .iter() - .filter(|path| is_embedding_model(path)) - .max_by_key(|path| embedding_model_score(path)) - .cloned() -} -fn pick_chat_model(models: &[PathBuf], settings: &LocalModelSettings) -> Option { - if let Ok(Some(path)) = env_model_path(AETHER_CHAT_MODEL_ENV) { - return Some(canonical_model_path(&path)); - } - if let Some(model) = settings - .chat_model - .as_deref() - .and_then(|value| pick_selected_model(models, value, false)) - { - return Some(model); - } - pick_model_by_hints(models, &PREFERRED_CHAT_MODEL_HINTS, false).or_else(|| { - models - .iter() - .find(|path| !is_embedding_model_name(path)) - .cloned() - }) -} + struct TempDir(PathBuf); -fn pick_selected_model(models: &[PathBuf], value: &str, embedding: bool) -> Option { - let value = value.trim(); - if value.is_empty() { - return None; - } - let direct = PathBuf::from(value); - if selected_model_matches_kind(&direct, embedding) { - return Some(canonical_model_path(&direct)); - } - let normalized = value.to_lowercase(); - models - .iter() - .find(|path| { - let label = model_label(path); - path_to_model_value(path) == value - || label == value - || strip_gguf_extension(&label) == value - || label.to_lowercase().contains(&normalized) - }) - .filter(|path| selected_model_matches_kind(path, embedding)) - .cloned() -} + impl TempDir { + fn new() -> Self { + let path = env::temp_dir().join(format!("aether-store-test-{}", uuid())); + fs::create_dir_all(&path).expect("temp dir"); + Self(path) + } -fn pick_model_by_hints(models: &[PathBuf], hints: &[&str], embedding: bool) -> Option { - for hint in hints { - let hint = hint.to_lowercase(); - if let Some(model) = models.iter().find(|path| { - let label = model_label(path).to_lowercase(); - label.contains(&hint) - && if embedding { - is_embedding_model(path) - } else { - is_chat_model(path) - } - }) { - return Some(model.clone()); + fn path(&self, name: &str) -> PathBuf { + self.0.join(name) } } - None -} -fn embedding_model_score(path: &Path) -> i32 { - let label = model_label(path).to_lowercase(); - let mut score = 0; - if is_gguf_model(path) { - score += 1_000; - } - if label.contains("qwen3-embedding") { - score += 650; - } - if label.contains("bf16") { - score += 400; - } else if label.contains("f16") { - score += 300; - } else if label.contains("q8") { - score += 150; + impl Drop for TempDir { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } } - score -} -fn embedding_pooling_type(path: &Path) -> LlamaPoolingType { - if is_qwen3_embedding_model(path) { - LlamaPoolingType::Last - } else { - LlamaPoolingType::Mean + fn library_marked(marker: &str) -> LibraryData { + let mut data = LibraryData::default(); + data.migrated_realm_tables.push(marker.to_string()); + data } -} -fn embedding_attention_type(path: &Path) -> LlamaAttentionType { - if is_qwen3_embedding_model(path) { - LlamaAttentionType::Causal - } else { - LlamaAttentionType::Unspecified + fn marker_of(data: &LibraryData) -> Option<&str> { + data.migrated_realm_tables.first().map(String::as_str) } -} -fn is_qwen3_embedding_model(path: &Path) -> bool { - let label = model_label(path).to_lowercase(); - label.contains("qwen3-embedding") -} + fn corrupt_count(dir: &Path) -> usize { + fs::read_dir(dir) + .expect("read temp dir") + .filter_map(Result::ok) + .filter(|entry| entry.file_name().to_string_lossy().contains(".corrupt-")) + .count() + } -fn qwen3_embedding_decode(path: &Path) -> bool { - is_qwen3_embedding_model(path) -} + #[test] + fn store_save_rotates_previous_version_into_backup() { + let dir = TempDir::new(); + let path = dir.path("library.json"); -fn is_gguf_model(path: &Path) -> bool { - path.is_file() - && !is_mmproj_model(path) - && path - .extension() - .and_then(|extension| extension.to_str()) - .is_some_and(|extension| extension.eq_ignore_ascii_case("gguf")) -} + block_on(save_json(&path, &library_marked("first"))).expect("first save"); + block_on(save_json(&path, &library_marked("second"))).expect("second save"); -fn is_chat_model(path: &Path) -> bool { - is_gguf_model(path) && !is_embedding_model_name(path) -} + let primary = block_on(read_json_or_default::(&path)).expect("read primary"); + assert_eq!(marker_of(&primary), Some("second")); -fn selected_model_matches_kind(path: &Path, embedding: bool) -> bool { - if embedding { - is_embedding_model(path) - } else { - is_chat_model(path) + let backup_raw = fs::read_to_string(backup_path(&path)).expect("backup exists"); + let backup: LibraryData = serde_json::from_str(&backup_raw).expect("backup parses"); + assert_eq!(marker_of(&backup), Some("first")); } -} -fn is_embedding_model(path: &Path) -> bool { - is_gguf_model(path) && is_embedding_model_name(path) -} + #[test] + fn store_save_leaves_no_temp_file_behind() { + let dir = TempDir::new(); + let path = dir.path("library.json"); -fn is_embedding_model_name(path: &Path) -> bool { - let label = model_label(path).to_lowercase(); - label.contains("embed") || label.contains("embedding") -} + block_on(save_json(&path, &library_marked("only"))).expect("save"); -fn is_mmproj_model(path: &Path) -> bool { - model_label(path).to_lowercase().contains("mmproj") -} + assert!( + !temp_write_path(&path).exists(), + "temp file should be renamed over the target, not left behind" + ); + } -fn canonical_model_path(path: &Path) -> PathBuf { - fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf()) -} + // The failure this guards against: a crash mid-save truncates the store and the + // user loses every capture. Recovery must be automatic and must not destroy the + // damaged file, so it stays available for manual inspection. + #[test] + fn store_read_recovers_from_backup_when_primary_is_truncated() { + let dir = TempDir::new(); + let path = dir.path("library.json"); -fn path_to_model_value(path: &PathBuf) -> String { - path.display().to_string() -} + block_on(save_json(&path, &library_marked("good"))).expect("first save"); + block_on(save_json(&path, &library_marked("newer"))).expect("second save"); + // Simulate a save that was interrupted partway through. + fs::write(&path, "{\"version\":1,\"collec").expect("truncate primary"); -fn model_label(path: &Path) -> String { - path.file_name() - .and_then(|name| name.to_str()) - .map(strip_gguf_extension) - .unwrap_or_else(|| path.display().to_string()) -} + let recovered = block_on(read_json_or_default::(&path)).expect("recover"); -/// Product name for user-facing status text. Filenames must stay in sync with -/// `managed_model_spec`; anything unmanaged falls back to its cleaned filename. -fn friendly_model_label(path: &Path) -> String { - match path.file_name().and_then(|name| name.to_str()) { - Some("gemma-4-E2B_q4_0-it.gguf") => "AiON LiTE".to_string(), - Some("gemma-4-E4B_q4_0-it.gguf") => "AiON WiSE".to_string(), - Some("Qwen3-Embedding-0.6B-Q8_0.gguf") => "AiON MiST".to_string(), - _ => model_label(path), + assert_eq!(marker_of(&recovered), Some("good")); + assert_eq!( + corrupt_count(&dir.0), + 1, + "damaged primary must be preserved" + ); + // The restored primary must itself be readable on the next launch. + let reread = block_on(read_json_or_default::(&path)).expect("reread"); + assert_eq!(marker_of(&reread), Some("good")); } -} -fn strip_gguf_extension(value: &str) -> String { - value - .strip_suffix(".gguf") - .or_else(|| value.strip_suffix(".GGUF")) - .unwrap_or(value) - .to_string() -} + #[test] + fn store_read_falls_back_to_default_without_destroying_unreadable_files() { + let dir = TempDir::new(); + let path = dir.path("library.json"); -fn chat_context_tokens() -> u32 { - // Phones get half the desktop window: the KV cache plus compute buffers - // for 6k context put a multi-GB model into zram-thrashing territory, - // which reads as a silent hang during prefill. - let default = if cfg!(mobile) { - 3072 - } else { - DEFAULT_CHAT_CONTEXT_TOKENS - }; - env::var(AETHER_LLM_CONTEXT_ENV) - .ok() - .and_then(|value| value.parse::().ok()) - .unwrap_or(default) - .clamp(1024, 65_536) -} + block_on(save_json(&path, &library_marked("good"))).expect("save"); + block_on(save_json(&path, &library_marked("newer"))).expect("rotate"); + fs::write(&path, "not json").expect("corrupt primary"); + fs::write(backup_path(&path), "also not json").expect("corrupt backup"); -fn chat_batch_token_limit() -> usize { - env::var(AETHER_LLM_BATCH_TOKENS_ENV) - .ok() - .and_then(|value| value.parse::().ok()) - .unwrap_or(DEFAULT_CHAT_BATCH_TOKENS) - .clamp(512, 8192) -} + let fresh = block_on(read_json_or_default::(&path)).expect("default"); -fn local_gpu_enabled() -> bool { - env_flag_enabled(AETHER_LLM_GPU_ENV, cfg!(target_os = "macos")) -} + assert_eq!(marker_of(&fresh), None); + assert_eq!(corrupt_count(&dir.0), 1, "unreadable primary must be kept"); + } -fn embedding_gpu_enabled() -> bool { - env_flag_enabled(AETHER_EMBED_GPU_ENV, false) -} + #[test] + fn store_read_seeds_a_default_when_nothing_exists_yet() { + let dir = TempDir::new(); + let path = dir.path("nested").join("library.json"); -fn env_flag_enabled(name: &str, default: bool) -> bool { - env::var(name).ok().map_or(default, |value| { - matches!(value.to_lowercase().as_str(), "1" | "true" | "yes" | "on") - }) -} + let seeded = block_on(read_json_or_default::(&path)).expect("seed"); -fn embedding_batch_size() -> usize { - env::var(AETHER_EMBED_BATCH_ENV) - .ok() - .and_then(|value| value.parse::().ok()) - .unwrap_or(DEFAULT_EMBEDDING_BATCH_SIZE) - .clamp(1, 24) -} + assert_eq!(marker_of(&seeded), None); + assert!(path.exists(), "first read should create the store"); + assert_eq!(corrupt_count(&dir.0), 0); + } -fn embedding_batch_token_limit() -> usize { - env::var(AETHER_EMBED_BATCH_TOKENS_ENV) - .ok() - .and_then(|value| value.parse::().ok()) - .unwrap_or(DEFAULT_EMBEDDING_BATCH_TOKENS) - .clamp(512, 8192) -} + fn turn(prompt: &str, answer: &str) -> ConversationTurn { + ConversationTurn { + id: uuid(), + prompt: prompt.to_string(), + answer: answer.to_string(), + model: "test".to_string(), + asked_at: "2026-07-01T00:00:00Z".to_string(), + citations: Vec::new(), + metrics: ChatMetrics { + generated_tokens: 0, + tokens_per_second: 0.0, + elapsed_seconds: 0.0, + chunks: 0, + }, + } + } -fn embedding_context_tokens(input_tokens: usize) -> u32 { - let needed = input_tokens.saturating_add(16).min(u32::MAX as usize) as u32; - DEFAULT_EMBEDDING_CONTEXT_TOKENS.max(needed).min(8192) -} + // Follow-ups only work if prior turns actually reach the model, and the current + // question must still come last so it is what gets answered. + #[test] + fn chat_messages_replay_history_before_the_current_question() { + let history = vec![turn("Who was Augustus?", "The first Roman emperor.")]; + let citations = vec![search_result("1", "https://example.com/a", "context text")]; -fn auto_thread_count() -> i32 { - // Mobile keeps one core free instead of two: recent flagships (like the - // all-big-core Snapdragon 8 Elite) have no little cores to avoid, prefill - // is compute-bound, and the UI sits idle while AiON works. - let reserve = if cfg!(mobile) { 1 } else { 2 }; - std::thread::available_parallelism() - .map(|threads| threads.get().saturating_sub(reserve).clamp(2, 12) as i32) - .unwrap_or(6) -} + let messages = build_chat_messages("What about Tiberius?", &citations, &history); -fn normalize_embedding(values: &[f32]) -> Vec { - let norm = values - .iter() - .map(|value| (*value as f64) * (*value as f64)) - .sum::() - .sqrt(); - if norm <= f64::EPSILON { - return values.to_vec(); + let roles = messages.iter().map(|m| m.role).collect::>(); + assert_eq!(roles, vec!["system", "user", "assistant", "user"]); + assert_eq!(messages[1].content, "Who was Augustus?"); + assert_eq!(messages[2].content, "The first Roman emperor."); + assert!(messages[3].content.contains("What about Tiberius?")); + assert!(messages[3].content.contains("context text")); } - values - .iter() - .map(|value| (*value as f64 / norm) as f32) - .collect() -} -// Phones prefill on CPU, so prompt length is the ask latency. The mobile -// budget (5 sources x ~1100 chars + system + question) fits the 2048-token -// prompt window, where desktop's 8 full chunks would overflow it and get -// front-truncated — losing the system message and top-ranked sources while -// still paying full prefill cost. -fn chat_citation_limit() -> usize { - if cfg!(mobile) { - 5 - } else { - 8 - } -} + #[test] + fn chat_messages_without_history_match_the_single_shot_shape() { + let messages = build_chat_messages("Question?", &[], &[]); -fn chat_snippet_char_limit() -> usize { - if cfg!(mobile) { - 1100 - } else { - usize::MAX + assert_eq!( + messages.iter().map(|m| m.role).collect::>(), + vec!["system", "user"] + ); + assert!(messages[1] + .content + .contains("No stored context was retrieved.")); } -} -fn build_chat_messages(prompt: &str, citations: &[SearchResult]) -> Vec { - let snippet_limit = chat_snippet_char_limit(); - let context_block = citations - .iter() - .enumerate() - .map(|(index, item)| { - let mut source_text = strip_numeric_bracket_markers(&item.text); - if source_text.chars().count() > snippet_limit { - source_text = source_text.chars().take(snippet_limit).collect(); - } - format!( - "[{}] {}\nURL: {}\nCollection: {}\n{}", - index + 1, - item.title, - item.url, - item.collection_id, - source_text - ) - }) - .collect::>() - .join("\n\n"); - let context = if context_block.is_empty() { - "No stored context was retrieved." - } else { - &context_block - }; - // Phones decode on CPU, so every generated token is user-visible latency: - // steer the model toward tight answers instead of hard-truncating them. - let brevity = if cfg!(mobile) { - " Keep answers concise: a few sentences or a short list unless the question needs more." - } else { - "" - }; - vec![ - ChatPromptMessage { - role: "system", - content: format!( - "You are Æther, a private local research assistant. Answer only from the supplied local collection context. If the context is insufficient, say what is missing. Cite sources only with Æther source numbers [1] through [{}]. Do not copy bracketed reference numbers from webpage text.{brevity}", - citations.len().max(1) - ), - }, - ChatPromptMessage { - role: "user", - content: format!("Local collection context:\n{context}\n\nQuestion: {prompt}"), - }, - ] -} + // Replaying old citation markers would let the model reuse source numbers that no + // longer point at anything in the current citation list. + #[test] + fn history_answers_are_stripped_of_stale_citation_markers_and_clipped() { + let condensed = condense_history_answer("Augustus won [1] and later reformed Rome [2]."); + assert!(!condensed.contains("[1]")); + assert!(!condensed.contains("[2]")); + assert!(condensed.contains("Augustus won")); -fn render_model_chat_prompt( - model: &LlamaModel, - messages: &[ChatPromptMessage], -) -> Cmd { - let template = match model.chat_template(None) { - Ok(template) => template, - Err(_) => { - return Ok(RenderedChatPrompt { - prompt: fallback_chat_prompt(messages), - add_bos: AddBos::Never, - }) - } - }; - let chat = messages - .iter() - .map(|message| LlamaChatMessage::new(message.role.to_string(), message.content.clone())) - .collect::, _>>() - .map_err(|error| error.to_string())?; - match model.apply_chat_template(&template, &chat, true) { - Ok(prompt) => Ok(RenderedChatPrompt { - prompt, - add_bos: AddBos::Never, - }), - Err(_) => Ok(RenderedChatPrompt { - prompt: fallback_chat_prompt(messages), - add_bos: AddBos::Never, - }), + let long = "x".repeat(PROMPT_HISTORY_ANSWER_CHARS + 200); + let clipped = condense_history_answer(&long); + assert_eq!(clipped.chars().count(), PROMPT_HISTORY_ANSWER_CHARS + 1); + assert!(clipped.ends_with('…')); } -} -fn fallback_chat_prompt(messages: &[ChatPromptMessage]) -> String { - let mut prompt = String::from(""); - let mut system_messages = Vec::new(); + #[test] + fn conversation_thread_key_separates_hub_and_page_threads() { + assert_eq!(conversation_thread_key(Some("hub-1")), "hub-1"); + assert_eq!(conversation_thread_key(None), CURRENT_PAGE_THREAD_KEY); + // An empty id is a page-only ask, not a hub called "". + assert_eq!(conversation_thread_key(Some("")), CURRENT_PAGE_THREAD_KEY); + } - for message in messages { - match message.role { - "system" | "developer" => { - system_messages.push(message.content.trim().to_string()); - } - "assistant" => { - prompt.push_str("<|turn>model\n"); - prompt.push_str(message.content.trim()); - prompt.push_str("\n"); - } - "user" => { - if !system_messages.is_empty() { - prompt.push_str("<|turn>system\n"); - prompt.push_str(&system_messages.join("\n\n")); - prompt.push_str("\n"); - system_messages.clear(); - } - prompt.push_str("<|turn>user\n"); - prompt.push_str(message.content.trim()); - prompt.push_str("\n"); - } - role => { - prompt.push_str("<|turn>"); - prompt.push_str(role); - prompt.push('\n'); - prompt.push_str(message.content.trim()); - prompt.push_str("\n"); - } + #[test] + fn conversation_threads_persist_and_stay_bounded() { + let dir = TempDir::new(); + let path = dir.path("conversations.json"); + let paths = DataPaths { + db_path: dir.0.clone(), + library_path: dir.path("library.json"), + settings_path: dir.path("settings.json"), + icebergs_path: dir.path("icebergs.json"), + conversations_path: path.clone(), + session_path: dir.path("session.json"), + air_exports_path: dir.0.clone(), + chunks_path: dir.path("chunks.json"), + models_path: dir.0.clone(), + exports_path: dir.0.clone(), + }; + + for index in 0..MAX_THREAD_TURNS + 5 { + block_on(append_conversation_turn( + &paths, + Some("hub-1"), + turn(&format!("q{index}"), &format!("a{index}")), + )) + .expect("append"); } - } - if !system_messages.is_empty() { - prompt.push_str("<|turn>system\n"); - prompt.push_str(&system_messages.join("\n\n")); - prompt.push_str("\n"); - } + let thread = block_on(conversation_thread(&paths, Some("hub-1"))); + assert_eq!( + thread.len(), + MAX_THREAD_TURNS, + "old turns should be trimmed" + ); + // The newest turns are the ones kept. + assert_eq!( + thread.last().unwrap().prompt, + format!("q{}", MAX_THREAD_TURNS + 4) + ); + assert_eq!(thread.first().unwrap().prompt, "q5"); - prompt.push_str("<|turn>model\n"); - prompt -} + // Threads must not leak into each other. + assert!(block_on(conversation_thread(&paths, None)).is_empty()); + } -// Streaming deltas hold back a short tail starting at the most recent '<' so a -// stop marker arriving across several tokens is never shown to the user. -fn stream_safe_len(output: &str) -> usize { - let tail_start = output.len().saturating_sub(18); - let Some((boundary, _)) = output.char_indices().find(|(index, _)| *index >= tail_start) else { - return output.len(); - }; - match output[boundary..].rfind('<') { - Some(position) => boundary + position, - None => output.len(), + // With no chat model the answer must be visibly a passage list, and must not claim + // generation metrics it did not earn. + #[test] + fn extractive_answer_quotes_sources_and_reports_no_generated_tokens() { + let citations = vec![ + search_result( + "1", + "https://example.com/a", + "Quantum mechanics arose gradually.", + ), + search_result( + "2", + "https://example.com/b", + "The Schrodinger equation governs.", + ), + ]; + + let result = extractive_answer(citations, 0.25); + + assert_eq!(result.metrics.generated_tokens, 0); + assert_eq!(result.metrics.tokens_per_second, 0.0); + assert_eq!(result.metrics.chunks, 2); + assert_eq!(result.model, EXTRACTIVE_MODEL_LABEL); + assert!(result.answer.contains("cannot write an answer")); + // Both passages must be present and marked as quotes. + assert!(result.answer.contains("Quantum mechanics arose gradually.")); + assert!(result.answer.contains("The Schrodinger equation governs.")); + assert_eq!(result.answer.matches("\n> ").count(), 2); + // Citation markers must line up with the citation list for the UI. + assert!(result.answer.contains("[1]")); + assert!(result.answer.contains("[2]")); + assert_eq!(result.citations.len(), 2); + } + + fn vector_chunk(capture_id: &str, vector: Vec) -> ChunkRecord { + ChunkRecord { + id: uuid(), + vector, + vector_slot: 0, + needs_reembed: false, + text: format!("text for {capture_id}"), + collection_id: "hub-1".to_string(), + capture_id: capture_id.to_string(), + title: format!("Title {capture_id}"), + url: format!("https://example.com/{capture_id}"), + app_id: "browser".to_string(), + captured_at: "2026-07-01T00:00:00Z".to_string(), + chunk_index: 0, + } } -} -fn contains_stop_marker(output: &str) -> bool { - output.contains("") - || output.contains("") - || output.contains("") - || output.contains("<|turn>") - || output.contains("<|eot_id|>") - || output.contains("<|end|>") -} + // Vectors must survive the JSON/sidecar split byte-for-byte; a silent precision or + // ordering change here would quietly degrade every future search result. + #[test] + fn vector_store_round_trips_vectors_through_the_sidecar() { + let dir = TempDir::new(); + let path = dir.path("chunks.json"); + + let mut store = VectorStoreData::default(); + store.push_chunks(vec![ + vector_chunk("a", vec![0.5, -0.25, 0.125, 1.0]), + vector_chunk("b", vec![-1.0, 0.0, 0.75, 0.5]), + ]); + block_on(save_vectors(&path, &mut store)).expect("save"); -fn clean_model_output(output: &str) -> String { - let mut cleaned = output.to_string(); - for marker in [ - "", - "model", - "assistant", - "", - "", - "<|turn>model", - "<|turn>assistant", - "<|turn>user", - "<|turn>system", - "<|turn>", - "<|eot_id|>", - "<|end|>", - ] { - cleaned = cleaned.replace(marker, ""); - } - cleaned.trim().to_string() -} + let loaded = block_on(load_vectors(&path)).expect("load"); -fn normalize_answer_citations(answer: &str, citation_count: usize) -> String { - tidy_citation_spacing(&rewrite_numeric_bracket_markers( - answer, - citation_count, - true, - )) -} + assert_eq!(loaded.version, VECTOR_STORE_VERSION); + assert_eq!(loaded.dim, 4); + assert_eq!(loaded.chunks.len(), 2); + assert_eq!(loaded.chunks[0].vector, vec![0.5, -0.25, 0.125, 1.0]); + assert_eq!(loaded.chunks[1].vector, vec![-1.0, 0.0, 0.75, 0.5]); + // The whole point of the split: no vector numbers in the JSON. + let raw = fs::read_to_string(&path).expect("metadata"); + assert!(!raw.contains("0.125"), "vectors must not be in the JSON"); + } -fn strip_numeric_bracket_markers(text: &str) -> String { - rewrite_numeric_bracket_markers(text, 0, false) -} + #[test] + fn vector_store_appends_without_rewriting_existing_slots() { + let dir = TempDir::new(); + let path = dir.path("chunks.json"); + + let mut store = VectorStoreData::default(); + store.push_chunks(vec![vector_chunk("a", vec![1.0, 2.0, 3.0, 4.0])]); + block_on(save_vectors(&path, &mut store)).expect("first save"); + let size_after_first = fs::metadata(vector_data_path(&path)) + .expect("sidecar") + .len(); + + store.push_chunks(vec![vector_chunk("b", vec![5.0, 6.0, 7.0, 8.0])]); + block_on(save_vectors(&path, &mut store)).expect("second save"); + let size_after_second = fs::metadata(vector_data_path(&path)) + .expect("sidecar") + .len(); + + // 4 dims * 4 bytes per append. + assert_eq!(size_after_first, 16); + assert_eq!(size_after_second, 32); + + let loaded = block_on(load_vectors(&path)).expect("load"); + assert_eq!(loaded.chunks[0].vector, vec![1.0, 2.0, 3.0, 4.0]); + assert_eq!(loaded.chunks[1].vector, vec![5.0, 6.0, 7.0, 8.0]); + } + + // Existing installs have a v1 store with inline vectors. Losing them on upgrade + // would silently empty every hub, so migration is the highest-risk path here. + #[test] + fn vector_store_migrates_a_v1_json_store_without_losing_vectors() { + let dir = TempDir::new(); + let path = dir.path("chunks.json"); + let legacy = serde_json::json!({ + "version": 1, + "chunks": [ + { + "id": "chunk-1", + "vector": [0.25, 0.5, 0.75, 1.0], + "text": "legacy text", + "collectionId": "hub-1", + "captureId": "capture-1", + "title": "Legacy source", + "url": "https://example.com/legacy", + "appId": "browser", + "capturedAt": "2026-06-01T00:00:00Z", + "chunkIndex": 0 + } + ] + }); + fs::write(&path, serde_json::to_string(&legacy).unwrap()).expect("seed v1"); -fn rewrite_numeric_bracket_markers(text: &str, citation_count: usize, keep_valid: bool) -> String { - let mut rewritten = String::with_capacity(text.len()); - let mut cursor = 0usize; + let migrated = block_on(load_vectors(&path)).expect("migrate"); - while let Some(relative_start) = text[cursor..].find('[') { - let start = cursor + relative_start; - let Some(relative_end) = text[start + 1..].find(']') else { - break; - }; - let end = start + 1 + relative_end; - let inner = &text[start + 1..end]; - let Some(numbers) = parse_numeric_citation_marker(inner) else { - rewritten.push_str(&text[cursor..=start]); - cursor = start + 1; - continue; - }; + assert_eq!(migrated.version, VECTOR_STORE_VERSION); + assert_eq!(migrated.chunks.len(), 1); + assert_eq!(migrated.chunks[0].vector, vec![0.25, 0.5, 0.75, 1.0]); + assert_eq!(migrated.chunks[0].text, "legacy text"); + // The pre-migration file must still be recoverable. + assert!( + backup_path(&path).exists(), + "v1 store should be kept as .bak" + ); - rewritten.push_str(&text[cursor..start]); - if keep_valid { - let valid = numbers - .into_iter() - .filter(|number| *number > 0 && *number <= citation_count) - .map(|number| number.to_string()) - .collect::>(); - if !valid.is_empty() { - rewritten.push('['); - rewritten.push_str(&valid.join(", ")); - rewritten.push(']'); - } - } - cursor = end + 1; + // Reloading must now take the v2 path and produce the same vectors. + let reloaded = block_on(load_vectors(&path)).expect("reload"); + assert_eq!(reloaded.chunks[0].vector, vec![0.25, 0.5, 0.75, 1.0]); } - rewritten.push_str(&text[cursor..]); - rewritten -} + #[test] + fn vector_store_survives_a_missing_sidecar_without_losing_the_library() { + let dir = TempDir::new(); + let path = dir.path("chunks.json"); -fn parse_numeric_citation_marker(value: &str) -> Option> { - if value.trim().is_empty() - || !value.chars().all(|character| { - character.is_ascii_digit() || character == ',' || character.is_whitespace() - }) - { - return None; - } + let mut store = VectorStoreData::default(); + store.push_chunks(vec![vector_chunk("a", vec![1.0, 0.0, 0.0, 0.0])]); + block_on(save_vectors(&path, &mut store)).expect("save"); + fs::remove_file(vector_data_path(&path)).expect("drop sidecar"); - let mut numbers = Vec::new(); - for part in value.split(',') { - let part = part.trim(); - if part.is_empty() { - return None; - } - let number = part.parse::().ok()?; - numbers.push(number); - } - (!numbers.is_empty()).then_some(numbers) -} + let loaded = block_on(load_vectors(&path)).expect("load without sidecar"); -fn tidy_citation_spacing(value: &str) -> String { - let mut tidied = value.trim().to_string(); - for (from, to) in [ - (" .", "."), - (" ,", ","), - (" ;", ";"), - (" :", ":"), - (" !", "!"), - (" ?", "?"), - (" )", ")"), - ("( ", "("), - ] { - tidied = tidied.replace(from, to); - } - while tidied.contains(" ") { - tidied = tidied.replace(" ", " "); + // Chunks are dropped (they cannot be searched) but the load itself succeeds. + assert!(loaded.chunks.is_empty()); } - tidied -} -async fn extract_readable_active_page( - state: &State<'_, Backend>, - active_tab: &ManagedTab, -) -> Cmd { - #[cfg(desktop)] - { - match extract_readable_page_from_webview(state, active_tab).await { - Ok(page) => return Ok(page), - Err(_) => {} - } - } + #[test] + fn vector_store_compacts_once_dead_slots_dominate() { + let dir = TempDir::new(); + let path = dir.path("chunks.json"); + + let mut store = VectorStoreData::default(); + let total = VECTOR_COMPACTION_MIN_SLOTS as usize + 8; + store + .push_chunks((0..total).map(|index| { + vector_chunk(&format!("c{index}"), vec![index as f32, 0.0, 0.0, 0.0]) + })); + block_on(save_vectors(&path, &mut store)).expect("save"); + assert_eq!(store.next_slot, total as u64); + + // Drop most chunks, which leaves their slots dead. + store.chunks.retain(|chunk| chunk.vector_slot % 8 == 0); + let survivors = store.chunks.len() as u64; + block_on(save_vectors(&path, &mut store)).expect("save after delete"); + + assert_eq!(store.next_slot, survivors, "slots should be renumbered"); + let sidecar = fs::metadata(vector_data_path(&path)) + .expect("sidecar") + .len(); + assert_eq!(sidecar, survivors * 4 * 4, "dead slots should be reclaimed"); + + let loaded = block_on(load_vectors(&path)).expect("load"); + assert_eq!(loaded.chunks.len() as u64, survivors); + // Values must still line up with their chunks after renumbering. + for chunk in &loaded.chunks { + let expected: f32 = chunk.capture_id.trim_start_matches('c').parse().unwrap(); + assert_eq!(chunk.vector[0], expected); + } + } + + // A width the store cannot hold must cost the vector, never the text: re-embedding + // is local compute, whereas dropping the record forces the page to be fetched again. + #[test] + fn vector_store_parks_mismatched_chunks_instead_of_discarding_them() { + let mut store = VectorStoreData::default(); + store.push_chunks(vec![ + vector_chunk("a", vec![1.0, 2.0, 3.0, 4.0]), + vector_chunk("b", vec![1.0, 2.0]), + vector_chunk("c", vec![]), + ]); - #[cfg(target_os = "android")] - { - match extract_readable_page_from_android(active_tab) { - Ok(page) => return Ok(page), - Err(_) => {} + assert_eq!(store.dim, 4); + assert_eq!(store.chunks.len(), 3, "no chunk is thrown away"); + assert_eq!(store.embedded_count(), 1); + assert_eq!(store.pending_reembed_count(), 2); + assert_eq!(store.next_slot, 1, "only embedded chunks consume slots"); + + for chunk in store.chunks.iter().filter(|chunk| chunk.needs_reembed) { + assert!(chunk.vector.is_empty(), "parked chunks hold no vector"); + assert!(!chunk.text.is_empty(), "parked chunks keep their text"); } } - extract_readable_page(&state.client, &active_tab.url).await -} - -// Android counterpart of extract_readable_page_from_webview: the Kotlin -// TabsPlugin's `snapshot` command reads the live DOM through -// evaluateJavascript's value callback, so logged-in and JS-rendered pages -// capture correctly instead of falling back to an anonymous HTTP re-fetch. -#[cfg(target_os = "android")] -fn extract_readable_page_from_android(active_tab: &ManagedTab) -> Cmd { - let response: android_tabs::SnapshotResponse = android_tabs::run_for_global( - "snapshot", - android_tabs::TabPayload { - tab_id: &active_tab.id, - }, - )?; - let payload = response.payload.trim(); - if payload.is_empty() || payload == "null" { - return Err("Unable to read the active page.".to_string()); + // Parked chunks must stay out of the sidecar entirely: giving one a slot would shift + // every later chunk against the fixed stride and silently mismatch vectors to text. + #[test] + fn parked_chunks_survive_a_save_without_taking_sidecar_slots() { + let dir = TempDir::new(); + let path = dir.path("chunks.json"); + + let mut store = VectorStoreData::default(); + store.push_chunks(vec![ + vector_chunk("a", vec![1.0, 2.0, 3.0, 4.0]), + vector_chunk("b", vec![9.0, 9.0]), + vector_chunk("c", vec![5.0, 6.0, 7.0, 8.0]), + ]); + block_on(save_vectors(&path, &mut store)).expect("save"); + + // Two embedded chunks at 4 dims; the parked one contributes nothing. + let sidecar = fs::metadata(vector_data_path(&path)) + .expect("sidecar") + .len(); + assert_eq!(sidecar, 2 * 4 * 4); + + let loaded = block_on(load_vectors(&path)).expect("load"); + assert_eq!(loaded.chunks.len(), 3); + assert_eq!(loaded.pending_reembed_count(), 1); + let embedded = loaded + .chunks + .iter() + .filter(|chunk| !chunk.needs_reembed) + .map(|chunk| chunk.vector.clone()) + .collect::>(); + assert!(embedded.contains(&vec![1.0, 2.0, 3.0, 4.0])); + assert!(embedded.contains(&vec![5.0, 6.0, 7.0, 8.0])); } - let snapshot = parse_page_snapshot(payload)?; - snapshot_to_captured_page(snapshot, &active_tab.title) -} - -#[cfg(desktop)] -async fn extract_readable_page_from_webview( - state: &State<'_, Backend>, - active_tab: &ManagedTab, -) -> Cmd { - let webview = state - .webviews - .lock() - .map_err(|_| "Æther webviews are unavailable.".to_string())? - .views - .get(&active_tab.id) - .cloned() - .ok_or_else(|| "Active browser webview is not ready.".to_string())?; - let script = r#"(() => { - const clone = document.documentElement.cloneNode(true); - clone.querySelectorAll('script, style, noscript, iframe, form, nav, footer, svg').forEach((node) => node.remove()); - return { - html: '' + clone.outerHTML, - url: location.href, - title: document.title, - description: document.querySelector('meta[name="description"]')?.getAttribute('content') || '', - bodyText: document.body?.innerText || '' - }; - })()"#; - let (sender, receiver) = tokio::sync::oneshot::channel::(); - let sender = Arc::new(Mutex::new(Some(sender))); - webview - .eval_with_callback(script, { - let sender = Arc::clone(&sender); - move |payload| { - if let Ok(mut sender) = sender.lock() { - if let Some(sender) = sender.take() { - let _ = sender.send(payload); - } - } - } - }) - .map_err(|error| error.to_string())?; - let payload = tokio::time::timeout(Duration::from_secs(5), receiver) - .await - .map_err(|_| "Timed out reading the active page.".to_string())? - .map_err(|_| "Unable to read the active page.".to_string())?; - let snapshot = parse_page_snapshot(&payload)?; - snapshot_to_captured_page(snapshot, &active_tab.title) -} -fn parse_page_snapshot(payload: &str) -> Cmd { - parse_json_payload::(payload) -} + // The real regression: a v1 store written across an embedding-model change holds two + // widths. Anchoring on the first chunk handed the store to whichever model was + // written first, which on a real install was the older, unusable one. + #[test] + fn migration_keeps_the_majority_width_and_parks_the_rest() { + let dir = TempDir::new(); + let path = dir.path("chunks.json"); + + let mut chunks = Vec::new(); + // One older-model chunk first in file order, then a wider majority. + chunks.push(serde_json::json!({ + "id": "old-1", + "vector": [0.1, 0.2], + "text": "older model chunk", + "collectionId": "hub-1", + "captureId": "capture-old", + "title": "Old", + "url": "https://example.com/old", + "appId": "browser", + "capturedAt": "2026-06-01T00:00:00Z", + "chunkIndex": 0 + })); + for index in 0..3 { + chunks.push(serde_json::json!({ + "id": format!("new-{index}"), + "vector": [0.5, 0.5, 0.5, 0.5], + "text": format!("current model chunk {index}"), + "collectionId": "hub-1", + "captureId": "capture-new", + "title": "New", + "url": "https://example.com/new", + "appId": "browser", + "capturedAt": "2026-07-01T00:00:00Z", + "chunkIndex": index + })); + } + fs::write( + &path, + serde_json::to_string(&serde_json::json!({"version": 1, "chunks": chunks})).unwrap(), + ) + .expect("seed v1"); -fn parse_json_payload(payload: &str) -> Cmd { - let value = - serde_json::from_str::(payload).map_err(|error| error.to_string())?; - if let Some(inner) = value.as_str() { - serde_json::from_str::(inner).map_err(|error| error.to_string()) - } else { - serde_json::from_value::(value).map_err(|error| error.to_string()) - } -} + let migrated = block_on(load_vectors(&path)).expect("migrate"); -fn snapshot_to_captured_page( - snapshot: BrowserPageSnapshot, - fallback_title: &str, -) -> Cmd { - let url = snapshot - .url - .filter(|url| !url.trim().is_empty()) - .ok_or_else(|| "Unable to read the active page.".to_string())?; - let parsed_document = snapshot - .html - .as_ref() - .map(|html| Html::parse_document(html)); - let title = snapshot - .title - .filter(|title| !title.trim().is_empty()) - .or_else(|| { - parsed_document - .as_ref() - .and_then(|document| select_first_text(document, "title")) - }) - .unwrap_or_else(|| fallback_title.to_string()); - let description = snapshot.description.unwrap_or_else(|| { - parsed_document - .as_ref() - .and_then(|document| select_meta_content(document, "description")) - .unwrap_or_default() - }); - let body_text = snapshot.body_text.unwrap_or_else(|| { - parsed_document - .as_ref() - .map(select_body_text) - .unwrap_or_default() - }); - let text = normalize_captured_text(&format!("{title}\n\n{description}\n\n{body_text}")); + assert_eq!( + migrated.dim, 4, + "the majority width wins, not the first one" + ); + assert_eq!(migrated.chunks.len(), 4, "nothing is discarded"); + assert_eq!(migrated.embedded_count(), 3); + assert_eq!(migrated.pending_reembed_count(), 1); - if text.len() < MIN_CAPTURE_TEXT_LENGTH { - return Err("This page does not contain enough readable text to capture.".to_string()); + let parked = migrated + .chunks + .iter() + .find(|chunk| chunk.needs_reembed) + .expect("parked chunk"); + assert_eq!(parked.id, "old-1"); + assert_eq!(parked.text, "older model chunk", "text is re-embeddable"); } - Ok(CapturedPage { title, url, text }) -} + // The `.bak` rotation is one generation deep, so an ordinary save after the upgrade + // would overwrite the v1 backup with a v2 copy. The archive must outlive that. + #[test] + fn pre_migration_archive_survives_later_saves() { + let dir = TempDir::new(); + let path = dir.path("chunks.json"); + let legacy = serde_json::json!({ + "version": 1, + "chunks": [{ + "id": "chunk-1", + "vector": [0.25, 0.5, 0.75, 1.0], + "text": "legacy text", + "collectionId": "hub-1", + "captureId": "capture-1", + "title": "Legacy source", + "url": "https://example.com/legacy", + "appId": "browser", + "capturedAt": "2026-06-01T00:00:00Z", + "chunkIndex": 0 + }] + }); + let raw = serde_json::to_string(&legacy).unwrap(); + fs::write(&path, &raw).expect("seed v1"); -async fn extract_readable_page(client: &Client, url: &str) -> Cmd { - let parsed = Url::parse(url).map_err(|_| "Unable to read the active page URL.".to_string())?; - if parsed.scheme() != "http" && parsed.scheme() != "https" { - return Err("Only http and https pages can be captured in the Tauri build.".to_string()); - } - let response = client - .get(url) - .timeout(Duration::from_secs(20)) - .send() - .await - .map_err(|error| error.to_string())?; - if !response.status().is_success() { - return Err(format!("Unable to fetch page: {}", response.status())); - } - let html = response.text().await.map_err(|error| error.to_string())?; - let document = Html::parse_document(&html); - let title = select_first_text(&document, "title") - .filter(|title| !title.is_empty()) - .unwrap_or_else(|| title_from_url(url)); - let description = select_meta_content(&document, "description").unwrap_or_default(); - let body_text = select_body_text(&document); - let text = normalize_captured_text(&format!("{title}\n\n{description}\n\n{body_text}")); - if text.len() < MIN_CAPTURE_TEXT_LENGTH { - return Err("This page does not contain enough readable text to capture.".to_string()); - } - Ok(CapturedPage { - title, - url: url.to_string(), - text, - }) -} + let mut migrated = block_on(load_vectors(&path)).expect("migrate"); + let archive = dir.path("chunks.v1.json"); + assert!(archive.exists(), "migration should archive the v1 store"); -fn select_first_text(document: &Html, selector: &str) -> Option { - let selector = Selector::parse(selector).ok()?; - document - .select(&selector) - .next() - .map(|node| node.text().collect::>().join(" ").trim().to_string()) -} + // Two further saves: more than enough to cycle the single `.bak` generation. + for index in 0..2 { + migrated.push_chunks(vec![vector_chunk( + &format!("later-{index}"), + vec![1.0, 1.0, 1.0, 1.0], + )]); + block_on(save_vectors(&path, &mut migrated)).expect("later save"); + } -fn select_meta_content(document: &Html, name: &str) -> Option { - let selector = Selector::parse(&format!("meta[name=\"{name}\"]")).ok()?; - document - .select(&selector) - .next() - .and_then(|node| node.value().attr("content")) - .map(|value| value.trim().to_string()) -} + let archived = fs::read_to_string(&archive).expect("archive readable"); + assert_eq!(archived, raw, "archive must still be the original v1 bytes"); + assert!( + archived.contains("0.75"), + "archived vectors are the only copy of anything the migration parked" + ); + } -fn select_body_text(document: &Html) -> String { - let selector = Selector::parse("body").expect("body selector"); - document - .select(&selector) - .flat_map(|node| node.text()) - .map(str::trim) - .filter(|text| !text.is_empty()) - .collect::>() - .join(" ") -} + #[test] + fn majority_width_breaks_ties_toward_the_wider_vector() { + let chunk = |vector: Vec| LegacyChunkRecord { + id: uuid(), + vector, + text: String::new(), + collection_id: String::new(), + capture_id: String::new(), + title: String::new(), + url: String::new(), + app_id: String::new(), + captured_at: String::new(), + chunk_index: 0, + }; -fn build_iceberg_prompt(keyword: &str) -> String { - format!( - r#"Create an iceberg chart for the topic "{keyword}". - -Return JSON only with this exact shape: -{{ - "recommendedItemCount": 24, - "items": [ - {{ - "name": "Visible phrase", - "description": "One short explanation.", - "depthScore": 12, - "familiarity": 85, - "specificity": 20, - "jargonDensity": 15, - "prerequisiteDepth": 10, - "obscurity": 12, - "confidence": 0.86, - "reason": "Common public entry point." - }} - ] -}} - -Rules: -- Choose recommendedItemCount based on topic scope: 12-18 for narrow topics, 20-30 for medium topics, 32-45 for broad domains. -- Return between 12 and 45 usable items. -- Cover all five iceberg layers whenever the topic has enough material. Include at least one item intentionally scored for each depth band: 0-19, 20-39, 40-59, 60-79, and 80-100. -- Prefer 2-5 genuinely obscure or specialist items for the 80-100 band instead of stopping at intermediate concepts. -- Do not include more than 10 items that would clearly belong to the same depth band. -- Use concise item names that fit on a node. -- Every item must include a non-empty description. -- Scores are integers from 0-100, never 1-10. Confidence is 0.0-1.0. -- depthScore: intended iceberg depth, where 0-19 is public surface knowledge and 80-100 is obscure, specialist, prerequisite-heavy, or rarely explained. -- familiarity: how likely a general audience already knows this. -- specificity: how narrow the concept is within the topic. -- jargonDensity: how much specialist vocabulary is needed. -- prerequisiteDepth: how much prior conceptual knowledge is required. -- obscurity: how rarely this appears in mainstream explainers or beginner material. -- reason: one short explanation for why the item sits at its depth. -- Do not include level; the app will compute levels from the scoring rubric. -- Do not include markdown, prose, or comments."# - ) -} + assert_eq!(majority_vector_dim(&[]), 0); + assert_eq!(majority_vector_dim(&[chunk(vec![]), chunk(vec![])]), 0); + // One each: the wider vector wins, since newer models are wider in practice. + assert_eq!( + majority_vector_dim(&[chunk(vec![0.0; 2]), chunk(vec![0.0; 8])]), + 8 + ); + // Count beats width. + assert_eq!( + majority_vector_dim(&[ + chunk(vec![0.0; 2]), + chunk(vec![0.0; 2]), + chunk(vec![0.0; 8]) + ]), + 2 + ); + } -#[derive(Clone)] -struct ScoredIcebergItem { - item: IcebergItem, - score: f64, -} + // Compaction measures dead slots against the sidecar, so counting parked chunks as + // live would understate the dead ratio and stop compaction from ever firing. + #[test] + fn compaction_ignores_parked_chunks_but_keeps_them() { + let dir = TempDir::new(); + let path = dir.path("chunks.json"); + + let mut store = VectorStoreData::default(); + let total = VECTOR_COMPACTION_MIN_SLOTS as usize + 8; + store + .push_chunks((0..total).map(|index| { + vector_chunk(&format!("c{index}"), vec![index as f32, 0.0, 0.0, 0.0]) + })); + store.push_chunks(vec![vector_chunk("parked", vec![1.0, 2.0])]); + block_on(save_vectors(&path, &mut store)).expect("save"); + + store + .chunks + .retain(|chunk| chunk.needs_reembed || chunk.vector_slot % 8 == 0); + let survivors = store.embedded_count(); + block_on(save_vectors(&path, &mut store)).expect("save after delete"); -fn normalize_iceberg_items(response: &str) -> Cmd> { - let json_text = response - .trim() - .trim_start_matches("```json") - .trim_start_matches("```") - .trim_end_matches("```") - .trim(); - let parsed = match serde_json::from_str::(json_text) { - Ok(value) => value, - Err(_) => { - let start = json_text - .find('[') - .ok_or_else(|| "Local model did not return valid iceberg JSON.".to_string())?; - let end = json_text - .rfind(']') - .ok_or_else(|| "Local model did not return valid iceberg JSON.".to_string())?; - serde_json::from_str(&json_text[start..=end]) - .map_err(|_| "Local model did not return valid iceberg JSON.".to_string())? - } - }; - let requested_count = iceberg_requested_count(&parsed); - let items_value = parsed.get("items").cloned().unwrap_or(parsed); - let raw_items = items_value - .as_array() - .ok_or_else(|| "Local model did not return valid iceberg JSON.".to_string())?; - let target_count = requested_count - .unwrap_or(raw_items.len()) - .clamp(ICEBERG_MIN_ITEMS, ICEBERG_MAX_ITEMS); - let mut candidates = Vec::::new(); - let mut seen_names = HashSet::::new(); - for raw in raw_items { - let name = raw - .get("name") - .and_then(|value| value.as_str()) - .unwrap_or("") - .trim(); - let description = raw - .get("description") - .and_then(|value| value.as_str()) - .unwrap_or("") - .trim(); - if name.is_empty() || description.is_empty() { - continue; + assert_eq!( + store.next_slot, survivors, + "slots renumber over embedded only" + ); + let sidecar = fs::metadata(vector_data_path(&path)) + .expect("sidecar") + .len(); + assert_eq!(sidecar, survivors * 4 * 4); + + let loaded = block_on(load_vectors(&path)).expect("load"); + assert_eq!(loaded.embedded_count(), survivors); + assert_eq!(loaded.pending_reembed_count(), 1, "parked chunk survives"); + for chunk in loaded.chunks.iter().filter(|chunk| !chunk.needs_reembed) { + let expected: f32 = chunk.capture_id.trim_start_matches('c').parse().unwrap(); + assert_eq!( + chunk.vector[0], expected, + "vectors stay matched to their text" + ); } + } - let dedupe_key = slugify(name); - if dedupe_key.is_empty() || !seen_names.insert(dedupe_key) { - continue; + // A download filename comes from a remote URL, so it is untrusted input that ends + // up as a filesystem path. Separators and traversal must not survive. + #[cfg(desktop)] + #[test] + fn download_filenames_cannot_escape_the_downloads_directory() { + let cases = [ + ("https://example.com/a/../../etc/passwd", "passwd"), + ("https://example.com/%2e%2e%2fetc%2fpasswd", "etcpasswd"), + ("https://example.com/dir/", "dir"), + ("https://example.com/", "download"), + ("https://example.com/..", "download"), + ]; + for (raw, expected) in cases { + let name = file_name_from_url(&Url::parse(raw).expect("url")); + assert_eq!(name, expected, "for {raw}"); + assert!(!name.contains('/'), "{name} must not contain a separator"); + assert!(!name.contains('\\'), "{name} must not contain a separator"); + assert_ne!(name, ".."); } - - let fallback_level = iceberg_level_field(raw).unwrap_or(3); - let fallback_score = iceberg_fallback_score(fallback_level); - let familiarity = iceberg_metric_field(raw, &["familiarity"]); - let specificity = iceberg_metric_field(raw, &["specificity"]); - let jargon_density = iceberg_metric_field(raw, &["jargonDensity", "jargon_density"]); - let prerequisite_depth = - iceberg_metric_field(raw, &["prerequisiteDepth", "prerequisite_depth"]); - let obscurity = iceberg_metric_field(raw, &["obscurity"]); - let confidence = iceberg_confidence_field(raw); - let explicit_depth = - iceberg_metric_field(raw, &["depthScore", "depth_score", "complexityScore"]); - let score = explicit_depth.unwrap_or_else(|| { - iceberg_depth_score( - familiarity, - specificity, - jargon_density, - prerequisite_depth, - obscurity, - fallback_score, - ) - }); - let score = round_score(score.clamp(0.0, 100.0)); - let level = iceberg_level_from_score(score); - let reason = iceberg_string_field(raw, &["reason", "rationale", "levelReason"]) - .map(|reason| reason.chars().take(220).collect::()); - - candidates.push(ScoredIcebergItem { - item: IcebergItem { - id: String::new(), - name: name.to_string(), - description: description.to_string(), - level, - x: 0.0, - y: 0.0, - depth_score: Some(score), - familiarity, - specificity, - jargon_density, - prerequisite_depth, - obscurity, - confidence, - reason, - }, - score, - }); } - stretch_iceberg_candidate_levels(&mut candidates); - - candidates.sort_by(|left, right| { - left.item - .level - .cmp(&right.item.level) - .then_with(|| { - left.score - .partial_cmp(&right.score) - .unwrap_or(Ordering::Equal) - }) - .then_with(|| left.item.name.cmp(&right.item.name)) - }); + #[cfg(desktop)] + #[test] + fn download_filenames_decode_and_keep_extensions() { + let name = file_name_from_url( + &Url::parse("https://example.com/docs/My%20Report%202026.pdf").unwrap(), + ); + assert_eq!(name, "My Report 2026.pdf"); - let mut buckets = HashMap::>::new(); - for candidate in candidates { - buckets.entry(candidate.item.level).or_default().push(candidate); + let stripped = file_name_from_url( + &Url::parse("https://example.com/a/b/paper.tar.gz?token=1").unwrap(), + ); + assert_eq!(stripped, "paper.tar.gz"); } - let mut selected = Vec::::new(); - let mut by_level = HashMap::::new(); - - for level in 1..=ICEBERG_LEVEL_COUNT { - if selected.len() >= target_count { - break; - } - if let Some(candidate) = take_iceberg_candidate(&mut buckets, level) { - *by_level.entry(level).or_default() += 1; - selected.push(candidate); - } + #[cfg(desktop)] + #[test] + fn download_filenames_are_length_capped() { + let long = "x".repeat(400); + let name = + file_name_from_url(&Url::parse(&format!("https://example.com/{long}.bin")).unwrap()); + assert!( + name.chars().count() <= 180, + "got {} chars", + name.chars().count() + ); } - while selected.len() < target_count { - let mut added_any = false; - for level in 1..=ICEBERG_LEVEL_COUNT { - if selected.len() >= target_count { - break; - } - if by_level.get(&level).copied().unwrap_or_default() >= ICEBERG_MAX_ITEMS_PER_LEVEL { + #[cfg(desktop)] + #[test] + fn restored_session_keeps_the_saved_active_tab() { + let session = SessionData { + version: 1, + tabs: vec![ + SessionTab { + id: "tab-a".to_string(), + url: "https://example.com/a".to_string(), + title: "A".to_string(), + }, + SessionTab { + id: "tab-b".to_string(), + url: "https://example.com/b".to_string(), + title: "B".to_string(), + }, + ], + active_tab_id: "tab-b".to_string(), + window: None, + }; + // Mirrors restore_session_tabs' selection rule without needing a Tauri app. + let ids = session + .tabs + .iter() + .map(|tab| tab.id.clone()) + .collect::>(); + let active = ids + .iter() + .any(|id| *id == session.active_tab_id) + .then(|| session.active_tab_id.clone()) + .unwrap_or_else(|| ids[0].clone()); + assert_eq!(active, "tab-b"); + + // A stale active id must fall back to the first tab, not to an empty string. + let stale = SessionData { + active_tab_id: "tab-gone".to_string(), + ..session + }; + let ids = stale + .tabs + .iter() + .map(|tab| tab.id.clone()) + .collect::>(); + let active = ids + .iter() + .any(|id| *id == stale.active_tab_id) + .then(|| stale.active_tab_id.clone()) + .unwrap_or_else(|| ids[0].clone()); + assert_eq!(active, "tab-a"); + } + + // --------------------------------------------------------------------------- + // Retrieval eval + // + // The real embedding model is 640 MB, so CI cannot download it. Instead these + // tests drive the *actual* pipeline — split_text -> push_chunks -> the binary + // store -> rank_library_hits — with a deterministic stand-in embedder. That + // covers every model-independent way retrieval can regress: chunk boundaries, + // slot/vector alignment, per-capture grouping, ordering, and limits. It does + // not judge model quality; see the AETHER_EMBEDDING_MODEL-gated test below. + // --------------------------------------------------------------------------- + + // Wide enough that hash collisions do not dominate. At 64 buckets, unrelated + // vocabularies shared slots often enough to invert rankings, which would have made + // these tests measure the stand-in embedder rather than the pipeline. + const EVAL_DIM: usize = 512; + + // Hashed bag-of-words, L2-normalised. Deterministic, and shares the property that + // matters for these assertions: passages about the same terms sit closer together + // under cosine distance than passages about different terms. + fn eval_embed(text: &str) -> Vec { + let mut vector = vec![0.0_f32; EVAL_DIM]; + for word in text.to_lowercase().split(|c: char| !c.is_alphanumeric()) { + if word.len() < 3 { continue; } - if let Some(candidate) = take_iceberg_candidate(&mut buckets, level) { - *by_level.entry(level).or_default() += 1; - selected.push(candidate); - added_any = true; + let mut hash = 2166136261_u32; + for byte in word.bytes() { + hash ^= byte as u32; + hash = hash.wrapping_mul(16777619); } + vector[(hash as usize) % EVAL_DIM] += 1.0; } - - if !added_any { - break; + let norm = vector.iter().map(|v| v * v).sum::().sqrt(); + if norm > 0.0 { + for value in &mut vector { + *value /= norm; + } } + vector } - selected.sort_by(|left, right| { - left.item - .level - .cmp(&right.item.level) - .then_with(|| { - left.score - .partial_cmp(&right.score) - .unwrap_or(Ordering::Equal) - }) - .then_with(|| left.item.name.cmp(&right.item.name)) - }); - - let mut normalized = Vec::::new(); - let mut ids = Vec::::new(); - let mut layout_counts = HashMap::::new(); - for candidate in selected { - let level_count = layout_counts.entry(candidate.item.level).or_default(); - let index = *level_count; - *level_count += 1; - let mut item = candidate.item; - item.id = unique_slug(&format!("{}-{}-{}", item.level, index + 1, item.name), &ids); - ids.push(item.id.clone()); - item.x = ICEBERG_LEVEL_LANES[index % ICEBERG_LEVEL_LANES.len()]; - item.y = 120.0 + index as f64 * 44.0; - normalized.push(item); + struct EvalDoc { + capture_id: &'static str, + title: &'static str, + url: &'static str, + body: &'static str, } - if normalized.is_empty() { - Err("Local model did not return any usable iceberg items.".to_string()) - } else { - Ok(normalized) + fn eval_corpus() -> Vec { + vec![ + EvalDoc { + capture_id: "quantum", + title: "Quantum mechanics", + url: "https://en.wikipedia.org/wiki/Quantum_mechanics", + body: "Quantum mechanics describes matter and light at atomic scale. Max Planck solved the black-body radiation problem in 1900. Niels Bohr, Erwin Schrodinger and Werner Heisenberg developed the theory during the mid 1920s. The wave function encodes probability amplitudes for a particle.", + }, + EvalDoc { + capture_id: "photosynthesis", + title: "Photosynthesis", + url: "https://en.wikipedia.org/wiki/Photosynthesis", + body: "Photosynthesis converts light energy into chemical energy inside chloroplasts. Plants absorb carbon dioxide and water, releasing oxygen. Chlorophyll captures photons that drive the light dependent reactions producing glucose.", + }, + EvalDoc { + capture_id: "roman-empire", + title: "Augustus and the Roman Empire", + url: "https://en.wikipedia.org/wiki/Augustus", + body: "Augustus founded the Roman Empire and became its first emperor in 27 BC. Julius Caesar named Octavian as his heir. The principate established an era of imperial peace known as the Pax Romana across the Roman world.", + }, + EvalDoc { + capture_id: "rust-ownership", + title: "Ownership in Rust", + url: "https://doc.rust-lang.org/book/ch04-01-what-is-ownership.html", + body: "Ownership governs how a Rust program manages heap memory. Each value has a single owner, and the value is dropped when the owner goes out of scope. Borrowing lends a reference without transferring ownership, and the borrow checker rejects dangling references at compile time.", + }, + ] + } + + // Indexes the corpus through the real chunking and storage path, so a regression in + // either shows up here rather than only in production. + fn eval_store(dir: &TempDir) -> (VectorStoreData, HashMap) { + let path = dir.path("chunks.json"); + let mut store = VectorStoreData::default(); + for doc in eval_corpus() { + let chunks = split_text(doc.body, 220, 40); + assert!( + !chunks.is_empty(), + "fixture {} produced no chunks", + doc.capture_id + ); + store.push_chunks( + chunks + .into_iter() + .enumerate() + .map(|(index, text)| ChunkRecord { + id: uuid(), + vector: eval_embed(&text), + vector_slot: 0, + needs_reembed: false, + text, + collection_id: "hub-eval".to_string(), + capture_id: doc.capture_id.to_string(), + title: doc.title.to_string(), + url: doc.url.to_string(), + app_id: "browser".to_string(), + captured_at: format!("2026-07-{:02}T00:00:00Z", 10 + index), + chunk_index: index, + }), + ); + } + block_on(save_vectors(&path, &mut store)).expect("persist eval store"); + + // Reload from disk: the eval must exercise the stored vectors, not the + // in-memory ones, so a serialisation bug cannot pass unnoticed. + let reloaded = block_on(load_vectors(&path)).expect("reload eval store"); + let mut names = HashMap::new(); + names.insert("hub-eval".to_string(), "Eval hub".to_string()); + (reloaded, names) + } + + fn eval_top_ids( + store: &VectorStoreData, + names: &HashMap, + question: &str, + take: usize, + ) -> Vec { + let query = eval_embed(question); + let (hits, _) = rank_library_hits(&store.chunks, names, None, Some(&query), "", 20); + hits.into_iter() + .take(take) + .map(|hit| hit.capture_id) + .collect() } -} -fn stretch_iceberg_candidate_levels(candidates: &mut [ScoredIcebergItem]) { - if candidates.len() < ICEBERG_LEVEL_COUNT as usize { - return; - } + // The core retrieval contract: asking about a topic must surface the source that + // covers it. This is the test that protects answer quality. + #[test] + fn retrieval_eval_ranks_the_expected_source_in_the_top_three() { + let dir = TempDir::new(); + let (store, names) = eval_store(&dir); - let present_levels = candidates - .iter() - .map(|candidate| candidate.item.level) - .collect::>(); - if present_levels.len() >= ICEBERG_LEVEL_COUNT as usize { - return; - } + let cases = [ + ("When did quantum mechanics develop?", "quantum"), + ("Who was the first Roman emperor?", "roman-empire"), + ( + "How do plants turn light into chemical energy?", + "photosynthesis", + ), + ( + "What happens when a value's owner goes out of scope?", + "rust-ownership", + ), + ("black-body radiation problem", "quantum"), + ("chlorophyll photons glucose", "photosynthesis"), + ("borrow checker dangling references", "rust-ownership"), + ("Pax Romana imperial peace", "roman-empire"), + ]; - // Small local models often compress everything into middle scores. For iCE, - // the useful ranking is relative to the requested topic, so spread a usable - // set across all five bands instead of returning empty deep layers. - let mut indices = (0..candidates.len()).collect::>(); - indices.sort_by(|left, right| { - candidates[*left] - .score - .partial_cmp(&candidates[*right].score) - .unwrap_or(Ordering::Equal) - .then_with(|| candidates[*left].item.name.cmp(&candidates[*right].item.name)) - }); - let total = candidates.len(); - for (rank, index) in indices.into_iter().enumerate() { - let level = ((rank * ICEBERG_LEVEL_COUNT as usize) / total + 1) - .min(ICEBERG_LEVEL_COUNT as usize) as u8; - let score = iceberg_score_for_level_band(candidates[index].score, level); - candidates[index].score = score; - candidates[index].item.level = level; - candidates[index].item.depth_score = Some(score); + let mut failures = Vec::new(); + for (question, expected) in cases { + let top = eval_top_ids(&store, &names, question, 3); + if !top.iter().any(|id| id == expected) { + failures.push(format!("{question:?} expected {expected}, got {top:?}")); + } + } + assert!( + failures.is_empty(), + "retrieval regressions:\n{}", + failures.join("\n") + ); } -} -fn iceberg_score_for_level_band(score: f64, level: u8) -> f64 { - let level = level.clamp(1, ICEBERG_LEVEL_COUNT); - let lower = f64::from(level.saturating_sub(1)) * 20.0; - let upper = if level == ICEBERG_LEVEL_COUNT { - 100.0 - } else { - f64::from(level) * 20.0 - 1.0 - }; - round_score(score.clamp(lower, upper)) -} + #[test] + fn retrieval_eval_ranks_the_expected_source_first_for_distinctive_terms() { + let dir = TempDir::new(); + let (store, names) = eval_store(&dir); -fn take_iceberg_candidate( - buckets: &mut HashMap>, - level: u8, -) -> Option { - let bucket = buckets.get_mut(&level)?; - if bucket.is_empty() { - None - } else { - Some(bucket.remove(0)) + // Terms unique to one document should win outright, not merely place. + for (question, expected) in [ + ("Schrodinger Heisenberg wave function", "quantum"), + ("chloroplasts carbon dioxide oxygen", "photosynthesis"), + ("Octavian Julius Caesar principate", "roman-empire"), + ("ownership borrowing heap memory", "rust-ownership"), + ] { + let top = eval_top_ids(&store, &names, question, 1); + assert_eq!(top, vec![expected.to_string()], "for query {question:?}"); + } } -} - -fn iceberg_requested_count(value: &serde_json::Value) -> Option { - let count = value - .get("recommendedItemCount") - .or_else(|| value.get("itemCount")) - .or_else(|| value.get("recommended_count")) - .and_then(|value| value.as_u64())?; - usize::try_from(count).ok() -} - -fn iceberg_level_field(value: &serde_json::Value) -> Option { - let level = value - .get("level") - .or_else(|| value.get("proposedLevel")) - .or_else(|| value.get("depthLevel")) - .and_then(|value| value.as_u64())?; - Some((level as u8).clamp(1, ICEBERG_LEVEL_COUNT)) -} - -fn iceberg_metric_field(value: &serde_json::Value, names: &[&str]) -> Option { - names - .iter() - .find_map(|name| value.get(*name).and_then(json_number)) - .map(normalize_iceberg_metric) -} - -fn iceberg_confidence_field(value: &serde_json::Value) -> Option { - value - .get("confidence") - .and_then(json_number) - .map(normalize_confidence) -} - -fn iceberg_string_field(value: &serde_json::Value, names: &[&str]) -> Option { - names - .iter() - .find_map(|name| value.get(*name).and_then(|value| value.as_str())) - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(ToString::to_string) -} - -fn json_number(value: &serde_json::Value) -> Option { - value - .as_f64() - .or_else(|| { - value - .as_str() - .and_then(|text| text.trim().parse::().ok()) - }) - .filter(|value| value.is_finite()) -} - -fn normalize_iceberg_metric(value: f64) -> f64 { - let normalized = if (0.0..=1.0).contains(&value) { - value * 100.0 - } else { - value - }; - round_score(normalized.clamp(0.0, 100.0)) -} -fn normalize_confidence(value: f64) -> f64 { - let normalized = if value > 10.0 { - value / 100.0 - } else if value > 1.0 { - value / 10.0 - } else { - value - }; - round_score(normalized.clamp(0.0, 1.0)) -} - -fn iceberg_fallback_score(level: u8) -> f64 { - match level.clamp(1, ICEBERG_LEVEL_COUNT) { - 1 => 10.0, - 2 => 30.0, - 3 => 50.0, - 4 => 70.0, - _ => 90.0, - } -} + #[test] + fn retrieval_eval_returns_one_row_per_source() { + let dir = TempDir::new(); + let (store, names) = eval_store(&dir); + let query = eval_embed("quantum mechanics wave function"); -fn iceberg_depth_score( - familiarity: Option, - specificity: Option, - jargon_density: Option, - prerequisite_depth: Option, - obscurity: Option, - fallback_score: f64, -) -> f64 { - let familiarity = familiarity.unwrap_or(100.0 - fallback_score); - let specificity = specificity.unwrap_or(fallback_score); - let jargon_density = jargon_density.unwrap_or(fallback_score); - let prerequisite_depth = prerequisite_depth.unwrap_or(fallback_score); - let obscurity = obscurity.unwrap_or(fallback_score); - - specificity * 0.3 - + jargon_density * 0.25 - + prerequisite_depth * 0.2 - + obscurity * 0.15 - + (100.0 - familiarity) * 0.1 -} + let (hits, examined) = rank_library_hits(&store.chunks, &names, None, Some(&query), "", 20); -fn iceberg_level_from_score(score: f64) -> u8 { - match score { - value if value < 20.0 => 1, - value if value < 40.0 => 2, - value if value < 60.0 => 3, - value if value < 80.0 => 4, - _ => 5, + assert_eq!(examined, store.chunks.len(), "every chunk should be scored"); + let mut ids = hits + .iter() + .map(|hit| hit.capture_id.clone()) + .collect::>(); + ids.sort(); + let unique = ids.iter().collect::>().len(); + assert_eq!(ids.len(), unique, "a source must not appear twice: {ids:?}"); + assert!(hits.len() <= eval_corpus().len()); + // Grouping must report how many passages matched, not silently collapse them. + let quantum = hits + .iter() + .find(|hit| hit.capture_id == "quantum") + .expect("quantum hit"); + assert!(quantum.chunk_matches >= 1); + assert_eq!(quantum.collection_name, "Eval hub"); + assert_eq!(quantum.host, "en.wikipedia.org"); } -} - -fn clamp_optional_score(value: Option, min: f64, max: f64) -> Option { - value - .filter(|value| value.is_finite()) - .map(|value| round_score(value.clamp(min, max))) -} - -fn normalize_saved_items(items: Vec) -> Vec { - items - .into_iter() - .filter(|item| !item.name.trim().is_empty() && !item.description.trim().is_empty()) - .map(|mut item| { - item.name = item.name.trim().to_string(); - item.description = item.description.trim().to_string(); - item.level = item.level.clamp(1, ICEBERG_LEVEL_COUNT); - item.depth_score = clamp_optional_score(item.depth_score, 0.0, 100.0); - item.familiarity = clamp_optional_score(item.familiarity, 0.0, 100.0); - item.specificity = clamp_optional_score(item.specificity, 0.0, 100.0); - item.jargon_density = clamp_optional_score(item.jargon_density, 0.0, 100.0); - item.prerequisite_depth = clamp_optional_score(item.prerequisite_depth, 0.0, 100.0); - item.obscurity = clamp_optional_score(item.obscurity, 0.0, 100.0); - item.confidence = clamp_optional_score(item.confidence, 0.0, 1.0); - item.reason = item - .reason - .map(|reason| reason.trim().chars().take(220).collect::()) - .filter(|reason| !reason.is_empty()); - if item.id.trim().is_empty() { - item.id = unique_slug(&format!("{}-{}", item.level, item.name), &[]); - } - item - }) - .collect() -} -fn saved_iceberg_summary(iceberg: &SavedIceberg) -> SavedIcebergSummary { - SavedIcebergSummary { - id: iceberg.id.clone(), - title: iceberg.title.clone(), - keyword: iceberg.iceberg.keyword.clone(), - model: iceberg.iceberg.model.clone(), - icon: iceberg.icon.clone(), - generated_at: iceberg.iceberg.generated_at.clone(), - saved_at: iceberg.saved_at.clone(), - updated_at: iceberg.updated_at.clone(), - item_count: iceberg.iceberg.items.len(), + #[test] + fn retrieval_eval_respects_hub_scope_and_limit() { + let dir = TempDir::new(); + let (store, names) = eval_store(&dir); + let query = eval_embed("quantum photosynthesis Augustus ownership"); + + let (limited, _) = rank_library_hits(&store.chunks, &names, None, Some(&query), "", 2); + assert_eq!(limited.len(), 2, "limit must cap the result set"); + + let (other_hub, _) = rank_library_hits( + &store.chunks, + &names, + Some("hub-missing"), + Some(&query), + "", + 20, + ); + assert!( + other_hub.is_empty(), + "scoping to another hub must exclude everything" + ); } -} -fn dedupe_citations(citations: Vec) -> Vec { - let mut unique = Vec::::new(); - let mut indexes = HashMap::::new(); - for citation in citations { - let key = normalize_citation_key(&citation.url); - if let Some(existing_index) = indexes.get(&key).copied() { - let existing = &mut unique[existing_index]; - if !existing.text.contains(&citation.text) { - // Join non-contiguous excerpts from the same page with a neutral - // marker. A numbered "Chunk N" label here leaks into the model's - // context and gets echoed as an uncitable "[Chunk N]" reference. - existing.text = format!("{}\n\n[…]\n\n{}", existing.text, citation.text) - .chars() - .take(9000) - .collect(); - } - existing.score = existing.score.min(citation.score); - } else { - indexes.insert(key, unique.len()); - unique.push(citation); + #[test] + fn retrieval_eval_ordering_is_stable_across_runs() { + let dir = TempDir::new(); + let (store, names) = eval_store(&dir); + let query = eval_embed("energy"); + + // HashMap iteration order varies per process; the sort must not depend on it. + let first = rank_library_hits(&store.chunks, &names, None, Some(&query), "", 20).0; + for _ in 0..5 { + let again = rank_library_hits(&store.chunks, &names, None, Some(&query), "", 20).0; + assert_eq!( + first.iter().map(|h| &h.capture_id).collect::>(), + again.iter().map(|h| &h.capture_id).collect::>() + ); } } - unique -} -fn split_text(text: &str, chunk_size: usize, overlap: usize) -> Vec { - let chars = text.chars().collect::>(); - let mut chunks = Vec::new(); - let mut start = 0; - while start < chars.len() { - let end = (start + chunk_size).min(chars.len()); - let chunk = chars[start..end] - .iter() - .collect::() - .trim() - .to_string(); - if !chunk.is_empty() { - chunks.push(chunk); - } - if end == chars.len() { - break; + // Chunking invariants. Retrieval cannot find text that chunking dropped, so these + // guard the input side of the pipeline. + #[test] + fn chunking_covers_the_whole_document_with_overlap() { + let body = eval_corpus()[0].body; + let chunks = split_text(body, 120, 30); + + assert!(chunks.len() > 1, "a long body should split"); + // Every character of the source must appear in some chunk. + let joined = chunks.concat(); + for word in body.split_whitespace() { + assert!(joined.contains(word), "chunking lost {word:?}"); + } + // Consecutive chunks must actually overlap, or a claim spanning a boundary + // becomes unretrievable. split_text trims each chunk, so the shared region is + // slightly shorter than the requested overlap; assert on a substring well + // inside it rather than on an exact prefix. + for pair in chunks.windows(2) { + let tail = pair[0] + .chars() + .rev() + .take(10) + .collect::>() + .into_iter() + .rev() + .collect::(); + assert!( + pair[1].contains(&tail), + "expected {:?} to carry the tail {tail:?} of the previous chunk", + pair[1].chars().take(40).collect::() + ); } - start = end.saturating_sub(overlap); - } - chunks -} - -fn cosine_distance(left: &[f32], right: &[f32]) -> f64 { - if left.is_empty() || left.len() != right.len() { - return f64::INFINITY; - } - let mut dot = 0.0_f64; - let mut left_norm = 0.0_f64; - let mut right_norm = 0.0_f64; - for (left, right) in left.iter().zip(right.iter()) { - let left = *left as f64; - let right = *right as f64; - dot += left * right; - left_norm += left * left; - right_norm += right * right; - } - if left_norm == 0.0 || right_norm == 0.0 { - f64::INFINITY - } else { - 1.0 - dot / (left_norm.sqrt() * right_norm.sqrt()) } -} - -fn lock_tabs<'a>(state: &'a State<'_, Backend>) -> Cmd> { - state - .tabs - .lock() - .map_err(|_| "Æther tab state is unavailable.".to_string()) -} -fn emit_state(app: &AppHandle, state: &State) -> Cmd<()> { - let tabs = lock_tabs(state)?; - app.emit("aether:state", tabs.state()) - .map_err(|error| error.to_string()) -} + #[test] + fn chunking_handles_multibyte_text_without_splitting_characters() { + let body = "— 私は研究します。".repeat(20); + let chunks = split_text(&body, 40, 10); -fn active_tab_url(state: &State) -> Cmd { - let tabs = lock_tabs(state)?; - tabs.active_tab() - .map(|tab| tab.url.clone()) - .ok_or_else(|| "No active browser tab.".to_string()) -} + assert!(!chunks.is_empty()); + for chunk in &chunks { + // Round-tripping proves no chunk ends mid-character. + assert_eq!(chunk.as_bytes(), chunk.clone().into_bytes().as_slice()); + } + assert!(chunks.concat().contains("私は研究します")); + } -#[cfg(desktop)] -fn active_tab_id(state: &State) -> Cmd { - Ok(lock_tabs(state)?.active_tab_id.clone()) -} + // Opt-in migration check against a real pre-upgrade store. Synthetic fixtures cannot + // reproduce what a store looks like after an embedding model changed under it, which + // is exactly the case the migration got wrong. Run locally with: + // AETHER_LEGACY_STORE=/path/to/chunks.json cargo test --lib legacy_store -- --ignored + #[test] + #[ignore = "requires AETHER_LEGACY_STORE; not available in CI"] + fn migration_of_a_real_legacy_store_loses_nothing() { + let Ok(source) = std::env::var("AETHER_LEGACY_STORE") else { + panic!("set AETHER_LEGACY_STORE to a v1 chunks.json"); + }; + let raw = fs::read_to_string(&source).expect("read legacy store"); + let legacy: LegacyVectorStoreData = serde_json::from_str(&raw).expect("parse legacy store"); + let before = legacy.chunks.len(); + let widths = legacy + .chunks + .iter() + .map(|chunk| chunk.vector.len()) + .collect::>(); -fn reorder(items: Vec, ids: &[String], id_of: F) -> Vec -where - T: Clone, - F: Fn(&T) -> &String, -{ - let requested = ids.iter().filter(|id| !id.is_empty()).collect::>(); - let requested_set = requested - .iter() - .map(|id| (*id).clone()) - .collect::>(); - let by_id = items - .iter() - .map(|item| (id_of(item).clone(), item.clone())) - .collect::>(); - let mut ordered = requested - .into_iter() - .filter_map(|id| by_id.get(id).cloned()) - .collect::>(); - ordered.extend( - items - .into_iter() - .filter(|item| !requested_set.contains(id_of(item))), - ); - ordered -} + // Copy so the real store is never the thing under test. + let dir = TempDir::new(); + let path = dir.path("chunks.json"); + fs::write(&path, &raw).expect("seed copy"); -fn normalize_captured_text(text: &str) -> String { - text.replace('\r', "") - .split('\n') - .map(str::trim) - .collect::>() - .join("\n") - .split_whitespace() - .collect::>() - .join(" ") - .trim() - .to_string() -} + let migrated = block_on(load_vectors(&path)).expect("migrate"); -fn normalize_url(raw_url: &str, search_engine: &str) -> String { - let trimmed = raw_url.trim(); - if trimmed.is_empty() { - return "https://www.google.com".to_string(); - } - if Url::parse(trimmed).is_ok() { - return trimmed.to_string(); - } - if trimmed.contains(char::is_whitespace) || !trimmed.contains(['.', ':']) { - return format!( - "{}{}", - search_engine_prefix(search_engine), - urlencoding(trimmed) + diag_warn!( + "legacy store: {before} chunks, widths {widths:?} -> dim {}, {} embedded, {} parked", + migrated.dim, + migrated.embedded_count(), + migrated.pending_reembed_count() ); - } - if trimmed.starts_with("localhost") - || trimmed.starts_with("127.0.0.1") - || trimmed.starts_with("[::1]") - { - return format!("http://{trimmed}"); - } - format!("https://{trimmed}") -} - -fn search_engine_prefix(id: &str) -> &'static str { - match id { - "bing" => "https://www.bing.com/search?q=", - "yahoo" => "https://search.yahoo.com/search?p=", - "ecosia" => "https://www.ecosia.org/search?q=", - "duckduckgo" => "https://duckduckgo.com/?q=", - _ => "https://www.google.com/search?q=", - } -} + assert_eq!( + migrated.chunks.len(), + before, + "migration must not drop any chunk" + ); + assert!( + dir.path("chunks.v1.json").exists(), + "the pre-migration store must be archived" + ); + for chunk in &migrated.chunks { + if chunk.needs_reembed { + assert!(!chunk.text.is_empty(), "parked chunks must keep their text"); + } else { + assert_eq!(chunk.vector.len(), migrated.dim); + } + } -fn normalize_search_engine_id(value: &str) -> String { - match value { - "google" | "bing" | "yahoo" | "ecosia" | "duckduckgo" => value.to_string(), - _ => "google".to_string(), + // Reloading must take the v2 path and preserve the same shape. + let reloaded = block_on(load_vectors(&path)).expect("reload"); + assert_eq!(reloaded.chunks.len(), before); + assert_eq!(reloaded.embedded_count(), migrated.embedded_count()); } -} -fn normalize_iceberg_icon(value: Option) -> Option { - let allowed = [ - "atom", - "book", - "brain", - "briefcase", - "code", - "cpu", - "dna", - "film", - "flask", - "gamepad", - "globe", - "heart", - "landmark", - "microscope", - "music", - "palette", - "shield", - "snowflake", - "sprout", - "telescope", - ]; - value - .map(|icon| icon.trim().to_lowercase()) - .filter(|icon| allowed.contains(&icon.as_str())) -} - -fn normalize_theme_color(color: &str) -> Option { - let value = color.trim().chars().take(64).collect::(); - if value.is_empty() { - return None; + // Opt-in eval against the real embedding model. Run locally with: + // AETHER_EMBEDDING_MODEL=/path/to/model.gguf cargo test --lib real_model -- --ignored + // Kept out of CI because the model is a 640 MB download. + #[test] + #[ignore = "requires AETHER_EMBEDDING_MODEL; not available in CI"] + fn retrieval_eval_real_model_is_configured() { + let path = env::var(AETHER_EMBEDDING_MODEL_ENV) + .expect("set AETHER_EMBEDDING_MODEL to the embedding GGUF to run this eval"); + assert!( + Path::new(&path).exists(), + "AETHER_EMBEDDING_MODEL points at {path}, which does not exist" + ); } - if let Some(hex) = value.strip_prefix('#') { - if (3..=8).contains(&hex.len()) - && hex.chars().all(|character| character.is_ascii_hexdigit()) - { - return Some(value); + fn chunk_for_search(capture_id: &str, title: &str, url: &str, text: &str) -> ChunkRecord { + ChunkRecord { + id: uuid(), + vector: vec![0.0; 4], + vector_slot: 0, + needs_reembed: false, + text: text.to_string(), + collection_id: "hub-1".to_string(), + capture_id: capture_id.to_string(), + title: title.to_string(), + url: url.to_string(), + app_id: "browser".to_string(), + captured_at: "2026-07-01T00:00:00Z".to_string(), + chunk_index: 0, } } - let lower = value.to_ascii_lowercase(); - let supported_function = lower.starts_with("rgb(") - || lower.starts_with("rgba(") - || lower.starts_with("hsl(") - || lower.starts_with("hsla("); - if supported_function && value.ends_with(')') { - return Some(value); - } + // A remembered page name should outrank an incidental body mention, otherwise + // the page the user is picturing gets buried under passing references to it. + #[test] + fn literal_search_ranks_title_matches_above_body_matches() { + let titled = chunk_for_search( + "a", + "Quantum mechanics", + "https://example.com/a", + "unrelated", + ); + let mentioned = chunk_for_search( + "b", + "Cooking basics", + "https://example.com/b", + "quantum mechanics", + ); - None -} + let titled_score = literal_match_score("quantum mechanics", &titled); + let mentioned_score = literal_match_score("quantum mechanics", &mentioned); -fn title_from_url(url: &str) -> String { - let host = get_tab_host(url); - if host.is_empty() { - "New tab".to_string() - } else { - host + assert!(titled_score > mentioned_score); + assert!( + mentioned_score > 0.0, + "body matches should still be findable" + ); } -} -fn favicon_for_url(url: &str) -> Option { - let parsed = Url::parse(url).ok()?; - Some(format!( - "{}://{}/favicon.ico", - parsed.scheme(), - parsed.host_str()? - )) -} + #[test] + fn literal_search_matches_hosts_and_ignores_misses() { + let chunk = chunk_for_search("a", "Augustus", "https://en.wikipedia.org/wiki/x", "body"); -fn get_tab_host(url: &str) -> String { - Url::parse(url) - .ok() - .and_then(|url| { - url.host_str() - .map(|host| host.trim_start_matches("www.").to_string()) - }) - .unwrap_or_default() -} + assert!(literal_match_score("wikipedia", &chunk) > 0.0); + assert_eq!(literal_match_score("nonexistent-term", &chunk), 0.0); + assert_eq!(literal_match_score("", &chunk), 0.0); + } -fn normalize_citation_key(url: &str) -> String { - match Url::parse(url) { - Ok(mut parsed) => { - parsed.set_fragment(None); - parsed.to_string() - } - Err(_) => url.to_string(), + #[test] + fn literal_search_is_case_insensitive_and_capped() { + let chunk = chunk_for_search("a", "Augustus", "https://augustus.example", "augustus"); + + // Title + url + body all match; the score must stay a valid percentage. + assert_eq!(literal_match_score("augustus", &chunk), 100.0); } -} -fn normalize_capture_url_key(url: &str) -> String { - match Url::parse(url) { - Ok(mut parsed) => { - parsed.set_fragment(None); - if parsed.path() == "/" { - parsed.set_path(""); - } - parsed.to_string().trim_end_matches('/').to_string() - } - Err(_) => url.trim().trim_end_matches('/').to_string(), + // The dangerous failure mode is silently capturing a search-results page for a + // typo, which would poison a hub with content the user never chose. + #[test] + fn capture_target_rejects_search_text_instead_of_guessing_a_url() { + assert!(capture_target_url("how do vaccines work").is_err()); + assert!(capture_target_url(" ").is_err()); + assert!(capture_target_url("notaurl").is_err()); } -} -fn unique_slug(name: &str, existing: &[String]) -> String { - let base = slugify(name); - let mut candidate = base.clone(); - let mut suffix = 2; - while existing.contains(&candidate) { - candidate = format!("{base}-{suffix}"); - suffix += 1; + #[test] + fn capture_target_accepts_bare_hosts_and_full_urls() { + assert_eq!( + capture_target_url("example.com/article"), + Ok("https://example.com/article".to_string()) + ); + assert_eq!( + capture_target_url(" https://en.wikipedia.org/wiki/Augustus "), + Ok("https://en.wikipedia.org/wiki/Augustus".to_string()) + ); + assert_eq!( + capture_target_url("http://example.com"), + Ok("http://example.com/".to_string()) + ); } - candidate -} -fn slugify(value: &str) -> String { - let mut slug = String::new(); - let mut last_dash = false; - for char in value.trim().to_lowercase().chars() { - if char.is_ascii_alphanumeric() || char == '_' { - slug.push(char); - last_dash = false; - } else if !last_dash { - slug.push('-'); - last_dash = true; + #[test] + fn capture_target_rejects_non_web_schemes() { + for raw in [ + "file:///etc/passwd", + "aether://dashboard", + "javascript:alert(1)", + "ftp://example.com/x", + ] { + assert!( + capture_target_url(raw).is_err(), + "{raw} should not be capturable" + ); } } - let slug = slug.trim_matches('-').to_string(); - if slug.is_empty() { - "collection".to_string() - } else { - slug - } -} - -fn urlencoding(value: &str) -> String { - value - .bytes() - .flat_map(|byte| match byte { - b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { - vec![byte as char] - } - b' ' => vec!['+'], - _ => format!("%{byte:02X}").chars().collect(), - }) - .collect() -} - -fn uuid() -> String { - uuid::Uuid::new_v4().to_string() -} - -fn now() -> String { - Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true) -} - -#[cfg(test)] -mod tests { - use super::*; #[test] fn answer_citation_normalizer_removes_out_of_range_markers() { @@ -8739,7 +2785,7 @@ mod tests { #[test] fn stream_safe_len_respects_multibyte_boundaries() { - let text = "Æther çalışması — özet = 80.0)); } + #[test] + fn iceberg_normalizer_recovers_complete_items_from_truncated_json() { + let response = r#"{ + "recommendedItemCount": 24, + "items": [ + { + "name": "Quantum mechanics", + "description": "The physical framework for quantum systems.", + "depthScore": 12 + }, + { + "name": "Bloch sphere", + "description": "A geometric representation of a qubit state.", + "depthScore": 32 + }, + { + "name": "Incomplete fragment", + "description": "Generation stopped midway" + "#; + + let normalized = + normalize_iceberg_items(response).expect("complete generated items are recoverable"); + + assert_eq!(normalized.len(), 2); + assert_eq!(normalized[0].name, "Quantum mechanics"); + assert_eq!(normalized[1].name, "Bloch sphere"); + } + #[test] fn semantic_trail_items_dedupe_urls_and_merge_excerpts() { let first = semantic_trail_candidate( @@ -8920,8 +2993,12 @@ mod tests { assert_eq!(items.len(), 1); assert!(items[0].excerpt.contains("First source passage")); assert!(items[0].excerpt.contains("Second source passage")); - assert!(items[0].reasons.contains(&SemanticTrailReason::SemanticMatch)); - assert!(items[0].reasons.contains(&SemanticTrailReason::RecentCapture)); + assert!(items[0] + .reasons + .contains(&SemanticTrailReason::SemanticMatch)); + assert!(items[0] + .reasons + .contains(&SemanticTrailReason::RecentCapture)); } #[test] @@ -8945,14 +3022,20 @@ mod tests { #[test] fn air_filename_matches_dossier_title_with_safe_separators() { assert_eq!( - air_dossier_filename("AiR Dossier: Local/Fluid Research", "2026-06-17T10:11:12.123Z"), + air_dossier_filename( + "AiR Dossier: Local/Fluid Research", + "2026-06-17T10:11:12.123Z" + ), "AiR Dossier: Local-Fluid Research.md" ); } #[test] fn air_yaml_string_escapes_quotes_and_newlines() { - assert_eq!(yaml_string("A \"quoted\"\nLens"), "\"A \\\"quoted\\\" Lens\""); + assert_eq!( + yaml_string("A \"quoted\"\nLens"), + "\"A \\\"quoted\\\" Lens\"" + ); } #[test] @@ -9028,6 +3111,8 @@ mod tests { chunk: ChunkRecord { id: uuid(), vector: vec![1.0, 0.0], + vector_slot: 0, + needs_reembed: false, text: text.to_string(), collection_id: "collection".to_string(), capture_id: "capture".to_string(), diff --git a/src-tauri/src/model_catalog.rs b/src-tauri/src/model_catalog.rs new file mode 100644 index 0000000..de8b57b --- /dev/null +++ b/src-tauri/src/model_catalog.rs @@ -0,0 +1,455 @@ +//! Finding, ranking, and describing the local GGUF models on disk, plus the +//! environment-variable overrides and the llama.cpp tuning knobs. + +use super::*; + +pub(crate) fn model_catalog(paths: &DataPaths, settings: &LocalModelSettings) -> ModelCatalog { + let mut errors = Vec::new(); + let model_dirs = [ + paths.models_path.clone(), + paths.models_path.join("chat"), + paths.models_path.join("embeddings"), + ]; + for dir in &model_dirs { + if let Err(error) = fs::create_dir_all(dir) { + errors.push(format!( + "Could not create model directory {}: {error}", + dir.display() + )); + } + } + + let mut models = Vec::new(); + collect_gguf_models(&paths.models_path, &mut models); + if let Ok(dir) = env::var(AETHER_MODEL_DIR_ENV) { + let dir = PathBuf::from(dir); + collect_gguf_models(&dir, &mut models); + } + for var in [AETHER_CHAT_MODEL_ENV, AETHER_EMBEDDING_MODEL_ENV] { + match env_model_path(var) { + Ok(Some(path)) => models.push(path), + Ok(None) => {} + Err(error) => errors.push(error), + } + } + for (value, embedding) in [ + (settings.chat_model.as_deref(), false), + (settings.embedding_model.as_deref(), true), + ] { + if let Some(path) = value.and_then(|value| selected_direct_model_path(value, embedding)) { + models.push(path); + } + } + + models = dedupe_model_paths(models); + let embedding_model = pick_embedding_model(&models, settings); + let chat_model = pick_chat_model(&models, settings); + if models.is_empty() { + errors.push(format!( + "No local models found. Use Model Setup to install AiON MiST/Qwen3 Embedding and Gemma 4, add compatible GGUF models to {}, or set {AETHER_MODEL_DIR_ENV}.", + paths.models_path.display() + )); + } else { + if embedding_model.is_none() { + errors.push(format!( + "No embedding model selected. Put Qwen3 Embedding or another embedding GGUF in {} or set {AETHER_EMBEDDING_MODEL_ENV}.", + paths.models_path.join("embeddings").display() + )); + } + if chat_model.is_none() { + errors.push(format!( + "No chat GGUF selected. Put a Gemma chat model in {} or set {AETHER_CHAT_MODEL_ENV}.", + paths.models_path.join("chat").display() + )); + } + } + + ModelCatalog { + models, + chat_model, + embedding_model, + error: if errors.is_empty() { + None + } else { + Some(errors.join(" ")) + }, + } +} + +pub(crate) fn selected_direct_model_path(value: &str, embedding: bool) -> Option { + let value = value.trim(); + if value.is_empty() { + return None; + } + let path = PathBuf::from(value); + if selected_model_matches_kind(&path, embedding) { + Some(canonical_model_path(&path)) + } else { + None + } +} + +pub(crate) fn collect_gguf_models(root: &Path, models: &mut Vec) { + let mut stack = vec![(root.to_path_buf(), 0usize)]; + while let Some((dir, depth)) = stack.pop() { + if depth > 4 { + continue; + } + let Ok(entries) = fs::read_dir(&dir) else { + continue; + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + stack.push((path, depth + 1)); + } else if is_gguf_model(&path) { + models.push(path); + } + } + } +} + +pub(crate) fn default_models_path(app_data_dir: &Path) -> PathBuf { + // The repo-relative dev path is a compile-time string from the build + // machine; on a phone it would point at a nonexistent host filesystem. + if cfg!(all(debug_assertions, desktop)) { + project_models_path() + } else { + app_data_dir.join("aether-models") + } +} + +pub(crate) fn project_models_path() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .parent() + .map(|path| path.join("aether-models")) + .unwrap_or_else(|| PathBuf::from("aether-models")) +} + +pub(crate) fn dedupe_model_paths(models: Vec) -> Vec { + let mut seen = HashSet::new(); + let mut deduped = Vec::new(); + for path in models { + let path = canonical_model_path(&path); + let key = path.display().to_string(); + if seen.insert(key) { + deduped.push(path); + } + } + deduped.sort_by_key(|path| model_label(path).to_lowercase()); + deduped +} + +pub(crate) fn env_model_path(var: &str) -> Cmd> { + let Ok(value) = env::var(var) else { + return Ok(None); + }; + let path = PathBuf::from(value.trim()); + let valid = match var { + AETHER_CHAT_MODEL_ENV => is_chat_model(&path), + AETHER_EMBEDDING_MODEL_ENV => is_embedding_model(&path), + _ => is_gguf_model(&path), + }; + if valid { + Ok(Some(path)) + } else { + Err(format!( + "{var} does not point to an existing local model: {}", + path.display() + )) + } +} + +pub(crate) fn pick_embedding_model( + models: &[PathBuf], + settings: &LocalModelSettings, +) -> Option { + if let Ok(Some(path)) = env_model_path(AETHER_EMBEDDING_MODEL_ENV) { + return Some(canonical_model_path(&path)); + } + if let Some(model) = settings + .embedding_model + .as_deref() + .and_then(|value| pick_selected_model(models, value, true)) + { + return Some(model); + } + models + .iter() + .filter(|path| is_embedding_model(path)) + .max_by_key(|path| embedding_model_score(path)) + .cloned() +} + +pub(crate) fn pick_chat_model( + models: &[PathBuf], + settings: &LocalModelSettings, +) -> Option { + if let Ok(Some(path)) = env_model_path(AETHER_CHAT_MODEL_ENV) { + return Some(canonical_model_path(&path)); + } + if let Some(model) = settings + .chat_model + .as_deref() + .and_then(|value| pick_selected_model(models, value, false)) + { + return Some(model); + } + pick_model_by_hints(models, &PREFERRED_CHAT_MODEL_HINTS, false).or_else(|| { + models + .iter() + .find(|path| !is_embedding_model_name(path)) + .cloned() + }) +} + +pub(crate) fn pick_selected_model( + models: &[PathBuf], + value: &str, + embedding: bool, +) -> Option { + let value = value.trim(); + if value.is_empty() { + return None; + } + let direct = PathBuf::from(value); + if selected_model_matches_kind(&direct, embedding) { + return Some(canonical_model_path(&direct)); + } + let normalized = value.to_lowercase(); + models + .iter() + .find(|path| { + let label = model_label(path); + path_to_model_value(path) == value + || label == value + || strip_gguf_extension(&label) == value + || label.to_lowercase().contains(&normalized) + }) + .filter(|path| selected_model_matches_kind(path, embedding)) + .cloned() +} + +pub(crate) fn pick_model_by_hints( + models: &[PathBuf], + hints: &[&str], + embedding: bool, +) -> Option { + for hint in hints { + let hint = hint.to_lowercase(); + if let Some(model) = models.iter().find(|path| { + let label = model_label(path).to_lowercase(); + label.contains(&hint) + && if embedding { + is_embedding_model(path) + } else { + is_chat_model(path) + } + }) { + return Some(model.clone()); + } + } + None +} + +pub(crate) fn embedding_model_score(path: &Path) -> i32 { + let label = model_label(path).to_lowercase(); + let mut score = 0; + if is_gguf_model(path) { + score += 1_000; + } + if label.contains("qwen3-embedding") { + score += 650; + } + if label.contains("bf16") { + score += 400; + } else if label.contains("f16") { + score += 300; + } else if label.contains("q8") { + score += 150; + } + score +} + +pub(crate) fn embedding_pooling_type(path: &Path) -> LlamaPoolingType { + if is_qwen3_embedding_model(path) { + LlamaPoolingType::Last + } else { + LlamaPoolingType::Mean + } +} + +pub(crate) fn embedding_attention_type(path: &Path) -> LlamaAttentionType { + if is_qwen3_embedding_model(path) { + LlamaAttentionType::Causal + } else { + LlamaAttentionType::Unspecified + } +} + +pub(crate) fn is_qwen3_embedding_model(path: &Path) -> bool { + let label = model_label(path).to_lowercase(); + label.contains("qwen3-embedding") +} + +pub(crate) fn qwen3_embedding_decode(path: &Path) -> bool { + is_qwen3_embedding_model(path) +} + +pub(crate) fn is_gguf_model(path: &Path) -> bool { + path.is_file() + && !is_mmproj_model(path) + && path + .extension() + .and_then(|extension| extension.to_str()) + .is_some_and(|extension| extension.eq_ignore_ascii_case("gguf")) +} + +pub(crate) fn is_chat_model(path: &Path) -> bool { + is_gguf_model(path) && !is_embedding_model_name(path) +} + +pub(crate) fn selected_model_matches_kind(path: &Path, embedding: bool) -> bool { + if embedding { + is_embedding_model(path) + } else { + is_chat_model(path) + } +} + +pub(crate) fn is_embedding_model(path: &Path) -> bool { + is_gguf_model(path) && is_embedding_model_name(path) +} + +pub(crate) fn is_embedding_model_name(path: &Path) -> bool { + let label = model_label(path).to_lowercase(); + label.contains("embed") || label.contains("embedding") +} + +pub(crate) fn is_mmproj_model(path: &Path) -> bool { + model_label(path).to_lowercase().contains("mmproj") +} + +pub(crate) fn canonical_model_path(path: &Path) -> PathBuf { + fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf()) +} + +pub(crate) fn path_to_model_value(path: &Path) -> String { + path.display().to_string() +} + +pub(crate) fn model_label(path: &Path) -> String { + path.file_name() + .and_then(|name| name.to_str()) + .map(strip_gguf_extension) + .unwrap_or_else(|| path.display().to_string()) +} + +/// Product name for user-facing status text. Filenames must stay in sync with +/// `managed_model_spec`; anything unmanaged falls back to its cleaned filename. +pub(crate) fn friendly_model_label(path: &Path) -> String { + match path.file_name().and_then(|name| name.to_str()) { + Some("gemma-4-E2B_q4_0-it.gguf") => "AiON LiTE".to_string(), + Some("gemma-4-E4B_q4_0-it.gguf") => "AiON WiSE".to_string(), + Some("Qwen3-Embedding-0.6B-Q8_0.gguf") => "AiON MiST".to_string(), + _ => model_label(path), + } +} + +pub(crate) fn strip_gguf_extension(value: &str) -> String { + value + .strip_suffix(".gguf") + .or_else(|| value.strip_suffix(".GGUF")) + .unwrap_or(value) + .to_string() +} + +pub(crate) fn chat_context_tokens() -> u32 { + // Phones get half the desktop window: the KV cache plus compute buffers + // for 6k context put a multi-GB model into zram-thrashing territory, + // which reads as a silent hang during prefill. + let default = if cfg!(mobile) { + 3072 + } else { + DEFAULT_CHAT_CONTEXT_TOKENS + }; + env::var(AETHER_LLM_CONTEXT_ENV) + .ok() + .and_then(|value| value.parse::().ok()) + .unwrap_or(default) + .clamp(1024, 65_536) +} + +pub(crate) fn chat_batch_token_limit() -> usize { + env::var(AETHER_LLM_BATCH_TOKENS_ENV) + .ok() + .and_then(|value| value.parse::().ok()) + .unwrap_or(DEFAULT_CHAT_BATCH_TOKENS) + .clamp(512, 8192) +} + +pub(crate) fn local_gpu_enabled() -> bool { + env_flag_enabled(AETHER_LLM_GPU_ENV, cfg!(target_os = "macos")) +} + +pub(crate) fn embedding_gpu_enabled() -> bool { + env_flag_enabled(AETHER_EMBED_GPU_ENV, false) +} + +pub(crate) fn env_flag_enabled(name: &str, default: bool) -> bool { + env::var(name).ok().map_or(default, |value| { + matches!(value.to_lowercase().as_str(), "1" | "true" | "yes" | "on") + }) +} + +pub(crate) fn embedding_batch_size() -> usize { + env::var(AETHER_EMBED_BATCH_ENV) + .ok() + .and_then(|value| value.parse::().ok()) + .unwrap_or(DEFAULT_EMBEDDING_BATCH_SIZE) + .clamp(1, 24) +} + +pub(crate) fn embedding_batch_token_limit() -> usize { + env::var(AETHER_EMBED_BATCH_TOKENS_ENV) + .ok() + .and_then(|value| value.parse::().ok()) + .unwrap_or(DEFAULT_EMBEDDING_BATCH_TOKENS) + .clamp(512, 8192) +} + +pub(crate) fn embedding_context_tokens(input_tokens: usize) -> u32 { + let needed = input_tokens.saturating_add(16).min(u32::MAX as usize) as u32; + DEFAULT_EMBEDDING_CONTEXT_TOKENS.max(needed).min(8192) +} + +pub(crate) fn auto_thread_count() -> i32 { + // Mobile keeps one core free instead of two: recent flagships (like the + // all-big-core Snapdragon 8 Elite) have no little cores to avoid, prefill + // is compute-bound, and the UI sits idle while AiON works. + let reserve = if cfg!(mobile) { 1 } else { 2 }; + std::thread::available_parallelism() + .map(|threads| threads.get().saturating_sub(reserve).clamp(2, 12) as i32) + .unwrap_or(6) +} + +pub(crate) fn normalize_embedding(values: &[f32]) -> Vec { + let norm = values + .iter() + .map(|value| (*value as f64) * (*value as f64)) + .sum::() + .sqrt(); + if norm <= f64::EPSILON { + return values.to_vec(); + } + values + .iter() + .map(|value| (*value as f64 / norm) as f32) + .collect() +} + +// Phones prefill on CPU, so prompt length is the ask latency. The mobile +// budget (5 sources x ~1100 chars + system + question) fits the 2048-token +// prompt window, where desktop's 8 full chunks would overflow it and get +// front-truncated — losing the system message and top-ranked sources while +// still paying full prefill cost. diff --git a/src-tauri/src/model_downloads.rs b/src-tauri/src/model_downloads.rs new file mode 100644 index 0000000..6b11ffb --- /dev/null +++ b/src-tauri/src/model_downloads.rs @@ -0,0 +1,380 @@ +//! Fetching managed GGUF models from Hugging Face, with resumable progress. + +use super::*; + +/// One progress tick for a managed model download. A struct rather than six +/// positional arguments: at the call sites the bare numbers read as +/// `0, Some(spec.expected_bytes), overall_downloaded, overall_total`, which is +/// impossible to check by eye. +pub(crate) struct ModelDownloadStage<'a> { + pub(crate) status: &'a str, + pub(crate) downloaded_bytes: u64, + pub(crate) total_bytes: Option, + pub(crate) overall_downloaded_bytes: u64, + pub(crate) overall_total_bytes: Option, + pub(crate) message: Option, +} + +pub(crate) async fn download_managed_models( + app: &AppHandle, + state: &State<'_, Backend>, + input: DownloadModelsInput, +) -> Cmd<()> { + let specs = selected_model_downloads(&state.paths, &input)?; + let hf_token = input + .hf_token + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) + .or_else(huggingface_token); + let overall_total = specs + .iter() + .map(|spec| spec.expected_bytes) + .reduce(|first, second| first.saturating_add(second)); + let mut overall_downloaded = 0u64; + + for spec in &specs { + if let Some(existing_bytes) = + completed_model_bytes(&spec.destination, spec.expected_bytes).await + { + overall_downloaded = overall_downloaded.saturating_add(existing_bytes); + emit_model_download_progress( + app, + spec, + ModelDownloadStage { + status: "skipped", + downloaded_bytes: existing_bytes, + total_bytes: Some(spec.expected_bytes), + overall_downloaded_bytes: overall_downloaded, + overall_total_bytes: overall_total, + message: Some("Already installed".to_string()), + }, + ); + continue; + } + + emit_model_download_progress( + app, + spec, + ModelDownloadStage { + status: "queued", + downloaded_bytes: 0, + total_bytes: Some(spec.expected_bytes), + overall_downloaded_bytes: overall_downloaded, + overall_total_bytes: overall_total, + message: Some("Preparing download".to_string()), + }, + ); + + match download_model_file( + app, + &state.client, + spec, + overall_downloaded, + overall_total, + hf_token.as_deref(), + ) + .await + { + Ok(downloaded_bytes) => { + overall_downloaded = overall_downloaded.saturating_add(downloaded_bytes); + emit_model_download_progress( + app, + spec, + ModelDownloadStage { + status: "complete", + downloaded_bytes, + total_bytes: Some(downloaded_bytes), + overall_downloaded_bytes: overall_downloaded, + overall_total_bytes: overall_total, + message: Some("Installed".to_string()), + }, + ); + } + Err(error) => { + emit_model_download_progress( + app, + spec, + ModelDownloadStage { + status: "error", + downloaded_bytes: 0, + total_bytes: Some(spec.expected_bytes), + overall_downloaded_bytes: overall_downloaded, + overall_total_bytes: overall_total, + message: Some(error.clone()), + }, + ); + return Err(error); + } + } + } + + persist_downloaded_model_selection(&state.paths, &specs).await +} + +pub(crate) fn selected_model_downloads( + paths: &DataPaths, + input: &DownloadModelsInput, +) -> Cmd> { + let mut specs = vec![managed_model_spec(paths, "mist")?]; + let mut selected = HashSet::new(); + + for model in &input.chat_models { + let normalized = model.trim().to_lowercase(); + if normalized.is_empty() { + continue; + } + if !selected.insert(normalized.clone()) { + continue; + } + specs.push(managed_model_spec(paths, &normalized)?); + } + + Ok(specs) +} + +pub(crate) fn managed_model_spec(paths: &DataPaths, id: &str) -> Cmd { + match id { + "mist" => Ok(ModelDownloadSpec { + id: "mist", + label: "AiON MiST", + repository: "Qwen/Qwen3-Embedding-0.6B-GGUF", + revision: "370f27d7550e0def9b39c1f16d3fbaa13aa67728", + filename: "Qwen3-Embedding-0.6B-Q8_0.gguf", + destination: paths + .models_path + .join("embeddings") + .join("Qwen3-Embedding-0.6B-GGUF") + .join("Qwen3-Embedding-0.6B-Q8_0.gguf"), + expected_bytes: 639_150_592, + }), + "lite" => Ok(ModelDownloadSpec { + id: "lite", + label: "AiON LiTE", + repository: "google/gemma-4-E2B-it-qat-q4_0-gguf", + revision: "1894d1fc0a19d86697abd40483f5983c867df03f", + filename: "gemma-4-E2B_q4_0-it.gguf", + destination: paths + .models_path + .join("chat") + .join("gemma-4-E2B-it-qat-q4_0-gguf") + .join("gemma-4-E2B_q4_0-it.gguf"), + expected_bytes: 3_349_514_112, + }), + "wise" => Ok(ModelDownloadSpec { + id: "wise", + label: "AiON WiSE", + repository: "google/gemma-4-E4B-it-qat-q4_0-gguf", + revision: "bb3b92e6f031fa438b409f898dd9f14f499a0cb0", + filename: "gemma-4-E4B_q4_0-it.gguf", + destination: paths + .models_path + .join("chat") + .join("gemma-4-E4B-it-qat-q4_0-gguf") + .join("gemma-4-E4B_q4_0-it.gguf"), + expected_bytes: 5_154_939_136, + }), + _ => Err(format!("Unknown AiON model selection: {id}")), + } +} + +pub(crate) async fn completed_model_bytes(path: &Path, expected_bytes: u64) -> Option { + let metadata = tokio::fs::metadata(path).await.ok()?; + let len = metadata.len(); + if metadata.is_file() && len == expected_bytes { + Some(len) + } else { + None + } +} + +pub(crate) async fn download_model_file( + app: &AppHandle, + client: &Client, + spec: &ModelDownloadSpec, + overall_base_bytes: u64, + overall_total_bytes: Option, + hf_token: Option<&str>, +) -> Cmd { + let parent = spec + .destination + .parent() + .ok_or_else(|| format!("Invalid model destination: {}", spec.destination.display()))?; + tokio::fs::create_dir_all(parent).await.map_err(|error| { + format!( + "Could not create model directory {}: {error}", + parent.display() + ) + })?; + + let temp_path = spec + .destination + .with_file_name(format!("{}.part", spec.filename)); + let _ = tokio::fs::remove_file(&temp_path).await; + + let mut request = client.get(spec.source_url()); + if let Some(token) = hf_token { + request = request.bearer_auth(token); + } + + let mut response = request + .send() + .await + .map_err(|error| format!("Could not reach Hugging Face for {}: {error}", spec.label))?; + let status = response.status(); + if !status.is_success() { + return Err(huggingface_download_error(spec, status.as_u16())); + } + + let total_bytes = response.content_length().or(Some(spec.expected_bytes)); + let mut file = tokio::fs::File::create(&temp_path).await.map_err(|error| { + format!( + "Could not create temporary model file {}: {error}", + temp_path.display() + ) + })?; + let mut downloaded_bytes = 0u64; + let mut last_emit = Instant::now(); + + emit_model_download_progress( + app, + spec, + ModelDownloadStage { + status: "downloading", + downloaded_bytes, + total_bytes, + overall_downloaded_bytes: overall_base_bytes, + overall_total_bytes, + message: Some("Downloading from Hugging Face".to_string()), + }, + ); + + while let Some(chunk) = response + .chunk() + .await + .map_err(|error| format!("Download interrupted for {}: {error}", spec.label))? + { + file.write_all(&chunk) + .await + .map_err(|error| format!("Could not write {}: {error}", spec.filename))?; + downloaded_bytes = downloaded_bytes.saturating_add(chunk.len() as u64); + + if last_emit.elapsed() >= Duration::from_millis(160) { + emit_model_download_progress( + app, + spec, + ModelDownloadStage { + status: "downloading", + downloaded_bytes, + total_bytes, + overall_downloaded_bytes: overall_base_bytes.saturating_add(downloaded_bytes), + overall_total_bytes, + message: None, + }, + ); + last_emit = Instant::now(); + } + } + + file.flush() + .await + .map_err(|error| format!("Could not finalize {}: {error}", spec.filename))?; + drop(file); + + if let Some(total) = total_bytes { + if downloaded_bytes != total { + let _ = tokio::fs::remove_file(&temp_path).await; + return Err(format!( + "Downloaded {} bytes for {}, expected {}.", + downloaded_bytes, spec.label, total + )); + } + } + + let _ = tokio::fs::remove_file(&spec.destination).await; + tokio::fs::rename(&temp_path, &spec.destination) + .await + .map_err(|error| { + format!( + "Could not move {} into {}: {error}", + temp_path.display(), + spec.destination.display() + ) + })?; + + Ok(downloaded_bytes) +} + +pub(crate) async fn persist_downloaded_model_selection( + paths: &DataPaths, + specs: &[ModelDownloadSpec], +) -> Cmd<()> { + let mut settings = load_settings(&paths.settings_path).await?; + if let Some(embedding) = specs.iter().find(|spec| spec.id == "mist") { + settings.local_model.embedding_model = Some(embedding.destination.display().to_string()); + } + if let Some(chat) = specs + .iter() + .rev() + .find(|spec| spec.id == "lite" || spec.id == "wise") + { + settings.local_model.chat_model = Some(chat.destination.display().to_string()); + } + save_json(&paths.settings_path, &settings).await +} + +pub(crate) fn huggingface_token() -> Option { + [ + HF_TOKEN_ENV, + HUGGINGFACE_HUB_TOKEN_ENV, + HUGGING_FACE_HUB_TOKEN_ENV, + ] + .iter() + .find_map(|var| env::var(var).ok()) + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) +} + +pub(crate) fn huggingface_download_error(spec: &ModelDownloadSpec, status: u16) -> String { + let auth_hint = if status == 401 || status == 403 { + format!( + " Accept the model terms on Hugging Face, then paste a Hugging Face read token in setup. Advanced users can also launch ÆTHER with {HF_TOKEN_ENV} or {HUGGINGFACE_HUB_TOKEN_ENV} set." + ) + } else { + String::new() + }; + format!( + "Could not download {} from official Hugging Face source {}/{} ({status}).{}", + spec.label, spec.repository, spec.filename, auth_hint + ) +} + +pub(crate) fn emit_model_download_progress( + app: &AppHandle, + spec: &ModelDownloadSpec, + stage: ModelDownloadStage<'_>, +) { + let ModelDownloadStage { + status, + downloaded_bytes, + total_bytes, + overall_downloaded_bytes, + overall_total_bytes, + message, + } = stage; + let _ = app.emit( + AETHER_MODEL_DOWNLOAD_PROGRESS_EVENT, + ModelDownloadProgress { + id: spec.id.to_string(), + label: spec.label.to_string(), + filename: spec.filename.to_string(), + status: status.to_string(), + downloaded_bytes, + total_bytes, + overall_downloaded_bytes, + overall_total_bytes, + message, + }, + ); +} diff --git a/src-tauri/src/retrieval.rs b/src-tauri/src/retrieval.rs new file mode 100644 index 0000000..8010ed9 --- /dev/null +++ b/src-tauri/src/retrieval.rs @@ -0,0 +1,457 @@ +//! Library and hub search, plus hub suggestion for a captured page. + +use super::*; + +pub(crate) async fn search_collection( + state: &State<'_, Backend>, + input: SearchCollectionInput, +) -> Cmd> { + let query = input.query.trim().to_string(); + if query.is_empty() { + return Ok(Vec::new()); + } + get_collection(&state.paths.library_path, &input.collection_id).await?; + let settings = load_settings(&state.paths.settings_path).await?; + let query_vector = local_embed_query(state, &settings, query).await?; + with_vectors_read(state, |vectors| { + let mut scored = vectors + .chunks + .iter() + .filter(|chunk| chunk.collection_id == input.collection_id) + .map(|chunk| (cosine_distance(&query_vector, &chunk.vector), chunk)) + .collect::>(); + scored.sort_by(|left, right| left.0.partial_cmp(&right.0).unwrap_or(Ordering::Equal)); + scored.truncate(input.limit.unwrap_or(8)); + scored + .into_iter() + .map(|(score, chunk)| SearchResult { + score, + id: chunk.id.clone(), + collection_id: chunk.collection_id.clone(), + capture_id: chunk.capture_id.clone(), + app_id: chunk.app_id.clone(), + title: chunk.title.clone(), + url: chunk.url.clone(), + captured_at: chunk.captured_at.clone(), + chunk_index: chunk.chunk_index, + text: chunk.text.clone(), + }) + .collect::>() + }) + .await +} + +// Library search groups by capture, not by chunk. The chunk-level results that +// power retrieval are the wrong shape for a person: eight hits from one long page +// reads as eight sources. One row per source, with its best-matching passage and a +// count of how many passages matched, is what someone scanning results wants. +pub(crate) async fn search_library( + state: &State<'_, Backend>, + input: SearchLibraryInput, +) -> Cmd { + let query = input.query.trim().to_string(); + if query.is_empty() { + return Ok(LibrarySearchResult { + query, + hits: Vec::new(), + mode: "semantic".to_string(), + searched_chunks: 0, + }); + } + + let library = load_library(&state.paths.library_path).await?; + let collection_names = library + .collections + .iter() + .map(|collection| (collection.id.clone(), collection.name.clone())) + .collect::>(); + if let Some(collection_id) = input.collection_id.as_deref() { + get_collection(&state.paths.library_path, collection_id).await?; + } + + let settings = load_settings(&state.paths.settings_path).await?; + let limit = input.limit.unwrap_or(20).clamp(1, 60); + + // Without an embedding model there is nothing to compare vectors against, so + // fall back to literal matching rather than failing. Search staying usable with + // no models installed is the difference between a browsable library and a + // library you can only guess at. + let query_vector = local_embed_query(state, &settings, query.clone()) + .await + .ok(); + let mode = if query_vector.is_some() { + "semantic" + } else { + "literal" + }; + let needle = query.to_lowercase(); + + let scope = input.collection_id.clone(); + let (hits, searched_chunks) = with_vectors_read(state, |vectors| { + rank_library_hits( + &vectors.chunks, + &collection_names, + scope.as_deref(), + query_vector.as_deref(), + &needle, + limit, + ) + }) + .await?; + + Ok(LibrarySearchResult { + query, + hits, + mode: mode.to_string(), + searched_chunks, + }) +} + +// Pure ranking core, split out from search_library so the retrieval contract can be +// tested without a Tauri app or a 640 MB embedding model. Everything model-independent +// about search quality — scoping, per-capture grouping, ordering, limits — lives here. +pub(crate) fn rank_library_hits( + chunks: &[ChunkRecord], + collection_names: &HashMap, + scope: Option<&str>, + query_vector: Option<&[f32]>, + needle: &str, + limit: usize, +) -> (Vec, usize) { + // map_or rather than is_none_or: the latter needs Rust 1.82 and this crate + // declares MSRV 1.77.2. + let scoped = chunks + .iter() + .filter(|chunk| scope.map_or(true, |id| chunk.collection_id == id)); + + let mut best: HashMap = HashMap::new(); + let mut examined = 0_usize; + + for chunk in scoped { + examined += 1; + let score = match query_vector { + Some(vector) => semantic_score_from_distance(cosine_distance(vector, &chunk.vector)), + None => literal_match_score(needle, chunk), + }; + if score <= 0.0 { + continue; + } + + match best.get_mut(&chunk.capture_id) { + Some(existing) => { + existing.chunk_matches += 1; + // One row per source shows its *best* passage, so a later weaker + // chunk must not overwrite a stronger earlier one. + if score > existing.score { + existing.score = score; + existing.excerpt = semantic_trail_excerpt(&chunk.text, 240); + } + } + None => { + best.insert( + chunk.capture_id.clone(), + LibrarySearchHit { + capture_id: chunk.capture_id.clone(), + collection_id: chunk.collection_id.clone(), + collection_name: collection_names + .get(&chunk.collection_id) + .cloned() + .unwrap_or_else(|| "Unknown hub".to_string()), + title: chunk.title.clone(), + url: chunk.url.clone(), + host: get_tab_host(&chunk.url), + captured_at: chunk.captured_at.clone(), + excerpt: semantic_trail_excerpt(&chunk.text, 240), + score, + chunk_matches: 1, + }, + ); + } + } + } + + let mut hits = best.into_values().collect::>(); + hits.sort_by(|left, right| { + right + .score + .partial_cmp(&left.score) + .unwrap_or(Ordering::Equal) + .then_with(|| right.captured_at.cmp(&left.captured_at)) + // Final tiebreak keeps output stable: HashMap iteration order is not. + .then_with(|| left.capture_id.cmp(&right.capture_id)) + }); + hits.truncate(limit); + (hits, examined) +} + +// Literal scoring for the no-embedding-model path. Title and host matches outrank +// body matches, because someone typing a remembered name wants that page first. +pub(crate) fn literal_match_score(needle: &str, chunk: &ChunkRecord) -> f64 { + if needle.is_empty() { + return 0.0; + } + let mut score = 0.0_f64; + if chunk.title.to_lowercase().contains(needle) { + score += 70.0; + } + if chunk.url.to_lowercase().contains(needle) { + score += 20.0; + } + if chunk.text.to_lowercase().contains(needle) { + score += 25.0; + } + score.min(100.0) +} + +#[tauri::command] +pub(crate) async fn aether_search_library( + state: State<'_, Backend>, + input: SearchLibraryInput, +) -> Cmd { + search_library(&state, input).await +} + +#[derive(Clone)] +pub(crate) struct SemanticTrailChunkCandidate { + pub(crate) chunk: ChunkRecord, + pub(crate) collection_name: String, + pub(crate) score: SemanticTrailScoreBreakdown, + pub(crate) reasons: Vec, +} + +#[derive(Clone)] +pub(crate) struct FlowSourceCandidate { + pub(crate) capture: CaptureSummary, + pub(crate) collection_name: String, + pub(crate) vector: Vec, + pub(crate) excerpt: String, +} + +pub(crate) struct FlowEdgeCandidate { + pub(crate) from: String, + pub(crate) to: String, + pub(crate) weight: f64, +} + +pub(crate) async fn semantic_trail_generate( + state: &State<'_, Backend>, + input: SemanticTrailInput, +) -> Cmd { + let limit = input + .limit + .unwrap_or(DEFAULT_SEMANTIC_TRAIL_LIMIT) + .clamp(1, MAX_SEMANTIC_TRAIL_LIMIT); + let explicit_query = input + .query + .as_deref() + .map(str::trim) + .filter(|query| !query.is_empty()); + let (root, visible_query, embedding_query, root_url_key) = if let Some(query) = explicit_query { + ( + SemanticTrailRoot { + title: query.to_string(), + url: String::new(), + host: String::new(), + excerpt: "Custom Focus lens matching captured sources across your knowledge hubs." + .to_string(), + }, + query.to_string(), + query.to_string(), + None, + ) + } else { + let active_tab = { + let tabs = lock_tabs(state)?; + if tabs.dashboard_open { + return Err("Open a web page before building Flow.".to_string()); + } + tabs.active_tab() + .cloned() + .ok_or_else(|| "No active browser tab.".to_string())? + }; + let captured = extract_readable_active_page(state, &active_tab).await?; + let root_host = get_tab_host(&captured.url); + let root_url_key = normalize_capture_url_key(&captured.url); + ( + SemanticTrailRoot { + title: captured.title.clone(), + url: captured.url.clone(), + host: root_host, + excerpt: semantic_trail_excerpt(&captured.text, 420), + }, + captured.title.clone(), + semantic_trail_default_query(&captured), + Some(root_url_key), + ) + }; + + let library = load_library(&state.paths.library_path).await?; + let collection_names = library + .collections + .iter() + .map(|collection| (collection.id.clone(), collection.name.clone())) + .collect::>(); + let root_collection_ids = root_url_key + .as_deref() + .map(|key| { + library + .captures + .iter() + .filter(|capture| normalize_capture_url_key(&capture.url) == key) + .map(|capture| capture.collection_id.clone()) + .collect::>() + }) + .unwrap_or_default(); + let chunks = with_vectors_read(state, |vectors| vectors.chunks.clone()).await?; + + if chunks.is_empty() { + return Ok(SemanticTrailResult { + query: visible_query, + generated_at: now(), + root, + items: Vec::new(), + edges: Vec::new(), + }); + } + + let settings = load_settings(&state.paths.settings_path).await?; + let query_vector = local_embed_query(state, &settings, embedding_query).await?; + + let mut candidates = chunks + .into_iter() + .filter_map(|chunk| { + let distance = cosine_distance(&query_vector, &chunk.vector); + if !distance.is_finite() { + return None; + } + let same_collection = root_collection_ids.contains(&chunk.collection_id); + let score = semantic_trail_score_breakdown(distance, &chunk.captured_at); + if score.semantic < SEMANTIC_TRAIL_MIN_SCORE { + return None; + } + let reasons = semantic_trail_reasons(&score, same_collection); + let collection_name = collection_names + .get(&chunk.collection_id) + .cloned() + .unwrap_or_else(|| "Knowledge Hub".to_string()); + Some(SemanticTrailChunkCandidate { + chunk, + collection_name, + score, + reasons, + }) + }) + .collect::>(); + + candidates.sort_by(|left, right| { + right + .score + .total + .partial_cmp(&left.score.total) + .unwrap_or(Ordering::Equal) + .then_with(|| { + right + .score + .semantic + .partial_cmp(&left.score.semantic) + .unwrap_or(Ordering::Equal) + }) + }); + + let items = semantic_trail_items(candidates, limit); + let edges = semantic_trail_edges(&root, &items); + + Ok(SemanticTrailResult { + query: visible_query, + generated_at: now(), + root, + items, + edges, + }) +} + +// Rank the user's hubs against the active page so capture can silently pre-select the best +// home for it. Best-effort: any reason we cannot produce a confident match returns Ok(None) +// rather than an error, so a failed suggestion never blocks or interrupts capturing. +pub(crate) async fn suggest_capture_hub( + state: &State<'_, Backend>, +) -> Cmd> { + let active_tab = { + let tabs = lock_tabs(state)?; + if tabs.dashboard_open { + return Ok(None); + } + match tabs.active_tab().cloned() { + Some(tab) => tab, + None => return Ok(None), + } + }; + + let captured = match extract_readable_active_page(state, &active_tab).await { + Ok(page) => page, + Err(_) => return Ok(None), + }; + + let library = load_library(&state.paths.library_path).await?; + if library.collections.is_empty() { + return Ok(None); + } + let chunks = with_vectors_read(state, |vectors| vectors.chunks.clone()).await?; + if chunks.is_empty() { + return Ok(None); + } + + let settings = load_settings(&state.paths.settings_path).await?; + let embedding_query = semantic_trail_default_query(&captured); + let query_vector = match local_embed_query(state, &settings, embedding_query).await { + Ok(vector) => vector, + Err(_) => return Ok(None), + }; + + // A hub is a strong home for this page if it already holds a source whose meaning is + // close to it, so score each hub by its single closest chunk. + let mut best_by_collection: HashMap = HashMap::new(); + for chunk in &chunks { + let distance = cosine_distance(&query_vector, &chunk.vector); + if !distance.is_finite() { + continue; + } + let semantic = semantic_score_from_distance(distance); + let entry = best_by_collection + .entry(chunk.collection_id.clone()) + .or_insert((0.0, String::new())); + if semantic > entry.0 { + entry.0 = semantic; + entry.1 = chunk.title.clone(); + } + } + + let Some((collection_id, (confidence, sample_title))) = + best_by_collection.into_iter().max_by(|left, right| { + left.1 + .0 + .partial_cmp(&right.1 .0) + .unwrap_or(Ordering::Equal) + }) + else { + return Ok(None); + }; + + if confidence < CAPTURE_SUGGEST_MIN_SCORE { + return Ok(None); + } + + let collection_name = library + .collections + .iter() + .find(|collection| collection.id == collection_id) + .map(|collection| collection.name.clone()) + .unwrap_or_else(|| "Knowledge Hub".to_string()); + + Ok(Some(CaptureHubSuggestion { + collection_id, + collection_name, + confidence: round_score(confidence), + sample_title, + })) +} diff --git a/src-tauri/src/retrieval_scoring.rs b/src-tauri/src/retrieval_scoring.rs new file mode 100644 index 0000000..e14ab70 --- /dev/null +++ b/src-tauri/src/retrieval_scoring.rs @@ -0,0 +1,193 @@ +//! Ranking a query against captured chunks: the current page as a pseudo-source, +//! semantic ranking, and the lexical fallback used with no embedding model. + +use super::*; + +pub(crate) fn capture_chunk_settings( + _paths: &DataPaths, + _settings: &UserSettings, +) -> (usize, usize) { + (DEFAULT_CAPTURE_CHUNK_SIZE, DEFAULT_CAPTURE_CHUNK_OVERLAP) +} + +pub(crate) fn current_page_search_result( + captured: &CapturedPage, + collection_id: Option<&str>, + chunk_index: usize, + score: f64, + text: String, +) -> SearchResult { + SearchResult { + id: format!("current-{}", uuid()), + collection_id: collection_id + .map(ToString::to_string) + .unwrap_or_else(|| "current-page".to_string()), + capture_id: "current-page".to_string(), + app_id: "browser".to_string(), + title: captured.title.clone(), + url: captured.url.clone(), + captured_at: now(), + chunk_index, + text, + score, + } +} + +// The current page isn't pre-indexed like a knowledge hub, so rank its chunks at +// ask-time. We mirror the hub's semantic retrieval (embed the query + chunks, rank +// by cosine distance, keep the best matches) and fall back to lexical scoring only +// when no embedding model is available. Returning several chunks instead of the +// single best is what lets the model actually answer: dedupe_citations later merges +// these same-URL chunks into one context-dense citation, just like the hub. +pub(crate) async fn current_page_citations( + state: &State<'_, Backend>, + settings: &UserSettings, + captured: CapturedPage, + prompt: &str, + collection_id: Option<&str>, + limit: usize, +) -> Vec { + if limit == 0 { + return Vec::new(); + } + let (chunk_size, chunk_overlap) = capture_chunk_settings(&state.paths, settings); + let chunks = split_text(&captured.text, chunk_size, chunk_overlap); + if chunks.is_empty() { + return Vec::new(); + } + + if let Some(ranked) = semantic_rank_chunks(state, settings, &chunks, prompt, limit).await { + return ranked + .into_iter() + .map(|(index, distance)| { + current_page_search_result( + &captured, + collection_id, + index, + distance, + chunks[index].clone(), + ) + }) + .collect(); + } + + // Lexical fallback (no embedding model): keep the highest-scoring chunks. + let mut scored = chunks + .iter() + .enumerate() + .map(|(index, text)| (lexical_relevance_score(text, prompt), index)) + .filter(|(score, _)| *score > 0.0) + .collect::>(); + scored.sort_by(|left, right| right.0.partial_cmp(&left.0).unwrap_or(Ordering::Equal)); + + if scored.is_empty() { + // Nothing matched lexically — anchor on the start of the page rather than + // returning nothing. + let first = chunks.into_iter().next().unwrap_or_default(); + return vec![current_page_search_result( + &captured, + collection_id, + 0, + 0.0, + first, + )]; + } + + scored + .into_iter() + .take(limit) + .map(|(score, index)| { + current_page_search_result( + &captured, + collection_id, + index, + score, + chunks[index].clone(), + ) + }) + .collect() +} + +// Embed the query alongside the page chunks in one batch, then order chunk indices +// by ascending cosine distance. Returns None when embedding is unavailable so the +// caller can fall back to lexical scoring. +pub(crate) async fn semantic_rank_chunks( + state: &State<'_, Backend>, + settings: &UserSettings, + chunks: &[String], + prompt: &str, + limit: usize, +) -> Option> { + let mut inputs = Vec::with_capacity(chunks.len() + 1); + inputs.push(embedding_query_input(&state.paths, settings, prompt)); + inputs.extend(chunks.iter().cloned()); + + let embeddings = local_embed(state, settings, inputs).await.ok()?; + if embeddings.len() != chunks.len() + 1 { + return None; + } + + let query_vector = &embeddings[0]; + let mut scored = embeddings[1..] + .iter() + .enumerate() + .map(|(index, vector)| (cosine_distance(query_vector, vector), index)) + .collect::>(); + scored.sort_by(|left, right| left.0.partial_cmp(&right.0).unwrap_or(Ordering::Equal)); + Some( + scored + .into_iter() + .take(limit) + .map(|(distance, index)| (index, distance)) + .collect(), + ) +} + +pub(crate) fn lexical_relevance_score(text: &str, query: &str) -> f64 { + let terms = query_terms(query); + if terms.is_empty() { + return 0.0; + } + let haystack = text.to_lowercase(); + terms + .iter() + .map(|term| lexical_term_score(&haystack, term)) + .sum() +} + +pub(crate) fn lexical_term_score(haystack: &str, term: &str) -> f64 { + let occurrences = haystack.matches(term).count(); + if occurrences > 0 { + // Reward repeated mentions so a chunk that actually discusses the term beats + // one that merely name-drops it once. Without this, every matching chunk ties + // and the tie-break is arbitrary. + return 2.0 + (term.len() as f64 / 10.0) + (occurrences.saturating_sub(1) as f64) * 0.5; + } + let stem_len = term.len().min(6); + if stem_len >= 5 && haystack.contains(&term[..stem_len]) { + return 1.25 + (stem_len as f64 / 12.0); + } + if let Some(singular) = term.strip_suffix('s') { + if singular.len() >= 5 && haystack.contains(singular) { + return 1.5 + (singular.len() as f64 / 12.0); + } + } + 0.0 +} + +pub(crate) fn query_terms(query: &str) -> Vec { + let stopwords = [ + "a", "an", "and", "are", "as", "at", "be", "by", "can", "do", "does", "for", "from", "how", + "i", "in", "is", "it", "me", "most", "of", "on", "or", "should", "the", "this", "to", + "was", "were", "what", "when", "where", "which", "who", "why", "with", + ]; + let stopwords = stopwords.into_iter().collect::>(); + let mut seen = HashSet::new(); + query + .split(|character: char| !character.is_alphanumeric()) + .map(str::trim) + .map(str::to_lowercase) + .filter(|term| term.len() > 2 && !stopwords.contains(term.as_str())) + .filter(|term| seen.insert(term.clone())) + .collect() +} diff --git a/src-tauri/src/store.rs b/src-tauri/src/store.rs new file mode 100644 index 0000000..23a650f --- /dev/null +++ b/src-tauri/src/store.rs @@ -0,0 +1,131 @@ +//! Durable store IO. Every write is temp-then-rename with a one-generation `.bak` +//! beside it, and an unreadable store is quarantined rather than overwritten — a +//! corrupt file must never cost the user their library. + +use super::*; + +pub(crate) fn backup_path(path: &Path) -> PathBuf { + let mut name = path.file_name().unwrap_or_default().to_os_string(); + name.push(BACKUP_SUFFIX); + path.with_file_name(name) +} + +pub(crate) fn temp_write_path(path: &Path) -> PathBuf { + let mut name = path.file_name().unwrap_or_default().to_os_string(); + name.push(TEMP_WRITE_SUFFIX); + path.with_file_name(name) +} + +pub(crate) async fn ensure_parent_dir(path: &Path) -> Cmd<()> { + if let Some(parent) = path.parent() { + tokio::fs::create_dir_all(parent) + .await + .map_err(|error| error.to_string())?; + } + Ok(()) +} + +// Writes bytes to a sibling temp file, fsyncs it, then renames it over the target. +// `rotate` additionally renames the previous good file to `.bak` first, so a +// crash can never leave the store both truncated and without a recoverable copy. +// +// Both renames are atomic within a directory, so the target is only ever the old +// complete file or the new complete file. The gap where the target is momentarily +// absent is covered by the backup, which `read_json_or_default` falls back to. +pub(crate) async fn write_bytes_atomically(path: &Path, bytes: &[u8], rotate: bool) -> Cmd<()> { + ensure_parent_dir(path).await?; + let temp = temp_write_path(path); + + // Scope the handle so it is flushed and closed before the rename. + { + let mut file = tokio::fs::File::create(&temp) + .await + .map_err(|error| error.to_string())?; + file.write_all(bytes) + .await + .map_err(|error| error.to_string())?; + // Without this the rename can land before the data does, which is exactly + // the truncated-store case this function exists to prevent. + file.sync_all().await.map_err(|error| error.to_string())?; + } + + if rotate && tokio::fs::try_exists(path).await.unwrap_or(false) { + let backup = backup_path(path); + if let Err(error) = tokio::fs::rename(path, &backup).await { + // A missing backup is recoverable; failing the whole save is not. + diag_warn!("could not rotate backup for {}: {error}", path.display()); + } + } + + tokio::fs::rename(&temp, path) + .await + .map_err(|error| error.to_string()) +} + +pub(crate) async fn write_store_durably(path: &Path, bytes: &[u8]) -> Cmd<()> { + write_bytes_atomically(path, bytes, true).await +} + +// Quarantines an unparseable store instead of overwriting it. Losing a store to a +// bug is bad; silently replacing it with a default and destroying the evidence is +// worse, so the bad bytes are kept for manual recovery. +pub(crate) async fn quarantine_unreadable_store(path: &Path) { + if !tokio::fs::try_exists(path).await.unwrap_or(false) { + return; + } + let mut name = path.file_name().unwrap_or_default().to_os_string(); + name.push(format!(".corrupt-{}", now().replace(':', "-"))); + let target = path.with_file_name(name); + match tokio::fs::rename(path, &target).await { + Ok(()) => diag_error!("kept unreadable store at {} for recovery", target.display()), + Err(error) => diag_error!("could not quarantine {}: {error}", path.display()), + } +} + +// Load order: primary store, then `.bak`, then a fresh default. A parse failure on +// the primary is treated as corruption rather than as an error, so one bad file +// cannot make the app permanently unopenable. +pub(crate) async fn read_json_or_default(path: &Path) -> Cmd +where + T: DeserializeOwned + Default + Serialize, +{ + if let Ok(raw) = tokio::fs::read_to_string(path).await { + match serde_json::from_str::(&raw) { + Ok(value) => return Ok(value), + Err(error) => diag_warn!("{} is unreadable ({error}); trying backup", path.display()), + } + } + + let backup = backup_path(path); + if let Ok(raw) = tokio::fs::read_to_string(&backup).await { + if let Ok(value) = serde_json::from_str::(&raw) { + diag_info!("recovered {} from backup", path.display()); + quarantine_unreadable_store(path).await; + // Restore without rotating: the backup we just read is the only good + // copy left, and rotating here would overwrite it with the bad file. + write_bytes_atomically(path, raw.as_bytes(), false).await?; + return Ok(value); + } + diag_error!("backup {} is also unreadable", backup.display()); + } + + quarantine_unreadable_store(path).await; + let data = T::default(); + let raw = serde_json::to_string_pretty(&data).map_err(|error| error.to_string())?; + write_bytes_atomically(path, format!("{raw}\n").as_bytes(), false).await?; + Ok(data) +} + +pub(crate) async fn save_json(path: &Path, data: &T) -> Cmd<()> { + let raw = serde_json::to_string_pretty(data).map_err(|error| error.to_string())?; + write_store_durably(path, format!("{raw}\n").as_bytes()).await +} + +pub(crate) async fn get_collection(path: &Path, collection_id: &str) -> Cmd { + load_library(path) + .await? + .collections + .into_iter() + .find(|collection| collection.id == collection_id) + .ok_or_else(|| "Collection not found.".to_string()) +} diff --git a/src-tauri/src/system.rs b/src-tauri/src/system.rs new file mode 100644 index 0000000..a454db6 --- /dev/null +++ b/src-tauri/src/system.rs @@ -0,0 +1,391 @@ +//! System status, settings, session restore, downloads, window geometry, and +//! conversation threads — the state that has to survive a quit. + +use super::*; + +pub(crate) async fn system_status(state: &State<'_, Backend>) -> Cmd { + let settings = load_settings(&state.paths.settings_path).await?; + let library = load_library(&state.paths.library_path).await?; + let catalog = model_catalog(&state.paths, &settings.local_model); + Ok(SystemStatus { + runtime_ready: catalog.chat_model.is_some() || catalog.embedding_model.is_some(), + runtime_name: LOCAL_RUNTIME_NAME.to_string(), + embedding_model: catalog + .embedding_model + .as_ref() + .map(|path| path_to_model_value(path)), + chat_model: catalog + .chat_model + .as_ref() + .map(|path| path_to_model_value(path)), + available_models: catalog + .models + .iter() + .map(|path| path_to_model_value(path)) + .collect(), + chat_models: catalog + .models + .iter() + .filter(|path| is_chat_model(path)) + .map(|path| path_to_model_value(path)) + .collect(), + embedding_models: catalog + .models + .iter() + .filter(|path| is_embedding_model(path)) + .map(|path| path_to_model_value(path)) + .collect(), + model_dir: state.paths.models_path.display().to_string(), + db_path: state.paths.db_path.display().to_string(), + library_path: state.paths.library_path.display().to_string(), + collections: library.collections, + error: catalog.error, + }) +} + +pub(crate) async fn load_library(path: &Path) -> Cmd { + read_json_or_default(path).await +} + +pub(crate) async fn load_settings(path: &Path) -> Cmd { + read_json_or_default(path).await +} + +pub(crate) async fn persist_update_last_checked_at(paths: &DataPaths, checked_at: &str) -> Cmd<()> { + let mut settings = load_settings(&paths.settings_path).await?; + settings.updates.last_checked_at = Some(checked_at.to_string()); + save_json(&paths.settings_path, &settings).await +} + +pub(crate) fn release_version_from_tag(tag: &str) -> String { + tag.trim() + .trim_start_matches('v') + .trim_start_matches('V') + .to_string() +} + +pub(crate) fn version_is_newer(candidate: &str, current: &str) -> bool { + let candidate_parts = version_parts(candidate); + let current_parts = version_parts(current); + let max_len = candidate_parts.len().max(current_parts.len()).max(3); + for index in 0..max_len { + let candidate_value = candidate_parts.get(index).copied().unwrap_or_default(); + let current_value = current_parts.get(index).copied().unwrap_or_default(); + if candidate_value > current_value { + return true; + } + if candidate_value < current_value { + return false; + } + } + false +} + +pub(crate) fn version_parts(version: &str) -> Vec { + version + .trim() + .trim_start_matches('v') + .trim_start_matches('V') + .split(|character: char| !character.is_ascii_digit()) + .filter(|part| !part.is_empty()) + .filter_map(|part| part.parse::().ok()) + .collect() +} + +pub(crate) async fn load_icebergs(path: &Path) -> Cmd { + read_json_or_default(path).await +} + +pub(crate) async fn load_conversations(path: &Path) -> Cmd { + read_json_or_default(path).await +} + +pub(crate) async fn load_session(path: &Path) -> Cmd { + read_json_or_default(path).await +} + +// Snapshots the open tabs. Called after any tab mutation rather than at exit, because +// force_exit() hard-kills the process on quit and never gives a shutdown hook a turn. +pub(crate) async fn persist_session_tabs(state: &State<'_, Backend>) -> Cmd<()> { + let (tabs, active_tab_id) = { + let guard = lock_tabs(state)?; + let tabs = guard + .tabs + .iter() + // A tab parked on the internal start page has nothing to reopen. + .filter(|tab| tab.url != START_PAGE_URL && !tab.url.starts_with("aether://")) + .map(|tab| SessionTab { + id: tab.id.clone(), + url: tab.url.clone(), + title: tab.title.clone(), + }) + .collect::>(); + (tabs, guard.active_tab_id.clone()) + }; + + let mut session = load_session(&state.paths.session_path).await?; + session.tabs = tabs; + session.active_tab_id = active_tab_id; + save_json(&state.paths.session_path, &session).await +} + +// Fire-and-forget wrapper for the sync command paths. A failed session write must +// never make a tab action fail. +pub(crate) fn schedule_session_save(app: &AppHandle) { + let app = app.clone(); + tauri::async_runtime::spawn(async move { + let state = app.state::(); + if let Err(error) = persist_session_tabs(&state).await { + diag_warn!("could not save session: {error}"); + } + }); +} + +pub(crate) async fn persist_session_window(paths: &DataPaths, window: SessionWindow) -> Cmd<()> { + let mut session = load_session(&paths.session_path).await?; + session.window = Some(window); + save_json(&paths.session_path, &session).await +} + +#[cfg(desktop)] +pub(crate) fn file_name_of(path: &Path) -> String { + path.file_name() + .map(|name| name.to_string_lossy().to_string()) + .unwrap_or_else(|| "download".to_string()) +} + +// Derives a safe filename from a URL. Path separators and control characters are +// stripped so a crafted URL cannot write outside the downloads directory. +#[cfg(desktop)] +pub(crate) fn file_name_from_url(url: &Url) -> String { + let raw = url + .path_segments() + .and_then(|mut segments| segments.rfind(|segment| !segment.is_empty())) + .unwrap_or("download"); + let decoded = percent_decode_download_name(raw); + let cleaned = decoded + .chars() + .filter(|character| { + !character.is_control() && !matches!(character, '/' | '\\' | ':' | '\0') + }) + .collect::(); + let trimmed = cleaned.trim().trim_start_matches('.').to_string(); + if trimmed.is_empty() { + "download".to_string() + } else { + trimmed.chars().take(180).collect() + } +} + +#[cfg(desktop)] +pub(crate) fn percent_decode_download_name(raw: &str) -> String { + let bytes = raw.as_bytes(); + let mut out = Vec::with_capacity(bytes.len()); + let mut index = 0; + while index < bytes.len() { + if bytes[index] == b'%' && index + 2 < bytes.len() { + if let Ok(byte) = u8::from_str_radix(&raw[index + 1..index + 3], 16) { + out.push(byte); + index += 3; + continue; + } + } + out.push(bytes[index]); + index += 1; + } + String::from_utf8_lossy(&out).to_string() +} + +// Never overwrite an existing file: a second download of the same name becomes +// "name (2).ext" the way a browser does. +#[cfg(desktop)] +pub(crate) fn resolve_download_destination(app: &AppHandle, url: &Url) -> Option { + let dir = app.path().download_dir().ok().or_else(|| { + app.path() + .home_dir() + .ok() + .map(|home| home.join("Downloads")) + })?; + if fs::create_dir_all(&dir).is_err() { + return None; + } + + let name = file_name_from_url(url); + let candidate = dir.join(&name); + if !candidate.exists() { + return Some(candidate); + } + + let path = Path::new(&name); + let stem = path + .file_stem() + .map(|stem| stem.to_string_lossy().to_string()) + .unwrap_or_else(|| "download".to_string()); + let extension = path + .extension() + .map(|extension| format!(".{}", extension.to_string_lossy())) + .unwrap_or_default(); + for index in 2..1000 { + let candidate = dir.join(format!("{stem} ({index}){extension}")); + if !candidate.exists() { + return Some(candidate); + } + } + None +} + +#[cfg(desktop)] +pub(crate) fn emit_download_event( + app: &AppHandle, + status: &str, + filename: &str, + path: Option<&Path>, + url: &str, +) { + let _ = app.emit( + AETHER_DOWNLOAD_EVENT, + DownloadProgress { + status: status.to_string(), + filename: filename.to_string(), + path: path.map(|path| path.display().to_string()), + url: url.to_string(), + }, + ); +} + +#[cfg(desktop)] +pub(crate) fn restore_session_tabs(state: &State, session: &SessionData) { + if session.tabs.is_empty() { + return; + } + let Ok(mut tabs) = state.tabs.lock() else { + return; + }; + let restored = session + .tabs + .iter() + .map(|tab| { + let mut managed = ManagedTab::new("browser", &tab.url); + // Keep the stored id so the saved active-tab id still resolves. + managed.id = tab.id.clone(); + if !tab.title.is_empty() { + managed.title = tab.title.clone(); + } + managed + }) + .collect::>(); + + let active = if restored.iter().any(|tab| tab.id == session.active_tab_id) { + session.active_tab_id.clone() + } else { + restored[0].id.clone() + }; + + tabs.active_tab_id = active; + tabs.tabs = restored; + // The dashboard is still the landing surface; restored tabs wait behind it. + tabs.dashboard_open = true; +} + +#[cfg(desktop)] +pub(crate) fn current_window_geometry(window: &Window) -> Option { + let scale = window.scale_factor().ok()?; + let size = window.inner_size().ok()?.to_logical::(scale); + let position = window.outer_position().ok()?.to_logical::(scale); + Some(SessionWindow { + width: size.width, + height: size.height, + x: position.x, + y: position.y, + }) +} + +#[cfg(desktop)] +pub(crate) fn apply_session_window(window: &Window, geometry: SessionWindow) { + // Guard against a stored geometry that would open the window off-screen or too + // small to use (a display was unplugged, or the config was hand-edited). + if geometry.width < 480.0 || geometry.height < 360.0 { + return; + } + let _ = window.set_size(Size::Logical(LogicalSize::new( + geometry.width, + geometry.height, + ))); + if geometry.x > -20_000.0 && geometry.y > -20_000.0 { + let _ = window.set_position(Position::Logical(LogicalPosition::new( + geometry.x, geometry.y, + ))); + } +} + +#[cfg(desktop)] +pub(crate) fn save_window_geometry_now(app: &AppHandle) { + let Some(window) = app.get_window("main") else { + return; + }; + let Some(geometry) = current_window_geometry(&window) else { + return; + }; + let app = app.clone(); + tauri::async_runtime::spawn(async move { + let state = app.state::(); + if let Err(error) = persist_session_window(&state.paths, geometry).await { + diag_warn!("could not save window geometry: {error}"); + } + }); +} + +// Resize and move fire continuously while dragging, so throttle the writes. The +// CloseRequested handler catches whatever the throttle skipped. +#[cfg(desktop)] +pub(crate) fn schedule_window_geometry_save(app: &AppHandle) { + let state = app.state::(); + { + let Ok(mut last) = state.window_geometry_saved_at.lock() else { + return; + }; + if let Some(previous) = *last { + if previous.elapsed() < Duration::from_millis(500) { + return; + } + } + *last = Some(Instant::now()); + } + save_window_geometry_now(app); +} + +pub(crate) fn conversation_thread_key(collection_id: Option<&str>) -> String { + collection_id + .filter(|id| !id.is_empty()) + .unwrap_or(CURRENT_PAGE_THREAD_KEY) + .to_string() +} + +pub(crate) async fn conversation_thread( + paths: &DataPaths, + collection_id: Option<&str>, +) -> Vec { + let key = conversation_thread_key(collection_id); + load_conversations(&paths.conversations_path) + .await + .unwrap_or_default() + .threads + .remove(&key) + .unwrap_or_default() +} + +pub(crate) async fn append_conversation_turn( + paths: &DataPaths, + collection_id: Option<&str>, + turn: ConversationTurn, +) -> Cmd<()> { + let key = conversation_thread_key(collection_id); + let mut data = load_conversations(&paths.conversations_path).await?; + let thread = data.threads.entry(key).or_default(); + thread.push(turn); + if thread.len() > MAX_THREAD_TURNS { + let overflow = thread.len() - MAX_THREAD_TURNS; + thread.drain(0..overflow); + } + save_json(&paths.conversations_path, &data).await +} diff --git a/src-tauri/src/trail.rs b/src-tauri/src/trail.rs new file mode 100644 index 0000000..aac25f5 --- /dev/null +++ b/src-tauri/src/trail.rs @@ -0,0 +1,217 @@ +//! Semantic trails: the ranked, reasoned list of captures related to a query. + +use super::*; + +pub(crate) fn semantic_trail_default_query(captured: &CapturedPage) -> String { + normalize_captured_text(&format!( + "{}\n\n{}", + captured.title, + semantic_trail_excerpt(&captured.text, 1600) + )) +} + +pub(crate) fn semantic_trail_items( + candidates: Vec, + limit: usize, +) -> Vec { + let mut items = Vec::::new(); + let mut indexes = HashMap::::new(); + + for candidate in candidates { + let key = normalize_capture_url_key(&candidate.chunk.url); + if let Some(index) = indexes.get(&key).copied() { + let item = &mut items[index]; + item.excerpt = merge_semantic_trail_excerpts(&item.excerpt, &candidate.chunk.text); + for reason in candidate.reasons { + add_semantic_trail_reason(&mut item.reasons, reason); + } + continue; + } + + if items.len() >= limit { + continue; + } + + indexes.insert(key, items.len()); + items.push(SemanticTrailItem { + id: candidate.chunk.capture_id.clone(), + collection_id: candidate.chunk.collection_id.clone(), + collection_name: candidate.collection_name, + capture_id: candidate.chunk.capture_id, + app_id: candidate.chunk.app_id, + title: candidate.chunk.title, + url: candidate.chunk.url.clone(), + host: get_tab_host(&candidate.chunk.url), + captured_at: candidate.chunk.captured_at, + chunk_index: candidate.chunk.chunk_index, + excerpt: semantic_trail_excerpt(&candidate.chunk.text, 520), + score: candidate.score, + reasons: candidate.reasons, + }); + } + + items +} + +pub(crate) fn semantic_trail_score_breakdown( + distance: f64, + captured_at: &str, +) -> SemanticTrailScoreBreakdown { + // Relatedness is about meaning across the whole library, regardless of where a source + // came from. Semantic similarity dominates; recency is only a gentle tiebreaker. + let semantic = semantic_score_from_distance(distance); + let recency = semantic_trail_recency_score(captured_at); + let total = round_score((semantic * 0.92) + (recency * 0.08)); + + SemanticTrailScoreBreakdown { + total, + semantic, + recency, + } +} + +pub(crate) fn semantic_score_from_distance(distance: f64) -> f64 { + if !distance.is_finite() { + return 0.0; + } + round_score((1.0 - (distance / 1.2)).clamp(0.0, 1.0) * 100.0) +} + +pub(crate) fn semantic_trail_recency_score(captured_at: &str) -> f64 { + let Ok(parsed) = DateTime::parse_from_rfc3339(captured_at) else { + return 0.0; + }; + let days = Utc::now() + .signed_duration_since(parsed.with_timezone(&Utc)) + .num_days() + .max(0); + match days { + 0..=7 => 100.0, + 8..=30 => 80.0, + 31..=90 => 60.0, + 91..=180 => 40.0, + 181..=365 => 25.0, + _ => 10.0, + } +} + +pub(crate) fn semantic_trail_reasons( + score: &SemanticTrailScoreBreakdown, + same_collection: bool, +) -> Vec { + let mut reasons = Vec::new(); + if score.semantic >= 55.0 { + reasons.push(SemanticTrailReason::SemanticMatch); + } + if score.recency >= 80.0 { + reasons.push(SemanticTrailReason::RecentCapture); + } + if same_collection { + reasons.push(SemanticTrailReason::SameCollection); + } + if reasons.is_empty() { + reasons.push(SemanticTrailReason::SemanticMatch); + } + reasons +} + +pub(crate) fn semantic_trail_edges( + root: &SemanticTrailRoot, + items: &[SemanticTrailItem], +) -> Vec { + let mut edges = Vec::new(); + for item in items.iter().take(12) { + push_semantic_trail_edge( + &mut edges, + "root", + &item.id, + SemanticTrailEdgeKind::SemanticMatch, + item.score.total, + ); + if !root.host.is_empty() && root.host == item.host { + push_semantic_trail_edge( + &mut edges, + "root", + &item.id, + SemanticTrailEdgeKind::SameHost, + item.score.total, + ); + } + } + + for left_index in 0..items.len().min(8) { + for right_index in (left_index + 1)..items.len().min(8) { + let left = &items[left_index]; + let right = &items[right_index]; + let weight = left.score.total.min(right.score.total); + if left.collection_id == right.collection_id { + push_semantic_trail_edge( + &mut edges, + &left.id, + &right.id, + SemanticTrailEdgeKind::SameCollection, + weight, + ); + } else if !left.host.is_empty() && left.host == right.host { + push_semantic_trail_edge( + &mut edges, + &left.id, + &right.id, + SemanticTrailEdgeKind::SameHost, + weight, + ); + } + } + } + + edges +} + +pub(crate) fn push_semantic_trail_edge( + edges: &mut Vec, + from: &str, + to: &str, + kind: SemanticTrailEdgeKind, + weight: f64, +) { + if edges.len() >= 36 || from == to { + return; + } + edges.push(SemanticTrailEdge { + from: from.to_string(), + to: to.to_string(), + kind, + weight: round_score(weight), + }); +} + +pub(crate) fn add_semantic_trail_reason( + reasons: &mut Vec, + reason: SemanticTrailReason, +) { + if !reasons.contains(&reason) { + reasons.push(reason); + } +} + +pub(crate) fn merge_semantic_trail_excerpts(existing: &str, next: &str) -> String { + let next = semantic_trail_excerpt(next, 360); + if next.is_empty() || existing.contains(&next) { + return existing.to_string(); + } + semantic_trail_excerpt(&format!("{existing}\n\n[…]\n\n{next}"), 920) +} + +pub(crate) fn semantic_trail_excerpt(text: &str, limit: usize) -> String { + let normalized = normalize_captured_text(text); + if normalized.chars().count() <= limit { + return normalized; + } + let mut excerpt = normalized.chars().take(limit).collect::(); + excerpt.push('…'); + excerpt +} + +pub(crate) fn round_score(value: f64) -> f64 { + (value * 10.0).round() / 10.0 +} diff --git a/src-tauri/src/types.rs b/src-tauri/src/types.rs new file mode 100644 index 0000000..db868cd --- /dev/null +++ b/src-tauri/src/types.rs @@ -0,0 +1,1383 @@ +//! Serde types crossing the IPC boundary, plus the on-disk store shapes. +//! +//! Extracted verbatim from lib.rs. Field names and `serde` attributes here are the +//! contract with src/shared/aether.ts — renaming one silently breaks the renderer. + +use super::*; + +pub(crate) struct Backend { + pub(crate) paths: DataPaths, + pub(crate) tabs: Mutex, + #[cfg(desktop)] + pub(crate) webviews: Mutex, + // Where the renderer wants live web content placed, in CSS pixels, reported via + // aether_layout_set_web_content_bounds. Both shells use it; on desktop it takes + // precedence over the SIDEBAR_WIDTH/BROWSER_VIEW_TOP/PANEL_WIDTH constants. + pub(crate) web_content_bounds: Mutex, + pub(crate) client: Client, + pub(crate) native_runtime: Arc>, + pub(crate) vectors: tokio::sync::RwLock>, + pub(crate) generation_cancelled: Arc, + // Throttle for window geometry writes; resize/move fire continuously. + #[cfg(desktop)] + pub(crate) window_geometry_saved_at: Mutex>, + // Destination chosen at request time, keyed by URL. macOS omits the path in the + // Finished event, so without this the completion toast has nothing to reveal. + #[cfg(desktop)] + pub(crate) pending_downloads: Mutex>, +} + +#[cfg(desktop)] +#[derive(Default)] +pub(crate) struct NativeBrowserViews { + pub(crate) views: HashMap, +} + +// Where live web content belongs inside the window, in CSS px, as measured by the +// renderer. Both shells report the same rect: Android positions native WebViews with +// it, desktop positions its child webviews with it. Measuring beats hardcoding, +// because the chrome that defines these edges is owned by CSS. +#[derive(Clone, Copy, Default, PartialEq)] +pub(crate) struct WebContentBounds { + pub(crate) top: f64, + pub(crate) left: f64, + pub(crate) width: f64, + pub(crate) height: f64, +} + +#[derive(Default)] +pub(crate) struct NativeModelRuntime { + pub(crate) backend: Option, + pub(crate) chat: Option, + pub(crate) embedding: Option, +} + +pub(crate) struct LoadedNativeModel { + pub(crate) path: PathBuf, + pub(crate) model: LlamaModel, +} + +#[derive(Clone)] +pub(crate) struct EmbeddingProgress { + pub(crate) app: AppHandle, + pub(crate) message: String, +} + +impl EmbeddingProgress { + pub(crate) fn emit(&self, current: usize, total: usize) { + emit_capture_progress(&self.app, &self.message, Some(current), Some(total)); + } + + pub(crate) fn emit_message(&self, message: impl Into, current: usize, total: usize) { + emit_capture_progress(&self.app, message, Some(current), Some(total)); + } +} + +pub(crate) struct ChatPromptMessage { + pub(crate) role: &'static str, + pub(crate) content: String, +} + +pub(crate) struct RenderedChatPrompt { + pub(crate) prompt: String, + pub(crate) add_bos: AddBos, +} + +#[derive(Clone, Copy)] +pub(crate) enum NativeModelKind { + Chat, + Embedding, +} + +pub(crate) enum WebviewHistoryDirection { + Back, + Forward, +} + +pub(crate) struct ModelCatalog { + pub(crate) models: Vec, + pub(crate) chat_model: Option, + pub(crate) embedding_model: Option, + pub(crate) error: Option, +} + +#[derive(Clone)] +pub(crate) struct DataPaths { + pub(crate) db_path: PathBuf, + pub(crate) library_path: PathBuf, + pub(crate) settings_path: PathBuf, + pub(crate) icebergs_path: PathBuf, + pub(crate) conversations_path: PathBuf, + pub(crate) session_path: PathBuf, + pub(crate) air_exports_path: PathBuf, + pub(crate) chunks_path: PathBuf, + pub(crate) models_path: PathBuf, + // User-owned library snapshots (see aether_system_export_library). + pub(crate) exports_path: PathBuf, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct DiagnosticsExportResult { + pub(crate) path: String, + pub(crate) filename: String, + pub(crate) byte_size: u64, + pub(crate) exported_at: String, +} + +#[derive(Clone)] +pub(crate) struct TabState { + pub(crate) tabs: Vec, + pub(crate) active_app_id: String, + pub(crate) active_tab_id: String, + pub(crate) dashboard_open: bool, + pub(crate) modal_overlay_open: bool, + pub(crate) panel_collapsed: bool, +} + +#[derive(Clone)] +pub(crate) struct ManagedTab { + pub(crate) id: String, + pub(crate) app_id: String, + pub(crate) title: String, + pub(crate) url: String, + pub(crate) is_loading: bool, + pub(crate) favicon: Option, + pub(crate) theme_color: Option, + pub(crate) history: Vec, + pub(crate) history_index: usize, + // On Android the tab's real history lives in its native WebView, whose + // canGoBack/canGoForward are reported via aether_tabs_report_native_event. + // They extend (OR with) the Rust-side history, which still tracks entries + // the WebView never saw — most notably the aether://start page. + pub(crate) native_can_go_back: Option, + pub(crate) native_can_go_forward: Option, +} + +#[derive(Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct AppSummary { + pub(crate) id: String, + pub(crate) name: String, + pub(crate) category: String, + pub(crate) home_url: String, + pub(crate) current_url: String, + pub(crate) title: String, + pub(crate) is_active: bool, + pub(crate) is_loading: bool, + pub(crate) can_go_back: bool, + pub(crate) can_go_forward: bool, +} + +#[derive(Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct BrowserTabSummary { + pub(crate) id: String, + pub(crate) app_id: String, + pub(crate) title: String, + pub(crate) url: String, + pub(crate) host: String, + pub(crate) is_active: bool, + pub(crate) is_loading: bool, + pub(crate) can_go_back: bool, + pub(crate) can_go_forward: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) favicon: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) theme_color: Option, +} + +#[derive(Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct AetherState { + pub(crate) apps: Vec, + pub(crate) tabs: Vec, + pub(crate) active_app_id: String, + pub(crate) active_tab_id: String, + pub(crate) dashboard_open: bool, + pub(crate) panel_collapsed: bool, +} + +#[derive(Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct HubShortcutSummary { + pub(crate) id: String, + pub(crate) title: String, + pub(crate) url: String, + pub(crate) host: String, + pub(crate) created_at: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) favicon: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) theme_color: Option, +} + +#[derive(Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct BrowserSettings { + pub(crate) default_search_engine: String, +} + +#[derive(Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct UpdateSettings { + #[serde(default = "default_update_auto_check")] + pub(crate) auto_check: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) last_checked_at: Option, +} + +#[derive(Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct AppSettings { + pub(crate) browser: BrowserSettings, + pub(crate) developer_mode: bool, + pub(crate) updates: UpdateSettings, + pub(crate) appearance: Appearance, +} + +#[derive(Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct CollectionSummary { + pub(crate) id: String, + pub(crate) name: String, + pub(crate) description: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) icon: Option, + pub(crate) created_at: String, + pub(crate) updated_at: String, + pub(crate) capture_count: usize, + pub(crate) chunk_count: usize, +} + +#[derive(Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct CaptureMetadata { + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) note: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) summary: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) tags: Option>, +} + +#[derive(Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct CaptureSummary { + pub(crate) id: String, + pub(crate) collection_id: String, + pub(crate) title: String, + pub(crate) url: String, + pub(crate) app_id: String, + pub(crate) captured_at: String, + pub(crate) chunk_count: usize, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) metadata: Option, +} + +#[derive(Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct CaptureResult { + #[serde(flatten)] + pub(crate) capture: CaptureSummary, + pub(crate) collection_name: String, +} + +#[derive(Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct CaptureProgress { + pub(crate) message: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) current: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) total: Option, +} + +#[derive(Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct SearchResult { + pub(crate) id: String, + pub(crate) collection_id: String, + pub(crate) capture_id: String, + pub(crate) app_id: String, + pub(crate) title: String, + pub(crate) url: String, + pub(crate) captured_at: String, + pub(crate) chunk_index: usize, + pub(crate) text: String, + pub(crate) score: f64, +} + +#[derive(Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct SemanticTrailRoot { + pub(crate) title: String, + pub(crate) url: String, + pub(crate) host: String, + pub(crate) excerpt: String, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "kebab-case")] +pub(crate) enum SemanticTrailReason { + SemanticMatch, + RecentCapture, + SameCollection, +} + +#[derive(Clone, Copy, Serialize)] +#[serde(rename_all = "kebab-case")] +pub(crate) enum SemanticTrailEdgeKind { + SemanticMatch, + SameHost, + SameCollection, +} + +#[derive(Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct SemanticTrailScoreBreakdown { + pub(crate) total: f64, + pub(crate) semantic: f64, + pub(crate) recency: f64, +} + +#[derive(Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct SemanticTrailItem { + pub(crate) id: String, + pub(crate) collection_id: String, + pub(crate) collection_name: String, + pub(crate) capture_id: String, + pub(crate) app_id: String, + pub(crate) title: String, + pub(crate) url: String, + pub(crate) host: String, + pub(crate) captured_at: String, + pub(crate) chunk_index: usize, + pub(crate) excerpt: String, + pub(crate) score: SemanticTrailScoreBreakdown, + pub(crate) reasons: Vec, +} + +#[derive(Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct SemanticTrailEdge { + pub(crate) from: String, + pub(crate) to: String, + pub(crate) kind: SemanticTrailEdgeKind, + pub(crate) weight: f64, +} + +#[derive(Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct CaptureHubSuggestion { + pub(crate) collection_id: String, + pub(crate) collection_name: String, + pub(crate) confidence: f64, + pub(crate) sample_title: String, +} + +#[derive(Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct SemanticTrailResult { + pub(crate) query: String, + pub(crate) generated_at: String, + pub(crate) root: SemanticTrailRoot, + pub(crate) items: Vec, + pub(crate) edges: Vec, +} + +#[derive(Clone, Copy, Serialize)] +#[serde(rename_all = "kebab-case")] +pub(crate) enum FlowGraphNodeKind { + Query, + Hub, + Source, +} + +#[derive(Clone, Copy, Serialize)] +#[serde(rename_all = "kebab-case")] +pub(crate) enum FlowGraphEdgeKind { + Contains, + Semantic, + QueryMatch, +} + +#[derive(Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct FlowGraphNode { + pub(crate) id: String, + pub(crate) kind: FlowGraphNodeKind, + pub(crate) title: String, + pub(crate) subtitle: String, + pub(crate) weight: f64, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) collection_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) collection_name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) capture_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) url: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) host: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) captured_at: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) excerpt: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) score: Option, +} + +#[derive(Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct FlowGraphEdge { + pub(crate) id: String, + pub(crate) from: String, + pub(crate) to: String, + pub(crate) kind: FlowGraphEdgeKind, + pub(crate) weight: f64, +} + +#[derive(Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct FlowGraphResult { + pub(crate) query: String, + pub(crate) generated_at: String, + pub(crate) nodes: Vec, + pub(crate) edges: Vec, + pub(crate) hub_count: usize, + pub(crate) source_count: usize, + pub(crate) omitted_source_count: usize, +} + +#[derive(Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ChatResult { + pub(crate) answer: String, + pub(crate) model: String, + pub(crate) citations: Vec, + pub(crate) metrics: ChatMetrics, +} + +#[derive(Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ChatMetrics { + pub(crate) generated_tokens: usize, + pub(crate) tokens_per_second: f64, + pub(crate) elapsed_seconds: f64, + pub(crate) chunks: usize, +} + +pub(crate) struct ChatCompletion { + pub(crate) text: String, + pub(crate) generated_tokens: usize, +} + +#[derive(Clone, Copy, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +#[derive(Default)] +pub(crate) enum AirLensKind { + #[default] + Topic, + Flow, + Hub, + Answer, + Iceberg, +} + +#[derive(Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct AirDossierInput { + pub(crate) lens: String, + pub(crate) lens_kind: Option, + pub(crate) collection_id: Option, + pub(crate) capture_id: Option, + pub(crate) saved_iceberg_id: Option, + pub(crate) answer: Option, + pub(crate) limit: Option, +} + +#[derive(Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct AirDossierSource { + pub(crate) id: String, + pub(crate) title: String, + pub(crate) excerpt: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) collection_name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) url: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) host: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) captured_at: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) score: Option, +} + +#[derive(Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct AirPreparedDossier { + pub(crate) title: String, + pub(crate) lens: String, + pub(crate) lens_kind: AirLensKind, + pub(crate) generated_at: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) model: Option, + pub(crate) output_dir: String, + pub(crate) markdown_preview: String, + pub(crate) sources: Vec, +} + +#[derive(Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct AirRenderResult { + pub(crate) path: String, + pub(crate) filename: String, + pub(crate) title: String, + pub(crate) source_count: usize, + pub(crate) rendered_at: String, +} + +#[derive(Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct AirRecentFile { + pub(crate) path: String, + pub(crate) filename: String, + pub(crate) title: String, + pub(crate) lens: String, + pub(crate) source_count: usize, + pub(crate) rendered_at: String, +} + +#[derive(Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ChatStreamPayload { + pub(crate) request_id: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) status: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) delta: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) citations: Option>, +} + +#[derive(Clone)] +pub(crate) struct ChatStreamEmitter { + pub(crate) app: AppHandle, + pub(crate) request_id: String, +} + +impl ChatStreamEmitter { + pub(crate) fn emit(&self, payload: ChatStreamPayload) { + let _ = self.app.emit(AETHER_CHAT_STREAM_EVENT, payload); + } + + pub(crate) fn status(&self, status: &str) { + self.emit(ChatStreamPayload { + request_id: self.request_id.clone(), + status: Some(status.to_string()), + delta: None, + citations: None, + }); + } + + pub(crate) fn citations(&self, citations: &[SearchResult]) { + self.emit(ChatStreamPayload { + request_id: self.request_id.clone(), + status: Some("Generating answer".to_string()), + delta: None, + citations: Some(citations.to_vec()), + }); + } + + pub(crate) fn delta(&self, delta: &str) { + self.emit(ChatStreamPayload { + request_id: self.request_id.clone(), + status: None, + delta: Some(delta.to_string()), + citations: None, + }); + } +} + +#[derive(Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct IcebergItem { + pub(crate) id: String, + pub(crate) name: String, + pub(crate) description: String, + pub(crate) level: u8, + pub(crate) x: f64, + pub(crate) y: f64, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) depth_score: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) familiarity: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) specificity: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) jargon_density: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) prerequisite_depth: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) obscurity: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) reason: Option, +} + +#[derive(Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct IcebergResult { + pub(crate) keyword: String, + pub(crate) model: String, + pub(crate) items: Vec, + pub(crate) generated_at: String, +} + +#[derive(Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct SavedIcebergSummary { + pub(crate) id: String, + pub(crate) title: String, + pub(crate) keyword: String, + pub(crate) model: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) icon: Option, + pub(crate) generated_at: String, + pub(crate) saved_at: String, + pub(crate) updated_at: String, + pub(crate) item_count: usize, +} + +#[derive(Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct SavedIceberg { + #[serde(flatten)] + pub(crate) iceberg: IcebergResult, + pub(crate) id: String, + pub(crate) title: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) icon: Option, + pub(crate) saved_at: String, + pub(crate) updated_at: String, +} + +#[derive(Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct SystemStatus { + pub(crate) runtime_ready: bool, + pub(crate) runtime_name: String, + pub(crate) embedding_model: Option, + pub(crate) chat_model: Option, + pub(crate) available_models: Vec, + pub(crate) chat_models: Vec, + pub(crate) embedding_models: Vec, + pub(crate) model_dir: String, + pub(crate) db_path: String, + pub(crate) library_path: String, + pub(crate) collections: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) error: Option, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct CreateTabInput { + pub(crate) url: Option, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct CreateShortcutInput { + pub(crate) title: String, + pub(crate) url: String, + pub(crate) favicon: Option, + pub(crate) theme_color: Option, +} + +#[cfg(desktop)] +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct PageMetadataSnapshot { + pub(crate) theme_color: Option, + pub(crate) favicon: Option, +} + +#[cfg(desktop)] +#[derive(Deserialize)] +pub(crate) struct FindMatchSnapshot { + pub(crate) current: usize, + pub(crate) total: usize, +} + +// Event payload forwarded by the renderer from the Kotlin TabsPlugin +// (window.__AETHER_TAB_EVENT__): per-tab navigation, title, and find updates. +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct NativeTabEventInput { + pub(crate) tab_id: String, + pub(crate) kind: String, + #[serde(default)] + pub(crate) url: Option, + #[serde(default)] + pub(crate) title: Option, + #[serde(default)] + pub(crate) is_loading: Option, + #[serde(default)] + pub(crate) can_go_back: Option, + #[serde(default)] + pub(crate) can_go_forward: Option, + #[serde(default)] + pub(crate) current: Option, + #[serde(default)] + pub(crate) total: Option, +} + +#[derive(Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct FindResultPayload { + pub(crate) tab_id: String, + pub(crate) current: usize, + pub(crate) total: usize, +} + +#[derive(Deserialize)] +pub(crate) struct CreateCollectionInput { + pub(crate) name: String, + pub(crate) description: Option, + pub(crate) icon: Option, +} + +#[derive(Deserialize)] +pub(crate) struct UpdateCollectionInput { + pub(crate) id: String, + pub(crate) name: Option, + pub(crate) description: Option, + pub(crate) icon: Option, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct CaptureCurrentPageInput { + pub(crate) collection_id: String, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct SearchLibraryInput { + pub(crate) query: String, + // None searches every hub. + #[serde(default)] + pub(crate) collection_id: Option, + #[serde(default)] + pub(crate) limit: Option, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct LibrarySearchHit { + pub(crate) capture_id: String, + pub(crate) collection_id: String, + pub(crate) collection_name: String, + pub(crate) title: String, + pub(crate) url: String, + pub(crate) host: String, + pub(crate) captured_at: String, + pub(crate) excerpt: String, + // 0-100 display score, not raw cosine distance. + pub(crate) score: f64, + pub(crate) chunk_matches: usize, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct LibrarySearchResult { + pub(crate) query: String, + pub(crate) hits: Vec, + // "semantic" when an embedding model ranked the results, "literal" when it fell + // back to substring matching so the UI can say which happened. + pub(crate) mode: String, + pub(crate) searched_chunks: usize, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct CaptureUrlInput { + pub(crate) collection_id: String, + pub(crate) url: String, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct CaptureUrlsInput { + pub(crate) collection_id: String, + pub(crate) urls: Vec, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct BulkCaptureFailure { + pub(crate) url: String, + pub(crate) reason: String, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct BulkCaptureResult { + pub(crate) captured: Vec, + pub(crate) collection_name: String, + pub(crate) failures: Vec, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct MoveCaptureInput { + pub(crate) capture_id: String, + pub(crate) collection_id: String, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct SearchCollectionInput { + pub(crate) collection_id: String, + pub(crate) query: String, + pub(crate) limit: Option, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct SemanticTrailInput { + pub(crate) query: Option, + pub(crate) limit: Option, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct FlowGraphInput { + pub(crate) query: Option, + pub(crate) source_limit: Option, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct AskChatInput { + pub(crate) collection_id: Option, + pub(crate) prompt: String, + pub(crate) include_current_page: Option, + pub(crate) request_id: Option, +} + +#[derive(Deserialize)] +pub(crate) struct GenerateIcebergInput { + pub(crate) keyword: String, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct SaveIcebergInput { + pub(crate) title: String, + pub(crate) keyword: String, + pub(crate) model: String, + pub(crate) icon: Option, + pub(crate) generated_at: String, + pub(crate) items: Vec, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct UpdateSettingsInput { + pub(crate) browser: Option, + pub(crate) developer_mode: Option, + pub(crate) updates: Option, + pub(crate) appearance: Option, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct PartialBrowserSettings { + pub(crate) default_search_engine: Option, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct PartialUpdateSettings { + pub(crate) auto_check: Option, +} + +#[derive(Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct DownloadProgress { + // "started" | "finished" | "failed" + pub(crate) status: String, + pub(crate) filename: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) path: Option, + pub(crate) url: String, +} + +// Restored on launch so quitting no longer discards every open tab. Only what is +// needed to reopen is stored: per-tab history stays in the webview. +#[derive(Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct SessionTab { + pub(crate) id: String, + pub(crate) url: String, + #[serde(default)] + pub(crate) title: String, +} + +#[derive(Clone, Copy, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct SessionWindow { + pub(crate) width: f64, + pub(crate) height: f64, + pub(crate) x: f64, + pub(crate) y: f64, +} + +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct SessionData { + pub(crate) version: u8, + #[serde(default)] + pub(crate) tabs: Vec, + #[serde(default)] + pub(crate) active_tab_id: String, + #[serde(default)] + pub(crate) window: Option, +} + +impl Default for SessionData { + fn default() -> Self { + Self { + version: 1, + tabs: Vec::new(), + active_tab_id: String::new(), + window: None, + } + } +} + +// One completed exchange. Stored per thread so a research session survives quitting +// the app, which is the whole point of keeping answers at all. +#[derive(Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ConversationTurn { + pub(crate) id: String, + pub(crate) prompt: String, + pub(crate) answer: String, + pub(crate) model: String, + pub(crate) asked_at: String, + pub(crate) citations: Vec, + pub(crate) metrics: ChatMetrics, +} + +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ConversationData { + pub(crate) version: u8, + // Keyed by hub id, or CURRENT_PAGE_THREAD_KEY for page-only asks. + #[serde(default)] + pub(crate) threads: HashMap>, +} + +impl Default for ConversationData { + fn default() -> Self { + Self { + version: 1, + threads: HashMap::new(), + } + } +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct LibraryExportResult { + pub(crate) path: String, + pub(crate) exported_at: String, + pub(crate) files: Vec, + pub(crate) capture_count: usize, + pub(crate) chunk_count: usize, + pub(crate) byte_size: u64, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct LibraryIndexStatus { + pub(crate) dim: usize, + pub(crate) embedded: usize, + pub(crate) pending_reembed: usize, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct LibraryReindexResult { + pub(crate) embedded: usize, + // Chunks the re-index could not embed. Non-zero means the model rejected them, so + // the number is worth showing rather than reporting a clean success. + pub(crate) still_pending: usize, + pub(crate) dim: usize, + pub(crate) reindexed_at: String, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct UpdateCheckResult { + pub(crate) current_version: String, + pub(crate) checked_at: String, + pub(crate) update_available: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) latest_version: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) latest_name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) release_url: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) release_notes: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) published_at: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) error: Option, +} + +// The in-app install can legitimately end four ways, and only one of them is a +// failure. Collapsing "this build has no signing key", "your install method +// updates by hand", and "the endpoint has nothing for this platform" into one +// error string would leave the user with no idea which applies to them. +#[derive(Clone, Copy, PartialEq, Eq)] +pub(crate) enum UpdateInstallStatus { + /// Downloaded, verified, and written over the installed bundle. + Installed, + /// No updater signing key was compiled into this build. + Unconfigured, + /// This platform or install method cannot replace itself in place. + Unsupported, + /// The updater endpoint has no newer build for this target. + Unavailable, +} + +impl UpdateInstallStatus { + pub(crate) fn as_str(self) -> &'static str { + match self { + Self::Installed => "installed", + Self::Unconfigured => "unconfigured", + Self::Unsupported => "unsupported", + Self::Unavailable => "unavailable", + } + } +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct UpdateInstallResult { + pub(crate) status: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) version: Option, + /// Only macOS and Linux hand control back to us; the Windows installer exits + /// the app itself, so the renderer never gets to act on this there. + pub(crate) needs_restart: bool, + pub(crate) message: String, +} + +impl UpdateInstallResult { + pub(crate) fn new(status: UpdateInstallStatus, message: impl Into) -> Self { + Self { + status: status.as_str().to_string(), + version: None, + needs_restart: status == UpdateInstallStatus::Installed, + message: message.into(), + } + } +} + +#[cfg(desktop)] +#[derive(Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct UpdateInstallProgress { + pub(crate) downloaded_bytes: u64, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) total_bytes: Option, + pub(crate) done: bool, +} + +#[derive(Deserialize)] +pub(crate) struct GithubRelease { + pub(crate) tag_name: String, + pub(crate) name: Option, + pub(crate) html_url: String, + pub(crate) body: Option, + pub(crate) published_at: Option, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct UpdateModelsInput { + pub(crate) embedding_model: Option, + pub(crate) chat_model: Option, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct DownloadModelsInput { + pub(crate) chat_models: Vec, + #[serde(default)] + pub(crate) hf_token: Option, +} + +#[derive(Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ModelDownloadProgress { + pub(crate) id: String, + pub(crate) label: String, + pub(crate) filename: String, + pub(crate) status: String, + pub(crate) downloaded_bytes: u64, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) total_bytes: Option, + pub(crate) overall_downloaded_bytes: u64, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) overall_total_bytes: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) message: Option, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct StatusToastInput { + pub(crate) message: String, + pub(crate) tone: String, + pub(crate) duration_ms: Option, +} + +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct LibraryData { + pub(crate) version: u8, + pub(crate) collections: Vec, + pub(crate) captures: Vec, + pub(crate) shortcuts: Vec, + pub(crate) migrated_realm_tables: Vec, +} + +impl Default for LibraryData { + fn default() -> Self { + Self { + version: 1, + collections: Vec::new(), + captures: Vec::new(), + shortcuts: Vec::new(), + migrated_realm_tables: Vec::new(), + } + } +} + +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct UserSettings { + #[serde(default = "default_settings_version")] + pub(crate) version: u8, + #[serde(default)] + pub(crate) browser: BrowserSettings, + #[serde(default)] + pub(crate) developer_mode: bool, + #[serde(default)] + pub(crate) updates: UpdateSettings, + #[serde(default)] + pub(crate) appearance: Appearance, + #[serde(default, alias = "ollama")] + pub(crate) local_model: LocalModelSettings, +} + +// Which theme the renderer stamps onto the root element. `System` follows the OS +// via prefers-color-scheme; the other two override it in both directions, so a +// user on a dark desktop can still choose the light theme. +#[derive(Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub(crate) enum Appearance { + #[default] + Light, + System, + Dark, +} + +#[derive(Clone, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub(crate) struct LocalModelSettings { + pub(crate) embedding_model: Option, + pub(crate) chat_model: Option, +} + +impl Default for BrowserSettings { + fn default() -> Self { + Self { + default_search_engine: "google".to_string(), + } + } +} + +impl Default for UpdateSettings { + fn default() -> Self { + Self { + auto_check: default_update_auto_check(), + last_checked_at: None, + } + } +} + +impl Default for UserSettings { + fn default() -> Self { + Self { + version: default_settings_version(), + browser: BrowserSettings::default(), + developer_mode: false, + updates: UpdateSettings::default(), + appearance: Appearance::default(), + local_model: LocalModelSettings::default(), + } + } +} + +pub(crate) fn default_settings_version() -> u8 { + 1 +} + +pub(crate) fn default_update_auto_check() -> bool { + true +} + +#[derive(Serialize, Deserialize)] +pub(crate) struct IcebergData { + pub(crate) version: u8, + pub(crate) icebergs: Vec, +} + +impl Default for IcebergData { + fn default() -> Self { + Self { + version: 1, + icebergs: Vec::new(), + } + } +} + +#[derive(Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ChunkRecord { + pub(crate) id: String, + // Vectors live in the binary sidecar, not in this JSON. Serializing 1024 f32s as + // decimal text cost ~12 KB per chunk and forced a full rewrite of every vector on + // every capture; `vector_slot` is the fixed-stride index into `chunks.vec`. + #[serde(skip)] + pub(crate) vector: Vec, + #[serde(default)] + pub(crate) vector_slot: u64, + // Set when the chunk's text is retained but its vector is not usable with the + // store's current width — typically because the embedding model changed. Such a + // chunk holds no sidecar slot and is invisible to semantic search until a + // re-index re-embeds it. Keeping the text is the whole point: re-embedding is + // local compute, whereas dropping the record would force a re-fetch of the page. + #[serde(default, skip_serializing_if = "is_false")] + pub(crate) needs_reembed: bool, + pub(crate) text: String, + pub(crate) collection_id: String, + pub(crate) capture_id: String, + pub(crate) title: String, + pub(crate) url: String, + pub(crate) app_id: String, + pub(crate) captured_at: String, + pub(crate) chunk_index: usize, +} + +pub(crate) fn is_false(value: &bool) -> bool { + !*value +} + +pub(crate) const VECTOR_STORE_VERSION: u8 = 2; +// Reclaim dead slots only once they dominate the file, so routine deletes stay O(1) +// instead of triggering a full rewrite each time. +pub(crate) const VECTOR_COMPACTION_MIN_SLOTS: u64 = 512; +pub(crate) const VECTOR_COMPACTION_DEAD_RATIO: f64 = 0.5; +// Re-index batch size. Small enough that progress moves visibly on a large library +// and peak memory stays bounded; large enough to keep the model's batching useful. +pub(crate) const REINDEX_BATCH_SIZE: usize = 64; + +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct VectorStoreData { + pub(crate) version: u8, + // Embedding width. Fixes the sidecar stride, so the store holds exactly one width + // at a time. A fresh store learns it from the first vector; a migrated store takes + // the width the most chunks already use; a re-index resets it to the loaded model's. + #[serde(default)] + pub(crate) dim: usize, + // Monotonic slot allocator. Counts every slot ever handed out, including slots + // whose chunk was later deleted, so appends always land past live data. + #[serde(default)] + pub(crate) next_slot: u64, + pub(crate) chunks: Vec, +} + +impl Default for VectorStoreData { + fn default() -> Self { + Self { + version: VECTOR_STORE_VERSION, + dim: 0, + next_slot: 0, + chunks: Vec::new(), + } + } +} + +impl VectorStoreData { + // Single place that hands out vector slots, so no caller can append a chunk whose + // slot does not match its position in the sidecar. + pub(crate) fn push_chunks(&mut self, records: impl IntoIterator) { + for mut record in records { + if self.dim == 0 && !record.vector.is_empty() { + self.dim = record.vector.len(); + } + // A chunk whose width does not match the store is parked, not discarded. + // Dropping it would throw away the text as well, turning a re-embeddable + // problem into one that needs the page fetched again. + if record.vector.is_empty() || record.vector.len() != self.dim { + if !record.vector.is_empty() { + diag_info!( + "chunk {} has {} dims (store is {}); parked for re-indexing", + record.id, + record.vector.len(), + self.dim + ); + } + record.vector.clear(); + record.vector_slot = 0; + record.needs_reembed = true; + self.chunks.push(record); + continue; + } + record.needs_reembed = false; + record.vector_slot = self.next_slot; + self.next_slot += 1; + self.chunks.push(record); + } + } + + // Chunks holding a sidecar slot. Parked chunks are excluded, so this — not + // `chunks.len()` — is what the slot accounting must be measured against. + pub(crate) fn embedded_count(&self) -> u64 { + self.chunks + .iter() + .filter(|chunk| !chunk.needs_reembed) + .count() as u64 + } + + pub(crate) fn pending_reembed_count(&self) -> usize { + self.chunks + .iter() + .filter(|chunk| chunk.needs_reembed) + .count() + } +} + +// Vectors sit next to the metadata as `chunks.vec`. diff --git a/src-tauri/src/util.rs b/src-tauri/src/util.rs new file mode 100644 index 0000000..5cc3f5c --- /dev/null +++ b/src-tauri/src/util.rs @@ -0,0 +1,240 @@ +//! Small pure helpers: string normalisation, URL tidying, slugs, ids, clocks. + +use super::*; + +pub(crate) fn reorder(items: Vec, ids: &[String], id_of: F) -> Vec +where + T: Clone, + F: Fn(&T) -> &String, +{ + let requested = ids.iter().filter(|id| !id.is_empty()).collect::>(); + let requested_set = requested + .iter() + .map(|id| (*id).clone()) + .collect::>(); + let by_id = items + .iter() + .map(|item| (id_of(item).clone(), item.clone())) + .collect::>(); + let mut ordered = requested + .into_iter() + .filter_map(|id| by_id.get(id).cloned()) + .collect::>(); + ordered.extend( + items + .into_iter() + .filter(|item| !requested_set.contains(id_of(item))), + ); + ordered +} + +pub(crate) fn normalize_captured_text(text: &str) -> String { + text.replace('\r', "") + .split('\n') + .map(str::trim) + .collect::>() + .join("\n") + .split_whitespace() + .collect::>() + .join(" ") + .trim() + .to_string() +} + +pub(crate) fn normalize_url(raw_url: &str, search_engine: &str) -> String { + let trimmed = raw_url.trim(); + if trimmed.is_empty() { + return "https://www.google.com".to_string(); + } + if Url::parse(trimmed).is_ok() { + return trimmed.to_string(); + } + if trimmed.contains(char::is_whitespace) || !trimmed.contains(['.', ':']) { + return format!( + "{}{}", + search_engine_prefix(search_engine), + urlencoding(trimmed) + ); + } + if trimmed.starts_with("localhost") + || trimmed.starts_with("127.0.0.1") + || trimmed.starts_with("[::1]") + { + return format!("http://{trimmed}"); + } + format!("https://{trimmed}") +} + +pub(crate) fn search_engine_prefix(id: &str) -> &'static str { + match id { + "bing" => "https://www.bing.com/search?q=", + "yahoo" => "https://search.yahoo.com/search?p=", + "ecosia" => "https://www.ecosia.org/search?q=", + "duckduckgo" => "https://duckduckgo.com/?q=", + _ => "https://www.google.com/search?q=", + } +} + +pub(crate) fn normalize_search_engine_id(value: &str) -> String { + match value { + "google" | "bing" | "yahoo" | "ecosia" | "duckduckgo" => value.to_string(), + _ => "google".to_string(), + } +} + +pub(crate) fn normalize_iceberg_icon(value: Option) -> Option { + let allowed = [ + "atom", + "book", + "brain", + "briefcase", + "code", + "cpu", + "dna", + "film", + "flask", + "gamepad", + "globe", + "heart", + "landmark", + "microscope", + "music", + "palette", + "shield", + "snowflake", + "sprout", + "telescope", + ]; + value + .map(|icon| icon.trim().to_lowercase()) + .filter(|icon| allowed.contains(&icon.as_str())) +} + +pub(crate) fn normalize_theme_color(color: &str) -> Option { + let value = color.trim().chars().take(64).collect::(); + if value.is_empty() { + return None; + } + + if let Some(hex) = value.strip_prefix('#') { + if (3..=8).contains(&hex.len()) + && hex.chars().all(|character| character.is_ascii_hexdigit()) + { + return Some(value); + } + } + + let lower = value.to_ascii_lowercase(); + let supported_function = lower.starts_with("rgb(") + || lower.starts_with("rgba(") + || lower.starts_with("hsl(") + || lower.starts_with("hsla("); + if supported_function && value.ends_with(')') { + return Some(value); + } + + None +} + +pub(crate) fn title_from_url(url: &str) -> String { + let host = get_tab_host(url); + if host.is_empty() { + "New tab".to_string() + } else { + host + } +} + +pub(crate) fn favicon_for_url(url: &str) -> Option { + let parsed = Url::parse(url).ok()?; + Some(format!( + "{}://{}/favicon.ico", + parsed.scheme(), + parsed.host_str()? + )) +} + +pub(crate) fn get_tab_host(url: &str) -> String { + Url::parse(url) + .ok() + .and_then(|url| { + url.host_str() + .map(|host| host.trim_start_matches("www.").to_string()) + }) + .unwrap_or_default() +} + +pub(crate) fn normalize_citation_key(url: &str) -> String { + match Url::parse(url) { + Ok(mut parsed) => { + parsed.set_fragment(None); + parsed.to_string() + } + Err(_) => url.to_string(), + } +} + +pub(crate) fn normalize_capture_url_key(url: &str) -> String { + match Url::parse(url) { + Ok(mut parsed) => { + parsed.set_fragment(None); + if parsed.path() == "/" { + parsed.set_path(""); + } + parsed.to_string().trim_end_matches('/').to_string() + } + Err(_) => url.trim().trim_end_matches('/').to_string(), + } +} + +pub(crate) fn unique_slug(name: &str, existing: &[String]) -> String { + let base = slugify(name); + let mut candidate = base.clone(); + let mut suffix = 2; + while existing.contains(&candidate) { + candidate = format!("{base}-{suffix}"); + suffix += 1; + } + candidate +} + +pub(crate) fn slugify(value: &str) -> String { + let mut slug = String::new(); + let mut last_dash = false; + for char in value.trim().to_lowercase().chars() { + if char.is_ascii_alphanumeric() || char == '_' { + slug.push(char); + last_dash = false; + } else if !last_dash { + slug.push('-'); + last_dash = true; + } + } + let slug = slug.trim_matches('-').to_string(); + if slug.is_empty() { + "collection".to_string() + } else { + slug + } +} + +pub(crate) fn urlencoding(value: &str) -> String { + value + .bytes() + .flat_map(|byte| match byte { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { + vec![byte as char] + } + b' ' => vec!['+'], + _ => format!("%{byte:02X}").chars().collect(), + }) + .collect() +} + +pub(crate) fn uuid() -> String { + uuid::Uuid::new_v4().to_string() +} + +pub(crate) fn now() -> String { + Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true) +} diff --git a/src-tauri/src/vectors.rs b/src-tauri/src/vectors.rs new file mode 100644 index 0000000..a48fd25 --- /dev/null +++ b/src-tauri/src/vectors.rs @@ -0,0 +1,300 @@ +//! The binary vector sidecar (`chunks.vec`) beside the JSON chunk metadata. +//! +//! Write ordering is load-bearing: vector data is fsynced before the metadata that +//! references it, so a crash mid-save can leave an unreferenced slot but never a +//! chunk pointing at a slot that was never written. + +use super::*; + +pub(crate) async fn load_vectors(path: &Path) -> Cmd { + // A v1 store carries its vectors inline, so it must be detected before the v2 + // deserializer silently drops them (`vector` is #[serde(skip)] there). + if let Ok(raw) = tokio::fs::read_to_string(path).await { + let declared_version = serde_json::from_str::(&raw) + .ok() + .and_then(|value| value.get("version").and_then(serde_json::Value::as_u64)); + if declared_version.unwrap_or(1) < VECTOR_STORE_VERSION as u64 { + return migrate_legacy_vectors(path, &raw).await; + } + } + + let mut data: VectorStoreData = read_json_or_default(path).await?; + hydrate_vectors(path, &mut data).await?; + Ok(data) +} + +// Reads the sidecar and attaches each chunk's vector. Chunks whose slot is missing +// from the file are dropped rather than fatal: a partial sidecar should cost the +// affected sources, not the whole library. +pub(crate) async fn hydrate_vectors(path: &Path, data: &mut VectorStoreData) -> Cmd<()> { + if data.chunks.is_empty() { + return Ok(()); + } + if data.dim == 0 { + diag_warn!("vector store has chunks but no dimension; dropping stale metadata"); + data.chunks.clear(); + return Ok(()); + } + + let vector_path = vector_data_path(path); + let bytes = match tokio::fs::read(&vector_path).await { + Ok(bytes) => bytes, + Err(error) => { + diag_warn!( + "vector sidecar {} unreadable ({error}); captures need re-indexing", + vector_path.display() + ); + data.chunks.clear(); + return Ok(()); + } + }; + + let stride = data.dim * 4; + let available = (bytes.len() / stride) as u64; + let before = data.chunks.len(); + + // Parked chunks hold no slot, so they survive regardless of what the sidecar has. + data.chunks + .retain(|chunk| chunk.needs_reembed || chunk.vector_slot < available); + for chunk in &mut data.chunks { + if chunk.needs_reembed { + continue; + } + let start = chunk.vector_slot as usize * stride; + chunk.vector = bytes[start..start + stride] + .chunks_exact(4) + .map(|word| f32::from_le_bytes([word[0], word[1], word[2], word[3]])) + .collect(); + } + + if data.chunks.len() != before { + diag_warn!( + "dropped {} chunk(s) missing from the vector sidecar", + before - data.chunks.len() + ); + } + Ok(()) +} + +pub(crate) async fn migrate_legacy_vectors(path: &Path, raw: &str) -> Cmd { + let legacy: LegacyVectorStoreData = match serde_json::from_str(raw) { + Ok(legacy) => legacy, + Err(error) => { + diag_warn!("could not read legacy vector store ({error}); starting fresh"); + return Ok(VectorStoreData::default()); + } + }; + + // v1 imposed no single width, so a store written across an embedding-model change + // holds a mix. v2 has one stride, so the migration must pick one width and park the + // rest. Choosing the width the most chunks use preserves the most vectors; picking + // the first chunk's width would hand the store to whichever model happened to be + // written first, which on a real install was the *older* model. + let mut data = VectorStoreData { + dim: majority_vector_dim(&legacy.chunks), + ..VectorStoreData::default() + }; + data.push_chunks(legacy.chunks.into_iter().map(|chunk| ChunkRecord { + id: chunk.id, + vector: chunk.vector, + vector_slot: 0, + needs_reembed: false, + text: chunk.text, + collection_id: chunk.collection_id, + capture_id: chunk.capture_id, + title: chunk.title, + url: chunk.url, + app_id: chunk.app_id, + captured_at: chunk.captured_at, + chunk_index: chunk.chunk_index, + })); + + let parked = data.pending_reembed_count(); + diag_info!( + "migrating {} chunk(s) to the binary vector store at {} dims{}", + data.chunks.len(), + data.dim, + if parked > 0 { + format!(", parking {parked} from another embedding model for re-indexing") + } else { + String::new() + } + ); + // Keep the pre-migration file under a name no ordinary save touches. The `.bak` + // rotation is one generation deep, so the next capture would overwrite a v1 backup + // with a v2 copy and leave no way back to the original vectors. + archive_pre_migration_store(path, raw).await; + write_vector_sidecar(path, &data, 0).await?; + save_vector_metadata(path, &data).await?; + Ok(data) +} + +// The width the most chunks share, so the migration keeps the largest set of usable +// vectors. Ties break toward the wider vector, which is the newer model in practice. +pub(crate) fn majority_vector_dim(chunks: &[LegacyChunkRecord]) -> usize { + let mut counts: HashMap = HashMap::new(); + for chunk in chunks { + if !chunk.vector.is_empty() { + *counts.entry(chunk.vector.len()).or_insert(0) += 1; + } + } + counts + .into_iter() + .max_by_key(|(dim, count)| (*count, *dim)) + .map(|(dim, _)| dim) + .unwrap_or(0) +} + +// Writes the untouched v1 bytes alongside the store, once. A second migration must not +// clobber the first archive: that copy is the only remaining source for any vector the +// migration parked. +pub(crate) async fn archive_pre_migration_store(path: &Path, raw: &str) { + let mut name = path.file_stem().unwrap_or_default().to_os_string(); + name.push(PRE_MIGRATION_SUFFIX); + let target = path.with_file_name(name); + if tokio::fs::try_exists(&target).await.unwrap_or(false) { + return; + } + match write_bytes_atomically(&target, raw.as_bytes(), false).await { + Ok(()) => diag_error!( + "archived the pre-migration vector store at {}", + target.display() + ), + Err(error) => diag_error!("could not archive the pre-migration store: {error}"), + } +} + +pub(crate) async fn with_vectors_read( + state: &State<'_, Backend>, + read: impl FnOnce(&VectorStoreData) -> T, +) -> Cmd { + { + let guard = state.vectors.read().await; + if let Some(vectors) = guard.as_ref() { + return Ok(read(vectors)); + } + } + let mut guard = state.vectors.write().await; + if guard.is_none() { + *guard = Some(load_vectors(&state.paths.chunks_path).await?); + } + Ok(read(guard.as_ref().expect("vector store cache"))) +} + +pub(crate) async fn with_vectors_mut( + state: &State<'_, Backend>, + mutate: impl FnOnce(&mut VectorStoreData) -> T, +) -> Cmd { + let mut guard = state.vectors.write().await; + if guard.is_none() { + *guard = Some(load_vectors(&state.paths.chunks_path).await?); + } + let vectors = guard.as_mut().expect("vector store cache"); + let result = mutate(vectors); + save_vectors(&state.paths.chunks_path, vectors).await?; + Ok(result) +} + +// Vector rows are large and machine-managed, so the metadata is persisted as compact +// JSON instead of the pretty format used for small user-editable stores. +pub(crate) async fn save_vector_metadata(path: &Path, data: &VectorStoreData) -> Cmd<()> { + let raw = serde_json::to_string(data).map_err(|error| error.to_string())?; + write_store_durably(path, raw.as_bytes()).await +} + +// Writes vectors for every chunk whose slot is at or beyond `slots_present`. +// `slots_present == 0` rewrites the sidecar from scratch. +pub(crate) async fn write_vector_sidecar( + path: &Path, + data: &VectorStoreData, + slots_present: u64, +) -> Cmd<()> { + let vector_path = vector_data_path(path); + ensure_parent_dir(&vector_path).await?; + + let mut pending = data + .chunks + .iter() + .filter(|chunk| !chunk.needs_reembed && chunk.vector_slot >= slots_present) + .collect::>(); + if pending.is_empty() && slots_present > 0 { + return Ok(()); + } + // Slot order is file order; appending out of order would corrupt the stride index. + pending.sort_by_key(|chunk| chunk.vector_slot); + + let mut buffer = Vec::with_capacity(pending.len() * data.dim.max(1) * 4); + for chunk in pending { + for value in &chunk.vector { + buffer.extend_from_slice(&value.to_le_bytes()); + } + } + + let mut file = tokio::fs::OpenOptions::new() + .create(true) + .write(true) + .append(slots_present > 0) + .truncate(slots_present == 0) + .open(&vector_path) + .await + .map_err(|error| error.to_string())?; + file.write_all(&buffer) + .await + .map_err(|error| error.to_string())?; + // The metadata written next will reference these slots, so they must be on disk + // before it lands. Orphaned vector bytes are harmless; dangling slots are not. + file.sync_all().await.map_err(|error| error.to_string()) +} + +pub(crate) async fn sidecar_slots_present(path: &Path, dim: usize) -> u64 { + if dim == 0 { + return 0; + } + match tokio::fs::metadata(vector_data_path(path)).await { + Ok(meta) => meta.len() / (dim as u64 * 4), + Err(_) => 0, + } +} + +// Deletes leave dead slots behind. Once they dominate the sidecar, renumber the live +// chunks and rewrite it so the file cannot grow without bound. +pub(crate) async fn compact_vectors_if_needed( + path: &Path, + data: &mut VectorStoreData, +) -> Cmd { + // Parked chunks hold no slot, so counting them as live would understate how much + // of the sidecar is dead and stop compaction from ever triggering. + let live = data.embedded_count(); + if data.next_slot < VECTOR_COMPACTION_MIN_SLOTS { + return Ok(false); + } + let dead = data.next_slot.saturating_sub(live); + if (dead as f64) < data.next_slot as f64 * VECTOR_COMPACTION_DEAD_RATIO { + return Ok(false); + } + + // Embedded chunks first in slot order, parked ones after, so renumbering walks + // exactly the records that occupy the sidecar. + data.chunks + .sort_by_key(|chunk| (chunk.needs_reembed, chunk.vector_slot)); + let mut next = 0_u64; + for chunk in data.chunks.iter_mut() { + if chunk.needs_reembed { + continue; + } + chunk.vector_slot = next; + next += 1; + } + data.next_slot = live; + diag_info!("compacted vector store, reclaimed {dead} dead slot(s)"); + write_vector_sidecar(path, data, 0).await?; + Ok(true) +} + +pub(crate) async fn save_vectors(path: &Path, data: &mut VectorStoreData) -> Cmd<()> { + if !compact_vectors_if_needed(path, data).await? { + let slots_present = sidecar_slots_present(path, data.dim).await; + write_vector_sidecar(path, data, slots_present).await?; + } + save_vector_metadata(path, data).await +} diff --git a/src-tauri/src/webview.rs b/src-tauri/src/webview.rs new file mode 100644 index 0000000..3eb1e2b --- /dev/null +++ b/src-tauri/src/webview.rs @@ -0,0 +1,1170 @@ +//! Native web content: one child webview per tab on desktop, one Android WebView +//! per tab on mobile, driven through the same `*_native_webview` functions. +//! +//! Each function is paired: a `#[cfg(desktop)]` implementation and a mobile one. +//! Also holds the two injected page scripts (find-in-page, scroll-to-text), which +//! are the only JavaScript ÆTHER runs inside a visited page. + +use super::*; + +#[cfg(desktop)] +pub(crate) fn ensure_native_webview( + app: &AppHandle, + state: &State, + tab_id: &str, +) -> Cmd<()> { + let tab = { + let tabs = lock_tabs(state)?; + tabs.tabs + .iter() + .find(|tab| tab.id == tab_id) + .cloned() + .ok_or_else(|| format!("Unknown tab: {tab_id}"))? + }; + + // A blank start-page tab has no remote page to load — just reconcile visibility so + // any previously-active webview is hidden and the renderer's start page shows. + if tab.url == START_PAGE_URL { + return sync_native_webview_visibility(app, state); + } + + let exists = state + .webviews + .lock() + .map_err(|_| "webviews are unavailable.".to_string())? + .views + .contains_key(tab_id); + if !exists { + let webview = create_native_webview(app, state, &tab)?; + state + .webviews + .lock() + .map_err(|_| "webviews are unavailable.".to_string())? + .views + .insert(tab.id.clone(), webview); + } + + sync_native_webview_visibility(app, state) +} + +#[cfg(not(desktop))] +pub(crate) fn ensure_native_webview( + app: &AppHandle, + state: &State, + tab_id: &str, +) -> Cmd<()> { + #[cfg(target_os = "android")] + { + let tab = { + let tabs = lock_tabs(state)?; + tabs.tabs + .iter() + .find(|tab| tab.id == tab_id) + .cloned() + .ok_or_else(|| format!("Unknown tab: {tab_id}"))? + }; + // Like the desktop path: a start-page tab has nothing to load, only + // visibility to reconcile so the renderer's start page shows. + if tab.url != START_PAGE_URL { + app.state::().run( + "ensure", + android_tabs::TabUrlPayload { + tab_id: &tab.id, + url: &tab.url, + }, + )?; + } + return sync_native_webview_visibility(app, state); + } + #[allow(unreachable_code)] + { + let _ = (app, state, tab_id); + Ok(()) + } +} + +#[cfg(desktop)] +pub(crate) fn create_native_webview( + app: &AppHandle, + state: &State, + tab: &ManagedTab, +) -> Cmd { + let window = app + .get_window("main") + .ok_or_else(|| "main window is not ready.".to_string())?; + let bounds = native_webview_bounds(&window, state)?; + let label = native_webview_label(&tab.id); + let tab_id_for_navigation = tab.id.clone(); + let tab_id_for_load = tab.id.clone(); + let tab_id_for_title = tab.id.clone(); + let app_for_navigation = app.clone(); + let app_for_load = app.clone(); + let app_for_title = app.clone(); + let app_for_new_window = app.clone(); + let app_for_download = app.clone(); + let url = Url::parse(&tab.url).map_err(|error| error.to_string())?; + + let builder = WebviewBuilder::new(label, WebviewUrl::External(url)) + .user_agent(DESKTOP_BROWSER_USER_AGENT) + .on_navigation(move |url| { + let state = app_for_navigation.state::(); + update_tab_navigation_state(&state, &tab_id_for_navigation, url.as_str(), true); + let _ = emit_state(&app_for_navigation, &state); + true + }) + .on_page_load(move |webview, payload| { + let state = app_for_load.state::(); + let is_loading = payload.event() == PageLoadEvent::Started; + update_tab_navigation_state( + &state, + &tab_id_for_load, + payload.url().as_str(), + is_loading, + ); + let _ = emit_state(&app_for_load, &state); + if payload.event() == PageLoadEvent::Finished { + // Records the settled URL and title, which is what a restore needs. + schedule_session_save(&app_for_load); + let _ = webview.eval(NATIVE_WEBVIEW_SCROLLBAR_SCRIPT); + read_native_webview_metadata( + &webview, + app_for_load.clone(), + tab_id_for_load.clone(), + ); + } + }) + .on_document_title_changed(move |_webview, title| { + let state = app_for_title.state::(); + update_tab_title(&state, &tab_id_for_title, &title); + let _ = emit_state(&app_for_title, &state); + }) + .on_new_window(move |url, _features| { + let state = app_for_new_window.state::(); + let _ = create_native_tab_from_url(&app_for_new_window, &state, url.as_str()); + NewWindowResponse::Deny + }) + // Without this hook the webview silently drops downloads: clicking a PDF or + // zip link did nothing at all, with no error and no file. + .on_download(move |_webview, event| match event { + DownloadEvent::Requested { url, destination } => { + match resolve_download_destination(&app_for_download, &url) { + Some(target) => { + let filename = file_name_of(&target); + app_for_download + .state::() + .pending_downloads + .lock() + .map(|mut pending| { + pending.insert(url.to_string(), target.clone()); + }) + .ok(); + *destination = target; + emit_download_event( + &app_for_download, + "started", + &filename, + None, + url.as_str(), + ); + true + } + None => { + diag_error!("no writable downloads directory; refusing download"); + emit_download_event( + &app_for_download, + "failed", + &file_name_from_url(&url), + None, + url.as_str(), + ); + false + } + } + } + DownloadEvent::Finished { url, path, success } => { + // macOS never reports the path here, so fall back to the destination + // recorded at request time. + let recorded = app_for_download + .state::() + .pending_downloads + .lock() + .ok() + .and_then(|mut pending| pending.remove(&url.to_string())); + let resolved = path.or(recorded); + let filename = resolved + .as_deref() + .map(file_name_of) + .unwrap_or_else(|| file_name_from_url(&url)); + emit_download_event( + &app_for_download, + if success { "finished" } else { "failed" }, + &filename, + resolved.as_deref(), + url.as_str(), + ); + true + } + _ => true, + }); + + let webview = window + .add_child(builder, bounds.position, bounds.size) + .map_err(|error| error.to_string())?; + webview.hide().map_err(|error| error.to_string())?; + Ok(webview) +} + +#[cfg(desktop)] +pub(crate) fn create_native_tab_from_url( + app: &AppHandle, + state: &State, + raw_url: &str, +) -> Cmd<()> { + let url = normalize_url(raw_url, "google"); + let tab = ManagedTab::new("browser", &url); + let tab_id = tab.id.clone(); + { + let mut tabs = lock_tabs(state)?; + tabs.active_tab_id = tab_id.clone(); + tabs.active_app_id = tab.app_id.clone(); + tabs.dashboard_open = false; + tabs.tabs.push(tab); + } + ensure_native_webview(app, state, &tab_id)?; + emit_state(app, state) +} + +#[cfg(desktop)] +pub(crate) fn navigate_native_webview( + app: &AppHandle, + state: &State, + tab_id: &str, + url: &str, +) -> Cmd<()> { + ensure_native_webview(app, state, tab_id)?; + let parsed = Url::parse(url).map_err(|error| error.to_string())?; + let webview = state + .webviews + .lock() + .map_err(|_| "webviews are unavailable.".to_string())? + .views + .get(tab_id) + .cloned() + .ok_or_else(|| format!("Native webview not found for tab: {tab_id}"))?; + webview.navigate(parsed).map_err(|error| error.to_string()) +} + +#[cfg(not(desktop))] +pub(crate) fn navigate_native_webview( + app: &AppHandle, + state: &State, + tab_id: &str, + url: &str, +) -> Cmd<()> { + #[cfg(target_os = "android")] + { + app.state::() + .run("navigate", android_tabs::TabUrlPayload { tab_id, url })?; + return sync_native_webview_visibility(app, state); + } + #[allow(unreachable_code)] + { + let _ = (app, state, tab_id, url); + Ok(()) + } +} + +#[cfg(desktop)] +pub(crate) fn navigate_native_webview_history( + _app: &AppHandle, + state: &State, + tab_id: &str, + direction: WebviewHistoryDirection, +) -> Cmd<()> { + let webview = state + .webviews + .lock() + .map_err(|_| "webviews are unavailable.".to_string())? + .views + .get(tab_id) + .cloned() + .ok_or_else(|| format!("Native webview not found for tab: {tab_id}"))?; + let script = match direction { + WebviewHistoryDirection::Back => "history.back();", + WebviewHistoryDirection::Forward => "history.forward();", + }; + webview.eval(script).map_err(|error| error.to_string()) +} + +#[cfg(not(desktop))] +pub(crate) fn navigate_native_webview_history( + app: &AppHandle, + state: &State, + tab_id: &str, + direction: WebviewHistoryDirection, +) -> Cmd<()> { + #[cfg(target_os = "android")] + { + let _ = state; + let command = match direction { + WebviewHistoryDirection::Back => "goBack", + WebviewHistoryDirection::Forward => "goForward", + }; + return app + .state::() + .run(command, android_tabs::TabPayload { tab_id }); + } + #[allow(unreachable_code)] + { + let _ = (app, state, tab_id, direction); + Ok(()) + } +} + +#[cfg(desktop)] +pub(crate) fn scroll_native_webview_to_text( + _app: &AppHandle, + state: &State, + tab_id: &str, + text: &str, +) -> Cmd<()> { + let source_text = text.trim(); + if source_text.is_empty() { + return Ok(()); + } + let webview = state + .webviews + .lock() + .map_err(|_| "webviews are unavailable.".to_string())? + .views + .get(tab_id) + .cloned() + .ok_or_else(|| format!("Native webview not found for tab: {tab_id}"))?; + let text_json = serde_json::to_string(source_text).map_err(|error| error.to_string())?; + let script = scroll_to_text_script().replace("__AETHER_SOURCE_TEXT__", &text_json); + webview.eval(script).map_err(|error| error.to_string()) +} + +#[cfg(not(desktop))] +pub(crate) fn scroll_native_webview_to_text( + app: &AppHandle, + state: &State, + tab_id: &str, + text: &str, +) -> Cmd<()> { + #[cfg(target_os = "android")] + { + let _ = state; + let source_text = text.trim(); + if source_text.is_empty() { + return Ok(()); + } + let text_json = serde_json::to_string(source_text).map_err(|error| error.to_string())?; + let script = scroll_to_text_script().replace("__AETHER_SOURCE_TEXT__", &text_json); + return app + .state::() + .run("eval", android_tabs::EvalPayload { tab_id, script }); + } + #[allow(unreachable_code)] + { + let _ = (app, state, tab_id, text); + Ok(()) + } +} + +#[cfg(desktop)] +pub(crate) fn find_native_webview_text( + app: &AppHandle, + state: &State, + tab_id: &str, + query: Option<&str>, + action: &str, +) -> Cmd<()> { + let webview = state + .webviews + .lock() + .map_err(|_| "webviews are unavailable.".to_string())? + .views + .get(tab_id) + .cloned() + .ok_or_else(|| format!("Native webview not found for tab: {tab_id}"))?; + let query_json = match query.map(str::trim).filter(|value| !value.is_empty()) { + Some(value) => serde_json::to_string(value).map_err(|error| error.to_string())?, + None => "null".to_string(), + }; + let action_json = serde_json::to_string(action).map_err(|error| error.to_string())?; + let script = find_in_page_script() + .replace("__AETHER_FIND_QUERY__", &query_json) + .replace("__AETHER_FIND_ACTION__", &action_json); + let app = app.clone(); + let tab_id = tab_id.to_string(); + webview + .eval_with_callback(script, move |payload| { + let Ok(snapshot) = parse_json_payload::(&payload) else { + return; + }; + let _ = app.emit( + AETHER_FIND_RESULT_EVENT, + FindResultPayload { + tab_id: tab_id.clone(), + current: snapshot.current, + total: snapshot.total, + }, + ); + }) + .map_err(|error| error.to_string()) +} + +#[cfg(not(desktop))] +pub(crate) fn find_native_webview_text( + app: &AppHandle, + state: &State, + tab_id: &str, + query: Option<&str>, + action: &str, +) -> Cmd<()> { + #[cfg(target_os = "android")] + { + let _ = state; + // Android WebView has native find support (findAllAsync/findNext); the + // match counts come back through the FindListener as a "find" event on + // aether_tabs_report_native_event. + return app.state::().run( + "find", + android_tabs::FindPayload { + tab_id, + query: query.map(str::trim).filter(|value| !value.is_empty()), + action, + }, + ); + } + #[allow(unreachable_code)] + { + let _ = (app, state, tab_id, query, action); + Ok(()) + } +} + +#[cfg(desktop)] +pub(crate) fn close_native_webview( + _app: &AppHandle, + state: &State, + tab_id: &str, +) -> Cmd<()> { + if let Some(webview) = state + .webviews + .lock() + .map_err(|_| "webviews are unavailable.".to_string())? + .views + .remove(tab_id) + { + webview.close().map_err(|error| error.to_string())?; + } + Ok(()) +} + +#[cfg(not(desktop))] +pub(crate) fn close_native_webview( + app: &AppHandle, + state: &State, + tab_id: &str, +) -> Cmd<()> { + #[cfg(target_os = "android")] + { + let _ = state; + return app + .state::() + .run("close", android_tabs::TabPayload { tab_id }); + } + #[allow(unreachable_code)] + { + let _ = (app, state, tab_id); + Ok(()) + } +} + +#[cfg(desktop)] +pub(crate) fn find_in_page_script() -> &'static str { + r#" +(() => { + const action = __AETHER_FIND_ACTION__; + const rawQuery = __AETHER_FIND_QUERY__; + const HL = 'aether-find'; + const HL_CUR = 'aether-find-current'; + const STYLE_ID = 'aether-find-style'; + const MAX = 5000; + const supportsHighlight = + typeof CSS !== 'undefined' && + CSS.highlights && + typeof Highlight !== 'undefined' && + typeof Range !== 'undefined'; + const normalize = (value) => String(value ?? '').replace(/\s+/g, ' ').trim(); + const state = (window.__aetherFind = window.__aetherFind || { query: '', index: 0, total: 0 }); + + const clearHighlights = () => { + if (supportsHighlight) { + try { CSS.highlights.delete(HL); CSS.highlights.delete(HL_CUR); } catch (error) {} + } + document.querySelectorAll('mark[data-aether-find]').forEach((mark) => { + const parent = mark.parentNode; + if (!parent) return; + while (mark.firstChild) parent.insertBefore(mark.firstChild, mark); + parent.removeChild(mark); + parent.normalize(); + }); + }; + + const ensureStyle = () => { + if (document.getElementById(STYLE_ID)) return; + const style = document.createElement('style'); + style.id = STYLE_ID; + style.textContent = + '::highlight(aether-find){background-color:#bfe9f7;color:#0e364a;}' + + '::highlight(aether-find-current){background-color:#247fa7;color:#f4fbff;}' + + 'mark[data-aether-find]{background-color:#bfe9f7;color:#0e364a;border-radius:2px;padding:0;}' + + 'mark[data-aether-find="current"]{background-color:#247fa7;color:#f4fbff;}'; + (document.head || document.documentElement).appendChild(style); + }; + + if (action === 'clear') { + clearHighlights(); + state.query = ''; state.index = 0; state.total = 0; + return { current: 0, total: 0 }; + } + + const query = normalize(rawQuery); + clearHighlights(); + if (!query) { + state.query = ''; state.index = 0; state.total = 0; + return { current: 0, total: 0 }; + } + + const collectRanges = (needle) => { + const lc = needle.toLowerCase(); + const len = lc.length; + const root = document.body || document.documentElement; + if (!root || !len) return []; + const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, { + acceptNode(node) { + if (!node.nodeValue) return NodeFilter.FILTER_REJECT; + const parent = node.parentElement; + if (!parent) return NodeFilter.FILTER_REJECT; + const tag = parent.tagName; + if (tag === 'SCRIPT' || tag === 'STYLE' || tag === 'NOSCRIPT' || tag === 'TEXTAREA') { + return NodeFilter.FILTER_REJECT; + } + return NodeFilter.FILTER_ACCEPT; + } + }); + const nodes = []; + let buffer = ''; + let node; + while ((node = walker.nextNode())) { + nodes.push({ node, start: buffer.length }); + buffer += node.nodeValue; + } + const haystack = buffer.toLowerCase(); + const nodeAt = (offset) => { + let lo = 0, hi = nodes.length - 1, pick = 0; + while (lo <= hi) { + const mid = (lo + hi) >> 1; + if (nodes[mid].start <= offset) { pick = mid; lo = mid + 1; } else { hi = mid - 1; } + } + return pick; + }; + const ranges = []; + let from = 0, at; + while ((at = haystack.indexOf(lc, from)) !== -1) { + const end = at + len; + const startNode = nodeAt(at); + const endNode = nodeAt(end - 1); + try { + const range = document.createRange(); + range.setStart(nodes[startNode].node, at - nodes[startNode].start); + range.setEnd(nodes[endNode].node, end - nodes[endNode].start); + ranges.push(range); + } catch (error) {} + from = end; + if (ranges.length >= MAX) break; + } + return ranges; + }; + + const ranges = collectRanges(query); + const total = ranges.length; + if (total === 0) { + state.query = query; state.index = 0; state.total = 0; + return { current: 0, total: 0 }; + } + + let index; + if ((action === 'next' || action === 'prev') && state.query === query) { + index = state.index + (action === 'next' ? 1 : -1); + } else { + index = 0; + } + index = ((index % total) + total) % total; + state.query = query; state.index = index; state.total = total; + + ensureStyle(); + if (supportsHighlight) { + try { + const all = new Highlight(); + for (const range of ranges) all.add(range); + CSS.highlights.set(HL, all); + const current = new Highlight(); + current.add(ranges[index]); + CSS.highlights.set(HL_CUR, current); + } catch (error) {} + } else { + for (let i = ranges.length - 1; i >= 0; i--) { + try { + const mark = document.createElement('mark'); + mark.setAttribute('data-aether-find', i === index ? 'current' : 'all'); + ranges[i].surroundContents(mark); + } catch (error) {} + } + } + + let scrollTarget = null; + if (supportsHighlight) { + const node = ranges[index].startContainer; + scrollTarget = node.nodeType === 1 ? node : node.parentElement; + } else { + scrollTarget = document.querySelector('mark[data-aether-find="current"]'); + } + try { + if (scrollTarget && scrollTarget.scrollIntoView) { + scrollTarget.scrollIntoView({ block: 'center', inline: 'nearest', behavior: 'smooth' }); + } + } catch (error) {} + + return { current: index + 1, total }; +})() +"# +} + +pub(crate) fn scroll_to_text_script() -> &'static str { + r#" +(() => { + const sourceText = __AETHER_SOURCE_TEXT__; + const normalize = (value) => String(value || '').replace(/\s+/g, ' ').trim().toLowerCase(); + const source = normalize(sourceText); + if (!source) return; + const EXACT_HL = 'aether-source-exact'; + const STYLE_ID = 'aether-source-style'; + const supportsHighlight = + typeof CSS !== 'undefined' && + CSS.highlights && + typeof Highlight !== 'undefined' && + typeof Range !== 'undefined'; + + const words = source.split(' ').filter(Boolean).slice(0, 180); + const snippets = []; + const seen = new Set(); + const addSnippet = (start, length) => { + const snippet = words.slice(start, start + length).join(' '); + if (snippet.length >= 32 && !seen.has(snippet)) { + seen.add(snippet); + snippets.push(snippet); + } + }; + + for (const length of [28, 22, 16, 12, 9, 7]) { + const step = Math.max(3, Math.floor(length / 2)); + for (let start = 0; start < words.length; start += step) { + addSnippet(start, length); + } + } + snippets.sort((left, right) => right.length - left.length); + + const ensureStyle = () => { + if (document.getElementById(STYLE_ID)) return; + const style = document.createElement('style'); + style.id = STYLE_ID; + style.textContent = + '::highlight(aether-source-exact){background-color:rgba(255,224,102,0.72);color:inherit;}' + + 'mark[data-aether-source-range]{background-color:rgba(255,224,102,0.72);color:inherit;border-radius:2px;padding:0;}'; + (document.head || document.documentElement).appendChild(style); + }; + + const clearExactHighlights = () => { + if (supportsHighlight) { + try { CSS.highlights.delete(EXACT_HL); } catch (error) {} + } + document.querySelectorAll('mark[data-aether-source-range]').forEach((mark) => { + const parent = mark.parentNode; + if (!parent) return; + while (mark.firstChild) parent.insertBefore(mark.firstChild, mark); + parent.removeChild(mark); + parent.normalize(); + }); + }; + + const restorePreviousHighlights = () => { + clearExactHighlights(); + document.querySelectorAll('[data-aether-source-highlight="true"]').forEach((element) => { + element.style.outline = element.dataset.aetherPreviousOutline || ''; + element.style.boxShadow = element.dataset.aetherPreviousBoxShadow || ''; + element.style.backgroundColor = element.dataset.aetherPreviousBackgroundColor || ''; + element.removeAttribute('data-aether-source-highlight'); + element.removeAttribute('data-aether-previous-outline'); + element.removeAttribute('data-aether-previous-box-shadow'); + element.removeAttribute('data-aether-previous-background-color'); + }); + }; + + const textNodeAccepted = (node) => { + if (!node.nodeValue) return false; + const parent = node.parentElement; + if (!parent) return false; + const tag = parent.tagName; + return tag !== 'SCRIPT' && tag !== 'STYLE' && tag !== 'NOSCRIPT' && tag !== 'TEXTAREA'; + }; + + const collectTextIndex = () => { + const root = document.body || document.documentElement; + if (!root) return null; + const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, { + acceptNode(node) { + return textNodeAccepted(node) ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT; + } + }); + const map = []; + let text = ''; + let node; + while ((node = walker.nextNode())) { + const value = node.nodeValue || ''; + for (let index = 0; index < value.length; index += 1) { + const char = value[index]; + if (/\s/.test(char)) { + if (text && !text.endsWith(' ')) { + text += ' '; + map.push({ node, offset: index }); + } + } else { + text += char.toLowerCase(); + map.push({ node, offset: index }); + } + } + } + while (text.endsWith(' ')) { + text = text.slice(0, -1); + map.pop(); + } + return { text, map }; + }; + + const rangeFromIndex = (index, length, map) => { + const start = map[index]; + const end = map[index + length - 1]; + if (!start || !end) return null; + try { + const range = document.createRange(); + range.setStart(start.node, start.offset); + range.setEnd(end.node, end.offset + 1); + return range; + } catch (error) { + return null; + } + }; + + const findRangeMatch = () => { + const index = collectTextIndex(); + if (!index) return null; + for (const snippet of snippets) { + const at = index.text.indexOf(snippet); + if (at === -1) continue; + const range = rangeFromIndex(at, snippet.length, index.map); + if (range) return range; + } + return null; + }; + + const scrollRangeIntoView = (range) => { + try { + const rects = range.getClientRects(); + const rect = rects.length ? rects[0] : range.getBoundingClientRect(); + if (rect && Number.isFinite(rect.top)) { + const top = rect.top + window.scrollY - window.innerHeight * 0.42; + window.scrollTo({ top: Math.max(0, top), behavior: 'smooth' }); + return; + } + } catch (error) {} + const node = range.startContainer; + const element = node.nodeType === 1 ? node : node.parentElement; + if (element && element.scrollIntoView) { + element.scrollIntoView({ block: 'center', inline: 'nearest', behavior: 'smooth' }); + } + }; + + const highlightRange = (range) => { + restorePreviousHighlights(); + ensureStyle(); + let highlighted = false; + if (supportsHighlight) { + try { + const exact = new Highlight(); + exact.add(range); + CSS.highlights.set(EXACT_HL, exact); + highlighted = true; + } catch (error) {} + } + if (!highlighted) { + try { + const mark = document.createElement('mark'); + mark.setAttribute('data-aether-source-range', 'true'); + range.surroundContents(mark); + highlighted = true; + } catch (error) {} + } + scrollRangeIntoView(range); + window.setTimeout(clearExactHighlights, 12000); + return highlighted; + }; + + const isVisible = (element) => { + const style = window.getComputedStyle(element); + const rect = element.getBoundingClientRect(); + return style.display !== 'none' && style.visibility !== 'hidden' && rect.width > 0 && rect.height > 0; + }; + + const scoreElement = (element) => { + const tag = element.tagName.toLowerCase(); + if (['p', 'li', 'blockquote', 'td', 'th', 'figcaption', 'dd', 'dt'].includes(tag)) return 0; + if (['article', 'section', 'main'].includes(tag)) return 2; + return 1; + }; + + const highlight = (element) => { + restorePreviousHighlights(); + element.dataset.aetherSourceHighlight = 'true'; + element.dataset.aetherPreviousOutline = element.style.outline || ''; + element.dataset.aetherPreviousBoxShadow = element.style.boxShadow || ''; + element.dataset.aetherPreviousBackgroundColor = element.style.backgroundColor || ''; + element.style.outline = '3px solid rgba(66, 153, 225, 0.72)'; + element.style.boxShadow = '0 0 0 8px rgba(66, 153, 225, 0.16)'; + element.style.backgroundColor = 'rgba(255, 246, 189, 0.42)'; + element.scrollIntoView({ block: 'center', inline: 'nearest', behavior: 'smooth' }); + window.setTimeout(() => { + if (element.dataset.aetherSourceHighlight === 'true') restorePreviousHighlights(); + }, 12000); + }; + + const findMatch = () => { + const elements = Array.from( + document.querySelectorAll('p, li, blockquote, td, th, figcaption, dd, dt, article, section, main, div') + ) + .filter(isVisible) + .map((element) => ({ element, text: normalize(element.textContent) })) + .filter((item) => item.text.length >= 32) + .sort((left, right) => { + const tagScore = scoreElement(left.element) - scoreElement(right.element); + if (tagScore !== 0) return tagScore; + return left.text.length - right.text.length; + }); + + for (const snippet of snippets) { + const match = elements.find((item) => item.text.includes(snippet)); + if (match) return match.element; + } + + return null; + }; + + let attempts = 0; + const retry = () => { + attempts += 1; + const range = findRangeMatch(); + if (range && highlightRange(range)) return; + const match = findMatch(); + if (match) { + highlight(match); + return; + } + if (attempts < 28) window.setTimeout(retry, 250); + }; + + retry(); +})(); +"# +} + +#[cfg(desktop)] +pub(crate) fn resize_native_webviews(app: &AppHandle, state: &State) -> Cmd<()> { + sync_native_webview_visibility(app, state) +} + +#[cfg(desktop)] +pub(crate) fn sync_native_webview_visibility(app: &AppHandle, state: &State) -> Cmd<()> { + let (active_tab_id, show_active, panel_collapsed) = { + let tabs = lock_tabs(state)?; + // A tab parked on the start page must keep its (possibly still-alive) webview + // hidden so the renderer's start page overlay stays visible. + let active_is_start = tabs + .active_tab() + .map(|tab| tab.url == START_PAGE_URL) + .unwrap_or(false); + ( + tabs.active_tab_id.clone(), + !tabs.dashboard_open && !tabs.modal_overlay_open && !active_is_start, + tabs.panel_collapsed, + ) + }; + // Prefer the renderer-measured slot; fall back to the layout constants until the + // first report arrives. + let bounds = match reported_webview_bounds(state) { + Some(bounds) => bounds, + None => { + let window = app + .get_window("main") + .ok_or_else(|| "main window is not ready.".to_string())?; + native_webview_bounds_for_window(&window, panel_collapsed)? + } + }; + let webviews = state + .webviews + .lock() + .map_err(|_| "webviews are unavailable.".to_string())?; + + for (tab_id, webview) in &webviews.views { + if show_active && tab_id == &active_tab_id { + webview + .set_bounds(bounds) + .map_err(|error| error.to_string())?; + webview.show().map_err(|error| error.to_string())?; + } else { + webview.hide().map_err(|error| error.to_string())?; + } + } + + Ok(()) +} + +#[cfg(not(desktop))] +pub(crate) fn sync_native_webview_visibility(app: &AppHandle, state: &State) -> Cmd<()> { + #[cfg(target_os = "android")] + { + let (active_tab_id, show_active) = { + let tabs = lock_tabs(state)?; + // Same rules as desktop: keep webviews hidden behind the dashboard, + // modal overlays, and the renderer's start-page overlay. + let active_is_start = tabs + .active_tab() + .map(|tab| tab.url == START_PAGE_URL) + .unwrap_or(false); + ( + tabs.active_tab_id.clone(), + !tabs.dashboard_open && !tabs.modal_overlay_open && !active_is_start, + ) + }; + let bounds = *state + .web_content_bounds + .lock() + .map_err(|_| "layout bounds are unavailable.".to_string())?; + return app.state::().run( + "sync", + android_tabs::SyncPayload { + active_tab_id: show_active.then_some(active_tab_id.as_str()), + top: bounds.top, + left: bounds.left, + width: bounds.width, + height: bounds.height, + }, + ); + } + #[allow(unreachable_code)] + { + let _ = (app, state); + Ok(()) + } +} + +#[cfg(desktop)] +pub(crate) fn native_webview_bounds(window: &Window, state: &State) -> Cmd { + if let Some(bounds) = reported_webview_bounds(state) { + return Ok(bounds); + } + let panel_collapsed = lock_tabs(state)?.panel_collapsed; + native_webview_bounds_for_window(window, panel_collapsed) +} + +#[cfg(desktop)] +pub(crate) fn native_webview_bounds_for_window( + window: &Window, + panel_collapsed: bool, +) -> Cmd { + let size = window + .inner_size() + .map_err(|error| error.to_string())? + .to_logical::(window.scale_factor().map_err(|error| error.to_string())?); + let right_width = if panel_collapsed { + PANEL_COLLAPSED_WIDTH + } else { + PANEL_WIDTH + }; + let width = (size.width - SIDEBAR_WIDTH - right_width).max(280.0); + let height = (size.height - BROWSER_VIEW_TOP).max(200.0); + + Ok(Rect { + position: Position::Logical(LogicalPosition::new(SIDEBAR_WIDTH, BROWSER_VIEW_TOP)), + size: Size::Logical(LogicalSize::new(width, height)), + }) +} + +// Preferred over the constants above: the renderer measures the actual content slot, +// so the chrome's real height and the panel's real width define the web view instead +// of numbers that silently drift whenever the CSS changes. The constants remain the +// fallback for the first frames, before the renderer has reported anything. +#[cfg(desktop)] +pub(crate) fn reported_webview_bounds(state: &State) -> Option { + let bounds = *state.web_content_bounds.lock().ok()?; + // A zero-size rect means the content slot is not laid out (dashboard open, or the + // very first frame); positioning a webview to it would collapse the view. + if bounds.width < 1.0 || bounds.height < 1.0 { + return None; + } + Some(Rect { + position: Position::Logical(LogicalPosition::new(bounds.left, bounds.top)), + size: Size::Logical(LogicalSize::new(bounds.width, bounds.height)), + }) +} + +#[cfg(desktop)] +pub(crate) fn native_webview_label(tab_id: &str) -> String { + format!("aether-browser-tab-{tab_id}") +} + +#[cfg(desktop)] +pub(crate) fn read_native_webview_metadata(webview: &Webview, app: AppHandle, tab_id: String) { + let script = r#"(() => { + const theme = document.querySelector('meta[name="theme-color"], meta[name="msapplication-TileColor"]'); + const icons = Array.from(document.querySelectorAll('link[rel]')) + .map((link) => { + const rel = link.getAttribute('rel') || ''; + if (!/\b(icon|apple-touch-icon|shortcut icon)\b/i.test(rel)) return null; + const href = link.href || ''; + const sizes = link.getAttribute('sizes') || ''; + const size = sizes + .split(/\s+/) + .map((item) => Number.parseInt(item, 10) || 0) + .reduce((largest, value) => Math.max(largest, value), 0); + return { href, rel, size }; + }) + .filter(Boolean) + .sort((left, right) => { + if (right.size !== left.size) return right.size - left.size; + return Number(/apple-touch-icon/i.test(right.rel)) - Number(/apple-touch-icon/i.test(left.rel)); + }); + return { + themeColor: theme?.getAttribute('content') || '', + favicon: icons[0]?.href || '' + }; + })()"#; + + let _ = webview.eval_with_callback(script, move |payload| { + let metadata = match parse_json_payload::(&payload) { + Ok(metadata) => metadata, + Err(_) => return, + }; + let favicon = metadata + .favicon + .map(|favicon| favicon.trim().to_string()) + .filter(|favicon| !favicon.is_empty()); + let theme_color = metadata + .theme_color + .as_deref() + .and_then(normalize_theme_color); + let state = app.state::(); + if update_tab_metadata(&state, &tab_id, theme_color, favicon) { + let _ = emit_state(&app, &state); + } + }); +} + +pub(crate) fn update_tab_navigation_state( + state: &State, + tab_id: &str, + url: &str, + is_loading: bool, +) { + if let Ok(mut tabs) = lock_tabs(state) { + if let Some(tab) = tabs.tabs.iter_mut().find(|tab| tab.id == tab_id) { + tab.is_loading = is_loading; + let url = url.trim(); + if !should_accept_webview_url(&tab.url, url) { + return; + } + + let url = url.to_string(); + let url_changed = tab.url != url; + tab.url = url.clone(); + tab.favicon = favicon_for_url(&url); + if url_changed { + tab.theme_color = None; + } + if tab.title == "New tab" || tab.title.is_empty() || tab.title == get_tab_host(&tab.url) + { + tab.title = title_from_url(&url); + } + if !is_loading { + tab.commit_history_url(url); + } + } + } +} + +pub(crate) fn should_accept_webview_url(current_url: &str, next_url: &str) -> bool { + if next_url.is_empty() { + return false; + } + // While a tab is parked on the start page, ignore stray events from its hidden + // webview so they don't overwrite the start-page sentinel. + if current_url == START_PAGE_URL { + return false; + } + if is_transient_webview_url(next_url) && !is_transient_webview_url(current_url) { + return false; + } + true +} + +pub(crate) fn is_transient_webview_url(url: &str) -> bool { + let normalized = url.trim().to_ascii_lowercase(); + normalized == "about:blank" + || normalized.starts_with("about:blank#") + || normalized == "about:srcdoc" +} + +#[cfg(desktop)] +pub(crate) fn update_tab_metadata( + state: &State, + tab_id: &str, + theme_color: Option, + favicon: Option, +) -> bool { + if let Ok(mut tabs) = lock_tabs(state) { + if let Some(tab) = tabs.tabs.iter_mut().find(|tab| tab.id == tab_id) { + let favicon = favicon.or_else(|| tab.favicon.clone()); + if tab.theme_color == theme_color && tab.favicon == favicon { + return false; + } + tab.theme_color = theme_color; + tab.favicon = favicon; + return true; + } + } + false +} + +pub(crate) fn update_tab_title(state: &State, tab_id: &str, title: &str) { + let title = title.trim(); + if title.is_empty() { + return; + } + if let Ok(mut tabs) = lock_tabs(state) { + if let Some(tab) = tabs.tabs.iter_mut().find(|tab| tab.id == tab_id) { + tab.title = title.to_string(); + } + } +} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index db81e2b..c39bbf5 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -23,7 +23,49 @@ } ], "security": { - "csp": null + "csp": { + "default-src": "'self'", + "script-src": "'self'", + "style-src": ["'self'", "'unsafe-inline'"], + "img-src": ["'self'", "data:", "blob:", "https:", "http:"], + "font-src": "'self'", + "connect-src": ["'self'", "ipc:", "http://ipc.localhost"], + "media-src": ["'self'", "data:", "blob:"], + "object-src": "'none'", + "base-uri": "'self'", + "form-action": "'none'", + "frame-ancestors": "'none'" + }, + "devCsp": { + "default-src": "'self'", + "script-src": ["'self'", "'unsafe-inline'", "'unsafe-eval'"], + "style-src": ["'self'", "'unsafe-inline'"], + "img-src": ["'self'", "data:", "blob:", "https:", "http:"], + "font-src": "'self'", + "connect-src": [ + "'self'", + "ipc:", + "http://ipc.localhost", + "ws://127.0.0.1:1420", + "ws://localhost:1420", + "http://127.0.0.1:1420" + ], + "media-src": ["'self'", "data:", "blob:"], + "object-src": "'none'", + "base-uri": "'self'", + "form-action": "'none'" + } + } + }, + "plugins": { + "updater": { + "endpoints": [ + "https://github.com/CanPixel/aether/releases/latest/download/latest.json" + ], + "pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDQ5QzUwQTk2MkJFQjA4MEIKUldRTENPc3JsZ3JGU1FWamQrMkUrb0hmSDR2MktHZjltZVJyb0RCcHQzNFdEelIxNlYzdmlvOFgK", + "windows": { + "installMode": "passive" + } } }, "bundle": { diff --git a/src-tauri/tauri.updater.conf.json b/src-tauri/tauri.updater.conf.json new file mode 100644 index 0000000..d50663d --- /dev/null +++ b/src-tauri/tauri.updater.conf.json @@ -0,0 +1,6 @@ +{ + "$schema": "https://schema.tauri.app/config/2", + "bundle": { + "createUpdaterArtifacts": true + } +} diff --git a/src/renderer/index.html b/src/renderer/index.html index 1f00400..448037a 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -7,11 +7,11 @@ ÆTHER - - + diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index c182129..da94e26 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -1,4 +1,13 @@ -import { FormEvent, RefObject, useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { + CSSProperties, + FormEvent, + RefObject, + useCallback, + useEffect, + useMemo, + useRef, + useState +} from 'react' import { addPluginListener, invoke, type PluginListener } from '@tauri-apps/api/core' import packageManifest from '../../../package.json' import { @@ -22,7 +31,12 @@ import { FlowGraphResult, HubShortcutSummary, IcebergItem, + ConversationTurn, IcebergResult, + LibraryExportResult, + LibraryIndexStatus, + LibrarySearchHit, + LibrarySearchResult, ModelDownloadChoice, ModelDownloadProgress, SearchEngineId, @@ -34,10 +48,15 @@ import { SavedIcebergSummary, StatusToastInput, SystemStatus, - UpdateCheckResult + Appearance, + DiagnosticEntry, + UpdateCheckResult, + UpdateInstallProgress, + UpdateInstallResult } from '../../shared/aether' import { BrowserChrome } from './components/BrowserChrome' import { MobileShell } from './components/MobileShell' +import { WebContentSlot } from './components/WebContentSlot' import { MobileTabView } from './components/MobileTabView' import { StartPage } from './components/StartPage' import { CollectionDialog, CollectionDialogState } from './components/CollectionDialog' @@ -51,17 +70,26 @@ import { ModelSetupModal } from './components/ModelSetupModal' import { QuickAction } from './types/ui' import { cleanTitle, + countLabel, + formatByteSize, + formatUpdateProgress, formatVisibleModelName, getQuickActions, getTabTint, normalizeComparableUrl } from './utils/aether-ui' import { HAS_NATIVE_TAB_WEBVIEWS, IS_ANDROID } from './utils/platform' +import { useDismissableOverlay } from './utils/dismissable-overlay' import { ChevronDown, ChevronUp, + ExternalLink, + HardDriveDownload, + RefreshCw, + FileText, SearchIcon, Snowflake, + SunMoon, Waves, Wind @@ -75,6 +103,29 @@ import { // Sentinel URL for a blank tab that shows the ÆTHER start page instead of loading a // page. Must match START_PAGE_URL in src-tauri/src/lib.rs. const START_PAGE_URL = 'aether://start' +const APPEARANCE_OPTIONS: Array<{ id: Appearance; name: string; description: string }> = [ + { id: 'light', name: 'Light', description: 'The pale glass theme.' }, + { id: 'system', name: 'System', description: 'Follow the desktop appearance.' }, + { id: 'dark', name: 'Dark', description: 'Deep navy, for night reading.' } +] +// AiON panel sizing. Must stay in step with --panel-collapsed-width in foundation.css +// and PANEL_COLLAPSED_WIDTH in src-tauri/src/lib.rs. +const PANEL_COLLAPSED_WIDTH = 58 +const PANEL_MIN_WIDTH = 320 +const PANEL_MAX_WIDTH = 720 +const PANEL_DEFAULT_WIDTH = 404 +const PANEL_WIDTH_STORAGE_KEY = 'aether:panel-width' + +function clampPanelWidth(value: number): number { + return Math.round(Math.min(PANEL_MAX_WIDTH, Math.max(PANEL_MIN_WIDTH, value))) +} + +// Panel width is pure window chrome, so it lives in localStorage rather than in the +// synced settings store. +function readStoredPanelWidth(): number { + const stored = Number(window.localStorage.getItem(PANEL_WIDTH_STORAGE_KEY)) + return Number.isFinite(stored) && stored > 0 ? clampPanelWidth(stored) : PANEL_DEFAULT_WIDTH +} const APP_VERSION = packageManifest.version const DASHBOARD_ADDRESSES = new Set([ 'æther://dashboard', @@ -349,12 +400,6 @@ function scheduleCitationSourceScroll(tabId: string, sourceText: string): void { } } -function getNoticeTone(message: string): StatusToastInput['tone'] { - const errorPattern = - /\b(failed|could not|unexpected|error|select|open a page|create a collection|already captured|already in)\b/i - return errorPattern.test(message) ? 'error' : 'success' -} - function getErrorMessage(error: unknown): string { if (typeof error === 'string') return error if (!(error instanceof Error)) return 'ÆTHER hit an unexpected error.' @@ -405,10 +450,27 @@ function App(): React.JSX.Element { const [settings, setSettings] = useState({ browser: { defaultSearchEngine: 'google' }, developerMode: false, - updates: { autoCheck: true } + updates: { autoCheck: true }, + appearance: 'light' }) const [updateCheck, setUpdateCheck] = useState(null) const [updateChecking, setUpdateChecking] = useState(false) + const [updateInstalling, setUpdateInstalling] = useState(false) + const [updateProgress, setUpdateProgress] = useState(null) + const [updateInstalled, setUpdateInstalled] = useState(null) + const [diagnostics, setDiagnostics] = useState(null) + const [exportingDiagnostics, setExportingDiagnostics] = useState(false) + const [panelWidth, setPanelWidth] = useState(readStoredPanelWidth) + const [panelResizing, setPanelResizing] = useState(false) + // Pointer handlers read the committed width synchronously mid-drag. + const panelWidthRef = useRef(panelWidth) + const [capturingLink, setCapturingLink] = useState(false) + const [searching, setSearching] = useState(false) + const [searchResult, setSearchResult] = useState(null) + const [exportingLibrary, setExportingLibrary] = useState(false) + const [libraryExport, setLibraryExport] = useState(null) + const [reindexing, setReindexing] = useState(false) + const [indexStatus, setIndexStatus] = useState(null) const [dashboardOpen, setDashboardOpen] = useState(true) const [workspaceMode, setWorkspaceMode] = useState<'dashboard' | 'crystallizer' | 'flow' | 'air'>( 'dashboard' @@ -426,6 +488,7 @@ function App(): React.JSX.Element { const appliedAskContextRef = useRef(null) const [askPanelOpen, setAskPanelOpen] = useState(true) const [chatResult, setChatResult] = useState(null) + const [chatThread, setChatThread] = useState([]) const [streamingAnswer, setStreamingAnswer] = useState('') const [streamingCitations, setStreamingCitations] = useState([]) const [semanticTrailQuery, setSemanticTrailQuery] = useState('') @@ -477,6 +540,51 @@ function App(): React.JSX.Element { const findInputRef = useRef(null) const toastIdRef = useRef(0) + const showToast = useCallback((input: StatusToastInput): void => { + if (!input.message || /^starting\s+æ?ther$/i.test(input.message)) return + + const id = toastIdRef.current + 1 + toastIdRef.current = id + setToast({ ...input, id }) + if (input.durationMs !== 0) { + window.setTimeout(() => { + setToast((current) => (current?.id === id ? null : current)) + }, input.durationMs ?? 3600) + } + window.aether.layout.showStatusToast(input).catch(() => undefined) + }, []) + + // One place where a user-facing outcome is announced: the footer keeps the text as + // ambient status, the toast surfaces it regardless of whether the panel is open. + // Tone is passed in rather than inferred from the wording — guessing it from the + // message meant a phrasing change silently altered how the result was presented. + const report = useCallback( + (message: string, tone: StatusToastInput['tone']): void => { + setNotice(message) + // Failures get longer on screen: they are the messages worth actually reading, + // and they tend to be the longest. + showToast({ message, tone, durationMs: tone === 'error' ? 7000 : undefined }) + }, + [showToast] + ) + + const reportError = useCallback( + (error: unknown): void => report(getErrorMessage(error), 'error'), + [report] + ) + + const reportSuccess = useCallback( + (message: string): void => report(message, 'success'), + [report] + ) + + // An action the user asked for that could not start yet — not a failure, but it must + // be visible, otherwise the control simply appears to do nothing. + const reportBlocked = useCallback( + (message: string): void => report(message, 'info'), + [report] + ) + const activeTab = useMemo( () => tabs.find((tab) => tab.id === activeTabId) ?? tabs.find((tab) => tab.isActive) ?? tabs[0], [activeTabId, tabs] @@ -521,10 +629,13 @@ function App(): React.JSX.Element { : '' const currentPageTint = canUseCurrentPage && activeTab ? getTabTint(activeTab.host, activeTab.themeColor) : '' - const chatBlocked = status ? !status.runtimeReady || !status.chatModel : false + // Only the embedding model is required. Without a chat model, Ask degrades to ranked + // cited passages rather than being blocked, so a 0.64 GB install is fully usable. + const chatBlocked = status ? !status.runtimeReady || !status.embeddingModel : false + const chatIsExtractive = Boolean(status && status.embeddingModel && !status.chatModel) const installedSetupModels = useMemo(() => installedSetupModelsFromStatus(status), [status]) const modelSetupCoreInstalled = setupCoreInstalled(status) - const modelSetupNeeded = Boolean(status && (!status.chatModel || !status.embeddingModel)) + const modelSetupNeeded = Boolean(status && !status.embeddingModel) const modelSetupBusy = Boolean( busy === 'Installing local models' || modelDownloadProgress.some( @@ -586,11 +697,11 @@ function App(): React.JSX.Element { const openFindBar = useCallback((): void => { if (dashboardOpen || isStartPage || !activeTab?.id) { - setNotice('Open a web page before using find.') + reportBlocked('Open a web page before using find.') return } setFindOpen(true) - }, [activeTab?.id, dashboardOpen, isStartPage]) + }, [activeTab?.id, dashboardOpen, isStartPage, reportBlocked]) const findHighlight = useCallback( (query: string, action: FindAction): void => { @@ -604,9 +715,9 @@ function App(): React.JSX.Element { } void window.aether.tabs .find(activeTab.id, trimmed, action) - .catch((error) => setNotice(getErrorMessage(error))) + .catch((error) => reportError(error)) }, - [activeTab?.id] + [activeTab?.id, reportError] ) const closeFindBar = useCallback((): void => { @@ -702,19 +813,232 @@ function App(): React.JSX.Element { } })) if (result.updateAvailable) { - setNotice(`ÆTHER ${result.latestVersion ?? result.latestName ?? 'update'} is available.`) + report(`ÆTHER ${result.latestVersion ?? result.latestName ?? 'update'} is available.`, 'info') } else if (!options?.quiet && result.error) { - setNotice(result.error) + report(result.error, 'error') } else if (!options?.quiet) { - setNotice('ÆTHER is up to date.') + reportSuccess('ÆTHER is up to date.') } } catch (error) { - if (!options?.quiet) setNotice(getErrorMessage(error)) + if (!options?.quiet) reportError(error) } finally { if (!options?.quiet) setUpdateChecking(false) } + }, [report, reportError, reportSuccess]) + + const searchLibrary = useCallback( + async (query: string, collectionId?: string): Promise => { + setSearching(true) + setNotice(null) + try { + setSearchResult(await window.aether.search.library({ query, collectionId })) + } catch (error) { + reportError(error) + } finally { + setSearching(false) + } + }, + [reportError] + ) + + const clearSearch = useCallback((): void => setSearchResult(null), []) + + // Pointer-driven panel resize. Width is committed to state on every move so the + // grid, the measured content slot, and the native webview stay in step. + const startPanelResize = useCallback((event: React.PointerEvent): void => { + event.preventDefault() + setPanelResizing(true) + const startX = event.clientX + const startWidth = panelWidthRef.current + + const onMove = (move: PointerEvent): void => { + // Dragging left widens the panel, so the delta is inverted. + const next = clampPanelWidth(startWidth + (startX - move.clientX)) + panelWidthRef.current = next + setPanelWidth(next) + } + const onUp = (): void => { + setPanelResizing(false) + window.removeEventListener('pointermove', onMove) + window.removeEventListener('pointerup', onUp) + window.localStorage.setItem(PANEL_WIDTH_STORAGE_KEY, String(panelWidthRef.current)) + } + + window.addEventListener('pointermove', onMove) + window.addEventListener('pointerup', onUp) + }, []) + + // Keyboard equivalent: a drag-only control is unusable without a pointer. + const handlePanelResizeKey = useCallback((event: React.KeyboardEvent): void => { + const step = event.shiftKey ? 40 : 12 + const delta = + event.key === 'ArrowLeft' ? step : event.key === 'ArrowRight' ? -step : 0 + if (delta === 0) return + event.preventDefault() + const next = clampPanelWidth(panelWidthRef.current + delta) + panelWidthRef.current = next + setPanelWidth(next) + window.localStorage.setItem(PANEL_WIDTH_STORAGE_KEY, String(next)) + }, []) + + // Thread key mirrors the backend: the selected hub, or the current-page thread when + // the ask is not scoped to a hub. + const activeThreadCollectionId = useMemo( + () => (askCurrentPageOnly ? undefined : askCollectionId || undefined), + [askCollectionId, askCurrentPageOnly] + ) + + const clearChatHistory = useCallback((): void => { + void (async () => { + try { + await window.aether.chat.clearHistory(activeThreadCollectionId) + setChatThread([]) + setChatResult(null) + } catch (error) { + reportError(error) + } + })() + }, [activeThreadCollectionId, reportError]) + + // Switching hub switches thread, so a stored session reappears where it was left. + useEffect(() => { + let cancelled = false + void (async () => { + try { + const thread = await window.aether.chat.history(activeThreadCollectionId) + if (!cancelled) setChatThread(thread) + } catch { + if (!cancelled) setChatThread([]) + } + })() + return () => { + cancelled = true + } + }, [activeThreadCollectionId]) + + const captureLink = useCallback( + async (url: string, collectionId: string): Promise => { + setCapturingLink(true) + setNotice(null) + try { + const result = await window.aether.capture.url({ collectionId, url }) + await refreshCollections(collectionId) + reportSuccess(`Captured "${result.title}" into ${result.collectionName}.`) + } catch (error) { + reportError(error) + } finally { + setCapturingLink(false) + } + }, + [refreshCollections, reportError, reportSuccess] + ) + + const captureOpenTabs = useCallback( + async (collectionId: string): Promise => { + // Start-page tabs have no web URL to fetch, so they are not offered. + const urls = tabs + .map((tab) => tab.url) + .filter((url) => url && url !== START_PAGE_URL && !url.startsWith('aether://')) + if (urls.length === 0) { + reportBlocked('No open tabs point at a web page yet.') + return + } + + setCapturingLink(true) + setNotice(null) + try { + const result = await window.aether.capture.urls({ collectionId, urls }) + await refreshCollections(collectionId) + const saved = `Captured ${result.captured.length} of ${countLabel(urls.length, 'tab')} into ${result.collectionName}.` + // A partial capture is neither a clean success nor a failure. + report( + result.failures.length > 0 + ? `${saved} Skipped: ${result.failures.map((item) => item.reason).join(' ')}` + : saved, + result.failures.length > 0 ? 'info' : 'success' + ) + } catch (error) { + reportError(error) + } finally { + setCapturingLink(false) + } + }, + [refreshCollections, report, reportBlocked, reportError, tabs] + ) + + const refreshDiagnostics = useCallback(async (): Promise => { + try { + setDiagnostics(await window.aether.system.diagnostics()) + } catch { + // Not surfaced: a diagnostics panel that cannot load is not itself worth an + // error toast, and reporting it would need the very channel that just failed. + setDiagnostics([]) + } }, []) + const exportDiagnostics = useCallback(async (): Promise => { + setExportingDiagnostics(true) + try { + const result = await window.aether.system.exportDiagnostics() + reportSuccess( + `Diagnostics log (${formatByteSize(result.byteSize)}) saved to ${result.path}` + ) + } catch (error) { + reportError(error) + } finally { + setExportingDiagnostics(false) + } + }, [reportError, reportSuccess]) + + const exportLibrary = useCallback(async (): Promise => { + setExportingLibrary(true) + setNotice(null) + try { + const result = await window.aether.system.exportLibrary() + setLibraryExport(result) + reportSuccess( + `Exported ${countLabel(result.captureCount, 'source')} (${formatByteSize(result.byteSize)}) to ${ + result.path + }` + ) + } catch (error) { + reportError(error) + } finally { + setExportingLibrary(false) + } + }, [reportError, reportSuccess]) + + // Asked for on open rather than at startup: answering it loads the whole vector + // store, which is work launch should not pay for. + const refreshIndexStatus = useCallback(async (): Promise => { + try { + setIndexStatus(await window.aether.system.indexStatus()) + } catch { + // A library that will not load is already reported elsewhere; the re-index + // panel simply stays hidden rather than showing a second error. + setIndexStatus(null) + } + }, []) + + const reindexLibrary = useCallback(async (): Promise => { + setReindexing(true) + setNotice(null) + try { + const result = await window.aether.system.reindexLibrary() + report( + result.stillPending > 0 + ? `Re-indexed ${result.embedded} passages at ${result.dim} dims — ${result.stillPending} could not be embedded.` + : `Re-indexed ${result.embedded} passages at ${result.dim} dims.`, + result.stillPending > 0 ? 'info' : 'success' + ) + await refreshIndexStatus() + } catch (error) { + reportError(error) + } finally { + setReindexing(false) + } + }, [refreshIndexStatus, report, reportError]) + useEffect(() => { if (!settings.updates.autoCheck || autoUpdateCheckStartedRef.current) return undefined const timer = window.setTimeout(() => { @@ -737,11 +1061,11 @@ function App(): React.JSX.Element { await refreshShell() return tab } catch (error) { - setNotice(getErrorMessage(error)) + reportError(error) return null } }, - [refreshShell] + [refreshShell, reportError] ) useEffect(() => { @@ -855,7 +1179,7 @@ function App(): React.JSX.Element { return case 'capture-page': if (dashboardOpen || startPageActive) { - setNotice('Open a web page before capturing.') + reportBlocked('Open a web page before capturing.') return } await capturePage() @@ -897,28 +1221,6 @@ function App(): React.JSX.Element { }) }) - const showToast = useCallback((input: StatusToastInput): void => { - if (!input.message || /^starting\s+æ?ther$/i.test(input.message)) return - - const id = toastIdRef.current + 1 - toastIdRef.current = id - setToast({ ...input, id }) - if (input.durationMs !== 0) { - window.setTimeout(() => { - setToast((current) => (current?.id === id ? null : current)) - }, input.durationMs ?? 3600) - } - window.aether.layout.showStatusToast(input).catch(() => undefined) - }, []) - - useEffect(() => { - if (!notice) return - showToast({ - message: notice, - tone: getNoticeTone(notice) - }) - }, [notice, showToast]) - useEffect(() => { const unsubscribe = window.aether.events.onCaptureProgress((progress: CaptureProgress) => { const suffix = @@ -937,6 +1239,30 @@ function App(): React.JSX.Element { return unsubscribe }, [showToast]) + // Webview downloads are otherwise invisible: the file lands in ~/Downloads with no + // sign anything happened. + useEffect(() => { + const unsubscribe = window.aether.events.onDownload((progress) => { + if (progress.status === 'started') { + showToast({ message: `Downloading ${progress.filename}…`, tone: 'info', durationMs: 4000 }) + return + } + if (progress.status === 'failed') { + showToast({ message: `Download failed: ${progress.filename}`, tone: 'error' }) + return + } + // Naming the folder matters: without it the user knows a file arrived but not + // where, which is barely better than silence. + const folder = progress.path?.replace(/[/\\][^/\\]+$/, '') + showToast({ + message: folder ? `Saved ${progress.filename} to ${folder}` : `Saved ${progress.filename}`, + tone: 'success' + }) + }) + + return unsubscribe + }, [showToast]) + useEffect(() => { const unsubscribe = window.aether.events.onModelDownloadProgress((progress) => { setModelDownloadProgress((current) => upsertModelProgress(current, progress)) @@ -948,6 +1274,22 @@ function App(): React.JSX.Element { return unsubscribe }, []) + useEffect(() => { + const unsubscribe = window.aether.events.onUpdateProgress((progress) => { + setUpdateProgress(progress.done ? null : progress) + }) + + return unsubscribe + }, []) + + // The stylesheet starts in light mode before this effect runs. Stamping every + // saved choice, including 'system', prevents a dark OS preference from flashing + // briefly before the user's saved Light choice has loaded. + useEffect(() => { + const root = document.documentElement + root.setAttribute('data-theme', settings.appearance) + }, [settings.appearance]) + useEffect(() => { if (!modelSetupVisible) return void window.aether.layout.setModalOverlayOpen(true).catch(() => undefined) @@ -971,7 +1313,7 @@ function App(): React.JSX.Element { await window.aether.layout.setModalOverlayOpen(Boolean(settingsOpen || collectionDialog)) } - async function openModelSetupFromSettings(): Promise { + async function openModelSetup(): Promise { setSettingsOpen(false) setModelSetupDismissed(false) setModelSetupRequested(true) @@ -1003,14 +1345,14 @@ function App(): React.JSX.Element { }) setStatus(nextStatus) setModelSetupComplete(true) - setNotice('AiON local models installed.') + reportSuccess('AiON local models installed.') window.setTimeout(() => { void closeModelSetup() }, 900) } catch (error) { const message = getErrorMessage(error) setModelSetupError(message) - setNotice(message) + report(message, 'error') } finally { setBusy(null) } @@ -1066,7 +1408,22 @@ function App(): React.JSX.Element { await window.aether.tabs.close(tabId) await refreshShell() } catch (error) { - setNotice(getErrorMessage(error)) + reportError(error) + } + } + + async function reorderTabs(ids: string[]): Promise { + const previousTabs = tabs + const byId = new Map(tabs.map((tab) => [tab.id, tab])) + const reorderedTabs = ids.map((id) => byId.get(id)).filter(Boolean) as BrowserTabSummary[] + if (reorderedTabs.length !== tabs.length) return + + setTabs(reorderedTabs) + try { + setTabs(await window.aether.tabs.reorder(ids)) + } catch (error) { + setTabs(previousTabs) + reportError(error) } } @@ -1085,7 +1442,7 @@ function App(): React.JSX.Element { setDashboardOpen(false) await refreshShell() } catch (error) { - setNotice(getErrorMessage(error)) + reportError(error) } } @@ -1103,7 +1460,7 @@ function App(): React.JSX.Element { setDashboardOpen(false) await refreshShell() } catch (error) { - setNotice(getErrorMessage(error)) + reportError(error) } } @@ -1130,7 +1487,7 @@ function App(): React.JSX.Element { setDashboardOpen(false) addressInputRef.current?.blur() } catch (error) { - setNotice(getErrorMessage(error)) + reportError(error) } } @@ -1148,7 +1505,7 @@ function App(): React.JSX.Element { await createTab({ url: target }) } } catch (error) { - setNotice(getErrorMessage(error)) + reportError(error) } } @@ -1163,7 +1520,7 @@ function App(): React.JSX.Element { await window.aether.tabs.navigate(activeTab.id, target) setDashboardOpen(false) } catch (error) { - setNotice(getErrorMessage(error)) + reportError(error) } } @@ -1175,7 +1532,7 @@ function App(): React.JSX.Element { await window.aether.tabs.goBack(activeTab.id) await refreshShell() } catch (error) { - setNotice(getErrorMessage(error)) + reportError(error) } } @@ -1187,7 +1544,7 @@ function App(): React.JSX.Element { await window.aether.tabs.goForward(activeTab.id) await refreshShell() } catch (error) { - setNotice(getErrorMessage(error)) + reportError(error) } } @@ -1199,7 +1556,7 @@ function App(): React.JSX.Element { if (!collectionDialog || collectionDialog.mode === 'delete') return await runTask( - collectionDialog.mode === 'create' ? 'Creating collection' : 'Updating collection', + collectionDialog.mode === 'create' ? 'Creating hub' : 'Updating hub', async () => { const collection = collectionDialog.mode === 'create' @@ -1213,7 +1570,7 @@ function App(): React.JSX.Element { await closeCollectionDialog() await refreshCollections(collection.id) setFlowGraphResult(null) - setNotice(`${collection.name} is ready.`) + reportSuccess(`${collection.name} is ready.`) } ) } @@ -1221,7 +1578,7 @@ function App(): React.JSX.Element { async function confirmDeleteCollection(): Promise { if (!collectionDialog || collectionDialog.mode !== 'delete') return - await runTask('Deleting collection', async () => { + await runTask('Deleting hub', async () => { await window.aether.collections.delete(collectionDialog.collection.id) await closeCollectionDialog() setChatResult(null) @@ -1229,7 +1586,7 @@ function App(): React.JSX.Element { setFlowGraphResult(null) await refreshCollections() setStatus(await window.aether.system.status()) - setNotice('Collection deleted.') + reportSuccess('Hub deleted.') }) } @@ -1299,7 +1656,7 @@ function App(): React.JSX.Element { async function capturePage(): Promise { if (!selectedCollection) { await openCollectionDialog({ mode: 'create' }) - setNotice('Create a collection before capturing.') + reportBlocked('Create a hub before capturing.') return } @@ -1317,7 +1674,7 @@ function App(): React.JSX.Element { setAskCollectionId(result.collectionId) setSemanticTrailResult(null) setFlowGraphResult(null) - setNotice(`Saved ${result.chunkCount} chunks into ${result.collectionName}.`) + reportSuccess(`Saved ${countLabel(result.chunkCount, 'chunk')} into ${result.collectionName}.`) }) } @@ -1327,7 +1684,7 @@ function App(): React.JSX.Element { await refreshCollections(selectedCollection?.id) setSemanticTrailResult(null) setFlowGraphResult(null) - setNotice('Capture deleted.') + reportSuccess('Capture deleted.') }) } @@ -1338,7 +1695,7 @@ function App(): React.JSX.Element { setChatResult(null) setSemanticTrailResult(null) setFlowGraphResult(null) - setNotice(`Moved ${capture.title}.`) + reportSuccess(`Moved ${capture.title}.`) }) } @@ -1369,12 +1726,12 @@ function App(): React.JSX.Element { setPanelCollapsed(false) await window.aether.layout.setIntelligencePanelCollapsed(false) setChatPrompt(prompt) - setNotice('Select a knowledge hub or include the current page before asking ÆTHER.') + reportBlocked('Select a knowledge hub or include the current page before asking ÆTHER.') return } if (includeCurrentPage && !canUseCurrentPage) { - setNotice('Open a page before asking with current-page context.') + reportBlocked('Open a page before asking with current-page context.') return } @@ -1397,6 +1754,8 @@ function App(): React.JSX.Element { setChatResult(result) setAskPanelOpen(false) + // The backend persisted this turn; reload so prior turns render as a thread. + setChatThread(await window.aether.chat.history(collectionId)) } finally { askRequestIdRef.current = null if (streamFlushRef.current !== null) { @@ -1434,11 +1793,11 @@ function App(): React.JSX.Element { async function saveActiveTabToHub(): Promise { if (!activeTab?.url) { - setNotice('Open a page before saving it to the Hub.') + reportBlocked('Open a page before saving it to the Hub.') return } if (activeTabSavedToHub && !activeTabHubNeedsMetadata) { - setNotice('This page is already saved as a portal.') + reportBlocked('This page is already saved as a portal.') return } @@ -1451,7 +1810,7 @@ function App(): React.JSX.Element { themeColor: activeTab.themeColor }) await refreshShortcuts() - setNotice(updatingPortal ? 'Updated portal appearance.' : 'Saved to Hub.') + reportSuccess(updatingPortal ? 'Updated portal appearance.' : 'Saved to Hub.') }) } @@ -1465,6 +1824,11 @@ function App(): React.JSX.Element { setDashboardOpen(false) } + async function openSearchHit(hit: LibrarySearchHit): Promise { + await createTab({ url: hit.url }) + setDashboardOpen(false) + } + async function openFlowSource(node: FlowGraphNode): Promise { if (!node.url) return await createTab({ url: node.url }) @@ -1530,7 +1894,7 @@ function App(): React.JSX.Element { setSemanticTrailResult(result) } catch (error) { if (semanticTrailRequestRef.current === requestId) { - setNotice(getErrorMessage(error)) + reportError(error) } } finally { if (semanticTrailRequestRef.current === requestId) { @@ -1538,7 +1902,7 @@ function App(): React.JSX.Element { } } }, - [canUseCurrentPage, dashboardOpen, semanticTrailQuery, status?.embeddingModel] + [canUseCurrentPage, dashboardOpen, reportError, semanticTrailQuery, status?.embeddingModel] ) async function buildFlowGraph(query = flowGraphQuery): Promise { @@ -1655,7 +2019,7 @@ function App(): React.JSX.Element { const result = await window.aether.air.render(buildAirInput()) setAirResult(result) await refreshAirRecent() - setNotice(`Rendered ${result.filename}.`) + reportSuccess(`Rendered ${result.filename}.`) }) } @@ -1691,15 +2055,15 @@ function App(): React.JSX.Element { try { const result = await window.aether.crystallizer.generate({ keyword }) - setNotice( - `Mapped ${result.items.length} fragments with ${ + reportSuccess( + `Mapped ${countLabel(result.items.length, 'topic')} with ${ formatVisibleModelName(result.model) ?? result.model }.` ) return result } catch (error) { - const message = error instanceof Error ? getErrorMessage(error) : 'Crystallization failed.' - setNotice(message) + const message = getErrorMessage(error) + report(message, 'error') throw error } finally { setBusy(null) @@ -1714,11 +2078,11 @@ function App(): React.JSX.Element { const saved = await window.aether.crystallizer.save(input) setActiveSavedIceberg(saved) await refreshSavedIcebergs() - setNotice(`Crystallized ${saved.title}.`) + reportSuccess(`Crystallized ${saved.title}.`) return saved } catch (error) { const message = error instanceof Error ? getErrorMessage(error) : 'Saving iceberg failed.' - setNotice(message) + report(message, 'error') throw error } finally { setBusy(null) @@ -1735,12 +2099,12 @@ function App(): React.JSX.Element { setActiveSavedIceberg(saved) setWorkspaceMode('crystallizer') setDashboardOpen(true) - setNotice(`Opened ${saved.title}.`) + reportSuccess(`Opened ${saved.title}.`) return saved } catch (error) { const message = error instanceof Error ? getErrorMessage(error) : 'Could not open saved iceberg.' - setNotice(message) + report(message, 'error') throw error } finally { setBusy(null) @@ -1757,11 +2121,11 @@ function App(): React.JSX.Element { setActiveSavedIceberg(null) } await refreshSavedIcebergs() - setNotice('Saved iceberg deleted.') + reportSuccess('Saved iceberg deleted.') } catch (error) { const message = error instanceof Error ? getErrorMessage(error) : 'Could not delete saved iceberg.' - setNotice(message) + report(message, 'error') throw error } finally { setBusy(null) @@ -1809,6 +2173,10 @@ function App(): React.JSX.Element { async function openSettings(): Promise { setSettingsOpen(true) + // Neither is awaited: the index status loads the vector store, and the panel + // should open immediately. + void refreshIndexStatus() + void refreshDiagnostics() await window.aether.layout.setModalOverlayOpen(true) } @@ -1823,7 +2191,7 @@ function App(): React.JSX.Element { browser: { defaultSearchEngine } }) setSettings(nextSettings) - setNotice('Default search engine updated.') + reportSuccess('Default search engine updated.') }) } @@ -1831,7 +2199,17 @@ function App(): React.JSX.Element { await runTask('Updating settings', async () => { const nextSettings = await window.aether.system.updateSettings({ developerMode }) setSettings(nextSettings) - setNotice(developerMode ? 'Developer Mode enabled.' : 'Developer Mode disabled.') + reportSuccess(developerMode ? 'Developer Mode enabled.' : 'Developer Mode disabled.') + }) + } + + async function updateAppearance(appearance: Appearance): Promise { + // Applied optimistically: the effect above repaints on the next frame, and + // waiting for the disk write would make a theme click feel broken. + setSettings((current) => ({ ...current, appearance })) + await runTask('Updating settings', async () => { + const nextSettings = await window.aether.system.updateSettings({ appearance }) + setSettings(nextSettings) }) } @@ -1841,7 +2219,7 @@ function App(): React.JSX.Element { updates: { autoCheck } }) setSettings(nextSettings) - setNotice(autoCheck ? 'Update checks enabled.' : 'Update checks disabled.') + reportSuccess(autoCheck ? 'Update checks enabled.' : 'Update checks disabled.') }) } @@ -1850,7 +2228,39 @@ function App(): React.JSX.Element { try { await window.aether.system.openExternalUrl(updateCheck.releaseUrl) } catch (error) { - setNotice(getErrorMessage(error)) + reportError(error) + } + } + + async function installUpdate(): Promise { + setUpdateInstalling(true) + setUpdateProgress(null) + try { + const result = await window.aether.system.installUpdate() + if (result.status === 'installed') { + setUpdateInstalled(result) + reportSuccess(result.message) + } else { + // The three non-install outcomes are all things the user has to act on + // themselves, so they read as blocked rather than as failures. + setUpdateInstalled(null) + reportBlocked(result.message) + } + } catch (error) { + setUpdateInstalled(null) + reportError(error) + } finally { + setUpdateInstalling(false) + setUpdateProgress(null) + } + } + + async function relaunchForUpdate(): Promise { + try { + // Never resolves when it works — the process is replaced. + await window.aether.system.relaunch() + } catch (error) { + reportError(error) } } @@ -1861,7 +2271,7 @@ function App(): React.JSX.Element { await runTask('Updating local models', async () => { const nextStatus = await window.aether.system.updateModels(input) setStatus(nextStatus) - setNotice('Local model selection updated.') + reportSuccess('Local model selection updated.') }) } @@ -1935,7 +2345,7 @@ function App(): React.JSX.Element { try { await task() } catch (error) { - setNotice(getErrorMessage(error)) + reportError(error) } finally { setBusy(null) } @@ -1985,7 +2395,18 @@ function App(): React.JSX.Element { const dashboardNode = ( tab.url && !tab.url.startsWith('aether://')).length + } collections={collections} deleteCapture={deleteCapture} deleteSavedIceberg={deleteSavedIceberg} @@ -2049,16 +2470,31 @@ function App(): React.JSX.Element { {settingsOpen && ( checkForUpdates()} onOpenUpdateRelease={openUpdateRelease} + onAppearanceChange={updateAppearance} onUpdateAutoCheck={updateAutoCheck} - onOpenModelSetup={openModelSetupFromSettings} + onOpenModelSetup={openModelSetup} /> )} @@ -2105,7 +2541,7 @@ function App(): React.JSX.Element { onCancel: cancelAsk, onChatPromptChange: setChatPrompt, onOpenCitation: openCitation, - onOpenModelSetup: openModelSetupFromSettings + onOpenModelSetup: openModelSetup }} backInterceptorRef={mobileBackInterceptorRef} openAionRef={mobileOpenAionRef} @@ -2159,7 +2595,14 @@ function App(): React.JSX.Element { } return ( -
+
{toast && } {findBarNode} @@ -767,13 +851,15 @@ function LocalModelSettings({ developerMode, settingsRef, status, - onUpdateModels + onUpdateModels, + onOpenModelSetup }: { busy: string | null developerMode: boolean settingsRef: RefObject status: SystemStatus | null onUpdateModels: (input: { embeddingModel?: string; chatModel?: string }) => Promise + onOpenModelSetup: () => Promise }): React.JSX.Element { const models = status?.availableModels ?? [] const chatModels = modelOptionsWithSelected(status?.chatModels ?? [], status?.chatModel) @@ -817,7 +903,7 @@ function LocalModelSettings({

Built-in Models

-

{status?.runtimeReady ? `${models.length} local models` : 'No local model'}

+

{status?.runtimeReady ? countLabel(models.length, 'local model') : 'No local model'}

{modelLabel}
@@ -855,6 +941,17 @@ function LocalModelSettings({ ))} + ) } @@ -928,7 +1025,7 @@ function AnswerCard({ })}